Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2025-12-16 20:36:40 -06:00
parent b08e92a402
commit a36525771d
12 changed files with 458 additions and 1 deletions

View File

@ -158,6 +158,58 @@ If SwiftData is configured to use CloudKit:
- Test with accessibility settings: Settings > Accessibility > Display & Text Size > Larger Text. - Test with accessibility settings: Settings > Accessibility > Display & Text Size > Larger Text.
## VoiceOver accessibility instructions
- All interactive elements (buttons, betting zones, selectable items) must have meaningful `.accessibilityLabel()`.
- Use `.accessibilityValue()` to communicate dynamic state (e.g., current bet amount, selection state, hand value).
- Use `.accessibilityHint()` to describe what will happen when interacting with an element:
```swift
Button("Deal", action: deal)
.accessibilityHint("Deals cards and starts the round")
```
- Use `.accessibilityAddTraits()` to communicate element type:
- `.isButton` for tappable elements that aren't SwiftUI Buttons
- `.isHeader` for section headers
- `.isModal` for modal overlays
- `.updatesFrequently` for live-updating content
- Hide purely decorative elements from VoiceOver:
```swift
TableBackgroundView()
.accessibilityHidden(true) // Decorative element
```
- Group related elements to reduce VoiceOver navigation complexity:
```swift
VStack {
handLabel
cardStack
valueDisplay
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Player hand")
.accessibilityValue("Ace of Hearts, King of Spades. Value: 1")
```
- For complex elements, use `.accessibilityElement(children: .contain)` to allow navigation to children while adding context.
- Post accessibility announcements for important events:
```swift
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(500))
UIAccessibility.post(notification: .announcement, argument: "Player wins!")
}
```
- Provide accessibility names for model types that appear in UI:
```swift
enum Suit {
var accessibilityName: String {
switch self {
case .hearts: return String(localized: "Hearts")
// ...
}
}
}
```
- Test with VoiceOver enabled: Settings > Accessibility > VoiceOver.
## Project structure ## Project structure
- Use a consistent project structure, with folder layout determined by app features. - Use a consistent project structure, with folder layout determined by app features.

View File

@ -20,6 +20,16 @@ enum Suit: String, CaseIterable, Identifiable {
var isRed: Bool { var isRed: Bool {
self == .hearts || self == .diamonds self == .hearts || self == .diamonds
} }
/// Accessibility name for VoiceOver.
var accessibilityName: String {
switch self {
case .hearts: return String(localized: "Hearts")
case .diamonds: return String(localized: "Diamonds")
case .clubs: return String(localized: "Clubs")
case .spades: return String(localized: "Spades")
}
}
} }
/// Represents the rank of a card from Ace through King. /// Represents the rank of a card from Ace through King.
@ -62,6 +72,25 @@ enum Rank: Int, CaseIterable, Identifiable {
return 0 return 0
} }
} }
/// Accessibility name for VoiceOver.
var accessibilityName: String {
switch self {
case .ace: return String(localized: "Ace")
case .two: return String(localized: "Two")
case .three: return String(localized: "Three")
case .four: return String(localized: "Four")
case .five: return String(localized: "Five")
case .six: return String(localized: "Six")
case .seven: return String(localized: "Seven")
case .eight: return String(localized: "Eight")
case .nine: return String(localized: "Nine")
case .ten: return String(localized: "Ten")
case .jack: return String(localized: "Jack")
case .queen: return String(localized: "Queen")
case .king: return String(localized: "King")
}
}
} }
/// Represents a single playing card with a suit and rank. /// Represents a single playing card with a suit and rank.

View File

@ -113,5 +113,11 @@ enum ChipDenomination: Int, CaseIterable, Identifiable {
case .hundredThousand: return Color.Chip.goldRubyStripe case .hundredThousand: return Color.Chip.goldRubyStripe
} }
} }
/// Accessibility label for VoiceOver.
var accessibilityLabel: String {
let format = String(localized: "chipDenominationFormat")
return String(format: format, rawValue.formatted())
}
} }

