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>
102 lines
3.7 KiB
Swift
102 lines
3.7 KiB
Swift
import SwiftUI
|
|
|
|
struct CardStackView: View {
|
|
let cards: [Card]
|
|
let location: CardLocation
|
|
let layout: CardLayout
|
|
let cardFaceStyle: CardFaceStyle
|
|
let cardBackDesign: CardBackDesign
|
|
@Bindable var viewModel: GameViewModel
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .top) {
|
|
// Empty slot placeholder — also a drop target
|
|
CardView(card: nil, cardFaceStyle: cardFaceStyle, cardBackDesign: cardBackDesign,
|
|
size: layout.cardSize())
|
|
.dropTarget(location)
|
|
.accessibilityLabel(emptySlotLabel)
|
|
|
|
// Stacked cards
|
|
ForEach(Array(cards.enumerated()), id: \.element.id) { index, card in
|
|
let isInDrag = viewModel.draggedCards.contains(card)
|
|
let isLast = index == cards.count - 1
|
|
|
|
CardView(card: card, cardFaceStyle: cardFaceStyle, cardBackDesign: cardBackDesign,
|
|
size: layout.cardSize(),
|
|
isHighlighted: isHinted(card))
|
|
.offset(y: yOffset(for: index))
|
|
.opacity(isInDrag ? 0.3 : 1.0)
|
|
.onTapGesture {
|
|
viewModel.tapCard(at: location, cardIndex: index)
|
|
}
|
|
.simultaneousGesture(
|
|
card.isFaceUp ? dragGesture(for: card, at: index) : nil
|
|
)
|
|
.overlay {
|
|
if isLast {
|
|
Color.clear
|
|
.dropTarget(location)
|
|
.offset(y: yOffset(for: index))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(width: layout.cardWidth)
|
|
.accessibilityElement(children: .contain)
|
|
.accessibilityLabel(stackAccessibilityLabel)
|
|
}
|
|
|
|
private var emptySlotLabel: Text {
|
|
switch location {
|
|
case .tableau(let i): Text("Empty tableau column \(i + 1)")
|
|
case .foundation(let i): Text("Empty foundation \(i + 1)")
|
|
case .freeCell(let i): Text("Empty free cell \(i + 1)")
|
|
default: Text("Empty slot")
|
|
}
|
|
}
|
|
|
|
private var stackAccessibilityLabel: Text {
|
|
switch location {
|
|
case .tableau(let i):
|
|
let count = cards.count
|
|
return Text("Tableau column \(i + 1), \(count) card\(count == 1 ? "" : "s")")
|
|
default:
|
|
return Text("")
|
|
}
|
|
}
|
|
|
|
private func yOffset(for index: Int) -> CGFloat {
|
|
var offset: CGFloat = 0
|
|
for i in 0..<index {
|
|
offset += cards[i].isFaceUp ? layout.verticalOverlapFaceUp : layout.verticalOverlapFaceDown
|
|
}
|
|
return offset
|
|
}
|
|
|
|
private func dragGesture(for card: Card, at 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 cardsToMove = Array(cards[index...])
|
|
viewModel.beginDrag(cards: cardsToMove, from: location)
|
|
}
|
|
viewModel.dragPosition = drag.location
|
|
}
|
|
}
|
|
.onEnded { value in
|
|
if case .second(true, let drag?) = value {
|
|
viewModel.endDrag(at: drag.location)
|
|
} else {
|
|
viewModel.cancelDrag()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func isHinted(_ card: Card) -> Bool {
|
|
guard viewModel.isShowingHint, let hint = viewModel.currentHint else { return false }
|
|
return hint.cards.contains(card)
|
|
}
|
|
}
|