Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
b08e92a402
commit
a36525771d
@ -158,6 +158,58 @@ If SwiftData is configured to use CloudKit:
|
||||
- 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
|
||||
|
||||
- Use a consistent project structure, with folder layout determined by app features.
|
||||
|
||||
@ -20,6 +20,16 @@ enum Suit: String, CaseIterable, Identifiable {
|
||||
var isRed: Bool {
|
||||
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.
|
||||
@ -62,6 +72,25 @@ enum Rank: Int, CaseIterable, Identifiable {
|
||||
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.
|
||||
|
||||
@ -113,5 +113,11 @@ enum ChipDenomination: Int, CaseIterable, Identifiable {
|
||||
case .hundredThousand: return Color.Chip.goldRubyStripe
|
||||
}
|
||||
}
|
||||
|
||||
/// Accessibility label for VoiceOver.
|
||||
var accessibilityLabel: String {
|
||||
let format = String(localized: "chipDenominationFormat")
|
||||
return String(format: format, rawValue.formatted())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -70,6 +70,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"$%@" : {
|
||||
|
||||
},
|
||||
"$%lldK" : {
|
||||
"comment" : "A button that allows the user to select a starting balance for the game.",
|
||||
@ -105,6 +108,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Ace" : {
|
||||
|
||||
},
|
||||
"B" : {
|
||||
"comment" : "The letter \"B\" displayed in the center of the playing card's back.",
|
||||
@ -140,6 +146,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Balance" : {
|
||||
|
||||
},
|
||||
"BALANCE" : {
|
||||
"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" : {
|
||||
"comment" : "Result banner text when banker wins.",
|
||||
@ -253,6 +268,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"betAmountFormat" : {
|
||||
|
||||
},
|
||||
"Betting disabled" : {
|
||||
|
||||
},
|
||||
"Cancel" : {
|
||||
"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" : {
|
||||
"comment" : "The label of a button that clears all current bets in the game.",
|
||||
@ -323,6 +359,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Clubs" : {
|
||||
|
||||
},
|
||||
"currentBetFormat" : {
|
||||
|
||||
},
|
||||
"Deal" : {
|
||||
"comment" : "The label of a button that deals cards in a game.",
|
||||
@ -428,6 +470,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Diamonds" : {
|
||||
|
||||
},
|
||||
"Done" : {
|
||||
"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" : {
|
||||
"comment" : "The title of the game over screen.",
|
||||
@ -498,6 +569,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"handValueFormat" : {
|
||||
|
||||
},
|
||||
"Hearts" : {
|
||||
|
||||
},
|
||||
"HISTORY" : {
|
||||
"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" : {
|
||||
"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" : {
|
||||
"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" : {
|
||||
"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" : {
|
||||
"comment" : "Result banner text when player wins.",
|
||||
@ -820,6 +930,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Queen" : {
|
||||
|
||||
},
|
||||
"Reset" : {
|
||||
"comment" : "A button that resets the game to its initial state.",
|
||||
@ -925,6 +1038,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"selected" : {
|
||||
|
||||
},
|
||||
"Selected" : {
|
||||
|
||||
},
|
||||
"Settings" : {
|
||||
"comment" : "The label of a button that navigates to the settings screen.",
|
||||
@ -960,6 +1079,15 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Seven" : {
|
||||
|
||||
},
|
||||
"Six" : {
|
||||
|
||||
},
|
||||
"Spades" : {
|
||||
|
||||
},
|
||||
"tableLimitsFormat" : {
|
||||
"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" : {
|
||||
"comment" : "The text displayed in the TIE betting zone.",
|
||||
@ -1037,6 +1172,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Tie bet, pays 8 to 1" : {
|
||||
|
||||
},
|
||||
"TIE GAME" : {
|
||||
"comment" : "Result banner text when the game is a tie.",
|
||||
@ -1079,6 +1217,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Two" : {
|
||||
|
||||
},
|
||||
"WIN" : {
|
||||
"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!" : {
|
||||
"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"
|
||||
}
|
||||
@ -23,6 +23,15 @@ struct CardView: View {
|
||||
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 {
|
||||
ZStack {
|
||||
if isFaceUp {
|
||||
@ -36,6 +45,8 @@ struct CardView: View {
|
||||
axis: (x: 0, y: 1, z: 0)
|
||||
)
|
||||
.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])
|
||||
)
|
||||
.frame(width: width, height: height)
|
||||
.accessibilityLabel(String(localized: "Empty card slot"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -82,8 +82,21 @@ struct ChipOnTable: View {
|
||||
.fill(Color.red)
|
||||
)
|
||||
.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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -48,6 +48,9 @@ struct ChipSelectorView: View {
|
||||
.padding(.vertical, Design.Spacing.small) // Extra padding for selection scale effect (1.1x)
|
||||
}
|
||||
.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
|
||||
// Auto-select highest affordable chip if current selection is now too expensive
|
||||
if newBalance < selectedChip.rawValue {
|
||||
|
||||
@ -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)
|
||||
.scaleEffect(isSelected ? Design.Scale.selected : Design.Scale.normal)
|
||||
.animation(.spring(duration: Design.Animation.selectionDuration), value: isSelected)
|
||||
.accessibilityLabel(denomination.accessibilityLabel)
|
||||
.accessibilityValue(isSelected ? String(localized: "Selected") : "")
|
||||
.accessibilityAddTraits(.isButton)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -275,6 +275,9 @@ struct GameOverView: View {
|
||||
showContent = true
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel(String(localized: "Game Over"))
|
||||
.accessibilityAddTraits(.isModal)
|
||||
}
|
||||
}
|
||||
|
||||
@ -295,6 +298,40 @@ struct CardsDisplayArea: View {
|
||||
|
||||
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 {
|
||||
HStack(spacing: Design.Spacing.xxxLarge) {
|
||||
// Player side
|
||||
@ -318,6 +355,9 @@ struct CardsDisplayArea: View {
|
||||
isWinner: playerIsWinner
|
||||
)
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(String(localized: "Player hand"))
|
||||
.accessibilityValue(playerHandDescription + (playerIsWinner ? ", " + String(localized: "Winner") : ""))
|
||||
|
||||
// Banker side
|
||||
VStack(spacing: Design.Spacing.small) {
|
||||
@ -340,6 +380,9 @@ struct CardsDisplayArea: View {
|
||||
isWinner: bankerIsWinner
|
||||
)
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(String(localized: "Banker hand"))
|
||||
.accessibilityValue(bankerHandDescription + (bankerIsWinner ? ", " + String(localized: "Winner") : ""))
|
||||
}
|
||||
.padding(.top, Design.Spacing.large)
|
||||
.padding(.bottom, Design.Spacing.xLarge)
|
||||
@ -347,6 +390,7 @@ struct CardsDisplayArea: View {
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.xLarge)
|
||||
.fill(Color.black.opacity(0.25))
|
||||
.accessibilityHidden(true)
|
||||
)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
@ -458,6 +502,7 @@ struct TableBackgroundView: View {
|
||||
.opacity(Design.Opacity.subtle / 3)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.accessibilityHidden(true) // Decorative element
|
||||
}
|
||||
}
|
||||
|
||||
@ -526,6 +571,9 @@ struct TopBarView: View {
|
||||
Capsule()
|
||||
.fill(Color.black.opacity(Design.Opacity.overlay))
|
||||
)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(String(localized: "Balance"))
|
||||
.accessibilityValue("$\(balance.formatted())")
|
||||
|
||||
Spacer()
|
||||
|
||||
@ -538,6 +586,9 @@ struct TopBarView: View {
|
||||
.font(.system(size: smallFontSize, weight: .medium))
|
||||
}
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.secondary))
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(String(localized: "Cards remaining in shoe"))
|
||||
.accessibilityValue("\(cardsRemaining)")
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
@ -210,6 +210,30 @@ struct TieBettingZone: View {
|
||||
private let titleFontSize: CGFloat = Design.BaseFontSize.medium
|
||||
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
|
||||
|
||||
private let cornerRadius = Design.CornerRadius.small
|
||||
@ -263,10 +287,15 @@ struct TieBettingZone: View {
|
||||
if betAmount > 0 {
|
||||
ChipOnTable(amount: betAmount, showMax: isAtMax)
|
||||
.padding(.trailing, chipTrailingPadding)
|
||||
.accessibilityHidden(true) // Included in zone description
|
||||
}
|
||||
}
|
||||
}
|
||||
.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 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
|
||||
|
||||
private let cornerRadius = Design.CornerRadius.medium
|
||||
@ -353,10 +409,15 @@ struct BankerBettingZone: View {
|
||||
if betAmount > 0 {
|
||||
ChipOnTable(amount: betAmount, showMax: isAtMax)
|
||||
.padding(.trailing, chipTrailingPadding)
|
||||
.accessibilityHidden(true) // Included in zone description
|
||||
}
|
||||
}
|
||||
}
|
||||
.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 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
|
||||
|
||||
private let cornerRadius = Design.CornerRadius.medium
|
||||
@ -443,10 +531,15 @@ struct PlayerBettingZone: View {
|
||||
if betAmount > 0 {
|
||||
ChipOnTable(amount: betAmount, showMax: isAtMax)
|
||||
.padding(.trailing, chipTrailingPadding)
|
||||
.accessibilityHidden(true) // Included in zone description
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(accessibilityDescription)
|
||||
.accessibilityHint(accessibilityHintText)
|
||||
.accessibilityAddTraits(.isButton)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// An animated banner showing the round result.
|
||||
struct ResultBannerView: View {
|
||||
@ -108,6 +109,34 @@ struct ResultBannerView: View {
|
||||
withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.springBounce).delay(Design.Animation.staggerDelay2)) {
|
||||
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)
|
||||
.accessibilityHidden(true) // Decorative element
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -16,6 +16,21 @@ struct RoadMapView: View {
|
||||
@ScaledMetric(relativeTo: .caption2) private var historyFontSize: CGFloat = Design.BaseFontSize.small
|
||||
@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 {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||
Text("HISTORY")
|
||||
@ -39,6 +54,9 @@ struct RoadMapView: View {
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
|
||||
.fill(Color.black.opacity(Design.Opacity.light))
|
||||
)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(String(localized: "Game history"))
|
||||
.accessibilityValue(historySummary)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user