diff --git a/Flights/Views/LiveFlightsView.swift b/Flights/Views/LiveFlightsView.swift index 2ec06e0..b948ef9 100644 --- a/Flights/Views/LiveFlightsView.swift +++ b/Flights/Views/LiveFlightsView.swift @@ -26,52 +26,65 @@ struct LiveFlightsView: View { @State private var selectedCategories: Set = [] @State private var hideOnGround: Bool = false - // MARK: - Selection + // MARK: - Selection & sheets + // + // A single `activeSheet` is set both for tap-on-aircraft and tap-on-gear, + // so SwiftUI only ever sees one .sheet modifier on this view. Two + // stacked .sheet modifiers fight each other and produce the "tap-the- + // plane-freezes-the-app" symptom. - @State private var selectedAircraft: LiveAircraft? + @State private var activeSheet: ActiveSheet? @State private var selectedTrack: AircraftTrack? - @State private var showSettings: Bool = false + + enum ActiveSheet: Identifiable { + case aircraft(LiveAircraft) + case settings + var id: String { + switch self { + case .aircraft(let a): return "ac-\(a.icao24)" + case .settings: return "settings" + } + } + } + + /// Aircraft currently selected (if any), unwrapped from `activeSheet`. + private var selectedAircraft: LiveAircraft? { + if case .aircraft(let a) = activeSheet { return a } + return nil + } // Refresh interval — paired with the rate-limit guard. Anonymous OpenSky // is 100/day so we keep the auto-refresh tab-conservative. private static let refreshInterval: TimeInterval = 15 var body: some View { - ZStack(alignment: .top) { - mapLayer - overlayHeader - footerBar - } - .ignoresSafeArea(.container, edges: .bottom) - .task { - await initialFetch() - } - .task(id: refreshTick) { - // Auto-refresh tick — only fires after the previous task completes. - try? await Task.sleep(nanoseconds: UInt64(Self.refreshInterval * 1_000_000_000)) - await refreshIfAllowed() - } - .sheet(item: $selectedAircraft) { ac in - LiveFlightDetailSheet( - aircraft: ac, - openSky: openSky, - database: database - ) - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) - } - .sheet(isPresented: $showSettings) { - OpenSkySettingsView() - } - .task(id: selectedAircraft?.icao24) { - await loadTrackForSelection() - } + mapLayer + .safeAreaInset(edge: .top, spacing: 0) { topFilterBar } + .safeAreaInset(edge: .bottom, spacing: 0) { bottomStatusBar } + .task { await initialFetch() } + .task(id: refreshTick) { + try? await Task.sleep(nanoseconds: UInt64(Self.refreshInterval * 1_000_000_000)) + await refreshIfAllowed() + } + .task(id: selectedAircraft?.icao24) { + await loadTrackForSelection() + } + .sheet(item: $activeSheet) { sheet in + switch sheet { + case .aircraft(let ac): + LiveFlightDetailSheet(aircraft: ac, openSky: openSky, database: database) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + case .settings: + OpenSkySettingsView() + } + } } // MARK: - Map private var mapLayer: some View { - Map(position: $position, selection: $selectedAircraft.iconSelection) { + Map(position: $position) { // Trail polyline for the currently selected aircraft. if let track = selectedTrack { let coords = track.path.map { @@ -85,7 +98,7 @@ struct LiveFlightsView: View { Annotation(ac.trimmedCallsign ?? ac.icao24, coordinate: ac.coordinate) { AircraftPin(ac: ac, isSelected: selectedAircraft?.id == ac.id) .onTapGesture { - selectedAircraft = ac + activeSheet = .aircraft(ac) } } .annotationTitles(.hidden) @@ -94,29 +107,27 @@ struct LiveFlightsView: View { .mapStyle(.standard(elevation: .flat)) .onMapCameraChange(frequency: .onEnd) { context in visibleRegion = context.region - // Refetch when the user pans/zooms to a new area. Task { await refreshIfAllowed() } } } - /// Fetches the trail (in-progress path) for whichever aircraft is - /// currently selected. Cleared automatically when selection clears. + /// Fetches the trail for whichever aircraft is currently selected. + /// Cleared automatically on deselection. Race-guarded. private func loadTrackForSelection() async { guard let selected = selectedAircraft else { selectedTrack = nil return } let track = await openSky.track(icao24: selected.icao24) - // Guard against race: only commit if the user didn't change selection - // while the call was in flight. if selectedAircraft?.icao24 == selected.icao24 { selectedTrack = track } } - // MARK: - Header (filters + search) + // MARK: - Top filter bar (lives inside the safe-area inset, never under + // the nav title or the tab bar) - private var overlayHeader: some View { + private var topFilterBar: some View { VStack(spacing: 8) { HStack(spacing: 8) { Image(systemName: "magnifyingglass") @@ -212,18 +223,28 @@ struct LiveFlightsView: View { } } } - .padding(.horizontal, 4) + .padding(.horizontal, 12) } } .padding(.horizontal, 12) .padding(.top, 8) + .padding(.bottom, 8) + .background(.ultraThinMaterial) } - // MARK: - Footer (count + refresh status) + // MARK: - Bottom status bar (count, refresh, gear) + + private var bottomStatusBar: some View { + VStack(spacing: 6) { + if let error { + Text(error) + .font(.caption) + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(FlightTheme.cancelled, in: Capsule()) + } - private var footerBar: some View { - VStack { - Spacer() HStack(spacing: 12) { if isLoading { ProgressView().tint(.white) @@ -250,7 +271,7 @@ struct LiveFlightsView: View { .opacity(isLoading ? 0.3 : 1) Button { - showSettings = true + activeSheet = .settings } label: { Image(systemName: "gearshape") .foregroundStyle(.white) @@ -258,27 +279,15 @@ struct LiveFlightsView: View { } .padding(.horizontal, 16) .padding(.vertical, 12) - .background(Color.black.opacity(0.6), in: Capsule()) - .padding(.horizontal, 12) - .padding(.bottom, 28) - - if let error { - Text(error) - .font(.caption) - .foregroundStyle(.white) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(FlightTheme.cancelled, in: Capsule()) - .padding(.bottom, 12) - } + .background(Color.black.opacity(0.7), in: Capsule()) } + .padding(.horizontal, 12) + .padding(.bottom, 8) } // MARK: - Derived private var refreshTick: Int { - // Returning the count of `aircraft` makes the .task(id:) fire again - // every time the data changes, scheduling the next refresh. aircraft.count &+ (isLoading ? 1 : 0) } @@ -302,7 +311,13 @@ struct LiveFlightsView: View { } } - private struct AirlineFilterItem: Hashable { let icao: String; let label: String } + private struct AirlineFilterItem: Hashable { + let icao: String + let name: String + let count: Int + var label: String { "\(name) (\(count))" } + } + private var visibleAirlines: [AirlineFilterItem] { var seen: [String: Int] = [:] for ac in aircraft { @@ -310,14 +325,25 @@ struct LiveFlightsView: View { seen[code, default: 0] += 1 } } - return seen.keys.sorted().map { icao in - let name = AircraftRegistry.shared.displayName(icao: icao) - let count = seen[icao] ?? 0 - return AirlineFilterItem(icao: icao, label: "\(name) (\(count))") + // Sort by the displayed airline name (alphabetical), not the ICAO code. + // This is what the user expects when scrolling the filter menu. + return seen.map { (icao, count) in + AirlineFilterItem( + icao: icao, + name: AircraftRegistry.shared.displayName(icao: icao), + count: count + ) } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + private struct CategoryFilterItem: Hashable { + let code: Int + let name: String + let count: Int + var label: String { "\(name) (\(count))" } } - private struct CategoryFilterItem: Hashable { let code: Int; let label: String } private var visibleCategories: [CategoryFilterItem] { var seen: [Int: Int] = [:] for ac in aircraft { @@ -325,18 +351,18 @@ struct LiveFlightsView: View { seen[c, default: 0] += 1 } } - return seen.keys.sorted().compactMap { code in + return seen.compactMap { (code, count) -> CategoryFilterItem? in guard let name = aircraftCategoryName(code) else { return nil } - let count = seen[code] ?? 0 - return CategoryFilterItem(code: code, label: "\(name) (\(count))") + return CategoryFilterItem(code: code, name: name, count: count) } + // Sort by the displayed name, A→Z. + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } } // MARK: - Fetch private func initialFetch() async { if visibleRegion == nil { - // Default to a US-centered view if no camera yet. let initial = MKCoordinateRegion( center: CLLocationCoordinate2D(latitude: 39.5, longitude: -98.0), span: MKCoordinateSpan(latitudeDelta: 30, longitudeDelta: 50) @@ -369,7 +395,6 @@ struct LiveFlightsView: View { } catch { self.error = (error as? OpenSkyClient.ClientError)?.errorDescription ?? error.localizedDescription - // Back off harder on throttling. if case OpenSkyClient.ClientError.throttled = error { nextFetchAllowedAt = Date().addingTimeInterval(60) } @@ -396,7 +421,7 @@ struct LiveFlightsView: View { guard let match = aircraft.first(where: { ($0.trimmedCallsign?.uppercased() ?? "").contains(s) }) else { return } - selectedAircraft = match + activeSheet = .aircraft(match) position = .region(MKCoordinateRegion( center: match.coordinate, span: MKCoordinateSpan(latitudeDelta: 4, longitudeDelta: 6) @@ -490,11 +515,3 @@ private struct FilterChipLabel: View { .shadow(color: FlightTheme.cardShadow, radius: 4, y: 1) } } - -// MARK: - Optional helper - -private extension Binding where Value == LiveAircraft? { - /// Swift's Map(selection:) wants a Binding where Value is Hashable. - /// LiveAircraft is Hashable + Identifiable so this just forwards through. - var iconSelection: Binding { self } -}