// // 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 { Group { if viewModel.isProcessing { processingView } else if viewModel.processedPhotos.isEmpty { emptyStateView } else { resultsView } } .frame(maxWidth: .infinity, maxHeight: .infinity) .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(.largeTitle) .foregroundStyle(Theme.warmOrange) } VStack(spacing: Theme.Spacing.sm) { Text("Import from Photos") .font(.title2) .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(.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(.body) .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) .accessibilityHidden(true) Text("How it works") .font(.body) } 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(.subheadline) .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() LoadingSpinner(size: .large) VStack(spacing: Theme.Spacing.xs) { Text("Processing Photos") .font(.headline) .foregroundStyle(Theme.textPrimary(colorScheme)) Text("\(viewModel.processedCount) of \(viewModel.totalCount)") .font(.subheadline) .foregroundStyle(Theme.textSecondary(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(.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(.title2) .foregroundStyle(color) Text(label) .font(.subheadline) .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(.body) .foregroundStyle(Theme.textPrimary(colorScheme)) Text(subtitle) .font(.subheadline) .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)) } .accessibilityLabel(isConfirmed ? "Deselect for import" : "Confirm import") } .font(.subheadline) .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(.body) .foregroundStyle(Theme.textPrimary(colorScheme)) Text("Tap to select the correct game") .font(.subheadline) .foregroundStyle(Theme.warmOrange) } Spacer() Image(systemName: "chevron.right") .foregroundStyle(Theme.textMuted(colorScheme)) .accessibilityHidden(true) } } case .noMatches(let reason): HStack { Image(systemName: "exclamationmark.triangle") .foregroundStyle(.red) .accessibilityLabel("Error") Text(reason.description) .font(.subheadline) .foregroundStyle(Theme.textSecondary(colorScheme)) } } } private func matchRow(_ match: GameMatchCandidate) -> some View { VStack(alignment: .leading, spacing: 4) { HStack { Text(match.fullMatchupDescription) .font(.body) .foregroundStyle(Theme.textPrimary(colorScheme)) Image(systemName: match.game.sport.iconName) .foregroundStyle(match.game.sport.themeColor) } Text("\(match.stadium.name) • \(match.gameDateTime)") .font(.subheadline) .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(.caption2) .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(.body) .foregroundStyle(Theme.textPrimary(colorScheme)) Spacer() Image(systemName: match.game.sport.iconName) .foregroundStyle(match.game.sport.themeColor) } Text("\(match.stadium.name) • \(match.gameDateTime)") .font(.subheadline) .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) .accessibilityHidden(true) Text(text) } } } // MARK: - Preview #Preview { PhotoImportView() .modelContainer(for: StadiumVisit.self, inMemory: true) }