Live tab: fix sheet-collision freeze, safe-area layout, A→Z filters

Three reported bugs:

1) Tap-a-plane freezes the app — two .sheet modifiers stacked on the
   same view fight each other in SwiftUI. Consolidated into a single
   .sheet(item:) backed by an ActiveSheet enum (aircraft / settings).
   Also dropped the Map's selection binding; relying purely on the
   pin's .onTapGesture eliminates the dual-binding race.

2) Filter bar sits behind the nav title / tab bar — replaced the
   ZStack overlay layout with safeAreaInset(edge:) so the search +
   chip bar at the top and the count/refresh/gear strip at the
   bottom are first-class inset views. Map fills the rest properly.

3) Aircraft type / airline menus not A→Z — both filter lists sorted
   by displayed name (localizedCaseInsensitiveCompare) instead of by
   ICAO code / category number. AirlineFilterItem and
   CategoryFilterItem now carry the displayed `name` separately and
   sort on it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-05-27 06:26:55 -05:00
parent 6b33a104c8
commit 0550376e3d
+99 -82
View File
@@ -26,52 +26,65 @@ struct LiveFlightsView: View {
@State private var selectedCategories: Set<Int> = [] @State private var selectedCategories: Set<Int> = []
@State private var hideOnGround: Bool = false @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 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 // Refresh interval paired with the rate-limit guard. Anonymous OpenSky
// is 100/day so we keep the auto-refresh tab-conservative. // is 100/day so we keep the auto-refresh tab-conservative.
private static let refreshInterval: TimeInterval = 15 private static let refreshInterval: TimeInterval = 15
var body: some View { var body: some View {
ZStack(alignment: .top) { mapLayer
mapLayer .safeAreaInset(edge: .top, spacing: 0) { topFilterBar }
overlayHeader .safeAreaInset(edge: .bottom, spacing: 0) { bottomStatusBar }
footerBar .task { await initialFetch() }
} .task(id: refreshTick) {
.ignoresSafeArea(.container, edges: .bottom) try? await Task.sleep(nanoseconds: UInt64(Self.refreshInterval * 1_000_000_000))
.task { await refreshIfAllowed()
await initialFetch() }
} .task(id: selectedAircraft?.icao24) {
.task(id: refreshTick) { await loadTrackForSelection()
// Auto-refresh tick only fires after the previous task completes. }
try? await Task.sleep(nanoseconds: UInt64(Self.refreshInterval * 1_000_000_000)) .sheet(item: $activeSheet) { sheet in
await refreshIfAllowed() switch sheet {
} case .aircraft(let ac):
.sheet(item: $selectedAircraft) { ac in LiveFlightDetailSheet(aircraft: ac, openSky: openSky, database: database)
LiveFlightDetailSheet( .presentationDetents([.medium, .large])
aircraft: ac, .presentationDragIndicator(.visible)
openSky: openSky, case .settings:
database: database OpenSkySettingsView()
) }
.presentationDetents([.medium, .large]) }
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $showSettings) {
OpenSkySettingsView()
}
.task(id: selectedAircraft?.icao24) {
await loadTrackForSelection()
}
} }
// MARK: - Map // MARK: - Map
private var mapLayer: some View { private var mapLayer: some View {
Map(position: $position, selection: $selectedAircraft.iconSelection) { Map(position: $position) {
// Trail polyline for the currently selected aircraft. // Trail polyline for the currently selected aircraft.
if let track = selectedTrack { if let track = selectedTrack {
let coords = track.path.map { let coords = track.path.map {
@@ -85,7 +98,7 @@ struct LiveFlightsView: View {
Annotation(ac.trimmedCallsign ?? ac.icao24, coordinate: ac.coordinate) { Annotation(ac.trimmedCallsign ?? ac.icao24, coordinate: ac.coordinate) {
AircraftPin(ac: ac, isSelected: selectedAircraft?.id == ac.id) AircraftPin(ac: ac, isSelected: selectedAircraft?.id == ac.id)
.onTapGesture { .onTapGesture {
selectedAircraft = ac activeSheet = .aircraft(ac)
} }
} }
.annotationTitles(.hidden) .annotationTitles(.hidden)
@@ -94,29 +107,27 @@ struct LiveFlightsView: View {
.mapStyle(.standard(elevation: .flat)) .mapStyle(.standard(elevation: .flat))
.onMapCameraChange(frequency: .onEnd) { context in .onMapCameraChange(frequency: .onEnd) { context in
visibleRegion = context.region visibleRegion = context.region
// Refetch when the user pans/zooms to a new area.
Task { await refreshIfAllowed() } Task { await refreshIfAllowed() }
} }
} }
/// Fetches the trail (in-progress path) for whichever aircraft is /// Fetches the trail for whichever aircraft is currently selected.
/// currently selected. Cleared automatically when selection clears. /// Cleared automatically on deselection. Race-guarded.
private func loadTrackForSelection() async { private func loadTrackForSelection() async {
guard let selected = selectedAircraft else { guard let selected = selectedAircraft else {
selectedTrack = nil selectedTrack = nil
return return
} }
let track = await openSky.track(icao24: selected.icao24) 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 { if selectedAircraft?.icao24 == selected.icao24 {
selectedTrack = track 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) { VStack(spacing: 8) {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: "magnifyingglass") Image(systemName: "magnifyingglass")
@@ -212,18 +223,28 @@ struct LiveFlightsView: View {
} }
} }
} }
.padding(.horizontal, 4) .padding(.horizontal, 12)
} }
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.top, 8) .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) { HStack(spacing: 12) {
if isLoading { if isLoading {
ProgressView().tint(.white) ProgressView().tint(.white)
@@ -250,7 +271,7 @@ struct LiveFlightsView: View {
.opacity(isLoading ? 0.3 : 1) .opacity(isLoading ? 0.3 : 1)
Button { Button {
showSettings = true activeSheet = .settings
} label: { } label: {
Image(systemName: "gearshape") Image(systemName: "gearshape")
.foregroundStyle(.white) .foregroundStyle(.white)
@@ -258,27 +279,15 @@ struct LiveFlightsView: View {
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 12) .padding(.vertical, 12)
.background(Color.black.opacity(0.6), in: Capsule()) .background(Color.black.opacity(0.7), 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)
}
} }
.padding(.horizontal, 12)
.padding(.bottom, 8)
} }
// MARK: - Derived // MARK: - Derived
private var refreshTick: Int { 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) 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] { private var visibleAirlines: [AirlineFilterItem] {
var seen: [String: Int] = [:] var seen: [String: Int] = [:]
for ac in aircraft { for ac in aircraft {
@@ -310,14 +325,25 @@ struct LiveFlightsView: View {
seen[code, default: 0] += 1 seen[code, default: 0] += 1
} }
} }
return seen.keys.sorted().map { icao in // Sort by the displayed airline name (alphabetical), not the ICAO code.
let name = AircraftRegistry.shared.displayName(icao: icao) // This is what the user expects when scrolling the filter menu.
let count = seen[icao] ?? 0 return seen.map { (icao, count) in
return AirlineFilterItem(icao: icao, label: "\(name) (\(count))") 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] { private var visibleCategories: [CategoryFilterItem] {
var seen: [Int: Int] = [:] var seen: [Int: Int] = [:]
for ac in aircraft { for ac in aircraft {
@@ -325,18 +351,18 @@ struct LiveFlightsView: View {
seen[c, default: 0] += 1 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 } guard let name = aircraftCategoryName(code) else { return nil }
let count = seen[code] ?? 0 return CategoryFilterItem(code: code, name: name, count: count)
return CategoryFilterItem(code: code, label: "\(name) (\(count))")
} }
// Sort by the displayed name, AZ.
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
} }
// MARK: - Fetch // MARK: - Fetch
private func initialFetch() async { private func initialFetch() async {
if visibleRegion == nil { if visibleRegion == nil {
// Default to a US-centered view if no camera yet.
let initial = MKCoordinateRegion( let initial = MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 39.5, longitude: -98.0), center: CLLocationCoordinate2D(latitude: 39.5, longitude: -98.0),
span: MKCoordinateSpan(latitudeDelta: 30, longitudeDelta: 50) span: MKCoordinateSpan(latitudeDelta: 30, longitudeDelta: 50)
@@ -369,7 +395,6 @@ struct LiveFlightsView: View {
} catch { } catch {
self.error = (error as? OpenSkyClient.ClientError)?.errorDescription self.error = (error as? OpenSkyClient.ClientError)?.errorDescription
?? error.localizedDescription ?? error.localizedDescription
// Back off harder on throttling.
if case OpenSkyClient.ClientError.throttled = error { if case OpenSkyClient.ClientError.throttled = error {
nextFetchAllowedAt = Date().addingTimeInterval(60) nextFetchAllowedAt = Date().addingTimeInterval(60)
} }
@@ -396,7 +421,7 @@ struct LiveFlightsView: View {
guard let match = aircraft.first(where: { guard let match = aircraft.first(where: {
($0.trimmedCallsign?.uppercased() ?? "").contains(s) ($0.trimmedCallsign?.uppercased() ?? "").contains(s)
}) else { return } }) else { return }
selectedAircraft = match activeSheet = .aircraft(match)
position = .region(MKCoordinateRegion( position = .region(MKCoordinateRegion(
center: match.coordinate, center: match.coordinate,
span: MKCoordinateSpan(latitudeDelta: 4, longitudeDelta: 6) span: MKCoordinateSpan(latitudeDelta: 4, longitudeDelta: 6)
@@ -490,11 +515,3 @@ private struct FilterChipLabel: View {
.shadow(color: FlightTheme.cardShadow, radius: 4, y: 1) .shadow(color: FlightTheme.cardShadow, radius: 4, y: 1)
} }
} }
// MARK: - Optional<Identifiable> helper
private extension Binding where Value == LiveAircraft? {
/// Swift's Map(selection:) wants a Binding<Value?> where Value is Hashable.
/// LiveAircraft is Hashable + Identifiable so this just forwards through.
var iconSelection: Binding<LiveAircraft?> { self }
}