- Add local canonicalization pipeline (stadiums, teams, games) that generates deterministic canonical IDs before CloudKit upload - Fix CanonicalSyncService to use deterministic UUIDs from canonical IDs instead of random UUIDs from CloudKit records - Add SyncStadium/SyncTeam/SyncGame types to CloudKitService that preserve canonical ID relationships during sync - Add canonical ID field keys to CKModels for reading from CloudKit records - Bundle canonical JSON files (stadiums_canonical, teams_canonical, games_canonical, stadium_aliases) for consistent bootstrap data - Update BootstrapService to prefer canonical format files over legacy format This ensures all entities use consistent deterministic UUIDs derived from their canonical IDs, preventing duplicate records when syncing CloudKit data with bootstrapped local data. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
540 lines
18 KiB
Swift
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(.system(size: 36))
|
|
.foregroundStyle(sportColor)
|
|
}
|
|
|
|
VStack(spacing: Theme.Spacing.xs) {
|
|
Text(stadium.name)
|
|
.font(.system(size: Theme.FontSize.cardTitle, weight: .bold, design: .rounded))
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
.multilineTextAlignment(.center)
|
|
|
|
Text(stadium.fullAddress)
|
|
.font(.system(size: Theme.FontSize.body))
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
|
|
// Visit type badge
|
|
Text(visit.visitType.displayName)
|
|
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
|
.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(.system(size: Theme.FontSize.body, weight: .semibold))
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
}
|
|
|
|
if let matchup = visit.matchupDescription {
|
|
HStack {
|
|
Text("Matchup")
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
Spacer()
|
|
Text(matchup)
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
.fontWeight(.medium)
|
|
}
|
|
}
|
|
|
|
if let score = visit.finalScore {
|
|
HStack {
|
|
Text("Final Score")
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
Spacer()
|
|
Text(score)
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
.fontWeight(.bold)
|
|
}
|
|
}
|
|
}
|
|
.font(.system(size: Theme.FontSize.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(.system(size: Theme.FontSize.body, weight: .semibold))
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
}
|
|
|
|
// Date
|
|
HStack {
|
|
Text("Date")
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
Spacer()
|
|
Text(formattedDate)
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
}
|
|
|
|
// Seat location
|
|
if let seat = visit.seatLocation, !seat.isEmpty {
|
|
HStack {
|
|
Text("Seat")
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
Spacer()
|
|
Text(seat)
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
}
|
|
}
|
|
|
|
// Source
|
|
HStack {
|
|
Text("Source")
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
Spacer()
|
|
Text(visit.source.displayName)
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
}
|
|
|
|
// Created date
|
|
HStack {
|
|
Text("Logged")
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
Spacer()
|
|
Text(formattedCreatedDate)
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
}
|
|
}
|
|
.font(.system(size: Theme.FontSize.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(.system(size: Theme.FontSize.body, weight: .semibold))
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
}
|
|
|
|
Text(visit.notes ?? "")
|
|
.font(.system(size: Theme.FontSize.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(.system(size: Theme.FontSize.caption, weight: .medium))
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
|
|
DatePicker("", selection: $editVisitDate, displayedComponents: .date)
|
|
.labelsHidden()
|
|
}
|
|
|
|
// Visit Type
|
|
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
|
Text("Visit Type")
|
|
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
|
.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(.system(size: Theme.FontSize.caption, weight: .medium))
|
|
.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(.system(size: Theme.FontSize.caption, weight: .medium))
|
|
.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(.system(size: Theme.FontSize.caption, weight: .medium))
|
|
.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: UUID(),
|
|
name: "Oracle Park",
|
|
city: "San Francisco",
|
|
state: "CA",
|
|
latitude: 37.7786,
|
|
longitude: -122.3893,
|
|
capacity: 41915,
|
|
sport: .mlb
|
|
)
|
|
|
|
NavigationStack {
|
|
Text("Preview placeholder")
|
|
}
|
|
}
|