diff --git a/ComputerSolitaire/ComputerSolitaireApp.swift b/ComputerSolitaire/ComputerSolitaireApp.swift index 1a6a2f5..8758acb 100644 --- a/ComputerSolitaire/ComputerSolitaireApp.swift +++ b/ComputerSolitaire/ComputerSolitaireApp.swift @@ -44,6 +44,10 @@ struct ComputerSolitaireApp: App { } .windowResizability(.contentSize) .defaultPosition(.center) + Settings { + MacSettingsView() + } + .windowResizability(.contentSize) #else WindowGroup { ContentView() @@ -91,14 +95,17 @@ struct ComputerSolitaireApp: App { } } GameMenuCommands() -#endif +#else + // On macOS the Settings scene provides the Settings… menu item and + // Command-Comma; this custom command covers iPad hardware keyboards. CommandGroup(replacing: .appSettings) { Button { NotificationCenter.default.post(name: .openSettings, object: nil) } label: { - Label("Settings…", systemImage: "gearshape") + Label("Settings…", systemImage: "gear") } .keyboardShortcut(",", modifiers: .command) } +#endif } } diff --git a/ComputerSolitaire/Feedback/HapticManager.swift b/ComputerSolitaire/Feedback/HapticManager.swift index 317c9ec..a0ef34e 100644 --- a/ComputerSolitaire/Feedback/HapticManager.swift +++ b/ComputerSolitaire/Feedback/HapticManager.swift @@ -25,11 +25,20 @@ final class HapticManager { func play(_ event: Event) { #if os(iOS) + guard isHapticFeedbackEnabled else { return } lastEvent = event trigger &+= 1 #endif } + private var isHapticFeedbackEnabled: Bool { + let defaults = UserDefaults.standard + guard defaults.object(forKey: SettingsKey.hapticFeedbackEnabled) != nil else { + return true + } + return defaults.bool(forKey: SettingsKey.hapticFeedbackEnabled) + } + var feedbackForTrigger: SensoryFeedback? { #if os(iOS) guard let lastEvent else { return nil } diff --git a/ComputerSolitaire/Views/AboutView.swift b/ComputerSolitaire/Views/AboutView.swift index 3a0a43d..3f3a93b 100644 --- a/ComputerSolitaire/Views/AboutView.swift +++ b/ComputerSolitaire/Views/AboutView.swift @@ -1,13 +1,13 @@ import Foundation import SwiftUI -#if os(iOS) enum AppInfo { static let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" static let copyrightYear = String(Calendar.current.component(.year, from: Date())) static let githubURL = URL(string: "https://github.com/austin-smith/ComputerSolitaire") } +#if os(iOS) struct AboutView: View { var body: some View { ScrollView { @@ -160,6 +160,9 @@ struct AboutView: View { } .padding(.horizontal, 28) .padding(.vertical, 16) + // Match the About window's column so hosts of any width (like the + // settings pane) wrap the copy identically. + .frame(maxWidth: 320) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 0)) } diff --git a/ComputerSolitaire/Views/AppIconPickerView.swift b/ComputerSolitaire/Views/AppIconPickerView.swift index ee3fa0f..b2aac5b 100644 --- a/ComputerSolitaire/Views/AppIconPickerView.swift +++ b/ComputerSolitaire/Views/AppIconPickerView.swift @@ -56,31 +56,33 @@ struct AppIconPreviewView: View { } } +/// The pushed page hosting the alternate-icon chooser. 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) + AppIconGridView(selection: $selection) + .padding(24) } .navigationTitle("App Icon") .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Done") { - dismiss() - } - .keyboardShortcut(.cancelAction) + } +} + +/// The alternate-icon chooser grid. +struct AppIconGridView: View { + @Binding var selection: AppIcon + + private let columns = [GridItem(.adaptive(minimum: 130), spacing: 12)] + + var body: some View { + LazyVGrid(columns: columns, spacing: 12) { + ForEach(AppIcon.all) { icon in + iconTile(icon) } } + .padding(.vertical, 4) } private func iconTile(_ icon: AppIcon) -> some View { diff --git a/ComputerSolitaire/Views/AppearanceSettingsViews.swift b/ComputerSolitaire/Views/AppearanceSettingsViews.swift new file mode 100644 index 0000000..797dd96 --- /dev/null +++ b/ComputerSolitaire/Views/AppearanceSettingsViews.swift @@ -0,0 +1,236 @@ +import Foundation +import SwiftUI + +/// Front-face thumbnail of a card style, reused at chip size on the Cards +/// page and at row-icon size on the settings top level. +struct CardStylePreview: View { + let style: CardStyle + let cardSize: CGSize + + private var previewCard: Card { + Card(suit: .hearts, rank: .queen, isFaceUp: true) + } + + var body: some View { + switch style { + case .classic: + ClassicCardFrontView(card: previewCard, cardSize: cardSize, isSelected: false) + case .simple: + SimpleCardFrontView(card: previewCard, cardSize: cardSize, isSelected: false) + case .pixel: + PixelCardFrontView(card: previewCard, cardSize: cardSize, isSelected: false) + } + } +} + +// MARK: - Table rows + +/// The table surface controls, shared by the iOS Table page and the macOS +/// Appearance pane. +struct TableSettingsRows: View { + @AppStorage(SettingsKey.tableBackgroundColor) + private var tableBackgroundColorRawValue = TableBackgroundColor.defaultValue.rawValue + @AppStorage(SettingsKey.feltEffectEnabled) private var isFeltEffectEnabled = true + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Background color") + Spacer() + if let selected = TableBackgroundColor(rawValue: tableBackgroundColorRawValue) { + Text(selected.label) + .foregroundStyle(.secondary) + } + } + HStack(spacing: 8) { + ForEach(TableBackgroundColor.allCases) { option in + colorSwatch(option) + } + Spacer() + } + } + .padding(.vertical, 4) + Toggle(isOn: $isFeltEffectEnabled) { + Text("Felt texture") + Text("Adds a fabric texture and vignette to the table.") + } + .toggleStyle(.switch) + } + + private func colorSwatch(_ option: TableBackgroundColor) -> some View { + let isSelected = tableBackgroundColorRawValue == option.rawValue + + return Button { + guard !isSelected else { return } + HapticManager.shared.play(.settingsSelection) + tableBackgroundColorRawValue = option.rawValue + } label: { + Circle() + .fill(option.color) + .overlay { + if isSelected { + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.white) + .accessibilityHidden(true) + } + } + .overlay { + Circle() + .stroke( + isSelected ? Color.accentColor : Color.primary.opacity(0.18), + lineWidth: isSelected ? 2.5 : 1 + ) + } + .frame(width: 32, height: 32) + } + .buttonStyle(.plain) + .accessibilityLabel(option.label) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } +} + +// MARK: - Cards rows + +/// The deck controls, shared by the iOS Cards page and the macOS Appearance +/// pane: front style, back color, then the lone behavior toggle. +struct CardsSettingsRows: View { + @AppStorage(SettingsKey.cardTiltEnabled) private var isCardTiltEnabled = true + @AppStorage(SettingsKey.cardStyle) private var cardStyleRawValue = CardStyle.defaultValue.rawValue + @AppStorage(SettingsKey.cardBackColor) private var cardBackColorRawValue = CardBackColor.defaultValue.id + + var body: some View { + Group { + HStack(spacing: 12) { + ForEach(CardStyle.allCases) { style in + cardStyleChip(style) + } + } + .padding(.vertical, 4) + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Card back color") + Spacer() + Text(CardBackColor.from(rawValue: cardBackColorRawValue).label) + .foregroundStyle(.secondary) + } + HStack(spacing: 8) { + ForEach(CardBackColor.all) { option in + cardBackSwatch(option) + } + Spacer() + } + } + .padding(.vertical, 4) + Toggle(isOn: $isCardTiltEnabled) { + Text("Natural card tilt") + Text("Adds a subtle organic angle to each card.") + } + .toggleStyle(.switch) + } + .onChange(of: cardStyleRawValue) { oldValue, newValue in + guard oldValue != newValue else { return } + HapticManager.shared.play(.settingsSelection) + } + } + + private func cardStyleChip(_ style: CardStyle) -> some View { + let isSelected = cardStyleRawValue == style.rawValue + + return Button { + guard !isSelected else { return } + HapticManager.shared.play(.settingsSelection) + withAnimation(.smooth(duration: 0.3)) { + cardStyleRawValue = style.rawValue + } + } label: { + VStack(spacing: 6) { + CardStylePreview(style: style, cardSize: CGSize(width: 44, height: 64)) + .frame(width: 44, height: 64) + + VStack(spacing: 1) { + Text(style.title) + .font(.caption.weight(.bold)) + Text(style.subtitle) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .selectionChip(isSelected: isSelected) + } + .buttonStyle(.plain) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } + + private func cardBackSwatch(_ option: CardBackColor) -> some View { + let isSelected = cardBackColorRawValue == option.id + + return Button { + guard !isSelected else { return } + HapticManager.shared.play(.settingsSelection) + cardBackColorRawValue = option.id + } label: { + Circle() + .fill(option.swatch) + .overlay { + if isSelected { + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.white) + .accessibilityHidden(true) + } + } + .overlay { + Circle() + .stroke( + isSelected ? Color.accentColor : Color.primary.opacity(0.18), + lineWidth: isSelected ? 2.5 : 1 + ) + } + .frame(width: 32, height: 32) + } + .buttonStyle(.plain) + .accessibilityLabel(option.label) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } +} + +// MARK: - iOS pages + +#if os(iOS) +struct TableSettingsView: View { + var body: some View { + Form { + Section { + TableSettingsRows() + } + } + .navigationTitle("Table") + .navigationBarTitleDisplayMode(.inline) + } +} + +struct CardsSettingsView: View { + var body: some View { + Form { + Section { + CardsSettingsRows() + } + } + .navigationTitle("Cards") + .navigationBarTitleDisplayMode(.inline) + } +} + +#Preview("Table") { + NavigationStack { + TableSettingsView() + } +} + +#Preview("Cards") { + NavigationStack { + CardsSettingsView() + } +} +#endif diff --git a/ComputerSolitaire/Views/MacSettingsView.swift b/ComputerSolitaire/Views/MacSettingsView.swift new file mode 100644 index 0000000..797ef88 --- /dev/null +++ b/ComputerSolitaire/Views/MacSettingsView.swift @@ -0,0 +1,60 @@ +#if os(macOS) +import SwiftUI + +/// The native macOS settings window: toolbar panes with the system's +/// animated resize between them. Content is shared with the iOS settings +/// sheet through the settings row views. +/// +/// Grouped forms are vertically greedy, so each pane pins the height its +/// content needs; the window hugs the active pane and animates the change. +struct MacSettingsView: View { + private enum PaneMetrics { + static let width: CGFloat = 500 + static let generalHeight: CGFloat = 360 + static let appearanceHeight: CGFloat = 560 + static let rulesHeight: CGFloat = 500 + static let aboutHeight: CGFloat = 400 + } + + var body: some View { + TabView { + Tab("General", systemImage: "gear") { + Form { + Section("Sound") { + SoundSettingsRows() + } + Section("Gameplay") { + GameplaySettingsRows() + } + } + .formStyle(.grouped) + .frame(width: PaneMetrics.width, height: PaneMetrics.generalHeight) + } + Tab("Appearance", systemImage: "paintpalette") { + Form { + Section("Table") { + TableSettingsRows() + } + Section("Cards") { + CardsSettingsRows() + } + } + .formStyle(.grouped) + .frame(width: PaneMetrics.width, height: PaneMetrics.appearanceHeight) + } + Tab("Rules", systemImage: "book") { + RulesAndScoringView(showsDoneButton: false) + .frame(width: PaneMetrics.width, height: PaneMetrics.rulesHeight) + } + Tab("About", systemImage: "info.circle") { + AboutView() + .frame(width: PaneMetrics.width, height: PaneMetrics.aboutHeight) + } + } + } +} + +#Preview { + MacSettingsView() +} +#endif diff --git a/ComputerSolitaire/Views/RulesAndScoringView.swift b/ComputerSolitaire/Views/RulesAndScoringView.swift index 507cf18..e52b4dc 100644 --- a/ComputerSolitaire/Views/RulesAndScoringView.swift +++ b/ComputerSolitaire/Views/RulesAndScoringView.swift @@ -14,8 +14,13 @@ struct RulesAndScoringView: View { @State private var selectedSection: Section - init(initialSection: Section = .rules) { + /// Hidden when the view is pushed onto a navigation stack rather than + /// presented as its own sheet. + private let showsDoneButton: Bool + + init(initialSection: Section = .rules, showsDoneButton: Bool = true) { _selectedSection = State(initialValue: initialSection) + self.showsDoneButton = showsDoneButton } private struct TermRow: Identifiable { @@ -39,8 +44,12 @@ struct RulesAndScoringView: View { TermRow(term: "Draw mode", definition: "How many cards you draw from the stock at a time: 1-card or 3-card.") ] + /// The variant being browsed. Defaults to the game in play, but the + /// picker lets any game's rules be read from anywhere. + @State private var selectedVariant: GameVariant? + private var gameVariant: GameVariant { - GameVariant(rawValue: gameVariantRawValue) ?? .klondike + selectedVariant ?? GameVariant(rawValue: gameVariantRawValue) ?? .klondike } private let scoringRows: [ScoringRow] = [ @@ -59,6 +68,19 @@ struct RulesAndScoringView: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 8) { + Text("Game") + .font(.subheadline.weight(.semibold)) + Picker("Game", selection: browsedVariantSelection) { + ForEach(GameVariant.allCases, id: \.self) { variant in + Text(variant.title).tag(variant) + } + } + .pickerStyle(.menu) + .labelsHidden() + .fixedSize() + } + Picker("Guide Section", selection: $selectedSection) { ForEach(Section.allCases) { section in Text(section.rawValue).tag(section) @@ -85,15 +107,24 @@ struct RulesAndScoringView: View { .navigationBarTitleDisplayMode(.inline) #endif .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Done") { - dismiss() + if showsDoneButton { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + dismiss() + } + .keyboardShortcut(.cancelAction) } - .keyboardShortcut(.cancelAction) } } } + private var browsedVariantSelection: Binding { + Binding( + get: { gameVariant }, + set: { selectedVariant = $0 } + ) + } + private var contentHorizontalPadding: CGFloat { #if os(iOS) return 30 @@ -110,15 +141,12 @@ struct RulesAndScoringView: View { #endif } + // The card deliberately has no heading of its own — the selected segment + // above already names it. private func sectionCard( - title: String, @ViewBuilder content: () -> Content ) -> some View { - VStack(alignment: .leading, spacing: 12) { - Text(title) - .font(.headline) - content() - } + content() .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .background( @@ -132,7 +160,7 @@ struct RulesAndScoringView: View { } private var termsCard: some View { - sectionCard(title: "Terms") { + sectionCard { VStack(alignment: .leading, spacing: 10) { ForEach(termsForCurrentVariant) { row in VStack(alignment: .leading, spacing: 2) { @@ -148,7 +176,7 @@ struct RulesAndScoringView: View { } private var rulesCard: some View { - sectionCard(title: "Rules") { + sectionCard { VStack(alignment: .leading, spacing: 8) { ForEach(rulesForCurrentVariant, id: \.self) { rule in rulesRow(rule) @@ -158,7 +186,7 @@ struct RulesAndScoringView: View { } private var scoringCard: some View { - sectionCard(title: "Scoring") { + sectionCard { VStack(alignment: .leading, spacing: 10) { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) { GridRow { diff --git a/ComputerSolitaire/Views/SettingsView.swift b/ComputerSolitaire/Views/SettingsView.swift index 532b26b..fd20e00 100644 --- a/ComputerSolitaire/Views/SettingsView.swift +++ b/ComputerSolitaire/Views/SettingsView.swift @@ -52,54 +52,80 @@ enum SettingsKey { static let tableBackgroundColor = "settings.tableBackgroundColor" static let feltEffectEnabled = "settings.feltEffectEnabled" static let soundEffectsEnabled = "settings.soundEffectsEnabled" + static let hapticFeedbackEnabled = "settings.hapticFeedbackEnabled" static let showHintButton = "settings.showHintButton" + static let showGameStats = "settings.showGameStats" + static let showStockCount = "settings.showStockCount" static let cardStyle = "settings.cardStyle" static let cardBackColor = "settings.cardBackColor" } +// MARK: - Shared rows + +/// The feedback toggles, shared by the iOS settings sheet and the macOS +/// settings window. Haptics exist only on iOS. +struct SoundSettingsRows: View { + @AppStorage(SettingsKey.soundEffectsEnabled) private var isSoundEffectsEnabled = true +#if os(iOS) + @AppStorage(SettingsKey.hapticFeedbackEnabled) private var isHapticFeedbackEnabled = true +#endif + + var body: some View { + Toggle("Sound effects", isOn: $isSoundEffectsEnabled) + .toggleStyle(.switch) +#if os(iOS) + Toggle("Haptic feedback", isOn: $isHapticFeedbackEnabled) + .toggleStyle(.switch) +#endif + } +} + +/// The in-play visibility toggles, shared by the iOS settings sheet and the +/// macOS settings window. +struct GameplaySettingsRows: View { + @AppStorage(SettingsKey.showGameStats) private var isGameStatsVisible = true + @AppStorage(SettingsKey.showStockCount) private var isStockCountVisible = true + @AppStorage(SettingsKey.showHintButton) private var isHintButtonVisible = true + + var body: some View { + Toggle(isOn: $isGameStatsVisible) { + Text("Show game stats") + Text("Display moves, time, and score above the board.") + } + .toggleStyle(.switch) + Toggle(isOn: $isStockCountVisible) { + Text("Show stock count") + Text("Display how many cards remain in the stock.") + } + .toggleStyle(.switch) + Toggle(isOn: $isHintButtonVisible) { + Text("Show hint button") + Text("Turn off to avoid spoilers about hint availability.") + } + .toggleStyle(.switch) + } +} + +// MARK: - iOS settings sheet + +#if os(iOS) struct SettingsView: View { @Environment(\.dismiss) private var dismiss - @State private var isShowingRulesAndScoring = false - @AppStorage(SettingsKey.cardTiltEnabled) private var isCardTiltEnabled = true @AppStorage(SettingsKey.tableBackgroundColor) private var tableBackgroundColorRawValue = TableBackgroundColor.defaultValue.rawValue - @AppStorage(SettingsKey.feltEffectEnabled) private var isFeltEffectEnabled = true - @AppStorage(SettingsKey.soundEffectsEnabled) private var isSoundEffectsEnabled = true - @AppStorage(SettingsKey.showHintButton) private var isHintButtonVisible = true @AppStorage(SettingsKey.cardStyle) private var cardStyleRawValue = CardStyle.defaultValue.rawValue - @AppStorage(SettingsKey.cardBackColor) private var cardBackColorRawValue = CardBackColor.defaultValue.id -#if os(iOS) @State private var selectedAppIcon = AppIcon.current() - @State private var isShowingAppIconPicker = false -#endif var body: some View { Form { - tableSection - cardsSection - audioSection + appearanceSection + soundAndHapticsSection gameplaySection - -#if os(iOS) - if UIApplication.shared.supportsAlternateIcons { - appIconSection - } -#endif - helpSection - -#if os(iOS) aboutSection -#endif } .navigationTitle("Settings") -#if os(iOS) .navigationBarTitleDisplayMode(.inline) -#else - .formStyle(.grouped) - .padding(16) - .frame(minWidth: 420, idealWidth: 480, maxWidth: 520, minHeight: 320) -#endif .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { @@ -108,160 +134,104 @@ struct SettingsView: View { .keyboardShortcut(.cancelAction) } } - .sheet(isPresented: $isShowingRulesAndScoring) { - NavigationStack { - RulesAndScoringView() - } - } -#if os(iOS) - .sheet(isPresented: $isShowingAppIconPicker) { - NavigationStack { - AppIconPickerView(selection: $selectedAppIcon) - } - .presentationDetents([.medium, .large]) - } -#endif - .onChange(of: cardStyleRawValue) { oldValue, newValue in - guard oldValue != newValue else { return } - HapticManager.shared.play(.settingsSelection) + } + + /// Title on the left, the current selection shown as a trailing preview — + /// the preview itself is the value, no restating it in text. + private func appearanceRow( + title: String, + value: String, + @ViewBuilder preview: () -> some View + ) -> some View { + HStack(spacing: 8) { + Text(title) + Spacer() + preview() + .frame(width: 24, height: 24) } + .accessibilityElement(children: .combine) + .accessibilityValue(value) } - // MARK: - Sections + private var selectedTableColor: TableBackgroundColor { + TableBackgroundColor(rawValue: tableBackgroundColorRawValue) ?? .defaultValue + } - private var tableSection: some View { - Section { - VStack(alignment: .leading, spacing: 12) { - HStack { - Text("Background color") - Spacer() - if let selected = TableBackgroundColor(rawValue: tableBackgroundColorRawValue) { - Text(selected.label) - .foregroundStyle(.secondary) - } - } - HStack(spacing: 8) { - ForEach(TableBackgroundColor.allCases) { option in - colorSwatch(option) - } - } - } - .padding(.vertical, 4) - Toggle(isOn: $isFeltEffectEnabled) { - Text("Felt texture") - Text("Adds a fabric texture and vignette to the table.") - } - .toggleStyle(.switch) - } header: { - Text("Table") - } + private var selectedCardStyle: CardStyle { + CardStyle(rawValue: cardStyleRawValue) ?? .defaultValue } - private var cardsSection: some View { + // MARK: - Sections + + private var appearanceSection: some View { Section { - HStack(spacing: 12) { - ForEach(CardStyle.allCases) { style in - cardStyleCard(style) + NavigationLink { + TableSettingsView() + } label: { + appearanceRow(title: "Table", value: selectedTableColor.label) { + Circle() + .fill(selectedTableColor.color) + .overlay { + Circle().stroke(Color.primary.opacity(0.18), lineWidth: 1) + } + .frame(width: 22, height: 22) } } - .padding(.vertical, 4) - Toggle(isOn: $isCardTiltEnabled) { - Text("Natural card tilt") - Text("Adds a subtle organic angle to each card.") - } - .toggleStyle(.switch) - VStack(alignment: .leading, spacing: 12) { - HStack { - Text("Card back color") - Spacer() - Text(CardBackColor.from(rawValue: cardBackColorRawValue).label) - .foregroundStyle(.secondary) + NavigationLink { + CardsSettingsView() + } label: { + appearanceRow(title: "Cards", value: selectedCardStyle.title) { + // Rendered at the chip size the card art is tuned for, + // then scaled down; tiny layout sizes distort the art. + CardStylePreview( + style: selectedCardStyle, + cardSize: CGSize(width: 44, height: 64) + ) + .frame(width: 44, height: 64) + .scaleEffect(24.0 / 64.0) + .frame(width: 17, height: 24) } - HStack(spacing: 8) { - ForEach(CardBackColor.all) { option in - cardBackSwatch(option) + } + if UIApplication.shared.supportsAlternateIcons { + NavigationLink { + AppIconPickerView(selection: $selectedAppIcon) + } label: { + appearanceRow(title: "App Icon", value: selectedAppIcon.name) { + AppIconPreviewView(icon: selectedAppIcon, size: 24) } - Spacer() } } - .padding(.vertical, 4) } header: { - Text("Cards") + Text("Appearance") } } - private var audioSection: some View { + private var soundAndHapticsSection: some View { Section { - Toggle(isOn: $isSoundEffectsEnabled) { - Text("Sound effects") - Text("Play card and game action sounds.") - } - .toggleStyle(.switch) + SoundSettingsRows() } header: { - Text("Audio") + Text("Sound & Haptics") } } private var gameplaySection: some View { Section { - Toggle(isOn: $isHintButtonVisible) { - Text("Show hint button") - Text("Turn off to avoid spoilers about hint availability.") - } - .toggleStyle(.switch) + GameplaySettingsRows() } header: { Text("Gameplay") } } -#if os(iOS) - private var appIconSection: some View { - Section { - Button { - isShowingAppIconPicker = true - } label: { - HStack(spacing: 10) { - AppIconPreviewView(icon: selectedAppIcon, size: 30) - Text(selectedAppIcon.name) - .foregroundStyle(.primary) - Spacer() - Image(systemName: "chevron.right") - .font(.footnote.weight(.semibold)) - .foregroundStyle(.tertiary) - .accessibilityHidden(true) - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } header: { - Text("App Icon") - } - } -#endif - private var helpSection: some View { Section { - Button { - isShowingRulesAndScoring = true - } label: { - HStack { - Text("Rules & Scoring") - .foregroundStyle(.primary) - Spacer() - Image(systemName: "chevron.right") - .font(.footnote.weight(.semibold)) - .foregroundStyle(.tertiary) - .accessibilityHidden(true) - } - .contentShape(Rectangle()) + NavigationLink("Rules & Scoring") { + RulesAndScoringView(showsDoneButton: false) } - .buttonStyle(.plain) } header: { Text("Help") } } -#if os(iOS) private var aboutSection: some View { Section { NavigationLink { @@ -279,126 +249,6 @@ struct SettingsView: View { Text("About") } } -#endif - - // MARK: - Custom controls - - private func cardStyleCard(_ style: CardStyle) -> some View { - let isSelected = cardStyleRawValue == style.rawValue - - return Button { - guard !isSelected else { return } - HapticManager.shared.play(.settingsSelection) - withAnimation(.smooth(duration: 0.3)) { - cardStyleRawValue = style.rawValue - } - } label: { - VStack(spacing: 6) { - cardStylePreview(style) - .frame(width: 44, height: 64) - - VStack(spacing: 1) { - Text(style.title) - .font(.caption.weight(.bold)) - Text(style.subtitle) - .font(.caption2) - .foregroundStyle(.secondary) - } - } - .selectionChip(isSelected: isSelected) - } - .buttonStyle(.plain) - .accessibilityAddTraits(isSelected ? .isSelected : []) - } - - @ViewBuilder - private func cardStylePreview(_ style: CardStyle) -> some View { - switch style { - case .classic: - ClassicCardFrontView( - card: Card(suit: .hearts, rank: .queen, isFaceUp: true), - cardSize: CGSize(width: 44, height: 64), - isSelected: false - ) - case .simple: - SimpleCardFrontView( - card: Card(suit: .hearts, rank: .queen, isFaceUp: true), - cardSize: CGSize(width: 44, height: 64), - isSelected: false - ) - case .pixel: - PixelCardFrontView( - card: Card(suit: .hearts, rank: .queen, isFaceUp: true), - cardSize: CGSize(width: 44, height: 64), - isSelected: false - ) - } - } - - private func colorSwatch(_ option: TableBackgroundColor) -> some View { - let isSelected = tableBackgroundColorRawValue == option.rawValue - - return Button { - guard !isSelected else { return } - HapticManager.shared.play(.settingsSelection) - tableBackgroundColorRawValue = option.rawValue - } label: { - Circle() - .fill(option.color) - .overlay { - if isSelected { - Image(systemName: "checkmark") - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(.white) - .accessibilityHidden(true) - } - } - .overlay { - Circle() - .stroke( - isSelected ? Color.accentColor : Color.primary.opacity(0.18), - lineWidth: isSelected ? 2.5 : 1 - ) - } - .frame(maxWidth: .infinity) - .aspectRatio(1, contentMode: .fit) - } - .buttonStyle(.plain) - .accessibilityLabel(option.label) - .accessibilityAddTraits(isSelected ? .isSelected : []) - } - - private func cardBackSwatch(_ option: CardBackColor) -> some View { - let isSelected = cardBackColorRawValue == option.id - - return Button { - guard !isSelected else { return } - HapticManager.shared.play(.settingsSelection) - cardBackColorRawValue = option.id - } label: { - Circle() - .fill(option.swatch) - .overlay { - if isSelected { - Image(systemName: "checkmark") - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(.white) - .accessibilityHidden(true) - } - } - .overlay { - Circle() - .stroke( - isSelected ? Color.accentColor : Color.primary.opacity(0.18), - lineWidth: isSelected ? 2.5 : 1 - ) - } - .frame(width: 32, height: 32) - } - .buttonStyle(.plain) - .accessibilityLabel(option.label) - .accessibilityAddTraits(isSelected ? .isSelected : []) - } } #Preview { @@ -406,3 +256,4 @@ struct SettingsView: View { SettingsView() } } +#endif diff --git a/ComputerSolitaire/Views/Shared/BoardViews.swift b/ComputerSolitaire/Views/Shared/BoardViews.swift index 721e22b..1332d0c 100644 --- a/ComputerSolitaire/Views/Shared/BoardViews.swift +++ b/ComputerSolitaire/Views/Shared/BoardViews.swift @@ -211,6 +211,8 @@ struct HeaderView: View { let onGameTitleTapped: () -> Void let onScoreTapped: () -> Void + @AppStorage(SettingsKey.showGameStats) private var isGameStatsVisible = true + // The title gets its own row so the stat strip keeps the full board // width and stays balanced over the board; sharing a row would push the // strip off the board's centerline. @@ -218,10 +220,12 @@ struct HeaderView: View { VStack(alignment: .leading, spacing: 6) { gameTitleButton .padding(.leading, 4) - HStack(spacing: 10) { - statTiles + if isGameStatsVisible { + HStack(spacing: 10) { + statTiles + } + .headerContainer() } - .headerContainer() } } diff --git a/ComputerSolitaire/Views/Shared/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift index f5fc9c0..46b9931 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -100,7 +100,11 @@ struct ContentView: View { @State private var wasteReturnAnchorFrame: CGRect? @State private var cardTilts: [UUID: Double] = [:] @State private var overlayTilt: Double = 0 +#if os(iOS) @State private var isShowingSettings = false +#else + @Environment(\.openSettings) private var openSettings +#endif @State private var stockFrame: CGRect = .zero @State private var wasteFrame: CGRect = .zero @State private var drawAnimationCards: [DrawAnimationCard] = [] @@ -168,7 +172,13 @@ struct ContentView: View { } private var isAnyMenuPresented: Bool { +#if os(iOS) isShowingSettings || isShowingRulesAndScoring || isShowingStats || isShowingGamePicker +#else + // The macOS settings window is separate; the game pauses through the + // main window losing active appearance instead. + isShowingRulesAndScoring || isShowingStats || isShowingGamePicker +#endif } private var shouldPauseForLifecycle: Bool { @@ -251,7 +261,7 @@ struct ContentView: View { } } Section { - Button("Settings", systemImage: "gearshape") { + Button("Settings", systemImage: "gear") { isShowingSettings = true } } @@ -359,9 +369,9 @@ struct ContentView: View { .labelStyle(.iconOnly) .help("Statistics") Button { - isShowingSettings = true + openSettings() } label: { - Label("Settings", systemImage: "gearshape") + Label("Settings", systemImage: "gear") } .labelStyle(.iconOnly) .help("Settings") @@ -402,17 +412,17 @@ struct ContentView: View { } private func applySheets(to view: AnyView) -> AnyView { - AnyView( - view.sheet(isPresented: $isShowingSettings) { #if os(iOS) + let view = AnyView( + view.sheet(isPresented: $isShowingSettings) { NavigationStack { SettingsView() } -#else - SettingsView() -#endif } - .sheet(isPresented: $isShowingRulesAndScoring) { + ) +#endif + return AnyView( + view.sheet(isPresented: $isShowingRulesAndScoring) { NavigationStack { RulesAndScoringView(initialSection: rulesAndScoringInitialSection) } @@ -426,11 +436,15 @@ struct ContentView: View { } private func applyObservers(to view: AnyView) -> AnyView { - let commandObservedView = AnyView( - view - .onReceive(NotificationCenter.default.publisher(for: .openSettings)) { _ in +#if os(iOS) + let view = AnyView( + view.onReceive(NotificationCenter.default.publisher(for: .openSettings)) { _ in isShowingSettings = true } + ) +#endif + let commandObservedView = AnyView( + view .onReceive(NotificationCenter.default.publisher(for: .openRulesAndScoring)) { _ in presentRulesAndScoring(initialSection: .rules) } diff --git a/ComputerSolitaire/Views/Shared/StockWasteViews.swift b/ComputerSolitaire/Views/Shared/StockWasteViews.swift index 2e62263..073ef60 100644 --- a/ComputerSolitaire/Views/Shared/StockWasteViews.swift +++ b/ComputerSolitaire/Views/Shared/StockWasteViews.swift @@ -13,6 +13,8 @@ struct StockView: View { let hintHighlightOpacity: Double let hintWiggleToken: UUID + @AppStorage(SettingsKey.showStockCount) private var isStockCountVisible = true + var body: some View { Button { viewModel.handleStockTap() @@ -30,10 +32,12 @@ struct StockView: View { } else { CardBackView(cardSize: cardSize) } - Text("\(viewModel.state.stock.count)") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.white.opacity(0.8)) - .offset(x: cardSize.width * 0.28, y: cardSize.height * 0.38) + if isStockCountVisible { + Text("\(viewModel.state.stock.count)") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.white.opacity(0.8)) + .offset(x: cardSize.width * 0.28, y: cardSize.height * 0.38) + } DropHighlightView( cardSize: cardSize, diff --git a/ComputerSolitaire/Views/Shared/TableauStockView.swift b/ComputerSolitaire/Views/Shared/TableauStockView.swift index e63c8a7..02485dd 100644 --- a/ComputerSolitaire/Views/Shared/TableauStockView.swift +++ b/ComputerSolitaire/Views/Shared/TableauStockView.swift @@ -12,6 +12,8 @@ struct TableauStockView: View { /// How a deal lands, for accessibility — e.g. "Deals one card to each pile". let dealDescription: String + @AppStorage(SettingsKey.showStockCount) private var isStockCountVisible = true + var body: some View { Button { viewModel.handleStockTap() @@ -21,10 +23,12 @@ struct TableauStockView: View { .allowsHitTesting(false) if !viewModel.state.stock.isEmpty { CardBackView(cardSize: cardSize) - Text("\(viewModel.state.stock.count)") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.white.opacity(0.8)) - .offset(x: cardSize.width * 0.28, y: cardSize.height * 0.38) + if isStockCountVisible { + Text("\(viewModel.state.stock.count)") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.white.opacity(0.8)) + .offset(x: cardSize.width * 0.28, y: cardSize.height * 0.38) + } } DropHighlightView(