Fixes ~95 issues from deep audit across 12 categories in 82 files: - Crash prevention: double-resume in PhotoMetadataExtractor, force unwraps in DateRangePicker, array bounds checks in polls/achievements, ProGate hit-test bypass, Dictionary(uniqueKeysWithValues:) → uniquingKeysWith in 4 files - Silent failure elimination: all 34 try? sites replaced with do/try/catch + logging (SavedTrip, TripDetailView, CanonicalSyncService, BootstrapService, CanonicalModels, CKModels, SportsTimeApp, and more) - Performance: cached DateFormatters (7 files), O(1) team lookups via AppDataProvider, achievement definition dictionary, AnimatedBackground consolidated from 19 Tasks to 1, task cancellation in SharePreviewView - Concurrency: UIKit drawing → MainActor.run, background fetch timeout guard, @MainActor on ThemeManager/AppearanceManager, SyncLogger read/write race fix - Planning engine: game end time in travel feasibility, state-aware city normalization, exact city matching, DrivingConstraints parameter propagation - IAP: unknown subscription states → expired, unverified transaction logging, entitlements updated before paywall dismiss, restore visible to all users - Security: API key to Info.plist lookup, filename sanitization in PDF export, honest User-Agent, removed stale "Feels" analytics super properties - Navigation: consolidated competing navigationDestination, boolean → value-based - Testing: 8 sleep() → waitForExistence, duplicates extracted, Swift 6 compat - Service bugs: infinite retry cap, duplicate achievement prevention, TOCTOU vote fix, PollVote.odg → voterId rename, deterministic placeholder IDs, parallel MKDirections, Sendable-safe POI struct Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
561 lines
18 KiB
Swift
561 lines
18 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 {
|
|
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 static let mediumDateFormatter: DateFormatter = {
|
|
let f = DateFormatter()
|
|
f.dateStyle = .medium
|
|
return f
|
|
}()
|
|
|
|
private func formatDate(_ date: Date) -> String {
|
|
Self.mediumDateFormatter.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)
|
|
}
|