Files
Sportstime/SportsTime/Features/Progress/Views/PhotoImportView.swift
Trey t c94e373e33 fix: comprehensive codebase hardening — crashes, silent failures, performance, and security
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>
2026-02-27 17:03:09 -06:00

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