Add Stadium Progress system and themed loading spinners
Stadium Progress & Achievements: - Add StadiumVisit and Achievement SwiftData models - Create Progress tab with interactive map view - Implement photo-based visit import with GPS/date matching - Add achievement badges (count-based, regional, journey) - Create shareable progress cards for social media - Add canonical data infrastructure (stadium identities, team aliases) - Implement score resolution from free APIs (MLB, NBA, NHL stats) UI Improvements: - Add ThemedSpinner and ThemedSpinnerCompact components - Replace all ProgressView() with themed spinners throughout app - Fix sport selection state not persisting when navigating away Bug Fixes: - Fix Coast to Coast trips showing only 1 city (validation issue) - Fix stadium progress showing 0/0 (filtering issue) - Remove "Stadium Quest" title from progress view 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,341 @@
|
||||
//
|
||||
// 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(.system(size: 36))
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
|
||||
VStack(spacing: Theme.Spacing.xs) {
|
||||
if let date = candidate.metadata.captureDate {
|
||||
Label(formatDate(date), systemImage: "calendar")
|
||||
.font(.system(size: Theme.FontSize.body, weight: .medium))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
}
|
||||
|
||||
if candidate.metadata.hasValidLocation {
|
||||
Label("Location data available", systemImage: "location.fill")
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
} else {
|
||||
Label("No location data", systemImage: "location.slash")
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.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(.system(size: Theme.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
Spacer()
|
||||
}
|
||||
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(match.stadium.name)
|
||||
.font(.system(size: Theme.FontSize.cardTitle, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Text(match.stadium.fullAddress)
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Distance badge
|
||||
VStack(spacing: 2) {
|
||||
Text(match.formattedDistance)
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(confidenceColor(match.confidence))
|
||||
|
||||
Text(match.confidence.description)
|
||||
.font(.system(size: 10))
|
||||
.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(.system(size: Theme.FontSize.body, weight: .semibold))
|
||||
.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(.system(size: Theme.FontSize.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(.system(size: Theme.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Image(systemName: match.game.sport.iconName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(match.game.sport.themeColor)
|
||||
}
|
||||
|
||||
Text(match.gameDateTime)
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.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(.system(size: 10))
|
||||
.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(.system(size: Theme.FontSize.body, weight: .semibold))
|
||||
.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(.system(size: Theme.FontSize.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: {}
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user