fix: 22 audit fixes — concurrency, memory, performance, accessibility

- Move 7 Data(contentsOf:) calls off MainActor via Task.detached (BootstrapService)
- Batch-fetch N+1 queries in sync merge loops (CanonicalSyncService)
- Predicate-based gamesForTeam fetch instead of fetching all games (DataProvider)
- Proper Sendable on RouteInfo with nonisolated(unsafe) polyline (LocationService)
- [weak self] in BGTaskScheduler register closures (BackgroundSyncManager)
- Cache tripDays, routeWaypoints as @State with recompute (TripDetailView)
- Remove unused AnyCancellable, add Task lifecycle management (TripDetailView)
- Cache sportStadiums, recentVisits as stored properties (ProgressViewModel)
- Dynamic Type fonts replacing hardcoded sizes (OnboardingPaywallView)
- Accessibility labels/hints on stadium picker, date picker, map, stats,
  settings toggle, and day cards

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-19 09:23:29 -06:00
parent dad3270be7
commit e7420061a5
12 changed files with 180 additions and 101 deletions

View File

@@ -63,10 +63,10 @@ final class BackgroundSyncManager {
BGTaskScheduler.shared.register( BGTaskScheduler.shared.register(
forTaskWithIdentifier: Self.refreshTaskIdentifier, forTaskWithIdentifier: Self.refreshTaskIdentifier,
using: nil using: nil
) { task in ) { [weak self] task in
guard let task = task as? BGAppRefreshTask else { return } guard let task = task as? BGAppRefreshTask else { return }
Task { @MainActor in Task { @MainActor in
await self.handleRefreshTask(task) await self?.handleRefreshTask(task)
} }
} }
@@ -74,10 +74,10 @@ final class BackgroundSyncManager {
BGTaskScheduler.shared.register( BGTaskScheduler.shared.register(
forTaskWithIdentifier: Self.processingTaskIdentifier, forTaskWithIdentifier: Self.processingTaskIdentifier,
using: nil using: nil
) { task in ) { [weak self] task in
guard let task = task as? BGProcessingTask else { return } guard let task = task as? BGProcessingTask else { return }
Task { @MainActor in Task { @MainActor in
await self.handleProcessingTask(task) await self?.handleProcessingTask(task)
} }
} }

View File

@@ -202,12 +202,13 @@ final class BootstrapService {
throw BootstrapError.bundledResourceNotFound("stadiums_canonical.json") throw BootstrapError.bundledResourceNotFound("stadiums_canonical.json")
} }
let data: Data
let stadiums: [JSONCanonicalStadium] let stadiums: [JSONCanonicalStadium]
do { do {
data = try Data(contentsOf: url) stadiums = try await Task.detached {
stadiums = try JSONDecoder().decode([JSONCanonicalStadium].self, from: data) let data = try Data(contentsOf: url)
return try JSONDecoder().decode([JSONCanonicalStadium].self, from: data)
}.value
} catch { } catch {
throw BootstrapError.jsonDecodingFailed("stadiums_canonical.json", error) throw BootstrapError.jsonDecodingFailed("stadiums_canonical.json", error)
} }
@@ -238,12 +239,13 @@ final class BootstrapService {
return return
} }
let data: Data
let aliases: [JSONStadiumAlias] let aliases: [JSONStadiumAlias]
do { do {
data = try Data(contentsOf: url) aliases = try await Task.detached {
aliases = try JSONDecoder().decode([JSONStadiumAlias].self, from: data) let data = try Data(contentsOf: url)
return try JSONDecoder().decode([JSONStadiumAlias].self, from: data)
}.value
} catch { } catch {
throw BootstrapError.jsonDecodingFailed("stadium_aliases.json", error) throw BootstrapError.jsonDecodingFailed("stadium_aliases.json", error)
} }
@@ -280,12 +282,13 @@ final class BootstrapService {
return return
} }
let data: Data
let structures: [JSONLeagueStructure] let structures: [JSONLeagueStructure]
do { do {
data = try Data(contentsOf: url) structures = try await Task.detached {
structures = try JSONDecoder().decode([JSONLeagueStructure].self, from: data) let data = try Data(contentsOf: url)
return try JSONDecoder().decode([JSONLeagueStructure].self, from: data)
}.value
} catch { } catch {
throw BootstrapError.jsonDecodingFailed("league_structure.json", error) throw BootstrapError.jsonDecodingFailed("league_structure.json", error)
} }
@@ -319,12 +322,13 @@ final class BootstrapService {
throw BootstrapError.bundledResourceNotFound("teams_canonical.json") throw BootstrapError.bundledResourceNotFound("teams_canonical.json")
} }
let data: Data
let teams: [JSONCanonicalTeam] let teams: [JSONCanonicalTeam]
do { do {
data = try Data(contentsOf: url) teams = try await Task.detached {
teams = try JSONDecoder().decode([JSONCanonicalTeam].self, from: data) let data = try Data(contentsOf: url)
return try JSONDecoder().decode([JSONCanonicalTeam].self, from: data)
}.value
} catch { } catch {
throw BootstrapError.jsonDecodingFailed("teams_canonical.json", error) throw BootstrapError.jsonDecodingFailed("teams_canonical.json", error)
} }
@@ -352,12 +356,13 @@ final class BootstrapService {
return return
} }
let data: Data
let aliases: [JSONTeamAlias] let aliases: [JSONTeamAlias]
do { do {
data = try Data(contentsOf: url) aliases = try await Task.detached {
aliases = try JSONDecoder().decode([JSONTeamAlias].self, from: data) let data = try Data(contentsOf: url)
return try JSONDecoder().decode([JSONTeamAlias].self, from: data)
}.value
} catch { } catch {
throw BootstrapError.jsonDecodingFailed("team_aliases.json", error) throw BootstrapError.jsonDecodingFailed("team_aliases.json", error)
} }
@@ -393,12 +398,13 @@ final class BootstrapService {
throw BootstrapError.bundledResourceNotFound("games_canonical.json") throw BootstrapError.bundledResourceNotFound("games_canonical.json")
} }
let data: Data
let games: [JSONCanonicalGame] let games: [JSONCanonicalGame]
do { do {
data = try Data(contentsOf: url) games = try await Task.detached {
games = try JSONDecoder().decode([JSONCanonicalGame].self, from: data) let data = try Data(contentsOf: url)
return try JSONDecoder().decode([JSONCanonicalGame].self, from: data)
}.value
} catch { } catch {
throw BootstrapError.jsonDecodingFailed("games_canonical.json", error) throw BootstrapError.jsonDecodingFailed("games_canonical.json", error)
} }
@@ -462,12 +468,13 @@ final class BootstrapService {
return return
} }
let data: Data
let sports: [JSONCanonicalSport] let sports: [JSONCanonicalSport]
do { do {
data = try Data(contentsOf: url) sports = try await Task.detached {
sports = try JSONDecoder().decode([JSONCanonicalSport].self, from: data) let data = try Data(contentsOf: url)
return try JSONDecoder().decode([JSONCanonicalSport].self, from: data)
}.value
} catch { } catch {
throw BootstrapError.jsonDecodingFailed("sports_canonical.json", error) throw BootstrapError.jsonDecodingFailed("sports_canonical.json", error)
} }

