diff --git a/Flights/Views/HistoryDetailView.swift b/Flights/Views/HistoryDetailView.swift index 34511ed..63b5141 100644 --- a/Flights/Views/HistoryDetailView.swift +++ b/Flights/Views/HistoryDetailView.swift @@ -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) + } + .annotationTitles(.hidden) } - if let arr = database.airport(byIATA: arrivalIATA) { - Marker("To " + arrivalIATA, systemImage: "airplane.arrival", coordinate: arr.coordinate) - .tint(HistoryStyle.runwayOrange) + 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 diff --git a/Flights/Views/HistoryView.swift b/Flights/Views/HistoryView.swift index ea9602b..a9f9c9f 100644 --- a/Flights/Views/HistoryView.swift +++ b/Flights/Views/HistoryView.swift @@ -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)