From c7b02f9f72851413c1f0a7b03c5cebe6cdf5737c Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Mon, 13 Jul 2026 23:03:17 -0700 Subject: [PATCH 1/2] standardize settings page and add feedback and visibility settings reorganize settings into appearance, sound & haptics, gameplay, help, and about sections with live previews on appearance rows. add haptic feedback, show game stats, and show stock count settings. replace nested sheets with navigation pushes and give the macos sheet a navigation stack. --- .../Feedback/HapticManager.swift | 9 + ComputerSolitaire/Views/AboutView.swift | 2 +- .../Views/AppIconPickerView.swift | 32 +- .../Views/AppearanceSettingsViews.swift | 224 ++++++++++++ .../Views/RulesAndScoringView.swift | 17 +- ComputerSolitaire/Views/SettingsView.swift | 346 +++++------------- .../Views/Shared/BoardViews.swift | 10 +- .../Views/Shared/ContentView.swift | 5 +- .../Views/Shared/StockWasteViews.swift | 12 +- .../Views/Shared/TableauStockView.swift | 12 +- 10 files changed, 380 insertions(+), 289 deletions(-) create mode 100644 ComputerSolitaire/Views/AppearanceSettingsViews.swift 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..04982fe 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 { 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..29ddbfb --- /dev/null +++ b/ComputerSolitaire/Views/AppearanceSettingsViews.swift @@ -0,0 +1,224 @@ +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 + +struct TableSettingsView: View { + @AppStorage(SettingsKey.tableBackgroundColor) + private var tableBackgroundColorRawValue = TableBackgroundColor.defaultValue.rawValue + @AppStorage(SettingsKey.feltEffectEnabled) private var isFeltEffectEnabled = true + + var body: some View { + Form { + 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) + } + } + .navigationTitle("Table") +#if os(iOS) + .navigationBarTitleDisplayMode(.inline) +#else + .formStyle(.grouped) + .padding(16) +#endif + } + + 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 : []) + } +} + +// MARK: - Cards + +struct CardsSettingsView: 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 { + Form { + Section { + 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) + } + } + .navigationTitle("Cards") +#if os(iOS) + .navigationBarTitleDisplayMode(.inline) +#else + .formStyle(.grouped) + .padding(16) +#endif + .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 : []) + } +} + +#Preview("Table") { + NavigationStack { + TableSettingsView() + } +} + +#Preview("Cards") { + NavigationStack { + CardsSettingsView() + } +} diff --git a/ComputerSolitaire/Views/RulesAndScoringView.swift b/ComputerSolitaire/Views/RulesAndScoringView.swift index 507cf18..80004d0 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 { @@ -85,11 +90,13 @@ 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) } } } diff --git a/ComputerSolitaire/Views/SettingsView.swift b/ComputerSolitaire/Views/SettingsView.swift index 532b26b..9ee6958 100644 --- a/ComputerSolitaire/Views/SettingsView.swift +++ b/ComputerSolitaire/Views/SettingsView.swift @@ -52,45 +52,35 @@ 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" } 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.hapticFeedbackEnabled) private var isHapticFeedbackEnabled = true @AppStorage(SettingsKey.showHintButton) private var isHintButtonVisible = true + @AppStorage(SettingsKey.showGameStats) private var isGameStatsVisible = true + @AppStorage(SettingsKey.showStockCount) private var isStockCountVisible = true + @AppStorage(SettingsKey.tableBackgroundColor) + private var tableBackgroundColorRawValue = TableBackgroundColor.defaultValue.rawValue @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) @@ -98,7 +88,6 @@ struct SettingsView: View { #else .formStyle(.grouped) .padding(16) - .frame(minWidth: 420, idealWidth: 480, maxWidth: 520, minHeight: 320) #endif .toolbar { ToolbarItem(placement: .confirmationAction) { @@ -108,102 +97,109 @@ 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) - } } - // MARK: - Sections + /// 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) + } - 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 selectedTableColor: TableBackgroundColor { + TableBackgroundColor(rawValue: tableBackgroundColorRawValue) ?? .defaultValue + } + + 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 os(iOS) + 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) +#endif } 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) + Toggle("Sound effects", isOn: $isSoundEffectsEnabled) + .toggleStyle(.switch) +#if os(iOS) + Toggle("Haptic feedback", isOn: $isHapticFeedbackEnabled) + .toggleStyle(.switch) +#endif } header: { - Text("Audio") +#if os(iOS) + Text("Sound & Haptics") +#else + Text("Sound") +#endif } } private var gameplaySection: some View { Section { + 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.") @@ -214,54 +210,16 @@ struct SettingsView: View { } } -#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 +237,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 { 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..99fb822 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -404,12 +404,11 @@ struct ContentView: View { private func applySheets(to view: AnyView) -> AnyView { AnyView( view.sheet(isPresented: $isShowingSettings) { -#if os(iOS) NavigationStack { SettingsView() } -#else - SettingsView() +#if os(macOS) + .frame(minWidth: 420, idealWidth: 480, maxWidth: 520, minHeight: 380) #endif } .sheet(isPresented: $isShowingRulesAndScoring) { 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( From 68c676579993dfc607a00614a0362dc4567e2e7d Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Mon, 13 Jul 2026 23:31:29 -0700 Subject: [PATCH 2/2] add native macos settings window replace the macos settings sheet with a settings scene: general, appearance, rules, and about panes with fitted sizes so pane switches get the system's animated resize. share settings rows between the ios sheet and the panes, add a game picker to rules & scoring, and wrap the about copy to the about window's column width. --- ComputerSolitaire/ComputerSolitaireApp.swift | 11 +- ComputerSolitaire/Views/AboutView.swift | 3 + .../Views/AppearanceSettingsViews.swift | 144 ++++++++++-------- ComputerSolitaire/Views/MacSettingsView.swift | 60 ++++++++ .../Views/RulesAndScoringView.swift | 41 +++-- ComputerSolitaire/Views/SettingsView.swift | 87 ++++++----- .../Views/Shared/ContentView.swift | 37 +++-- 7 files changed, 257 insertions(+), 126 deletions(-) create mode 100644 ComputerSolitaire/Views/MacSettingsView.swift 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/Views/AboutView.swift b/ComputerSolitaire/Views/AboutView.swift index 04982fe..3f3a93b 100644 --- a/ComputerSolitaire/Views/AboutView.swift +++ b/ComputerSolitaire/Views/AboutView.swift @@ -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/AppearanceSettingsViews.swift b/ComputerSolitaire/Views/AppearanceSettingsViews.swift index 29ddbfb..797dd96 100644 --- a/ComputerSolitaire/Views/AppearanceSettingsViews.swift +++ b/ComputerSolitaire/Views/AppearanceSettingsViews.swift @@ -23,46 +23,38 @@ struct CardStylePreview: View { } } -// MARK: - Table +// MARK: - Table rows -struct TableSettingsView: View { +/// 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 { - Form { - 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) - } - } + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Background color") + Spacer() + if let selected = TableBackgroundColor(rawValue: tableBackgroundColorRawValue) { + Text(selected.label) + .foregroundStyle(.secondary) } - .padding(.vertical, 4) - Toggle(isOn: $isFeltEffectEnabled) { - Text("Felt texture") - Text("Adds a fabric texture and vignette to the table.") + } + HStack(spacing: 8) { + ForEach(TableBackgroundColor.allCases) { option in + colorSwatch(option) } - .toggleStyle(.switch) + Spacer() } } - .navigationTitle("Table") -#if os(iOS) - .navigationBarTitleDisplayMode(.inline) -#else - .formStyle(.grouped) - .padding(16) -#endif + .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 { @@ -90,8 +82,7 @@ struct TableSettingsView: View { lineWidth: isSelected ? 2.5 : 1 ) } - .frame(maxWidth: .infinity) - .aspectRatio(1, contentMode: .fit) + .frame(width: 32, height: 32) } .buttonStyle(.plain) .accessibilityLabel(option.label) @@ -99,51 +90,44 @@ struct TableSettingsView: View { } } -// MARK: - Cards +// MARK: - Cards rows -struct CardsSettingsView: View { +/// 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 { - Form { - Section { - HStack(spacing: 12) { - ForEach(CardStyle.allCases) { style in - cardStyleChip(style) - } + 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) + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Card back color") + Spacer() + Text(CardBackColor.from(rawValue: cardBackColorRawValue).label) + .foregroundStyle(.secondary) } - .padding(.vertical, 4) - Toggle(isOn: $isCardTiltEnabled) { - Text("Natural card tilt") - Text("Adds a subtle organic angle to each card.") + HStack(spacing: 8) { + ForEach(CardBackColor.all) { option in + cardBackSwatch(option) + } + Spacer() } - .toggleStyle(.switch) } + .padding(.vertical, 4) + Toggle(isOn: $isCardTiltEnabled) { + Text("Natural card tilt") + Text("Adds a subtle organic angle to each card.") + } + .toggleStyle(.switch) } - .navigationTitle("Cards") -#if os(iOS) - .navigationBarTitleDisplayMode(.inline) -#else - .formStyle(.grouped) - .padding(16) -#endif .onChange(of: cardStyleRawValue) { oldValue, newValue in guard oldValue != newValue else { return } HapticManager.shared.play(.settingsSelection) @@ -211,6 +195,33 @@ struct CardsSettingsView: View { } } +// 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() @@ -222,3 +233,4 @@ struct CardsSettingsView: View { 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 80004d0..e52b4dc 100644 --- a/ComputerSolitaire/Views/RulesAndScoringView.swift +++ b/ComputerSolitaire/Views/RulesAndScoringView.swift @@ -44,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] = [ @@ -64,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) @@ -101,6 +118,13 @@ struct RulesAndScoringView: View { } } + private var browsedVariantSelection: Binding { + Binding( + get: { gameVariant }, + set: { selectedVariant = $0 } + ) + } + private var contentHorizontalPadding: CGFloat { #if os(iOS) return 30 @@ -117,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( @@ -139,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) { @@ -155,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) @@ -165,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 9ee6958..fd20e00 100644 --- a/ComputerSolitaire/Views/SettingsView.swift +++ b/ComputerSolitaire/Views/SettingsView.swift @@ -60,19 +60,61 @@ enum SettingsKey { static let cardBackColor = "settings.cardBackColor" } -struct SettingsView: View { - @Environment(\.dismiss) private var dismiss +// 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 - @AppStorage(SettingsKey.showHintButton) private var isHintButtonVisible = 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 @AppStorage(SettingsKey.tableBackgroundColor) private var tableBackgroundColorRawValue = TableBackgroundColor.defaultValue.rawValue @AppStorage(SettingsKey.cardStyle) private var cardStyleRawValue = CardStyle.defaultValue.rawValue -#if os(iOS) @State private var selectedAppIcon = AppIcon.current() -#endif var body: some View { Form { @@ -83,12 +125,7 @@ struct SettingsView: View { aboutSection } .navigationTitle("Settings") -#if os(iOS) .navigationBarTitleDisplayMode(.inline) -#else - .formStyle(.grouped) - .padding(16) -#endif .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { @@ -155,7 +192,6 @@ struct SettingsView: View { .frame(width: 17, height: 24) } } -#if os(iOS) if UIApplication.shared.supportsAlternateIcons { NavigationLink { AppIconPickerView(selection: $selectedAppIcon) @@ -165,7 +201,6 @@ struct SettingsView: View { } } } -#endif } header: { Text("Appearance") } @@ -173,38 +208,15 @@ struct SettingsView: View { private var soundAndHapticsSection: some View { Section { - Toggle("Sound effects", isOn: $isSoundEffectsEnabled) - .toggleStyle(.switch) -#if os(iOS) - Toggle("Haptic feedback", isOn: $isHapticFeedbackEnabled) - .toggleStyle(.switch) -#endif + SoundSettingsRows() } header: { -#if os(iOS) Text("Sound & Haptics") -#else - Text("Sound") -#endif } } private var gameplaySection: some View { Section { - 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) + GameplaySettingsRows() } header: { Text("Gameplay") } @@ -244,3 +256,4 @@ struct SettingsView: View { SettingsView() } } +#endif diff --git a/ComputerSolitaire/Views/Shared/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift index 99fb822..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,16 +412,17 @@ struct ContentView: View { } private func applySheets(to view: AnyView) -> AnyView { - AnyView( +#if os(iOS) + let view = AnyView( view.sheet(isPresented: $isShowingSettings) { NavigationStack { SettingsView() } -#if os(macOS) - .frame(minWidth: 420, idealWidth: 480, maxWidth: 520, minHeight: 380) -#endif } - .sheet(isPresented: $isShowingRulesAndScoring) { + ) +#endif + return AnyView( + view.sheet(isPresented: $isShowingRulesAndScoring) { NavigationStack { RulesAndScoringView(initialSection: rulesAndScoringInitialSection) } @@ -425,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) }