Add Live Flights tab: real-time aircraft map with filters + tap detail

New top-level TabView (RootView) splits the app into:
  Tab 1 (Search): existing RoutePlannerView home
  Tab 2 (Live):   live flight tracker

Live tab features:
- MapKit map showing every aircraft in the visible viewport, rotated
  to true heading. Color-coded by vertical state: climbing/level/
  descending/on-ground.
- Auto-refresh every 15s + on map pan/zoom (debounced); manual
  refresh button. Rate-limit aware (60s backoff on HTTP 429).
- Tap any aircraft → modal sheet with live state grid (altitude,
  speed, heading, vertical rate, squawk, last-contact), current
  route (lazily fetched per-aircraft from OpenSky's /flights/
  aircraft endpoint, mapped from ICAO to IATA airport codes), and
  recent flight history (up to 8 prior legs).
- Filters: airline (multi-select from currently visible callsigns,
  with counts), aircraft type (ADS-B emitter category), airborne-
  only toggle. All filters render as horizontal chips and clear
  with a single tap.
- Search bar: callsign/flight number — submitting centers the map
  on the match and opens its detail sheet.

Data source: OpenSky Network REST API. Free, anonymous (~100 req/
day cap), JSON. Same ADS-B data FR24 starts with — without satellite
ADS-B coverage but more than enough for the in-flight tracker use
case. Reviewed FR24's APK and confirmed they migrated their live
feed to gRPC+protobuf with anti-bot device-id headers; OpenSky's
plain JSON is the right tradeoff for our build.

Implementation:
- LiveAircraft model: decodes OpenSky's mixed-type position arrays
  into a typed struct; computed properties for ft/knots/heading and
  airline ICAO extracted from callsign.
- OpenSkyClient: actor with /states/all + /flights/aircraft. Bbox
  query, throttle-aware errors.
- AircraftRegistry: ~80 ICAO → (IATA, name) entries for the major
  carriers; everything else falls through to the raw ICAO code.
- LiveFlightsView: the main map + filter UI.
- LiveFlightDetailSheet: tap modal with live state + route history.
- RootView: TabView wrapping RoutePlannerView (Search) and the new
  LiveFlightsView (Live).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-05-27 06:08:58 -05:00
