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:
@@ -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, 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<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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user