- Add VoiceOver labels, hints, and element grouping across all 60+ views - Add Reduce Motion support (Theme.Animation.prefersReducedMotion) to all animations - Replace fixed font sizes with semantic Dynamic Type styles - Hide decorative elements from VoiceOver with .accessibilityHidden(true) - Add .minimumHitTarget() modifier ensuring 44pt touch targets - Add AccessibilityAnnouncer utility for VoiceOver announcements - Improve color contrast values in Theme.swift for WCAG AA compliance - Extract CloudKitContainerConfig for explicit container identity - Remove PostHog debug console log from AnalyticsManager Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
544 lines
18 KiB
Swift
544 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)
|
|
.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 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(
|
|
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")
|
|
}
|
|
}
|