View File

@ -70,6 +70,9 @@
} }
} }
} }
},
"$%@" : {
}, },
"$%lldK" : { "$%lldK" : {
"comment" : "A button that allows the user to select a starting balance for the game.", "comment" : "A button that allows the user to select a starting balance for the game.",
@ -105,6 +108,9 @@
} }
} }
} }
},
"Ace" : {
}, },
"B" : { "B" : {
"comment" : "The letter \"B\" displayed in the center of the playing card's back.", "comment" : "The letter \"B\" displayed in the center of the playing card's back.",
@ -140,6 +146,9 @@
} }
} }
} }
},
"Balance" : {
}, },
"BALANCE" : { "BALANCE" : {
"comment" : "The label for the user's balance in the top bar.", "comment" : "The label for the user's balance in the top bar.",
@ -211,6 +220,12 @@
} }
} }
} }
},
"Banker bet, pays 0.95 to 1" : {
},
"Banker hand" : {
}, },
"BANKER WINS" : { "BANKER WINS" : {
"comment" : "Result banner text when banker wins.", "comment" : "Result banner text when banker wins.",
@ -253,6 +268,12 @@
} }
} }
} }
},
"betAmountFormat" : {
},
"Betting disabled" : {
}, },
"Cancel" : { "Cancel" : {
"comment" : "The label of a button to cancel making changes in the settings.", "comment" : "The label of a button to cancel making changes in the settings.",
@ -288,6 +309,21 @@
} }
} }
} }
},
"Card face down" : {
},
"Cards face down" : {
},
"Cards remaining in shoe" : {
},
"Chip selector" : {
},
"chipDenominationFormat" : {
}, },
"Clear" : { "Clear" : {
"comment" : "The label of a button that clears all current bets in the game.", "comment" : "The label of a button that clears all current bets in the game.",
@ -323,6 +359,12 @@
} }
} }
} }
},
"Clubs" : {
},
"currentBetFormat" : {
}, },
"Deal" : { "Deal" : {
"comment" : "The label of a button that deals cards in a game.", "comment" : "The label of a button that deals cards in a game.",
@ -428,6 +470,9 @@
} }
} }
} }
},
"Diamonds" : {
}, },
"Done" : { "Done" : {
"comment" : "The text for a button that confirms and saves settings.", "comment" : "The text for a button that confirms and saves settings.",
@ -463,6 +508,32 @@
} }
} }
} }
},
"Double tap a chip to select bet amount" : {
"comment" : "A hint that appears when hovering over the chip selector, explaining how to select a bet amount.",
"isCommentAutoGenerated" : true
},
"Double tap to place bet" : {
},
"Eight" : {
},
"Empty card slot" : {
"comment" : "An accessibility label for an empty card slot.",
"isCommentAutoGenerated" : true
},
"Five" : {
},
"Four" : {
},
"Game history" : {
},
"Game Over" : {
}, },
"GAME OVER" : { "GAME OVER" : {
"comment" : "The title of the game over screen.", "comment" : "The title of the game over screen.",
@ -498,6 +569,12 @@
} }
} }
} }
},
"handValueFormat" : {
},
"Hearts" : {
}, },
"HISTORY" : { "HISTORY" : {
"comment" : "A label displayed above the road map view, indicating that it shows a history of past game results.", "comment" : "A label displayed above the road map view, indicating that it shows a history of past game results.",
@ -533,6 +610,18 @@
} }
} }
} }
},
"historySummaryFormat" : {
},
"Jack" : {
},
"King" : {
},
"lostAmountFormat" : {
}, },
"MAX" : { "MAX" : {
"comment" : "A label displayed as a badge on top-right of a chip to indicate it's the maximum bet.", "comment" : "A label displayed as a badge on top-right of a chip to indicate it's the maximum bet.",
@ -568,6 +657,12 @@
} }
} }
} }
},
"maximum bet" : {
},
"Maximum bet reached" : {
}, },
"New Round" : { "New Round" : {
"comment" : "The label of a button that starts a new round of the game.", "comment" : "The label of a button that starts a new round of the game.",
@ -603,6 +698,15 @@
} }
} }
} }
},
"Nine" : {
},
"No cards" : {
},
"No history yet" : {
}, },
"PAYS 0.95 TO 1" : { "PAYS 0.95 TO 1" : {
"comment" : "A description of the payout for betting on the banker in a mini baccarat table.", "comment" : "A description of the payout for betting on the banker in a mini baccarat table.",
@ -778,6 +882,12 @@
} }
} }
} }
},
"Player bet, pays 1 to 1" : {
},
"Player hand" : {
}, },
"PLAYER WINS" : { "PLAYER WINS" : {
"comment" : "Result banner text when player wins.", "comment" : "Result banner text when player wins.",
@ -820,6 +930,9 @@
} }
} }
} }
},
"Queen" : {
}, },
"Reset" : { "Reset" : {
"comment" : "A button that resets the game to its initial state.", "comment" : "A button that resets the game to its initial state.",
@ -925,6 +1038,12 @@
} }
} }
} }
},
"selected" : {
},
"Selected" : {
}, },
"Settings" : { "Settings" : {
"comment" : "The label of a button that navigates to the settings screen.", "comment" : "The label of a button that navigates to the settings screen.",
@ -960,6 +1079,15 @@
} }
} }
} }
},
"Seven" : {
},
"Six" : {
},
"Spades" : {
}, },
"tableLimitsFormat" : { "tableLimitsFormat" : {
"comment" : "Format string for table limits display. First argument is min bet, second is max bet.", "comment" : "Format string for table limits display. First argument is min bet, second is max bet.",
@ -1002,6 +1130,13 @@
} }
} }
} }
},
"Ten" : {
"comment" : "Accessibility name for a card with the rank of Ten.",
"isCommentAutoGenerated" : true
},
"Three" : {
}, },
"TIE" : { "TIE" : {
"comment" : "The text displayed in the TIE betting zone.", "comment" : "The text displayed in the TIE betting zone.",
@ -1037,6 +1172,9 @@
} }
} }
} }
},
"Tie bet, pays 8 to 1" : {
}, },
"TIE GAME" : { "TIE GAME" : {
"comment" : "Result banner text when the game is a tie.", "comment" : "Result banner text when the game is a tie.",
@ -1079,6 +1217,9 @@
} }
} }
} }
},
"Two" : {
}, },
"WIN" : { "WIN" : {
"comment" : "The text that appears as a badge when a player wins a hand in baccarat.", "comment" : "The text that appears as a badge when a player wins a hand in baccarat.",
@ -1114,6 +1255,12 @@
} }
} }
} }
},
"Winner" : {
},
"wonAmountFormat" : {
}, },
"You've run out of chips!" : { "You've run out of chips!" : {
"comment" : "A message displayed when a player runs out of money in the game over screen.", "comment" : "A message displayed when a player runs out of money in the game over screen.",
@ -1151,5 +1298,5 @@
} }
} }
}, },
"version" : "1.0" "version" : "1.1"
} }

