Files
Sportstime/SportsTime/Features/Progress/Views/VisitDetailView.swift
Trey t 1703ca5b0f refactor: change domain model IDs from UUID to String canonical IDs
This refactor fixes the achievement system by using stable canonical string
IDs (e.g., "stadium_mlb_fenway_park") instead of random UUIDs. This ensures
stadium mappings for achievements are consistent across app launches and
CloudKit sync operations.

Changes:
- Stadium, Team, Game: id property changed from UUID to String
- Trip, TripStop, TripPreferences: updated to use String IDs for games/stadiums
- CKModels: removed UUID parsing, use canonical IDs directly
- AchievementEngine: now matches against canonical stadium IDs
- All test files updated to use String IDs instead of UUID()

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 09:24:33 -06:00

540 lines
18 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
// 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.")
}
}
// 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)
}
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)
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)
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)
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 var formattedDate: String {
let formatter = DateFormatter()
formatter.dateStyle = .long
return formatter.string(from: visit.visitDate)
}
private var formattedCreatedDate: String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .short
return formatter.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
}
try? modelContext.save()
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)
try? modelContext.save()
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 = 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")
}
}