Files
Sportstime/SportsTime/Features/Progress/Views/VisitDetailView.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

575 lines
19 KiB
Swift

//
// VisitDetailView.swift
// SportsTime
//
// View for displaying and editing a stadium visit's details.
//
import SwiftUI
import SwiftData
struct VisitDetailView: View {
@Bindable var visit: StadiumVisit
let stadium: Stadium
@Environment(\.modelContext) private var modelContext
@Environment(\.colorScheme) private var colorScheme
@Environment(\.dismiss) private var dismiss
@State private var isEditing = false
@State private var showDeleteConfirmation = false
@State private var errorMessage: String?
// Edit state
@State private var editVisitDate: Date
@State private var editVisitType: VisitType
@State private var editHomeTeamName: String
@State private var editAwayTeamName: String
@State private var editHomeScore: String
@State private var editAwayScore: String
@State private var editSeatLocation: String
@State private var editNotes: String
init(visit: StadiumVisit, stadium: Stadium) {
self.visit = visit
self.stadium = stadium
// Initialize edit state from visit
_editVisitDate = State(initialValue: visit.visitDate)
_editVisitType = State(initialValue: visit.visitType)
_editHomeTeamName = State(initialValue: visit.homeTeamName ?? "")
_editAwayTeamName = State(initialValue: visit.awayTeamName ?? "")
// Parse score if available
if let score = visit.finalScore {
let parts = score.split(separator: "-")
if parts.count == 2 {
_editAwayScore = State(initialValue: String(parts[0]))
_editHomeScore = State(initialValue: String(parts[1]))
} else {
_editAwayScore = State(initialValue: "")
_editHomeScore = State(initialValue: "")
}
} else {
_editAwayScore = State(initialValue: "")
_editHomeScore = State(initialValue: "")
}
_editSeatLocation = State(initialValue: visit.seatLocation ?? "")
_editNotes = State(initialValue: visit.notes ?? "")
}
var body: some View {
ScrollView {
VStack(spacing: Theme.Spacing.lg) {
// Header
visitHeader
.staggeredAnimation(index: 0)
// Game info (if applicable)
if visit.visitType == .game {
gameInfoCard
.staggeredAnimation(index: 1)
}
// Visit details
detailsCard
.staggeredAnimation(index: 2)
// Notes
if !isEditing && (visit.notes?.isEmpty == false) {
notesCard
.staggeredAnimation(index: 3)
}
// Edit form (when editing)
if isEditing {
editForm
.staggeredAnimation(index: 4)
}
// Delete button
if !isEditing {
deleteButton
.staggeredAnimation(index: 5)
}
}
.padding(Theme.Spacing.md)
}
.themedBackground()
.navigationTitle(isEditing ? "Edit Visit" : "Visit Details")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
if isEditing {
Button("Save") {
saveChanges()
}
.fontWeight(.semibold)
} else {
Button("Edit") {
withAnimation {
isEditing = true
}
}
}
}
if isEditing {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
cancelEditing()
}
}
}
}
.confirmationDialog(
"Delete Visit",
isPresented: $showDeleteConfirmation,
titleVisibility: .visible
) {
Button("Delete Visit", role: .destructive) {
deleteVisit()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Are you sure you want to delete this visit? This action cannot be undone.")
}
.alert("Error", isPresented: Binding(
get: { errorMessage != nil },
set: { if !$0 { errorMessage = nil } }
)) {
Button("OK", role: .cancel) {
errorMessage = nil
}
} message: {
if let errorMessage {
Text(errorMessage)
}
}
}
// MARK: - Header
private var visitHeader: some View {
VStack(spacing: Theme.Spacing.md) {
// Sport icon
ZStack {
Circle()
.fill(sportColor.opacity(0.15))
.frame(width: 80, height: 80)
Image(systemName: visit.sportEnum?.iconName ?? "sportscourt")
.font(.largeTitle)
.foregroundStyle(sportColor)
.accessibilityLabel(visit.sportEnum?.displayName ?? "Sport")
}
VStack(spacing: Theme.Spacing.xs) {
Text(stadium.name)
.font(.headline)
.foregroundStyle(Theme.textPrimary(colorScheme))
.multilineTextAlignment(.center)
Text(stadium.fullAddress)
.font(.body)
.foregroundStyle(Theme.textSecondary(colorScheme))
// Visit type badge
Text(visit.visitType.displayName)
.font(.subheadline)
.foregroundStyle(.white)
.padding(.horizontal, Theme.Spacing.sm)
.padding(.vertical, 4)
.background(sportColor)
.clipShape(Capsule())
}
}
.frame(maxWidth: .infinity)
.padding(Theme.Spacing.lg)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
.overlay {
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
}
}
// MARK: - Game Info Card
private var gameInfoCard: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
HStack {
Image(systemName: "sportscourt.fill")
.foregroundStyle(sportColor)
.accessibilityHidden(true)
Text("Game Info")
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
}
if let matchup = visit.matchupDescription {
VStack(alignment: .leading, spacing: 4) {
Text("Matchup")
.foregroundStyle(Theme.textSecondary(colorScheme))
Text(matchup)
.foregroundStyle(Theme.textPrimary(colorScheme))
.fontWeight(.medium)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
if let score = visit.finalScore {
VStack(alignment: .leading, spacing: 4) {
Text("Final Score")
.foregroundStyle(Theme.textSecondary(colorScheme))
Text(score)
.foregroundStyle(Theme.textPrimary(colorScheme))
.fontWeight(.bold)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.font(.body)
.padding(Theme.Spacing.lg)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
.overlay {
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
}
}
// MARK: - Details Card
private var detailsCard: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
HStack {
Image(systemName: "info.circle.fill")
.foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
Text("Details")
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
}
// Date
VStack(alignment: .leading, spacing: 4) {
Text("Date")
.foregroundStyle(Theme.textSecondary(colorScheme))
Text(formattedDate)
.foregroundStyle(Theme.textPrimary(colorScheme))
}
.frame(maxWidth: .infinity, alignment: .leading)
// Seat location
if let seat = visit.seatLocation, !seat.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("Seat")
.foregroundStyle(Theme.textSecondary(colorScheme))
Text(seat)
.foregroundStyle(Theme.textPrimary(colorScheme))
}
.frame(maxWidth: .infinity, alignment: .leading)
}
// Source
VStack(alignment: .leading, spacing: 4) {
Text("Source")
.foregroundStyle(Theme.textSecondary(colorScheme))
Text(visit.source.displayName)
.foregroundStyle(Theme.textMuted(colorScheme))
}
.frame(maxWidth: .infinity, alignment: .leading)
// Created date
VStack(alignment: .leading, spacing: 4) {
Text("Logged")
.foregroundStyle(Theme.textSecondary(colorScheme))
Text(formattedCreatedDate)
.foregroundStyle(Theme.textMuted(colorScheme))
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.font(.body)
.padding(Theme.Spacing.lg)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
.overlay {
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
}
}
// MARK: - Notes Card
private var notesCard: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
HStack {
Image(systemName: "note.text")
.foregroundStyle(Theme.routeGold)
.accessibilityHidden(true)
Text("Notes")
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
}
Text(visit.notes ?? "")
.font(.body)
.foregroundStyle(Theme.textSecondary(colorScheme))
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Theme.Spacing.lg)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
.overlay {
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
}
}
// MARK: - Edit Form
private var editForm: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.lg) {
// Date
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text("Date")
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
DatePicker("", selection: $editVisitDate, displayedComponents: .date)
.labelsHidden()
}
// Visit Type
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text("Visit Type")
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
Picker("", selection: $editVisitType) {
ForEach(VisitType.allCases, id: \.self) { type in
Text(type.displayName).tag(type)
}
}
.pickerStyle(.segmented)
}
// Game info (if game type)
if editVisitType == .game {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
Text("Game Info")
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
HStack {
TextField("Away Team", text: $editAwayTeamName)
.textFieldStyle(.roundedBorder)
Text("@")
.foregroundStyle(Theme.textMuted(colorScheme))
TextField("Home Team", text: $editHomeTeamName)
.textFieldStyle(.roundedBorder)
}
HStack {
TextField("Away Score", text: $editAwayScore)
.textFieldStyle(.roundedBorder)
.keyboardType(.numberPad)
.frame(width: 80)
Text("-")
.foregroundStyle(Theme.textMuted(colorScheme))
TextField("Home Score", text: $editHomeScore)
.textFieldStyle(.roundedBorder)
.keyboardType(.numberPad)
.frame(width: 80)
Spacer()
}
}
}
// Seat location
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text("Seat Location")
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
TextField("e.g., Section 120, Row 5", text: $editSeatLocation)
.textFieldStyle(.roundedBorder)
}
// Notes
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text("Notes")
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
TextEditor(text: $editNotes)
.frame(minHeight: 100)
.scrollContentBackground(.hidden)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small))
.overlay {
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
}
}
}
.padding(Theme.Spacing.lg)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
.overlay {
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
.stroke(Theme.warmOrange.opacity(0.5), lineWidth: 2)
}
}
// MARK: - Delete Button
private var deleteButton: some View {
Button(role: .destructive) {
showDeleteConfirmation = true
} label: {
HStack {
Image(systemName: "trash")
Text("Delete Visit")
}
.frame(maxWidth: .infinity)
.padding(Theme.Spacing.md)
.background(Color.red.opacity(0.1))
.foregroundStyle(.red)
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
}
// MARK: - Computed Properties
private var sportColor: Color {
visit.sportEnum?.themeColor ?? Theme.warmOrange
}
private static let longDateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .long
return f
}()
private static let mediumDateShortTimeFormatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
f.timeStyle = .short
return f
}()
private var formattedDate: String {
Self.longDateFormatter.string(from: visit.visitDate)
}
private var formattedCreatedDate: String {
Self.mediumDateShortTimeFormatter.string(from: visit.createdAt)
}
// MARK: - Actions
private func saveChanges() {
visit.visitDate = editVisitDate
visit.visitType = editVisitType
visit.homeTeamName = editHomeTeamName.isEmpty ? nil : editHomeTeamName
visit.awayTeamName = editAwayTeamName.isEmpty ? nil : editAwayTeamName
visit.seatLocation = editSeatLocation.isEmpty ? nil : editSeatLocation
visit.notes = editNotes.isEmpty ? nil : editNotes
// Update score
if let away = Int(editAwayScore), let home = Int(editHomeScore) {
visit.finalScore = "\(away)-\(home)"
visit.scoreSource = .user
} else {
visit.finalScore = nil
visit.scoreSource = nil
}
// Mark as user corrected if it wasn't fully manual
if visit.dataSource != .fullyManual {
visit.dataSource = .userCorrected
}
do {
try modelContext.save()
} catch {
errorMessage = "Failed to save: \(error.localizedDescription)"
return
}
withAnimation {
isEditing = false
}
}
private func cancelEditing() {
// Reset to original values
editVisitDate = visit.visitDate
editVisitType = visit.visitType
editHomeTeamName = visit.homeTeamName ?? ""
editAwayTeamName = visit.awayTeamName ?? ""
editSeatLocation = visit.seatLocation ?? ""
editNotes = visit.notes ?? ""
if let score = visit.finalScore {
let parts = score.split(separator: "-")
if parts.count == 2 {
editAwayScore = String(parts[0])
editHomeScore = String(parts[1])
}
} else {
editAwayScore = ""
editHomeScore = ""
}
withAnimation {
isEditing = false
}
}
private func deleteVisit() {
modelContext.delete(visit)
do {
try modelContext.save()
} catch {
errorMessage = "Failed to delete: \(error.localizedDescription)"
return
}
dismiss()
}
}
// MARK: - Visit Source Display Name
extension VisitSource {
var displayName: String {
switch self {
case .trip: return "From Trip"
case .manual: return "Manual Entry"
case .photoImport: return "Photo Import"
}
}
}
// MARK: - Preview
#Preview {
let _ = Stadium(
id: "stadium_preview_oracle_park",
name: "Oracle Park",
city: "San Francisco",
state: "CA",
latitude: 37.7786,
longitude: -122.3893,
capacity: 41915,
sport: .mlb
)
NavigationStack {
Text("Preview placeholder")
}
}