Live tab: resolve dep/arr for every aircraft via schedule cascade
OpenSky alone can't answer "where is this plane going" — /flights/aircraft
only returns landed flights, so an in-progress flight produced an empty
route card. Built a 3-tier resolution cascade that always lands somewhere
useful:
1) Scheduled lookup via route-explorer's /schedule endpoint. Parse the
ADS-B callsign (AAL3055 → carrier AA, number 3055), pull the day's
operating record, get real departure + arrival airports and times.
Works for every carrier route-explorer indexes (mainline + many
regionals). Smoke-tested live: AAL3055 → DFW→MIA, DAL1050 → IAH→MSP,
AAL1753 → XNA→DFW, AAL2978 → XNA→PHL. All from live aircraft caught
in the DFW area at the moment.
2) OpenSky historical (/flights/aircraft). If route-explorer doesn't
have the carrier, fall back to whatever OpenSky last logged for
this airframe. Labeled "LAST FLIGHT · 3h AGO" etc.
3) Trail-derived inference. Last resort for ICAO24s nothing knows about
(private jets, cargo, ad-hoc callsigns). Pull the OpenSky track,
take the first position, find the nearest airport in the bundled
3,900-entry DB (new AirportDatabase.nearestAirport(to:)). Shows
"Departed from KAUS — Austin Bergstrom" with "Heading to —"
acknowledging arrival is unknown.
Plumbed RouteExplorerClient through LiveFlightsView → RootView →
FlightsApp. Added searchSchedule(carrierCode:flightNumber:startDate:
endDate:) that returns [RouteFlight] directly (the /schedule envelope
is `{ flights: [...] }`, distinct from /route's `{ connections: [...] }`).
Three distinct route cards now render based on what we resolved:
- scheduled → green live dot + "Departed / Heading to"
- openSky → "Departed / Arrived" with age label
- inferred → "Departed from X / Heading to —"
- none → explicit "Route unavailable" message
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -155,6 +155,17 @@ struct RouteAppendixEquipment: Decodable, Sendable {
|
|||||||
|
|
||||||
// MARK: - Search result
|
// MARK: - Search result
|
||||||
|
|
||||||
|
/// Response from `/schedule` — flat list of operating records for one
|
||||||
|
/// (carrier, flightNumber, date). Different envelope from `/route` /
|
||||||
|
/// `/departures` which return nested `connections[]`.
|
||||||
|
struct RouteExplorerScheduleResponse: Decodable, Sendable {
|
||||||
|
let json: Body
|
||||||
|
struct Body: Decodable, Sendable {
|
||||||
|
let flights: [RouteFlight]
|
||||||
|
let appendix: RouteAppendix?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct RouteSearchResult: Sendable {
|
struct RouteSearchResult: Sendable {
|
||||||
let connections: [RouteConnection]
|
let connections: [RouteConnection]
|
||||||
let appendix: RouteAppendix?
|
let appendix: RouteAppendix?
|
||||||
|
|||||||
@@ -79,6 +79,28 @@ final class AirportDatabase: Sendable {
|
|||||||
airports.first { $0.iata == code }
|
airports.first { $0.iata == code }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return the airport closest to a given coordinate, optionally
|
||||||
|
/// within a max distance. Linear scan — O(n) with ~3,900 airports,
|
||||||
|
/// fast enough on the main thread for tap-then-lookup flows.
|
||||||
|
func nearestAirport(to coordinate: CLLocationCoordinate2D, maxMiles: Double = 25) -> MapAirport? {
|
||||||
|
guard !airports.isEmpty else { return nil }
|
||||||
|
var bestAirport: MapAirport?
|
||||||
|
var bestDistSq: Double = .greatestFiniteMagnitude
|
||||||
|
// Convert max miles to (degrees lat)² in a rough planar sense — good
|
||||||
|
// enough for "nearest airport" filtering. ~1 degree lat ≈ 69 miles.
|
||||||
|
let cutoffSq = (maxMiles / 69.0) * (maxMiles / 69.0)
|
||||||
|
for ap in airports {
|
||||||
|
let dLat = ap.lat - coordinate.latitude
|
||||||
|
let dLng = ap.lng - coordinate.longitude
|
||||||
|
let dSq = dLat * dLat + dLng * dLng
|
||||||
|
if dSq < bestDistSq {
|
||||||
|
bestDistSq = dSq
|
||||||
|
bestAirport = ap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestDistSq <= cutoffSq ? bestAirport : nil
|
||||||
|
}
|
||||||
|
|
||||||
private static func buildRegionNames() -> [String: String] {
|
private static func buildRegionNames() -> [String: String] {
|
||||||
// US states + territories
|
// US states + territories
|
||||||
var names: [String: String] = [
|
var names: [String: String] = [
|
||||||
|
|||||||
@@ -85,6 +85,59 @@ actor RouteExplorerClient {
|
|||||||
return try await callFlightSearch(endpoint: "/route", json: payload)
|
return try await callFlightSearch(endpoint: "/route", json: payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Schedule lookup for a specific flight number across a date range.
|
||||||
|
/// Powers the live-flight detail sheet — given an ICAO callsign like
|
||||||
|
/// `AAL1234` we resolve it to `(carrier: "AA", flightNumber: 1234)` and
|
||||||
|
/// pull the operating record, which carries real departure + arrival
|
||||||
|
/// airports and times.
|
||||||
|
///
|
||||||
|
/// Returns `nil` if the carrier/flight isn't in route-explorer's
|
||||||
|
/// schedule feed (typical for regional codeshares, charter ops, and
|
||||||
|
/// carriers the upstream platform doesn't index).
|
||||||
|
func searchSchedule(
|
||||||
|
carrierCode: String,
|
||||||
|
flightNumber: Int,
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date? = nil
|
||||||
|
) async -> [RouteFlight] {
|
||||||
|
let startStr = dateFormatter.string(from: startDate)
|
||||||
|
let endStr = dateFormatter.string(from: endDate ?? startDate)
|
||||||
|
let payload: [String: Any] = [
|
||||||
|
"carrierCode": carrierCode.uppercased(),
|
||||||
|
"flightNumber": flightNumber,
|
||||||
|
"startDate": startStr,
|
||||||
|
"endDate": endStr,
|
||||||
|
"limit": 20,
|
||||||
|
"includeAppendix": true
|
||||||
|
]
|
||||||
|
do {
|
||||||
|
let token = try await currentToken()
|
||||||
|
let url = baseURL.appendingPathComponent("api/flight-search")
|
||||||
|
let body = try JSONSerialization.data(withJSONObject: [
|
||||||
|
"endpoint": "/schedule",
|
||||||
|
"body": ["json": payload]
|
||||||
|
])
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.httpMethod = "POST"
|
||||||
|
Self.applyBrowserHeaders(to: &req)
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
req.setValue(token, forHTTPHeaderField: "X-API-Token")
|
||||||
|
req.httpBody = body
|
||||||
|
|
||||||
|
let (data, response) = try await session.data(for: req)
|
||||||
|
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||||
|
guard (200...299).contains(status) else { return [] }
|
||||||
|
|
||||||
|
let decoded = try JSONDecoder.routeExplorer().decode(
|
||||||
|
RouteExplorerScheduleResponse.self, from: data
|
||||||
|
)
|
||||||
|
return decoded.json.flights
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// All departures from an airport on a date. We filter by time window
|
/// All departures from an airport on a date. We filter by time window
|
||||||
/// client-side because the upstream endpoint doesn't accept one.
|
/// client-side because the upstream endpoint doesn't accept one.
|
||||||
func searchDepartures(
|
func searchDepartures(
|
||||||
|
|||||||
@@ -4,11 +4,27 @@ import CoreLocation
|
|||||||
struct LiveFlightDetailSheet: View {
|
struct LiveFlightDetailSheet: View {
|
||||||
let aircraft: LiveAircraft
|
let aircraft: LiveAircraft
|
||||||
let openSky: OpenSkyClient
|
let openSky: OpenSkyClient
|
||||||
|
let routeExplorer: RouteExplorerClient
|
||||||
let database: AirportDatabase
|
let database: AirportDatabase
|
||||||
|
|
||||||
@State private var recentFlights: [OpenSkyFlight] = []
|
@State private var recentFlights: [OpenSkyFlight] = []
|
||||||
@State private var isLoadingRoute = false
|
@State private var isLoadingRoute = false
|
||||||
|
|
||||||
|
/// The resolved route for the current selection. Built from a cascade:
|
||||||
|
/// scheduled flight (via route-explorer) → OpenSky history → trail-based
|
||||||
|
/// nearest-airport inference. See `resolveRoute()`.
|
||||||
|
@State private var resolvedRoute: ResolvedRoute?
|
||||||
|
|
||||||
|
enum ResolvedRoute {
|
||||||
|
/// Real schedule match from route-explorer. Best fidelity.
|
||||||
|
case scheduled(RouteFlight)
|
||||||
|
/// OpenSky historical flight. departure + arrival from FAA tracking.
|
||||||
|
case fromOpenSky(OpenSkyFlight, ageHours: Int)
|
||||||
|
/// Couldn't find a flight record — inferred departure from the
|
||||||
|
/// aircraft's trail start. Arrival unknown.
|
||||||
|
case inferred(departureIATA: String, departureName: String?)
|
||||||
|
}
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -62,11 +78,94 @@ struct LiveFlightDetailSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
await loadRoute()
|
await resolveRoute()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Route resolution
|
||||||
|
|
||||||
|
/// Cascade to find departure + arrival, in order of fidelity:
|
||||||
|
/// 1. Scheduled lookup via route-explorer (carrier + flight number)
|
||||||
|
/// — this is the only path that gives a real arrival airport for
|
||||||
|
/// an in-progress flight.
|
||||||
|
/// 2. OpenSky `/flights/aircraft` — only landed flights; useful if
|
||||||
|
/// the aircraft just landed or for "last flight" context.
|
||||||
|
/// 3. Trail-based inference — first point of the OpenSky `/tracks/all`
|
||||||
|
/// response → nearest airport in our local DB → "Departed from X".
|
||||||
|
private func resolveRoute() async {
|
||||||
|
isLoadingRoute = true
|
||||||
|
defer { isLoadingRoute = false }
|
||||||
|
|
||||||
|
// 1) Try the scheduled lookup if we have a parseable airline + flight.
|
||||||
|
if let scheduled = await tryScheduledLookup() {
|
||||||
|
resolvedRoute = .scheduled(scheduled)
|
||||||
|
// Still fetch OpenSky history in the background for the "recent
|
||||||
|
// flights" list — but we don't need to await it.
|
||||||
|
recentFlights = await openSky.recentFlights(icao24: aircraft.icao24)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Fall back to OpenSky history.
|
||||||
|
let flights = await openSky.recentFlights(icao24: aircraft.icao24)
|
||||||
|
recentFlights = flights
|
||||||
|
if let mostRecent = flights.first {
|
||||||
|
let hoursAgo = max(0, Int(
|
||||||
|
(Date().timeIntervalSince1970 - Double(mostRecent.lastSeen)) / 3600
|
||||||
|
))
|
||||||
|
resolvedRoute = .fromOpenSky(mostRecent, ageHours: hoursAgo)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Last resort: nearest-airport from the trail start.
|
||||||
|
if let track = await openSky.track(icao24: aircraft.icao24),
|
||||||
|
let firstPoint = track.path.first {
|
||||||
|
let coord = CLLocationCoordinate2D(
|
||||||
|
latitude: firstPoint.latitude,
|
||||||
|
longitude: firstPoint.longitude
|
||||||
|
)
|
||||||
|
if let nearest = database.nearestAirport(to: coord, maxMiles: 30) {
|
||||||
|
resolvedRoute = .inferred(
|
||||||
|
departureIATA: nearest.iata,
|
||||||
|
departureName: nearest.name
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolvedRoute = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the ADS-B callsign (e.g. "AAL1234") into carrier + flight number
|
||||||
|
/// and hit route-explorer's `/schedule` endpoint. Returns the day's
|
||||||
|
/// operating record if route-explorer indexes the carrier; nil otherwise.
|
||||||
|
private func tryScheduledLookup() async -> RouteFlight? {
|
||||||
|
guard let icao = aircraft.airlineICAO,
|
||||||
|
let airline = AircraftRegistry.shared.lookup(icao: icao),
|
||||||
|
let iata = airline.iata,
|
||||||
|
let flightStr = aircraft.flightNumber,
|
||||||
|
let flightNum = Int(flightStr)
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
// Look across today ± yesterday — handles late-running redeyes that
|
||||||
|
// departed on D-1 but are still airborne on D.
|
||||||
|
let today = Date()
|
||||||
|
let yesterday = today.addingTimeInterval(-86400)
|
||||||
|
let results = await routeExplorer.searchSchedule(
|
||||||
|
carrierCode: iata,
|
||||||
|
flightNumber: flightNum,
|
||||||
|
startDate: yesterday,
|
||||||
|
endDate: today
|
||||||
|
)
|
||||||
|
guard !results.isEmpty else { return nil }
|
||||||
|
|
||||||
|
// Pick the entry whose departure is closest to "now" (typically
|
||||||
|
// the in-progress flight).
|
||||||
|
return results.min { a, b in
|
||||||
|
abs(a.departure.dateTime.timeIntervalSinceNow) <
|
||||||
|
abs(b.departure.dateTime.timeIntervalSinceNow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Header
|
// MARK: - Header
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
@@ -173,46 +272,37 @@ struct LiveFlightDetailSheet: View {
|
|||||||
// label it clearly: "In flight" if the snapshot is fresh, "Last flight"
|
// label it clearly: "In flight" if the snapshot is fresh, "Last flight"
|
||||||
// if it's older, "—" only when we genuinely have nothing.
|
// if it's older, "—" only when we genuinely have nothing.
|
||||||
|
|
||||||
private enum RouteStatus { case loading, inFlight, recent(hoursAgo: Int), none }
|
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var routeSection: some View {
|
private var routeSection: some View {
|
||||||
if isLoadingRoute && recentFlights.isEmpty {
|
if let resolved = resolvedRoute {
|
||||||
HStack {
|
|
||||||
ProgressView()
|
|
||||||
Text("Looking up route…")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(FlightTheme.textSecondary)
|
|
||||||
}
|
|
||||||
} else if let mostRecent = recentFlights.first {
|
|
||||||
let now = Date().timeIntervalSince1970
|
|
||||||
let hoursAgo = max(0, Int((now - Double(mostRecent.lastSeen)) / 3600))
|
|
||||||
let status: RouteStatus = {
|
|
||||||
if aircraft.onGround { return .recent(hoursAgo: hoursAgo) }
|
|
||||||
if hoursAgo < 6 { return .inFlight }
|
|
||||||
return .recent(hoursAgo: hoursAgo)
|
|
||||||
}()
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Text(routeHeader(status))
|
Text(headerLabel(for: resolved))
|
||||||
.font(FlightTheme.label())
|
.font(FlightTheme.label())
|
||||||
.foregroundStyle(FlightTheme.textTertiary)
|
.foregroundStyle(FlightTheme.textTertiary)
|
||||||
.tracking(1)
|
.tracking(1)
|
||||||
if case .inFlight = status {
|
if case .scheduled = resolved {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(FlightTheme.onTime)
|
.fill(FlightTheme.onTime)
|
||||||
.frame(width: 6, height: 6)
|
.frame(width: 6, height: 6)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
routeCard(mostRecent, status: status)
|
resolvedRouteCard(resolved)
|
||||||
|
}
|
||||||
|
} else if isLoadingRoute {
|
||||||
|
HStack {
|
||||||
|
ProgressView()
|
||||||
|
Text("Resolving route…")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(FlightTheme.textSecondary)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("THIS FLIGHT")
|
Text("ROUTE")
|
||||||
.font(FlightTheme.label())
|
.font(FlightTheme.label())
|
||||||
.foregroundStyle(FlightTheme.textTertiary)
|
.foregroundStyle(FlightTheme.textTertiary)
|
||||||
.tracking(1)
|
.tracking(1)
|
||||||
Text("Route not available from OpenSky for this aircraft.")
|
Text("Route unavailable. This aircraft isn't in any schedule source we cover (callsign \(aircraft.trimmedCallsign ?? aircraft.icao24)).")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(FlightTheme.textSecondary)
|
.foregroundStyle(FlightTheme.textSecondary)
|
||||||
.padding(12)
|
.padding(12)
|
||||||
@@ -222,44 +312,105 @@ struct LiveFlightDetailSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func routeHeader(_ status: RouteStatus) -> String {
|
private func headerLabel(for r: ResolvedRoute) -> String {
|
||||||
switch status {
|
switch r {
|
||||||
case .loading: return "THIS FLIGHT"
|
case .scheduled:
|
||||||
case .inFlight: return "IN FLIGHT"
|
return aircraft.onGround ? "FLIGHT (ON GROUND)" : "IN FLIGHT"
|
||||||
case .recent(let hoursAgo):
|
case .fromOpenSky(_, let hoursAgo):
|
||||||
if hoursAgo < 1 { return "LAST FLIGHT · JUST LANDED" }
|
if hoursAgo < 1 { return "LAST FLIGHT · JUST LANDED" }
|
||||||
if hoursAgo < 24 { return "LAST FLIGHT · \(hoursAgo)h AGO" }
|
if hoursAgo < 24 { return "LAST FLIGHT · \(hoursAgo)h AGO" }
|
||||||
let days = hoursAgo / 24
|
return "LAST FLIGHT · \(hoursAgo / 24)d AGO"
|
||||||
return "LAST FLIGHT · \(days)d AGO"
|
case .inferred:
|
||||||
case .none: return "THIS FLIGHT"
|
return aircraft.onGround ? "ON GROUND" : "IN FLIGHT (INFERRED)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func routeCard(_ f: OpenSkyFlight, status: RouteStatus) -> some View {
|
@ViewBuilder
|
||||||
let depLabel: String
|
private func resolvedRouteCard(_ r: ResolvedRoute) -> some View {
|
||||||
let arrLabel: String
|
switch r {
|
||||||
switch status {
|
case .scheduled(let f):
|
||||||
case .inFlight:
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
depLabel = "Departed"
|
|
||||||
arrLabel = "Heading to"
|
|
||||||
case .recent:
|
|
||||||
depLabel = "Departed"
|
|
||||||
arrLabel = "Arrived"
|
|
||||||
case .loading, .none:
|
|
||||||
depLabel = "From"
|
|
||||||
arrLabel = "To"
|
|
||||||
}
|
|
||||||
return VStack(alignment: .leading, spacing: 12) {
|
|
||||||
HStack(spacing: 16) {
|
HStack(spacing: 16) {
|
||||||
routeEndpoint(code: f.estDepartureAirport, label: depLabel, time: f.departureDate)
|
routeEndpoint(
|
||||||
|
code: f.departure.airportIata,
|
||||||
|
label: aircraft.onGround ? "From" : "Departed",
|
||||||
|
time: f.departure.dateTime
|
||||||
|
)
|
||||||
Image(systemName: "airplane")
|
Image(systemName: "airplane")
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.foregroundStyle(FlightTheme.accent)
|
.foregroundStyle(FlightTheme.accent)
|
||||||
.rotationEffect(.degrees(-45))
|
.rotationEffect(.degrees(-45))
|
||||||
routeEndpoint(code: f.estArrivalAirport, label: arrLabel, time: f.arrivalDate)
|
routeEndpoint(
|
||||||
|
code: f.arrival.airportIata,
|
||||||
|
label: aircraft.onGround ? "To" : "Heading to",
|
||||||
|
time: f.arrival.dateTime
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.flightCard()
|
.flightCard()
|
||||||
|
|
||||||
|
case .fromOpenSky(let f, _):
|
||||||
|
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: "Arrived",
|
||||||
|
time: f.arrivalDate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.flightCard()
|
||||||
|
|
||||||
|
case .inferred(let depIata, let depName):
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Departed")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(FlightTheme.textTertiary)
|
||||||
|
.tracking(0.5)
|
||||||
|
Text(depIata)
|
||||||
|
.font(FlightTheme.airportCode(28))
|
||||||
|
.foregroundStyle(FlightTheme.textPrimary)
|
||||||
|
if let depName {
|
||||||
|
Text(depName)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(FlightTheme.textSecondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
Image(systemName: "airplane")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(FlightTheme.accent)
|
||||||
|
.rotationEffect(.degrees(-45))
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Heading to")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(FlightTheme.textTertiary)
|
||||||
|
.tracking(0.5)
|
||||||
|
Text("—")
|
||||||
|
.font(FlightTheme.airportCode(28))
|
||||||
|
.foregroundStyle(FlightTheme.textTertiary)
|
||||||
|
Text("Not in schedule data")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(FlightTheme.textSecondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.flightCard()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func routeEndpoint(code: String?, label: String, time: Date) -> some View {
|
private func routeEndpoint(code: String?, label: String, time: Date) -> some View {
|
||||||
@@ -352,13 +503,6 @@ struct LiveFlightDetailSheet: View {
|
|||||||
?? "Unknown"
|
?? "Unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
private func formatNumber(_ n: Int) -> String {
|
||||||
let f = NumberFormatter()
|
let f = NumberFormatter()
|
||||||
f.numberStyle = .decimal
|
f.numberStyle = .decimal
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import CoreLocation
|
|||||||
|
|
||||||
struct LiveFlightsView: View {
|
struct LiveFlightsView: View {
|
||||||
let openSky: OpenSkyClient
|
let openSky: OpenSkyClient
|
||||||
|
let routeExplorer: RouteExplorerClient
|
||||||
let database: AirportDatabase
|
let database: AirportDatabase
|
||||||
|
|
||||||
// MARK: - Map state
|
// MARK: - Map state
|
||||||
@@ -80,7 +81,12 @@ struct LiveFlightsView: View {
|
|||||||
.sheet(item: $activeSheet) { sheet in
|
.sheet(item: $activeSheet) { sheet in
|
||||||
switch sheet {
|
switch sheet {
|
||||||
case .aircraft(let ac):
|
case .aircraft(let ac):
|
||||||
LiveFlightDetailSheet(aircraft: ac, openSky: openSky, database: database)
|
LiveFlightDetailSheet(
|
||||||
|
aircraft: ac,
|
||||||
|
openSky: openSky,
|
||||||
|
routeExplorer: routeExplorer,
|
||||||
|
database: database
|
||||||
|
)
|
||||||
.presentationDetents([.medium, .large])
|
.presentationDetents([.medium, .large])
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
case .settings:
|
case .settings:
|
||||||
|
|||||||
@@ -27,7 +27,11 @@ struct RootView: View {
|
|||||||
.tag(Tab.search)
|
.tag(Tab.search)
|
||||||
|
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
LiveFlightsView(openSky: openSky, database: database)
|
LiveFlightsView(
|
||||||
|
openSky: openSky,
|
||||||
|
routeExplorer: routeExplorer,
|
||||||
|
database: database
|
||||||
|
)
|
||||||
.navigationTitle("Live Flights")
|
.navigationTitle("Live Flights")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user