View File

@ -23,6 +23,15 @@ struct CardView: View {
cardWidth * 1.4 cardWidth * 1.4
} }
/// Accessibility label describing the card for VoiceOver.
private var accessibilityDescription: String {
if isFaceUp {
return "\(card.rank.accessibilityName) of \(card.suit.accessibilityName)"
} else {
return String(localized: "Card face down")
}
}
var body: some View { var body: some View {
ZStack { ZStack {
if isFaceUp { if isFaceUp {
@ -36,6 +45,8 @@ struct CardView: View {
axis: (x: 0, y: 1, z: 0) axis: (x: 0, y: 1, z: 0)
) )
.animation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.cardFlipBounce), value: isFaceUp) .animation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.cardFlipBounce), value: isFaceUp)
.accessibilityLabel(accessibilityDescription)
.accessibilityAddTraits(.isImage)
} }
} }
@ -254,6 +265,7 @@ struct CardPlaceholderView: View {
style: StrokeStyle(lineWidth: Design.LineWidth.medium, dash: [8, 4]) style: StrokeStyle(lineWidth: Design.LineWidth.medium, dash: [8, 4])
) )
.frame(width: width, height: height) .frame(width: width, height: height)
.accessibilityLabel(String(localized: "Empty card slot"))
} }
} }

