History v2: everything — Wallet auto-prompt, age, track replay, share

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>
This commit is contained in:
Trey T
2026-05-27 09:51:30 -05:00
parent 8308d9cf03
commit 803c812f86
10 changed files with 656 additions and 28 deletions
+33 -14
View File
@@ -20,6 +20,9 @@ struct HistoryDetailView: View {
@State private var track: AircraftTrack?
@State private var editedNotes: String = ""
@State private var showDeleteConfirm = false
/// Re-render trigger after we upsert airframe metadata. SwiftData
/// changes don't auto-invalidate non-@Query views.
@State private var metadataLoaded = false
var body: some View {
ScrollView {
@@ -44,9 +47,10 @@ struct HistoryDetailView: View {
.task {
editedNotes = flight.notes ?? ""
if let reg = flight.registration {
photo = await AircraftPhotoService.shared.photo(registration: reg, icao24: "")
photo = await AircraftPhotoService.shared.photo(registration: reg, icao24: flight.icao24 ?? "")
}
await loadTrackIfRecent()
await loadAirframeMetadata()
}
.alert("Delete this flight?", isPresented: $showDeleteConfirm) {
Button("Delete", role: .destructive) {
@@ -186,23 +190,38 @@ struct HistoryDetailView: View {
}
private func loadTrackIfRecent() async {
// OpenSky's anonymous track endpoint goes back roughly 7 days
// before they trim history. Older logs get the great-circle
// fallback drawn by FlightRouteMap.
// OpenSky's anonymous track endpoint trims history after ~7
// days. Older logs get the great-circle fallback drawn by
// FlightRouteMap.
let ageDays = Date().timeIntervalSince(flight.flightDate) / 86400
guard ageDays < 7, let icao24 = guessICAO24() else { return }
guard ageDays < 7, let icao24 = flight.icao24, !icao24.isEmpty else { return }
track = await openSky.track(icao24: icao24)
}
/// We don't store icao24 on the LoggedFlight (we store registration
/// instead) but for track replay we need icao24. Future work: pull
/// regicao24 mapping from a fresh OpenSky lookup. For now, only the
/// most-recently-logged airframe gets a replay attempt.
private func guessICAO24() -> String? {
// TODO: tie this to a regicao24 resolution. For v1 the
// track replay only fires when icao24 is in notes or we
// resolve via aircraft DB.
return nil
/// Hit OpenSky's metadata endpoint for first-flight / built dates.
/// We persist the result so subsequent views of the same airframe
/// don't re-query the network. Best-effort many newer airframes
/// have no metadata yet.
private func loadAirframeMetadata() async {
guard let reg = flight.registration,
!reg.isEmpty,
let icao24 = flight.icao24,
!icao24.isEmpty
else { return }
// Skip if we already have a cached entry with at least one date.
if let cached = store.airframe(for: reg),
cached.firstFlightDate != nil || cached.deliveryDate != nil {
metadataLoaded.toggle()
return
}
if let meta = await AirframeMetadataService.shared.metadata(forICAO24: icao24) {
store.upsertAirframe(
registration: reg,
firstFlightDate: meta.firstFlightDate,
deliveryDate: meta.built
)
metadataLoaded.toggle()
}
}
// MARK: - Aircraft card