diff --git a/ComputerSolitaire.xcodeproj/project.pbxproj b/ComputerSolitaire.xcodeproj/project.pbxproj index 4abcade..e0f30de 100644 --- a/ComputerSolitaire.xcodeproj/project.pbxproj +++ b/ComputerSolitaire.xcodeproj/project.pbxproj @@ -216,6 +216,7 @@ 4B75C1E02F1A233500761A12 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = QueenOfHeartsAppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = ComputerSolitaireAppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; @@ -267,6 +268,7 @@ 4B75C1E12F1A233500761A12 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = QueenOfHeartsAppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = ComputerSolitaireAppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; diff --git a/ComputerSolitaire/ComputerSolitaireAppIcon.icon/Assets/cards.png b/ComputerSolitaire/AppIcons/ComputerSolitaireAppIcon.icon/Assets/cards.png similarity index 100% rename from ComputerSolitaire/ComputerSolitaireAppIcon.icon/Assets/cards.png rename to ComputerSolitaire/AppIcons/ComputerSolitaireAppIcon.icon/Assets/cards.png diff --git a/ComputerSolitaire/ComputerSolitaireAppIcon.icon/icon.json b/ComputerSolitaire/AppIcons/ComputerSolitaireAppIcon.icon/icon.json similarity index 100% rename from ComputerSolitaire/ComputerSolitaireAppIcon.icon/icon.json rename to ComputerSolitaire/AppIcons/ComputerSolitaireAppIcon.icon/icon.json diff --git a/ComputerSolitaire/AppIcons/QueenOfHeartsAppIcon.icon/Assets/computer-solitaire.png b/ComputerSolitaire/AppIcons/QueenOfHeartsAppIcon.icon/Assets/computer-solitaire.png new file mode 100644 index 0000000..dcd7ac9 Binary files /dev/null and b/ComputerSolitaire/AppIcons/QueenOfHeartsAppIcon.icon/Assets/computer-solitaire.png differ diff --git a/ComputerSolitaire/AppIcons/QueenOfHeartsAppIcon.icon/icon.json b/ComputerSolitaire/AppIcons/QueenOfHeartsAppIcon.icon/icon.json new file mode 100644 index 0000000..7d78004 --- /dev/null +++ b/ComputerSolitaire/AppIcons/QueenOfHeartsAppIcon.icon/icon.json @@ -0,0 +1,47 @@ +{ + "fill" : { + "automatic-gradient" : "display-p3:0.45728,0.63330,0.83252,1.00000", + "orientation" : { + "start" : { + "x" : 0.5, + "y" : 1 + }, + "stop" : { + "x" : 0.5, + "y" : 0.3 + } + } + }, + "groups" : [ + { + "layers" : [ + { + "glass" : false, + "image-name" : "computer-solitaire.png", + "name" : "computer-solitaire", + "position" : { + "scale" : 0.6, + "translation-in-points" : [ + 0, + 0 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/ComputerSolitaire/Assets.xcassets/AppIconPreviews/AppIconPreviewDefault.imageset/Contents.json b/ComputerSolitaire/Assets.xcassets/AppIconPreviews/AppIconPreviewDefault.imageset/Contents.json new file mode 100644 index 0000000..343d594 --- /dev/null +++ b/ComputerSolitaire/Assets.xcassets/AppIconPreviews/AppIconPreviewDefault.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "default.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ComputerSolitaire/Assets.xcassets/AppIconPreviews/AppIconPreviewDefault.imageset/default.png b/ComputerSolitaire/Assets.xcassets/AppIconPreviews/AppIconPreviewDefault.imageset/default.png new file mode 100644 index 0000000..a768f86 Binary files /dev/null and b/ComputerSolitaire/Assets.xcassets/AppIconPreviews/AppIconPreviewDefault.imageset/default.png differ diff --git a/ComputerSolitaire/Assets.xcassets/AppIconPreviews/AppIconPreviewQueenOfHearts.imageset/Contents.json b/ComputerSolitaire/Assets.xcassets/AppIconPreviews/AppIconPreviewQueenOfHearts.imageset/Contents.json new file mode 100644 index 0000000..b4169eb --- /dev/null +++ b/ComputerSolitaire/Assets.xcassets/AppIconPreviews/AppIconPreviewQueenOfHearts.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "queen-of-hearts.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ComputerSolitaire/Assets.xcassets/AppIconPreviews/AppIconPreviewQueenOfHearts.imageset/queen-of-hearts.png b/ComputerSolitaire/Assets.xcassets/AppIconPreviews/AppIconPreviewQueenOfHearts.imageset/queen-of-hearts.png new file mode 100644 index 0000000..402298e Binary files /dev/null and b/ComputerSolitaire/Assets.xcassets/AppIconPreviews/AppIconPreviewQueenOfHearts.imageset/queen-of-hearts.png differ diff --git a/ComputerSolitaire/Assets.xcassets/AppIconPreviews/Contents.json b/ComputerSolitaire/Assets.xcassets/AppIconPreviews/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ComputerSolitaire/Assets.xcassets/AppIconPreviews/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ComputerSolitaire/Views/AppIconPickerView.swift b/ComputerSolitaire/Views/AppIconPickerView.swift new file mode 100644 index 0000000..a7c5f34 --- /dev/null +++ b/ComputerSolitaire/Views/AppIconPickerView.swift @@ -0,0 +1,158 @@ +#if os(iOS) +import SwiftUI +import UIKit + +/// To add a new alternate icon: +/// 1. Add its Icon Composer package to `AppIcons/`. +/// 2. Append the icon name to `ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES` in build settings. +/// 3. Add a flattened preview export to `Assets.xcassets/AppIconPreviews/` and declare it below. +struct AppIcon: Identifiable, Equatable { + /// The name passed to `setAlternateIconName(_:)`; `nil` selects the primary icon. + let alternateIconName: String? + let name: String + let previewImageName: String + + var id: String { previewImageName } + + static let `default` = AppIcon( + alternateIconName: nil, + name: "Default", + previewImageName: "AppIconPreviewDefault" + ) + + static let queenOfHearts = AppIcon( + alternateIconName: "QueenOfHeartsAppIcon", + name: "Queen of Hearts", + previewImageName: "AppIconPreviewQueenOfHearts" + ) + + static let all: [AppIcon] = [.default, .queenOfHearts] + + static func current() -> AppIcon { + let activeName = UIApplication.shared.alternateIconName + return all.first { $0.alternateIconName == activeName } ?? .default + } +} + +struct AppIconPreviewView: View { + let icon: AppIcon + var size: CGFloat = 56 + + private var shape: RoundedRectangle { + // Matches the home screen icon corner radius ratio. + RoundedRectangle(cornerRadius: size * 0.2237, style: .continuous) + } + + var body: some View { + Image(icon.previewImageName) + .resizable() + .scaledToFill() + .frame(width: size, height: size) + .clipShape(shape) + .overlay { + shape.stroke(Color.primary.opacity(0.12), lineWidth: 0.5) + } + } +} + +struct AppIconPickerView: View { + @Environment(\.dismiss) private var dismiss + @Binding var selection: AppIcon + + private let columns = [GridItem(.adaptive(minimum: 130), spacing: 12)] + + var body: some View { + ScrollView { + LazyVGrid(columns: columns, spacing: 12) { + ForEach(AppIcon.all) { icon in + iconTile(icon) + } + } + .padding(24) + } + .navigationTitle("App Icon") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + dismiss() + } + .keyboardShortcut(.cancelAction) + } + } + } + + private func iconTile(_ icon: AppIcon) -> some View { + let isSelected = selection == icon + + return Button { + guard !isSelected else { return } + HapticManager.shared.play(.settingsSelection) + let previous = selection + withAnimation(.smooth(duration: 0.3)) { + selection = icon + } + UIApplication.shared.setAlternateIconName(icon.alternateIconName) { error in + guard error != nil else { return } + Task { @MainActor in + withAnimation(.smooth(duration: 0.3)) { + selection = previous + } + } + } + } label: { + VStack(spacing: 6) { + AppIconPreviewView(icon: icon) + + Text(icon.name) + .font(.caption.weight(.bold)) + } + .padding(.vertical, 12) + .padding(.horizontal, 10) + .frame(maxWidth: .infinity) + .background { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(isSelected ? .thickMaterial : .thinMaterial) + .shadow( + color: .black.opacity(isSelected ? 0.12 : 0.04), + radius: isSelected ? 8 : 2, + y: isSelected ? 4 : 1 + ) + } + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke( + Color.accentColor.opacity(isSelected ? 1 : 0), + lineWidth: 2.5 + ) + } + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke( + Color.primary.opacity(isSelected ? 0 : 0.1), + lineWidth: 1 + ) + } + .overlay(alignment: .topTrailing) { + if isSelected { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 18, weight: .semibold)) + .symbolRenderingMode(.palette) + .foregroundStyle(.white, Color.accentColor) + .padding(6) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("\(icon.name) app icon") + .accessibilityAddTraits(isSelected ? .isSelected : []) + } +} + +#Preview { + NavigationStack { + AppIconPickerView(selection: .constant(.default)) + } +} +#endif diff --git a/ComputerSolitaire/Views/SettingsView.swift b/ComputerSolitaire/Views/SettingsView.swift index aa5cb2e..5b946d4 100644 --- a/ComputerSolitaire/Views/SettingsView.swift +++ b/ComputerSolitaire/Views/SettingsView.swift @@ -1,5 +1,8 @@ import Foundation import SwiftUI +#if os(iOS) +import UIKit +#endif enum TableBackgroundColor: String, CaseIterable, Identifiable { case ocean = "#0671b7" @@ -84,6 +87,10 @@ struct SettingsView: View { @AppStorage(SettingsKey.soundEffectsEnabled) private var isSoundEffectsEnabled = true @AppStorage(SettingsKey.showHintButton) private var isHintButtonVisible = true @AppStorage(SettingsKey.cardStyle) private var cardStyleRawValue = CardStyle.classic.rawValue +#if os(iOS) + @State private var selectedAppIcon = AppIcon.current() + @State private var isShowingAppIconPicker = false +#endif var body: some View { ScrollView { @@ -197,6 +204,28 @@ struct SettingsView: View { } } +#if os(iOS) + if UIApplication.shared.supportsAlternateIcons { + SettingsCard(title: "App Icon") { + Button { + isShowingAppIconPicker = true + } label: { + HStack(spacing: 10) { + AppIconPreviewView(icon: selectedAppIcon, size: 30) + Text(selectedAppIcon.name) + .font(.subheadline.weight(.semibold)) + Spacer() + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } +#endif + SettingsCard(title: "Help") { Button { isShowingRulesAndScoring = true @@ -234,6 +263,14 @@ struct SettingsView: View { RulesAndScoringView() } } +#if os(iOS) + .sheet(isPresented: $isShowingAppIconPicker) { + NavigationStack { + AppIconPickerView(selection: $selectedAppIcon) + } + .presentationDetents([.medium, .large]) + } +#endif .onChange(of: drawModeRawValue) { oldValue, newValue in guard oldValue != newValue else { return } HapticManager.shared.play(.settingsSelection)