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 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, AZ.
.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<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 }
}