View File

@ -82,8 +82,21 @@ struct ChipOnTable: View {
.fill(Color.red) .fill(Color.red)
) )
.offset(x: maxBadgeOffsetX, y: maxBadgeOffsetY) .offset(x: maxBadgeOffsetX, y: maxBadgeOffsetY)
.accessibilityHidden(true) // Included in parent label
} }
} }
.accessibilityLabel(accessibilityDescription)
}
// MARK: - Accessibility
private var accessibilityDescription: String {
let format = String(localized: "betAmountFormat")
let base = String(format: format, amount.formatted())
if showMax {
return base + ", " + String(localized: "maximum bet")
}
return base
} }
} }

View File

@ -48,6 +48,9 @@ struct ChipSelectorView: View {
.padding(.vertical, Design.Spacing.small) // Extra padding for selection scale effect (1.1x) .padding(.vertical, Design.Spacing.small) // Extra padding for selection scale effect (1.1x)
} }
.scrollIndicators(.hidden) .scrollIndicators(.hidden)
.accessibilityElement(children: .contain)
.accessibilityLabel(String(localized: "Chip selector"))
.accessibilityHint(String(localized: "Double tap a chip to select bet amount"))
.onChange(of: balance) { _, newBalance in .onChange(of: balance) { _, newBalance in
// Auto-select highest affordable chip if current selection is now too expensive // Auto-select highest affordable chip if current selection is now too expensive
if newBalance < selectedChip.rawValue { if newBalance < selectedChip.rawValue {

View File

@ -103,6 +103,9 @@ struct ChipView: View {
.shadow(color: .black.opacity(Design.Opacity.overlay), radius: isSelected ? Design.Shadow.radiusSmall * 2 : Design.Shadow.radiusSmall, x: shadowOffset, y: shadowOffsetY) .shadow(color: .black.opacity(Design.Opacity.overlay), radius: isSelected ? Design.Shadow.radiusSmall * 2 : Design.Shadow.radiusSmall, x: shadowOffset, y: shadowOffsetY)
.scaleEffect(isSelected ? Design.Scale.selected : Design.Scale.normal) .scaleEffect(isSelected ? Design.Scale.selected : Design.Scale.normal)
.animation(.spring(duration: Design.Animation.selectionDuration), value: isSelected) .animation(.spring(duration: Design.Animation.selectionDuration), value: isSelected)
.accessibilityLabel(denomination.accessibilityLabel)
.accessibilityValue(isSelected ? String(localized: "Selected") : "")
.accessibilityAddTraits(.isButton)
} }
} }

View File

@ -275,6 +275,9 @@ struct GameOverView: View {
showContent = true showContent = true
} }
} }
.accessibilityElement(children: .contain)
.accessibilityLabel(String(localized: "Game Over"))
.accessibilityAddTraits(.isModal)
} }
} }
@ -295,6 +298,40 @@ struct CardsDisplayArea: View {
private let labelFontSize: CGFloat = 14 private let labelFontSize: CGFloat = 14
// MARK: - Accessibility
private var playerHandDescription: String {
if playerCards.isEmpty {
return String(localized: "No cards")
}
let visibleCards = zip(playerCards, playerCardsFaceUp)
.filter { $1 }
.map { "\($0.0.rank.accessibilityName) of \($0.0.suit.accessibilityName)" }
if visibleCards.isEmpty {
return String(localized: "Cards face down")
}
let format = String(localized: "handValueFormat")
return visibleCards.joined(separator: ", ") + ". " + String(format: format, playerValue)
}
private var bankerHandDescription: String {
if bankerCards.isEmpty {
return String(localized: "No cards")
}
let visibleCards = zip(bankerCards, bankerCardsFaceUp)
.filter { $1 }
.map { "\($0.0.rank.accessibilityName) of \($0.0.suit.accessibilityName)" }
if visibleCards.isEmpty {
return String(localized: "Cards face down")
}
let format = String(localized: "handValueFormat")
return visibleCards.joined(separator: ", ") + ". " + String(format: format, bankerValue)
}
var body: some View { var body: some View {
HStack(spacing: Design.Spacing.xxxLarge) { HStack(spacing: Design.Spacing.xxxLarge) {
// Player side // Player side
@ -318,6 +355,9 @@ struct CardsDisplayArea: View {
isWinner: playerIsWinner isWinner: playerIsWinner
) )
} }
.accessibilityElement(children: .ignore)
.accessibilityLabel(String(localized: "Player hand"))
.accessibilityValue(playerHandDescription + (playerIsWinner ? ", " + String(localized: "Winner") : ""))
// Banker side // Banker side
VStack(spacing: Design.Spacing.small) { VStack(spacing: Design.Spacing.small) {
@ -340,6 +380,9 @@ struct CardsDisplayArea: View {
isWinner: bankerIsWinner isWinner: bankerIsWinner
) )
} }
.accessibilityElement(children: .ignore)
.accessibilityLabel(String(localized: "Banker hand"))
.accessibilityValue(bankerHandDescription + (bankerIsWinner ? ", " + String(localized: "Winner") : ""))
} }
.padding(.top, Design.Spacing.large) .padding(.top, Design.Spacing.large)
.padding(.bottom, Design.Spacing.xLarge) .padding(.bottom, Design.Spacing.xLarge)
@ -347,6 +390,7 @@ struct CardsDisplayArea: View {
.background( .background(
RoundedRectangle(cornerRadius: Design.CornerRadius.xLarge) RoundedRectangle(cornerRadius: Design.CornerRadius.xLarge)
.fill(Color.black.opacity(0.25)) .fill(Color.black.opacity(0.25))
.accessibilityHidden(true)
) )
.padding(.horizontal) .padding(.horizontal)
} }
@ -458,6 +502,7 @@ struct TableBackgroundView: View {
.opacity(Design.Opacity.subtle / 3) .opacity(Design.Opacity.subtle / 3)
} }
.ignoresSafeArea() .ignoresSafeArea()
.accessibilityHidden(true) // Decorative element
} }
} }
@ -526,6 +571,9 @@ struct TopBarView: View {
Capsule() Capsule()
.fill(Color.black.opacity(Design.Opacity.overlay)) .fill(Color.black.opacity(Design.Opacity.overlay))
) )
.accessibilityElement(children: .ignore)
.accessibilityLabel(String(localized: "Balance"))
.accessibilityValue("$\(balance.formatted())")
Spacer() Spacer()
@ -538,6 +586,9 @@ struct TopBarView: View {
.font(.system(size: smallFontSize, weight: .medium)) .font(.system(size: smallFontSize, weight: .medium))
} }
.foregroundStyle(.white.opacity(Design.Opacity.secondary)) .foregroundStyle(.white.opacity(Design.Opacity.secondary))
.accessibilityElement(children: .ignore)
.accessibilityLabel(String(localized: "Cards remaining in shoe"))
.accessibilityValue("\(cardsRemaining)")
Spacer() Spacer()
} }

