1425 lines
42 KiB
Markdown
1425 lines
42 KiB
Markdown
# Progress Tracking Enhancements Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Enhance the stadium bucket list experience with multiple visits per stadium display, a dedicated games history view, and an interactive zoomable map.
|
|
|
|
**Architecture:** Extends the existing Progress feature module with new views (GamesHistoryView) and modifications to existing views (ProgressMapView, VisitDetailView, StadiumVisitSheet). Uses existing SwiftData models (StadiumVisit already supports multiple visits). Adds computed properties to ProgressViewModel for grouping data.
|
|
|
|
**Tech Stack:** SwiftUI, SwiftData, MapKit
|
|
|
|
---
|
|
|
|
## Phase 1: Zoomable Map
|
|
|
|
### Task 1: Create ProgressMapView Interaction Tests
|
|
|
|
**Files:**
|
|
- Create: `SportsTimeTests/Features/Progress/ProgressMapViewTests.swift`
|
|
|
|
**Step 1: Write the test file**
|
|
|
|
```swift
|
|
import XCTest
|
|
import MapKit
|
|
@testable import SportsTime
|
|
|
|
final class ProgressMapViewTests: XCTestCase {
|
|
|
|
func test_MapViewModel_TracksUserInteraction() {
|
|
// Given: A map view model
|
|
let viewModel = MapInteractionViewModel()
|
|
|
|
// When: User interacts with map (zoom/pan)
|
|
viewModel.userDidInteract()
|
|
|
|
// Then: Interaction is tracked
|
|
XCTAssertTrue(viewModel.hasUserInteracted, "Should track user interaction")
|
|
XCTAssertTrue(viewModel.shouldShowResetButton, "Should show reset button after interaction")
|
|
}
|
|
|
|
func test_MapViewModel_ResetClearsInteraction() {
|
|
// Given: A map with user interaction
|
|
let viewModel = MapInteractionViewModel()
|
|
viewModel.userDidInteract()
|
|
|
|
// When: User resets the view
|
|
viewModel.resetToDefault()
|
|
|
|
// Then: Interaction flag is cleared
|
|
XCTAssertFalse(viewModel.hasUserInteracted, "Should clear interaction flag after reset")
|
|
XCTAssertFalse(viewModel.shouldShowResetButton, "Should hide reset button after reset")
|
|
}
|
|
|
|
func test_MapViewModel_ZoomToStadium_SetsCorrectRegion() {
|
|
// Given: A map view model
|
|
let viewModel = MapInteractionViewModel()
|
|
|
|
// When: Zooming to a stadium location
|
|
let stadiumCoord = CLLocationCoordinate2D(latitude: 40.8296, longitude: -73.9262) // Yankee Stadium
|
|
viewModel.zoomToStadium(at: stadiumCoord)
|
|
|
|
// Then: Region is set to city-level zoom
|
|
XCTAssertEqual(viewModel.region.center.latitude, stadiumCoord.latitude, accuracy: 0.001)
|
|
XCTAssertEqual(viewModel.region.center.longitude, stadiumCoord.longitude, accuracy: 0.001)
|
|
XCTAssertEqual(viewModel.region.span.latitudeDelta, 0.01, accuracy: 0.005, "Should use city-level zoom span")
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/ProgressMapViewTests test 2>&1 | tail -20`
|
|
|
|
Expected: FAIL with "Cannot find 'MapInteractionViewModel' in scope"
|
|
|
|
**Step 3: Commit test file**
|
|
|
|
```bash
|
|
git add SportsTimeTests/Features/Progress/ProgressMapViewTests.swift
|
|
git commit -m "test: add ProgressMapView interaction tests"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Create MapInteractionViewModel
|
|
|
|
**Files:**
|
|
- Create: `SportsTime/Features/Progress/ViewModels/MapInteractionViewModel.swift`
|
|
|
|
**Step 1: Create the view model**
|
|
|
|
```swift
|
|
import SwiftUI
|
|
import MapKit
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class MapInteractionViewModel {
|
|
// Default region: Continental US
|
|
static let defaultRegion = MKCoordinateRegion(
|
|
center: CLLocationCoordinate2D(latitude: 39.8283, longitude: -98.5795),
|
|
span: MKCoordinateSpan(latitudeDelta: 35, longitudeDelta: 55)
|
|
)
|
|
|
|
// City-level zoom span
|
|
static let stadiumZoomSpan = MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01)
|
|
|
|
var region: MKCoordinateRegion = MapInteractionViewModel.defaultRegion
|
|
var hasUserInteracted: Bool = false
|
|
var selectedStadiumId: String?
|
|
|
|
var shouldShowResetButton: Bool {
|
|
hasUserInteracted
|
|
}
|
|
|
|
func userDidInteract() {
|
|
hasUserInteracted = true
|
|
}
|
|
|
|
func resetToDefault() {
|
|
withAnimation(.easeInOut(duration: 0.5)) {
|
|
region = MapInteractionViewModel.defaultRegion
|
|
}
|
|
hasUserInteracted = false
|
|
selectedStadiumId = nil
|
|
}
|
|
|
|
func zoomToStadium(at coordinate: CLLocationCoordinate2D) {
|
|
withAnimation(.easeInOut(duration: 0.3)) {
|
|
region = MKCoordinateRegion(
|
|
center: coordinate,
|
|
span: MapInteractionViewModel.stadiumZoomSpan
|
|
)
|
|
}
|
|
hasUserInteracted = true
|
|
}
|
|
|
|
func selectStadium(id: String, coordinate: CLLocationCoordinate2D) {
|
|
selectedStadiumId = id
|
|
zoomToStadium(at: coordinate)
|
|
}
|
|
|
|
func deselectStadium() {
|
|
selectedStadiumId = nil
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Run tests to verify they pass**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/ProgressMapViewTests test 2>&1 | tail -20`
|
|
|
|
Expected: PASS
|
|
|
|
**Step 3: Commit view model**
|
|
|
|
```bash
|
|
git add SportsTime/Features/Progress/ViewModels/MapInteractionViewModel.swift
|
|
git commit -m "feat: add MapInteractionViewModel for map interactions"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Update ProgressMapView to Enable Interactions
|
|
|
|
**Files:**
|
|
- Modify: `SportsTime/Features/Progress/Views/ProgressMapView.swift`
|
|
|
|
**Step 1: Read the current file**
|
|
|
|
Read `SportsTime/Features/Progress/Views/ProgressMapView.swift` to understand its structure.
|
|
|
|
**Step 2: Enable map interactions and add reset button**
|
|
|
|
Update the Map view to:
|
|
1. Replace `interactionModes: []` with `interactionModes: [.zoom, .pan]`
|
|
2. Add `@State private var mapViewModel = MapInteractionViewModel()`
|
|
3. Add `@Binding var region` to track map region
|
|
4. Add `onMapCameraChange` handler to detect user interaction
|
|
5. Add floating "Reset View" button overlay
|
|
|
|
The Map should look something like:
|
|
|
|
```swift
|
|
Map(position: $cameraPosition, interactionModes: [.zoom, .pan]) {
|
|
// ... existing annotations ...
|
|
}
|
|
.onMapCameraChange { context in
|
|
mapViewModel.userDidInteract()
|
|
}
|
|
.overlay(alignment: .bottomTrailing) {
|
|
if mapViewModel.shouldShowResetButton {
|
|
Button {
|
|
withAnimation {
|
|
cameraPosition = .region(MapInteractionViewModel.defaultRegion)
|
|
mapViewModel.resetToDefault()
|
|
}
|
|
} label: {
|
|
Image(systemName: "arrow.counterclockwise")
|
|
.font(.title3)
|
|
.padding(12)
|
|
.background(.regularMaterial, in: Circle())
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 3: Update annotation tap handling**
|
|
|
|
When tapping a stadium pin, call `mapViewModel.selectStadium(id:coordinate:)` to zoom and select.
|
|
|
|
**Step 4: Verify build succeeds**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20`
|
|
|
|
Expected: BUILD SUCCEEDED
|
|
|
|
**Step 5: Commit changes**
|
|
|
|
```bash
|
|
git add SportsTime/Features/Progress/Views/ProgressMapView.swift
|
|
git commit -m "feat: enable zoom/pan on progress map with reset button"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 2: Multiple Visits Per Stadium
|
|
|
|
### Task 4: Create Visit List Tests
|
|
|
|
**Files:**
|
|
- Create: `SportsTimeTests/Features/Progress/VisitListTests.swift`
|
|
|
|
**Step 1: Write tests for multiple visits display**
|
|
|
|
```swift
|
|
import XCTest
|
|
import SwiftData
|
|
@testable import SportsTime
|
|
|
|
@MainActor
|
|
final class VisitListTests: XCTestCase {
|
|
var modelContainer: ModelContainer!
|
|
var modelContext: ModelContext!
|
|
|
|
override func setUp() async throws {
|
|
let config = ModelConfiguration(isStoredInMemoryOnly: true)
|
|
modelContainer = try ModelContainer(
|
|
for: StadiumVisit.self, Achievement.self, UserPreferences.self,
|
|
configurations: config
|
|
)
|
|
modelContext = modelContainer.mainContext
|
|
}
|
|
|
|
override func tearDown() async throws {
|
|
modelContainer = nil
|
|
modelContext = nil
|
|
}
|
|
|
|
func test_VisitsForStadium_ReturnsAllVisitsSortedByDate() async throws {
|
|
// Given: Multiple visits to the same stadium
|
|
let stadiumId = "yankee-stadium"
|
|
|
|
let visit1 = StadiumVisit(
|
|
stadiumId: stadiumId,
|
|
stadiumNameAtVisit: "Yankee Stadium",
|
|
visitDate: Date().addingTimeInterval(-86400 * 30), // 30 days ago
|
|
visitType: .game,
|
|
dataSource: .manual
|
|
)
|
|
|
|
let visit2 = StadiumVisit(
|
|
stadiumId: stadiumId,
|
|
stadiumNameAtVisit: "Yankee Stadium",
|
|
visitDate: Date().addingTimeInterval(-86400 * 7), // 7 days ago
|
|
visitType: .game,
|
|
dataSource: .manual
|
|
)
|
|
|
|
let visit3 = StadiumVisit(
|
|
stadiumId: stadiumId,
|
|
stadiumNameAtVisit: "Yankee Stadium",
|
|
visitDate: Date(), // today
|
|
visitType: .tour,
|
|
dataSource: .manual
|
|
)
|
|
|
|
modelContext.insert(visit1)
|
|
modelContext.insert(visit2)
|
|
modelContext.insert(visit3)
|
|
try modelContext.save()
|
|
|
|
// When: Fetching visits for that stadium
|
|
var descriptor = FetchDescriptor<StadiumVisit>(
|
|
predicate: #Predicate { $0.stadiumId == stadiumId },
|
|
sortBy: [SortDescriptor(\.visitDate, order: .reverse)]
|
|
)
|
|
let visits = try modelContext.fetch(descriptor)
|
|
|
|
// Then: All visits returned, most recent first
|
|
XCTAssertEqual(visits.count, 3, "Should return all 3 visits")
|
|
XCTAssertEqual(visits[0].visitType, .tour, "Most recent visit should be first")
|
|
XCTAssertEqual(visits[2].visitType, .game, "Oldest visit should be last")
|
|
}
|
|
|
|
func test_VisitCountForStadium_ReturnsCorrectCount() async throws {
|
|
// Given: 3 visits to one stadium, 1 to another
|
|
let stadium1 = "yankee-stadium"
|
|
let stadium2 = "fenway-park"
|
|
|
|
for i in 0..<3 {
|
|
let visit = StadiumVisit(
|
|
stadiumId: stadium1,
|
|
stadiumNameAtVisit: "Yankee Stadium",
|
|
visitDate: Date().addingTimeInterval(Double(-i * 86400)),
|
|
visitType: .game,
|
|
dataSource: .manual
|
|
)
|
|
modelContext.insert(visit)
|
|
}
|
|
|
|
let fenwayVisit = StadiumVisit(
|
|
stadiumId: stadium2,
|
|
stadiumNameAtVisit: "Fenway Park",
|
|
visitDate: Date(),
|
|
visitType: .game,
|
|
dataSource: .manual
|
|
)
|
|
modelContext.insert(fenwayVisit)
|
|
try modelContext.save()
|
|
|
|
// When: Counting visits per stadium
|
|
let yankeeDescriptor = FetchDescriptor<StadiumVisit>(
|
|
predicate: #Predicate { $0.stadiumId == stadium1 }
|
|
)
|
|
let fenwayDescriptor = FetchDescriptor<StadiumVisit>(
|
|
predicate: #Predicate { $0.stadiumId == stadium2 }
|
|
)
|
|
|
|
let yankeeCount = try modelContext.fetchCount(yankeeDescriptor)
|
|
let fenwayCount = try modelContext.fetchCount(fenwayDescriptor)
|
|
|
|
// Then: Correct counts
|
|
XCTAssertEqual(yankeeCount, 3)
|
|
XCTAssertEqual(fenwayCount, 1)
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Run tests to verify they pass**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/VisitListTests test 2>&1 | tail -20`
|
|
|
|
Expected: PASS (tests existing functionality)
|
|
|
|
**Step 3: Commit tests**
|
|
|
|
```bash
|
|
git add SportsTimeTests/Features/Progress/VisitListTests.swift
|
|
git commit -m "test: add tests for multiple visits per stadium"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Create VisitListCard Component
|
|
|
|
**Files:**
|
|
- Create: `SportsTime/Features/Progress/Views/Components/VisitListCard.swift`
|
|
|
|
**Step 1: Create compact visit card for lists**
|
|
|
|
```swift
|
|
import SwiftUI
|
|
|
|
struct VisitListCard: View {
|
|
let visit: StadiumVisit
|
|
@State private var isExpanded = false
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
// Header row (always visible)
|
|
Button {
|
|
withAnimation(.easeInOut(duration: 0.2)) {
|
|
isExpanded.toggle()
|
|
}
|
|
} label: {
|
|
HStack {
|
|
// Date
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(visit.visitDate.formatted(date: .abbreviated, time: .omitted))
|
|
.font(.subheadline.bold())
|
|
Text(visit.visitType.rawValue.capitalized)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Game info preview (if available)
|
|
if let gameInfo = visit.gameInfo {
|
|
Text("\(gameInfo.awayTeamName) @ \(gameInfo.homeTeamName)")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
|
|
// Chevron
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.rotationEffect(.degrees(isExpanded ? 90 : 0))
|
|
}
|
|
.padding()
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
// Expanded content
|
|
if isExpanded {
|
|
Divider()
|
|
.padding(.horizontal)
|
|
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
// Game details
|
|
if let gameInfo = visit.gameInfo {
|
|
GameInfoRow(gameInfo: gameInfo)
|
|
}
|
|
|
|
// Seat location
|
|
if let seat = visit.seatLocation, !seat.isEmpty {
|
|
InfoRow(icon: "seat.airdrop", label: "Seat", value: seat)
|
|
}
|
|
|
|
// Notes
|
|
if let notes = visit.notes, !notes.isEmpty {
|
|
InfoRow(icon: "note.text", label: "Notes", value: notes)
|
|
}
|
|
|
|
// Photos count
|
|
if !visit.photos.isEmpty {
|
|
InfoRow(
|
|
icon: "photo.on.rectangle",
|
|
label: "Photos",
|
|
value: "\(visit.photos.count) photo\(visit.photos.count == 1 ? "" : "s")"
|
|
)
|
|
}
|
|
}
|
|
.padding()
|
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
|
}
|
|
}
|
|
.background(Color(.systemBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
}
|
|
|
|
private struct GameInfoRow: View {
|
|
let gameInfo: StadiumVisit.GameInfo
|
|
|
|
var body: some View {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("\(gameInfo.awayTeamName) @ \(gameInfo.homeTeamName)")
|
|
.font(.subheadline.bold())
|
|
|
|
if let score = gameInfo.finalScore {
|
|
Text("Final: \(score)")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct InfoRow: View {
|
|
let icon: String
|
|
let label: String
|
|
let value: String
|
|
|
|
var body: some View {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: icon)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 16)
|
|
|
|
Text(label)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
|
|
Text(value)
|
|
.font(.caption)
|
|
.lineLimit(2)
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
let visit = StadiumVisit(
|
|
stadiumId: "yankee-stadium",
|
|
stadiumNameAtVisit: "Yankee Stadium",
|
|
visitDate: Date(),
|
|
visitType: .game,
|
|
dataSource: .manual
|
|
)
|
|
visit.seatLocation = "Section 203, Row A, Seat 5"
|
|
visit.notes = "Great game! Saw a home run."
|
|
|
|
return VStack {
|
|
VisitListCard(visit: visit)
|
|
}
|
|
.padding()
|
|
.background(Color(.systemGroupedBackground))
|
|
}
|
|
```
|
|
|
|
**Step 2: Verify build succeeds**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20`
|
|
|
|
Expected: BUILD SUCCEEDED
|
|
|
|
**Step 3: Commit component**
|
|
|
|
```bash
|
|
git add SportsTime/Features/Progress/Views/Components/VisitListCard.swift
|
|
git commit -m "feat: add VisitListCard component for visit history"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Create StadiumVisitHistoryView
|
|
|
|
**Files:**
|
|
- Create: `SportsTime/Features/Progress/Views/StadiumVisitHistoryView.swift`
|
|
|
|
**Step 1: Create the visit history view**
|
|
|
|
```swift
|
|
import SwiftUI
|
|
import SwiftData
|
|
|
|
struct StadiumVisitHistoryView: View {
|
|
let stadium: Stadium
|
|
@Environment(\.modelContext) private var modelContext
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
@State private var visits: [StadiumVisit] = []
|
|
@State private var isLoading = true
|
|
@State private var showingAddVisit = false
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Group {
|
|
if isLoading {
|
|
ProgressView()
|
|
} else if visits.isEmpty {
|
|
EmptyVisitHistoryView()
|
|
} else {
|
|
VisitHistoryList(visits: visits)
|
|
}
|
|
}
|
|
.navigationTitle(stadium.name)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Done") { dismiss() }
|
|
}
|
|
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Button {
|
|
showingAddVisit = true
|
|
} label: {
|
|
Image(systemName: "plus")
|
|
}
|
|
}
|
|
}
|
|
.sheet(isPresented: $showingAddVisit) {
|
|
StadiumVisitSheet(preselectedStadium: stadium)
|
|
}
|
|
}
|
|
.task {
|
|
await loadVisits()
|
|
}
|
|
}
|
|
|
|
private func loadVisits() async {
|
|
isLoading = true
|
|
defer { isLoading = false }
|
|
|
|
let stadiumId = stadium.id
|
|
var descriptor = FetchDescriptor<StadiumVisit>(
|
|
predicate: #Predicate { $0.stadiumId == stadiumId },
|
|
sortBy: [SortDescriptor(\.visitDate, order: .reverse)]
|
|
)
|
|
|
|
do {
|
|
visits = try modelContext.fetch(descriptor)
|
|
} catch {
|
|
visits = []
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct VisitHistoryList: View {
|
|
let visits: [StadiumVisit]
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(spacing: 12) {
|
|
// Visit count header
|
|
HStack {
|
|
Text("\(visits.count) Visit\(visits.count == 1 ? "" : "s")")
|
|
.font(.headline)
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal)
|
|
|
|
// Visit cards
|
|
ForEach(visits, id: \.id) { visit in
|
|
VisitListCard(visit: visit)
|
|
.padding(.horizontal)
|
|
}
|
|
}
|
|
.padding(.vertical)
|
|
}
|
|
.background(Color(.systemGroupedBackground))
|
|
}
|
|
}
|
|
|
|
private struct EmptyVisitHistoryView: View {
|
|
var body: some View {
|
|
VStack(spacing: 16) {
|
|
Image(systemName: "calendar.badge.plus")
|
|
.font(.system(size: 48))
|
|
.foregroundStyle(.secondary)
|
|
|
|
Text("No visits recorded")
|
|
.font(.headline)
|
|
|
|
Text("Tap + to add your first visit to this stadium")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Verify build succeeds**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20`
|
|
|
|
Expected: BUILD SUCCEEDED
|
|
|
|
**Step 3: Commit view**
|
|
|
|
```bash
|
|
git add SportsTime/Features/Progress/Views/StadiumVisitHistoryView.swift
|
|
git commit -m "feat: add StadiumVisitHistoryView for multiple visits"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Add Visit Count Badge to Stadium Cards
|
|
|
|
**Files:**
|
|
- Modify: `SportsTime/Features/Progress/Views/ProgressTabView.swift`
|
|
|
|
**Step 1: Read ProgressTabView to find stadium card/chip component**
|
|
|
|
Read the file to identify where visited stadium chips are displayed.
|
|
|
|
**Step 2: Add visit count badge**
|
|
|
|
Where visited stadiums are shown as chips, add a badge showing visit count:
|
|
|
|
```swift
|
|
// Example modification to stadium chip
|
|
HStack(spacing: 4) {
|
|
Text(stadiumName)
|
|
.font(.caption)
|
|
|
|
// Visit count badge (if more than 1)
|
|
if visitCount > 1 {
|
|
Text("\(visitCount)")
|
|
.font(.caption2.bold())
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(Color.orange, in: Capsule())
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 3: Update tap action to show history**
|
|
|
|
When tapping a visited stadium, present `StadiumVisitHistoryView` instead of just the most recent visit.
|
|
|
|
**Step 4: Verify build succeeds**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20`
|
|
|
|
Expected: BUILD SUCCEEDED
|
|
|
|
**Step 5: Commit changes**
|
|
|
|
```bash
|
|
git add SportsTime/Features/Progress/Views/ProgressTabView.swift
|
|
git commit -m "feat: add visit count badges and history view navigation"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 3: Games History View
|
|
|
|
### Task 8: Create GamesHistoryViewModel Test
|
|
|
|
**Files:**
|
|
- Create: `SportsTimeTests/Features/Progress/GamesHistoryViewModelTests.swift`
|
|
|
|
**Step 1: Write tests for games history**
|
|
|
|
```swift
|
|
import XCTest
|
|
import SwiftData
|
|
@testable import SportsTime
|
|
|
|
@MainActor
|
|
final class GamesHistoryViewModelTests: XCTestCase {
|
|
var modelContainer: ModelContainer!
|
|
var modelContext: ModelContext!
|
|
|
|
override func setUp() async throws {
|
|
let config = ModelConfiguration(isStoredInMemoryOnly: true)
|
|
modelContainer = try ModelContainer(
|
|
for: StadiumVisit.self, Achievement.self, UserPreferences.self,
|
|
configurations: config
|
|
)
|
|
modelContext = modelContainer.mainContext
|
|
}
|
|
|
|
override func tearDown() async throws {
|
|
modelContainer = nil
|
|
modelContext = nil
|
|
}
|
|
|
|
func test_GamesHistoryViewModel_GroupsVisitsByYear() async throws {
|
|
// Given: Visits in different years
|
|
let visit2026 = StadiumVisit(
|
|
stadiumId: "stadium-1",
|
|
stadiumNameAtVisit: "Stadium 2026",
|
|
visitDate: Calendar.current.date(from: DateComponents(year: 2026, month: 6, day: 15))!,
|
|
visitType: .game,
|
|
dataSource: .manual
|
|
)
|
|
|
|
let visit2025 = StadiumVisit(
|
|
stadiumId: "stadium-2",
|
|
stadiumNameAtVisit: "Stadium 2025",
|
|
visitDate: Calendar.current.date(from: DateComponents(year: 2025, month: 6, day: 15))!,
|
|
visitType: .game,
|
|
dataSource: .manual
|
|
)
|
|
|
|
modelContext.insert(visit2026)
|
|
modelContext.insert(visit2025)
|
|
try modelContext.save()
|
|
|
|
// When: Loading games history
|
|
let viewModel = GamesHistoryViewModel(modelContext: modelContext)
|
|
await viewModel.loadGames()
|
|
|
|
// Then: Visits are grouped by year
|
|
XCTAssertEqual(viewModel.visitsByYear.keys.count, 2, "Should have 2 years")
|
|
XCTAssertTrue(viewModel.visitsByYear.keys.contains(2026))
|
|
XCTAssertTrue(viewModel.visitsByYear.keys.contains(2025))
|
|
}
|
|
|
|
func test_GamesHistoryViewModel_FiltersBySport() async throws {
|
|
// Given: Visits to different sport stadiums
|
|
// Note: This requires stadiums in AppDataProvider to map stadiumId → sport
|
|
let mlbVisit = StadiumVisit(
|
|
stadiumId: "yankee-stadium",
|
|
stadiumNameAtVisit: "Yankee Stadium",
|
|
visitDate: Date(),
|
|
visitType: .game,
|
|
dataSource: .manual
|
|
)
|
|
|
|
modelContext.insert(mlbVisit)
|
|
try modelContext.save()
|
|
|
|
// When: Filtering by MLB
|
|
let viewModel = GamesHistoryViewModel(modelContext: modelContext)
|
|
await viewModel.loadGames()
|
|
viewModel.selectedSports = [.mlb]
|
|
|
|
// Then: Only MLB visits shown
|
|
let filteredCount = viewModel.filteredVisits.count
|
|
XCTAssertGreaterThanOrEqual(filteredCount, 0, "Filter should work without crashing")
|
|
}
|
|
|
|
func test_GamesHistoryViewModel_SortsMostRecentFirst() async throws {
|
|
// Given: Visits on different dates
|
|
let oldVisit = StadiumVisit(
|
|
stadiumId: "stadium-1",
|
|
stadiumNameAtVisit: "Old Stadium",
|
|
visitDate: Date().addingTimeInterval(-86400 * 30), // 30 days ago
|
|
visitType: .game,
|
|
dataSource: .manual
|
|
)
|
|
|
|
let newVisit = StadiumVisit(
|
|
stadiumId: "stadium-2",
|
|
stadiumNameAtVisit: "New Stadium",
|
|
visitDate: Date(),
|
|
visitType: .game,
|
|
dataSource: .manual
|
|
)
|
|
|
|
modelContext.insert(oldVisit)
|
|
modelContext.insert(newVisit)
|
|
try modelContext.save()
|
|
|
|
// When: Loading games
|
|
let viewModel = GamesHistoryViewModel(modelContext: modelContext)
|
|
await viewModel.loadGames()
|
|
|
|
// Then: Most recent first within year
|
|
let visits = viewModel.allVisits
|
|
XCTAssertEqual(visits.first?.stadiumNameAtVisit, "New Stadium", "Most recent should be first")
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Run tests to verify they fail**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/GamesHistoryViewModelTests test 2>&1 | tail -20`
|
|
|
|
Expected: FAIL with "Cannot find 'GamesHistoryViewModel' in scope"
|
|
|
|
**Step 3: Commit tests**
|
|
|
|
```bash
|
|
git add SportsTimeTests/Features/Progress/GamesHistoryViewModelTests.swift
|
|
git commit -m "test: add GamesHistoryViewModel tests"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Create GamesHistoryViewModel
|
|
|
|
**Files:**
|
|
- Create: `SportsTime/Features/Progress/ViewModels/GamesHistoryViewModel.swift`
|
|
|
|
**Step 1: Create the view model**
|
|
|
|
```swift
|
|
import SwiftUI
|
|
import SwiftData
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class GamesHistoryViewModel {
|
|
private let modelContext: ModelContext
|
|
|
|
var allVisits: [StadiumVisit] = []
|
|
var selectedSports: Set<Sport> = []
|
|
var isLoading = false
|
|
var error: String?
|
|
|
|
// Computed: visits grouped by year
|
|
var visitsByYear: [Int: [StadiumVisit]] {
|
|
let calendar = Calendar.current
|
|
let filtered = filteredVisits
|
|
return Dictionary(grouping: filtered) { visit in
|
|
calendar.component(.year, from: visit.visitDate)
|
|
}
|
|
}
|
|
|
|
// Computed: sorted year keys (descending)
|
|
var sortedYears: [Int] {
|
|
visitsByYear.keys.sorted(by: >)
|
|
}
|
|
|
|
// Computed: filtered by selected sports
|
|
var filteredVisits: [StadiumVisit] {
|
|
guard !selectedSports.isEmpty else { return allVisits }
|
|
|
|
return allVisits.filter { visit in
|
|
guard let stadium = AppDataProvider.shared.stadium(byId: visit.stadiumId) else {
|
|
return false
|
|
}
|
|
return selectedSports.contains(stadium.sport)
|
|
}
|
|
}
|
|
|
|
// Total count
|
|
var totalGamesCount: Int {
|
|
filteredVisits.count
|
|
}
|
|
|
|
init(modelContext: ModelContext) {
|
|
self.modelContext = modelContext
|
|
}
|
|
|
|
func loadGames() async {
|
|
isLoading = true
|
|
defer { isLoading = false }
|
|
|
|
let descriptor = FetchDescriptor<StadiumVisit>(
|
|
sortBy: [SortDescriptor(\.visitDate, order: .reverse)]
|
|
)
|
|
|
|
do {
|
|
allVisits = try modelContext.fetch(descriptor)
|
|
} catch {
|
|
self.error = "Failed to load games: \(error.localizedDescription)"
|
|
allVisits = []
|
|
}
|
|
}
|
|
|
|
func toggleSport(_ sport: Sport) {
|
|
if selectedSports.contains(sport) {
|
|
selectedSports.remove(sport)
|
|
} else {
|
|
selectedSports.insert(sport)
|
|
}
|
|
}
|
|
|
|
func clearFilters() {
|
|
selectedSports.removeAll()
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Run tests to verify they pass**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/GamesHistoryViewModelTests test 2>&1 | tail -20`
|
|
|
|
Expected: PASS
|
|
|
|
**Step 3: Commit view model**
|
|
|
|
```bash
|
|
git add SportsTime/Features/Progress/ViewModels/GamesHistoryViewModel.swift
|
|
git commit -m "feat: add GamesHistoryViewModel with year grouping and sport filtering"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: Create GamesHistoryRow Component
|
|
|
|
**Files:**
|
|
- Create: `SportsTime/Features/Progress/Views/Components/GamesHistoryRow.swift`
|
|
|
|
**Step 1: Create the row component**
|
|
|
|
```swift
|
|
import SwiftUI
|
|
|
|
struct GamesHistoryRow: View {
|
|
let visit: StadiumVisit
|
|
let stadium: Stadium?
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
// Sport icon
|
|
if let stadium {
|
|
Image(systemName: sportIcon(for: stadium.sport))
|
|
.font(.title3)
|
|
.foregroundStyle(stadium.sport.color)
|
|
.frame(width: 32)
|
|
}
|
|
|
|
// Visit info
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
// Date
|
|
Text(visit.visitDate.formatted(date: .abbreviated, time: .omitted))
|
|
.font(.subheadline.bold())
|
|
|
|
// Teams (if game)
|
|
if let gameInfo = visit.gameInfo {
|
|
Text("\(gameInfo.awayTeamName) @ \(gameInfo.homeTeamName)")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
Text(visit.stadiumNameAtVisit)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Score (if available)
|
|
if let gameInfo = visit.gameInfo, let score = gameInfo.finalScore {
|
|
Text(score)
|
|
.font(.caption.monospaced())
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
// Chevron
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
.padding()
|
|
.background(Color(.systemBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
}
|
|
|
|
private func sportIcon(for sport: Sport) -> String {
|
|
switch sport {
|
|
case .mlb: return "baseball"
|
|
case .nba: return "basketball"
|
|
case .nhl: return "hockey.puck"
|
|
case .nfl: return "football"
|
|
case .mls: return "soccerball"
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
let visit = StadiumVisit(
|
|
stadiumId: "yankee-stadium",
|
|
stadiumNameAtVisit: "Yankee Stadium",
|
|
visitDate: Date(),
|
|
visitType: .game,
|
|
dataSource: .manual
|
|
)
|
|
|
|
GamesHistoryRow(
|
|
visit: visit,
|
|
stadium: nil
|
|
)
|
|
.padding()
|
|
.background(Color(.systemGroupedBackground))
|
|
}
|
|
```
|
|
|
|
**Step 2: Verify build succeeds**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20`
|
|
|
|
Expected: BUILD SUCCEEDED
|
|
|
|
**Step 3: Commit component**
|
|
|
|
```bash
|
|
git add SportsTime/Features/Progress/Views/Components/GamesHistoryRow.swift
|
|
git commit -m "feat: add GamesHistoryRow component"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 11: Create GamesHistoryView
|
|
|
|
**Files:**
|
|
- Create: `SportsTime/Features/Progress/Views/GamesHistoryView.swift`
|
|
|
|
**Step 1: Create the games history view**
|
|
|
|
```swift
|
|
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 {
|
|
NavigationStack {
|
|
Group {
|
|
if let viewModel {
|
|
GamesHistoryContent(
|
|
viewModel: viewModel,
|
|
selectedVisit: $selectedVisit
|
|
)
|
|
} else {
|
|
ProgressView("Loading games...")
|
|
}
|
|
}
|
|
.navigationTitle("Games Attended")
|
|
.sheet(item: $selectedVisit) { visit in
|
|
VisitDetailView(visit: visit)
|
|
}
|
|
}
|
|
.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.abbreviation)
|
|
.font(.caption.bold())
|
|
}
|
|
.foregroundStyle(isSelected ? .white : .primary)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(
|
|
Capsule()
|
|
.fill(isSelected ? sport.color : 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"
|
|
}
|
|
}
|
|
}
|
|
|
|
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(byId: 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()
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Verify build succeeds**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20`
|
|
|
|
Expected: BUILD SUCCEEDED
|
|
|
|
**Step 3: Commit view**
|
|
|
|
```bash
|
|
git add SportsTime/Features/Progress/Views/GamesHistoryView.swift
|
|
git commit -m "feat: add GamesHistoryView with year grouping and sport filters"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 12: Add Navigation to GamesHistoryView
|
|
|
|
**Files:**
|
|
- Modify: `SportsTime/Features/Progress/Views/ProgressTabView.swift`
|
|
|
|
**Step 1: Read ProgressTabView**
|
|
|
|
Read the file to find the header area and "Recent Visits" section.
|
|
|
|
**Step 2: Add navigation links**
|
|
|
|
Add a button in the header or toolbar to access GamesHistoryView:
|
|
|
|
```swift
|
|
// In toolbar or header
|
|
NavigationLink(destination: GamesHistoryView()) {
|
|
Label("All Games", systemImage: "list.bullet")
|
|
}
|
|
|
|
// Or a "See All" link in Recent Visits section
|
|
HStack {
|
|
Text("Recent Visits")
|
|
.font(.headline)
|
|
Spacer()
|
|
NavigationLink("See All") {
|
|
GamesHistoryView()
|
|
}
|
|
.font(.caption)
|
|
}
|
|
```
|
|
|
|
**Step 3: Verify build succeeds**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20`
|
|
|
|
Expected: BUILD SUCCEEDED
|
|
|
|
**Step 4: Commit changes**
|
|
|
|
```bash
|
|
git add SportsTime/Features/Progress/Views/ProgressTabView.swift
|
|
git commit -m "feat: add navigation to GamesHistoryView from Progress tab"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 4: Final Integration and Testing
|
|
|
|
### Task 13: Run Full Test Suite
|
|
|
|
**Step 1: Run all tests**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test 2>&1 | tail -50`
|
|
|
|
Expected: All tests pass
|
|
|
|
**Step 2: Fix any failures**
|
|
|
|
If any tests fail, investigate and fix them:
|
|
|
|
```bash
|
|
# If fixes needed
|
|
git add -A
|
|
git commit -m "fix: address test failures in progress tracking enhancements"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 14: Verify Build and Commit Feature
|
|
|
|
**Step 1: Final build verification**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20`
|
|
|
|
Expected: BUILD SUCCEEDED
|
|
|
|
**Step 2: Final commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat: complete progress tracking enhancements
|
|
|
|
- Enable zoom/pan on progress map with reset button
|
|
- Add multiple visits per stadium display with visit count badges
|
|
- Create GamesHistoryView with year grouping and sport filters
|
|
- Add StadiumVisitHistoryView for viewing all visits to a stadium
|
|
- Add VisitListCard and GamesHistoryRow components"
|
|
```
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
This plan implements three features:
|
|
|
|
1. **Zoomable Map** (Tasks 1-3)
|
|
- Enable zoom/pan interactions on ProgressMapView
|
|
- Add MapInteractionViewModel for state management
|
|
- Add floating "Reset View" button
|
|
|
|
2. **Multiple Visits Per Stadium** (Tasks 4-7)
|
|
- Create VisitListCard expandable component
|
|
- Create StadiumVisitHistoryView for full visit history
|
|
- Add visit count badges to stadium chips
|
|
- Update tap handling to show history view
|
|
|
|
3. **Games History View** (Tasks 8-12)
|
|
- Create GamesHistoryViewModel with year grouping
|
|
- Create GamesHistoryRow component
|
|
- Create GamesHistoryView with sport filter chips
|
|
- Add navigation from Progress tab
|
|
|
|
**Files Created:**
|
|
- `SportsTime/Features/Progress/ViewModels/MapInteractionViewModel.swift`
|
|
- `SportsTime/Features/Progress/Views/Components/VisitListCard.swift`
|
|
- `SportsTime/Features/Progress/Views/StadiumVisitHistoryView.swift`
|
|
- `SportsTime/Features/Progress/ViewModels/GamesHistoryViewModel.swift`
|
|
- `SportsTime/Features/Progress/Views/Components/GamesHistoryRow.swift`
|
|
- `SportsTime/Features/Progress/Views/GamesHistoryView.swift`
|
|
|
|
**Files Modified:**
|
|
- `SportsTime/Features/Progress/Views/ProgressMapView.swift`
|
|
- `SportsTime/Features/Progress/Views/ProgressTabView.swift`
|
|
|
|
**Tests Created:**
|
|
- `SportsTimeTests/Features/Progress/ProgressMapViewTests.swift`
|
|
- `SportsTimeTests/Features/Progress/VisitListTests.swift`
|
|
- `SportsTimeTests/Features/Progress/GamesHistoryViewModelTests.swift`
|