Replace all custom Theme.FontSize values and hardcoded font sizes with Apple's built-in text styles (.largeTitle, .title2, .headline, .body, .subheadline, .caption, .caption2) to support accessibility scaling. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
342 lines
11 KiB
Swift
342 lines
11 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)
|
|
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))
|
|
|
|
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)
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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.matchupDescription)
|
|
.font(.body)
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
|
|
Image(systemName: match.game.sport.iconName)
|
|
.font(.caption)
|
|
.foregroundStyle(match.game.sport.themeColor)
|
|
}
|
|
|
|
Text(match.gameDateTime)
|
|
.font(.subheadline)
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
|
|
// Confidence
|
|
HStack(spacing: 4) {
|
|
Circle()
|
|
.fill(combinedConfidenceColor(match.confidence.combined))
|
|
.frame(width: 8, height: 8)
|
|
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))
|
|
}
|
|
.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
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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: {}
|
|
)
|
|
}
|