View File

@ -210,6 +210,30 @@ struct TieBettingZone: View {
private let titleFontSize: CGFloat = Design.BaseFontSize.medium private let titleFontSize: CGFloat = Design.BaseFontSize.medium
private let subtitleFontSize: CGFloat = Design.BaseFontSize.xSmall private let subtitleFontSize: CGFloat = Design.BaseFontSize.xSmall
// MARK: - Accessibility
private var accessibilityDescription: String {
var description = String(localized: "Tie bet, pays 8 to 1")
if betAmount > 0 {
let format = String(localized: "currentBetFormat")
description += ". " + String(format: format, betAmount.formatted())
if isAtMax {
description += ", " + String(localized: "maximum bet")
}
}
return description
}
private var accessibilityHintText: String {
if isEnabled {
return String(localized: "Double tap to place bet")
} else if isAtMax {
return String(localized: "Maximum bet reached")
} else {
return String(localized: "Betting disabled")
}
}
// MARK: - Layout Constants // MARK: - Layout Constants
private let cornerRadius = Design.CornerRadius.small private let cornerRadius = Design.CornerRadius.small
@ -263,10 +287,15 @@ struct TieBettingZone: View {
if betAmount > 0 { if betAmount > 0 {
ChipOnTable(amount: betAmount, showMax: isAtMax) ChipOnTable(amount: betAmount, showMax: isAtMax)
.padding(.trailing, chipTrailingPadding) .padding(.trailing, chipTrailingPadding)
.accessibilityHidden(true) // Included in zone description
} }
} }
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityDescription)
.accessibilityHint(accessibilityHintText)
.accessibilityAddTraits(.isButton)
} }
} }
@ -284,6 +313,33 @@ struct BankerBettingZone: View {
private let titleFontSize: CGFloat = Design.BaseFontSize.large private let titleFontSize: CGFloat = Design.BaseFontSize.large
private let subtitleFontSize: CGFloat = Design.BaseFontSize.xSmall private let subtitleFontSize: CGFloat = Design.BaseFontSize.xSmall
// MARK: - Accessibility
private var accessibilityDescription: String {
var description = String(localized: "Banker bet, pays 0.95 to 1")
if isSelected {
description += ", " + String(localized: "selected")
}
if betAmount > 0 {
let format = String(localized: "currentBetFormat")
description += ". " + String(format: format, betAmount.formatted())
if isAtMax {
description += ", " + String(localized: "maximum bet")
}
}
return description
}
private var accessibilityHintText: String {
if isEnabled {
return String(localized: "Double tap to place bet")
} else if isAtMax {
return String(localized: "Maximum bet reached")
} else {
return String(localized: "Betting disabled")
}
}
// MARK: - Layout Constants // MARK: - Layout Constants
private let cornerRadius = Design.CornerRadius.medium private let cornerRadius = Design.CornerRadius.medium
@ -353,10 +409,15 @@ struct BankerBettingZone: View {
if betAmount > 0 { if betAmount > 0 {
ChipOnTable(amount: betAmount, showMax: isAtMax) ChipOnTable(amount: betAmount, showMax: isAtMax)
.padding(.trailing, chipTrailingPadding) .padding(.trailing, chipTrailingPadding)
.accessibilityHidden(true) // Included in zone description
} }
} }
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityDescription)
.accessibilityHint(accessibilityHintText)
.accessibilityAddTraits(.isButton)
} }
} }
@ -374,6 +435,33 @@ struct PlayerBettingZone: View {
private let titleFontSize: CGFloat = Design.BaseFontSize.large private let titleFontSize: CGFloat = Design.BaseFontSize.large
private let subtitleFontSize: CGFloat = Design.BaseFontSize.xSmall private let subtitleFontSize: CGFloat = Design.BaseFontSize.xSmall
// MARK: - Accessibility
private var accessibilityDescription: String {
var description = String(localized: "Player bet, pays 1 to 1")
if isSelected {
description += ", " + String(localized: "selected")
}
if betAmount > 0 {
let format = String(localized: "currentBetFormat")
description += ". " + String(format: format, betAmount.formatted())
if isAtMax {
description += ", " + String(localized: "maximum bet")
}
}
return description
}
private var accessibilityHintText: String {
if isEnabled {
return String(localized: "Double tap to place bet")
} else if isAtMax {
return String(localized: "Maximum bet reached")
} else {
return String(localized: "Betting disabled")
}
}
// MARK: - Layout Constants // MARK: - Layout Constants
private let cornerRadius = Design.CornerRadius.medium private let cornerRadius = Design.CornerRadius.medium
@ -443,10 +531,15 @@ struct PlayerBettingZone: View {
if betAmount > 0 { if betAmount > 0 {
ChipOnTable(amount: betAmount, showMax: isAtMax) ChipOnTable(amount: betAmount, showMax: isAtMax)
.padding(.trailing, chipTrailingPadding) .padding(.trailing, chipTrailingPadding)
.accessibilityHidden(true) // Included in zone description
} }
} }
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityDescription)
.accessibilityHint(accessibilityHintText)
.accessibilityAddTraits(.isButton)
} }
} }

