diff --git a/ComputerSolitaire/Animation/UndoAnimationCoordinator.swift b/ComputerSolitaire/Animation/UndoAnimationCoordinator.swift index 12b748f..9d35e8b 100644 --- a/ComputerSolitaire/Animation/UndoAnimationCoordinator.swift +++ b/ComputerSolitaire/Animation/UndoAnimationCoordinator.swift @@ -39,7 +39,12 @@ enum UndoAnimationCoordinator { case .drawFromStock: for (index, id) in cardIDs.enumerated() { guard let card = beforeCards[id] ?? afterCards[id] else { continue } - guard let startFrame = cardFrames[id] ?? wasteAnchorFrame(for: index, totalCards: cardIDs.count, stockFrame: stockFrame, wasteFrame: wasteFrame) else { + guard let startFrame = cardFrames[id] ?? wasteAnchorFrame( + for: index, + totalCards: cardIDs.count, + stockFrame: stockFrame, + wasteFrame: wasteFrame + ) else { continue } items.append(UndoAnimationItem(id: id, card: card, startFrame: startFrame, endFrame: startFrame)) @@ -49,7 +54,8 @@ enum UndoAnimationCoordinator { case .recycleWaste: for (index, id) in cardIDs.enumerated() { - guard let card = afterCards[id] ?? beforeCards[id], let startFrame = stockAnchorFrame(for: index, stockFrame: stockFrame) else { + guard let card = afterCards[id] ?? beforeCards[id], + let startFrame = stockAnchorFrame(for: index, stockFrame: stockFrame) else { continue } items.append(UndoAnimationItem(id: id, card: card, startFrame: startFrame, endFrame: startFrame)) diff --git a/ComputerSolitaire/ComputerSolitaireApp.swift b/ComputerSolitaire/ComputerSolitaireApp.swift index e5b2de4..1a6a2f5 100644 --- a/ComputerSolitaire/ComputerSolitaireApp.swift +++ b/ComputerSolitaire/ComputerSolitaireApp.swift @@ -74,11 +74,14 @@ struct ComputerSolitaireApp: App { private var appCommands: some Commands { #if os(macOS) CommandGroup(replacing: .appInfo) { - Button(action: { - openWindow(id: "about") - }) { - Label("About Computer Solitaire", systemImage: "info.circle") - } + Button( + action: { + openWindow(id: "about") + }, + label: { + Label("About Computer Solitaire", systemImage: "info.circle") + } + ) } CommandGroup(replacing: .help) { Button { diff --git a/ComputerSolitaire/Game/FreeCell/FreeCellSolver.swift b/ComputerSolitaire/Game/FreeCell/FreeCellSolver.swift index faebf03..27b9bae 100644 --- a/ComputerSolitaire/Game/FreeCell/FreeCellSolver.swift +++ b/ComputerSolitaire/Game/FreeCell/FreeCellSolver.swift @@ -451,12 +451,12 @@ private extension FreeCellSolver { // Cascade → cascade (supermoves included; the fitting length is unique per pair). for source in board.cascades.indices { let cascade = board.cascades[source] - guard !cascade.isEmpty else { continue } + guard let sourceTop = cascade.last else { continue } let runLength = topRunLength(of: cascade) for destination in board.cascades.indices where destination != source { guard let top = board.cascades[destination].last else { continue } - let neededCount = rank(top) - rank(cascade.last!) + let neededCount = rank(top) - rank(sourceTop) guard neededCount >= 1, neededCount <= runLength, neededCount <= transferCap else { continue } let bottomMoving = cascade[cascade.count - neededCount] if rank(bottomMoving) == rank(top) - 1, isRed(bottomMoving) != isRed(top) { diff --git a/ComputerSolitaire/Game/Klondike/KlondikePlanner.swift b/ComputerSolitaire/Game/Klondike/KlondikePlanner.swift index 2283dae..0f630c4 100644 --- a/ComputerSolitaire/Game/Klondike/KlondikePlanner.swift +++ b/ComputerSolitaire/Game/Klondike/KlondikePlanner.swift @@ -43,9 +43,10 @@ enum KlondikePlanner { let node = nodes[nodeIndex] if node.score > rootScore { - if best == nil - || node.score > best!.score - || (node.score == best!.score && node.depth < best!.depth) { + let improvesBest = best.map { + node.score > $0.score || (node.score == $0.score && node.depth < $0.depth) + } ?? true + if improvesBest { best = (nodeIndex, node.score, node.depth) } if isWon(node.state) { break } diff --git a/ComputerSolitaire/Game/Shared/Card.swift b/ComputerSolitaire/Game/Shared/Card.swift index f67f0df..4595106 100644 --- a/ComputerSolitaire/Game/Shared/Card.swift +++ b/ComputerSolitaire/Game/Shared/Card.swift @@ -27,6 +27,15 @@ enum Suit: CaseIterable, Codable { return "suit.club.fill" } } + + var accessibilityName: String { + switch self { + case .spades: "Spades" + case .hearts: "Hearts" + case .diamonds: "Diamonds" + case .clubs: "Clubs" + } + } } enum Rank: Int, CaseIterable, Comparable, Codable { @@ -62,6 +71,24 @@ enum Rank: Int, CaseIterable, Comparable, Codable { return String(rawValue) } } + + var accessibilityName: String { + switch self { + case .ace: "Ace" + case .two: "Two" + case .three: "Three" + case .four: "Four" + case .five: "Five" + case .six: "Six" + case .seven: "Seven" + case .eight: "Eight" + case .nine: "Nine" + case .ten: "Ten" + case .jack: "Jack" + case .queen: "Queen" + case .king: "King" + } + } } struct Card: Identifiable, Equatable, Codable { @@ -79,6 +106,11 @@ struct Card: Identifiable, Equatable, Codable { } extension Card { + var accessibilityName: String { + guard isFaceUp else { return "Face-down card" } + return "\(rank.accessibilityName) of \(suit.accessibilityName)" + } + static func fullDeck() -> [Card] { var deck: [Card] = [] for suit in Suit.allCases { diff --git a/ComputerSolitaire/Game/Shared/GamePersistence.swift b/ComputerSolitaire/Game/Shared/GamePersistence.swift index 0a72671..24e332e 100644 --- a/ComputerSolitaire/Game/Shared/GamePersistence.swift +++ b/ComputerSolitaire/Game/Shared/GamePersistence.swift @@ -1,7 +1,6 @@ import Foundation import SwiftData - @Model final class SavedGameRecord { static let currentRecordKey = "current" @@ -227,7 +226,6 @@ struct SavedGamePayload: Codable { } } - enum GamePersistenceError: Error { case invalidPayload } @@ -321,7 +319,10 @@ struct GameStatistics: Codable, Equatable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - let decodedSchemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? Self.currentSchemaVersion + let decodedSchemaVersion = try container.decodeIfPresent( + Int.self, + forKey: .schemaVersion + ) ?? Self.currentSchemaVersion let decodedGamesPlayed = max(0, try container.decodeIfPresent(Int.self, forKey: .gamesPlayed) ?? 0) let decodedGamesWon = max( 0, diff --git a/ComputerSolitaire/Game/Shared/GameSession.swift b/ComputerSolitaire/Game/Shared/GameSession.swift index 9af52f7..42424dc 100644 --- a/ComputerSolitaire/Game/Shared/GameSession.swift +++ b/ComputerSolitaire/Game/Shared/GameSession.swift @@ -563,7 +563,10 @@ extension SolitaireViewModel { switch destination { case .foundation(let index): guard selection.cards.count == 1 else { return false } - guard GameRules.canMoveToFoundation(card: movingCard, foundation: state.foundations[index]) else { return false } + guard GameRules.canMoveToFoundation( + card: movingCard, + foundation: state.foundations[index] + ) else { return false } clearHint() pushHistory( undoContext: UndoAnimationContext( diff --git a/ComputerSolitaire/Game/Shared/TapMovePolicy.swift b/ComputerSolitaire/Game/Shared/TapMovePolicy.swift index 99216df..63d2560 100644 --- a/ComputerSolitaire/Game/Shared/TapMovePolicy.swift +++ b/ComputerSolitaire/Game/Shared/TapMovePolicy.swift @@ -17,7 +17,7 @@ enum TapMovePolicy { var best: (destination: Destination, priority: Priority)? for destination in destinations { let priority = priority(of: destination, for: selection, in: state) - if best == nil || priority.isBetter(than: best!.priority) { + if best.map({ priority.isBetter(than: $0.priority) }) ?? true { best = (destination, priority) } } @@ -33,7 +33,7 @@ enum TapMovePolicy { if case .foundation = selection.source { continue } for destination in AutoMoveAdvisor.legalDestinations(for: selection, in: state) { let priority = priority(of: destination, for: selection, in: state) - if best == nil || priority.isBetter(than: best!.priority) { + if best.map({ priority.isBetter(than: $0.priority) }) ?? true { best = (selection, destination, priority) } } diff --git a/ComputerSolitaire/Views/AboutView.swift b/ComputerSolitaire/Views/AboutView.swift index d6601df..bb2e326 100644 --- a/ComputerSolitaire/Views/AboutView.swift +++ b/ComputerSolitaire/Views/AboutView.swift @@ -5,7 +5,7 @@ import SwiftUI enum AppInfo { static let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "—" static let copyrightYear = String(Calendar.current.component(.year, from: Date())) - static let githubURL = URL(string: "https://github.com/austin-smith/ComputerSolitaire")! + static let githubURL = URL(string: "https://github.com/austin-smith/ComputerSolitaire") } struct AboutView: View { @@ -30,7 +30,10 @@ struct AboutView: View { .foregroundStyle(.secondary) } - Text("Computer Solitaire is a low-frills, ad-free solitaire game for your computer. Includes Klondike, FreeCell, and other things you enjoy.") + Text( + "Computer Solitaire is a low-frills, ad-free solitaire game for your computer. " + + "Includes Klondike, FreeCell, and other things you enjoy." + ) .font(.system(size: 14)) .foregroundStyle(.primary) .multilineTextAlignment(.center) @@ -58,9 +61,11 @@ struct AboutView: View { Divider() .padding(.horizontal, 24) - Link("GitHub", destination: AppInfo.githubURL) - .buttonStyle(.bordered) - .controlSize(.regular) + if let githubURL = AppInfo.githubURL { + Link("GitHub", destination: githubURL) + .buttonStyle(.bordered) + .controlSize(.regular) + } } } .frame(maxWidth: .infinity) @@ -83,7 +88,7 @@ struct AboutView: View { @Environment(\.openURL) private var openURL private var appVersion: String { - Bundle.main.infoDictionary!["CFBundleShortVersionString"] as! String + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "—" } private var copyrightYear: String { @@ -101,6 +106,7 @@ struct AboutView: View { .frame(width: 100, height: 100) .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 6) + .accessibilityHidden(true) VStack(spacing: 3) { Text("Computer Solitaire") @@ -113,7 +119,10 @@ struct AboutView: View { } } - Text("Computer Solitaire is a low-frills, ad-free solitaire game for your computer. Includes Klondike, FreeCell, and other things you enjoy.") + Text( + "Computer Solitaire is a low-frills, ad-free solitaire game for your computer. " + + "Includes Klondike, FreeCell, and other things you enjoy." + ) .font(.system(size: 11)) .foregroundStyle(.primary) .multilineTextAlignment(.center) diff --git a/ComputerSolitaire/Views/AppIconPickerView.swift b/ComputerSolitaire/Views/AppIconPickerView.swift index a7c5f34..ee3fa0f 100644 --- a/ComputerSolitaire/Views/AppIconPickerView.swift +++ b/ComputerSolitaire/Views/AppIconPickerView.swift @@ -49,6 +49,7 @@ struct AppIconPreviewView: View { .scaledToFill() .frame(width: size, height: size) .clipShape(shape) + .accessibilityHidden(true) .overlay { shape.stroke(Color.primary.opacity(0.12), lineWidth: 0.5) } @@ -140,6 +141,7 @@ struct AppIconPickerView: View { .symbolRenderingMode(.palette) .foregroundStyle(.white, Color.accentColor) .padding(6) + .accessibilityHidden(true) } } .contentShape(Rectangle()) diff --git a/ComputerSolitaire/Views/Cards/CardView.swift b/ComputerSolitaire/Views/Cards/CardView.swift index 88d38d0..443b2aa 100644 --- a/ComputerSolitaire/Views/Cards/CardView.swift +++ b/ComputerSolitaire/Views/Cards/CardView.swift @@ -166,6 +166,7 @@ struct CardView: View { let hintWiggleToken: UUID? let flipOnAppear: Bool let flipDelay: Double + let isAccessibilityElement: Bool @State private var flipRotation: Double @State private var tiltAngle: Double = 0 @Environment(\.cardStyle) private var cardStyle @@ -178,7 +179,8 @@ struct CardView: View { cardTilts: Binding<[UUID: Double]>, hintWiggleToken: UUID? = nil, flipOnAppear: Bool = false, - flipDelay: Double = 0 + flipDelay: Double = 0, + isAccessibilityElement: Bool = true ) { self.card = card self.isSelected = isSelected @@ -188,6 +190,7 @@ struct CardView: View { self.hintWiggleToken = hintWiggleToken self.flipOnAppear = flipOnAppear self.flipDelay = flipDelay + self.isAccessibilityElement = isAccessibilityElement let startFaceDown = flipOnAppear && card.isFaceUp _flipRotation = State(initialValue: startFaceDown ? 180 : (card.isFaceUp ? 0 : 180)) } @@ -208,6 +211,9 @@ struct CardView: View { .rotation3DEffect(.degrees(backAngle), axis: (x: 0, y: 1, z: 0), perspective: 0.7) } .frame(width: cardSize.width, height: cardSize.height) + .accessibilityElement(children: .ignore) + .accessibilityLabel(card.accessibilityName) + .accessibilityHidden(!isAccessibilityElement) .rotationEffect(.degrees(tiltAngle)) .hintWiggle(token: hintWiggleToken) .scaleEffect(isSelected ? 1.03 : 1) diff --git a/ComputerSolitaire/Views/Cards/Styles/ClassicCardViews.swift b/ComputerSolitaire/Views/Cards/Styles/ClassicCardViews.swift index 9222829..bc70da6 100644 --- a/ComputerSolitaire/Views/Cards/Styles/ClassicCardViews.swift +++ b/ComputerSolitaire/Views/Cards/Styles/ClassicCardViews.swift @@ -69,6 +69,7 @@ struct ClassicCardFrontView: View { Spacer(minLength: 0) Image(systemName: card.suit.symbolName) .font(.system(size: cardSize.width * 0.2, weight: .semibold)) + .accessibilityHidden(true) } ZStack { @@ -114,6 +115,7 @@ struct ClassicCardFrontView: View { .foregroundStyle(inkColor.opacity(0.12)) .rotationEffect(.degrees(8)) .frame(width: cardSize.width, height: cardSize.height, alignment: Alignment.center) + .accessibilityHidden(true) } } } diff --git a/ComputerSolitaire/Views/Cards/Styles/PixelCardViews.swift b/ComputerSolitaire/Views/Cards/Styles/PixelCardViews.swift index 603b569..dc9601d 100644 --- a/ComputerSolitaire/Views/Cards/Styles/PixelCardViews.swift +++ b/ComputerSolitaire/Views/Cards/Styles/PixelCardViews.swift @@ -159,7 +159,7 @@ struct PixelSprite { init(_ art: String) { let map: [Character: UInt8] = [ ".": 0, "#": 1, "+": 2, "K": 3, "S": 4, "s": 5, - "G": 6, "R": 7, "D": 8, "H": 9, "W": 10, "A": 11, "B": 12, + "G": 6, "R": 7, "D": 8, "H": 9, "W": 10, "A": 11, "B": 12 ] let lines = art.split(separator: "\n").map(String.init) let w = lines.map(\.count).max() ?? 0 @@ -269,16 +269,18 @@ enum PixelSprites { } // Rank glyphs — 5x7 ("10" is 7 wide). + static let aceRank = PixelSprite(""" + .###. + #...# + #...# + ##### + #...# + #...# + #...# + """) + static let ranks: [String: PixelSprite] = [ - "A": PixelSprite(""" - .###. - #...# - #...# - ##### - #...# - #...# - #...# - """), + "A": aceRank, "2": PixelSprite(""" .###. #...# @@ -386,11 +388,11 @@ enum PixelSprites { #.#.. #..#. #...# - """), + """) ] static func rank(_ rank: Rank) -> PixelSprite { - ranks[rank.label] ?? ranks["A"]! + ranks[rank.label] ?? aceRank } // Face card portraits — 28x35, outlined forms in the classic style. diff --git a/ComputerSolitaire/Views/Cards/Styles/SimpleCardViews.swift b/ComputerSolitaire/Views/Cards/Styles/SimpleCardViews.swift index 034b3bc..c4b609f 100644 --- a/ComputerSolitaire/Views/Cards/Styles/SimpleCardViews.swift +++ b/ComputerSolitaire/Views/Cards/Styles/SimpleCardViews.swift @@ -88,6 +88,7 @@ struct SimpleCardFrontView: View { Spacer(minLength: 0) Image(systemName: card.suit.symbolName) .font(.system(size: cardSize.width * 0.22, weight: .semibold)) + .accessibilityHidden(true) } .foregroundStyle(inkColor) .padding(cardSize.width * 0.08) @@ -108,6 +109,7 @@ struct SimpleCardFrontView: View { y: cardSize.width * (isJack ? 0.36 : 0.32)) .frame(width: cardSize.width, height: cardSize.height, alignment: Alignment.bottomTrailing) .clipShape(RoundedRectangle(cornerRadius: chrome.cornerRadius, style: .continuous)) + .accessibilityHidden(true) } else { // Optically centered in the region below the top marks, not // the full card, so the face doesn't read bottom-heavy. @@ -116,6 +118,7 @@ struct SimpleCardFrontView: View { .foregroundStyle(inkColor) .offset(y: cardSize.width * 0.14) .frame(width: cardSize.width, height: cardSize.height, alignment: Alignment.center) + .accessibilityHidden(true) } } } diff --git a/ComputerSolitaire/Views/FreeCell/FreeCellSlotView.swift b/ComputerSolitaire/Views/FreeCell/FreeCellSlotView.swift index 649678d..e507399 100644 --- a/ComputerSolitaire/Views/FreeCell/FreeCellSlotView.swift +++ b/ComputerSolitaire/Views/FreeCell/FreeCellSlotView.swift @@ -17,6 +17,14 @@ struct FreeCellView: View { var body: some View { let card = viewModel.state.freeCells[index] + let accessibleCard: Card? = card.flatMap { card in + let isHidden = hiddenCardIDs.contains(card.id) + let isDragged = viewModel.isDragging && viewModel.isSelected(card: card) + return isHidden || isDragged ? nil : card + } + let isAccessibleCardSelected = accessibleCard.map { + viewModel.isSelected(card: $0) + } ?? false let isDragSource: Bool = { guard viewModel.isDragging, let selection = viewModel.selection else { return false } if case .freeCell(let slot) = selection.source { @@ -40,9 +48,13 @@ struct FreeCellView: View { cardSize: cardSize, isCardTiltEnabled: isCardTiltEnabled, cardTilts: $cardTilts, - hintWiggleToken: hintedCardIDs.contains(card.id) ? hintWiggleToken : nil + hintWiggleToken: hintedCardIDs.contains(card.id) ? hintWiggleToken : nil, + isAccessibilityElement: false + ) + .opacity( + (viewModel.isDragging && viewModel.isSelected(card: card)) + || hiddenCardIDs.contains(card.id) ? 0 : 1 ) - .opacity((viewModel.isDragging && viewModel.isSelected(card: card)) || hiddenCardIDs.contains(card.id) ? 0 : 1) .gesture(dragGesture(.freeCell(index))) .cardFramePreference(card.id) } @@ -50,6 +62,9 @@ struct FreeCellView: View { .onTapGesture { viewModel.handleFreeCellTap(index: index) } + .accessibilityElement(children: .ignore) + .accessibilityAddTraits(.isButton) + .accessibilityAddTraits(isAccessibleCardSelected ? .isSelected : []) .background( GeometryReader { proxy in let boardFrame = proxy.frame(in: .named("board")) @@ -72,5 +87,6 @@ struct FreeCellView: View { ) .zIndex(isDragSource ? 10 : 0) .accessibilityLabel("Free Cell \(index + 1)") + .accessibilityValue(accessibleCard?.accessibilityName ?? "Empty") } } diff --git a/ComputerSolitaire/Views/Klondike/KlondikeStockWasteViews.swift b/ComputerSolitaire/Views/Klondike/KlondikeStockWasteViews.swift index 11ab8ec..04ae953 100644 --- a/ComputerSolitaire/Views/Klondike/KlondikeStockWasteViews.swift +++ b/ComputerSolitaire/Views/Klondike/KlondikeStockWasteViews.swift @@ -9,41 +9,60 @@ struct StockView: View { let hintWiggleToken: UUID var body: some View { - ZStack { - PilePlaceholderView(cardSize: cardSize) + Button { + viewModel.handleStockTap() + } label: { + ZStack { + PilePlaceholderView(cardSize: cardSize) + .allowsHitTesting(false) + if viewModel.state.stock.isEmpty { + Image(systemName: "arrow.counterclockwise") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(.white.opacity(0.7)) + .accessibilityHidden(true) + } else { + CardBackView(cardSize: cardSize) + } + Text("\(viewModel.state.stock.count)") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.white.opacity(0.8)) + .offset(x: cardSize.width * 0.28, y: cardSize.height * 0.38) + + DropHighlightView( + cardSize: cardSize, + isTargeted: false, + isHintTargeted: isHintTargeted, + hintOpacity: hintHighlightOpacity + ) .allowsHitTesting(false) - if viewModel.state.stock.isEmpty { - Image(systemName: "arrow.counterclockwise") - .font(.system(size: 20, weight: .semibold)) - .foregroundStyle(.white.opacity(0.7)) - } else { - CardBackView(cardSize: cardSize) } - Text("\(viewModel.state.stock.count)") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.white.opacity(0.8)) - .offset(x: cardSize.width * 0.28, y: cardSize.height * 0.38) - - DropHighlightView( - cardSize: cardSize, - isTargeted: false, - isHintTargeted: isHintTargeted, - hintOpacity: hintHighlightOpacity + .hintWiggle(token: isHintTargeted ? hintWiggleToken : nil) + .background( + GeometryReader { proxy in + Color.clear + .preference(key: StockFrameKey.self, value: proxy.frame(in: .named("board"))) + } ) - .allowsHitTesting(false) - } - .hintWiggle(token: isHintTargeted ? hintWiggleToken : nil) - .background( - GeometryReader { proxy in - Color.clear - .preference(key: StockFrameKey.self, value: proxy.frame(in: .named("board"))) - } - ) - .contentShape(Rectangle()) - .onTapGesture { - viewModel.handleStockTap() + .contentShape(Rectangle()) } + .buttonStyle(.plain) + .disabled(isStockActionUnavailable) .accessibilityLabel("Stock") + .accessibilityValue(stockAccessibilityValue) + } + + private var isStockActionUnavailable: Bool { + viewModel.state.stock.isEmpty && viewModel.state.waste.isEmpty + } + + private var stockAccessibilityValue: String { + if !viewModel.state.stock.isEmpty { + return "\(viewModel.state.stock.count) cards" + } + if !viewModel.state.waste.isEmpty { + return "Empty. Activate to recycle the waste pile" + } + return "Empty" } } @@ -70,6 +89,14 @@ struct WasteView: View { return false }() let visibleWaste = viewModel.visibleWasteCards() + let accessibleTopCard: Card? = visibleWaste.last.flatMap { card in + let isDragged = viewModel.isDragging && viewModel.isSelected(card: card) + let isUnavailable = isDragged || drawingCardIDs.contains(card.id) || hiddenCardIDs.contains(card.id) + return isUnavailable ? nil : card + } + let isAccessibleTopCardSelected = accessibleTopCard.map { + viewModel.isSelected(card: $0) + } ?? false let isSelected = visibleWaste.contains(where: { viewModel.isSelected(card: $0) }) let fanWidth = fanSpacing * CGFloat(max(0, visibleWaste.count - 1)) @@ -89,7 +116,8 @@ struct WasteView: View { cardSize: cardSize, isCardTiltEnabled: isCardTiltEnabled, cardTilts: $cardTilts, - hintWiggleToken: hintedCardIDs.contains(card.id) ? hintWiggleToken : nil + hintWiggleToken: hintedCardIDs.contains(card.id) ? hintWiggleToken : nil, + isAccessibilityElement: false ) .opacity(isDragged || isDrawing || isHidden ? 0 : 1) .offset(x: xOffset, y: 0) @@ -115,6 +143,11 @@ struct WasteView: View { viewModel.handleWasteTap() } .zIndex(isDragSource || isSelected ? 10 : 0) + .accessibilityElement(children: .ignore) .accessibilityLabel("Waste") + .accessibilityValue(accessibleTopCard?.accessibilityName ?? "Empty") + .accessibilityAddTraits(.isButton) + .accessibilityAddTraits(isAccessibleTopCardSelected ? .isSelected : []) + .accessibilityHidden(accessibleTopCard == nil) } } diff --git a/ComputerSolitaire/Views/RulesAndScoringView.swift b/ComputerSolitaire/Views/RulesAndScoringView.swift index 6951ebc..b44b8d5 100644 --- a/ComputerSolitaire/Views/RulesAndScoringView.swift +++ b/ComputerSolitaire/Views/RulesAndScoringView.swift @@ -188,7 +188,11 @@ struct RulesAndScoringView: View { Text("On win, a time bonus is added.") .font(.caption) .foregroundStyle(.secondary) - Text("Time bonus starts at \(Scoring.timedMaxBonusDrawOne) in 1-card draw and \(Scoring.timedMaxBonusDrawThree) in 3-card draw, then drops by \(Scoring.timedPointsLostPerSecond) point per second.") + Text( + "Time bonus starts at \(Scoring.timedMaxBonusDrawOne) in 1-card draw and " + + "\(Scoring.timedMaxBonusDrawThree) in 3-card draw, then drops by " + + "\(Scoring.timedPointsLostPerSecond) point per second." + ) .font(.caption) .foregroundStyle(.secondary) Text("Score cannot go below \(Scoring.minimumScore).") @@ -223,7 +227,10 @@ struct RulesAndScoringView: View { TermRow(term: "Cascade", definition: "One of eight tableau columns where all cards are face up."), TermRow(term: "Free Cell", definition: "A temporary single-card holding slot (four total)."), TermRow(term: "Foundation", definition: "Four suit piles built from Ace to King."), - TermRow(term: "Supermove", definition: "A multi-card move enabled by available free cells and empty cascades.") + TermRow( + term: "Supermove", + definition: "A multi-card move enabled by available free cells and empty cascades." + ) ] } } @@ -258,7 +265,11 @@ struct RulesAndScoringView: View { case .freecell: return [ ScoringRow(move: "Move cards", points: 0, note: "FreeCell currently tracks time and completion."), - ScoringRow(move: "Win time bonus", points: Scoring.timedMaxBonusDrawThree, note: "Reduced by elapsed time.") + ScoringRow( + move: "Win time bonus", + points: Scoring.timedMaxBonusDrawThree, + note: "Reduced by elapsed time." + ) ] } } diff --git a/ComputerSolitaire/Views/SettingsView.swift b/ComputerSolitaire/Views/SettingsView.swift index 17a5f6d..064893a 100644 --- a/ComputerSolitaire/Views/SettingsView.swift +++ b/ComputerSolitaire/Views/SettingsView.swift @@ -62,7 +62,8 @@ struct SettingsView: View { @AppStorage(SettingsKey.cardTiltEnabled) private var isCardTiltEnabled = true @AppStorage(SettingsKey.gameVariant) private var gameVariantRawValue = GameVariant.klondike.rawValue @AppStorage(SettingsKey.drawMode) private var drawModeRawValue = DrawMode.three.rawValue - @AppStorage(SettingsKey.tableBackgroundColor) private var tableBackgroundColorRawValue = TableBackgroundColor.defaultValue.rawValue + @AppStorage(SettingsKey.tableBackgroundColor) + private var tableBackgroundColorRawValue = TableBackgroundColor.defaultValue.rawValue @AppStorage(SettingsKey.feltEffectEnabled) private var isFeltEffectEnabled = true @AppStorage(SettingsKey.soundEffectsEnabled) private var isSoundEffectsEnabled = true @AppStorage(SettingsKey.showHintButton) private var isHintButtonVisible = true @@ -260,6 +261,7 @@ struct SettingsView: View { Image(systemName: "chevron.right") .font(.footnote.weight(.semibold)) .foregroundStyle(.tertiary) + .accessibilityHidden(true) } .contentShape(Rectangle()) } @@ -282,6 +284,7 @@ struct SettingsView: View { Image(systemName: "chevron.right") .font(.footnote.weight(.semibold)) .foregroundStyle(.tertiary) + .accessibilityHidden(true) } .contentShape(Rectangle()) } @@ -402,6 +405,7 @@ struct SettingsView: View { Image(systemName: "checkmark") .font(.system(size: 11, weight: .bold)) .foregroundStyle(.white) + .accessibilityHidden(true) } } .overlay { @@ -434,6 +438,7 @@ struct SettingsView: View { Image(systemName: "checkmark") .font(.system(size: 11, weight: .bold)) .foregroundStyle(.white) + .accessibilityHidden(true) } } .overlay { @@ -459,7 +464,11 @@ private extension View { .frame(maxWidth: .infinity) .background { RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(isSelected ? AnyShapeStyle(Color.accentColor.opacity(0.12)) : AnyShapeStyle(.quaternary.opacity(0.5))) + .fill( + isSelected + ? AnyShapeStyle(Color.accentColor.opacity(0.12)) + : AnyShapeStyle(.quaternary.opacity(0.5)) + ) } .overlay { RoundedRectangle(cornerRadius: 14, style: .continuous) diff --git a/ComputerSolitaire/Views/Shared/BoardOverlayViews.swift b/ComputerSolitaire/Views/Shared/BoardOverlayViews.swift index 66ec172..8043c08 100644 --- a/ComputerSolitaire/Views/Shared/BoardOverlayViews.swift +++ b/ComputerSolitaire/Views/Shared/BoardOverlayViews.swift @@ -37,7 +37,8 @@ struct UndoOverlayView: View { isSelected: false, cardSize: item.startFrame.size, isCardTiltEnabled: false, - cardTilts: .constant([:]) + cardTilts: .constant([:]), + isAccessibilityElement: false ) .position(x: currentX, y: currentY) } @@ -57,7 +58,8 @@ struct WinCascadeOverlayView: View { isSelected: false, cardSize: item.size, isCardTiltEnabled: false, - cardTilts: .constant([:]) + cardTilts: .constant([:]), + isAccessibilityElement: false ) .rotationEffect(.degrees(item.rotationDegrees)) .position(item.position) @@ -92,7 +94,8 @@ private struct DrawOverlayCardView: View { cardTilts: $cardTilts, flipOnAppear: true, // The packet flips in the air while it travels and spreads. - flipDelay: delay + flipDelay: delay, + isAccessibilityElement: false ) .position(x: currentX, y: currentY) .onAppear { @@ -143,7 +146,8 @@ struct DragOverlayView: View { isSelected: true, cardSize: frame.size, isCardTiltEnabled: false, - cardTilts: .constant([:]) + cardTilts: .constant([:]), + isAccessibilityElement: false ) .rotationEffect(.degrees(overlayTilt)) .position(x: frame.midX, y: frame.midY) diff --git a/ComputerSolitaire/Views/Shared/BoardViews.swift b/ComputerSolitaire/Views/Shared/BoardViews.swift index b9048e7..65fb301 100644 --- a/ComputerSolitaire/Views/Shared/BoardViews.swift +++ b/ComputerSolitaire/Views/Shared/BoardViews.swift @@ -106,7 +106,9 @@ enum Layout { } else if isCompactBoard && boardSize.height > boardSize.width { // Portrait phones have far more height than the width-fitted cards // use; spread the worst-case pile into it, capped for readability. - let fittedFaceUp = (tableauMaxHeight - cardSize.height - maxFaceDownGaps * baseFaceDownOffset) / maxFaceUpGaps + let fittedFaceUp = ( + tableauMaxHeight - cardSize.height - maxFaceDownGaps * baseFaceDownOffset + ) / maxFaceUpGaps faceUpOffset = min(max(baseFaceUpOffset, fittedFaceUp), cardSize.height * 0.38) faceDownOffset = baseFaceDownOffset } else { @@ -415,6 +417,13 @@ struct FoundationView: View { } return false }() + let accessibleTopCard: Card? = foundation.last.flatMap { card in + let isDragged = viewModel.isDragging && viewModel.isSelected(card: card) + return isDragged || hiddenCardIDs.contains(card.id) ? nil : card + } + let isAccessibleTopCardSelected = accessibleTopCard.map { + viewModel.isSelected(card: $0) + } ?? false let highlightZ: Double = 1 ZStack { PilePlaceholderView(cardSize: cardSize) @@ -441,7 +450,8 @@ struct FoundationView: View { cardSize: cardSize, isCardTiltEnabled: isCardTiltEnabled, cardTilts: $cardTilts, - hintWiggleToken: hintedCardIDs.contains(card.id) ? hintWiggleToken : nil + hintWiggleToken: hintedCardIDs.contains(card.id) ? hintWiggleToken : nil, + isAccessibilityElement: false ) .opacity(isDragged || isHidden ? 0 : 1) .zIndex(isTopCard && isDragged ? 20 : 0) @@ -460,6 +470,9 @@ struct FoundationView: View { .onTapGesture { viewModel.handleFoundationTap(index: index) } + .accessibilityElement(children: .ignore) + .accessibilityAddTraits(.isButton) + .accessibilityAddTraits(isAccessibleTopCardSelected ? .isSelected : []) .background( GeometryReader { proxy in let boardFrame = proxy.frame(in: .named("board")) @@ -482,6 +495,7 @@ struct FoundationView: View { ) .zIndex(isDragSource ? 10 : 0) .accessibilityLabel("Foundation \(index + 1)") + .accessibilityValue(accessibleTopCard?.accessibilityName ?? "Empty") } } @@ -537,6 +551,10 @@ struct TableauPileView: View { .onTapGesture { viewModel.handleTableauTap(pileIndex: pileIndex, cardIndex: nil) } + .accessibilityAddTraits(.isButton) + .accessibilityLabel("Tableau \(pileIndex + 1)") + .accessibilityValue("Empty") + .accessibilityHidden(!pile.isEmpty) PilePlaceholderView(cardSize: cardSize) DropHighlightView( @@ -551,14 +569,30 @@ struct TableauPileView: View { ForEach(Array(pile.enumerated()), id: \.element.id) { index, card in let isDragged = viewModel.isDragging && viewModel.isSelected(card: card) let isHidden = hiddenCardIDs.contains(card.id) + let isSelected = viewModel.isSelected(card: card) + let selectableCards = Array(pile[index...]) + let isValidRunOrigin = card.isFaceUp + && GameRules.isValidDescendingAlternatingSequence(selectableCards) + let isExposedFaceDownCard = viewModel.state.variant == .klondike + && !card.isFaceUp + && index == pile.indices.last + let isAccessibilityElement = (isValidRunOrigin || isExposedFaceDownCard) + && !isDragged + && !isHidden + let accessibilityHint = isExposedFaceDownCard + ? "Flip card" + : selectableCards.count > 1 + ? "Selects a \(selectableCards.count)-card run" + : "Selects this card" let yOffset = yOffsets[index] let cardView = CardView( card: card, - isSelected: viewModel.isSelected(card: card), + isSelected: isSelected, cardSize: cardSize, isCardTiltEnabled: isCardTiltEnabled, cardTilts: $cardTilts, - hintWiggleToken: hintedCardIDs.contains(card.id) ? hintWiggleToken : nil + hintWiggleToken: hintedCardIDs.contains(card.id) ? hintWiggleToken : nil, + isAccessibilityElement: isAccessibilityElement ) .opacity(isDragged || isHidden ? 0 : 1) .offset(x: 0, y: yOffset) @@ -567,6 +601,9 @@ struct TableauPileView: View { .onTapGesture { viewModel.handleTableauTap(pileIndex: pileIndex, cardIndex: index) } + .accessibilityAddTraits(.isButton) + .accessibilityAddTraits(isSelected ? .isSelected : []) + .accessibilityHint(accessibilityHint) .cardFramePreference(card.id, yOffset: yOffset) cardView.gesture(dragGesture(.tableau(pile: pileIndex, index: index))) @@ -608,7 +645,6 @@ struct TableauPileView: View { } ) .zIndex(isDragSource ? 10 : 0) - .accessibilityLabel("Tableau \(pileIndex + 1)") } else { Color.clear .frame(width: cardSize.width, height: cardSize.height) @@ -685,11 +721,13 @@ struct DropHighlightView: View { } struct TableBackground: View { - @AppStorage(SettingsKey.tableBackgroundColor) private var tableBackgroundColorRawValue = TableBackgroundColor.defaultValue.rawValue + @AppStorage(SettingsKey.tableBackgroundColor) + private var tableBackgroundColorRawValue = TableBackgroundColor.defaultValue.rawValue @AppStorage(SettingsKey.feltEffectEnabled) private var feltEffectEnabled = true var body: some View { - let baseColor = (TableBackgroundColor(rawValue: tableBackgroundColorRawValue) ?? TableBackgroundColor.defaultValue).color + let background = TableBackgroundColor(rawValue: tableBackgroundColorRawValue) ?? .defaultValue + let baseColor = background.color Group { if feltEffectEnabled { GeometryReader { proxy in diff --git a/ComputerSolitaire/Views/Shared/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift index b3ddd4e..91c81b3 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -73,7 +73,6 @@ extension View { } } - struct ContentView: View { @Environment(\.modelContext) private var modelContext @Environment(\.scenePhase) private var scenePhase @@ -439,7 +438,10 @@ struct ContentView: View { availableWidth: geometry.size.width, requiredWidth: boardContentWidth + (metrics.horizontalPadding * 2) ) - let effectiveCardSize = CGSize(width: cardSize.width * boardScaleFactor, height: cardSize.height * boardScaleFactor) + let effectiveCardSize = CGSize( + width: cardSize.width * boardScaleFactor, + height: cardSize.height * boardScaleFactor + ) let isBoardReady = hasLoadedGame && !isHydratingGame let hintedTarget: DropTarget? = { guard let destination = viewModel.hintedDestination else { return nil } @@ -1091,7 +1093,11 @@ struct ContentView: View { return } - let (startingItems, targets, needsPostUndoFrames): ([UndoAnimationItem], [UUID: UndoAnimationEndTarget], Bool) = { + let (startingItems, targets, needsPostUndoFrames): ( + [UndoAnimationItem], + [UUID: UndoAnimationEndTarget], + Bool + ) = { if let context = snapshot.undoContext { return buildUndoAnimationPlan( context: context, @@ -1180,7 +1186,8 @@ struct ContentView: View { private func resolveUndoAnimationTargets(attemptsRemaining: Int) { let resolvedItems = undoAnimationItems.compactMap { item -> UndoAnimationItem? in - guard let target = undoAnimationTargets[item.id], let endFrame = resolveUndoTargetFrame(target) else { return nil } + guard let target = undoAnimationTargets[item.id], + let endFrame = resolveUndoTargetFrame(target) else { return nil } return UndoAnimationItem(id: item.id, card: item.card, startFrame: item.startFrame, endFrame: endFrame) } diff --git a/ComputerSolitaire/Views/StatisticsView.swift b/ComputerSolitaire/Views/StatisticsView.swift index 43a14eb..adee71d 100644 --- a/ComputerSolitaire/Views/StatisticsView.swift +++ b/ComputerSolitaire/Views/StatisticsView.swift @@ -278,6 +278,7 @@ struct StatisticsView: View { Image(systemName: icon) .font(.subheadline) .foregroundStyle(.secondary) + .accessibilityHidden(true) Text(value) .font(.system(.headline, design: .monospaced, weight: .bold)) Text(label) @@ -285,6 +286,7 @@ struct StatisticsView: View { .foregroundStyle(.secondary) } .frame(maxWidth: .infinity) + .accessibilityElement(children: .combine) } @ViewBuilder diff --git a/ComputerSolitaireTests/Shared/GamePersistenceStoreTests.swift b/ComputerSolitaireTests/Shared/GamePersistenceStoreTests.swift index 5e9e20c..ef07303 100644 --- a/ComputerSolitaireTests/Shared/GamePersistenceStoreTests.swift +++ b/ComputerSolitaireTests/Shared/GamePersistenceStoreTests.swift @@ -44,8 +44,20 @@ final class GamePersistenceStoreTests: XCTestCase { func testSaveOverwritesExistingRecord() throws { let context = try makeInMemoryContext() let state = GameStateFixtures.validPersistenceState() - let first = SavedGamePayload(state: state, movesCount: 1, score: 10, stockDrawCount: DrawMode.three.rawValue, history: []) - let second = SavedGamePayload(state: state, movesCount: 9, score: 90, stockDrawCount: DrawMode.one.rawValue, history: []) + let first = SavedGamePayload( + state: state, + movesCount: 1, + score: 10, + stockDrawCount: DrawMode.three.rawValue, + history: [] + ) + let second = SavedGamePayload( + state: state, + movesCount: 9, + score: 90, + stockDrawCount: DrawMode.one.rawValue, + history: [] + ) try GamePersistence.save(first, in: context) try GamePersistence.save(second, in: context) diff --git a/ComputerSolitaireTests/Shared/GameStatisticsStoreTests.swift b/ComputerSolitaireTests/Shared/GameStatisticsStoreTests.swift index f57469c..c16ef2c 100644 --- a/ComputerSolitaireTests/Shared/GameStatisticsStoreTests.swift +++ b/ComputerSolitaireTests/Shared/GameStatisticsStoreTests.swift @@ -57,8 +57,8 @@ final class GameStatisticsStoreTests: XCTestCase { XCTAssertEqual(stats.cleanWins, Int.max) } - func testStatisticsStoreMarkTrackingStartedAndReset() { - let defaults = makeIsolatedDefaults() + func testStatisticsStoreMarkTrackingStartedAndReset() throws { + let defaults = try makeIsolatedDefaults() defer { defaults.removePersistentDomain(forName: defaultsSuiteName) } GameStatisticsStore.markTrackingStarted( @@ -88,8 +88,8 @@ final class GameStatisticsStoreTests: XCTestCase { XCTAssertEqual(reset.gamesWon, 0) } - func testStatisticsStoreUpdatePersistsMutation() { - let defaults = makeIsolatedDefaults() + func testStatisticsStoreUpdatePersistsMutation() throws { + let defaults = try makeIsolatedDefaults() defer { defaults.removePersistentDomain(forName: defaultsSuiteName) } GameStatisticsStore.update(for: .klondike, userDefaults: defaults) { stats in @@ -110,8 +110,8 @@ final class GameStatisticsStoreTests: XCTestCase { XCTAssertEqual(loaded.bestTimeSeconds, 123) } - func testVariantStoresRemainIsolated() { - let defaults = makeIsolatedDefaults() + func testVariantStoresRemainIsolated() throws { + let defaults = try makeIsolatedDefaults() defer { defaults.removePersistentDomain(forName: defaultsSuiteName) } GameStatisticsStore.update(for: .klondike, userDefaults: defaults) { stats in @@ -193,8 +193,8 @@ final class GameStatisticsStoreTests: XCTestCase { private let defaultsSuiteName = "ComputerSolitaire.GameStatisticsStoreTests" - private func makeIsolatedDefaults() -> UserDefaults { - let defaults = UserDefaults(suiteName: defaultsSuiteName)! + private func makeIsolatedDefaults() throws -> UserDefaults { + let defaults = try XCTUnwrap(UserDefaults(suiteName: defaultsSuiteName)) defaults.removePersistentDomain(forName: defaultsSuiteName) return defaults } diff --git a/ComputerSolitaireUITests/SnapshotHelper.swift b/ComputerSolitaireUITests/SnapshotHelper.swift index eef2cfe..a969e11 100644 --- a/ComputerSolitaireUITests/SnapshotHelper.swift +++ b/ComputerSolitaireUITests/SnapshotHelper.swift @@ -31,7 +31,8 @@ func snapshot(_ name: String, waitForLoadingIndicator: Bool) { /// - Parameters: /// - name: The name of the snapshot -/// - timeout: Amount of seconds to wait until the network loading indicator disappears. Pass `0` if you don't want to wait. +/// - timeout: Amount of seconds to wait until the network loading indicator disappears. +/// Pass `0` if you don't want to wait. @MainActor func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) { Snapshot.snapshot(name, timeWaitingForIdle: timeout) @@ -132,7 +133,8 @@ open class Snapshot: NSObject { do { let launchArguments = try String(contentsOf: path, encoding: String.Encoding.utf8) let regex = try NSRegularExpression(pattern: "(\\\".+?\\\"|\\S+)", options: []) - let matches = regex.matches(in: launchArguments, options: [], range: NSRange(location: 0, length: launchArguments.count)) + let range = NSRange(location: 0, length: launchArguments.count) + let matches = regex.matches(in: launchArguments, options: [], range: range) let results = matches.map { result -> String in (launchArguments as NSString).substring(with: result.range) } @@ -147,7 +149,8 @@ open class Snapshot: NSObject { waitForLoadingIndicatorToDisappear(within: timeout) } - NSLog("snapshot: \(name)") // more information about this, check out https://docs.fastlane.tools/actions/snapshot/#how-does-it-work + // More information: https://docs.fastlane.tools/actions/snapshot/#how-does-it-work + NSLog("snapshot: \(name)") if Snapshot.waitForAnimations { sleep(1) // Waiting for the animation to be finished (kind of) @@ -170,15 +173,19 @@ open class Snapshot: NSObject { let screenshot = XCUIScreen.main.screenshot() #if os(iOS) && !targetEnvironment(macCatalyst) - let image = XCUIDevice.shared.orientation.isLandscape ? fixLandscapeOrientation(image: screenshot.image) : screenshot.image + let image = XCUIDevice.shared.orientation.isLandscape + ? fixLandscapeOrientation(image: screenshot.image) + : screenshot.image #else let image = screenshot.image #endif - guard var simulator = ProcessInfo().environment["SIMULATOR_DEVICE_NAME"], let screenshotsDir = screenshotsDirectory else { return } + guard var simulator = ProcessInfo().environment["SIMULATOR_DEVICE_NAME"], + let screenshotsDir = screenshotsDirectory else { return } do { - // The simulator name contains "Clone X of " inside the screenshot file when running parallelized UI Tests on concurrent devices + // The simulator name contains "Clone X of " inside the screenshot file when + // running parallelized UI tests on concurrent devices. let regex = try NSRegularExpression(pattern: "Clone [0-9]+ of ") let range = NSRange(location: 0, length: simulator.count) simulator = regex.stringByReplacingMatches(in: simulator, range: range, withTemplate: "") @@ -207,7 +214,7 @@ open class Snapshot: NSObject { let format = UIGraphicsImageRendererFormat() format.scale = image.scale let renderer = UIGraphicsImageRenderer(size: image.size, format: format) - return renderer.image { context in + return renderer.image { _ in image.draw(in: CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height)) } } else { @@ -228,7 +235,10 @@ open class Snapshot: NSObject { } let networkLoadingIndicator = app.otherElements.deviceStatusBars.networkLoadingIndicators.element - let networkLoadingIndicatorDisappeared = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: networkLoadingIndicator) + let networkLoadingIndicatorDisappeared = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "exists == false"), + object: networkLoadingIndicator + ) _ = XCTWaiter.wait(for: [networkLoadingIndicatorDisappeared], timeout: timeout) } @@ -256,7 +266,8 @@ private extension XCUIElementAttributes { if hasAllowListedIdentifier { return false } let hasOldLoadingIndicatorSize = frame.size == CGSize(width: 10, height: 20) - let hasNewLoadingIndicatorSize = frame.size.width.isBetween(46, and: 47) && frame.size.height.isBetween(2, and: 3) + let hasNewLoadingIndicatorSize = frame.size.width.isBetween(46, and: 47) + && frame.size.height.isBetween(2, and: 3) return hasOldLoadingIndicatorSize || hasNewLoadingIndicatorSize }