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:
357
SportsTime/Features/Progress/Views/StadiumVisitSheet.swift
Normal file
357
SportsTime/Features/Progress/Views/StadiumVisitSheet.swift
Normal file
@@ -0,0 +1,357 @@
|
||||
//
|
||||
// StadiumVisitSheet.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Sheet for manually logging a stadium visit.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct StadiumVisitSheet: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
// Optional pre-selected values
|
||||
var initialStadium: Stadium?
|
||||
var initialSport: Sport?
|
||||
var onSave: ((StadiumVisit) -> Void)?
|
||||
|
||||
// Form state
|
||||
@State private var selectedSport: Sport
|
||||
@State private var selectedStadium: Stadium?
|
||||
@State private var visitDate: Date = Date()
|
||||
@State private var visitType: VisitType = .game
|
||||
@State private var homeTeamName: String = ""
|
||||
@State private var awayTeamName: String = ""
|
||||
@State private var homeScore: String = ""
|
||||
@State private var awayScore: String = ""
|
||||
@State private var seatLocation: String = ""
|
||||
@State private var notes: String = ""
|
||||
|
||||
// UI state
|
||||
@State private var showStadiumPicker = false
|
||||
@State private var isSaving = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
// Data
|
||||
private let dataProvider = AppDataProvider.shared
|
||||
|
||||
init(
|
||||
initialStadium: Stadium? = nil,
|
||||
initialSport: Sport? = nil,
|
||||
onSave: ((StadiumVisit) -> Void)? = nil
|
||||
) {
|
||||
self.initialStadium = initialStadium
|
||||
self.initialSport = initialSport
|
||||
self.onSave = onSave
|
||||
_selectedSport = State(initialValue: initialSport ?? .mlb)
|
||||
_selectedStadium = State(initialValue: initialStadium)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
// Sport & Stadium Section
|
||||
Section {
|
||||
// Sport Picker
|
||||
Picker("Sport", selection: $selectedSport) {
|
||||
ForEach(Sport.supported) { sport in
|
||||
HStack {
|
||||
Image(systemName: sport.iconName)
|
||||
Text(sport.displayName)
|
||||
}
|
||||
.tag(sport)
|
||||
}
|
||||
}
|
||||
|
||||
// Stadium Selection
|
||||
Button {
|
||||
showStadiumPicker = true
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Stadium")
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
Spacer()
|
||||
if let stadium = selectedStadium {
|
||||
Text(stadium.name)
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
} else {
|
||||
Text("Select Stadium")
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Location")
|
||||
}
|
||||
|
||||
// Visit Details Section
|
||||
Section {
|
||||
DatePicker("Date", selection: $visitDate, displayedComponents: .date)
|
||||
|
||||
Picker("Visit Type", selection: $visitType) {
|
||||
ForEach(VisitType.allCases, id: \.self) { type in
|
||||
Text(type.displayName).tag(type)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Visit Details")
|
||||
}
|
||||
|
||||
// Game Info Section (only for game visits)
|
||||
if visitType == .game {
|
||||
Section {
|
||||
HStack {
|
||||
Text("Away Team")
|
||||
Spacer()
|
||||
TextField("Team Name", text: $awayTeamName)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Home Team")
|
||||
Spacer()
|
||||
TextField("Team Name", text: $homeTeamName)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Final Score")
|
||||
Spacer()
|
||||
TextField("Away", text: $awayScore)
|
||||
.keyboardType(.numberPad)
|
||||
.frame(width: 50)
|
||||
.multilineTextAlignment(.center)
|
||||
Text("-")
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
TextField("Home", text: $homeScore)
|
||||
.keyboardType(.numberPad)
|
||||
.frame(width: 50)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
} header: {
|
||||
Text("Game Info")
|
||||
} footer: {
|
||||
Text("Leave blank if you don't remember the score")
|
||||
}
|
||||
}
|
||||
|
||||
// Optional Details Section
|
||||
Section {
|
||||
HStack {
|
||||
Text("Seat Location")
|
||||
Spacer()
|
||||
TextField("e.g., Section 120", text: $seatLocation)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
||||
Text("Notes")
|
||||
TextEditor(text: $notes)
|
||||
.frame(minHeight: 80)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Theme.cardBackgroundElevated(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small))
|
||||
}
|
||||
} header: {
|
||||
Text("Additional Info")
|
||||
}
|
||||
|
||||
// Error Message
|
||||
if let error = errorMessage {
|
||||
Section {
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
Text(error)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.navigationTitle("Log Visit")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
saveVisit()
|
||||
}
|
||||
.disabled(!canSave || isSaving)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showStadiumPicker) {
|
||||
StadiumPickerSheet(
|
||||
sport: selectedSport,
|
||||
selectedStadium: $selectedStadium
|
||||
)
|
||||
}
|
||||
.onChange(of: selectedSport) { _, _ in
|
||||
// Clear stadium selection when sport changes
|
||||
if let stadium = selectedStadium {
|
||||
// Check if stadium belongs to new sport
|
||||
let sportTeams = dataProvider.teams.filter { $0.sport == selectedSport }
|
||||
if !sportTeams.contains(where: { $0.stadiumId == stadium.id }) {
|
||||
selectedStadium = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
private var canSave: Bool {
|
||||
selectedStadium != nil
|
||||
}
|
||||
|
||||
private var finalScoreString: String? {
|
||||
guard let away = Int(awayScore), let home = Int(homeScore) else {
|
||||
return nil
|
||||
}
|
||||
return "\(away)-\(home)"
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func saveVisit() {
|
||||
guard let stadium = selectedStadium else {
|
||||
errorMessage = "Please select a stadium"
|
||||
return
|
||||
}
|
||||
|
||||
isSaving = true
|
||||
errorMessage = nil
|
||||
|
||||
// Create the visit
|
||||
let visit = StadiumVisit(
|
||||
canonicalStadiumId: stadium.id.uuidString, // Simplified - in production use StadiumIdentityService
|
||||
stadiumUUID: stadium.id,
|
||||
stadiumNameAtVisit: stadium.name,
|
||||
visitDate: visitDate,
|
||||
sport: selectedSport,
|
||||
visitType: visitType,
|
||||
homeTeamName: homeTeamName.isEmpty ? nil : homeTeamName,
|
||||
awayTeamName: awayTeamName.isEmpty ? nil : awayTeamName,
|
||||
finalScore: finalScoreString,
|
||||
scoreSource: finalScoreString != nil ? .user : nil,
|
||||
dataSource: .fullyManual,
|
||||
seatLocation: seatLocation.isEmpty ? nil : seatLocation,
|
||||
notes: notes.isEmpty ? nil : notes,
|
||||
source: .manual
|
||||
)
|
||||
|
||||
// Save to SwiftData
|
||||
modelContext.insert(visit)
|
||||
|
||||
do {
|
||||
try modelContext.save()
|
||||
onSave?(visit)
|
||||
dismiss()
|
||||
} catch {
|
||||
errorMessage = "Failed to save visit: \(error.localizedDescription)"
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stadium Picker Sheet
|
||||
|
||||
struct StadiumPickerSheet: View {
|
||||
let sport: Sport
|
||||
@Binding var selectedStadium: Stadium?
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var searchText = ""
|
||||
|
||||
private let dataProvider = AppDataProvider.shared
|
||||
|
||||
private var stadiums: [Stadium] {
|
||||
let sportTeams = dataProvider.teams.filter { $0.sport == sport }
|
||||
let stadiumIds = Set(sportTeams.map { $0.stadiumId })
|
||||
return dataProvider.stadiums.filter { stadiumIds.contains($0.id) }
|
||||
}
|
||||
|
||||
private var filteredStadiums: [Stadium] {
|
||||
if searchText.isEmpty {
|
||||
return stadiums.sorted { $0.name < $1.name }
|
||||
}
|
||||
return stadiums.filter {
|
||||
$0.name.localizedCaseInsensitiveContains(searchText) ||
|
||||
$0.city.localizedCaseInsensitiveContains(searchText)
|
||||
}.sorted { $0.name < $1.name }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if stadiums.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Stadiums",
|
||||
systemImage: "building.2",
|
||||
description: Text("No stadiums found for \(sport.displayName)")
|
||||
)
|
||||
} else if filteredStadiums.isEmpty {
|
||||
ContentUnavailableView.search(text: searchText)
|
||||
} else {
|
||||
List(filteredStadiums) { stadium in
|
||||
Button {
|
||||
selectedStadium = stadium
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(stadium.name)
|
||||
.font(.system(size: Theme.FontSize.body, weight: .medium))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Text(stadium.fullAddress)
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if selectedStadium?.id == stadium.id {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
}
|
||||
}
|
||||
.searchable(text: $searchText, prompt: "Search stadiums")
|
||||
.navigationTitle("Select Stadium")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
StadiumVisitSheet()
|
||||
.modelContainer(for: StadiumVisit.self, inMemory: true)
|
||||
}
|
||||
Reference in New Issue
Block a user