Files
Flights/Flights/Views/LiveFlightsView.swift
T
Trey T de7a70b198 Live tab: hardening pass for smooth, snappy feel
Targeted every hitch in the live-tracker flow:

1) Async DB loading. AircraftDatabase (1.5MB JSON) and
   AircraftRegistry (200KB JSON) were parsing synchronously on the
   main thread the first time anything touched them — typically when
   the user first opened the Live tab. Both now bootstrap with a
   safe fallback in init() and parse the full JSON on a background
   Task at app launch (FlightsApp.init calls preload() on both).
   Reads are NSLock-guarded with lock.withLock {} (async-safe).

2) Crossfade suppression on refresh. The 15s auto-refresh swaps the
   `aircraft` array wholesale, which made SwiftUI try to crossfade
   every annotation. Wrapped the assignment in a Transaction with
   disablesAnimations = true so the swap is instant.

3) Cached filtered aircraft. `filteredAircraft` was a computed
   property running through every aircraft on every body re-render
   (e.g. while a sheet was animating in). Moved to @State,
   recomputed via .onChange handlers on each dependency.

4) Lighter pin view. AircraftPin no longer carries the full
   LiveAircraft struct or contains a conditional ZStack — just the
   minimal {tint, rotation, isSelected} props, conforms to
   Equatable so SwiftUI can skip diffing identical pins, and uses
   .animation(nil) on the tint to prevent color crossfades during
   refresh.

5) Coarse relative-time bucketing. The footer's "updated 5s ago"
   text was ticking every second, which dirtied the footer subtree
   on every body pass. Now snaps to {just now, <30s ago, <1m ago,
   Nm ago} — no second-by-second ticks.

Net effect: tab opening is instant (DBs are warm), refreshes don't
flicker, filter sheet animation is smooth, map panning isn't fighting
view-tree rebuilds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 07:27:26 -05:00

