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>
84 lines
3.4 KiB
Swift
84 lines
3.4 KiB
Swift
import SwiftUI
|
|
|
|
struct FreeCellBoardView: View {
|
|
@Bindable var viewModel: GameViewModel
|
|
let layout: CardLayout
|
|
let theme: GameTheme
|
|
let cardFaceStyle: CardFaceStyle
|
|
let cardBackDesign: CardBackDesign
|
|
|
|
var body: some View {
|
|
VStack(spacing: layout.horizontalPadding) {
|
|
HStack(spacing: layout.horizontalPadding) {
|
|
ForEach(0..<min(4, viewModel.state.freeCells.count), id: \.self) { index in
|
|
freeCellView(index: index)
|
|
}
|
|
Spacer()
|
|
ForEach(0..<min(4, viewModel.state.foundations.count), id: \.self) { index in
|
|
foundationView(index: index)
|
|
}
|
|
}
|
|
.padding(.horizontal, layout.horizontalPadding)
|
|
|
|
HStack(alignment: .top, spacing: layout.horizontalPadding) {
|
|
ForEach(0..<min(8, viewModel.state.tableaus.count), id: \.self) { column in
|
|
CardStackView(
|
|
cards: viewModel.state.tableaus[column],
|
|
location: .tableau(column),
|
|
layout: layout,
|
|
cardFaceStyle: cardFaceStyle,
|
|
cardBackDesign: cardBackDesign,
|
|
viewModel: viewModel
|
|
)
|
|
}
|
|
}
|
|
.padding(.horizontal, layout.horizontalPadding)
|
|
|
|
}
|
|
}
|
|
|
|
private func freeCellView(index: Int) -> some View {
|
|
let card = viewModel.state.freeCells[index]
|
|
return CardView(card: card,
|
|
cardFaceStyle: cardFaceStyle, cardBackDesign: cardBackDesign,
|
|
size: layout.cardSize())
|
|
.dropTarget(.freeCell(index))
|
|
.accessibilityLabel(card != nil
|
|
? Text("Free cell \(index + 1), \(card!.rank.displayName) of \(card!.suit.displayName)")
|
|
: Text("Free cell \(index + 1), empty"))
|
|
.onTapGesture { viewModel.tapCard(at: .freeCell(index), cardIndex: 0) }
|
|
.simultaneousGesture(freeCellDragGesture(index: index))
|
|
}
|
|
|
|
private func foundationView(index: Int) -> some View {
|
|
let topCard = viewModel.state.foundations[index].last
|
|
return CardView(card: topCard,
|
|
cardFaceStyle: cardFaceStyle, cardBackDesign: cardBackDesign,
|
|
size: layout.cardSize())
|
|
.dropTarget(.foundation(index))
|
|
.accessibilityLabel(topCard != nil
|
|
? Text("Foundation \(index + 1), \(topCard!.rank.displayName) of \(topCard!.suit.displayName)")
|
|
: Text("Foundation \(index + 1), empty"))
|
|
}
|
|
|
|
private func freeCellDragGesture(index: Int) -> some Gesture {
|
|
LongPressGesture(minimumDuration: 0.15)
|
|
.sequenced(before: DragGesture(coordinateSpace: .named("board")))
|
|
.onChanged { value in
|
|
if case .second(true, let drag?) = value {
|
|
if viewModel.draggedCards.isEmpty, let card = viewModel.state.freeCells[index] {
|
|
viewModel.beginDrag(cards: [card], from: .freeCell(index))
|
|
}
|
|
viewModel.dragPosition = drag.location
|
|
}
|
|
}
|
|
.onEnded { value in
|
|
if case .second(true, let drag?) = value {
|
|
viewModel.endDrag(at: drag.location)
|
|
} else {
|
|
viewModel.cancelDrag()
|
|
}
|
|
}
|
|
}
|
|
}
|