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:
@@ -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,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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (0–7). 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, 1–7, 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user