View File

@ -6,6 +6,7 @@
// //
import SwiftUI import SwiftUI
import UIKit
/// An animated banner showing the round result. /// An animated banner showing the round result.
struct ResultBannerView: View { struct ResultBannerView: View {
@ -108,6 +109,34 @@ struct ResultBannerView: View {
withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.springBounce).delay(Design.Animation.staggerDelay2)) { withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.springBounce).delay(Design.Animation.staggerDelay2)) {
showWinnings = true showWinnings = true
} }
// Announce result to VoiceOver users
announceResult()
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityDescription)
.accessibilityAddTraits(.updatesFrequently)
}
// MARK: - Accessibility
private var accessibilityDescription: String {
var description = result.displayText
if winnings > 0 {
let format = String(localized: "wonAmountFormat")
description += ". " + String(format: format, winnings.formatted())
} else if winnings < 0 {
let format = String(localized: "lostAmountFormat")
description += ". " + String(format: format, abs(winnings).formatted())
}
return description
}
private func announceResult() {
// Post accessibility announcement for screen reader users
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(500))
UIAccessibility.post(notification: .announcement, argument: accessibilityDescription)
} }
} }
} }
@ -157,6 +186,7 @@ struct ConfettiView: View {
} }
} }
.allowsHitTesting(false) .allowsHitTesting(false)
.accessibilityHidden(true) // Decorative element
} }
} }

