// // 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) }