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:
Trey t
2026-01-08 20:20:03 -06:00
parent 2281440bf8
commit 92d808caf5
55 changed files with 14348 additions and 61 deletions

View File

@@ -9,11 +9,11 @@ struct TripCreationView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
@Bindable var viewModel: TripCreationViewModel
let initialSport: Sport?
@State private var viewModel = TripCreationViewModel()
init(initialSport: Sport? = nil) {
init(viewModel: TripCreationViewModel, initialSport: Sport? = nil) {
self.viewModel = viewModel
self.initialSport = initialSport
}
@State private var showGamePicker = false
@@ -25,6 +25,16 @@ struct TripCreationView: View {
@State private var completedTrip: Trip?
@State private var tripOptions: [ItineraryOption] = []
// Location search state
@State private var startLocationSuggestions: [LocationSearchResult] = []
@State private var endLocationSuggestions: [LocationSearchResult] = []
@State private var startSearchTask: Task<Void, Never>?
@State private var endSearchTask: Task<Void, Never>?
@State private var isSearchingStart = false
@State private var isSearchingEnd = false
private let locationService = LocationService.shared
enum CityInputType {
case mustStop
case preferred
@@ -214,35 +224,192 @@ struct TripCreationView: View {
private var locationSection: some View {
ThemedSection(title: "Locations") {
ThemedTextField(
label: "Start Location",
placeholder: "Where are you starting from?",
text: $viewModel.startLocationText,
icon: "location.circle.fill"
)
// Start Location with suggestions
VStack(alignment: .leading, spacing: 0) {
ThemedTextField(
label: "Start Location",
placeholder: "Where are you starting from?",
text: $viewModel.startLocationText,
icon: "location.circle.fill"
)
.onChange(of: viewModel.startLocationText) { _, newValue in
searchLocation(query: newValue, isStart: true)
}
ThemedTextField(
label: "End Location",
placeholder: "Where do you want to end up?",
text: $viewModel.endLocationText,
icon: "mappin.circle.fill"
)
// Suggestions for start location
if !startLocationSuggestions.isEmpty {
locationSuggestionsList(
suggestions: startLocationSuggestions,
isLoading: isSearchingStart
) { result in
viewModel.startLocationText = result.name
viewModel.startLocation = result.toLocationInput()
startLocationSuggestions = []
}
} else if isSearchingStart {
HStack {
ThemedSpinnerCompact(size: 14)
Text("Searching...")
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textMuted(colorScheme))
}
.padding(.top, Theme.Spacing.xs)
}
}
// End Location with suggestions
VStack(alignment: .leading, spacing: 0) {
ThemedTextField(
label: "End Location",
placeholder: "Where do you want to end up?",
text: $viewModel.endLocationText,
icon: "mappin.circle.fill"
)
.onChange(of: viewModel.endLocationText) { _, newValue in
searchLocation(query: newValue, isStart: false)
}
// Suggestions for end location
if !endLocationSuggestions.isEmpty {
locationSuggestionsList(
suggestions: endLocationSuggestions,
isLoading: isSearchingEnd
) { result in
viewModel.endLocationText = result.name
viewModel.endLocation = result.toLocationInput()
endLocationSuggestions = []
}
} else if isSearchingEnd {
HStack {
ThemedSpinnerCompact(size: 14)
Text("Searching...")
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textMuted(colorScheme))
}
.padding(.top, Theme.Spacing.xs)
}
}
}
}
private func searchLocation(query: String, isStart: Bool) {
// Cancel previous search
if isStart {
startSearchTask?.cancel()
} else {
endSearchTask?.cancel()
}
guard query.count >= 2 else {
if isStart {
startLocationSuggestions = []
isSearchingStart = false
} else {
endLocationSuggestions = []
isSearchingEnd = false
}
return
}
let task = Task {
// Debounce
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
if isStart {
isSearchingStart = true
} else {
isSearchingEnd = true
}
do {
let results = try await locationService.searchLocations(query)
guard !Task.isCancelled else { return }
if isStart {
startLocationSuggestions = Array(results.prefix(5))
isSearchingStart = false
} else {
endLocationSuggestions = Array(results.prefix(5))
isSearchingEnd = false
}
} catch {
if isStart {
startLocationSuggestions = []
isSearchingStart = false
} else {
endLocationSuggestions = []
isSearchingEnd = false
}
}
}
if isStart {
startSearchTask = task
} else {
endSearchTask = task
}
}
@ViewBuilder
private func locationSuggestionsList(
suggestions: [LocationSearchResult],
isLoading: Bool,
onSelect: @escaping (LocationSearchResult) -> Void
) -> some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(suggestions) { result in
Button {
onSelect(result)
} label: {
HStack(spacing: Theme.Spacing.sm) {
Image(systemName: "mappin.circle.fill")
.foregroundStyle(Theme.warmOrange)
.font(.system(size: 14))
VStack(alignment: .leading, spacing: 2) {
Text(result.name)
.font(.system(size: Theme.FontSize.caption, weight: .medium))
.foregroundStyle(Theme.textPrimary(colorScheme))
if !result.address.isEmpty {
Text(result.address)
.font(.system(size: Theme.FontSize.micro))
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
Spacer()
}
.padding(.vertical, Theme.Spacing.sm)
.padding(.horizontal, Theme.Spacing.xs)
}
.buttonStyle(.plain)
if result.id != suggestions.last?.id {
Divider()
.overlay(Theme.surfaceGlow(colorScheme))
}
}
}
.padding(.top, Theme.Spacing.xs)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small))
}
private var gameBrowserSection: some View {
ThemedSection(title: "Select Games") {
if viewModel.isLoadingGames || viewModel.availableGames.isEmpty {
HStack(spacing: Theme.Spacing.sm) {
ProgressView()
.tint(Theme.warmOrange)
ThemedSpinnerCompact(size: 20)
Text("Loading games...")
.font(.system(size: Theme.FontSize.body))
.foregroundStyle(Theme.textSecondary(colorScheme))
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, Theme.Spacing.md)
.task {
.task(id: viewModel.selectedSports) {
// Re-run when sports selection changes
if viewModel.availableGames.isEmpty {
await viewModel.loadGamesForBrowsing()
}
@@ -290,6 +457,16 @@ struct TripCreationView: View {
Text("\(viewModel.mustSeeGameIds.count) game(s) selected")
.font(.system(size: Theme.FontSize.body, weight: .medium))
.foregroundStyle(Theme.textPrimary(colorScheme))
Spacer()
Button {
viewModel.deselectAllGames()
} label: {
Text("Deselect All")
.font(.system(size: Theme.FontSize.caption, weight: .medium))
.foregroundStyle(.red)
}
}
// Show selected games preview
@@ -927,8 +1104,7 @@ struct LocationSearchSheet: View {
.textFieldStyle(.plain)
.autocorrectionDisabled()
if isSearching {
ProgressView()
.scaleEffect(0.8)
ThemedSpinnerCompact(size: 16)
} else if !searchText.isEmpty {
Button {
searchText = ""
@@ -1260,8 +1436,7 @@ struct TripOptionCard: View {
.transition(.opacity)
} else if isLoadingDescription {
HStack(spacing: 4) {
ProgressView()
.scaleEffect(0.6)
ThemedSpinnerCompact(size: 12)
Text("Generating...")
.font(.system(size: 11))
.foregroundStyle(Theme.textMuted(colorScheme))
@@ -1806,5 +1981,5 @@ struct SportSelectionChip: View {
}
#Preview {
TripCreationView()
TripCreationView(viewModel: TripCreationViewModel())
}

View File

@@ -198,8 +198,7 @@ struct TripDetailView: View {
// Loading indicator
if isLoadingRoutes {
ProgressView()
.tint(Theme.warmOrange)
ThemedSpinnerCompact(size: 24)
.padding(.bottom, 40)
}
}