fix: 14 audit fixes — concurrency, memory, performance, accessibility
Second audit round addressing data races, task stacking, unbounded caches, and VoiceOver gaps across 7 files. Concurrency: - Move NSItemProvider @State access into MainActor block (3 drop handlers) - Cancel stale ScheduleViewModel tasks on rapid filter changes Memory: - Replace unbounded image dict with LRUCache(countLimit: 50) - Replace demo-mode asyncAfter with cancellable Task Performance: - Wrap debug NBA print() in #if DEBUG - Cache visitsById via @State + onChange instead of per-render computed - Pre-compute sportProgressFractions in ProgressViewModel - Replace allGames computed property with hasGames bool check - Cache sortedTrips via @State + onChange in SavedTripsListView Accessibility: - Add combined VoiceOver label to progress ring - Combine away/home team text into single readable phrase - Hide decorative StadiumDetailSheet icon from VoiceOver - Add explicit accessibilityLabel to SportFilterChip - Add combined accessibilityLabel to GameRowView Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,7 @@ struct TripDetailView: View {
|
||||
@State private var loadedGames: [String: RichGame] = [:]
|
||||
@State private var isLoadingGames = false
|
||||
@State private var hasAppliedDemoSelection = false
|
||||
@State private var demoSaveTask: Task<Void, Never>?
|
||||
|
||||
// Itinerary items state
|
||||
@State private var itineraryItems: [ItineraryItem] = []
|
||||
@@ -122,10 +123,10 @@ struct TripDetailView: View {
|
||||
// Demo mode: auto-favorite the trip
|
||||
if isDemoMode && !hasAppliedDemoSelection && !isSaved {
|
||||
hasAppliedDemoSelection = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay + 0.5) {
|
||||
if !isSaved {
|
||||
saveTrip()
|
||||
}
|
||||
demoSaveTask = Task {
|
||||
try? await Task.sleep(for: .seconds(DemoConfig.selectionDelay + 0.5))
|
||||
guard !Task.isCancelled, !isSaved else { return }
|
||||
saveTrip()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,7 +138,10 @@ struct TripDetailView: View {
|
||||
}
|
||||
recomputeSections()
|
||||
}
|
||||
.onDisappear { subscriptionCancellable?.cancel() }
|
||||
.onDisappear {
|
||||
subscriptionCancellable?.cancel()
|
||||
demoSaveTask?.cancel()
|
||||
}
|
||||
.onChange(of: itineraryItems) { _, newItems in
|
||||
handleItineraryItemsChange(newItems)
|
||||
recomputeSections()
|
||||
@@ -715,10 +719,10 @@ struct TripDetailView: View {
|
||||
|
||||
provider.loadObject(ofClass: NSString.self) { item, _ in
|
||||
guard let droppedId = item as? String,
|
||||
let itemId = UUID(uuidString: droppedId),
|
||||
let droppedItem = self.itineraryItems.first(where: { $0.id == itemId }) else { return }
|
||||
let itemId = UUID(uuidString: droppedId) else { return }
|
||||
|
||||
Task { @MainActor in
|
||||
guard let droppedItem = self.itineraryItems.first(where: { $0.id == itemId }) else { return }
|
||||
let day = self.findDayForTravelSegment(segment)
|
||||
// Place at beginning of day (sortOrder before existing items)
|
||||
let minSortOrder = self.itineraryItems
|
||||
@@ -743,11 +747,11 @@ struct TripDetailView: View {
|
||||
|
||||
provider.loadObject(ofClass: NSString.self) { item, _ in
|
||||
guard let droppedId = item as? String,
|
||||
let itemId = UUID(uuidString: droppedId),
|
||||
let droppedItem = self.itineraryItems.first(where: { $0.id == itemId }),
|
||||
droppedItem.id != targetItem.id else { return }
|
||||
let itemId = UUID(uuidString: droppedId) else { return }
|
||||
|
||||
Task { @MainActor in
|
||||
guard let droppedItem = self.itineraryItems.first(where: { $0.id == itemId }),
|
||||
droppedItem.id != targetItem.id else { return }
|
||||
// Place before target item using midpoint insertion
|
||||
let itemsInDay = self.itineraryItems.filter { $0.day == targetItem.day && $0.id != droppedItem.id }
|
||||
.sorted { $0.sortOrder < $1.sortOrder }
|
||||
@@ -772,10 +776,10 @@ struct TripDetailView: View {
|
||||
|
||||
provider.loadObject(ofClass: NSString.self) { item, _ in
|
||||
guard let droppedId = item as? String,
|
||||
let itemId = UUID(uuidString: droppedId),
|
||||
let droppedItem = self.itineraryItems.first(where: { $0.id == itemId }) else { return }
|
||||
let itemId = UUID(uuidString: droppedId) else { return }
|
||||
|
||||
Task { @MainActor in
|
||||
guard let droppedItem = self.itineraryItems.first(where: { $0.id == itemId }) else { return }
|
||||
// Calculate sortOrder: append at end of day's items
|
||||
let maxSortOrder = self.itineraryItems
|
||||
.filter { $0.day == day && $0.id != droppedItem.id }
|
||||
|
||||
Reference in New Issue
Block a user