Files
Sportstime/SportsTime/Features/Progress/Views/PhotoImportView.swift
Trey t 92d808caf5 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>
2026-01-08 20:20:03 -06:00

549 lines
19 KiB
Swift

//
// PhotoImportView.swift
// SportsTime
//
// View for importing stadium visits from photos using GPS/date metadata.
//
import SwiftUI
import SwiftData
import PhotosUI
import Photos
// MARK: - Photo Import View
struct PhotoImportView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.colorScheme) private var colorScheme
@Environment(\.dismiss) private var dismiss
@State private var viewModel = PhotoImportViewModel()
@State private var selectedPhotos: [PhotosPickerItem] = []
@State private var showingPermissionAlert = false
var body: some View {
NavigationStack {
VStack(spacing: 0) {
if viewModel.isProcessing {
processingView
} else if viewModel.processedPhotos.isEmpty {
emptyStateView
} else {
resultsView
}
}
.themedBackground()
.navigationTitle("Import from Photos")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
if !viewModel.processedPhotos.isEmpty {
ToolbarItem(placement: .confirmationAction) {
Button("Import") {
importSelectedVisits()
}
.fontWeight(.semibold)
.disabled(!viewModel.hasConfirmedImports)
}
}
}
.photosPicker(
isPresented: $viewModel.showingPicker,
selection: $selectedPhotos,
maxSelectionCount: 20,
matching: .images,
photoLibrary: .shared()
)
.onChange(of: selectedPhotos) { _, newValue in
Task {
await viewModel.processSelectedPhotos(newValue)
}
}
.alert("Photo Library Access", isPresented: $showingPermissionAlert) {
Button("Open Settings") {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
Button("Cancel", role: .cancel) {}
} message: {
Text("SportsTime needs access to your photos to import stadium visits. Please enable access in Settings.")
}
}
}
// MARK: - Empty State
private var emptyStateView: some View {
VStack(spacing: Theme.Spacing.lg) {
Spacer()
// Icon
ZStack {
Circle()
.fill(Theme.warmOrange.opacity(0.15))
.frame(width: 120, height: 120)
Image(systemName: "photo.on.rectangle.angled")
.font(.system(size: 50))
.foregroundStyle(Theme.warmOrange)
}
VStack(spacing: Theme.Spacing.sm) {
Text("Import from Photos")
.font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded))
.foregroundStyle(Theme.textPrimary(colorScheme))
Text("Select photos taken at stadiums to automatically log your visits. We'll use GPS and date data to match them to games.")
.font(.system(size: Theme.FontSize.body))
.foregroundStyle(Theme.textSecondary(colorScheme))
.multilineTextAlignment(.center)
.padding(.horizontal, Theme.Spacing.xl)
}
// Select Photos Button
Button {
checkPermissionsAndShowPicker()
} label: {
HStack {
Image(systemName: "photo.stack")
Text("Select Photos")
}
.font(.system(size: Theme.FontSize.body, weight: .semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(Theme.Spacing.md)
.background(Theme.warmOrange)
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
.padding(.horizontal, Theme.Spacing.xl)
// Info card
infoCard
Spacer()
}
.padding(Theme.Spacing.md)
}
private var infoCard: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
HStack {
Image(systemName: "info.circle.fill")
.foregroundStyle(Theme.warmOrange)
Text("How it works")
.font(.system(size: Theme.FontSize.body, weight: .semibold))
}
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
InfoRow(icon: "location.fill", text: "We read GPS location from your photos")
InfoRow(icon: "calendar", text: "We match the date to scheduled games")
InfoRow(icon: "checkmark.circle", text: "High confidence matches are auto-selected")
InfoRow(icon: "hand.tap", text: "You confirm or edit the rest")
}
}
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textSecondary(colorScheme))
.padding(Theme.Spacing.md)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
.padding(.horizontal, Theme.Spacing.lg)
}
// MARK: - Processing View
private var processingView: some View {
VStack(spacing: Theme.Spacing.lg) {
Spacer()
ThemedSpinner(size: 50, lineWidth: 4)
Text("Processing photos...")
.font(.system(size: Theme.FontSize.body, weight: .medium))
.foregroundStyle(Theme.textSecondary(colorScheme))
Text("\(viewModel.processedCount) of \(viewModel.totalCount) photos")
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textMuted(colorScheme))
Spacer()
}
}
// MARK: - Results View
private var resultsView: some View {
ScrollView {
VStack(spacing: Theme.Spacing.lg) {
// Summary header
summaryHeader
// Categorized results
if !viewModel.categorized.autoProcessable.isEmpty {
resultSection(
title: "Auto-Matched",
subtitle: "High confidence matches",
icon: "checkmark.circle.fill",
color: .green,
candidates: viewModel.categorized.autoProcessable
)
}
if !viewModel.categorized.needsConfirmation.isEmpty {
resultSection(
title: "Needs Confirmation",
subtitle: "Please verify these matches",
icon: "questionmark.circle.fill",
color: Theme.warmOrange,
candidates: viewModel.categorized.needsConfirmation
)
}
if !viewModel.categorized.needsManualEntry.isEmpty {
resultSection(
title: "Manual Entry Required",
subtitle: "Could not auto-match these photos",
icon: "exclamationmark.triangle.fill",
color: .red,
candidates: viewModel.categorized.needsManualEntry
)
}
// Add more photos button
Button {
viewModel.showingPicker = true
} label: {
HStack {
Image(systemName: "plus.circle")
Text("Add More Photos")
}
.font(.system(size: Theme.FontSize.body))
.foregroundStyle(Theme.warmOrange)
}
.padding(.top, Theme.Spacing.md)
}
.padding(Theme.Spacing.md)
}
}
private var summaryHeader: some View {
HStack(spacing: Theme.Spacing.md) {
summaryBadge(
count: viewModel.categorized.autoProcessable.count,
label: "Auto",
color: .green
)
summaryBadge(
count: viewModel.categorized.needsConfirmation.count,
label: "Confirm",
color: Theme.warmOrange
)
summaryBadge(
count: viewModel.categorized.needsManualEntry.count,
label: "Manual",
color: .red
)
}
.padding(Theme.Spacing.md)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
private func summaryBadge(count: Int, label: String, color: Color) -> some View {
VStack(spacing: 4) {
Text("\(count)")
.font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded))
.foregroundStyle(color)
Text(label)
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textSecondary(colorScheme))
}
.frame(maxWidth: .infinity)
}
private func resultSection(
title: String,
subtitle: String,
icon: String,
color: Color,
candidates: [PhotoImportCandidate]
) -> some View {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
// Section header
HStack {
Image(systemName: icon)
.foregroundStyle(color)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.system(size: Theme.FontSize.body, weight: .semibold))
.foregroundStyle(Theme.textPrimary(colorScheme))
Text(subtitle)
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textSecondary(colorScheme))
}
Spacer()
}
// Candidate cards
ForEach(candidates) { candidate in
PhotoImportCandidateCard(
candidate: candidate,
isConfirmed: viewModel.confirmedImports.contains(candidate.id),
onToggleConfirm: {
viewModel.toggleConfirmation(for: candidate.id)
},
onSelectMatch: { match in
viewModel.selectMatch(match, for: candidate.id)
}
)
}
}
.padding(Theme.Spacing.md)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
}
// MARK: - Actions
private func checkPermissionsAndShowPicker() {
Task {
let status = await PhotoMetadataExtractor.shared.requestPhotoLibraryAccess()
await MainActor.run {
switch status {
case .authorized, .limited:
viewModel.showingPicker = true
case .denied, .restricted:
showingPermissionAlert = true
default:
break
}
}
}
}
private func importSelectedVisits() {
Task {
await viewModel.createVisits(modelContext: modelContext)
dismiss()
}
}
}
// MARK: - Photo Import Candidate Card
struct PhotoImportCandidateCard: View {
let candidate: PhotoImportCandidate
let isConfirmed: Bool
let onToggleConfirm: () -> Void
let onSelectMatch: (GameMatchCandidate) -> Void
@Environment(\.colorScheme) private var colorScheme
@State private var showingMatchPicker = false
var body: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
// Photo date/location info
HStack {
if let date = candidate.metadata.captureDate {
Label(formatDate(date), systemImage: "calendar")
}
if let stadium = candidate.bestStadiumMatch {
Label(stadium.stadium.name, systemImage: "mappin")
}
Spacer()
// Confirm toggle
Button {
onToggleConfirm()
} label: {
Image(systemName: isConfirmed ? "checkmark.circle.fill" : "circle")
.font(.title2)
.foregroundStyle(isConfirmed ? .green : Theme.textMuted(colorScheme))
}
}
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textSecondary(colorScheme))
// Match result
matchResultView
}
.padding(Theme.Spacing.sm)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small))
.sheet(isPresented: $showingMatchPicker) {
if case .multipleMatches(let matches) = candidate.matchResult {
GameMatchPickerSheet(
matches: matches,
onSelect: { match in
onSelectMatch(match)
showingMatchPicker = false
}
)
}
}
}
@ViewBuilder
private var matchResultView: some View {
switch candidate.matchResult {
case .singleMatch(let match):
matchRow(match)
case .multipleMatches(let matches):
Button {
showingMatchPicker = true
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("\(matches.count) possible games")
.font(.system(size: Theme.FontSize.body, weight: .medium))
.foregroundStyle(Theme.textPrimary(colorScheme))
Text("Tap to select the correct game")
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.warmOrange)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
case .noMatches(let reason):
HStack {
Image(systemName: "exclamationmark.triangle")
.foregroundStyle(.red)
Text(reason.description)
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textSecondary(colorScheme))
}
}
}
private func matchRow(_ match: GameMatchCandidate) -> some View {
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)
.foregroundStyle(match.game.sport.themeColor)
}
Text("\(match.stadium.name)\(match.gameDateTime)")
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textSecondary(colorScheme))
// Confidence badge
confidenceBadge(match.confidence.combined)
}
}
private func confidenceBadge(_ confidence: CombinedConfidence) -> some View {
let (text, color): (String, Color) = {
switch confidence {
case .autoSelect:
return ("High confidence", .green)
case .userConfirm:
return ("Needs confirmation", Theme.warmOrange)
case .manualOnly:
return ("Low confidence", .red)
}
}()
return Text(text)
.font(.system(size: 10, weight: .medium))
.foregroundStyle(color)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(color.opacity(0.15))
.clipShape(Capsule())
}
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter.string(from: date)
}
}
// MARK: - Game Match Picker Sheet
struct GameMatchPickerSheet: View {
let matches: [GameMatchCandidate]
let onSelect: (GameMatchCandidate) -> Void
@Environment(\.colorScheme) private var colorScheme
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List(matches) { match in
Button {
onSelect(match)
} label: {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(match.fullMatchupDescription)
.font(.system(size: Theme.FontSize.body, weight: .medium))
.foregroundStyle(Theme.textPrimary(colorScheme))
Spacer()
Image(systemName: match.game.sport.iconName)
.foregroundStyle(match.game.sport.themeColor)
}
Text("\(match.stadium.name)\(match.gameDateTime)")
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textSecondary(colorScheme))
}
.padding(.vertical, 4)
}
}
.navigationTitle("Select Game")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
}
}
}
// MARK: - Info Row
private struct InfoRow: View {
let icon: String
let text: String
var body: some View {
HStack(spacing: 8) {
Image(systemName: icon)
.frame(width: 16)
Text(text)
}
}
}
// MARK: - Preview
#Preview {
PhotoImportView()
.modelContainer(for: StadiumVisit.self, inMemory: true)
}