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:
@@ -16,6 +16,7 @@ struct HomeView: View {
|
||||
@State private var selectedTab = 0
|
||||
@State private var suggestedTripsGenerator = SuggestedTripsGenerator()
|
||||
@State private var selectedSuggestedTrip: SuggestedTrip?
|
||||
@State private var tripCreationViewModel = TripCreationViewModel()
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $selectedTab) {
|
||||
@@ -83,6 +84,15 @@ struct HomeView: View {
|
||||
}
|
||||
.tag(2)
|
||||
|
||||
// Progress Tab
|
||||
NavigationStack {
|
||||
ProgressTabView()
|
||||
}
|
||||
.tabItem {
|
||||
Label("Progress", systemImage: "chart.bar.fill")
|
||||
}
|
||||
.tag(3)
|
||||
|
||||
// Settings Tab
|
||||
NavigationStack {
|
||||
SettingsView()
|
||||
@@ -90,11 +100,11 @@ struct HomeView: View {
|
||||
.tabItem {
|
||||
Label("Settings", systemImage: "gear")
|
||||
}
|
||||
.tag(3)
|
||||
.tag(4)
|
||||
}
|
||||
.tint(Theme.warmOrange)
|
||||
.sheet(isPresented: $showNewTrip) {
|
||||
TripCreationView(initialSport: selectedSport)
|
||||
TripCreationView(viewModel: tripCreationViewModel, initialSport: selectedSport)
|
||||
}
|
||||
.onChange(of: showNewTrip) { _, isShowing in
|
||||
if !isShowing {
|
||||
@@ -110,6 +120,7 @@ struct HomeView: View {
|
||||
NavigationStack {
|
||||
TripDetailView(trip: suggestedTrip.trip, games: suggestedTrip.richGames)
|
||||
}
|
||||
.interactiveDismissDisabled()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//
|
||||
// PhotoImportViewModel.swift
|
||||
// SportsTime
|
||||
//
|
||||
// ViewModel for photo import flow - orchestrates extraction, matching, and import.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
import SwiftData
|
||||
import Photos
|
||||
|
||||
@MainActor @Observable
|
||||
final class PhotoImportViewModel {
|
||||
// State
|
||||
var showingPicker = false
|
||||
var isProcessing = false
|
||||
var processedCount = 0
|
||||
var totalCount = 0
|
||||
|
||||
// Results
|
||||
var processedPhotos: [PhotoImportCandidate] = []
|
||||
var confirmedImports: Set<UUID> = []
|
||||
var selectedMatches: [UUID: GameMatchCandidate] = [:]
|
||||
|
||||
// Services
|
||||
private let metadataExtractor = PhotoMetadataExtractor.shared
|
||||
private let gameMatcher = GameMatcher.shared
|
||||
|
||||
// MARK: - Computed
|
||||
|
||||
var categorized: GameMatcher.CategorizedImports {
|
||||
gameMatcher.categorizeImports(processedPhotos)
|
||||
}
|
||||
|
||||
var hasConfirmedImports: Bool {
|
||||
!confirmedImports.isEmpty
|
||||
}
|
||||
|
||||
var confirmedCount: Int {
|
||||
confirmedImports.count
|
||||
}
|
||||
|
||||
// MARK: - Photo Processing
|
||||
|
||||
func processSelectedPhotos(_ items: [PhotosPickerItem]) async {
|
||||
guard !items.isEmpty else { return }
|
||||
|
||||
isProcessing = true
|
||||
totalCount = items.count
|
||||
processedCount = 0
|
||||
processedPhotos = []
|
||||
confirmedImports = []
|
||||
selectedMatches = [:]
|
||||
|
||||
// Load PHAssets from PhotosPickerItems
|
||||
var assets: [PHAsset] = []
|
||||
|
||||
for item in items {
|
||||
if let assetId = item.itemIdentifier {
|
||||
let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil)
|
||||
if let asset = fetchResult.firstObject {
|
||||
assets.append(asset)
|
||||
}
|
||||
}
|
||||
processedCount += 1
|
||||
}
|
||||
|
||||
// Extract metadata from all assets
|
||||
let metadataList = await metadataExtractor.extractMetadata(from: assets)
|
||||
|
||||
// Process each photo through game matcher
|
||||
processedCount = 0
|
||||
for metadata in metadataList {
|
||||
let candidate = await gameMatcher.processPhotoForImport(metadata: metadata)
|
||||
processedPhotos.append(candidate)
|
||||
|
||||
// Auto-confirm high-confidence matches
|
||||
if candidate.canAutoProcess {
|
||||
confirmedImports.insert(candidate.id)
|
||||
}
|
||||
|
||||
processedCount += 1
|
||||
}
|
||||
|
||||
isProcessing = false
|
||||
}
|
||||
|
||||
// MARK: - User Actions
|
||||
|
||||
func toggleConfirmation(for candidateId: UUID) {
|
||||
if confirmedImports.contains(candidateId) {
|
||||
confirmedImports.remove(candidateId)
|
||||
} else {
|
||||
confirmedImports.insert(candidateId)
|
||||
}
|
||||
}
|
||||
|
||||
func selectMatch(_ match: GameMatchCandidate, for candidateId: UUID) {
|
||||
selectedMatches[candidateId] = match
|
||||
confirmedImports.insert(candidateId)
|
||||
}
|
||||
|
||||
func confirmAll() {
|
||||
for candidate in processedPhotos {
|
||||
if case .singleMatch = candidate.matchResult {
|
||||
confirmedImports.insert(candidate.id)
|
||||
} else if case .multipleMatches = candidate.matchResult,
|
||||
selectedMatches[candidate.id] != nil {
|
||||
confirmedImports.insert(candidate.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Import Creation
|
||||
|
||||
func createVisits(modelContext: ModelContext) async {
|
||||
for candidate in processedPhotos {
|
||||
guard confirmedImports.contains(candidate.id) else { continue }
|
||||
|
||||
// Get the match to use
|
||||
let matchToUse: GameMatchCandidate?
|
||||
|
||||
switch candidate.matchResult {
|
||||
case .singleMatch(let match):
|
||||
matchToUse = match
|
||||
case .multipleMatches:
|
||||
matchToUse = selectedMatches[candidate.id]
|
||||
case .noMatches:
|
||||
matchToUse = nil
|
||||
}
|
||||
|
||||
guard let match = matchToUse else { continue }
|
||||
|
||||
// Create the visit
|
||||
let visit = StadiumVisit(
|
||||
canonicalStadiumId: match.stadium.id.uuidString,
|
||||
stadiumUUID: match.stadium.id,
|
||||
stadiumNameAtVisit: match.stadium.name,
|
||||
visitDate: match.game.dateTime,
|
||||
sport: match.game.sport,
|
||||
visitType: .game,
|
||||
homeTeamName: match.homeTeam.fullName,
|
||||
awayTeamName: match.awayTeam.fullName,
|
||||
finalScore: nil,
|
||||
scoreSource: nil,
|
||||
dataSource: .automatic,
|
||||
seatLocation: nil,
|
||||
notes: nil,
|
||||
photoLatitude: candidate.metadata.coordinates?.latitude,
|
||||
photoLongitude: candidate.metadata.coordinates?.longitude,
|
||||
photoCaptureDate: candidate.metadata.captureDate,
|
||||
source: .photoImport
|
||||
)
|
||||
|
||||
modelContext.insert(visit)
|
||||
}
|
||||
|
||||
try? modelContext.save()
|
||||
}
|
||||
|
||||
// MARK: - Reset
|
||||
|
||||
func reset() {
|
||||
processedPhotos = []
|
||||
confirmedImports = []
|
||||
selectedMatches = [:]
|
||||
isProcessing = false
|
||||
processedCount = 0
|
||||
totalCount = 0
|
||||
}
|
||||
}
|
||||
204
SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift
Normal file
204
SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift
Normal file
@@ -0,0 +1,204 @@
|
||||
//
|
||||
// ProgressViewModel.swift
|
||||
// SportsTime
|
||||
//
|
||||
// ViewModel for stadium progress tracking and visualization.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ProgressViewModel {
|
||||
|
||||
// MARK: - State
|
||||
|
||||
var selectedSport: Sport = .mlb
|
||||
var isLoading = false
|
||||
var error: Error?
|
||||
var errorMessage: String?
|
||||
|
||||
// MARK: - Data
|
||||
|
||||
private(set) var visits: [StadiumVisit] = []
|
||||
private(set) var stadiums: [Stadium] = []
|
||||
private(set) var teams: [Team] = []
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private var modelContainer: ModelContainer?
|
||||
private let dataProvider = AppDataProvider.shared
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
/// Overall progress for the selected sport
|
||||
var leagueProgress: LeagueProgress {
|
||||
// Filter stadiums by sport directly (same as sportStadiums)
|
||||
let sportStadiums = stadiums.filter { $0.sport == selectedSport }
|
||||
|
||||
let visitedStadiumIds = Set(
|
||||
visits
|
||||
.filter { $0.sportEnum == selectedSport }
|
||||
.compactMap { visit -> UUID? in
|
||||
// Match visit's canonical stadium ID to a stadium
|
||||
stadiums.first { stadium in
|
||||
stadium.id == visit.stadiumUUID
|
||||
}?.id
|
||||
}
|
||||
)
|
||||
|
||||
let visited = sportStadiums.filter { visitedStadiumIds.contains($0.id) }
|
||||
let remaining = sportStadiums.filter { !visitedStadiumIds.contains($0.id) }
|
||||
|
||||
return LeagueProgress(
|
||||
sport: selectedSport,
|
||||
totalStadiums: sportStadiums.count,
|
||||
visitedStadiums: visited.count,
|
||||
stadiumsVisited: visited,
|
||||
stadiumsRemaining: remaining
|
||||
)
|
||||
}
|
||||
|
||||
/// Stadium visit status indexed by stadium ID
|
||||
var stadiumVisitStatus: [UUID: StadiumVisitStatus] {
|
||||
var statusMap: [UUID: StadiumVisitStatus] = [:]
|
||||
|
||||
// Group visits by stadium
|
||||
let visitsByStadium = Dictionary(grouping: visits.filter { $0.sportEnum == selectedSport }) { $0.stadiumUUID }
|
||||
|
||||
for stadium in stadiums {
|
||||
if let stadiumVisits = visitsByStadium[stadium.id], !stadiumVisits.isEmpty {
|
||||
let summaries = stadiumVisits.map { visit in
|
||||
VisitSummary(
|
||||
id: visit.id,
|
||||
stadium: stadium,
|
||||
visitDate: visit.visitDate,
|
||||
visitType: visit.visitType,
|
||||
sport: selectedSport,
|
||||
matchup: visit.matchupDescription,
|
||||
score: visit.finalScore,
|
||||
photoCount: visit.photoMetadata?.count ?? 0,
|
||||
notes: visit.notes
|
||||
)
|
||||
}
|
||||
statusMap[stadium.id] = .visited(visits: summaries)
|
||||
} else {
|
||||
statusMap[stadium.id] = .notVisited
|
||||
}
|
||||
}
|
||||
|
||||
return statusMap
|
||||
}
|
||||
|
||||
/// Stadiums for the selected sport
|
||||
var sportStadiums: [Stadium] {
|
||||
stadiums.filter { $0.sport == selectedSport }
|
||||
}
|
||||
|
||||
/// Visited stadiums for the selected sport
|
||||
var visitedStadiums: [Stadium] {
|
||||
leagueProgress.stadiumsVisited
|
||||
}
|
||||
|
||||
/// Unvisited stadiums for the selected sport
|
||||
var unvisitedStadiums: [Stadium] {
|
||||
leagueProgress.stadiumsRemaining
|
||||
}
|
||||
|
||||
/// Recent visits sorted by date
|
||||
var recentVisits: [VisitSummary] {
|
||||
visits
|
||||
.sorted { $0.visitDate > $1.visitDate }
|
||||
.prefix(10)
|
||||
.compactMap { visit -> VisitSummary? in
|
||||
guard let stadium = stadiums.first(where: { $0.id == visit.stadiumUUID }),
|
||||
let sport = visit.sportEnum else {
|
||||
return nil
|
||||
}
|
||||
return VisitSummary(
|
||||
id: visit.id,
|
||||
stadium: stadium,
|
||||
visitDate: visit.visitDate,
|
||||
visitType: visit.visitType,
|
||||
sport: sport,
|
||||
matchup: visit.matchupDescription,
|
||||
score: visit.finalScore,
|
||||
photoCount: visit.photoMetadata?.count ?? 0,
|
||||
notes: visit.notes
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
func configure(with container: ModelContainer) {
|
||||
self.modelContainer = container
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
func loadData() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
// Load stadiums and teams from data provider
|
||||
if dataProvider.stadiums.isEmpty {
|
||||
await dataProvider.loadInitialData()
|
||||
}
|
||||
stadiums = dataProvider.stadiums
|
||||
teams = dataProvider.teams
|
||||
|
||||
// Load visits from SwiftData
|
||||
if let container = modelContainer {
|
||||
let context = ModelContext(container)
|
||||
let descriptor = FetchDescriptor<StadiumVisit>(
|
||||
sortBy: [SortDescriptor(\.visitDate, order: .reverse)]
|
||||
)
|
||||
visits = try context.fetch(descriptor)
|
||||
}
|
||||
} catch {
|
||||
self.error = error
|
||||
self.errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func selectSport(_ sport: Sport) {
|
||||
selectedSport = sport
|
||||
}
|
||||
|
||||
func clearError() {
|
||||
error = nil
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
// MARK: - Visit Management
|
||||
|
||||
func deleteVisit(_ visit: StadiumVisit) async throws {
|
||||
guard let container = modelContainer else { return }
|
||||
|
||||
let context = ModelContext(container)
|
||||
context.delete(visit)
|
||||
try context.save()
|
||||
|
||||
// Reload data
|
||||
await loadData()
|
||||
}
|
||||
|
||||
// MARK: - Progress Card Generation
|
||||
|
||||
func progressCardData(includeUsername: Bool = false) -> ProgressCardData {
|
||||
ProgressCardData(
|
||||
sport: selectedSport,
|
||||
progress: leagueProgress,
|
||||
username: nil,
|
||||
includeMap: true,
|
||||
showDetailedStats: false
|
||||
)
|
||||
}
|
||||
}
|
||||
526
SportsTime/Features/Progress/Views/AchievementsListView.swift
Normal file
526
SportsTime/Features/Progress/Views/AchievementsListView.swift
Normal file
@@ -0,0 +1,526 @@
|
||||
//
|
||||
// AchievementsListView.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Displays achievements gallery with earned, in-progress, and locked badges.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct AchievementsListView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@State private var achievements: [AchievementProgress] = []
|
||||
@State private var isLoading = true
|
||||
@State private var selectedCategory: AchievementCategory?
|
||||
@State private var selectedAchievement: AchievementProgress?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
// Summary header
|
||||
achievementSummary
|
||||
.staggeredAnimation(index: 0)
|
||||
|
||||
// Category filter
|
||||
categoryFilter
|
||||
.staggeredAnimation(index: 1)
|
||||
|
||||
// Achievements grid
|
||||
achievementsGrid
|
||||
.staggeredAnimation(index: 2)
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
}
|
||||
.themedBackground()
|
||||
.navigationTitle("Achievements")
|
||||
.task {
|
||||
await loadAchievements()
|
||||
}
|
||||
.sheet(item: $selectedAchievement) { achievement in
|
||||
AchievementDetailSheet(achievement: achievement)
|
||||
.presentationDetents([.medium])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Achievement Summary
|
||||
|
||||
private var achievementSummary: some View {
|
||||
let earned = achievements.filter { $0.isEarned }.count
|
||||
let total = achievements.count
|
||||
|
||||
return HStack(spacing: Theme.Spacing.lg) {
|
||||
// Trophy icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Theme.warmOrange.opacity(0.15))
|
||||
.frame(width: 70, height: 70)
|
||||
|
||||
Image(systemName: "trophy.fill")
|
||||
.font(.system(size: 32))
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
||||
Text("\(earned) / \(total)")
|
||||
.font(.system(size: Theme.FontSize.heroTitle, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Text("Achievements Earned")
|
||||
.font(.system(size: Theme.FontSize.body))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
|
||||
if earned == total && total > 0 {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "star.fill")
|
||||
Text("All achievements unlocked!")
|
||||
}
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .semibold))
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.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)
|
||||
}
|
||||
.shadow(color: Theme.cardShadow(colorScheme), radius: 10, y: 5)
|
||||
}
|
||||
|
||||
// MARK: - Category Filter
|
||||
|
||||
private var categoryFilter: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
CategoryFilterButton(
|
||||
title: "All",
|
||||
icon: "square.grid.2x2",
|
||||
isSelected: selectedCategory == nil
|
||||
) {
|
||||
withAnimation(Theme.Animation.spring) {
|
||||
selectedCategory = nil
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(AchievementCategory.allCases, id: \.self) { category in
|
||||
CategoryFilterButton(
|
||||
title: category.displayName,
|
||||
icon: category.iconName,
|
||||
isSelected: selectedCategory == category
|
||||
) {
|
||||
withAnimation(Theme.Animation.spring) {
|
||||
selectedCategory = category
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Achievements Grid
|
||||
|
||||
private var achievementsGrid: some View {
|
||||
let filtered = filteredAchievements
|
||||
|
||||
return LazyVGrid(
|
||||
columns: [GridItem(.flexible()), GridItem(.flexible())],
|
||||
spacing: Theme.Spacing.md
|
||||
) {
|
||||
ForEach(filtered) { achievement in
|
||||
AchievementCard(achievement: achievement)
|
||||
.onTapGesture {
|
||||
selectedAchievement = achievement
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var filteredAchievements: [AchievementProgress] {
|
||||
guard let category = selectedCategory else {
|
||||
return achievements.sorted { first, second in
|
||||
// Earned first, then by progress
|
||||
if first.isEarned != second.isEarned {
|
||||
return first.isEarned
|
||||
}
|
||||
return first.progressPercentage > second.progressPercentage
|
||||
}
|
||||
}
|
||||
return achievements.filter { $0.definition.category == category }
|
||||
.sorted { first, second in
|
||||
if first.isEarned != second.isEarned {
|
||||
return first.isEarned
|
||||
}
|
||||
return first.progressPercentage > second.progressPercentage
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Data Loading
|
||||
|
||||
private func loadAchievements() async {
|
||||
isLoading = true
|
||||
do {
|
||||
let engine = AchievementEngine(modelContext: modelContext)
|
||||
achievements = try await engine.getProgress()
|
||||
} catch {
|
||||
// Handle error silently, show empty state
|
||||
achievements = []
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Category Filter Button
|
||||
|
||||
struct CategoryFilterButton: View {
|
||||
let title: String
|
||||
let icon: String
|
||||
let isSelected: Bool
|
||||
let action: () -> Void
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: Theme.Spacing.xs) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 14))
|
||||
Text(title)
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||
}
|
||||
.padding(.horizontal, Theme.Spacing.md)
|
||||
.padding(.vertical, Theme.Spacing.sm)
|
||||
.background(isSelected ? Theme.warmOrange : Theme.cardBackground(colorScheme))
|
||||
.foregroundStyle(isSelected ? .white : Theme.textPrimary(colorScheme))
|
||||
.clipShape(Capsule())
|
||||
.overlay {
|
||||
Capsule()
|
||||
.stroke(isSelected ? Color.clear : Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Achievement Card
|
||||
|
||||
struct AchievementCard: View {
|
||||
let achievement: AchievementProgress
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Theme.Spacing.sm) {
|
||||
// Badge icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(badgeBackgroundColor)
|
||||
.frame(width: 60, height: 60)
|
||||
|
||||
Image(systemName: achievement.definition.iconName)
|
||||
.font(.system(size: 28))
|
||||
.foregroundStyle(badgeIconColor)
|
||||
|
||||
if !achievement.isEarned {
|
||||
Circle()
|
||||
.fill(.black.opacity(0.3))
|
||||
.frame(width: 60, height: 60)
|
||||
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
|
||||
// Title
|
||||
Text(achievement.definition.name)
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .semibold))
|
||||
.foregroundStyle(achievement.isEarned ? Theme.textPrimary(colorScheme) : Theme.textMuted(colorScheme))
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
// Progress or earned date
|
||||
if achievement.isEarned {
|
||||
if let earnedAt = achievement.earnedAt {
|
||||
Text(earnedAt.formatted(date: .abbreviated, time: .omitted))
|
||||
.font(.system(size: Theme.FontSize.micro))
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
} else {
|
||||
// Progress bar
|
||||
VStack(spacing: 4) {
|
||||
ProgressView(value: achievement.progressPercentage)
|
||||
.progressViewStyle(AchievementProgressStyle())
|
||||
|
||||
Text(achievement.progressText)
|
||||
.font(.system(size: Theme.FontSize.micro))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
.frame(maxWidth: .infinity, minHeight: 170)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||
.stroke(achievement.isEarned ? Theme.warmOrange.opacity(0.5) : Theme.surfaceGlow(colorScheme), lineWidth: achievement.isEarned ? 2 : 1)
|
||||
}
|
||||
.shadow(color: Theme.cardShadow(colorScheme), radius: 5, y: 2)
|
||||
.opacity(achievement.isEarned ? 1.0 : 0.7)
|
||||
}
|
||||
|
||||
private var badgeBackgroundColor: Color {
|
||||
if achievement.isEarned {
|
||||
return categoryColor.opacity(0.2)
|
||||
}
|
||||
return Theme.cardBackgroundElevated(colorScheme)
|
||||
}
|
||||
|
||||
private var badgeIconColor: Color {
|
||||
if achievement.isEarned {
|
||||
return categoryColor
|
||||
}
|
||||
return Theme.textMuted(colorScheme)
|
||||
}
|
||||
|
||||
private var categoryColor: Color {
|
||||
switch achievement.definition.category {
|
||||
case .count:
|
||||
return Theme.warmOrange
|
||||
case .division:
|
||||
return Theme.routeGold
|
||||
case .conference:
|
||||
return Theme.routeAmber
|
||||
case .league:
|
||||
return Color(hex: "FFD700") // Gold
|
||||
case .journey:
|
||||
return Color(hex: "9B59B6") // Purple
|
||||
case .special:
|
||||
return Color(hex: "E74C3C") // Red
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Achievement Progress Style
|
||||
|
||||
struct AchievementProgressStyle: ProgressViewStyle {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Theme.cardBackgroundElevated(colorScheme))
|
||||
.frame(height: 4)
|
||||
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Theme.warmOrange)
|
||||
.frame(width: geometry.size.width * CGFloat(configuration.fractionCompleted ?? 0), height: 4)
|
||||
}
|
||||
}
|
||||
.frame(height: 4)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Achievement Detail Sheet
|
||||
|
||||
struct AchievementDetailSheet: View {
|
||||
let achievement: AchievementProgress
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: Theme.Spacing.xl) {
|
||||
// Large badge
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(badgeBackgroundColor)
|
||||
.frame(width: 120, height: 120)
|
||||
|
||||
if achievement.isEarned {
|
||||
Circle()
|
||||
.stroke(Theme.warmOrange, lineWidth: 4)
|
||||
.frame(width: 130, height: 130)
|
||||
}
|
||||
|
||||
Image(systemName: achievement.definition.iconName)
|
||||
.font(.system(size: 56))
|
||||
.foregroundStyle(badgeIconColor)
|
||||
|
||||
if !achievement.isEarned {
|
||||
Circle()
|
||||
.fill(.black.opacity(0.3))
|
||||
.frame(width: 120, height: 120)
|
||||
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 24))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
|
||||
// Title and description
|
||||
VStack(spacing: Theme.Spacing.sm) {
|
||||
Text(achievement.definition.name)
|
||||
.font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Text(achievement.definition.description)
|
||||
.font(.system(size: Theme.FontSize.body))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
// Category badge
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: achievement.definition.category.iconName)
|
||||
Text(achievement.definition.category.displayName)
|
||||
}
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(categoryColor)
|
||||
.padding(.horizontal, Theme.Spacing.sm)
|
||||
.padding(.vertical, Theme.Spacing.xs)
|
||||
.background(categoryColor.opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
// Status section
|
||||
if achievement.isEarned {
|
||||
if let earnedAt = achievement.earnedAt {
|
||||
VStack(spacing: 4) {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.system(size: 24))
|
||||
.foregroundStyle(.green)
|
||||
|
||||
Text("Earned on \(earnedAt.formatted(date: .long, time: .omitted))")
|
||||
.font(.system(size: Theme.FontSize.body, weight: .medium))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Progress section
|
||||
VStack(spacing: Theme.Spacing.sm) {
|
||||
Text("Progress")
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
|
||||
ProgressView(value: achievement.progressPercentage)
|
||||
.progressViewStyle(LargeProgressStyle())
|
||||
.frame(width: 200)
|
||||
|
||||
Text("\(achievement.currentProgress) / \(achievement.totalRequired)")
|
||||
.font(.system(size: Theme.FontSize.cardTitle, weight: .bold))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
}
|
||||
}
|
||||
|
||||
// Sport badge if applicable
|
||||
if let sport = achievement.definition.sport {
|
||||
HStack(spacing: Theme.Spacing.xs) {
|
||||
Image(systemName: sport.iconName)
|
||||
Text(sport.displayName)
|
||||
}
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(sport.themeColor)
|
||||
.padding(.horizontal, Theme.Spacing.md)
|
||||
.padding(.vertical, Theme.Spacing.sm)
|
||||
.background(sport.themeColor.opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(Theme.Spacing.lg)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var badgeBackgroundColor: Color {
|
||||
if achievement.isEarned {
|
||||
return categoryColor.opacity(0.2)
|
||||
}
|
||||
return Theme.cardBackgroundElevated(colorScheme)
|
||||
}
|
||||
|
||||
private var badgeIconColor: Color {
|
||||
if achievement.isEarned {
|
||||
return categoryColor
|
||||
}
|
||||
return Theme.textMuted(colorScheme)
|
||||
}
|
||||
|
||||
private var categoryColor: Color {
|
||||
switch achievement.definition.category {
|
||||
case .count:
|
||||
return Theme.warmOrange
|
||||
case .division:
|
||||
return Theme.routeGold
|
||||
case .conference:
|
||||
return Theme.routeAmber
|
||||
case .league:
|
||||
return Color(hex: "FFD700")
|
||||
case .journey:
|
||||
return Color(hex: "9B59B6")
|
||||
case .special:
|
||||
return Color(hex: "E74C3C")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Large Progress Style
|
||||
|
||||
struct LargeProgressStyle: ProgressViewStyle {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Theme.cardBackgroundElevated(colorScheme))
|
||||
.frame(height: 8)
|
||||
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Theme.warmOrange)
|
||||
.frame(width: geometry.size.width * CGFloat(configuration.fractionCompleted ?? 0), height: 8)
|
||||
}
|
||||
}
|
||||
.frame(height: 8)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Category Extensions
|
||||
|
||||
extension AchievementCategory {
|
||||
var iconName: String {
|
||||
switch self {
|
||||
case .count: return "number.circle"
|
||||
case .division: return "map"
|
||||
case .conference: return "building.2"
|
||||
case .league: return "crown"
|
||||
case .journey: return "car.fill"
|
||||
case .special: return "star.circle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
AchievementsListView()
|
||||
}
|
||||
.modelContainer(for: StadiumVisit.self, inMemory: true)
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
//
|
||||
// GameMatchConfirmationView.swift
|
||||
// SportsTime
|
||||
//
|
||||
// View for confirming/selecting the correct game match from photo import.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Game Match Confirmation View
|
||||
|
||||
struct GameMatchConfirmationView: View {
|
||||
let candidate: PhotoImportCandidate
|
||||
let onConfirm: (GameMatchCandidate) -> Void
|
||||
let onSkip: () -> Void
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var selectedMatch: GameMatchCandidate?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
// Photo info header
|
||||
photoInfoHeader
|
||||
.staggeredAnimation(index: 0)
|
||||
|
||||
// Stadium info
|
||||
if let stadium = candidate.bestStadiumMatch {
|
||||
stadiumCard(stadium)
|
||||
.staggeredAnimation(index: 1)
|
||||
}
|
||||
|
||||
// Match options
|
||||
matchOptionsSection
|
||||
.staggeredAnimation(index: 2)
|
||||
|
||||
// Action buttons
|
||||
actionButtons
|
||||
.staggeredAnimation(index: 3)
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
}
|
||||
.themedBackground()
|
||||
.navigationTitle("Confirm Game")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
// Pre-select if single match
|
||||
if case .singleMatch(let match) = candidate.matchResult {
|
||||
selectedMatch = match
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Photo Info Header
|
||||
|
||||
private var photoInfoHeader: some View {
|
||||
VStack(spacing: Theme.Spacing.md) {
|
||||
// Icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Theme.warmOrange.opacity(0.15))
|
||||
.frame(width: 80, height: 80)
|
||||
|
||||
Image(systemName: "photo.fill")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
|
||||
VStack(spacing: Theme.Spacing.xs) {
|
||||
if let date = candidate.metadata.captureDate {
|
||||
Label(formatDate(date), systemImage: "calendar")
|
||||
.font(.system(size: Theme.FontSize.body, weight: .medium))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
}
|
||||
|
||||
if candidate.metadata.hasValidLocation {
|
||||
Label("Location data available", systemImage: "location.fill")
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
} else {
|
||||
Label("No location data", systemImage: "location.slash")
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(Theme.Spacing.lg)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
|
||||
}
|
||||
|
||||
// MARK: - Stadium Card
|
||||
|
||||
private func stadiumCard(_ match: StadiumMatch) -> some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
|
||||
HStack {
|
||||
Image(systemName: "mappin.circle.fill")
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
Text("Nearest Stadium")
|
||||
.font(.system(size: Theme.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
Spacer()
|
||||
}
|
||||
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(match.stadium.name)
|
||||
.font(.system(size: Theme.FontSize.cardTitle, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Text(match.stadium.fullAddress)
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Distance badge
|
||||
VStack(spacing: 2) {
|
||||
Text(match.formattedDistance)
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(confidenceColor(match.confidence))
|
||||
|
||||
Text(match.confidence.description)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
}
|
||||
}
|
||||
.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: - Match Options Section
|
||||
|
||||
private var matchOptionsSection: some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
|
||||
HStack {
|
||||
Image(systemName: "sportscourt.fill")
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
Text(matchOptionsTitle)
|
||||
.font(.system(size: Theme.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
Spacer()
|
||||
}
|
||||
|
||||
matchOptionsContent
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
private var matchOptionsTitle: String {
|
||||
switch candidate.matchResult {
|
||||
case .singleMatch:
|
||||
return "Matched Game"
|
||||
case .multipleMatches(let matches):
|
||||
return "Select Game (\(matches.count) options)"
|
||||
case .noMatches:
|
||||
return "No Games Found"
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var matchOptionsContent: some View {
|
||||
switch candidate.matchResult {
|
||||
case .singleMatch(let match):
|
||||
gameMatchRow(match, isSelected: true)
|
||||
|
||||
case .multipleMatches(let matches):
|
||||
VStack(spacing: Theme.Spacing.sm) {
|
||||
ForEach(matches) { match in
|
||||
Button {
|
||||
selectedMatch = match
|
||||
} label: {
|
||||
gameMatchRow(match, isSelected: selectedMatch?.id == match.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case .noMatches(let reason):
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.red)
|
||||
Text(reason.description)
|
||||
.font(.system(size: Theme.FontSize.body))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
}
|
||||
}
|
||||
|
||||
private func gameMatchRow(_ match: GameMatchCandidate, isSelected: Bool) -> some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(match.matchupDescription)
|
||||
.font(.system(size: Theme.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Image(systemName: match.game.sport.iconName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(match.game.sport.themeColor)
|
||||
}
|
||||
|
||||
Text(match.gameDateTime)
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
|
||||
// Confidence
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(combinedConfidenceColor(match.confidence.combined))
|
||||
.frame(width: 8, height: 8)
|
||||
Text(match.confidence.combined.description)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Selection indicator
|
||||
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
|
||||
.font(.title2)
|
||||
.foregroundStyle(isSelected ? .green : Theme.textMuted(colorScheme))
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
.background(isSelected ? Theme.cardBackgroundElevated(colorScheme) : Color.clear)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small))
|
||||
.overlay {
|
||||
if isSelected {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||
.stroke(.green.opacity(0.5), lineWidth: 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Action Buttons
|
||||
|
||||
private var actionButtons: some View {
|
||||
VStack(spacing: Theme.Spacing.sm) {
|
||||
// Confirm button
|
||||
Button {
|
||||
if let match = selectedMatch {
|
||||
onConfirm(match)
|
||||
dismiss()
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
Text("Confirm & Import")
|
||||
}
|
||||
.font(.system(size: Theme.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(Theme.Spacing.md)
|
||||
.background(selectedMatch != nil ? .green : Theme.textMuted(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
}
|
||||
.disabled(selectedMatch == nil)
|
||||
|
||||
// Skip button
|
||||
Button {
|
||||
onSkip()
|
||||
dismiss()
|
||||
} label: {
|
||||
Text("Skip This Photo")
|
||||
.font(.system(size: Theme.FontSize.body))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func formatDate(_ date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .long
|
||||
formatter.timeStyle = .short
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
private func confidenceColor(_ confidence: MatchConfidence) -> Color {
|
||||
switch confidence {
|
||||
case .high: return .green
|
||||
case .medium: return Theme.warmOrange
|
||||
case .low: return .red
|
||||
case .none: return Theme.textMuted(colorScheme)
|
||||
}
|
||||
}
|
||||
|
||||
private func combinedConfidenceColor(_ confidence: CombinedConfidence) -> Color {
|
||||
switch confidence {
|
||||
case .autoSelect: return .green
|
||||
case .userConfirm: return Theme.warmOrange
|
||||
case .manualOnly: return .red
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
let metadata = PhotoMetadata(
|
||||
captureDate: Date(),
|
||||
coordinates: nil
|
||||
)
|
||||
let candidate = PhotoImportCandidate(
|
||||
metadata: metadata,
|
||||
matchResult: .noMatches(.metadataMissing(.noLocation)),
|
||||
stadiumMatches: []
|
||||
)
|
||||
|
||||
GameMatchConfirmationView(
|
||||
candidate: candidate,
|
||||
onConfirm: { _ in },
|
||||
onSkip: {}
|
||||
)
|
||||
}
|
||||
548
SportsTime/Features/Progress/Views/PhotoImportView.swift
Normal file
548
SportsTime/Features/Progress/Views/PhotoImportView.swift
Normal file
@@ -0,0 +1,548 @@
|
||||
//
|
||||
// PhotoImportView.swift
|
||||
// SportsTime
|
||||
//
|
||||
// View for importing stadium visits from photos using GPS/date metadata.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import PhotosUI
|
||||
import Photos
|
||||
|
||||
// MARK: - Photo Import View
|
||||
|
||||
struct PhotoImportView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var viewModel = PhotoImportViewModel()
|
||||
@State private var selectedPhotos: [PhotosPickerItem] = []
|
||||
@State private var showingPermissionAlert = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
if viewModel.isProcessing {
|
||||
processingView
|
||||
} else if viewModel.processedPhotos.isEmpty {
|
||||
emptyStateView
|
||||
} else {
|
||||
resultsView
|
||||
}
|
||||
}
|
||||
.themedBackground()
|
||||
.navigationTitle("Import from Photos")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
if !viewModel.processedPhotos.isEmpty {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Import") {
|
||||
importSelectedVisits()
|
||||
}
|
||||
.fontWeight(.semibold)
|
||||
.disabled(!viewModel.hasConfirmedImports)
|
||||
}
|
||||
}
|
||||
}
|
||||
.photosPicker(
|
||||
isPresented: $viewModel.showingPicker,
|
||||
selection: $selectedPhotos,
|
||||
maxSelectionCount: 20,
|
||||
matching: .images,
|
||||
photoLibrary: .shared()
|
||||
)
|
||||
.onChange(of: selectedPhotos) { _, newValue in
|
||||
Task {
|
||||
await viewModel.processSelectedPhotos(newValue)
|
||||
}
|
||||
}
|
||||
.alert("Photo Library Access", isPresented: $showingPermissionAlert) {
|
||||
Button("Open Settings") {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("SportsTime needs access to your photos to import stadium visits. Please enable access in Settings.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty State
|
||||
|
||||
private var emptyStateView: some View {
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
Spacer()
|
||||
|
||||
// Icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Theme.warmOrange.opacity(0.15))
|
||||
.frame(width: 120, height: 120)
|
||||
|
||||
Image(systemName: "photo.on.rectangle.angled")
|
||||
.font(.system(size: 50))
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
|
||||
VStack(spacing: Theme.Spacing.sm) {
|
||||
Text("Import from Photos")
|
||||
.font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Text("Select photos taken at stadiums to automatically log your visits. We'll use GPS and date data to match them to games.")
|
||||
.font(.system(size: Theme.FontSize.body))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, Theme.Spacing.xl)
|
||||
}
|
||||
|
||||
// Select Photos Button
|
||||
Button {
|
||||
checkPermissionsAndShowPicker()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "photo.stack")
|
||||
Text("Select Photos")
|
||||
}
|
||||
.font(.system(size: Theme.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(Theme.Spacing.md)
|
||||
.background(Theme.warmOrange)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
}
|
||||
.padding(.horizontal, Theme.Spacing.xl)
|
||||
|
||||
// Info card
|
||||
infoCard
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
}
|
||||
|
||||
private var infoCard: some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
HStack {
|
||||
Image(systemName: "info.circle.fill")
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
Text("How it works")
|
||||
.font(.system(size: Theme.FontSize.body, weight: .semibold))
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
||||
InfoRow(icon: "location.fill", text: "We read GPS location from your photos")
|
||||
InfoRow(icon: "calendar", text: "We match the date to scheduled games")
|
||||
InfoRow(icon: "checkmark.circle", text: "High confidence matches are auto-selected")
|
||||
InfoRow(icon: "hand.tap", text: "You confirm or edit the rest")
|
||||
}
|
||||
}
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
.padding(Theme.Spacing.md)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
.padding(.horizontal, Theme.Spacing.lg)
|
||||
}
|
||||
|
||||
// MARK: - Processing View
|
||||
|
||||
private var processingView: some View {
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
Spacer()
|
||||
|
||||
ThemedSpinner(size: 50, lineWidth: 4)
|
||||
|
||||
Text("Processing photos...")
|
||||
.font(.system(size: Theme.FontSize.body, weight: .medium))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
|
||||
Text("\(viewModel.processedCount) of \(viewModel.totalCount) photos")
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Results View
|
||||
|
||||
private var resultsView: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
// Summary header
|
||||
summaryHeader
|
||||
|
||||
// Categorized results
|
||||
if !viewModel.categorized.autoProcessable.isEmpty {
|
||||
resultSection(
|
||||
title: "Auto-Matched",
|
||||
subtitle: "High confidence matches",
|
||||
icon: "checkmark.circle.fill",
|
||||
color: .green,
|
||||
candidates: viewModel.categorized.autoProcessable
|
||||
)
|
||||
}
|
||||
|
||||
if !viewModel.categorized.needsConfirmation.isEmpty {
|
||||
resultSection(
|
||||
title: "Needs Confirmation",
|
||||
subtitle: "Please verify these matches",
|
||||
icon: "questionmark.circle.fill",
|
||||
color: Theme.warmOrange,
|
||||
candidates: viewModel.categorized.needsConfirmation
|
||||
)
|
||||
}
|
||||
|
||||
if !viewModel.categorized.needsManualEntry.isEmpty {
|
||||
resultSection(
|
||||
title: "Manual Entry Required",
|
||||
subtitle: "Could not auto-match these photos",
|
||||
icon: "exclamationmark.triangle.fill",
|
||||
color: .red,
|
||||
candidates: viewModel.categorized.needsManualEntry
|
||||
)
|
||||
}
|
||||
|
||||
// Add more photos button
|
||||
Button {
|
||||
viewModel.showingPicker = true
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "plus.circle")
|
||||
Text("Add More Photos")
|
||||
}
|
||||
.font(.system(size: Theme.FontSize.body))
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
.padding(.top, Theme.Spacing.md)
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
}
|
||||
}
|
||||
|
||||
private var summaryHeader: some View {
|
||||
HStack(spacing: Theme.Spacing.md) {
|
||||
summaryBadge(
|
||||
count: viewModel.categorized.autoProcessable.count,
|
||||
label: "Auto",
|
||||
color: .green
|
||||
)
|
||||
|
||||
summaryBadge(
|
||||
count: viewModel.categorized.needsConfirmation.count,
|
||||
label: "Confirm",
|
||||
color: Theme.warmOrange
|
||||
)
|
||||
|
||||
summaryBadge(
|
||||
count: viewModel.categorized.needsManualEntry.count,
|
||||
label: "Manual",
|
||||
color: .red
|
||||
)
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
}
|
||||
|
||||
private func summaryBadge(count: Int, label: String, color: Color) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
Text("\(count)")
|
||||
.font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(color)
|
||||
|
||||
Text(label)
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private func resultSection(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
icon: String,
|
||||
color: Color,
|
||||
candidates: [PhotoImportCandidate]
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
// Section header
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.foregroundStyle(color)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.system(size: Theme.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
Text(subtitle)
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
// Candidate cards
|
||||
ForEach(candidates) { candidate in
|
||||
PhotoImportCandidateCard(
|
||||
candidate: candidate,
|
||||
isConfirmed: viewModel.confirmedImports.contains(candidate.id),
|
||||
onToggleConfirm: {
|
||||
viewModel.toggleConfirmation(for: candidate.id)
|
||||
},
|
||||
onSelectMatch: { match in
|
||||
viewModel.selectMatch(match, for: candidate.id)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func checkPermissionsAndShowPicker() {
|
||||
Task {
|
||||
let status = await PhotoMetadataExtractor.shared.requestPhotoLibraryAccess()
|
||||
|
||||
await MainActor.run {
|
||||
switch status {
|
||||
case .authorized, .limited:
|
||||
viewModel.showingPicker = true
|
||||
case .denied, .restricted:
|
||||
showingPermissionAlert = true
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func importSelectedVisits() {
|
||||
Task {
|
||||
await viewModel.createVisits(modelContext: modelContext)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Photo Import Candidate Card
|
||||
|
||||
struct PhotoImportCandidateCard: View {
|
||||
let candidate: PhotoImportCandidate
|
||||
let isConfirmed: Bool
|
||||
let onToggleConfirm: () -> Void
|
||||
let onSelectMatch: (GameMatchCandidate) -> Void
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@State private var showingMatchPicker = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
// Photo date/location info
|
||||
HStack {
|
||||
if let date = candidate.metadata.captureDate {
|
||||
Label(formatDate(date), systemImage: "calendar")
|
||||
}
|
||||
|
||||
if let stadium = candidate.bestStadiumMatch {
|
||||
Label(stadium.stadium.name, systemImage: "mappin")
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Confirm toggle
|
||||
Button {
|
||||
onToggleConfirm()
|
||||
} label: {
|
||||
Image(systemName: isConfirmed ? "checkmark.circle.fill" : "circle")
|
||||
.font(.title2)
|
||||
.foregroundStyle(isConfirmed ? .green : Theme.textMuted(colorScheme))
|
||||
}
|
||||
}
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
|
||||
// Match result
|
||||
matchResultView
|
||||
}
|
||||
.padding(Theme.Spacing.sm)
|
||||
.background(Theme.cardBackgroundElevated(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small))
|
||||
.sheet(isPresented: $showingMatchPicker) {
|
||||
if case .multipleMatches(let matches) = candidate.matchResult {
|
||||
GameMatchPickerSheet(
|
||||
matches: matches,
|
||||
onSelect: { match in
|
||||
onSelectMatch(match)
|
||||
showingMatchPicker = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var matchResultView: some View {
|
||||
switch candidate.matchResult {
|
||||
case .singleMatch(let match):
|
||||
matchRow(match)
|
||||
|
||||
case .multipleMatches(let matches):
|
||||
Button {
|
||||
showingMatchPicker = true
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("\(matches.count) possible games")
|
||||
.font(.system(size: Theme.FontSize.body, weight: .medium))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
Text("Tap to select the correct game")
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
}
|
||||
|
||||
case .noMatches(let reason):
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.foregroundStyle(.red)
|
||||
Text(reason.description)
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func matchRow(_ match: GameMatchCandidate) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(match.matchupDescription)
|
||||
.font(.system(size: Theme.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Image(systemName: match.game.sport.iconName)
|
||||
.foregroundStyle(match.game.sport.themeColor)
|
||||
}
|
||||
|
||||
Text("\(match.stadium.name) • \(match.gameDateTime)")
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
|
||||
// Confidence badge
|
||||
confidenceBadge(match.confidence.combined)
|
||||
}
|
||||
}
|
||||
|
||||
private func confidenceBadge(_ confidence: CombinedConfidence) -> some View {
|
||||
let (text, color): (String, Color) = {
|
||||
switch confidence {
|
||||
case .autoSelect:
|
||||
return ("High confidence", .green)
|
||||
case .userConfirm:
|
||||
return ("Needs confirmation", Theme.warmOrange)
|
||||
case .manualOnly:
|
||||
return ("Low confidence", .red)
|
||||
}
|
||||
}()
|
||||
|
||||
return Text(text)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(color)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(color.opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
private func formatDate(_ date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Game Match Picker Sheet
|
||||
|
||||
struct GameMatchPickerSheet: View {
|
||||
let matches: [GameMatchCandidate]
|
||||
let onSelect: (GameMatchCandidate) -> Void
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List(matches) { match in
|
||||
Button {
|
||||
onSelect(match)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(match.fullMatchupDescription)
|
||||
.font(.system(size: Theme.FontSize.body, weight: .medium))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: match.game.sport.iconName)
|
||||
.foregroundStyle(match.game.sport.themeColor)
|
||||
}
|
||||
|
||||
Text("\(match.stadium.name) • \(match.gameDateTime)")
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Select Game")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Info Row
|
||||
|
||||
private struct InfoRow: View {
|
||||
let icon: String
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: icon)
|
||||
.frame(width: 16)
|
||||
Text(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
PhotoImportView()
|
||||
.modelContainer(for: StadiumVisit.self, inMemory: true)
|
||||
}
|
||||
186
SportsTime/Features/Progress/Views/ProgressMapView.swift
Normal file
186
SportsTime/Features/Progress/Views/ProgressMapView.swift
Normal file
@@ -0,0 +1,186 @@
|
||||
//
|
||||
// ProgressMapView.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Interactive map showing stadium visit progress with custom annotations.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import MapKit
|
||||
|
||||
// MARK: - Progress Map View
|
||||
|
||||
struct ProgressMapView: View {
|
||||
let stadiums: [Stadium]
|
||||
let visitStatus: [UUID: StadiumVisitStatus]
|
||||
@Binding var selectedStadium: Stadium?
|
||||
|
||||
@State private var mapRegion = MKCoordinateRegion(
|
||||
center: CLLocationCoordinate2D(latitude: 39.8283, longitude: -98.5795), // US center
|
||||
span: MKCoordinateSpan(latitudeDelta: 50, longitudeDelta: 50)
|
||||
)
|
||||
|
||||
var body: some View {
|
||||
Map(coordinateRegion: $mapRegion, annotationItems: stadiums) { stadium in
|
||||
MapAnnotation(coordinate: CLLocationCoordinate2D(
|
||||
latitude: stadium.latitude,
|
||||
longitude: stadium.longitude
|
||||
)) {
|
||||
StadiumMapPin(
|
||||
stadium: stadium,
|
||||
isVisited: isVisited(stadium),
|
||||
isSelected: selectedStadium?.id == stadium.id,
|
||||
onTap: {
|
||||
withAnimation(.spring(response: 0.3)) {
|
||||
if selectedStadium?.id == stadium.id {
|
||||
selectedStadium = nil
|
||||
} else {
|
||||
selectedStadium = stadium
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.mapStyle(.standard(elevation: .realistic))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
|
||||
private func isVisited(_ stadium: Stadium) -> Bool {
|
||||
if case .visited = visitStatus[stadium.id] {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stadium Map Pin
|
||||
|
||||
struct StadiumMapPin: View {
|
||||
let stadium: Stadium
|
||||
let isVisited: Bool
|
||||
let isSelected: Bool
|
||||
let onTap: () -> Void
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
VStack(spacing: 2) {
|
||||
ZStack {
|
||||
// Pin background
|
||||
Circle()
|
||||
.fill(pinColor)
|
||||
.frame(width: pinSize, height: pinSize)
|
||||
.shadow(color: .black.opacity(0.2), radius: 2, y: 1)
|
||||
|
||||
// Icon
|
||||
Image(systemName: isVisited ? "checkmark" : "sportscourt")
|
||||
.font(.system(size: iconSize, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
// Pin pointer
|
||||
Triangle()
|
||||
.fill(pinColor)
|
||||
.frame(width: 10, height: 6)
|
||||
.offset(y: -2)
|
||||
|
||||
// Stadium name (when selected)
|
||||
if isSelected {
|
||||
Text(stadium.name)
|
||||
.font(.caption2)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(colorScheme == .dark ? .white : .primary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill(colorScheme == .dark ? Color(.systemGray5) : .white)
|
||||
.shadow(color: .black.opacity(0.15), radius: 4, y: 2)
|
||||
}
|
||||
.fixedSize()
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.animation(.spring(response: 0.3), value: isSelected)
|
||||
}
|
||||
|
||||
private var pinColor: Color {
|
||||
if isVisited {
|
||||
return .green
|
||||
} else {
|
||||
return .orange
|
||||
}
|
||||
}
|
||||
|
||||
private var pinSize: CGFloat {
|
||||
isSelected ? 36 : 28
|
||||
}
|
||||
|
||||
private var iconSize: CGFloat {
|
||||
isSelected ? 16 : 12
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Triangle Shape
|
||||
|
||||
struct Triangle: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: rect.midX, y: rect.maxY))
|
||||
path.addLine(to: CGPoint(x: rect.minX, y: rect.minY))
|
||||
path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
|
||||
path.closeSubpath()
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Map Region Extension
|
||||
|
||||
extension ProgressMapView {
|
||||
/// Calculate region to fit all stadiums
|
||||
static func region(for stadiums: [Stadium]) -> MKCoordinateRegion {
|
||||
guard !stadiums.isEmpty else {
|
||||
// Default to US center
|
||||
return MKCoordinateRegion(
|
||||
center: CLLocationCoordinate2D(latitude: 39.8283, longitude: -98.5795),
|
||||
span: MKCoordinateSpan(latitudeDelta: 50, longitudeDelta: 50)
|
||||
)
|
||||
}
|
||||
|
||||
let latitudes = stadiums.map { $0.latitude }
|
||||
let longitudes = stadiums.map { $0.longitude }
|
||||
|
||||
let minLat = latitudes.min()!
|
||||
let maxLat = latitudes.max()!
|
||||
let minLon = longitudes.min()!
|
||||
let maxLon = longitudes.max()!
|
||||
|
||||
let center = CLLocationCoordinate2D(
|
||||
latitude: (minLat + maxLat) / 2,
|
||||
longitude: (minLon + maxLon) / 2
|
||||
)
|
||||
|
||||
let span = MKCoordinateSpan(
|
||||
latitudeDelta: (maxLat - minLat) * 1.3 + 2, // Add padding
|
||||
longitudeDelta: (maxLon - minLon) * 1.3 + 2
|
||||
)
|
||||
|
||||
return MKCoordinateRegion(center: center, span: span)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
ProgressMapView(
|
||||
stadiums: [],
|
||||
visitStatus: [:],
|
||||
selectedStadium: .constant(nil)
|
||||
)
|
||||
.frame(height: 300)
|
||||
.padding()
|
||||
}
|
||||
685
SportsTime/Features/Progress/Views/ProgressTabView.swift
Normal file
685
SportsTime/Features/Progress/Views/ProgressTabView.swift
Normal file
@@ -0,0 +1,685 @@
|
||||
//
|
||||
// ProgressTabView.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Main view for stadium progress tracking with league selector and map.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct ProgressTabView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@State private var viewModel = ProgressViewModel()
|
||||
@State private var showVisitSheet = false
|
||||
@State private var showPhotoImport = false
|
||||
@State private var showShareSheet = false
|
||||
@State private var selectedStadium: Stadium?
|
||||
@State private var selectedVisitId: UUID?
|
||||
|
||||
@Query private var visits: [StadiumVisit]
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
// League Selector
|
||||
leagueSelector
|
||||
.staggeredAnimation(index: 0)
|
||||
|
||||
// Progress Summary Card
|
||||
progressSummaryCard
|
||||
.staggeredAnimation(index: 1)
|
||||
|
||||
// Map View
|
||||
ProgressMapView(
|
||||
stadiums: viewModel.sportStadiums,
|
||||
visitStatus: viewModel.stadiumVisitStatus,
|
||||
selectedStadium: $selectedStadium
|
||||
)
|
||||
.frame(height: 300)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
|
||||
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
||||
}
|
||||
.staggeredAnimation(index: 2)
|
||||
|
||||
// Stadium Lists
|
||||
stadiumListsSection
|
||||
.staggeredAnimation(index: 3)
|
||||
|
||||
// Achievements Teaser
|
||||
achievementsSection
|
||||
.staggeredAnimation(index: 4)
|
||||
|
||||
// Recent Visits
|
||||
if !viewModel.recentVisits.isEmpty {
|
||||
recentVisitsSection
|
||||
.staggeredAnimation(index: 5)
|
||||
}
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
}
|
||||
.themedBackground()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button {
|
||||
showShareSheet = true
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Menu {
|
||||
Button {
|
||||
showVisitSheet = true
|
||||
} label: {
|
||||
Label("Manual Entry", systemImage: "pencil")
|
||||
}
|
||||
|
||||
Button {
|
||||
showPhotoImport = true
|
||||
} label: {
|
||||
Label("Import from Photos", systemImage: "photo.on.rectangle.angled")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
viewModel.configure(with: modelContext.container)
|
||||
await viewModel.loadData()
|
||||
}
|
||||
.sheet(isPresented: $showVisitSheet) {
|
||||
StadiumVisitSheet(initialSport: viewModel.selectedSport) { _ in
|
||||
Task {
|
||||
await viewModel.loadData()
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showPhotoImport) {
|
||||
PhotoImportView()
|
||||
.onDisappear {
|
||||
Task {
|
||||
await viewModel.loadData()
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(item: $selectedStadium) { stadium in
|
||||
StadiumDetailSheet(
|
||||
stadium: stadium,
|
||||
visitStatus: viewModel.stadiumVisitStatus[stadium.id] ?? .notVisited,
|
||||
sport: viewModel.selectedSport,
|
||||
onVisitLogged: {
|
||||
Task {
|
||||
await viewModel.loadData()
|
||||
}
|
||||
}
|
||||
)
|
||||
.presentationDetents([.medium])
|
||||
}
|
||||
.sheet(isPresented: $showShareSheet) {
|
||||
ProgressShareView(progress: viewModel.leagueProgress)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - League Selector
|
||||
|
||||
private var leagueSelector: some View {
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
ForEach(Sport.supported) { sport in
|
||||
LeagueSelectorButton(
|
||||
sport: sport,
|
||||
isSelected: viewModel.selectedSport == sport,
|
||||
progress: progressForSport(sport)
|
||||
) {
|
||||
withAnimation(Theme.Animation.spring) {
|
||||
viewModel.selectSport(sport)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func progressForSport(_ sport: Sport) -> Double {
|
||||
let visitedCount = viewModel.visits.filter { $0.sportEnum == sport }.count
|
||||
let total = LeagueStructure.stadiumCount(for: sport)
|
||||
guard total > 0 else { return 0 }
|
||||
return Double(min(visitedCount, total)) / Double(total)
|
||||
}
|
||||
|
||||
// MARK: - Progress Summary Card
|
||||
|
||||
private var progressSummaryCard: some View {
|
||||
let progress = viewModel.leagueProgress
|
||||
|
||||
return VStack(spacing: Theme.Spacing.lg) {
|
||||
// Title and progress ring
|
||||
HStack(alignment: .center, spacing: Theme.Spacing.lg) {
|
||||
// Progress Ring
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(Theme.warmOrange.opacity(0.2), lineWidth: 8)
|
||||
.frame(width: 80, height: 80)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: progress.progressFraction)
|
||||
.stroke(Theme.warmOrange, style: StrokeStyle(lineWidth: 8, lineCap: .round))
|
||||
.frame(width: 80, height: 80)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.easeInOut(duration: 0.5), value: progress.progressFraction)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Text("\(progress.visitedStadiums)")
|
||||
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
Text("/\(progress.totalStadiums)")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
||||
Text(viewModel.selectedSport.displayName)
|
||||
.font(.system(size: Theme.FontSize.cardTitle, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Text("Stadium Quest")
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
|
||||
if progress.isComplete {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
Text("Complete!")
|
||||
}
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .semibold))
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
} else {
|
||||
Text("\(progress.totalStadiums - progress.visitedStadiums) stadiums remaining")
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
// Stats row
|
||||
HStack(spacing: Theme.Spacing.lg) {
|
||||
ProgressStatPill(
|
||||
icon: "mappin.circle.fill",
|
||||
value: "\(progress.visitedStadiums)",
|
||||
label: "Visited"
|
||||
)
|
||||
|
||||
ProgressStatPill(
|
||||
icon: "circle.dotted",
|
||||
value: "\(progress.totalStadiums - progress.visitedStadiums)",
|
||||
label: "Remaining"
|
||||
)
|
||||
|
||||
ProgressStatPill(
|
||||
icon: "percent",
|
||||
value: String(format: "%.0f%%", progress.completionPercentage),
|
||||
label: "Complete"
|
||||
)
|
||||
}
|
||||
}
|
||||
.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)
|
||||
}
|
||||
.shadow(color: Theme.cardShadow(colorScheme), radius: 10, y: 5)
|
||||
}
|
||||
|
||||
// MARK: - Stadium Lists Section
|
||||
|
||||
private var stadiumListsSection: some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
|
||||
// Visited Stadiums
|
||||
if !viewModel.visitedStadiums.isEmpty {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
Text("Visited (\(viewModel.visitedStadiums.count))")
|
||||
.font(.system(size: Theme.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
}
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
ForEach(viewModel.visitedStadiums) { stadium in
|
||||
StadiumChip(
|
||||
stadium: stadium,
|
||||
isVisited: true
|
||||
) {
|
||||
selectedStadium = stadium
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unvisited Stadiums
|
||||
if !viewModel.unvisitedStadiums.isEmpty {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
HStack {
|
||||
Image(systemName: "circle.dotted")
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
Text("Not Yet Visited (\(viewModel.unvisitedStadiums.count))")
|
||||
.font(.system(size: Theme.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
}
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
ForEach(viewModel.unvisitedStadiums) { stadium in
|
||||
StadiumChip(
|
||||
stadium: stadium,
|
||||
isVisited: false
|
||||
) {
|
||||
selectedStadium = stadium
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Achievements Section
|
||||
|
||||
private var achievementsSection: some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
HStack {
|
||||
Text("Achievements")
|
||||
.font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Spacer()
|
||||
|
||||
NavigationLink {
|
||||
AchievementsListView()
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Text("View All")
|
||||
Image(systemName: "chevron.right")
|
||||
}
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
AchievementsListView()
|
||||
} label: {
|
||||
HStack(spacing: Theme.Spacing.md) {
|
||||
// Trophy icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Theme.warmOrange.opacity(0.15))
|
||||
.frame(width: 50, height: 50)
|
||||
|
||||
Image(systemName: "trophy.fill")
|
||||
.font(.system(size: 24))
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Track Your Progress")
|
||||
.font(.system(size: Theme.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Text("Earn badges for stadium visits")
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||
.stroke(Theme.warmOrange.opacity(0.3), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Recent Visits Section
|
||||
|
||||
private var recentVisitsSection: some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
Text("Recent Visits")
|
||||
.font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
ForEach(viewModel.recentVisits) { visitSummary in
|
||||
if let stadiumVisit = visits.first(where: { $0.id == visitSummary.id }) {
|
||||
NavigationLink {
|
||||
VisitDetailView(visit: stadiumVisit, stadium: visitSummary.stadium)
|
||||
} label: {
|
||||
RecentVisitRow(visit: visitSummary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
RecentVisitRow(visit: visitSummary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Supporting Views
|
||||
|
||||
struct LeagueSelectorButton: View {
|
||||
let sport: Sport
|
||||
let isSelected: Bool
|
||||
let progress: Double
|
||||
let action: () -> Void
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
VStack(spacing: Theme.Spacing.xs) {
|
||||
ZStack {
|
||||
// Background circle with progress
|
||||
Circle()
|
||||
.stroke(sport.themeColor.opacity(0.2), lineWidth: 3)
|
||||
.frame(width: 50, height: 50)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: progress)
|
||||
.stroke(sport.themeColor, style: StrokeStyle(lineWidth: 3, lineCap: .round))
|
||||
.frame(width: 50, height: 50)
|
||||
.rotationEffect(.degrees(-90))
|
||||
|
||||
// Sport icon
|
||||
Image(systemName: sport.iconName)
|
||||
.font(.title2)
|
||||
.foregroundStyle(isSelected ? sport.themeColor : Theme.textMuted(colorScheme))
|
||||
}
|
||||
|
||||
Text(sport.rawValue)
|
||||
.font(.system(size: Theme.FontSize.micro, weight: isSelected ? .bold : .medium))
|
||||
.foregroundStyle(isSelected ? Theme.textPrimary(colorScheme) : Theme.textMuted(colorScheme))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, Theme.Spacing.sm)
|
||||
.background(isSelected ? Theme.cardBackground(colorScheme) : Color.clear)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
.overlay {
|
||||
if isSelected {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||
.stroke(sport.themeColor, lineWidth: 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProgressStatPill: View {
|
||||
let icon: String
|
||||
let value: String
|
||||
let label: String
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 12))
|
||||
Text(value)
|
||||
.font(.system(size: Theme.FontSize.body, weight: .bold))
|
||||
}
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Text(label)
|
||||
.font(.system(size: Theme.FontSize.micro))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
struct StadiumChip: View {
|
||||
let stadium: Stadium
|
||||
let isVisited: Bool
|
||||
let action: () -> Void
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: Theme.Spacing.xs) {
|
||||
if isVisited {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(stadium.name)
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
.lineLimit(1)
|
||||
|
||||
Text(stadium.city)
|
||||
.font(.system(size: Theme.FontSize.micro))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Theme.Spacing.sm)
|
||||
.padding(.vertical, Theme.Spacing.xs)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(Capsule())
|
||||
.overlay {
|
||||
Capsule()
|
||||
.stroke(isVisited ? Color.green.opacity(0.3) : Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
struct RecentVisitRow: View {
|
||||
let visit: VisitSummary
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Theme.Spacing.md) {
|
||||
// Sport icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(visit.sport.themeColor.opacity(0.15))
|
||||
.frame(width: 40, height: 40)
|
||||
|
||||
Image(systemName: visit.sport.iconName)
|
||||
.foregroundStyle(visit.sport.themeColor)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(visit.stadium.name)
|
||||
.font(.system(size: Theme.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
Text(visit.shortDateDescription)
|
||||
if let matchup = visit.matchup {
|
||||
Text("•")
|
||||
Text(matchup)
|
||||
}
|
||||
}
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if visit.photoCount > 0 {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "photo")
|
||||
Text("\(visit.photoCount)")
|
||||
}
|
||||
.font(.system(size: Theme.FontSize.micro))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct StadiumDetailSheet: View {
|
||||
let stadium: Stadium
|
||||
let visitStatus: StadiumVisitStatus
|
||||
let sport: Sport
|
||||
var onVisitLogged: (() -> Void)?
|
||||
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var showLogVisit = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
// Stadium header
|
||||
VStack(spacing: Theme.Spacing.sm) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(sport.themeColor.opacity(0.15))
|
||||
.frame(width: 80, height: 80)
|
||||
|
||||
Image(systemName: visitStatus.isVisited ? "checkmark.seal.fill" : sport.iconName)
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(visitStatus.isVisited ? .green : sport.themeColor)
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
if visitStatus.isVisited {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
Text("Visited \(visitStatus.visitCount) time\(visitStatus.visitCount == 1 ? "" : "s")")
|
||||
}
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
}
|
||||
|
||||
// Visit history if visited
|
||||
if case .visited(let visits) = visitStatus {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
Text("Visit History")
|
||||
.font(.system(size: Theme.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
ForEach(visits.sorted(by: { $0.visitDate > $1.visitDate })) { visit in
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(visit.shortDateDescription)
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||
if let matchup = visit.matchup {
|
||||
Text(matchup)
|
||||
.font(.system(size: Theme.FontSize.micro))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Text(visit.visitType.displayName)
|
||||
.font(.system(size: Theme.FontSize.micro))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
.padding(Theme.Spacing.sm)
|
||||
.background(Theme.cardBackgroundElevated(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Action button
|
||||
Button {
|
||||
showLogVisit = true
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: visitStatus.isVisited ? "plus" : "checkmark.circle")
|
||||
Text(visitStatus.isVisited ? "Log Another Visit" : "Log Visit")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(Theme.Spacing.md)
|
||||
.background(Theme.warmOrange)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
}
|
||||
.pressableStyle()
|
||||
}
|
||||
.padding(Theme.Spacing.lg)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showLogVisit) {
|
||||
StadiumVisitSheet(
|
||||
initialStadium: stadium,
|
||||
initialSport: sport
|
||||
) { _ in
|
||||
onVisitLogged?()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
ProgressTabView()
|
||||
}
|
||||
.modelContainer(for: StadiumVisit.self, inMemory: true)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ScheduleListView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@State private var viewModel = ScheduleViewModel()
|
||||
@State private var showDatePicker = false
|
||||
|
||||
@@ -97,9 +98,11 @@ struct ScheduleListView: View {
|
||||
Text(formatSectionDate(dateGroup.date))
|
||||
.font(.headline)
|
||||
}
|
||||
.listRowBackground(Theme.cardBackground(colorScheme))
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.refreshable {
|
||||
await viewModel.loadGames()
|
||||
}
|
||||
@@ -128,8 +131,7 @@ struct ScheduleListView: View {
|
||||
|
||||
private var loadingView: some View {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
.scaleEffect(1.5)
|
||||
ThemedSpinner(size: 44)
|
||||
Text("Loading schedule...")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@State private var viewModel = SettingsViewModel()
|
||||
@State private var showResetConfirmation = false
|
||||
|
||||
@@ -91,6 +92,7 @@ struct SettingsView: View {
|
||||
} footer: {
|
||||
Text("Choose a color scheme for the app.")
|
||||
}
|
||||
.listRowBackground(Theme.cardBackground(colorScheme))
|
||||
}
|
||||
|
||||
// MARK: - Sports Section
|
||||
@@ -115,6 +117,7 @@ struct SettingsView: View {
|
||||
} footer: {
|
||||
Text("Selected sports will be shown by default in schedules and trip planning.")
|
||||
}
|
||||
.listRowBackground(Theme.cardBackground(colorScheme))
|
||||
}
|
||||
|
||||
// MARK: - Travel Section
|
||||
@@ -159,6 +162,7 @@ struct SettingsView: View {
|
||||
} footer: {
|
||||
Text("Trips will be optimized to keep daily driving within this limit.")
|
||||
}
|
||||
.listRowBackground(Theme.cardBackground(colorScheme))
|
||||
}
|
||||
|
||||
// MARK: - Data Section
|
||||
@@ -176,7 +180,7 @@ struct SettingsView: View {
|
||||
Spacer()
|
||||
|
||||
if viewModel.isSyncing {
|
||||
ProgressView()
|
||||
ThemedSpinnerCompact(size: 18)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -209,6 +213,7 @@ struct SettingsView: View {
|
||||
Text("Schedule data is synced from CloudKit.")
|
||||
#endif
|
||||
}
|
||||
.listRowBackground(Theme.cardBackground(colorScheme))
|
||||
}
|
||||
|
||||
// MARK: - About Section
|
||||
@@ -236,6 +241,7 @@ struct SettingsView: View {
|
||||
} header: {
|
||||
Text("About")
|
||||
}
|
||||
.listRowBackground(Theme.cardBackground(colorScheme))
|
||||
}
|
||||
|
||||
// MARK: - Reset Section
|
||||
@@ -248,6 +254,7 @@ struct SettingsView: View {
|
||||
Label("Reset to Defaults", systemImage: "arrow.counterclockwise")
|
||||
}
|
||||
}
|
||||
.listRowBackground(Theme.cardBackground(colorScheme))
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
@@ -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