View File

@@ -429,11 +429,16 @@ final class CanonicalSyncService {
var skippedIncompatible = 0 var skippedIncompatible = 0
var skippedOlder = 0 var skippedOlder = 0
// Batch-fetch all existing stadiums to avoid N+1 FetchDescriptor lookups
let allExistingStadiums = try context.fetch(FetchDescriptor<CanonicalStadium>())
let existingStadiumsByCanonicalId = Dictionary(grouping: allExistingStadiums, by: \.canonicalId).compactMapValues(\.first)
for syncStadium in syncStadiums { for syncStadium in syncStadiums {
// Use canonical ID directly from CloudKit - no UUID-based generation! // Use canonical ID directly from CloudKit - no UUID-based generation!
let result = try mergeStadium( let result = try mergeStadium(
syncStadium.stadium, syncStadium.stadium,
canonicalId: syncStadium.canonicalId, canonicalId: syncStadium.canonicalId,
existingRecord: existingStadiumsByCanonicalId[syncStadium.canonicalId],
context: context context: context
) )
@@ -465,6 +470,10 @@ final class CanonicalSyncService {
var skippedIncompatible = 0 var skippedIncompatible = 0
var skippedOlder = 0 var skippedOlder = 0
// Batch-fetch all existing teams to avoid N+1 FetchDescriptor lookups
let allExistingTeams = try context.fetch(FetchDescriptor<CanonicalTeam>())
let existingTeamsByCanonicalId = Dictionary(grouping: allExistingTeams, by: \.canonicalId).compactMapValues(\.first)
for syncTeam in allSyncTeams { for syncTeam in allSyncTeams {
// Use canonical IDs directly from CloudKit - no UUID lookups! // Use canonical IDs directly from CloudKit - no UUID lookups!
let result = try mergeTeam( let result = try mergeTeam(
@@ -472,6 +481,7 @@ final class CanonicalSyncService {
canonicalId: syncTeam.canonicalId, canonicalId: syncTeam.canonicalId,
stadiumCanonicalId: syncTeam.stadiumCanonicalId, stadiumCanonicalId: syncTeam.stadiumCanonicalId,
validStadiumIds: &validStadiumIds, validStadiumIds: &validStadiumIds,
existingRecord: existingTeamsByCanonicalId[syncTeam.canonicalId],
context: context context: context
) )
@@ -514,6 +524,10 @@ final class CanonicalSyncService {
var skippedIncompatible = 0 var skippedIncompatible = 0
var skippedOlder = 0 var skippedOlder = 0
// Batch-fetch all existing games to avoid N+1 FetchDescriptor lookups
let allExistingGames = try context.fetch(FetchDescriptor<CanonicalGame>())
let existingGamesByCanonicalId = Dictionary(grouping: allExistingGames, by: \.canonicalId).compactMapValues(\.first)
for syncGame in syncGames { for syncGame in syncGames {
// Use canonical IDs directly from CloudKit - no UUID lookups! // Use canonical IDs directly from CloudKit - no UUID lookups!
let result = try mergeGame( let result = try mergeGame(
@@ -525,6 +539,7 @@ final class CanonicalSyncService {
validTeamIds: validTeamIds, validTeamIds: validTeamIds,
teamStadiumByTeamId: teamStadiumByTeamId, teamStadiumByTeamId: teamStadiumByTeamId,
validStadiumIds: &validStadiumIds, validStadiumIds: &validStadiumIds,
existingRecord: existingGamesByCanonicalId[syncGame.canonicalId],
context: context context: context
) )
@@ -649,13 +664,19 @@ final class CanonicalSyncService {
private func mergeStadium( private func mergeStadium(
_ remote: Stadium, _ remote: Stadium,
canonicalId: String, canonicalId: String,
existingRecord: CanonicalStadium? = nil,
context: ModelContext context: ModelContext
) throws -> MergeResult { ) throws -> MergeResult {
// Look up existing // Use pre-fetched record if available, otherwise fall back to individual fetch
let descriptor = FetchDescriptor<CanonicalStadium>( let existing: CanonicalStadium?
predicate: #Predicate { $0.canonicalId == canonicalId } if let existingRecord {
) existing = existingRecord
let existing = try context.fetch(descriptor).first } else {
let descriptor = FetchDescriptor<CanonicalStadium>(
predicate: #Predicate { $0.canonicalId == canonicalId }
)
existing = try context.fetch(descriptor).first
}
if let existing = existing { if let existing = existing {
// Preserve user fields // Preserve user fields
@@ -715,12 +736,19 @@ final class CanonicalSyncService {
canonicalId: String, canonicalId: String,
stadiumCanonicalId: String?, stadiumCanonicalId: String?,
validStadiumIds: inout Set<String>, validStadiumIds: inout Set<String>,
existingRecord: CanonicalTeam? = nil,
context: ModelContext context: ModelContext
) throws -> MergeResult { ) throws -> MergeResult {
let descriptor = FetchDescriptor<CanonicalTeam>( // Use pre-fetched record if available, otherwise fall back to individual fetch
predicate: #Predicate { $0.canonicalId == canonicalId } let existing: CanonicalTeam?
) if let existingRecord {
let existing = try context.fetch(descriptor).first existing = existingRecord
} else {
let descriptor = FetchDescriptor<CanonicalTeam>(
predicate: #Predicate { $0.canonicalId == canonicalId }
)
existing = try context.fetch(descriptor).first
}
let remoteStadiumId = stadiumCanonicalId?.trimmingCharacters(in: .whitespacesAndNewlines) let remoteStadiumId = stadiumCanonicalId?.trimmingCharacters(in: .whitespacesAndNewlines)
let existingStadiumId = existing?.stadiumCanonicalId.trimmingCharacters(in: .whitespacesAndNewlines) let existingStadiumId = existing?.stadiumCanonicalId.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -809,12 +837,19 @@ final class CanonicalSyncService {
validTeamIds: Set<String>, validTeamIds: Set<String>,
teamStadiumByTeamId: [String: String], teamStadiumByTeamId: [String: String],
validStadiumIds: inout Set<String>, validStadiumIds: inout Set<String>,
existingRecord: CanonicalGame? = nil,
context: ModelContext context: ModelContext
) throws -> MergeResult { ) throws -> MergeResult {
let descriptor = FetchDescriptor<CanonicalGame>( // Use pre-fetched record if available, otherwise fall back to individual fetch
predicate: #Predicate { $0.canonicalId == canonicalId } let existing: CanonicalGame?
) if let existingRecord {
let existing = try context.fetch(descriptor).first existing = existingRecord
} else {
let descriptor = FetchDescriptor<CanonicalGame>(
predicate: #Predicate { $0.canonicalId == canonicalId }
)
existing = try context.fetch(descriptor).first
}
func resolveTeamId(remote: String, existing: String?) -> String? { func resolveTeamId(remote: String, existing: String?) -> String? {
if validTeamIds.contains(remote) { if validTeamIds.contains(remote) {

View File

@@ -289,20 +289,25 @@ final class AppDataProvider: ObservableObject {
throw DataProviderError.contextNotConfigured throw DataProviderError.contextNotConfigured
} }
// Fetch all non-deprecated games (predicate with captured vars causes type-check timeout) // Use two separate predicates to avoid the type-check timeout from combining
let descriptor = FetchDescriptor<CanonicalGame>( // captured vars with OR in a single #Predicate expression.
predicate: #Predicate<CanonicalGame> { $0.deprecatedAt == nil }, let homeDescriptor = FetchDescriptor<CanonicalGame>(
predicate: #Predicate<CanonicalGame> { $0.deprecatedAt == nil && $0.homeTeamCanonicalId == teamId },
sortBy: [SortDescriptor(\.dateTime)]
)
let awayDescriptor = FetchDescriptor<CanonicalGame>(
predicate: #Predicate<CanonicalGame> { $0.deprecatedAt == nil && $0.awayTeamCanonicalId == teamId },
sortBy: [SortDescriptor(\.dateTime)] sortBy: [SortDescriptor(\.dateTime)]
) )
let allCanonical: [CanonicalGame] = try context.fetch(descriptor) let homeGames = try context.fetch(homeDescriptor)
let awayGames = try context.fetch(awayDescriptor)
// Filter by team in Swift (fast for ~5K games) // Merge and deduplicate
var seenIds = Set<String>()
var teamGames: [RichGame] = [] var teamGames: [RichGame] = []
for canonical in allCanonical { for canonical in homeGames + awayGames {
guard canonical.homeTeamCanonicalId == teamId || canonical.awayTeamCanonicalId == teamId else { guard seenIds.insert(canonical.canonicalId).inserted else { continue }
continue
}
let game = canonical.toDomain() let game = canonical.toDomain()
guard let homeTeam = teamsById[game.homeTeamId], guard let homeTeam = teamsById[game.homeTeamId],
let awayTeam = teamsById[game.awayTeamId], let awayTeam = teamsById[game.awayTeamId],
@@ -311,7 +316,7 @@ final class AppDataProvider: ObservableObject {
} }
teamGames.append(RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)) teamGames.append(RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium))
} }
return teamGames return teamGames.sorted { $0.game.dateTime < $1.game.dateTime }
} }
// Resolve stadium defensively: direct game reference first, then team home-venue fallbacks. // Resolve stadium defensively: direct game reference first, then team home-venue fallbacks.

View File

@@ -160,10 +160,10 @@ actor LocationService {
// MARK: - Route Info // MARK: - Route Info
struct RouteInfo: @unchecked Sendable { struct RouteInfo: Sendable {
let distance: CLLocationDistance // meters let distance: CLLocationDistance // meters
let expectedTravelTime: TimeInterval // seconds let expectedTravelTime: TimeInterval // seconds
let polyline: MKPolyline? nonisolated(unsafe) let polyline: MKPolyline?
var distanceMiles: Double { distance * 0.000621371 } var distanceMiles: Double { distance * 0.000621371 }
var travelTimeHours: Double { expectedTravelTime / 3600.0 } var travelTimeHours: Double { expectedTravelTime / 3600.0 }

View File

@@ -32,7 +32,8 @@ final class NetworkMonitor {
/// Debounce delay in seconds (2.5s to handle WiFicellular handoffs) /// Debounce delay in seconds (2.5s to handle WiFicellular handoffs)
private let debounceDelay: TimeInterval = 2.5 private let debounceDelay: TimeInterval = 2.5
/// Callback for when sync should be triggered /// Callback for when sync should be triggered after network restoration.
/// - Important: Capture references weakly in this closure to avoid retain cycles.
var onSyncNeeded: (() async -> Void)? var onSyncNeeded: (() async -> Void)?
// MARK: - Singleton // MARK: - Singleton

View File

@@ -351,11 +351,11 @@ struct StadiumMapBackground: View {
GeometryReader { geo in GeometryReader { geo in
HStack(spacing: 4) { HStack(spacing: 4) {
Text("6") Text("6")
.font(.system(size: 18, weight: .bold, design: .rounded)) .font(.title3.bold())
Text("/") Text("/")
.font(.system(size: 14)) .font(.subheadline)
Text("12") Text("12")
.font(.system(size: 14, weight: .medium, design: .rounded)) .font(.subheadline.weight(.medium))
} }
.foregroundStyle(color.opacity(0.25)) .foregroundStyle(color.opacity(0.25))
.padding(.horizontal, 12) .padding(.horizontal, 12)

View File

@@ -49,10 +49,8 @@ final class ProgressViewModel {
/// Pre-computed progress fractions per sport (avoids filtering visits per sport per render) /// Pre-computed progress fractions per sport (avoids filtering visits per sport per render)
private(set) var sportProgressFractions: [Sport: Double] = [:] private(set) var sportProgressFractions: [Sport: Double] = [:]
/// Stadiums for the selected sport /// Stadiums for the selected sport (cached, recomputed on data change)
var sportStadiums: [Stadium] { private(set) var sportStadiums: [Stadium] = []
stadiums.filter { $0.sport == selectedSport }
}
/// Count of trips for the selected sport (stub - can be enhanced) /// Count of trips for the selected sport (stub - can be enhanced)
var tripCount: Int { var tripCount: Int {
@@ -60,30 +58,8 @@ final class ProgressViewModel {
0 0
} }
/// Recent visits sorted by date /// Recent visits sorted by date (cached, recomputed on data change)
var recentVisits: [VisitSummary] { private(set) var recentVisits: [VisitSummary] = []
visits
.sorted { $0.visitDate > $1.visitDate }
.prefix(10)
.compactMap { visit -> VisitSummary? in
guard let stadium = dataProvider.stadium(for: visit.stadiumId),
let sport = visit.sportEnum else {
return nil
}
return VisitSummary(
id: visit.id,
stadium: stadium,
visitDate: visit.visitDate,
visitType: visit.visitType,
sport: sport,
homeTeamName: visit.homeTeamName,
awayTeamName: visit.awayTeamName,
score: visit.finalScore,
photoCount: visit.photoMetadata?.count ?? 0,
notes: visit.notes
)
}
}
// MARK: - Configuration // MARK: - Configuration
@@ -154,7 +130,8 @@ final class ProgressViewModel {
private func recomputeDerivedState() { private func recomputeDerivedState() {
// Compute league progress once // Compute league progress once
let sportStadiums = stadiums.filter { $0.sport == selectedSport } let filteredStadiums = stadiums.filter { $0.sport == selectedSport }
self.sportStadiums = filteredStadiums
let visitedStadiumIds = Set( let visitedStadiumIds = Set(
visits visits
@@ -164,12 +141,12 @@ final class ProgressViewModel {
} }
) )
let visited = sportStadiums.filter { visitedStadiumIds.contains($0.id) } let visited = filteredStadiums.filter { visitedStadiumIds.contains($0.id) }
let remaining = sportStadiums.filter { !visitedStadiumIds.contains($0.id) } let remaining = filteredStadiums.filter { !visitedStadiumIds.contains($0.id) }
leagueProgress = LeagueProgress( leagueProgress = LeagueProgress(
sport: selectedSport, sport: selectedSport,
totalStadiums: sportStadiums.count, totalStadiums: filteredStadiums.count,
visitedStadiums: visited.count, visitedStadiums: visited.count,
stadiumsVisited: visited, stadiumsVisited: visited,
stadiumsRemaining: remaining stadiumsRemaining: remaining
@@ -216,6 +193,33 @@ final class ProgressViewModel {
let visited = min(sportCounts[sport] ?? 0, total) let visited = min(sportCounts[sport] ?? 0, total)
return (sport, total > 0 ? Double(visited) / Double(total) : 0) return (sport, total > 0 ? Double(visited) / Double(total) : 0)
}) })
// Recompute recent visits cache
recomputeRecentVisits()
}
private func recomputeRecentVisits() {
recentVisits = visits
.sorted { $0.visitDate > $1.visitDate }
.prefix(10)
.compactMap { visit -> VisitSummary? in
guard let stadium = dataProvider.stadium(for: visit.stadiumId),
let sport = visit.sportEnum else {
return nil
}
return VisitSummary(
id: visit.id,
stadium: stadium,
visitDate: visit.visitDate,
visitType: visit.visitType,
sport: sport,
homeTeamName: visit.homeTeamName,
awayTeamName: visit.awayTeamName,
score: visit.finalScore,
photoCount: visit.photoMetadata?.count ?? 0,
notes: visit.notes
)
}
} }
// MARK: - Progress Card Generation // MARK: - Progress Card Generation

View File

@@ -247,6 +247,8 @@ struct ProgressTabView: View {
label: "Complete" label: "Complete"
) )
} }
.accessibilityElement(children: .ignore)
.accessibilityLabel("Statistics: \(progress.visitedStadiums) visited, \(progress.totalStadiums - progress.visitedStadiums) remaining, \(String(format: "%.0f", progress.completionPercentage)) percent complete")
} }
.padding(Theme.Spacing.lg) .padding(Theme.Spacing.lg)
.background(Theme.cardBackground(colorScheme)) .background(Theme.cardBackground(colorScheme))

View File

@@ -114,6 +114,8 @@ struct StadiumVisitSheet: View {
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
} }
} }
.accessibilityLabel(selectedStadium != nil ? "Stadium: \(selectedStadium!.name)" : "Select stadium")
.accessibilityHint("Opens stadium picker")
} header: { } header: {
Text("Location") Text("Location")
} }
@@ -122,6 +124,7 @@ struct StadiumVisitSheet: View {
// Visit Details Section // Visit Details Section
Section { Section {
DatePicker("Date", selection: $visitDate, displayedComponents: .date) DatePicker("Date", selection: $visitDate, displayedComponents: .date)
.accessibilityHint("Select the date you visited this stadium")
Picker("Visit Type", selection: $visitType) { Picker("Visit Type", selection: $visitType) {
ForEach(VisitType.allCases, id: \.self) { type in ForEach(VisitType.allCases, id: \.self) { type in

View File

@@ -229,6 +229,9 @@ struct SettingsView: View {
.accessibilityHidden(true) .accessibilityHidden(true)
} }
} }
.accessibilityLabel("Animated background")
.accessibilityValue(DesignStyleManager.shared.animationsEnabled ? "On" : "Off")
.accessibilityHint("Toggle animated sports graphics on home screen")
.accessibilityIdentifier("settings.animationsToggle") .accessibilityIdentifier("settings.animationsToggle")
} header: { } header: {
Text("Home Screen") Text("Home Screen")

View File

@@ -6,7 +6,6 @@
import SwiftUI import SwiftUI
import SwiftData import SwiftData
import MapKit import MapKit
import Combine
import UniformTypeIdentifiers import UniformTypeIdentifiers
struct TripDetailView: View { struct TripDetailView: View {
@@ -41,12 +40,14 @@ struct TripDetailView: View {
@State private var itineraryItems: [ItineraryItem] = [] @State private var itineraryItems: [ItineraryItem] = []
@State private var addItemAnchor: AddItemAnchor? @State private var addItemAnchor: AddItemAnchor?
@State private var editingItem: ItineraryItem? @State private var editingItem: ItineraryItem?
@State private var subscriptionCancellable: AnyCancellable? @State private var mapUpdateTask: Task<Void, Never>?
@State private var draggedItem: ItineraryItem? @State private var draggedItem: ItineraryItem?
@State private var draggedTravelId: String? // Track which travel segment is being dragged @State private var draggedTravelId: String? // Track which travel segment is being dragged
@State private var dropTargetId: String? // Track which drop zone is being hovered @State private var dropTargetId: String? // Track which drop zone is being hovered
@State private var travelOverrides: [String: TravelOverride] = [:] // Key: travel ID, Value: day + sortOrder @State private var travelOverrides: [String: TravelOverride] = [:] // Key: travel ID, Value: day + sortOrder
@State private var cachedSections: [ItinerarySection] = [] @State private var cachedSections: [ItinerarySection] = []
@State private var cachedTripDays: [Date] = []
@State private var cachedRouteWaypoints: [(name: String, coordinate: CLLocationCoordinate2D, isCustomItem: Bool)] = []
// Apple Maps state // Apple Maps state
@State private var showMultiRouteAlert = false @State private var showMultiRouteAlert = false
@@ -134,25 +135,32 @@ struct TripDetailView: View {
await loadGamesIfNeeded() await loadGamesIfNeeded()
if allowCustomItems { if allowCustomItems {
await loadItineraryItems() await loadItineraryItems()
await setupSubscription()
} }
recomputeTripDays()
recomputeSections() recomputeSections()
recomputeRouteWaypoints()
} }
.onDisappear { .onDisappear {
subscriptionCancellable?.cancel() mapUpdateTask?.cancel()
demoSaveTask?.cancel() demoSaveTask?.cancel()
} }
.onChange(of: itineraryItems) { _, newItems in .onChange(of: itineraryItems) { _, newItems in
handleItineraryItemsChange(newItems) handleItineraryItemsChange(newItems)
recomputeTripDays()
recomputeSections() recomputeSections()
recomputeRouteWaypoints()
} }
.onChange(of: travelOverrides.count) { _, _ in .onChange(of: travelOverrides.count) { _, _ in
draggedTravelId = nil draggedTravelId = nil
dropTargetId = nil dropTargetId = nil
recomputeTripDays()
recomputeSections() recomputeSections()
recomputeRouteWaypoints()
} }
.onChange(of: loadedGames.count) { _, _ in .onChange(of: loadedGames.count) { _, _ in
recomputeTripDays()
recomputeSections() recomputeSections()
recomputeRouteWaypoints()
} }
.overlay { .overlay {
if isExporting { exportProgressOverlay } if isExporting { exportProgressOverlay }
@@ -194,7 +202,8 @@ struct TripDetailView: View {
print("🗺️ [MapUpdate] Mappable: \(info.title) on day \(item.day), sortOrder: \(item.sortOrder)") print("🗺️ [MapUpdate] Mappable: \(info.title) on day \(item.day), sortOrder: \(item.sortOrder)")
} }
} }
Task { mapUpdateTask?.cancel()
mapUpdateTask = Task {
updateMapRegion() updateMapRegion()
await fetchDrivingRoutes() await fetchDrivingRoutes()
} }
@@ -865,9 +874,16 @@ struct TripDetailView: View {
} }
private var tripDays: [Date] { private var tripDays: [Date] {
cachedTripDays
}
private func recomputeTripDays() {
let calendar = Calendar.current let calendar = Calendar.current
guard let startDate = trip.stops.first?.arrivalDate, guard let startDate = trip.stops.first?.arrivalDate,
let endDate = trip.stops.last?.departureDate else { return [] } let endDate = trip.stops.last?.departureDate else {
cachedTripDays = []
return
}
var days: [Date] = [] var days: [Date] = []
var current = calendar.startOfDay(for: startDate) var current = calendar.startOfDay(for: startDate)
@@ -877,7 +893,7 @@ struct TripDetailView: View {
days.append(current) days.append(current)
current = calendar.date(byAdding: .day, value: 1, to: current)! current = calendar.date(byAdding: .day, value: 1, to: current)!
} }
return days cachedTripDays = days
} }
private func gamesOn(date: Date) -> [RichGame] { private func gamesOn(date: Date) -> [RichGame] {
@@ -1045,6 +1061,10 @@ struct TripDetailView: View {
/// Route waypoints including both game stops and mappable custom items in itinerary order /// Route waypoints including both game stops and mappable custom items in itinerary order
private var routeWaypoints: [(name: String, coordinate: CLLocationCoordinate2D, isCustomItem: Bool)] { private var routeWaypoints: [(name: String, coordinate: CLLocationCoordinate2D, isCustomItem: Bool)] {
cachedRouteWaypoints
}
private func recomputeRouteWaypoints() {
// Build an ordered list combining game stops and mappable custom items // Build an ordered list combining game stops and mappable custom items
// Items are ordered by (day, sortOrder) - visual order matches route order // Items are ordered by (day, sortOrder) - visual order matches route order
let itemsByDay = Dictionary(grouping: mappableCustomItems) { $0.day } let itemsByDay = Dictionary(grouping: mappableCustomItems) { $0.day }
@@ -1125,7 +1145,7 @@ struct TripDetailView: View {
} }
} }
return waypoints cachedRouteWaypoints = waypoints
} }
private func updateMapRegion() { private func updateMapRegion() {
@@ -1357,13 +1377,6 @@ struct TripDetailView: View {
// MARK: - Itinerary Items (CloudKit persistence) // MARK: - Itinerary Items (CloudKit persistence)
private func setupSubscription() async {
// TODO: Re-implement CloudKit subscription for ItineraryItem changes
// The subscription service was removed during the ItineraryItem refactor.
// For now, items are only loaded on view appear.
print("📡 [Subscription] CloudKit subscriptions not yet implemented for ItineraryItem")
}
private func loadItineraryItems() async { private func loadItineraryItems() async {
print("🔍 [ItineraryItems] Loading items for trip: \(trip.id)") print("🔍 [ItineraryItems] Loading items for trip: \(trip.id)")
do { do {
@@ -1685,6 +1698,8 @@ struct DaySection: View {
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.cardStyle() .cardStyle()
.accessibilityElement(children: .combine)
.accessibilityLabel("Day \(dayNumber), \(formattedDate), rest day")
} else { } else {
// Full game day display // Full game day display
VStack(alignment: .leading, spacing: Theme.Spacing.sm) { VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
@@ -1719,6 +1734,8 @@ struct DaySection: View {
} }
} }
.cardStyle() .cardStyle()
.accessibilityElement(children: .combine)
.accessibilityLabel("Day \(dayNumber), \(formattedDate), \(games.count) game\(games.count > 1 ? "s" : "") in \(gameCity ?? "unknown city")")
} }
} }
} }
@@ -2032,6 +2049,8 @@ struct TripMapView: View {
} }
.id(routeVersion) // Force Map to recreate when routes change .id(routeVersion) // Force Map to recreate when routes change
.mapStyle(colorScheme == .dark ? .standard(elevation: .flat, emphasis: .muted) : .standard) .mapStyle(colorScheme == .dark ? .standard(elevation: .flat, emphasis: .muted) : .standard)
.accessibilityElement(children: .contain)
.accessibilityLabel("Trip route map showing \(stopCoordinates.count) stops")
} }
} }