803c812f86
Adds the deferred pieces from the v1 ship, plus a Mail Share
Extension target so the iOS share sheet picks up flight emails.
Track replay
- `LoggedFlight.icao24` field — populated from FR24 enrichment on
live-tap adds.
- HistoryDetailView's track query now fires for any flight younger
than 7 days that has an icao24, pulling the actual flown path
from OpenSky's /tracks/all endpoint. Falls back to a clean
great-circle arc otherwise.
Wallet auto-prompt
- RootView subscribes to WalletPassObserver.shared. When the user
adds a boarding pass to Apple Wallet, the observer's published
`pendingPass` flips and we present AddFlightView pre-filled with
the parsed origin / destination / flight # / date.
Airframe age + first-flight date
- `AirframeMetadataService` queries OpenSky's
/api/metadata/aircraft/icao/{icao24} endpoint. Caches results in
the existing `AirframeMetadata` SwiftData model so we never
re-fetch the same airframe twice. (jetphotos and planespotters
pages are both Cloudflare-gated; OpenSky's metadata API is the
cleanest free source.)
- HistoryDetailView fires the lookup on appear and persists the
result; the aircraft card already renders "Age" when a date is
cached.
Mail Share Extension
- New `FlightsShareExtension` Xcode target (app-extension product
type) built into the app bundle via an Embed Foundation
Extensions copy phase.
- `ShareViewController` (SLComposeServiceViewController) parses
shared text + URLs for flight-shaped codes ("AA 2178"), route
hints ("DFW → ORD"), and date strings.
- On Save, the extension builds a `flights://import?carrier=…&num=
…&dep=…&arr=…&date=…` URL and opens it via the responder-chain
openURL trick (Share Extensions can't access UIApplication
directly).
- Host app handles the URL via `.onOpenURL` in RootView, switches
to the History tab and presents AddFlightView prefilled.
- App now has an actual Info.plist (CFBundleURLTypes registered
for `flights://`); switched from GENERATE_INFOPLIST_FILE to
INFOPLIST_FILE for the app target.
If the dev portal hasn't registered bundle id
`com.flights.app.share` for the team, the signed archive will
fail. In that case the simpler URL-scheme path still works —
users can hit `flights://import?...` from a Shortcut.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
739 lines
29 KiB
Swift
739 lines
29 KiB
Swift
import SwiftUI
|
|
import SwiftData
|
|
import CoreLocation
|
|
|
|
struct LiveFlightDetailSheet: View {
|
|
let aircraft: LiveAircraft
|
|
let openSky: OpenSkyClient
|
|
let routeExplorer: RouteExplorerClient
|
|
let database: AirportDatabase
|
|
|
|
@State private var recentFlights: [OpenSkyFlight] = []
|
|
@State private var isLoadingRoute = false
|
|
@State private var aircraftPhoto: AircraftPhotoService.Photo?
|
|
@State private var showingAddToHistory = 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 {
|
|
/// Inline data straight off the FR24 feed (departure + arrival
|
|
/// IATA, plus the flight number). This is the fast path — no
|
|
/// extra network call required because FR24 already gave us
|
|
/// these in the feed.js response.
|
|
case fromFR24(departureIATA: String?, arrivalIATA: String?, flightIATA: String?)
|
|
/// Real schedule match from route-explorer. Best fidelity for
|
|
/// flights that aren't FR24-sourced.
|
|
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(\.openURL) private var openURL
|
|
@Environment(\.modelContext) private var modelContext
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
header
|
|
|
|
// Depart → Arrival sits directly under the callsign
|
|
// header — it's the single most important thing the
|
|
// user opened the sheet to see.
|
|
routeSection
|
|
|
|
addToHistoryButton
|
|
|
|
// Aircraft photo follows the route. Negative
|
|
// horizontal padding lets the photo break out of the
|
|
// 16pt content padding to be full-bleed edge-to-edge.
|
|
photoBanner
|
|
.padding(.horizontal, -16)
|
|
|
|
if let photo = aircraftPhoto, let credit = photo.photographer {
|
|
photoCredit(name: credit, link: photo.detailLink)
|
|
}
|
|
|
|
Divider()
|
|
|
|
Text("LIVE STATE")
|
|
.font(FlightTheme.label())
|
|
.foregroundStyle(FlightTheme.textTertiary)
|
|
.tracking(1)
|
|
|
|
liveStateGrid
|
|
|
|
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 resolveRoute()
|
|
}
|
|
.task(id: aircraft.icao24) {
|
|
aircraftPhoto = await AircraftPhotoService.shared.photo(
|
|
registration: aircraft.enrichment?.registration,
|
|
icao24: aircraft.icao24
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 }
|
|
|
|
// 0) Fast path: FR24's feed gave us departure + arrival inline.
|
|
// This is the common case now that FR24 is the primary feed.
|
|
// Skips the route-explorer network call entirely.
|
|
if let e = aircraft.enrichment,
|
|
e.departureIATA != nil || e.arrivalIATA != nil {
|
|
resolvedRoute = .fromFR24(
|
|
departureIATA: e.departureIATA,
|
|
arrivalIATA: e.arrivalIATA,
|
|
flightIATA: e.flightIATA
|
|
)
|
|
// Fire OpenSky history in the background for "recent flights".
|
|
recentFlights = await openSky.recentFlights(icao24: aircraft.icao24)
|
|
return
|
|
}
|
|
|
|
// 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: - Add-to-history button
|
|
//
|
|
// Lives between the route card and the photo banner — the natural
|
|
// "I'm on this plane right now, save it" spot. Opens AddFlightView
|
|
// pre-populated from FR24 enrichment so the user can confirm any
|
|
// detail before it lands in their log.
|
|
|
|
private var addToHistoryButton: some View {
|
|
Button {
|
|
showingAddToHistory = true
|
|
} label: {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "plus.circle.fill")
|
|
Text("Add to my flights")
|
|
.font(.subheadline.weight(.semibold))
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 12)
|
|
}
|
|
.background(FlightTheme.accent, in: RoundedRectangle(cornerRadius: 12))
|
|
.foregroundStyle(.white)
|
|
.sheet(isPresented: $showingAddToHistory) {
|
|
let store = FlightHistoryStore(context: modelContext, airportDatabase: database)
|
|
AddFlightView(
|
|
routeExplorer: routeExplorer,
|
|
database: database,
|
|
store: store,
|
|
prefill: AddFlightView.Prefill(
|
|
flightDate: Date(),
|
|
carrierICAO: aircraft.airlineICAO,
|
|
carrierIATA: AircraftRegistry.shared.lookup(icao: aircraft.airlineICAO)?.iata,
|
|
flightNumber: aircraft.flightNumber,
|
|
departureIATA: aircraft.enrichment?.departureIATA,
|
|
arrivalIATA: aircraft.enrichment?.arrivalIATA,
|
|
scheduledDeparture: nil,
|
|
scheduledArrival: nil,
|
|
aircraftType: aircraft.typeCode,
|
|
registration: aircraft.enrichment?.registration,
|
|
icao24: aircraft.icao24,
|
|
source: "live-tap"
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Photo banner
|
|
//
|
|
// Hero image at the very top of the sheet, sourced from planespotters.
|
|
// Hides itself entirely when no photo is available (lots of GA / cargo
|
|
// / older airframes have no public photo). Most recent photo per
|
|
// airframe is what planespotters serves — which means special
|
|
// liveries surface naturally because photographers chase them first.
|
|
|
|
@ViewBuilder
|
|
private var photoBanner: some View {
|
|
if let photo = aircraftPhoto {
|
|
AsyncImage(url: photo.largeURL) { phase in
|
|
switch phase {
|
|
case .success(let img):
|
|
img.resizable().aspectRatio(contentMode: .fill)
|
|
case .empty, .failure:
|
|
Rectangle().fill(FlightTheme.cardBackground)
|
|
@unknown default:
|
|
Rectangle().fill(FlightTheme.cardBackground)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 200)
|
|
.clipped()
|
|
}
|
|
}
|
|
|
|
private func photoCredit(name: String, link: URL?) -> some View {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "camera.fill")
|
|
.font(.caption2)
|
|
Text("Photo by \(name) · planespotters.net")
|
|
.font(.caption2)
|
|
}
|
|
.foregroundStyle(FlightTheme.textTertiary)
|
|
.padding(.top, 4)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
if let link { openURL(link) }
|
|
}
|
|
}
|
|
|
|
// MARK: - Header
|
|
|
|
private var header: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack(spacing: 10) {
|
|
if let logoURL = airlineEntry?.logoURL {
|
|
AsyncImage(url: logoURL) { phase in
|
|
switch phase {
|
|
case .success(let img):
|
|
img.resizable().scaledToFit()
|
|
default:
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(FlightTheme.accent.opacity(0.2))
|
|
}
|
|
}
|
|
.frame(width: 40, height: 40)
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
}
|
|
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
|
|
//
|
|
// OpenSky's /flights/aircraft endpoint only returns *landed* flights, so
|
|
// for a plane currently in flight we usually get nothing relevant — the
|
|
// most recent entry would be its previous leg (often from yesterday).
|
|
//
|
|
// To still show something useful, we display whatever's most recent and
|
|
// label it clearly: "In flight" if the snapshot is fresh, "Last flight"
|
|
// if it's older, "—" only when we genuinely have nothing.
|
|
|
|
@ViewBuilder
|
|
private var routeSection: some View {
|
|
if let resolved = resolvedRoute {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack(spacing: 8) {
|
|
Text(headerLabel(for: resolved))
|
|
.font(FlightTheme.label())
|
|
.foregroundStyle(FlightTheme.textTertiary)
|
|
.tracking(1)
|
|
if case .scheduled = resolved {
|
|
Circle()
|
|
.fill(FlightTheme.onTime)
|
|
.frame(width: 6, height: 6)
|
|
} else if case .fromFR24 = resolved {
|
|
Circle()
|
|
.fill(FlightTheme.onTime)
|
|
.frame(width: 6, height: 6)
|
|
}
|
|
}
|
|
resolvedRouteCard(resolved)
|
|
}
|
|
} else if isLoadingRoute {
|
|
HStack {
|
|
ProgressView()
|
|
Text("Resolving route…")
|
|
.font(.caption)
|
|
.foregroundStyle(FlightTheme.textSecondary)
|
|
}
|
|
} else {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("ROUTE")
|
|
.font(FlightTheme.label())
|
|
.foregroundStyle(FlightTheme.textTertiary)
|
|
.tracking(1)
|
|
Text("Route unavailable. This aircraft isn't in any schedule source we cover (callsign \(aircraft.trimmedCallsign ?? aircraft.icao24)).")
|
|
.font(.caption)
|
|
.foregroundStyle(FlightTheme.textSecondary)
|
|
.padding(12)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(FlightTheme.cardBackground, in: RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
}
|
|
}
|
|
|
|
private func headerLabel(for r: ResolvedRoute) -> String {
|
|
switch r {
|
|
case .fromFR24:
|
|
return aircraft.onGround ? "FLIGHT (ON GROUND)" : "IN FLIGHT"
|
|
case .scheduled:
|
|
return aircraft.onGround ? "FLIGHT (ON GROUND)" : "IN FLIGHT"
|
|
case .fromOpenSky(_, let hoursAgo):
|
|
if hoursAgo < 1 { return "LAST FLIGHT · JUST LANDED" }
|
|
if hoursAgo < 24 { return "LAST FLIGHT · \(hoursAgo)h AGO" }
|
|
return "LAST FLIGHT · \(hoursAgo / 24)d AGO"
|
|
case .inferred:
|
|
return aircraft.onGround ? "ON GROUND" : "IN FLIGHT (INFERRED)"
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func resolvedRouteCard(_ r: ResolvedRoute) -> some View {
|
|
switch r {
|
|
case .fromFR24(let dep, let arr, _):
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack(spacing: 16) {
|
|
fr24Endpoint(
|
|
iata: dep,
|
|
label: aircraft.onGround ? "From" : "Departed",
|
|
emptyText: "Unknown"
|
|
)
|
|
Image(systemName: "airplane")
|
|
.font(.title3)
|
|
.foregroundStyle(FlightTheme.accent)
|
|
.rotationEffect(.degrees(-45))
|
|
fr24Endpoint(
|
|
iata: arr,
|
|
label: aircraft.onGround ? "To" : "Heading to",
|
|
emptyText: "Unknown"
|
|
)
|
|
}
|
|
}
|
|
.flightCard()
|
|
|
|
case .scheduled(let f):
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack(spacing: 16) {
|
|
routeEndpoint(
|
|
code: f.departure.airportIata,
|
|
label: aircraft.onGround ? "From" : "Departed",
|
|
time: f.departure.dateTime
|
|
)
|
|
Image(systemName: "airplane")
|
|
.font(.title3)
|
|
.foregroundStyle(FlightTheme.accent)
|
|
.rotationEffect(.degrees(-45))
|
|
routeEndpoint(
|
|
code: f.arrival.airportIata,
|
|
label: aircraft.onGround ? "To" : "Heading to",
|
|
time: f.arrival.dateTime
|
|
)
|
|
}
|
|
}
|
|
.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()
|
|
}
|
|
}
|
|
|
|
/// Endpoint cell for FR24-sourced routes. Different from
|
|
/// `routeEndpoint` because FR24 gives us IATA codes directly (no
|
|
/// ICAO-to-IATA conversion) and no scheduled time.
|
|
private func fr24Endpoint(iata: String?, label: String, emptyText: String) -> some View {
|
|
let code = iata ?? ""
|
|
let cityName: String = {
|
|
guard let iata, let m = database.airport(byIATA: iata) else { return "" }
|
|
return m.name
|
|
}()
|
|
return VStack(alignment: .leading, spacing: 2) {
|
|
Text(label)
|
|
.font(.caption2)
|
|
.foregroundStyle(FlightTheme.textTertiary)
|
|
.tracking(0.5)
|
|
Text(code.isEmpty ? "—" : code)
|
|
.font(FlightTheme.airportCode(28))
|
|
.foregroundStyle(code.isEmpty ? FlightTheme.textTertiary : FlightTheme.textPrimary)
|
|
if !cityName.isEmpty {
|
|
Text(cityName)
|
|
.font(.caption2)
|
|
.foregroundStyle(FlightTheme.textSecondary)
|
|
.lineLimit(1)
|
|
} else if code.isEmpty {
|
|
Text(emptyText)
|
|
.font(.caption2)
|
|
.foregroundStyle(FlightTheme.textSecondary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
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 airlineEntry: AircraftRegistry.Entry? {
|
|
AircraftRegistry.shared.lookup(icao: aircraft.airlineICAO)
|
|
}
|
|
|
|
private var airlineDisplayName: String {
|
|
airlineEntry?.name
|
|
?? aircraft.airlineICAO?.uppercased()
|
|
?? "Unknown"
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|