Add Stadium Progress system and themed loading spinners
Stadium Progress & Achievements: - Add StadiumVisit and Achievement SwiftData models - Create Progress tab with interactive map view - Implement photo-based visit import with GPS/date matching - Add achievement badges (count-based, regional, journey) - Create shareable progress cards for social media - Add canonical data infrastructure (stadium identities, team aliases) - Implement score resolution from free APIs (MLB, NBA, NHL stats) UI Improvements: - Add ThemedSpinner and ThemedSpinnerCompact components - Replace all ProgressView() with themed spinners throughout app - Fix sport selection state not persisting when navigating away Bug Fixes: - Fix Coast to Coast trips showing only 1 city (validation issue) - Fix stadium progress showing 0/0 (filtering issue) - Remove "Stadium Quest" title from progress view 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
538
SportsTime/Features/Progress/Views/VisitDetailView.swift
Normal file
538
SportsTime/Features/Progress/Views/VisitDetailView.swift
Normal file
@@ -0,0 +1,538 @@
|
||||
//
|
||||
// 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(
|
||||
name: "Oracle Park",
|
||||
city: "San Francisco",
|
||||
state: "CA",
|
||||
latitude: 37.7786,
|
||||
longitude: -122.3893,
|
||||
capacity: 41915,
|
||||
sport: .mlb
|
||||
)
|
||||
|
||||
NavigationStack {
|
||||
Text("Preview placeholder")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user