Files
Sportstime/SportsTime/Features/Progress/Views/GameMatchConfirmationView.swift
Trey t d63d311cab feat: add WCAG AA accessibility app-wide, fix CloudKit container config, remove debug logs
- Add VoiceOver labels, hints, and element grouping across all 60+ views
- Add Reduce Motion support (Theme.Animation.prefersReducedMotion) to all animations
- Replace fixed font sizes with semantic Dynamic Type styles
- Hide decorative elements from VoiceOver with .accessibilityHidden(true)
- Add .minimumHitTarget() modifier ensuring 44pt touch targets
- Add AccessibilityAnnouncer utility for VoiceOver announcements
- Improve color contrast values in Theme.swift for WCAG AA compliance
- Extract CloudKitContainerConfig for explicit container identity
- Remove PostHog debug console log from AnalyticsManager

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 09:27:23 -06:00

359 lines
12 KiB
Swift

//
// GameMatchConfirmationView.swift
// SportsTime
//
// View for confirming/selecting the correct game match from photo import.
//
import SwiftUI
// MARK: - Game Match Confirmation View
struct GameMatchConfirmationView: View {
let candidate: PhotoImportCandidate
let onConfirm: (GameMatchCandidate) -> Void
let onSkip: () -> Void
@Environment(\.colorScheme) private var colorScheme
@Environment(\.dismiss) private var dismiss
@State private var selectedMatch: GameMatchCandidate?
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: Theme.Spacing.lg) {
// Photo info header
photoInfoHeader
.staggeredAnimation(index: 0)
// Stadium info
if let stadium = candidate.bestStadiumMatch {
stadiumCard(stadium)
.staggeredAnimation(index: 1)
}
// Match options
matchOptionsSection
.staggeredAnimation(index: 2)
// Action buttons
actionButtons
.staggeredAnimation(index: 3)
}
.padding(Theme.Spacing.md)
}
.themedBackground()
.navigationTitle("Confirm Game")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
}
.onAppear {
// Pre-select if single match
if case .singleMatch(let match) = candidate.matchResult {
selectedMatch = match
}
}
}
// MARK: - Photo Info Header
private var photoInfoHeader: some View {
VStack(spacing: Theme.Spacing.md) {
// Icon
ZStack {
Circle()
.fill(Theme.warmOrange.opacity(0.15))
.frame(width: 80, height: 80)
Image(systemName: "photo.fill")
.font(.largeTitle)
.foregroundStyle(Theme.warmOrange)
}
VStack(spacing: Theme.Spacing.xs) {
if let date = candidate.metadata.captureDate {
Label(formatDate(date), systemImage: "calendar")
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
}
if candidate.metadata.hasValidLocation {
Label("Location data available", systemImage: "location.fill")
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
} else {
Label("No location data", systemImage: "location.slash")
.font(.subheadline)
.foregroundStyle(.red)
}
}
}
.frame(maxWidth: .infinity)
.padding(Theme.Spacing.lg)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
}
// MARK: - Stadium Card
private func stadiumCard(_ match: StadiumMatch) -> some View {
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
HStack {
Image(systemName: "mappin.circle.fill")
.foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
Text("Nearest Stadium")
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
Spacer()
}
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(match.stadium.name)
.font(.headline)
.foregroundStyle(Theme.textPrimary(colorScheme))
Text(match.stadium.fullAddress)
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
}
Spacer()
// Distance badge
VStack(spacing: 2) {
Text(match.formattedDistance)
.font(.subheadline)
.foregroundStyle(confidenceColor(match.confidence))
.accessibilityLabel("\(match.formattedDistance), \(match.confidence.description) confidence")
Text(match.confidence.description)
.font(.caption2)
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
}
.padding(Theme.Spacing.lg)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
.overlay {
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
}
}
// MARK: - Match Options Section
private var matchOptionsSection: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
HStack {
Image(systemName: "sportscourt.fill")
.foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
Text(matchOptionsTitle)
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
Spacer()
}
matchOptionsContent
}
.padding(Theme.Spacing.lg)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
.overlay {
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
}
}
private var matchOptionsTitle: String {
switch candidate.matchResult {
case .singleMatch:
return "Matched Game"
case .multipleMatches(let matches):
return "Select Game (\(matches.count) options)"
case .noMatches:
return "No Games Found"
}
}
@ViewBuilder
private var matchOptionsContent: some View {
switch candidate.matchResult {
case .singleMatch(let match):
gameMatchRow(match, isSelected: true)
case .multipleMatches(let matches):
VStack(spacing: Theme.Spacing.sm) {
ForEach(matches) { match in
Button {
selectedMatch = match
} label: {
gameMatchRow(match, isSelected: selectedMatch?.id == match.id)
}
.buttonStyle(.plain)
.accessibilityValue(selectedMatch?.id == match.id ? "Selected" : "Not selected")
.accessibilityAddTraits(selectedMatch?.id == match.id ? .isSelected : [])
}
}
case .noMatches(let reason):
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
Text(reason.description)
.font(.body)
.foregroundStyle(Theme.textSecondary(colorScheme))
}
.padding(Theme.Spacing.md)
}
}
private func gameMatchRow(_ match: GameMatchCandidate, isSelected: Bool) -> some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(match.fullMatchupDescription)
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
Image(systemName: match.game.sport.iconName)
.font(.caption)
.foregroundStyle(match.game.sport.themeColor)
.accessibilityHidden(true)
}
Text(match.gameDateTime)
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
// Confidence
HStack(spacing: 4) {
Circle()
.fill(combinedConfidenceColor(match.confidence.combined))
.frame(width: 8, height: 8)
.accessibilityLabel(confidenceAccessibilityLabel(match.confidence.combined))
Text(match.confidence.combined.description)
.font(.caption2)
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
Spacer()
// Selection indicator
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.font(.title2)
.foregroundStyle(isSelected ? .green : Theme.textMuted(colorScheme))
.accessibilityHidden(true)
}
.padding(Theme.Spacing.md)
.background(isSelected ? Theme.cardBackgroundElevated(colorScheme) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small))
.overlay {
if isSelected {
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
.stroke(.green.opacity(0.5), lineWidth: 2)
}
}
}
// MARK: - Action Buttons
private var actionButtons: some View {
VStack(spacing: Theme.Spacing.sm) {
// Confirm button
Button {
if let match = selectedMatch {
onConfirm(match)
dismiss()
}
} label: {
HStack {
Image(systemName: "checkmark.circle.fill")
Text("Confirm & Import")
}
.font(.body)
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(Theme.Spacing.md)
.background(selectedMatch != nil ? .green : Theme.textMuted(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
.disabled(selectedMatch == nil)
// Skip button
Button {
onSkip()
dismiss()
} label: {
Text("Skip This Photo")
.font(.body)
.foregroundStyle(Theme.textSecondary(colorScheme))
}
}
}
// MARK: - Helpers
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .long
formatter.timeStyle = .short
return formatter.string(from: date)
}
private func confidenceColor(_ confidence: MatchConfidence) -> Color {
switch confidence {
case .high: return .green
case .medium: return Theme.warmOrange
case .low: return .red
case .none: return Theme.textMuted(colorScheme)
}
}
private func combinedConfidenceColor(_ confidence: CombinedConfidence) -> Color {
switch confidence {
case .autoSelect: return .green
case .userConfirm: return Theme.warmOrange
case .manualOnly: return .red
}
}
private func confidenceAccessibilityLabel(_ confidence: CombinedConfidence) -> String {
switch confidence {
case .autoSelect: return "High confidence"
case .userConfirm: return "Medium confidence"
case .manualOnly: return "Low confidence"
}
}
}
// MARK: - Preview
#Preview {
let metadata = PhotoMetadata(
captureDate: Date(),
coordinates: nil
)
let candidate = PhotoImportCandidate(
metadata: metadata,
matchResult: .noMatches(.metadataMissing(.noLocation)),
stadiumMatches: []
)
GameMatchConfirmationView(
candidate: candidate,
onConfirm: { _ in },
onSkip: {}
)
}