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:
@@ -63,10 +63,10 @@ final class BackgroundSyncManager {
|
||||
BGTaskScheduler.shared.register(
|
||||
forTaskWithIdentifier: Self.refreshTaskIdentifier,
|
||||
using: nil
|
||||
) { task in
|
||||
) { [weak self] task in
|
||||
guard let task = task as? BGAppRefreshTask else { return }
|
||||
Task { @MainActor in
|
||||
await self.handleRefreshTask(task)
|
||||
await self?.handleRefreshTask(task)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,10 +74,10 @@ final class BackgroundSyncManager {
|
||||
BGTaskScheduler.shared.register(
|
||||
forTaskWithIdentifier: Self.processingTaskIdentifier,
|
||||
using: nil
|
||||
) { task in
|
||||
) { [weak self] task in
|
||||
guard let task = task as? BGProcessingTask else { return }
|
||||
Task { @MainActor in
|
||||
await self.handleProcessingTask(task)
|
||||
await self?.handleProcessingTask(task)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -202,12 +202,13 @@ final class BootstrapService {
|
||||
throw BootstrapError.bundledResourceNotFound("stadiums_canonical.json")
|
||||
}
|
||||
|
||||
let data: Data
|
||||
let stadiums: [JSONCanonicalStadium]
|
||||
|
||||
do {
|
||||
data = try Data(contentsOf: url)
|
||||
stadiums = try JSONDecoder().decode([JSONCanonicalStadium].self, from: data)
|
||||
stadiums = try await Task.detached {
|
||||
let data = try Data(contentsOf: url)
|
||||
return try JSONDecoder().decode([JSONCanonicalStadium].self, from: data)
|
||||
}.value
|
||||
} catch {
|
||||
throw BootstrapError.jsonDecodingFailed("stadiums_canonical.json", error)
|
||||
}
|
||||
@@ -238,12 +239,13 @@ final class BootstrapService {
|
||||
return
|
||||
}
|
||||
|
||||
let data: Data
|
||||
let aliases: [JSONStadiumAlias]
|
||||
|
||||
do {
|
||||
data = try Data(contentsOf: url)
|
||||
aliases = try JSONDecoder().decode([JSONStadiumAlias].self, from: data)
|
||||
aliases = try await Task.detached {
|
||||
let data = try Data(contentsOf: url)
|
||||
return try JSONDecoder().decode([JSONStadiumAlias].self, from: data)
|
||||
}.value
|
||||
} catch {
|
||||
throw BootstrapError.jsonDecodingFailed("stadium_aliases.json", error)
|
||||
}
|
||||
@@ -280,12 +282,13 @@ final class BootstrapService {
|
||||
return
|
||||
}
|
||||
|
||||
let data: Data
|
||||
let structures: [JSONLeagueStructure]
|
||||
|
||||
do {
|
||||
data = try Data(contentsOf: url)
|
||||
structures = try JSONDecoder().decode([JSONLeagueStructure].self, from: data)
|
||||
structures = try await Task.detached {
|
||||
let data = try Data(contentsOf: url)
|
||||
return try JSONDecoder().decode([JSONLeagueStructure].self, from: data)
|
||||
}.value
|
||||
} catch {
|
||||
throw BootstrapError.jsonDecodingFailed("league_structure.json", error)
|
||||
}
|
||||
@@ -319,12 +322,13 @@ final class BootstrapService {
|
||||
throw BootstrapError.bundledResourceNotFound("teams_canonical.json")
|
||||
}
|
||||
|
||||
let data: Data
|
||||
let teams: [JSONCanonicalTeam]
|
||||
|
||||
do {
|
||||
data = try Data(contentsOf: url)
|
||||
teams = try JSONDecoder().decode([JSONCanonicalTeam].self, from: data)
|
||||
teams = try await Task.detached {
|
||||
let data = try Data(contentsOf: url)
|
||||
return try JSONDecoder().decode([JSONCanonicalTeam].self, from: data)
|
||||
}.value
|
||||
} catch {
|
||||
throw BootstrapError.jsonDecodingFailed("teams_canonical.json", error)
|
||||
}
|
||||
@@ -352,12 +356,13 @@ final class BootstrapService {
|
||||
return
|
||||
}
|
||||
|
||||
let data: Data
|
||||
let aliases: [JSONTeamAlias]
|
||||
|
||||
do {
|
||||
data = try Data(contentsOf: url)
|
||||
aliases = try JSONDecoder().decode([JSONTeamAlias].self, from: data)
|
||||
aliases = try await Task.detached {
|
||||
let data = try Data(contentsOf: url)
|
||||
return try JSONDecoder().decode([JSONTeamAlias].self, from: data)
|
||||
}.value
|
||||
} catch {
|
||||
throw BootstrapError.jsonDecodingFailed("team_aliases.json", error)
|
||||
}
|
||||
@@ -393,12 +398,13 @@ final class BootstrapService {
|
||||
throw BootstrapError.bundledResourceNotFound("games_canonical.json")
|
||||
}
|
||||
|
||||
let data: Data
|
||||
let games: [JSONCanonicalGame]
|
||||
|
||||
do {
|
||||
data = try Data(contentsOf: url)
|
||||
games = try JSONDecoder().decode([JSONCanonicalGame].self, from: data)
|
||||
games = try await Task.detached {
|
||||
let data = try Data(contentsOf: url)
|
||||
return try JSONDecoder().decode([JSONCanonicalGame].self, from: data)
|
||||
}.value
|
||||
} catch {
|
||||
throw BootstrapError.jsonDecodingFailed("games_canonical.json", error)
|
||||
}
|
||||
@@ -462,12 +468,13 @@ final class BootstrapService {
|
||||
return
|
||||
}
|
||||
|
||||
let data: Data
|
||||
let sports: [JSONCanonicalSport]
|
||||
|
||||
do {
|
||||
data = try Data(contentsOf: url)
|
||||
sports = try JSONDecoder().decode([JSONCanonicalSport].self, from: data)
|
||||
sports = try await Task.detached {
|
||||
let data = try Data(contentsOf: url)
|
||||
return try JSONDecoder().decode([JSONCanonicalSport].self, from: data)
|
||||
}.value
|
||||
} catch {
|
||||
throw BootstrapError.jsonDecodingFailed("sports_canonical.json", error)
|
||||
}
|
||||
|
||||
@@ -429,11 +429,16 @@ final class CanonicalSyncService {
|
||||
var skippedIncompatible = 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 {
|
||||
// Use canonical ID directly from CloudKit - no UUID-based generation!
|
||||
let result = try mergeStadium(
|
||||
syncStadium.stadium,
|
||||
canonicalId: syncStadium.canonicalId,
|
||||
existingRecord: existingStadiumsByCanonicalId[syncStadium.canonicalId],
|
||||
context: context
|
||||
)
|
||||
|
||||
@@ -465,6 +470,10 @@ final class CanonicalSyncService {
|
||||
var skippedIncompatible = 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 {
|
||||
// Use canonical IDs directly from CloudKit - no UUID lookups!
|
||||
let result = try mergeTeam(
|
||||
@@ -472,6 +481,7 @@ final class CanonicalSyncService {
|
||||
canonicalId: syncTeam.canonicalId,
|
||||
stadiumCanonicalId: syncTeam.stadiumCanonicalId,
|
||||
validStadiumIds: &validStadiumIds,
|
||||
existingRecord: existingTeamsByCanonicalId[syncTeam.canonicalId],
|
||||
context: context
|
||||
)
|
||||
|
||||
@@ -514,6 +524,10 @@ final class CanonicalSyncService {
|
||||
var skippedIncompatible = 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 {
|
||||
// Use canonical IDs directly from CloudKit - no UUID lookups!
|
||||
let result = try mergeGame(
|
||||
@@ -525,6 +539,7 @@ final class CanonicalSyncService {
|
||||
validTeamIds: validTeamIds,
|
||||
teamStadiumByTeamId: teamStadiumByTeamId,
|
||||
validStadiumIds: &validStadiumIds,
|
||||
existingRecord: existingGamesByCanonicalId[syncGame.canonicalId],
|
||||
context: context
|
||||
)
|
||||
|
||||
@@ -649,13 +664,19 @@ final class CanonicalSyncService {
|
||||
private func mergeStadium(
|
||||
_ remote: Stadium,
|
||||
canonicalId: String,
|
||||
existingRecord: CanonicalStadium? = nil,
|
||||
context: ModelContext
|
||||
) throws -> MergeResult {
|
||||
// Look up existing
|
||||
let descriptor = FetchDescriptor<CanonicalStadium>(
|
||||
predicate: #Predicate { $0.canonicalId == canonicalId }
|
||||
)
|
||||
let existing = try context.fetch(descriptor).first
|
||||
// Use pre-fetched record if available, otherwise fall back to individual fetch
|
||||
let existing: CanonicalStadium?
|
||||
if let existingRecord {
|
||||
existing = existingRecord
|
||||
} else {
|
||||
let descriptor = FetchDescriptor<CanonicalStadium>(
|
||||
predicate: #Predicate { $0.canonicalId == canonicalId }
|
||||
)
|
||||
existing = try context.fetch(descriptor).first
|
||||
}
|
||||
|
||||
if let existing = existing {
|
||||
// Preserve user fields
|
||||
@@ -715,12 +736,19 @@ final class CanonicalSyncService {
|
||||
canonicalId: String,
|
||||
stadiumCanonicalId: String?,
|
||||
validStadiumIds: inout Set<String>,
|
||||
existingRecord: CanonicalTeam? = nil,
|
||||
context: ModelContext
|
||||
) throws -> MergeResult {
|
||||
let descriptor = FetchDescriptor<CanonicalTeam>(
|
||||
predicate: #Predicate { $0.canonicalId == canonicalId }
|
||||
)
|
||||
let existing = try context.fetch(descriptor).first
|
||||
// Use pre-fetched record if available, otherwise fall back to individual fetch
|
||||
let existing: CanonicalTeam?
|
||||
if let existingRecord {
|
||||
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 existingStadiumId = existing?.stadiumCanonicalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -809,12 +837,19 @@ final class CanonicalSyncService {
|
||||
validTeamIds: Set<String>,
|
||||
teamStadiumByTeamId: [String: String],
|
||||
validStadiumIds: inout Set<String>,
|
||||
existingRecord: CanonicalGame? = nil,
|
||||
context: ModelContext
|
||||
) throws -> MergeResult {
|
||||
let descriptor = FetchDescriptor<CanonicalGame>(
|
||||
predicate: #Predicate { $0.canonicalId == canonicalId }
|
||||
)
|
||||
let existing = try context.fetch(descriptor).first
|
||||
// Use pre-fetched record if available, otherwise fall back to individual fetch
|
||||
let existing: CanonicalGame?
|
||||
if let existingRecord {
|
||||
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? {
|
||||
if validTeamIds.contains(remote) {
|
||||
|
||||
@@ -289,20 +289,25 @@ final class AppDataProvider: ObservableObject {
|
||||
throw DataProviderError.contextNotConfigured
|
||||
}
|
||||
|
||||
// Fetch all non-deprecated games (predicate with captured vars causes type-check timeout)
|
||||
let descriptor = FetchDescriptor<CanonicalGame>(
|
||||
predicate: #Predicate<CanonicalGame> { $0.deprecatedAt == nil },
|
||||
// Use two separate predicates to avoid the type-check timeout from combining
|
||||
// captured vars with OR in a single #Predicate expression.
|
||||
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)]
|
||||
)
|
||||
|
||||
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] = []
|
||||
for canonical in allCanonical {
|
||||
guard canonical.homeTeamCanonicalId == teamId || canonical.awayTeamCanonicalId == teamId else {
|
||||
continue
|
||||
}
|
||||
for canonical in homeGames + awayGames {
|
||||
guard seenIds.insert(canonical.canonicalId).inserted else { continue }
|
||||
let game = canonical.toDomain()
|
||||
guard let homeTeam = teamsById[game.homeTeamId],
|
||||
let awayTeam = teamsById[game.awayTeamId],
|
||||
@@ -311,7 +316,7 @@ final class AppDataProvider: ObservableObject {
|
||||
}
|
||||
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.
|
||||
|
||||
@@ -160,10 +160,10 @@ actor LocationService {
|
||||
|
||||
// MARK: - Route Info
|
||||
|
||||
struct RouteInfo: @unchecked Sendable {
|
||||
struct RouteInfo: Sendable {
|
||||
let distance: CLLocationDistance // meters
|
||||
let expectedTravelTime: TimeInterval // seconds
|
||||
let polyline: MKPolyline?
|
||||
nonisolated(unsafe) let polyline: MKPolyline?
|
||||
|
||||
var distanceMiles: Double { distance * 0.000621371 }
|
||||
var travelTimeHours: Double { expectedTravelTime / 3600.0 }
|
||||
|
||||
@@ -32,7 +32,8 @@ final class NetworkMonitor {
|
||||
/// Debounce delay in seconds (2.5s to handle WiFi↔cellular handoffs)
|
||||
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)?
|
||||
|
||||
// MARK: - Singleton
|
||||
|
||||
@@ -351,11 +351,11 @@ struct StadiumMapBackground: View {
|
||||
GeometryReader { geo in
|
||||
HStack(spacing: 4) {
|
||||
Text("6")
|
||||
.font(.system(size: 18, weight: .bold, design: .rounded))
|
||||
.font(.title3.bold())
|
||||
Text("/")
|
||||
.font(.system(size: 14))
|
||||
.font(.subheadline)
|
||||
Text("12")
|
||||
.font(.system(size: 14, weight: .medium, design: .rounded))
|
||||
.font(.subheadline.weight(.medium))
|
||||
}
|
||||
.foregroundStyle(color.opacity(0.25))
|
||||
.padding(.horizontal, 12)
|
||||
|
||||
@@ -49,10 +49,8 @@ final class ProgressViewModel {
|
||||
/// Pre-computed progress fractions per sport (avoids filtering visits per sport per render)
|
||||
private(set) var sportProgressFractions: [Sport: Double] = [:]
|
||||
|
||||
/// Stadiums for the selected sport
|
||||
var sportStadiums: [Stadium] {
|
||||
stadiums.filter { $0.sport == selectedSport }
|
||||
}
|
||||
/// Stadiums for the selected sport (cached, recomputed on data change)
|
||||
private(set) var sportStadiums: [Stadium] = []
|
||||
|
||||
/// Count of trips for the selected sport (stub - can be enhanced)
|
||||
var tripCount: Int {
|
||||
@@ -60,30 +58,8 @@ final class ProgressViewModel {
|
||||
0
|
||||
}
|
||||
|
||||
/// Recent visits sorted by date
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
/// Recent visits sorted by date (cached, recomputed on data change)
|
||||
private(set) var recentVisits: [VisitSummary] = []
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
@@ -154,7 +130,8 @@ final class ProgressViewModel {
|
||||
|
||||
private func recomputeDerivedState() {
|
||||
// Compute league progress once
|
||||
let sportStadiums = stadiums.filter { $0.sport == selectedSport }
|
||||
let filteredStadiums = stadiums.filter { $0.sport == selectedSport }
|
||||
self.sportStadiums = filteredStadiums
|
||||
|
||||
let visitedStadiumIds = Set(
|
||||
visits
|
||||
@@ -164,12 +141,12 @@ final class ProgressViewModel {
|
||||
}
|
||||
)
|
||||
|
||||
let visited = sportStadiums.filter { visitedStadiumIds.contains($0.id) }
|
||||
let remaining = sportStadiums.filter { !visitedStadiumIds.contains($0.id) }
|
||||
let visited = filteredStadiums.filter { visitedStadiumIds.contains($0.id) }
|
||||
let remaining = filteredStadiums.filter { !visitedStadiumIds.contains($0.id) }
|
||||
|
||||
leagueProgress = LeagueProgress(
|
||||
sport: selectedSport,
|
||||
totalStadiums: sportStadiums.count,
|
||||
totalStadiums: filteredStadiums.count,
|
||||
visitedStadiums: visited.count,
|
||||
stadiumsVisited: visited,
|
||||
stadiumsRemaining: remaining
|
||||
@@ -216,6 +193,33 @@ final class ProgressViewModel {
|
||||
let visited = min(sportCounts[sport] ?? 0, total)
|
||||
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
|
||||
|
||||
@@ -247,6 +247,8 @@ struct ProgressTabView: View {
|
||||
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)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
|
||||
@@ -114,6 +114,8 @@ struct StadiumVisitSheet: View {
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
}
|
||||
.accessibilityLabel(selectedStadium != nil ? "Stadium: \(selectedStadium!.name)" : "Select stadium")
|
||||
.accessibilityHint("Opens stadium picker")
|
||||
} header: {
|
||||
Text("Location")
|
||||
}
|
||||
@@ -122,6 +124,7 @@ struct StadiumVisitSheet: View {
|
||||
// Visit Details Section
|
||||
Section {
|
||||
DatePicker("Date", selection: $visitDate, displayedComponents: .date)
|
||||
.accessibilityHint("Select the date you visited this stadium")
|
||||
|
||||
Picker("Visit Type", selection: $visitType) {
|
||||
ForEach(VisitType.allCases, id: \.self) { type in
|
||||
|
||||
@@ -229,6 +229,9 @@ struct SettingsView: View {
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("Animated background")
|
||||
.accessibilityValue(DesignStyleManager.shared.animationsEnabled ? "On" : "Off")
|
||||
.accessibilityHint("Toggle animated sports graphics on home screen")
|
||||
.accessibilityIdentifier("settings.animationsToggle")
|
||||
} header: {
|
||||
Text("Home Screen")
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import MapKit
|
||||
import Combine
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct TripDetailView: View {
|
||||
@@ -41,12 +40,14 @@ struct TripDetailView: View {
|
||||
@State private var itineraryItems: [ItineraryItem] = []
|
||||
@State private var addItemAnchor: AddItemAnchor?
|
||||
@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 draggedTravelId: String? // Track which travel segment is being dragged
|
||||
@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 cachedSections: [ItinerarySection] = []
|
||||
@State private var cachedTripDays: [Date] = []
|
||||
@State private var cachedRouteWaypoints: [(name: String, coordinate: CLLocationCoordinate2D, isCustomItem: Bool)] = []
|
||||
|
||||
// Apple Maps state
|
||||
@State private var showMultiRouteAlert = false
|
||||
@@ -134,25 +135,32 @@ struct TripDetailView: View {
|
||||
await loadGamesIfNeeded()
|
||||
if allowCustomItems {
|
||||
await loadItineraryItems()
|
||||
await setupSubscription()
|
||||
}
|
||||
recomputeTripDays()
|
||||
recomputeSections()
|
||||
recomputeRouteWaypoints()
|
||||
}
|
||||
.onDisappear {
|
||||
subscriptionCancellable?.cancel()
|
||||
mapUpdateTask?.cancel()
|
||||
demoSaveTask?.cancel()
|
||||
}
|
||||
.onChange(of: itineraryItems) { _, newItems in
|
||||
handleItineraryItemsChange(newItems)
|
||||
recomputeTripDays()
|
||||
recomputeSections()
|
||||
recomputeRouteWaypoints()
|
||||
}
|
||||
.onChange(of: travelOverrides.count) { _, _ in
|
||||
draggedTravelId = nil
|
||||
dropTargetId = nil
|
||||
recomputeTripDays()
|
||||
recomputeSections()
|
||||
recomputeRouteWaypoints()
|
||||
}
|
||||
.onChange(of: loadedGames.count) { _, _ in
|
||||
recomputeTripDays()
|
||||
recomputeSections()
|
||||
recomputeRouteWaypoints()
|
||||
}
|
||||
.overlay {
|
||||
if isExporting { exportProgressOverlay }
|
||||
@@ -194,7 +202,8 @@ struct TripDetailView: View {
|
||||
print("🗺️ [MapUpdate] Mappable: \(info.title) on day \(item.day), sortOrder: \(item.sortOrder)")
|
||||
}
|
||||
}
|
||||
Task {
|
||||
mapUpdateTask?.cancel()
|
||||
mapUpdateTask = Task {
|
||||
updateMapRegion()
|
||||
await fetchDrivingRoutes()
|
||||
}
|
||||
@@ -865,9 +874,16 @@ struct TripDetailView: View {
|
||||
}
|
||||
|
||||
private var tripDays: [Date] {
|
||||
cachedTripDays
|
||||
}
|
||||
|
||||
private func recomputeTripDays() {
|
||||
let calendar = Calendar.current
|
||||
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 current = calendar.startOfDay(for: startDate)
|
||||
@@ -877,7 +893,7 @@ struct TripDetailView: View {
|
||||
days.append(current)
|
||||
current = calendar.date(byAdding: .day, value: 1, to: current)!
|
||||
}
|
||||
return days
|
||||
cachedTripDays = days
|
||||
}
|
||||
|
||||
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
|
||||
private var routeWaypoints: [(name: String, coordinate: CLLocationCoordinate2D, isCustomItem: Bool)] {
|
||||
cachedRouteWaypoints
|
||||
}
|
||||
|
||||
private func recomputeRouteWaypoints() {
|
||||
// Build an ordered list combining game stops and mappable custom items
|
||||
// Items are ordered by (day, sortOrder) - visual order matches route order
|
||||
let itemsByDay = Dictionary(grouping: mappableCustomItems) { $0.day }
|
||||
@@ -1125,7 +1145,7 @@ struct TripDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
return waypoints
|
||||
cachedRouteWaypoints = waypoints
|
||||
}
|
||||
|
||||
private func updateMapRegion() {
|
||||
@@ -1357,13 +1377,6 @@ struct TripDetailView: View {
|
||||
|
||||
// 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 {
|
||||
print("🔍 [ItineraryItems] Loading items for trip: \(trip.id)")
|
||||
do {
|
||||
@@ -1685,6 +1698,8 @@ struct DaySection: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.cardStyle()
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("Day \(dayNumber), \(formattedDate), rest day")
|
||||
} else {
|
||||
// Full game day display
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
@@ -1719,6 +1734,8 @@ struct DaySection: View {
|
||||
}
|
||||
}
|
||||
.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
|
||||
.mapStyle(colorScheme == .dark ? .standard(elevation: .flat, emphasis: .muted) : .standard)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel("Trip route map showing \(stopCoordinates.count) stops")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user