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 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, A→Z.
|
||||||
|
.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 }
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user