feat(progress): add progress tracking enhancements
- Enable zoom/pan on progress map with reset button - Add visit count badges to stadium chips - Create GamesHistoryView with year grouping and sport filters - Create StadiumVisitHistoryView for viewing all visits to a stadium - Add VisitListCard and GamesHistoryRow components - Add "See All" navigation from Recent Visits to Games History - Add tests for map interactions, visit lists, and games history Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
210
SportsTime/Features/Progress/Views/GamesHistoryView.swift
Normal file
210
SportsTime/Features/Progress/Views/GamesHistoryView.swift
Normal file
@@ -0,0 +1,210 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct GamesHistoryView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@State private var viewModel: GamesHistoryViewModel?
|
||||
@State private var selectedVisit: StadiumVisit?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let viewModel {
|
||||
GamesHistoryContent(
|
||||
viewModel: viewModel,
|
||||
selectedVisit: $selectedVisit
|
||||
)
|
||||
} else {
|
||||
ProgressView("Loading games...")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Games Attended")
|
||||
.sheet(item: $selectedVisit) { visit in
|
||||
if let stadium = AppDataProvider.shared.stadium(for: visit.stadiumId) {
|
||||
VisitDetailView(visit: visit, stadium: stadium)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
if viewModel == nil {
|
||||
let vm = GamesHistoryViewModel(modelContext: modelContext)
|
||||
await vm.loadGames()
|
||||
viewModel = vm
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct GamesHistoryContent: View {
|
||||
@Bindable var viewModel: GamesHistoryViewModel
|
||||
@Binding var selectedVisit: StadiumVisit?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header with count and filters
|
||||
VStack(spacing: 12) {
|
||||
// Total count
|
||||
HStack {
|
||||
Text("\(viewModel.totalGamesCount) Games")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
|
||||
if !viewModel.selectedSports.isEmpty {
|
||||
Button("Clear") {
|
||||
viewModel.clearFilters()
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
// Sport filter chips
|
||||
SportFilterChips(
|
||||
selectedSports: viewModel.selectedSports,
|
||||
onToggle: viewModel.toggleSport
|
||||
)
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
|
||||
Divider()
|
||||
|
||||
// Games list grouped by year
|
||||
if viewModel.filteredVisits.isEmpty {
|
||||
EmptyGamesView()
|
||||
} else {
|
||||
GamesListByYear(
|
||||
visitsByYear: viewModel.visitsByYear,
|
||||
sortedYears: viewModel.sortedYears,
|
||||
onSelect: { visit in
|
||||
selectedVisit = visit
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
}
|
||||
}
|
||||
|
||||
private struct SportFilterChips: View {
|
||||
let selectedSports: Set<Sport>
|
||||
let onToggle: (Sport) -> Void
|
||||
|
||||
private let sports: [Sport] = [.mlb, .nba, .nhl]
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(sports, id: \.self) { sport in
|
||||
SportChip(
|
||||
sport: sport,
|
||||
isSelected: selectedSports.contains(sport),
|
||||
onTap: { onToggle(sport) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SportChip: View {
|
||||
let sport: Sport
|
||||
let isSelected: Bool
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: sportIcon)
|
||||
.font(.caption)
|
||||
Text(sport.rawValue)
|
||||
.font(.caption.bold())
|
||||
}
|
||||
.foregroundStyle(isSelected ? .white : .primary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(isSelected ? sport.themeColor : Color(.systemGray5))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var sportIcon: String {
|
||||
switch sport {
|
||||
case .mlb: return "baseball"
|
||||
case .nba: return "basketball"
|
||||
case .nhl: return "hockey.puck"
|
||||
case .nfl: return "football"
|
||||
case .mls: return "soccerball"
|
||||
@unknown default: return "sportscourt"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct GamesListByYear: View {
|
||||
let visitsByYear: [Int: [StadiumVisit]]
|
||||
let sortedYears: [Int]
|
||||
let onSelect: (StadiumVisit) -> Void
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) {
|
||||
ForEach(sortedYears, id: \.self) { year in
|
||||
Section {
|
||||
VStack(spacing: 8) {
|
||||
ForEach(visitsByYear[year] ?? [], id: \.id) { visit in
|
||||
Button {
|
||||
onSelect(visit)
|
||||
} label: {
|
||||
GamesHistoryRow(
|
||||
visit: visit,
|
||||
stadium: AppDataProvider.shared.stadium(for: visit.stadiumId)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
} header: {
|
||||
YearHeader(year: year)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct YearHeader: View {
|
||||
let year: Int
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(String(year))
|
||||
.font(.title3.bold())
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color(.systemGroupedBackground))
|
||||
}
|
||||
}
|
||||
|
||||
private struct EmptyGamesView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "ticket")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text("No games recorded yet")
|
||||
.font(.headline)
|
||||
|
||||
Text("Add your first stadium visit to see it here!")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user