From 888943deb4b4886a4dc0b876d5ba1bb024d31554 Mon Sep 17 00:00:00 2001 From: Trey T Date: Wed, 27 May 2026 06:08:58 -0500 Subject: [PATCH] Add Live Flights tab: real-time aircraft map with filters + tap detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Flights.xcodeproj/project.pbxproj | 24 ++ Flights/FlightsApp.swift | 8 +- Flights/Models/LiveAircraft.swift | 129 ++++++ Flights/Services/AircraftRegistry.swift | 117 ++++++ Flights/Services/OpenSkyClient.swift | 187 +++++++++ Flights/Views/LiveFlightDetailSheet.swift | 331 ++++++++++++++++ Flights/Views/LiveFlightsView.swift | 462 ++++++++++++++++++++++ Flights/Views/RootView.swift | 41 ++ 8 files changed, 1296 insertions(+), 3 deletions(-) create mode 100644 Flights/Models/LiveAircraft.swift create mode 100644 Flights/Services/AircraftRegistry.swift create mode 100644 Flights/Services/OpenSkyClient.swift create mode 100644 Flights/Views/LiveFlightDetailSheet.swift create mode 100644 Flights/Views/LiveFlightsView.swift create mode 100644 Flights/Views/RootView.swift diff --git a/Flights.xcodeproj/project.pbxproj b/Flights.xcodeproj/project.pbxproj index 83054c6..3930df8 100644 --- a/Flights.xcodeproj/project.pbxproj +++ b/Flights.xcodeproj/project.pbxproj @@ -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 = ""; }; T1000000000000000000001B /* AirlineLoadIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirlineLoadIntegrationTests.swift; sourceTree = ""; }; 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 = ""; }; + LV2200002222000022220002 /* OpenSkyClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenSkyClient.swift; sourceTree = ""; }; + LV3300003333000033330002 /* AircraftRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AircraftRegistry.swift; sourceTree = ""; }; + LV4400004444000044440002 /* LiveFlightsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveFlightsView.swift; sourceTree = ""; }; + LV5500005555000055550002 /* LiveFlightDetailSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveFlightDetailSheet.swift; sourceTree = ""; }; + LV6600006666000066660002 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; /* 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 = ""; @@ -235,6 +252,7 @@ BB1100001111000011110002 /* FlightLoad.swift */, RE1100001111000011110002 /* RouteExplorerModels.swift */, RE8800008888000088880002 /* SearchRoute.swift */, + LV1100001111000011110002 /* LiveAircraft.swift */, ); path = Models; sourceTree = ""; @@ -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; }; diff --git a/Flights/FlightsApp.swift b/Flights/FlightsApp.swift index 89be555..e053948 100644 --- a/Flights/FlightsApp.swift +++ b/Flights/FlightsApp.swift @@ -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 ) } } diff --git a/Flights/Models/LiveAircraft.swift b/Flights/Models/LiveAircraft.swift new file mode 100644 index 0000000..1811037 --- /dev/null +++ b/Flights/Models/LiveAircraft.swift @@ -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 + } +} diff --git a/Flights/Services/AircraftRegistry.swift b/Flights/Services/AircraftRegistry.swift new file mode 100644 index 0000000..83f07a7 --- /dev/null +++ b/Flights/Services/AircraftRegistry.swift @@ -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) + } +} diff --git a/Flights/Services/OpenSkyClient.swift b/Flights/Services/OpenSkyClient.swift new file mode 100644 index 0000000..4201c7c --- /dev/null +++ b/Flights/Services/OpenSkyClient.swift @@ -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)) + ) + } +} diff --git a/Flights/Views/LiveFlightDetailSheet.swift b/Flights/Views/LiveFlightDetailSheet.swift new file mode 100644 index 0000000..c9c47ca --- /dev/null +++ b/Flights/Views/LiveFlightDetailSheet.swift @@ -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 + } +} diff --git a/Flights/Views/LiveFlightsView.swift b/Flights/Views/LiveFlightsView.swift new file mode 100644 index 0000000..cc3271a --- /dev/null +++ b/Flights/Views/LiveFlightsView.swift @@ -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 = [] + @State private var selectedCategories: Set = [] + @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(_ set: inout Set, _ 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 helper + +private extension Binding where Value == LiveAircraft? { + /// Swift's Map(selection:) wants a Binding where Value is Hashable. + /// LiveAircraft is Hashable + Identifiable so this just forwards through. + var iconSelection: Binding { self } +} diff --git a/Flights/Views/RootView.swift b/Flights/Views/RootView.swift new file mode 100644 index 0000000..2445a0d --- /dev/null +++ b/Flights/Views/RootView.swift @@ -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) + } +}