640 lines
25 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import MapKit
import CoreLocation
struct LiveFlightsView: View {
let openSky: OpenSkyClient
let routeExplorer: RouteExplorerClient
let database: AirportDatabase
// MARK: - Map state
@State private var position: MapCameraPosition = .automatic
@State private var visibleRegion: MKCoordinateRegion?
// MARK: - Data state
@State private var aircraft: [LiveAircraft] = []
@State private var lastFetchAt: Date?
@State private var isLoading = false
@State private var error: String?
// MARK: - Filters
@State private var searchText: String = ""
@State private var selectedAirlineICAO: Set<String> = []
@State private var selectedTypeCodes: Set<String> = []
@State private var selectedAltitudeBand: AltitudeBand? = nil
@State private var hideOnGround: Bool = false
/// Altitude bands the user can filter by. Derived from data we always
/// have from OpenSky (baroAltitude / geoAltitude) unlike `category`,
/// which the anonymous tier returns as null for most aircraft.
enum AltitudeBand: String, CaseIterable, Identifiable, Hashable {
case lowLevel = "Below 10k ft"
case midLevel = "10k 25k ft"
case cruiseLevel = "25k 40k ft"
case highLevel = "Above 40k ft"
var id: String { rawValue }
func contains(_ ft: Int) -> Bool {
switch self {
case .lowLevel: return ft < 10_000
case .midLevel: return ft >= 10_000 && ft < 25_000
case .cruiseLevel: return ft >= 25_000 && ft < 40_000
case .highLevel: return ft >= 40_000
}
}
}
// 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 activeSheet: ActiveSheet?
@State private var selectedTrack: AircraftTrack?
// Cached filter-menu items recomputed only when `aircraft` changes.
// Computing them on every body re-render caused the menu-tap freeze
// (each refresh fed the airlines registry an N×M lookup on the main
// thread).
@State private var cachedAirlineItems: [AirlineFilterItem] = []
@State private var cachedTypeItems: [TypeFilterItem] = []
/// Pre-filtered aircraft snapshot. Recomputed only when `aircraft` or
/// any filter state changes. Caching here avoids running the filter
/// loop on every body re-render (e.g. when a sheet animates in).
@State private var cachedFilteredAircraft: [LiveAircraft] = []
/// Tracks the last bounding box we fetched against. Used to throttle
/// the on-pan refresh so that micro-camera-settlements (which happen
/// every time annotations re-render) don't fire fresh OpenSky calls.
@State private var lastFetchedBoundingBox: (latMin: Double, lonMin: Double, latMax: Double, lonMax: Double)?
enum ActiveSheet: Identifiable {
case aircraft(LiveAircraft)
case settings
case airlinePicker
case typePicker
var id: String {
switch self {
case .aircraft(let a): return "ac-\(a.icao24)"
case .settings: return "settings"
case .airlinePicker: return "airline"
case .typePicker: return "type"
}
}
}
/// 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 {
mapLayer
.safeAreaInset(edge: .top, spacing: 0) { topFilterBar }
.safeAreaInset(edge: .bottom, spacing: 0) { bottomStatusBar }
.task { await autoRefreshLoop() }
.task(id: selectedAircraft?.icao24) {
await loadTrackForSelection()
}
.onChange(of: aircraft) { _, _ in
rebuildFilterItems()
rebuildFilteredAircraft()
}
.onChange(of: selectedAirlineICAO) { _, _ in rebuildFilteredAircraft() }
.onChange(of: selectedTypeCodes) { _, _ in rebuildFilteredAircraft() }
.onChange(of: selectedAltitudeBand) { _, _ in rebuildFilteredAircraft() }
.onChange(of: hideOnGround) { _, _ in rebuildFilteredAircraft() }
.onChange(of: searchText) { _, _ in rebuildFilteredAircraft() }
.sheet(item: $activeSheet) { sheet in
switch sheet {
case .aircraft(let ac):
LiveFlightDetailSheet(
aircraft: ac,
openSky: openSky,
routeExplorer: routeExplorer,
database: database
)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
case .settings:
OpenSkySettingsView()
case .airlinePicker:
LiveFilterPicker(
title: "Airline",
items: cachedAirlineItems.map { .init(id: $0.icao, label: $0.name, count: $0.count) },
selection: $selectedAirlineICAO
)
case .typePicker:
LiveFilterPicker(
title: "Aircraft Type",
items: cachedTypeItems.map { .init(id: $0.code, label: $0.label, count: $0.count) },
selection: $selectedTypeCodes
)
}
}
}
// MARK: - Map
private var mapLayer: some View {
Map(position: $position) {
// Trail polyline for the currently selected aircraft.
if let track = selectedTrack {
let coords = track.path.map {
CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude)
}
MapPolyline(coordinates: coords)
.stroke(FlightTheme.accent, lineWidth: 3)
}
ForEach(filteredAircraft) { ac in
// Pre-compute the pin's inputs outside the closure so the
// SwiftUI builder doesn't re-derive them on every diff.
let tint = aircraftTint(for: ac)
let rotation = Double(ac.heading ?? 0) - 45 // SF airplane symbol points up-right
let selected = selectedAircraft?.id == ac.id
Annotation(ac.trimmedCallsign ?? ac.icao24, coordinate: ac.coordinate) {
AircraftPin(tint: tint, headingMinus45: rotation, isSelected: selected)
.onTapGesture {
activeSheet = .aircraft(ac)
}
}
.annotationTitles(.hidden)
}
}
.mapStyle(.standard(elevation: .flat))
.onMapCameraChange(frequency: .onEnd) { context in
visibleRegion = context.region
Task { await refreshIfRegionChanged() }
}
}
/// 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)
if selectedAircraft?.icao24 == selected.icao24 {
selectedTrack = track
}
}
// MARK: - Top filter bar (lives inside the safe-area inset, never under
// the nav title or the tab bar)
private var topFilterBar: some View {
VStack(spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(FlightTheme.textSecondary)
TextField("Search callsign or flight (e.g. AA2178)", text: $searchText)
.autocorrectionDisabled()
.textInputAutocapitalization(.characters)
.onSubmit { centerOnSearchMatch() }
if !searchText.isEmpty {
Button { searchText = "" } label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(FlightTheme.textSecondary)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(FlightTheme.cardBackground)
.clipShape(Capsule())
.shadow(color: FlightTheme.cardShadow, radius: 6, y: 2)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
FilterChip(
label: hideOnGround ? "Airborne only" : "Include ground",
systemImage: hideOnGround ? "airplane" : "airplane.circle",
isActive: hideOnGround
) { hideOnGround.toggle() }
Button { activeSheet = .airlinePicker } label: {
FilterChipLabel(
label: selectedAirlineICAO.isEmpty
? "Airline"
: "Airline · \(selectedAirlineICAO.count)",
systemImage: "building.2",
isActive: !selectedAirlineICAO.isEmpty
)
}
.buttonStyle(.plain)
Button { activeSheet = .typePicker } label: {
FilterChipLabel(
label: selectedTypeCodes.isEmpty
? "Type"
: "Type · \(selectedTypeCodes.count)",
systemImage: "airplane.departure",
isActive: !selectedTypeCodes.isEmpty
)
}
.buttonStyle(.plain)
Menu {
let counts = altitudeBandCounts
ForEach(AltitudeBand.allCases) { band in
let count = counts[band] ?? 0
Button {
selectedAltitudeBand = (selectedAltitudeBand == band) ? nil : band
} label: {
let label = "\(band.rawValue) (\(count))"
if selectedAltitudeBand == band {
Label(label, systemImage: "checkmark")
} else {
Text(label)
}
}
}
if selectedAltitudeBand != nil {
Button("Clear", role: .destructive) { selectedAltitudeBand = nil }
}
} label: {
FilterChipLabel(
label: selectedAltitudeBand?.rawValue ?? "Altitude",
systemImage: "arrow.up.and.down",
isActive: selectedAltitudeBand != nil
)
}
if !selectedAirlineICAO.isEmpty || !selectedTypeCodes.isEmpty || selectedAltitudeBand != nil || hideOnGround {
Button {
selectedAirlineICAO.removeAll()
selectedTypeCodes.removeAll()
selectedAltitudeBand = nil
hideOnGround = false
} label: {
FilterChipLabel(label: "Reset", systemImage: "arrow.counterclockwise", isActive: false)
}
}
}
.padding(.horizontal, 12)
}
}
.padding(.horizontal, 12)
.padding(.top, 8)
.padding(.bottom, 8)
.background(.ultraThinMaterial)
}
// 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())
}
HStack(spacing: 12) {
if isLoading {
ProgressView().tint(.white)
} else {
Image(systemName: "antenna.radiowaves.left.and.right")
.foregroundStyle(.white)
}
Text("\(filteredAircraft.count) aircraft")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white)
if let last = lastFetchAt {
Text("· updated \(relativeTime(last))")
.font(.caption)
.foregroundStyle(.white.opacity(0.8))
}
Spacer()
Button {
Task { await refreshNow() }
} label: {
Image(systemName: "arrow.clockwise")
.foregroundStyle(.white)
}
.disabled(isLoading)
.opacity(isLoading ? 0.3 : 1)
Button {
activeSheet = .settings
} label: {
Image(systemName: "gearshape")
.foregroundStyle(.white)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color.black.opacity(0.7), in: Capsule())
}
.padding(.horizontal, 12)
.padding(.bottom, 8)
}
// MARK: - Derived
/// Quick read accessor used by the rendering layer. Always returns the
/// cached snapshot; rebuilds happen via the .onChange handlers above.
private var filteredAircraft: [LiveAircraft] { cachedFilteredAircraft }
private func rebuildFilteredAircraft() {
let s = searchText.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
let airlines = selectedAirlineICAO
let types = selectedTypeCodes
let band = selectedAltitudeBand
let hideGround = hideOnGround
cachedFilteredAircraft = aircraft.filter { ac in
if hideGround && ac.onGround { return false }
if !airlines.isEmpty {
guard let code = ac.airlineICAO, airlines.contains(code) else { return false }
}
if !types.isEmpty {
guard let tc = ac.typeCode, types.contains(tc) else { return false }
}
if let band {
guard let alt = ac.altitudeFeet, band.contains(alt) else { return false }
}
if !s.isEmpty {
let cs = ac.trimmedCallsign?.uppercased() ?? ""
if !cs.contains(s) { return false }
}
return true
}
}
struct AirlineFilterItem: Hashable {
let icao: String
let name: String
let count: Int
var label: String { "\(name) (\(count))" }
}
struct TypeFilterItem: Hashable {
let code: String
let label: String // e.g. "Boeing 737-800 · B738"
let count: Int
}
/// Rebuilds the cached filter items. Called from a .task tied to the
/// aircraft array so it doesn't run on every body re-render.
private func rebuildFilterItems() {
var airlines: [String: Int] = [:]
var types: [String: Int] = [:]
for ac in aircraft {
if let code = ac.airlineICAO {
airlines[code, default: 0] += 1
}
if let tc = ac.typeCode {
types[tc, default: 0] += 1
}
}
cachedAirlineItems = airlines.map { (icao, count) in
AirlineFilterItem(
icao: icao,
name: AircraftRegistry.shared.displayName(icao: icao),
count: count
)
}.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
cachedTypeItems = types.map { (code, count) in
let friendly = AircraftDatabase.shared.displayName(forTypeCode: code)
// If the friendly name differs from the raw code, show both;
// otherwise just the code so we don't render "B738 · B738".
let label = friendly == code ? code : "\(friendly) · \(code)"
return TypeFilterItem(code: code, label: label, count: count)
}.sorted { $0.label.localizedCaseInsensitiveCompare($1.label) == .orderedAscending }
}
/// Counts of how many aircraft fall in each altitude band drives the
/// altitude filter menu labels.
private var altitudeBandCounts: [AltitudeBand: Int] {
var counts: [AltitudeBand: Int] = [:]
for ac in aircraft {
guard let ft = ac.altitudeFeet else { continue }
for band in AltitudeBand.allCases where band.contains(ft) {
counts[band, default: 0] += 1
}
}
return counts
}
// MARK: - Fetch
/// Single long-lived auto-refresh loop. Runs for the lifetime of the
/// view (cancelled by SwiftUI when the tab disappears). Replaces the
/// old .task(id:) cascade, which restarted the timer every time
/// `isLoading` flipped and produced the "constantly refreshing"
/// symptom.
private func autoRefreshLoop() async {
if visibleRegion == nil {
let initial = MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 39.5, longitude: -98.0),
span: MKCoordinateSpan(latitudeDelta: 30, longitudeDelta: 50)
)
position = .region(initial)
visibleRegion = initial
}
await refreshNow()
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: UInt64(Self.refreshInterval * 1_000_000_000))
await refreshNow()
}
}
/// Called on map camera change. Only fires a fresh fetch if the new
/// bounding box actually moved by a meaningful amount micro-camera
/// settlements caused by annotation re-renders would otherwise
/// hammer OpenSky.
private func refreshIfRegionChanged() async {
guard let r = visibleRegion else { return }
let bb = boundingBox(of: r)
if let last = lastFetchedBoundingBox {
let centerDelta = max(
abs((bb.latMin + bb.latMax) / 2 - (last.latMin + last.latMax) / 2),
abs((bb.lonMin + bb.lonMax) / 2 - (last.lonMin + last.lonMax) / 2)
)
let widthRatio = (bb.lonMax - bb.lonMin) / max(0.001, last.lonMax - last.lonMin)
// Center moved less than 15% of the box width, AND box didn't
// zoom by more than 20% skip.
let halfWidth = (last.lonMax - last.lonMin) / 2
if centerDelta < halfWidth * 0.15, widthRatio > 0.8, widthRatio < 1.2 {
return
}
}
await refreshNow()
}
private func refreshNow() async {
guard !isLoading, let r = visibleRegion else { return }
isLoading = true
defer { isLoading = false }
let bb = boundingBox(of: r)
do {
let results = try await openSky.states(
latMin: bb.latMin, lonMin: bb.lonMin, latMax: bb.latMax, lonMax: bb.lonMax
)
// Suppress the implicit crossfade SwiftUI would apply to the
// map annotations when the underlying array swaps wholesale.
var tx = Transaction()
tx.disablesAnimations = true
withTransaction(tx) {
aircraft = results
}
lastFetchAt = Date()
lastFetchedBoundingBox = bb
error = nil
} catch {
self.error = (error as? OpenSkyClient.ClientError)?.errorDescription
?? error.localizedDescription
if case OpenSkyClient.ClientError.throttled = error {
// On 429, slow the loop down to once per minute for the
// next sleep cycle.
try? await Task.sleep(nanoseconds: 60 * 1_000_000_000)
}
}
}
private func boundingBox(of r: MKCoordinateRegion) -> (latMin: Double, lonMin: Double, latMax: Double, lonMax: Double) {
let lat = r.center.latitude
let lon = r.center.longitude
let dLat = r.span.latitudeDelta / 2
let dLon = r.span.longitudeDelta / 2
return (
latMin: max(-90, lat - dLat),
lonMin: max(-180, lon - dLon),
latMax: min( 90, lat + dLat),
lonMax: min( 180, lon + dLon)
)
}
// MARK: - Search / selection helpers
private func centerOnSearchMatch() {
let s = searchText.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
guard let match = aircraft.first(where: {
($0.trimmedCallsign?.uppercased() ?? "").contains(s)
}) else { return }
activeSheet = .aircraft(match)
position = .region(MKCoordinateRegion(
center: match.coordinate,
span: MKCoordinateSpan(latitudeDelta: 4, longitudeDelta: 6)
))
}
private func toggle<T: Hashable>(_ set: inout Set<T>, _ value: T) {
if set.contains(value) { set.remove(value) } else { set.insert(value) }
}
private func relativeTime(_ d: Date) -> String {
let secs = Int(Date().timeIntervalSince(d))
// Snap to coarse buckets so the footer text doesn't tick every
// second (which would force the footer subtree to re-render
// every body pass that happens to land on a different second).
if secs < 5 { return "just now" }
if secs < 30 { return "<30s ago" }
if secs < 60 { return "<1m ago" }
return "\(secs / 60)m ago"
}
}
// MARK: - Aircraft pin
/// Per-aircraft map pin. Kept deliberately minimal every annotation is
/// a SwiftUI view in the map's content tree, so view-tree depth × N pins
/// directly affects scroll/pan performance. Equatable so SwiftUI can
/// skip diffing identical pins on re-renders.
private struct AircraftPin: View, Equatable {
let tint: Color
let headingMinus45: Double
let isSelected: Bool
var body: some View {
Image(systemName: "airplane")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(.white)
.padding(6)
.background(Circle().fill(tint))
.overlay(
Circle()
.stroke(.white, lineWidth: isSelected ? 2.5 : 0)
)
.rotationEffect(.degrees(headingMinus45))
.scaleEffect(isSelected ? 1.3 : 1)
.animation(nil, value: tint)
.contentShape(Rectangle())
}
static func == (lhs: AircraftPin, rhs: AircraftPin) -> Bool {
lhs.tint == rhs.tint
&& lhs.headingMinus45 == rhs.headingMinus45
&& lhs.isSelected == rhs.isSelected
}
}
private func aircraftTint(for ac: LiveAircraft) -> Color {
if ac.onGround { return FlightTheme.textTertiary }
switch ac.verticalState {
case .climbing: return FlightTheme.onTime
case .descending: return FlightTheme.delayed
case .level: return FlightTheme.accent
}
}
// MARK: - Filter chips
private struct FilterChip: View {
let label: String
let systemImage: String
let isActive: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
FilterChipLabel(label: label, systemImage: systemImage, isActive: isActive)
}
.buttonStyle(.plain)
}
}
private struct FilterChipLabel: View {
let label: String
let systemImage: String
let isActive: Bool
var body: some View {
HStack(spacing: 6) {
Image(systemName: systemImage)
.font(.caption)
Text(label)
.font(.caption.weight(.semibold))
}
.foregroundStyle(isActive ? .white : FlightTheme.textPrimary)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(
Capsule()
.fill(isActive ? FlightTheme.accent : FlightTheme.cardBackground)
)
.shadow(color: FlightTheme.cardShadow, radius: 4, y: 1)
}
}