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 */; };
|
RE7700007777000077770001 /* ConnectionLoadDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE7700007777000077770002 /* ConnectionLoadDetailView.swift */; };
|
||||||
RE8800008888000088880001 /* SearchRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE8800008888000088880002 /* SearchRoute.swift */; };
|
RE8800008888000088880001 /* SearchRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE8800008888000088880002 /* SearchRoute.swift */; };
|
||||||
T1000000000000000000001A /* AirlineLoadIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T1000000000000000000001B /* AirlineLoadIntegrationTests.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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -100,6 +106,12 @@
|
|||||||
RE8800008888000088880002 /* SearchRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRoute.swift; sourceTree = "<group>"; };
|
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>"; };
|
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; };
|
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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -133,6 +145,9 @@
|
|||||||
BB1100001111000011110006 /* FlightLoadDetailView.swift */,
|
BB1100001111000011110006 /* FlightLoadDetailView.swift */,
|
||||||
RE3300003333000033330002 /* RoutePlannerView.swift */,
|
RE3300003333000033330002 /* RoutePlannerView.swift */,
|
||||||
RE7700007777000077770002 /* ConnectionLoadDetailView.swift */,
|
RE7700007777000077770002 /* ConnectionLoadDetailView.swift */,
|
||||||
|
LV4400004444000044440002 /* LiveFlightsView.swift */,
|
||||||
|
LV5500005555000055550002 /* LiveFlightDetailSheet.swift */,
|
||||||
|
LV6600006666000066660002 /* RootView.swift */,
|
||||||
AA5555555555555555555555 /* Styles */,
|
AA5555555555555555555555 /* Styles */,
|
||||||
AA6666666666666666666666 /* Components */,
|
AA6666666666666666666666 /* Components */,
|
||||||
);
|
);
|
||||||
@@ -208,6 +223,8 @@
|
|||||||
BB1100001111000011110004 /* AirlineLoadService.swift */,
|
BB1100001111000011110004 /* AirlineLoadService.swift */,
|
||||||
BB2200002222000022220002 /* JSXWebViewFetcher.swift */,
|
BB2200002222000022220002 /* JSXWebViewFetcher.swift */,
|
||||||
RE2200002222000022220002 /* RouteExplorerClient.swift */,
|
RE2200002222000022220002 /* RouteExplorerClient.swift */,
|
||||||
|
LV2200002222000022220002 /* OpenSkyClient.swift */,
|
||||||
|
LV3300003333000033330002 /* AircraftRegistry.swift */,
|
||||||
);
|
);
|
||||||
path = Services;
|
path = Services;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -235,6 +252,7 @@
|
|||||||
BB1100001111000011110002 /* FlightLoad.swift */,
|
BB1100001111000011110002 /* FlightLoad.swift */,
|
||||||
RE1100001111000011110002 /* RouteExplorerModels.swift */,
|
RE1100001111000011110002 /* RouteExplorerModels.swift */,
|
||||||
RE8800008888000088880002 /* SearchRoute.swift */,
|
RE8800008888000088880002 /* SearchRoute.swift */,
|
||||||
|
LV1100001111000011110002 /* LiveAircraft.swift */,
|
||||||
);
|
);
|
||||||
path = Models;
|
path = Models;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -366,6 +384,12 @@
|
|||||||
RE6600006666000066660001 /* ConnectionRow.swift in Sources */,
|
RE6600006666000066660001 /* ConnectionRow.swift in Sources */,
|
||||||
RE7700007777000077770001 /* ConnectionLoadDetailView.swift in Sources */,
|
RE7700007777000077770001 /* ConnectionLoadDetailView.swift in Sources */,
|
||||||
RE8800008888000088880001 /* SearchRoute.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;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ struct FlightsApp: App {
|
|||||||
let database: AirportDatabase
|
let database: AirportDatabase
|
||||||
let loadService: AirlineLoadService
|
let loadService: AirlineLoadService
|
||||||
let routeExplorer = RouteExplorerClient()
|
let routeExplorer = RouteExplorerClient()
|
||||||
|
let openSky = OpenSkyClient()
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let db = AirportDatabase()
|
let db = AirportDatabase()
|
||||||
@@ -14,10 +15,11 @@ struct FlightsApp: App {
|
|||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
RoutePlannerView(
|
RootView(
|
||||||
database: database,
|
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