Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions ComputerSolitaire/ComputerSolitaireApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ struct ComputerSolitaireApp: App {
}
.windowResizability(.contentSize)
.defaultPosition(.center)
Settings {
MacSettingsView()
}
.windowResizability(.contentSize)
#else
WindowGroup {
ContentView()
Expand Down Expand Up @@ -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
}
}
9 changes: 9 additions & 0 deletions ComputerSolitaire/Feedback/HapticManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
5 changes: 4 additions & 1 deletion ComputerSolitaire/Views/AboutView.swift
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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))
}
Expand Down
32 changes: 17 additions & 15 deletions ComputerSolitaire/Views/AppIconPickerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
236 changes: 236 additions & 0 deletions ComputerSolitaire/Views/AppearanceSettingsViews.swift
Original file line number Diff line number Diff line change
@@ -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
Loading