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>
124 lines
3.9 KiB
Swift
124 lines
3.9 KiB
Swift
import SwiftUI
|
|
|
|
struct CardView: View {
|
|
let card: Card?
|
|
let cardFaceStyle: CardFaceStyle
|
|
let cardBackDesign: CardBackDesign
|
|
let size: CGSize
|
|
var isHighlighted: Bool = false
|
|
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
|
|
var body: some View {
|
|
Group {
|
|
if let card {
|
|
if card.isFaceUp {
|
|
cardFront(card)
|
|
} else {
|
|
cardBack
|
|
}
|
|
} else {
|
|
emptySlot
|
|
}
|
|
}
|
|
.frame(width: size.width, height: size.height)
|
|
.clipShape(RoundedRectangle(cornerRadius: size.width * 0.08))
|
|
.shadow(color: .black.opacity(0.15), radius: 2, y: 1)
|
|
.overlay {
|
|
if isHighlighted {
|
|
RoundedRectangle(cornerRadius: size.width * 0.08)
|
|
.stroke(Color.yellow, lineWidth: 3)
|
|
.animation(reduceMotion ? nil : .easeInOut(duration: 0.8).repeatForever(), value: isHighlighted)
|
|
}
|
|
}
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel(accessibilityLabel)
|
|
.accessibilityHint(accessibilityHint)
|
|
.accessibilityAddTraits(card?.isFaceUp == true ? .isButton : [])
|
|
}
|
|
|
|
private var accessibilityLabel: Text {
|
|
guard let card else { return Text("Empty card slot") }
|
|
if card.isFaceUp {
|
|
return Text("\(card.rank.displayName) of \(card.suit.displayName)")
|
|
} else {
|
|
return Text("Face down card")
|
|
}
|
|
}
|
|
|
|
private var accessibilityHint: Text {
|
|
guard let card, card.isFaceUp else { return Text("") }
|
|
return Text("Double tap to move to best available position")
|
|
}
|
|
|
|
private func cardFront(_ card: Card) -> some View {
|
|
let imageName = card.frontImageName(style: cardFaceStyle)
|
|
|
|
return ZStack {
|
|
RoundedRectangle(cornerRadius: size.width * 0.08)
|
|
.fill(.white)
|
|
|
|
if let uiImage = loadImage(named: imageName) {
|
|
Image(uiImage: uiImage)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.padding(1)
|
|
.accessibilityHidden(true)
|
|
} else {
|
|
VStack(spacing: 2) {
|
|
Text(card.rank.shortName)
|
|
.font(.system(size: size.width * 0.28, weight: .bold))
|
|
Text(card.suit.symbol)
|
|
.font(.system(size: size.width * 0.22))
|
|
}
|
|
.foregroundStyle(card.color == .red ? .red : .black)
|
|
.accessibilityHidden(true)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var cardBack: some View {
|
|
let imageName = cardBackDesign.imageName
|
|
|
|
return ZStack {
|
|
RoundedRectangle(cornerRadius: size.width * 0.08)
|
|
.fill(Color.blue)
|
|
|
|
if let uiImage = loadImage(named: imageName) {
|
|
Image(uiImage: uiImage)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.padding(2)
|
|
.accessibilityHidden(true)
|
|
} else {
|
|
RoundedRectangle(cornerRadius: size.width * 0.08)
|
|
.strokeBorder(Color.white.opacity(0.3), lineWidth: 2)
|
|
.padding(3)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var emptySlot: some View {
|
|
RoundedRectangle(cornerRadius: size.width * 0.08)
|
|
.strokeBorder(Color.white.opacity(0.3), lineWidth: 2)
|
|
}
|
|
|
|
#if os(macOS)
|
|
private func loadImage(named name: String) -> NSImage? {
|
|
NSImage(named: name)
|
|
}
|
|
|
|
private func Image(uiImage: NSImage) -> SwiftUI.Image {
|
|
SwiftUI.Image(nsImage: uiImage)
|
|
}
|
|
#else
|
|
private func loadImage(named name: String) -> UIImage? {
|
|
UIImage(named: name)
|
|
}
|
|
|
|
private func Image(uiImage: UIImage) -> SwiftUI.Image {
|
|
SwiftUI.Image(uiImage: uiImage)
|
|
}
|
|
#endif
|
|
}
|