parent 92a69cf16c
commit 888943deb4
8 changed files with 1296 additions and 3 deletions
+24
View File
@@ -46,6 +46,12 @@
RE7700007777000077770001 /* ConnectionLoadDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE7700007777000077770002 /* ConnectionLoadDetailView.swift */; };
RE8800008888000088880001 /* SearchRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE8800008888000088880002 /* SearchRoute.swift */; };
T1000000000000000000001A /* AirlineLoadIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T1000000000000000000001B /* AirlineLoadIntegrationTests.swift */; };
LV1100001111000011110001 /* LiveAircraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = LV1100001111000011110002 /* LiveAircraft.swift */; };
LV2200002222000022220001 /* OpenSkyClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = LV2200002222000022220002 /* OpenSkyClient.swift */; };
LV3300003333000033330001 /* AircraftRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = LV3300003333000033330002 /* AircraftRegistry.swift */; };
LV4400004444000044440001 /* LiveFlightsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = LV4400004444000044440002 /* LiveFlightsView.swift */; };
LV5500005555000055550001 /* LiveFlightDetailSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = LV5500005555000055550002 /* LiveFlightDetailSheet.swift */; };
LV6600006666000066660001 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = LV6600006666000066660002 /* RootView.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -100,6 +106,12 @@
RE8800008888000088880002 /* SearchRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRoute.swift; sourceTree = "<group>"; };
T1000000000000000000001B /* AirlineLoadIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirlineLoadIntegrationTests.swift; sourceTree = "<group>"; };
T1000000000000000000003A /* FlightsTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FlightsTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
LV1100001111000011110002 /* LiveAircraft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveAircraft.swift; sourceTree = "<group>"; };
LV2200002222000022220002 /* OpenSkyClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenSkyClient.swift; sourceTree = "<group>"; };
LV3300003333000033330002 /* AircraftRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AircraftRegistry.swift; sourceTree = "<group>"; };
LV4400004444000044440002 /* LiveFlightsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveFlightsView.swift; sourceTree = "<group>"; };
LV5500005555000055550002 /* LiveFlightDetailSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveFlightDetailSheet.swift; sourceTree = "<group>"; };
LV6600006666000066660002 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -133,6 +145,9 @@
BB1100001111000011110006 /* FlightLoadDetailView.swift */,
RE3300003333000033330002 /* RoutePlannerView.swift */,
RE7700007777000077770002 /* ConnectionLoadDetailView.swift */,
LV4400004444000044440002 /* LiveFlightsView.swift */,
LV5500005555000055550002 /* LiveFlightDetailSheet.swift */,
LV6600006666000066660002 /* RootView.swift */,
AA5555555555555555555555 /* Styles */,
AA6666666666666666666666 /* Components */,
);
@@ -208,6 +223,8 @@
BB1100001111000011110004 /* AirlineLoadService.swift */,
BB2200002222000022220002 /* JSXWebViewFetcher.swift */,
RE2200002222000022220002 /* RouteExplorerClient.swift */,
LV2200002222000022220002 /* OpenSkyClient.swift */,
LV3300003333000033330002 /* AircraftRegistry.swift */,
);
path = Services;
sourceTree = "<group>";
@@ -235,6 +252,7 @@
BB1100001111000011110002 /* FlightLoad.swift */,
RE1100001111000011110002 /* RouteExplorerModels.swift */,
RE8800008888000088880002 /* SearchRoute.swift */,
LV1100001111000011110002 /* LiveAircraft.swift */,
);
path = Models;
sourceTree = "<group>";
@@ -366,6 +384,12 @@
RE6600006666000066660001 /* ConnectionRow.swift in Sources */,
RE7700007777000077770001 /* ConnectionLoadDetailView.swift in Sources */,
RE8800008888000088880001 /* SearchRoute.swift in Sources */,
LV1100001111000011110001 /* LiveAircraft.swift in Sources */,
LV2200002222000022220001 /* OpenSkyClient.swift in Sources */,
LV3300003333000033330001 /* AircraftRegistry.swift in Sources */,
LV4400004444000044440001 /* LiveFlightsView.swift in Sources */,
LV5500005555000055550001 /* LiveFlightDetailSheet.swift in Sources */,
LV6600006666000066660001 /* RootView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
+5 -3
View File
@@ -5,6 +5,7 @@ struct FlightsApp: App {
let database: AirportDatabase
let loadService: AirlineLoadService
let routeExplorer = RouteExplorerClient()
let openSky = OpenSkyClient()
init() {
let db = AirportDatabase()
@@ -14,10 +15,11 @@ struct FlightsApp: App {
var body: some Scene {
WindowGroup {
RoutePlannerView(
RootView(
database: database,
client: routeExplorer,
loadService: loadService
loadService: loadService,
routeExplorer: routeExplorer,
openSky: openSky
)
}
}
+129
View File
@@ -0,0 +1,129 @@
import Foundation
import CoreLocation
/// One aircraft's live state vector, normalized from OpenSky's `/states/all`
/// positional array format into a typed struct.
struct LiveAircraft: Identifiable, Hashable, Sendable {
var id: String { icao24 }
/// 24-bit ICAO transponder address as hex (lowercased).
let icao24: String
/// ADS-B broadcast callsign, e.g. `DAL1234` (ICAO airline code + flight number).
/// Often padded with trailing whitespace `trimmedCallsign` strips that.
let callsign: String?
/// ICAO-registered country of the operator.
let originCountry: String
let latitude: Double
let longitude: Double
/// Barometric altitude in meters. Falls back to geometric altitude in
/// `altitudeFeet`.
let baroAltitude: Double?
let geoAltitude: Double?
/// Velocity in m/s.
let velocity: Double?
/// True track in degrees from North (0..360).
let trueTrack: Double?
/// Vertical rate in m/s; positive = climbing.
let verticalRate: Double?
let onGround: Bool
let squawk: String?
/// Aircraft category from ADS-B emitter category (07). Decodes per
/// `aircraftCategoryName`.
let category: Int?
/// When the position was last updated (server-side).
let lastContact: Date
var coordinate: CLLocationCoordinate2D {
CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
}
var altitudeFeet: Int? {
guard let alt = baroAltitude ?? geoAltitude else { return nil }
return Int(alt * 3.28084)
}
var velocityKnots: Int? {
guard let v = velocity else { return nil }
return Int(v * 1.94384)
}
var heading: Int? {
guard let t = trueTrack else { return nil }
return Int(t.truncatingRemainder(dividingBy: 360))
}
var verticalState: VerticalState {
guard let vr = verticalRate else { return .level }
if vr > 1.5 { return .climbing }
if vr < -1.5 { return .descending }
return .level
}
var trimmedCallsign: String? {
let s = (callsign ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return s.isEmpty ? nil : s
}
/// 3-letter ICAO airline prefix from the callsign (e.g. "DAL" from "DAL1234").
/// Returns nil when the callsign doesn't follow the standard pattern (e.g.
/// GA tail numbers like "N12345").
var airlineICAO: String? {
guard let cs = trimmedCallsign else { return nil }
let letters = cs.prefix(while: { $0.isLetter })
guard letters.count == 3 else { return nil }
return String(letters)
}
/// Numeric flight number portion (everything after the airline prefix).
var flightNumber: String? {
guard let cs = trimmedCallsign else { return nil }
let s = String(cs.drop(while: { $0.isLetter }))
return s.isEmpty ? nil : s
}
}
enum VerticalState {
case climbing, descending, level
}
/// ADS-B emitter category, 17, per RTCA DO-260.
func aircraftCategoryName(_ code: Int?) -> String? {
switch code {
case 1: return "Light"
case 2: return "Small"
case 3: return "Large"
case 4: return "High vortex large"
case 5: return "Heavy"
case 6: return "High performance"
case 7: return "Rotorcraft"
default: return nil
}
}
/// Historical OpenSky flight record used to surface "where did this aircraft
/// take off from / where's it going" in the detail sheet.
struct OpenSkyFlight: Decodable, Sendable, Hashable {
let icao24: String
let firstSeen: Int
let lastSeen: Int
let estDepartureAirport: String?
let estArrivalAirport: String?
let callsign: String?
var departureDate: Date { Date(timeIntervalSince1970: TimeInterval(firstSeen)) }
var arrivalDate: Date { Date(timeIntervalSince1970: TimeInterval(lastSeen)) }
var trimmedCallsign: String? {
let s = (callsign ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return s.isEmpty ? nil : s
}
}
+117
View File
@@ -0,0 +1,117 @@
import Foundation
/// Tiny lookup for the most common ICAO airline codes IATA + display name.
/// Used to enrich live-tracker callsigns (e.g. "DAL1234" "Delta", IATA "DL").
///
/// Not exhaustive about 80 entries covering the carriers we encounter
/// most. Anything not in the table falls back to the raw 3-letter ICAO code.
/// If this grows past ~200, switch to a bundled JSON.
enum AircraftRegistry {
/// ICAO airline code (IATA, display name).
static let airlines: [String: (iata: String, name: String)] = [
// US legacy + major
"AAL": ("AA", "American Airlines"),
"DAL": ("DL", "Delta Air Lines"),
"UAL": ("UA", "United Airlines"),
"SWA": ("WN", "Southwest Airlines"),
"ASA": ("AS", "Alaska Airlines"),
"JBU": ("B6", "JetBlue"),
"SCX": ("SY", "Sun Country Airlines"),
"HAL": ("HA", "Hawaiian Airlines"),
"FFT": ("F9", "Frontier Airlines"),
"AAY": ("G4", "Allegiant Air"),
"JIA": ("OH", "PSA Airlines"),
"ASH": ("YX", "Mesa Airlines"),
"ENY": ("MQ", "Envoy Air"),
"RPA": ("YX", "Republic Airways"),
"SKW": ("OO", "SkyWest"),
"EDV": ("9E", "Endeavor Air"),
"GJS": ("OO", "GoJet"),
"JSX": ("XE", "JSX"),
// Canada
"ACA": ("AC", "Air Canada"),
"WJA": ("WS", "WestJet"),
"JZA": ("QK", "Jazz Aviation"),
// Mexico / LatAm
"AMX": ("AM", "Aeromexico"),
"VOI": ("Y4", "Volaris"),
"VIV": ("VB", "Viva Aerobus"),
"AVA": ("AV", "Avianca"),
"GLO": ("G3", "GOL"),
"AZU": ("AD", "Azul"),
"LAN": ("LA", "LATAM"),
"TAM": ("JJ", "LATAM Brasil"),
"CMP": ("CM", "Copa Airlines"),
// Europe
"BAW": ("BA", "British Airways"),
"DLH": ("LH", "Lufthansa"),
"AFR": ("AF", "Air France"),
"KLM": ("KL", "KLM"),
"IBE": ("IB", "Iberia"),
"AEA": ("UX", "Air Europa"),
"SWR": ("LX", "Swiss"),
"AUA": ("OS", "Austrian Airlines"),
"BEL": ("SN", "Brussels Airlines"),
"SAS": ("SK", "SAS"),
"FIN": ("AY", "Finnair"),
"TAP": ("TP", "TAP Portugal"),
"VIR": ("VS", "Virgin Atlantic"),
"EZY": ("U2", "EasyJet"),
"RYR": ("FR", "Ryanair"),
"WZZ": ("W6", "Wizz Air"),
"AZA": ("AZ", "ITA Airways"),
"THY": ("TK", "Turkish Airlines"),
"AEE": ("A3", "Aegean Airlines"),
"ROT": ("RO", "TAROM"),
"LOT": ("LO", "LOT Polish"),
"CSA": ("OK", "Czech Airlines"),
// Middle East
"UAE": ("EK", "Emirates"),
"ETD": ("EY", "Etihad Airways"),
"QTR": ("QR", "Qatar Airways"),
"SVA": ("SV", "Saudia"),
"KAC": ("KU", "Kuwait Airways"),
"ELY": ("LY", "El Al"),
// Asia / Pacific
"ANA": ("NH", "All Nippon Airways"),
"JAL": ("JL", "Japan Airlines"),
"KAL": ("KE", "Korean Air"),
"AAR": ("OZ", "Asiana"),
"CCA": ("CA", "Air China"),
"CSN": ("CZ", "China Southern"),
"CES": ("MU", "China Eastern"),
"CPA": ("CX", "Cathay Pacific"),
"SIA": ("SQ", "Singapore Airlines"),
"THA": ("TG", "Thai Airways"),
"MAS": ("MH", "Malaysia Airlines"),
"PAL": ("PR", "Philippine Airlines"),
"GIA": ("GA", "Garuda Indonesia"),
"QFA": ("QF", "Qantas"),
"ANZ": ("NZ", "Air New Zealand"),
"VOZ": ("VA", "Virgin Australia"),
"JST": ("JQ", "Jetstar"),
"AIC": ("AI", "Air India"),
"IGO": ("6E", "IndiGo"),
// Cargo
"FDX": ("FX", "FedEx"),
"UPS": ("5X", "UPS Airlines"),
"GTI": ("5Y", "Atlas Air"),
"ABX": ("GB", "ABX Air"),
"GEC": ("LH", "Lufthansa Cargo"),
"CKS": ("K4", "Kalitta Air")
]
/// Look up display info for an ICAO code. Returns the raw code as the
/// name if unknown so the UI never shows blank.
static func airline(forICAO code: String?) -> (iata: String?, name: String) {
guard let code = code?.uppercased() else { return (nil, "Unknown") }
if let hit = airlines[code] { return (hit.iata, hit.name) }
return (nil, code)
}
}
+187
View File
@@ -0,0 +1,187 @@
import Foundation
/// Thin client for the OpenSky Network REST API. Two endpoints:
/// - `/states/all` live aircraft state vectors (positions, velocity, etc.)
/// - `/flights/aircraft` recent flight history per aircraft (used for the
/// tap-to-detail "where did this come from / going to" panel)
///
/// Anonymous access is rate-limited (~10s minimum between requests, 100/day);
/// authenticated access gets higher quotas but requires the user to register.
/// We default to anonymous + heavy debouncing in the UI layer.
actor OpenSkyClient {
enum ClientError: Error, LocalizedError {
case requestFailed(status: Int)
case decodingFailed(Error)
case throttled
var errorDescription: String? {
switch self {
case .requestFailed(let s): return "OpenSky HTTP \(s)"
case .decodingFailed(let e): return "Could not parse OpenSky response: \(e.localizedDescription)"
case .throttled: return "OpenSky rate limit reached. Wait a moment and retry."
}
}
}
private let session: URLSession
init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 20
config.requestCachePolicy = .reloadIgnoringLocalCacheData
session = URLSession(configuration: config)
}
/// Aircraft inside the given lat/lon bounding box.
///
/// Use the smallest bounding box that covers the user's visible map too
/// wide and you'll pull thousands of state vectors per call (and burn
/// quota faster).
func states(latMin: Double, lonMin: Double, latMax: Double, lonMax: Double) async throws -> [LiveAircraft] {
var comps = URLComponents(string: "https://opensky-network.org/api/states/all")!
comps.queryItems = [
URLQueryItem(name: "lamin", value: String(latMin)),
URLQueryItem(name: "lomin", value: String(lonMin)),
URLQueryItem(name: "lamax", value: String(latMax)),
URLQueryItem(name: "lomax", value: String(lonMax))
]
guard let url = comps.url else { throw ClientError.requestFailed(status: -1) }
return try await decodeStates(from: url)
}
private func decodeStates(from url: URL) async throws -> [LiveAircraft] {
var req = URLRequest(url: url)
req.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await session.data(for: req)
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
if status == 429 { throw ClientError.throttled }
guard status == 200 else { throw ClientError.requestFailed(status: status) }
do {
let resp = try JSONDecoder().decode(StatesResponse.self, from: data)
return resp.states ?? []
} catch {
throw ClientError.decodingFailed(error)
}
}
/// Flights an aircraft has flown in the past N days.
/// OpenSky requires a `begin` and `end` window, max 30 days each.
func recentFlights(icao24: String, daysBack: Int = 7) async -> [OpenSkyFlight] {
let now = Int(Date().timeIntervalSince1970)
let begin = now - (daysBack * 86400)
var comps = URLComponents(string: "https://opensky-network.org/api/flights/aircraft")!
comps.queryItems = [
URLQueryItem(name: "icao24", value: icao24.lowercased()),
URLQueryItem(name: "begin", value: String(begin)),
URLQueryItem(name: "end", value: String(now))
]
guard let url = comps.url else { return [] }
var req = URLRequest(url: url)
req.setValue("application/json", forHTTPHeaderField: "Accept")
do {
let (data, response) = try await session.data(for: req)
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
// 404 from OpenSky means "no flights in this window" not an error.
guard status == 200 else { return [] }
return (try? JSONDecoder().decode([OpenSkyFlight].self, from: data)) ?? []
} catch {
return []
}
}
}
// MARK: - Decoding
/// OpenSky returns each state vector as a heterogeneous array index 0 is
/// the ICAO24 hex, 1 is callsign, 2 is country, 3 is unix time of last
/// position, 4 is unix time of last contact, 5 is longitude, 6 is latitude,
/// 7 is barometric altitude in meters, 8 is on_ground bool, 9 is velocity
/// m/s, 10 is true_track degrees, 11 is vertical_rate m/s, 12 is sensors
/// (array of receiver IDs, skipped), 13 is geometric altitude meters, 14
/// is squawk, 15 is spi bool (skipped), 16 is position_source (skipped),
/// 17 is category. Most entries can be `null`.
private struct StatesResponse: Decodable {
let time: Int
let states: [LiveAircraft]?
enum CodingKeys: String, CodingKey { case time, states }
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
time = try c.decode(Int.self, forKey: .time)
let raw = try c.decodeIfPresent([RawStateVector].self, forKey: .states)
states = raw?.compactMap(LiveAircraft.from)
}
}
/// Intermediate decoder that consumes the heterogeneous array into named
/// optional fields without exploding on null values or shape drift.
private struct RawStateVector: Decodable {
let icao24: String
let callsign: String?
let originCountry: String
let timePosition: Int?
let lastContact: Int
let longitude: Double?
let latitude: Double?
let baroAltitude: Double?
let onGround: Bool
let velocity: Double?
let trueTrack: Double?
let verticalRate: Double?
let geoAltitude: Double?
let squawk: String?
let category: Int?
init(from decoder: Decoder) throws {
var c = try decoder.unkeyedContainer()
icao24 = (try? c.decode(String.self)) ?? ""
callsign = try? c.decodeIfPresent(String.self)
originCountry = (try? c.decode(String.self)) ?? ""
timePosition = try? c.decodeIfPresent(Int.self)
lastContact = (try? c.decode(Int.self)) ?? 0
longitude = try? c.decodeIfPresent(Double.self)
latitude = try? c.decodeIfPresent(Double.self)
baroAltitude = try? c.decodeIfPresent(Double.self)
onGround = (try? c.decode(Bool.self)) ?? false
velocity = try? c.decodeIfPresent(Double.self)
trueTrack = try? c.decodeIfPresent(Double.self)
verticalRate = try? c.decodeIfPresent(Double.self)
// [12] sensors array skip.
_ = try? c.decodeIfPresent([Int].self)
geoAltitude = try? c.decodeIfPresent(Double.self)
squawk = try? c.decodeIfPresent(String.self)
// [15] spi, [16] position_source skip.
_ = try? c.decode(Bool.self)
_ = try? c.decode(Int.self)
category = try? c.decodeIfPresent(Int.self)
}
}
private extension LiveAircraft {
static func from(_ raw: RawStateVector) -> LiveAircraft? {
guard let lat = raw.latitude, let lon = raw.longitude, !raw.icao24.isEmpty else {
return nil
}
return LiveAircraft(
icao24: raw.icao24,
callsign: raw.callsign,
originCountry: raw.originCountry,
latitude: lat,
longitude: lon,
baroAltitude: raw.baroAltitude,
geoAltitude: raw.geoAltitude,
velocity: raw.velocity,
trueTrack: raw.trueTrack,
verticalRate: raw.verticalRate,
onGround: raw.onGround,
squawk: raw.squawk,
category: raw.category,
lastContact: Date(timeIntervalSince1970: TimeInterval(raw.lastContact))
)
}
}
+331
View File
@@ -0,0 +1,331 @@
import SwiftUI
import CoreLocation
struct LiveFlightDetailSheet: View {
let aircraft: LiveAircraft
let openSky: OpenSkyClient
let database: AirportDatabase
@State private var recentFlights: [OpenSkyFlight] = []
@State private var isLoadingRoute = false
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
header
Divider()
// Live state grid
Text("LIVE STATE")
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textTertiary)
.tracking(1)
liveStateGrid
if let route = currentRoute {
Text("THIS FLIGHT")
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textTertiary)
.tracking(1)
.padding(.top, 4)
routeCard(route)
} else if isLoadingRoute {
HStack {
ProgressView()
Text("Looking up route…")
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
}
}
if recentFlights.count > 1 {
Text("RECENT FLIGHTS")
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textTertiary)
.tracking(1)
.padding(.top, 4)
ForEach(recentFlights.prefix(8), id: \.self) { flight in
recentFlightRow(flight)
}
}
Text("AIRCRAFT")
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textTertiary)
.tracking(1)
.padding(.top, 4)
aircraftCard
}
.padding(16)
}
.background(FlightTheme.background.ignoresSafeArea())
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.subheadline.weight(.semibold))
}
}
}
.task {
await loadRoute()
}
}
}
// MARK: - Header
private var header: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 10) {
Text(aircraft.trimmedCallsign ?? aircraft.icao24)
.font(.title.weight(.bold).monospaced())
.foregroundStyle(FlightTheme.textPrimary)
Spacer()
statusBadge
}
Text(airlineDisplayName)
.font(.subheadline)
.foregroundStyle(FlightTheme.textSecondary)
}
}
private var statusBadge: some View {
let (text, color): (String, Color) = {
if aircraft.onGround { return ("On ground", FlightTheme.textSecondary) }
switch aircraft.verticalState {
case .climbing: return ("Climbing", FlightTheme.onTime)
case .descending: return ("Descending", FlightTheme.delayed)
case .level: return ("Cruising", FlightTheme.accent)
}
}()
return Text(text)
.font(.caption.weight(.bold))
.foregroundStyle(.white)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(color, in: Capsule())
}
// MARK: - Live state grid
private var liveStateGrid: some View {
VStack(spacing: 0) {
HStack(spacing: 0) {
statCell(label: "Altitude",
value: aircraft.altitudeFeet.map { "\(formatNumber($0)) ft" } ?? "")
statCell(label: "Speed",
value: aircraft.velocityKnots.map { "\($0) kt" } ?? "")
}
Divider()
HStack(spacing: 0) {
statCell(label: "Heading",
value: aircraft.heading.map { "\($0)°" } ?? "")
statCell(label: "Vertical",
value: verticalDisplay)
}
Divider()
HStack(spacing: 0) {
statCell(label: "Squawk", value: aircraft.squawk ?? "")
statCell(label: "Updated", value: shortTime(aircraft.lastContact))
}
}
.flightCard(padding: 0)
}
private var verticalDisplay: String {
if aircraft.onGround { return "" }
switch aircraft.verticalState {
case .climbing: return "Climb"
case .descending: return "Descend"
case .level: return "Level"
}
}
private func statCell(label: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.caption2)
.foregroundStyle(FlightTheme.textTertiary)
.tracking(0.5)
Text(value)
.font(.subheadline.weight(.semibold).monospaced())
.foregroundStyle(FlightTheme.textPrimary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
}
// MARK: - Route
/// The most recent flight (could be in-progress or just-landed).
private var currentRoute: OpenSkyFlight? {
recentFlights.first { f in
let now = Date().timeIntervalSince1970
return Double(f.lastSeen) > now - 6 * 3600
} ?? recentFlights.first
}
private func routeCard(_ f: OpenSkyFlight) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 16) {
routeEndpoint(
code: f.estDepartureAirport,
label: "Departed",
time: f.departureDate
)
Image(systemName: "airplane")
.font(.title3)
.foregroundStyle(FlightTheme.accent)
.rotationEffect(.degrees(-45))
routeEndpoint(
code: f.estArrivalAirport,
label: aircraft.onGround ? "Arrived" : "Heading to",
time: f.arrivalDate
)
}
}
.flightCard()
}
private func routeEndpoint(code: String?, label: String, time: Date) -> some View {
let iata = code.flatMap(icaoToIATA(_:)) ?? code ?? ""
let cityName: String = {
guard let code, let m = database.airport(byIATA: icaoToIATA(code) ?? code) else { return "" }
return m.name
}()
return VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.caption2)
.foregroundStyle(FlightTheme.textTertiary)
.tracking(0.5)
Text(iata)
.font(FlightTheme.airportCode(28))
.foregroundStyle(FlightTheme.textPrimary)
if !cityName.isEmpty {
Text(cityName)
.font(.caption2)
.foregroundStyle(FlightTheme.textSecondary)
.lineLimit(1)
}
Text(shortDateTime(time))
.font(.caption2.monospaced())
.foregroundStyle(FlightTheme.textTertiary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func recentFlightRow(_ f: OpenSkyFlight) -> some View {
let dep = f.estDepartureAirport.flatMap(icaoToIATA(_:)) ?? f.estDepartureAirport ?? ""
let arr = f.estArrivalAirport.flatMap(icaoToIATA(_:)) ?? f.estArrivalAirport ?? ""
return HStack(spacing: 12) {
Text(dep)
.font(.subheadline.weight(.semibold).monospaced())
.foregroundStyle(FlightTheme.textPrimary)
.frame(width: 56, alignment: .leading)
Image(systemName: "arrow.right")
.font(.caption)
.foregroundStyle(FlightTheme.textTertiary)
Text(arr)
.font(.subheadline.weight(.semibold).monospaced())
.foregroundStyle(FlightTheme.textPrimary)
.frame(width: 56, alignment: .leading)
Spacer()
Text(shortDate(f.departureDate))
.font(.caption.monospaced())
.foregroundStyle(FlightTheme.textSecondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(FlightTheme.cardBackground, in: RoundedRectangle(cornerRadius: 10))
}
// MARK: - Aircraft card
private var aircraftCard: some View {
VStack(spacing: 0) {
HStack(spacing: 0) {
statCell(label: "ICAO24", value: aircraft.icao24.uppercased())
statCell(label: "Country", value: aircraft.originCountry.isEmpty ? "" : aircraft.originCountry)
}
if let cat = aircraft.category, let name = aircraftCategoryName(cat) {
Divider()
HStack(spacing: 0) {
statCell(label: "Category", value: name)
statCell(label: "Position", value: shortCoord(aircraft.coordinate))
}
} else {
Divider()
HStack(spacing: 0) {
statCell(label: "Position", value: shortCoord(aircraft.coordinate))
statCell(label: "", value: "")
}
}
}
.flightCard(padding: 0)
}
// MARK: - Helpers
private var airlineDisplayName: String {
AircraftRegistry.airline(forICAO: aircraft.airlineICAO).name
}
private func loadRoute() async {
guard !isLoadingRoute else { return }
isLoadingRoute = true
defer { isLoadingRoute = false }
recentFlights = await openSky.recentFlights(icao24: aircraft.icao24)
}
private func formatNumber(_ n: Int) -> String {
let f = NumberFormatter()
f.numberStyle = .decimal
return f.string(from: NSNumber(value: n)) ?? "\(n)"
}
private func shortCoord(_ c: CLLocationCoordinate2D) -> String {
String(format: "%.2f, %.2f", c.latitude, c.longitude)
}
private func shortTime(_ d: Date) -> String {
let f = DateFormatter()
f.timeStyle = .short
return f.string(from: d)
}
private func shortDate(_ d: Date) -> String {
let f = DateFormatter()
f.dateFormat = "MMM d"
return f.string(from: d)
}
private func shortDateTime(_ d: Date) -> String {
let f = DateFormatter()
f.dateFormat = "MMM d, HH:mm"
return f.string(from: d)
}
/// OpenSky returns 4-letter ICAO airport codes (e.g. "KDFW"). Strip the
/// leading region letter for common 3-letter IATA codes in the US/
/// Canada/etc. Best-effort falls back to the raw value.
private func icaoToIATA(_ icao: String?) -> String? {
guard let icao else { return nil }
let s = icao.uppercased()
guard s.count == 4 else { return s }
// US: KXXX, Canada: CYxx (3 chars after C), Mexico: MMxx (3 chars after M).
if s.hasPrefix("K") { return String(s.dropFirst()) }
if s.hasPrefix("CY") { return String(s.dropFirst()) } // YYZ stays YYZ
if s.hasPrefix("MM") { return String(s.dropFirst()) }
return s
}
}
+462
View File
@@ -0,0 +1,462 @@
import SwiftUI
import MapKit
import CoreLocation
struct LiveFlightsView: View {
let openSky: OpenSkyClient
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 nextFetchAllowedAt: Date = .distantPast
@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 selectedCategories: Set<Int> = []
@State private var hideOnGround: Bool = false
// MARK: - Selection
@State private var selectedAircraft: LiveAircraft?
// 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)
}
}
// MARK: - Map
private var mapLayer: some View {
Map(position: $position, selection: $selectedAircraft.iconSelection) {
ForEach(filteredAircraft) { ac in
Annotation(ac.trimmedCallsign ?? ac.icao24, coordinate: ac.coordinate) {
AircraftPin(ac: ac, isSelected: selectedAircraft?.id == ac.id)
.onTapGesture {
selectedAircraft = ac
}
}
.annotationTitles(.hidden)
}
}
.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() }
}
}
// MARK: - Header (filters + search)
private var overlayHeader: 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() }
Menu {
Section("Airline") {
ForEach(visibleAirlines, id: \.icao) { item in
Button {
toggle(&selectedAirlineICAO, item.icao)
} label: {
if selectedAirlineICAO.contains(item.icao) {
Label(item.label, systemImage: "checkmark")
} else {
Text(item.label)
}
}
}
if !selectedAirlineICAO.isEmpty {
Button("Clear", role: .destructive) { selectedAirlineICAO.removeAll() }
}
}
} label: {
FilterChipLabel(
label: selectedAirlineICAO.isEmpty
? "Airline"
: "Airline · \(selectedAirlineICAO.count)",
systemImage: "building.2",
isActive: !selectedAirlineICAO.isEmpty
)
}
Menu {
Section("Aircraft type") {
ForEach(visibleCategories, id: \.code) { item in
Button {
toggle(&selectedCategories, item.code)
} label: {
if selectedCategories.contains(item.code) {
Label(item.label, systemImage: "checkmark")
} else {
Text(item.label)
}
}
}
if !selectedCategories.isEmpty {
Button("Clear", role: .destructive) { selectedCategories.removeAll() }
}
}
} label: {
FilterChipLabel(
label: selectedCategories.isEmpty
? "Type"
: "Type · \(selectedCategories.count)",
systemImage: "airplane.departure",
isActive: !selectedCategories.isEmpty
)
}
if !selectedAirlineICAO.isEmpty || !selectedCategories.isEmpty || hideOnGround {
Button {
selectedAirlineICAO.removeAll()
selectedCategories.removeAll()
hideOnGround = false
} label: {
FilterChipLabel(label: "Reset", systemImage: "arrow.counterclockwise", isActive: false)
}
}
}
.padding(.horizontal, 4)
}
}
.padding(.horizontal, 12)
.padding(.top, 8)
}
// MARK: - Footer (count + refresh status)
private var footerBar: some View {
VStack {
Spacer()
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)
}
.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)
}
}
}
// 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)
}
private var filteredAircraft: [LiveAircraft] {
let s = searchText.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
return aircraft.filter { ac in
if hideOnGround && ac.onGround { return false }
if !selectedAirlineICAO.isEmpty,
let code = ac.airlineICAO,
!selectedAirlineICAO.contains(code) {
return false
}
if !selectedCategories.isEmpty {
guard let cat = ac.category, selectedCategories.contains(cat) else { return false }
}
if !s.isEmpty {
let cs = ac.trimmedCallsign?.uppercased() ?? ""
if !cs.contains(s) { return false }
}
return true
}
}
private struct AirlineFilterItem: Hashable { let icao: String; let label: String }
private var visibleAirlines: [AirlineFilterItem] {
var seen: [String: Int] = [:]
for ac in aircraft {
if let code = ac.airlineICAO {
seen[code, default: 0] += 1
}
}
return seen.keys.sorted().map { icao in
let info = AircraftRegistry.airline(forICAO: icao)
let count = seen[icao] ?? 0
let name = info.name
return AirlineFilterItem(icao: icao, label: "\(name) (\(count))")
}
}
private struct CategoryFilterItem: Hashable { let code: Int; let label: String }
private var visibleCategories: [CategoryFilterItem] {
var seen: [Int: Int] = [:]
for ac in aircraft {
if let c = ac.category, c > 0 {
seen[c, default: 0] += 1
}
}
return seen.keys.sorted().compactMap { code in
guard let name = aircraftCategoryName(code) else { return nil }
let count = seen[code] ?? 0
return CategoryFilterItem(code: code, label: "\(name) (\(count))")
}
}
// 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)
)
position = .region(initial)
visibleRegion = initial
}
await refreshNow()
}
private func refreshIfAllowed() async {
guard Date() >= nextFetchAllowedAt else { return }
await refreshNow()
}
private func refreshNow() async {
guard !isLoading, let r = visibleRegion else { return }
isLoading = true
defer { isLoading = false }
let (latMin, lonMin, latMax, lonMax) = boundingBox(of: r)
do {
let results = try await openSky.states(
latMin: latMin, lonMin: lonMin, latMax: latMax, lonMax: lonMax
)
aircraft = results
lastFetchAt = Date()
nextFetchAllowedAt = Date().addingTimeInterval(Self.refreshInterval)
error = nil
} 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)
}
}
}
private func boundingBox(of r: MKCoordinateRegion) -> (Double, Double, Double, Double) {
let lat = r.center.latitude
let lon = r.center.longitude
let dLat = r.span.latitudeDelta / 2
let dLon = r.span.longitudeDelta / 2
return (
max(-90, lat - dLat),
max(-180, lon - dLon),
min( 90, lat + dLat),
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 }
selectedAircraft = 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))
if secs < 5 { return "just now" }
if secs < 60 { return "\(secs)s ago" }
return "\(secs / 60)m ago"
}
}
// MARK: - Aircraft pin
private struct AircraftPin: View {
let ac: LiveAircraft
let isSelected: Bool
var body: some View {
ZStack {
if isSelected {
Circle()
.fill(FlightTheme.accent.opacity(0.25))
.frame(width: 36, height: 36)
}
Image(systemName: "airplane")
.font(.system(size: isSelected ? 18 : 14, weight: .bold))
.foregroundStyle(.white)
.padding(6)
.background(
Circle().fill(tint)
)
.rotationEffect(.degrees(Double(ac.heading ?? 0) - 45))
// SF Symbol "airplane" points up-and-right by default;
// -45° aligns it to true north before applying the heading.
}
.contentShape(Rectangle())
}
private var tint: 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)
}
}
// 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 }
}
+41
View File
@@ -0,0 +1,41 @@
import SwiftUI
/// Top-level tab container.
///
/// Tab 1: the existing search / connection / where-to-go home screen.
/// Tab 2: the live flight tracker (map + filters + tap-to-detail).
struct RootView: View {
let database: AirportDatabase
let loadService: AirlineLoadService
let routeExplorer: RouteExplorerClient
let openSky: OpenSkyClient
@State private var selectedTab: Tab = .search
enum Tab: Hashable { case search, live }
var body: some View {
TabView(selection: $selectedTab) {
RoutePlannerView(
database: database,
client: routeExplorer,
loadService: loadService
)
.tabItem {
Label("Search", systemImage: "magnifyingglass")
}
.tag(Tab.search)
NavigationStack {
LiveFlightsView(openSky: openSky, database: database)
.navigationTitle("Live Flights")
.navigationBarTitleDisplayMode(.inline)
}
.tabItem {
Label("Live", systemImage: "antenna.radiowaves.left.and.right")
}
.tag(Tab.live)
}
.tint(FlightTheme.accent)
}
}