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. Key features: - MVVM + Protocol-Oriented Strategy architecture - DragGesture with coordinate-space hit-testing (long press + drag) - Smart zoom: cards auto-size to fit screen based on deepest column - Landscape: 30% bigger cards with scrollable overflow (iOS) - macOS: 120pt card cap, 92% height buffer for window resizing - Auto-save, game resume, statistics tracking via SwiftData - Privacy manifest, app icon, String Catalog, zero dependencies Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
308 lines
10 KiB
Swift
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")
|
|
}
|
|
}
|
|
}
|