SoliCards-iOS-iPadOS-MacOS/SoliCards/ContentView.swift
idev2025 de0da01f25 feat: SoliCards v1.2.0 — native SwiftUI solitaire for iOS, iPadOS, macOS
Complete native rewrite of the web-based SoliCards game as a SwiftUI
multiplatform app targeting iOS 17+, iPadOS 17+, and macOS 14+.

Three solitaire variants (Klondike, Spider, FreeCell) with full game
rules, drag & drop, smart zoom layout, 6 themes, 4 difficulty levels,
SwiftData persistence, VoiceOver accessibility, and 57 unit tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 08:26:14 -04:00

308 lines
10 KiB
Swift

import SwiftUI
import SwiftData
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@State private var viewModel = GameViewModel()
@State private var themeManager = ThemeManager()
@State private var cardFaceStyle: CardFaceStyle = .classic
@State private var cardBackDesign: CardBackDesign = .blue
@State private var showingRules = false
@State private var showingNewGame = false
@State private var showingSettings = false
@State private var showingStats = false
@State private var hasLoaded = false
var body: some View {
NavigationStack {
GameBoardView(
viewModel: viewModel,
theme: themeManager.currentTheme,
cardFaceStyle: cardFaceStyle,
cardBackDesign: cardBackDesign
)
.toolbar {
#if os(iOS)
ToolbarItemGroup(placement: .bottomBar) {
iOSToolbar
}
#else
ToolbarItemGroup(placement: .primaryAction) {
macToolbar
}
#endif
}
.navigationTitle(viewModel.variant.displayName)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.visible, for: .bottomBar)
.toolbarBackground(.ultraThinMaterial, for: .bottomBar)
.toolbarColorScheme(.dark, for: .bottomBar)
#endif
.tint(.white)
.sheet(isPresented: $showingRules) {
RulesView(variant: viewModel.variant)
}
.sheet(isPresented: $showingNewGame) {
NewGameSheet(
variant: viewModel.variant,
difficulty: viewModel.difficulty
) { newVariant, newDifficulty in
showingNewGame = false
viewModel.difficulty = newDifficulty
viewModel.changeVariant(to: newVariant)
}
}
.sheet(isPresented: $showingSettings) {
SettingsView(
theme: Binding(
get: { themeManager.currentTheme },
set: { themeManager.applyTheme($0) }
),
cardFaceStyle: $cardFaceStyle,
cardBackDesign: $cardBackDesign,
soundEnabled: Binding(
get: { viewModel.isSoundEnabled },
set: { viewModel.isSoundEnabled = $0 }
)
)
}
.sheet(isPresented: $showingStats) {
StatisticsView()
}
}
.onAppear {
guard !hasLoaded else { return }
hasLoaded = true
let pm = PersistenceManager(modelContext: modelContext)
viewModel.persistenceManager = pm
let prefs = pm.loadPreferences()
if let theme = GameTheme.allThemes.first(where: { $0.id == prefs.themeId }) {
themeManager.applyTheme(theme)
}
if let style = CardFaceStyle(rawValue: prefs.cardFaceStyle) {
cardFaceStyle = style
}
if let back = CardBackDesign(rawValue: prefs.cardBackDesign) {
cardBackDesign = back
}
viewModel.isSoundEnabled = prefs.soundEnabled
if let savedGame = pm.loadMostRecentGame() {
viewModel.resumeGame(from: savedGame)
} else {
if let savedVariant = GameVariant(rawValue: prefs.lastVariant) {
viewModel.changeVariant(to: savedVariant)
} else {
viewModel.newGame()
}
}
}
.onChange(of: cardFaceStyle) { savePreferences() }
.onChange(of: cardBackDesign) { savePreferences() }
.onChange(of: themeManager.currentTheme) { savePreferences() }
.onChange(of: viewModel.isSoundEnabled) { savePreferences() }
#if os(iOS)
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { _ in
viewModel.saveGameNow()
savePreferences()
}
#endif
#if os(macOS)
.frame(minWidth: 800, minHeight: 600)
#endif
}
private func savePreferences() {
viewModel.persistenceManager?.savePreferences(
themeId: themeManager.currentTheme.id,
cardFaceStyle: cardFaceStyle,
cardBackDesign: cardBackDesign,
soundEnabled: viewModel.isSoundEnabled,
lastVariant: viewModel.variant,
lastDifficulty: viewModel.difficulty
)
}
// MARK: - iOS Toolbar (bottom bar 5 items max, with labels)
@ViewBuilder
private var iOSToolbar: some View {
Button { showingNewGame = true } label: {
VStack(spacing: 2) {
Image(systemName: "plus.circle.fill")
.font(.title3)
Text("New")
.font(.caption2)
}
}
.keyboardShortcut("n", modifiers: .command)
Spacer()
Button { viewModel.undo() } label: {
VStack(spacing: 2) {
Image(systemName: "arrow.uturn.backward.circle.fill")
.font(.title3)
Text("Undo")
.font(.caption2)
}
}
.keyboardShortcut("z", modifiers: .command)
.disabled(!viewModel.canUndo)
Spacer()
Button { viewModel.requestHint() } label: {
VStack(spacing: 2) {
Image(systemName: "lightbulb.fill")
.font(.title3)
Text("Hint")
.font(.caption2)
}
}
.keyboardShortcut("h", modifiers: [])
Spacer()
// "More" menu consolidates secondary actions
Menu {
// Game variants
Menu("Game Variant") {
ForEach(GameVariant.allCases) { variant in
Button {
viewModel.changeVariant(to: variant)
} label: {
HStack {
Text(variant.displayName)
if variant == viewModel.variant {
Image(systemName: "checkmark")
}
}
}
}
}
// Difficulty
Menu("Difficulty") {
ForEach(Difficulty.allCases) { diff in
Button {
viewModel.difficulty = diff
viewModel.newGame()
} label: {
HStack {
Text(diff.displayName)
if diff == viewModel.difficulty {
Image(systemName: "checkmark")
}
}
}
}
}
Divider()
Button { viewModel.autoComplete() } label: {
Label("Auto-Complete", systemImage: "wand.and.stars")
}
Button {
viewModel.isSoundEnabled.toggle()
} label: {
Label(viewModel.isSoundEnabled ? "Sound On" : "Sound Off",
systemImage: viewModel.isSoundEnabled ? "speaker.wave.2" : "speaker.slash")
}
Divider()
Button { showingRules = true } label: {
Label("Rules", systemImage: "book")
}
Button { showingStats = true } label: {
Label("Statistics", systemImage: "chart.bar")
}
Button { showingSettings = true } label: {
Label("Settings", systemImage: "gearshape")
}
} label: {
VStack(spacing: 2) {
Image(systemName: "ellipsis.circle.fill")
.font(.title3)
Text("More")
.font(.caption2)
}
}
}
// MARK: - macOS Toolbar (all items fit, no labels needed)
@ViewBuilder
private var macToolbar: some View {
Menu {
ForEach(GameVariant.allCases) { variant in
Button(variant.displayName) {
viewModel.changeVariant(to: variant)
}
}
Divider()
ForEach(Difficulty.allCases) { diff in
Button {
viewModel.difficulty = diff
viewModel.newGame()
} label: {
HStack {
Text(diff.displayName)
if diff == viewModel.difficulty {
Image(systemName: "checkmark")
}
}
}
}
} label: {
Label("Game", systemImage: "suit.spade.fill")
}
Button { showingNewGame = true } label: {
Label("New Game", systemImage: "plus.circle")
}
.keyboardShortcut("n", modifiers: .command)
Button { viewModel.undo() } label: {
Label("Undo", systemImage: "arrow.uturn.backward")
}
.keyboardShortcut("z", modifiers: .command)
.disabled(!viewModel.canUndo)
Button { viewModel.requestHint() } label: {
Label("Hint", systemImage: "lightbulb")
}
.keyboardShortcut("h", modifiers: [])
Button { viewModel.autoComplete() } label: {
Label("Auto", systemImage: "wand.and.stars")
}
Button { showingRules = true } label: {
Label("Rules", systemImage: "book")
}
Button { showingStats = true } label: {
Label("Stats", systemImage: "chart.bar")
}
Button { showingSettings = true } label: {
Label("Settings", systemImage: "gearshape")
}
Button {
viewModel.isSoundEnabled.toggle()
} label: {
Label("Sound", systemImage: viewModel.isSoundEnabled ? "speaker.wave.2" : "speaker.slash")
}
}
}