View File

@ -16,6 +16,21 @@ struct RoadMapView: View {
@ScaledMetric(relativeTo: .caption2) private var historyFontSize: CGFloat = Design.BaseFontSize.small @ScaledMetric(relativeTo: .caption2) private var historyFontSize: CGFloat = Design.BaseFontSize.small
@ScaledMetric(relativeTo: .caption2) private var dotSize: CGFloat = 22 @ScaledMetric(relativeTo: .caption2) private var dotSize: CGFloat = 22
// MARK: - Accessibility
private var historySummary: String {
if results.isEmpty {
return String(localized: "No history yet")
}
let playerWins = results.filter { $0.result == .playerWins }.count
let bankerWins = results.filter { $0.result == .bankerWins }.count
let ties = results.filter { $0.result == .tie }.count
let format = String(localized: "historySummaryFormat")
return String(format: format, results.count, playerWins, bankerWins, ties)
}
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
Text("HISTORY") Text("HISTORY")
@ -39,6 +54,9 @@ struct RoadMapView: View {
RoundedRectangle(cornerRadius: Design.CornerRadius.small) RoundedRectangle(cornerRadius: Design.CornerRadius.small)
.fill(Color.black.opacity(Design.Opacity.light)) .fill(Color.black.opacity(Design.Opacity.light))
) )
.accessibilityElement(children: .ignore)
.accessibilityLabel(String(localized: "Game history"))
.accessibilityValue(historySummary)
} }
} }