Flight detail: bearing-aware icons + auto type lookup
Icons: - Route card's central airplane and the two map markers now rotate by the actual dep→arr compass bearing instead of a hardcoded -45°. DAL→SAN (westbound) shows planes pointing left; LAS→DAL (eastbound) points right; DAL→HOU (south) points down. New HistoryDetailView.bearing(from:to:) computes great-circle bearing; rotation is (bearing - 45) since SF's airplane glyph naturally sits at ~45°. - Replaced the FlightRouteMap's `Marker(systemImage:)` for departure and arrival with custom `Annotation` views (routeMarker helper) that wrap an SF airplane.departure/arrival glyph in a tinted circle and rotate the icon by the same bearing. Aircraft data lookup: - HistoryDetailView now auto-enriches the missing aircraftType on .task via the same two-step chain the bulk Aircraft Stats button uses: route-explorer first, FlightAware activity-log fallback, normalized through AircraftDatabase.normalizedICAO before saving. - Aircraft card's Type cell now shows the friendly name when available — "B738 · Boeing 737-800" instead of just "B738". - When neither registration nor icao24 is set (typical for CSV- imported historical flights), the card shows an honest one-line caption explaining why tail / first-flight / ICAO24 are blank and what populates them. Honest about what we can't get: - Tested OpenSky /flights/departure (anonymous tier blocks historical, 403 "You cannot access historical flights"), FlightAware /history/<date>/<time>Z/... (needs exact UTC scheduled departure time we don't have for CSV rows), JetPhotos and Planespotters flight search (Cloudflare-gated). For historical Southwest flights from a PNR-style CSV, the most we can pull for free is the typical aircraft type for that flight number from FlightAware's activity log — which we now do automatically when the detail view appears. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ struct HistoryDetailView: View {
|
||||
let store: FlightHistoryStore
|
||||
let database: AirportDatabase
|
||||
let openSky: OpenSkyClient
|
||||
var routeExplorer: RouteExplorerClient? = nil
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.openURL) private var openURL
|
||||
@@ -50,6 +51,7 @@ struct HistoryDetailView: View {
|
||||
}
|
||||
await loadTrackIfRecent()
|
||||
await loadAirframeMetadata()
|
||||
await enrichAircraftTypeIfMissing()
|
||||
}
|
||||
.alert("Delete this flight?", isPresented: $showDeleteConfirm) {
|
||||
Button("Delete", role: .destructive) {
|
||||
@@ -85,6 +87,26 @@ struct HistoryDetailView: View {
|
||||
?? flight.carrierIATA ?? "Unknown"
|
||||
}
|
||||
|
||||
/// Compass bearing from departure to arrival airport. Falls back to
|
||||
/// 90° (eastbound) when we can't resolve coordinates so the icon
|
||||
/// just stays in a sensible default.
|
||||
private var routeBearing: Double {
|
||||
guard let dep = database.airport(byIATA: flight.departureIATA),
|
||||
let arr = database.airport(byIATA: flight.arrivalIATA)
|
||||
else { return 90 }
|
||||
return Self.bearing(from: dep.coordinate, to: arr.coordinate)
|
||||
}
|
||||
|
||||
static func bearing(from a: CLLocationCoordinate2D, to b: CLLocationCoordinate2D) -> Double {
|
||||
let lat1 = a.latitude * .pi / 180
|
||||
let lat2 = b.latitude * .pi / 180
|
||||
let dLon = (b.longitude - a.longitude) * .pi / 180
|
||||
let y = sin(dLon) * cos(lat2)
|
||||
let x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon)
|
||||
let theta = atan2(y, x)
|
||||
return (theta * 180 / .pi + 360).truncatingRemainder(dividingBy: 360)
|
||||
}
|
||||
|
||||
// MARK: - Route
|
||||
|
||||
private var routeCard: some View {
|
||||
@@ -95,7 +117,10 @@ struct HistoryDetailView: View {
|
||||
Image(systemName: "airplane")
|
||||
.font(.system(size: 24, weight: .heavy))
|
||||
.foregroundStyle(HistoryStyle.runwayOrange)
|
||||
.rotationEffect(.degrees(-45))
|
||||
// SF airplane symbol naturally points up-right
|
||||
// (~45°). To align with the actual travel
|
||||
// bearing, rotate by (bearing - 45).
|
||||
.rotationEffect(.degrees(routeBearing - 45))
|
||||
Spacer()
|
||||
routeEndpoint(iata: flight.arrivalIATA, label: "To", time: flight.actualArrival ?? flight.scheduledArrival)
|
||||
}
|
||||
@@ -200,6 +225,54 @@ struct HistoryDetailView: View {
|
||||
track = await openSky.track(icao24: icao24)
|
||||
}
|
||||
|
||||
/// If the flight is missing its aircraft type, run the same
|
||||
/// two-step lookup the bulk enricher does: route-explorer for
|
||||
/// future flights, FlightAware activity-log scrape for historical.
|
||||
/// Result is normalized to ICAO and patched onto the LoggedFlight.
|
||||
/// No-op when type is already set.
|
||||
private func enrichAircraftTypeIfMissing() async {
|
||||
guard flight.aircraftType == nil || flight.aircraftType?.isEmpty == true else { return }
|
||||
guard let carrier = flight.carrierIATA,
|
||||
let numStr = flight.flightNumber,
|
||||
let num = Int(numStr)
|
||||
else { return }
|
||||
|
||||
// 1) route-explorer (works for future schedules)
|
||||
if let routeExplorer {
|
||||
let day = Calendar.current.startOfDay(for: flight.flightDate)
|
||||
let next = Calendar.current.date(byAdding: .day, value: 1, to: day) ?? day
|
||||
let results = await routeExplorer.searchSchedule(
|
||||
carrierCode: carrier,
|
||||
flightNumber: num,
|
||||
startDate: day,
|
||||
endDate: next
|
||||
)
|
||||
let exact = results.first {
|
||||
$0.departure.airportIata == flight.departureIATA
|
||||
&& $0.arrival.airportIata == flight.arrivalIATA
|
||||
} ?? results.first
|
||||
if let eq = exact?.equipmentIata, !eq.isEmpty {
|
||||
flight.aircraftType = AircraftDatabase.shared.normalizedICAO(forCode: eq)
|
||||
try? store.context.save()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 2) FlightAware activity-log fallback
|
||||
guard let carrierICAO = flight.carrierICAO
|
||||
?? AircraftRegistry.shared.lookup(iata: carrier)?.icao
|
||||
else { return }
|
||||
let callsign = "\(carrierICAO)\(num)"
|
||||
if let icaoType = await FlightAwareLookup.shared.lookupType(
|
||||
callsign: callsign,
|
||||
departureIATA: flight.departureIATA,
|
||||
arrivalIATA: flight.arrivalIATA
|
||||
) {
|
||||
flight.aircraftType = AircraftDatabase.shared.normalizedICAO(forCode: icaoType)
|
||||
try? store.context.save()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadAirframeMetadata() async {
|
||||
guard let reg = flight.registration, !reg.isEmpty,
|
||||
let icao24 = flight.icao24, !icao24.isEmpty
|
||||
@@ -226,12 +299,17 @@ struct HistoryDetailView: View {
|
||||
let airframe = flight.registration.flatMap(store.airframe(for:))
|
||||
let firstFlight = airframe?.firstFlightDate
|
||||
let ageYears = firstFlight.map { years(since: $0) }
|
||||
let typeDisplay: String = {
|
||||
guard let code = flight.aircraftType, !code.isEmpty else { return "—" }
|
||||
let friendly = AircraftDatabase.shared.displayName(forTypeCode: code)
|
||||
return friendly == code ? code : "\(code) · \(friendly)"
|
||||
}()
|
||||
|
||||
return VStack(alignment: .leading, spacing: 12) {
|
||||
HistorySectionLabel("Aircraft")
|
||||
VStack(spacing: 0) {
|
||||
aircraftRow(
|
||||
leftLabel: "Type", leftValue: flight.aircraftType ?? "—",
|
||||
leftLabel: "Type", leftValue: typeDisplay,
|
||||
rightLabel: "Tail #", rightValue: flight.registration ?? "—"
|
||||
)
|
||||
divider
|
||||
@@ -250,6 +328,17 @@ struct HistoryDetailView: View {
|
||||
)
|
||||
}
|
||||
.historyCard(scheme, padding: 0)
|
||||
|
||||
// Honest note: tail / icao24 / first-flight come from
|
||||
// OpenSky metadata which requires icao24 — only captured
|
||||
// when the flight is added via the Live tab. Historical
|
||||
// CSV imports won't have these.
|
||||
if flight.registration == nil && flight.icao24 == nil {
|
||||
Text("Tail #, first-flight date and ICAO24 aren't available for this flight — those come from the live ADS-B feed when you tap a plane on the Live tab. Aircraft type was looked up from FlightAware's recent activity for this flight number.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,14 +538,28 @@ private struct FlightRouteMap: View {
|
||||
let database: AirportDatabase
|
||||
|
||||
var body: some View {
|
||||
let dep = database.airport(byIATA: departureIATA)?.coordinate
|
||||
let arr = database.airport(byIATA: arrivalIATA)?.coordinate
|
||||
let bearing = (dep != nil && arr != nil)
|
||||
? HistoryDetailView.bearing(from: dep!, to: arr!)
|
||||
: 90
|
||||
|
||||
Map {
|
||||
if let dep = database.airport(byIATA: departureIATA) {
|
||||
Marker("From " + departureIATA, systemImage: "airplane.departure", coordinate: dep.coordinate)
|
||||
.tint(HistoryStyle.stampGreen)
|
||||
if let dep {
|
||||
Annotation("From " + departureIATA, coordinate: dep) {
|
||||
routeMarker(systemName: "airplane.departure",
|
||||
bearing: bearing,
|
||||
tint: HistoryStyle.stampGreen)
|
||||
}
|
||||
if let arr = database.airport(byIATA: arrivalIATA) {
|
||||
Marker("To " + arrivalIATA, systemImage: "airplane.arrival", coordinate: arr.coordinate)
|
||||
.tint(HistoryStyle.runwayOrange)
|
||||
.annotationTitles(.hidden)
|
||||
}
|
||||
if let arr {
|
||||
Annotation("To " + arrivalIATA, coordinate: arr) {
|
||||
routeMarker(systemName: "airplane.arrival",
|
||||
bearing: bearing,
|
||||
tint: HistoryStyle.runwayOrange)
|
||||
}
|
||||
.annotationTitles(.hidden)
|
||||
}
|
||||
if let track {
|
||||
let coords = track.path.map {
|
||||
@@ -472,6 +575,25 @@ private struct FlightRouteMap: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Map-pin chrome for an airport endpoint. Wraps the SF symbol
|
||||
/// in a circle background, rotates the icon so it points in the
|
||||
/// actual flight direction (so a DAL→SAN flight shows planes
|
||||
/// pointing left, not the default right).
|
||||
@ViewBuilder
|
||||
private func routeMarker(systemName: String, bearing: Double, tint: Color) -> some View {
|
||||
Image(systemName: systemName)
|
||||
.font(.system(size: 14, weight: .black))
|
||||
.foregroundStyle(.white)
|
||||
// SF airplane.departure/airplane.arrival glyphs orient
|
||||
// roughly up-right at ~45°; rotation matches the central
|
||||
// route icon's correction.
|
||||
.rotationEffect(.degrees(bearing - 45))
|
||||
.frame(width: 30, height: 30)
|
||||
.background(tint, in: Circle())
|
||||
.overlay(Circle().stroke(.white, lineWidth: 1.5))
|
||||
.shadow(color: .black.opacity(0.4), radius: 2, y: 1)
|
||||
}
|
||||
|
||||
private func greatCircle(from a: CLLocationCoordinate2D, to b: CLLocationCoordinate2D, segments: Int) -> [CLLocationCoordinate2D] {
|
||||
let lat1 = a.latitude * .pi / 180
|
||||
let lon1 = a.longitude * .pi / 180
|
||||
|
||||
@@ -398,7 +398,7 @@ struct HistoryView: View {
|
||||
}
|
||||
ForEach(group.flights) { f in
|
||||
NavigationLink {
|
||||
HistoryDetailView(flight: f, store: store, database: database, openSky: openSky)
|
||||
HistoryDetailView(flight: f, store: store, database: database, openSky: openSky, routeExplorer: routeExplorer)
|
||||
} label: {
|
||||
PassportFlightRow(flight: f, database: database)
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
Reference in New Issue
Block a user