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:
@@ -348,6 +348,10 @@ final class TripCreationViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
func deselectAllGames() {
|
||||
mustSeeGameIds.removeAll()
|
||||
}
|
||||
|
||||
func switchPlanningMode(_ mode: PlanningMode) {
|
||||
planningMode = mode
|
||||
// Clear mode-specific selections when switching
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -198,8 +198,7 @@ struct TripDetailView: View {
|
||||
|
||||
// Loading indicator
|
||||
if isLoadingRoutes {
|
||||
ProgressView()
|
||||
.tint(Theme.warmOrange)
|
||||
ThemedSpinnerCompact(size: 24)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user