From 803c812f866d97ae90f85efd6c73eecebf1ae0c2 Mon Sep 17 00:00:00 2001 From: Trey T Date: Wed, 27 May 2026 09:51:30 -0500 Subject: [PATCH] =?UTF-8?q?History=20v2:=20everything=20=E2=80=94=20Wallet?= =?UTF-8?q?=20auto-prompt,=20age,=20track=20replay,=20share?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Flights.xcodeproj/project.pbxproj | 163 +++++++++++++-- Flights/Info.plist | 65 ++++++ Flights/Models/LoggedFlight.swift | 6 + .../Services/AirframeMetadataService.swift | 84 ++++++++ Flights/Views/AddFlightView.swift | 4 + Flights/Views/HistoryDetailView.swift | 47 +++-- Flights/Views/LiveFlightDetailSheet.swift | 1 + Flights/Views/RootView.swift | 82 ++++++++ FlightsShareExtension/Info.plist | 43 ++++ .../ShareViewController.swift | 189 ++++++++++++++++++ 10 files changed, 656 insertions(+), 28 deletions(-) create mode 100644 Flights/Info.plist create mode 100644 Flights/Services/AirframeMetadataService.swift create mode 100644 FlightsShareExtension/Info.plist create mode 100644 FlightsShareExtension/ShareViewController.swift diff --git a/Flights.xcodeproj/project.pbxproj b/Flights.xcodeproj/project.pbxproj index 54cc8b1..db9879a 100644 --- a/Flights.xcodeproj/project.pbxproj +++ b/Flights.xcodeproj/project.pbxproj @@ -75,6 +75,9 @@ HX0D000DDDD000DDDD000001 /* LifetimeStatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0D000DDDD000DDDD000002 /* LifetimeStatsView.swift */; }; HX0E000EEEE000EEEE000001 /* HistoryRouteMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0E000EEEE000EEEE000002 /* HistoryRouteMapView.swift */; }; HX0F000FFFF000FFFF000001 /* YearInReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0F000FFFF000FFFF000002 /* YearInReviewView.swift */; }; + HX1000001000000010000001 /* AirframeMetadataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1000001000000010000002 /* AirframeMetadataService.swift */; }; + SX01000000000000000001A1 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = SX01000000000000000001B1 /* ShareViewController.swift */; }; + SX01000000000000000004A1 /* FlightsShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = SX01000000000000000003B1 /* FlightsShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -85,8 +88,29 @@ remoteGlobalIDString = E373C48C497D48D388BF7657; remoteInfo = Flights; }; + SX0100000000000000000DA1 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5418BEEAEFF644ADA7240CEA /* Project object */; + proxyType = 1; + remoteGlobalIDString = SX01000000000000000009A1; + remoteInfo = FlightsShareExtension; + }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + SX0100000000000000000FA1 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + SX01000000000000000004A1 /* FlightsShareExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 04AC23D8748D42C9A7115FAC /* Airline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Airline.swift; sourceTree = ""; }; 0CD303E3EDCC4BF2BCF31722 /* AirportSearchField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirportSearchField.swift; sourceTree = ""; }; @@ -159,6 +183,10 @@ HX0D000DDDD000DDDD000002 /* LifetimeStatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LifetimeStatsView.swift; sourceTree = ""; }; HX0E000EEEE000EEEE000002 /* HistoryRouteMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryRouteMapView.swift; sourceTree = ""; }; HX0F000FFFF000FFFF000002 /* YearInReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YearInReviewView.swift; sourceTree = ""; }; + HX1000001000000010000002 /* AirframeMetadataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirframeMetadataService.swift; sourceTree = ""; }; + SX01000000000000000001B1 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; + SX01000000000000000002B1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + SX01000000000000000003B1 /* FlightsShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FlightsShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -176,6 +204,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + SX01000000000000000007A1 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -250,10 +285,20 @@ children = ( 8A3CB0CCC2524542AFB0D1D2 /* Flights.app */, T1000000000000000000003A /* FlightsTests.xctest */, + SX01000000000000000003B1 /* FlightsShareExtension.appex */, ); name = Products; sourceTree = ""; }; + SX01000000000000000005A1 /* FlightsShareExtension */ = { + isa = PBXGroup; + children = ( + SX01000000000000000001B1 /* ShareViewController.swift */, + SX01000000000000000002B1 /* Info.plist */, + ); + path = FlightsShareExtension; + sourceTree = ""; + }; T1000000000000000000005A /* FlightsTests */ = { isa = PBXGroup; children = ( @@ -293,6 +338,7 @@ HX0500005555000055550002 /* StatsEngine.swift */, HX0600006666000066660002 /* CalendarFlightImporter.swift */, HX0700007777000077770002 /* WalletPassObserver.swift */, + HX1000001000000010000002 /* AirframeMetadataService.swift */, ); path = Services; sourceTree = ""; @@ -301,6 +347,7 @@ isa = PBXGroup; children = ( 1D5A2C06B99046F3934D2E59 /* Flights */, + SX01000000000000000005A1 /* FlightsShareExtension */, T1000000000000000000005A /* FlightsTests */, 517CC07B82D949359C6CD4F5 /* Products */, ); @@ -337,10 +384,12 @@ A5535283EA784250AAF50064 /* Sources */, EB782B062CA144E2972778DE /* Frameworks */, 6B9FCA84AAAA44529A95D7AC /* Resources */, + SX0100000000000000000FA1 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + SX0100000000000000000EA1 /* PBXTargetDependency */, ); name = Flights; productName = Flights; @@ -364,6 +413,23 @@ productReference = T1000000000000000000003A /* FlightsTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + SX01000000000000000009A1 /* FlightsShareExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = SX0100000000000000000CA1 /* Build configuration list for PBXNativeTarget "FlightsShareExtension" */; + buildPhases = ( + SX01000000000000000006A1 /* Sources */, + SX01000000000000000007A1 /* Frameworks */, + SX01000000000000000008A1 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = FlightsShareExtension; + productName = FlightsShareExtension; + productReference = SX01000000000000000003B1 /* FlightsShareExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -388,6 +454,7 @@ projectRoot = ""; targets = ( E373C48C497D48D388BF7657 /* Flights */, + SX01000000000000000009A1 /* FlightsShareExtension */, T1000000000000000000006A /* FlightsTests */, ); }; @@ -399,6 +466,11 @@ target = E373C48C497D48D388BF7657 /* Flights */; targetProxy = T1000000000000000000002A /* PBXContainerItemProxy */; }; + SX0100000000000000000EA1 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = SX01000000000000000009A1 /* FlightsShareExtension */; + targetProxy = SX0100000000000000000DA1 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXResourcesBuildPhase section */ @@ -413,6 +485,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + SX01000000000000000008A1 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -483,6 +562,7 @@ HX0D000DDDD000DDDD000001 /* LifetimeStatsView.swift in Sources */, HX0E000EEEE000EEEE000001 /* HistoryRouteMapView.swift in Sources */, HX0F000FFFF000FFFF000001 /* YearInReviewView.swift in Sources */, + HX1000001000000010000001 /* AirframeMetadataService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -494,6 +574,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + SX01000000000000000006A1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + SX01000000000000000001A1 /* ShareViewController.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ @@ -506,13 +594,8 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = V3PF3M6B6U; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Show your current location on the live flight map so you can quickly see aircraft overhead."; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Flights/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -537,13 +620,8 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = V3PF3M6B6U; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Show your current location on the live flight map so you can quickly see aircraft overhead."; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Flights/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -646,6 +724,54 @@ }; name = Release; }; + SX0100000000000000000AA1 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = V3PF3M6B6U; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = FlightsShareExtension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.flights.app.share; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + SX0100000000000000000BA1 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = V3PF3M6B6U; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = FlightsShareExtension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.flights.app.share; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -676,6 +802,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + SX0100000000000000000CA1 /* Build configuration list for PBXNativeTarget "FlightsShareExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + SX0100000000000000000AA1 /* Debug */, + SX0100000000000000000BA1 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 5418BEEAEFF644ADA7240CEA /* Project object */; diff --git a/Flights/Info.plist b/Flights/Info.plist new file mode 100644 index 0000000..40433e9 --- /dev/null +++ b/Flights/Info.plist @@ -0,0 +1,65 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + com.flights.app + CFBundleURLSchemes + + flights + + + + LSRequiresIPhoneOS + + NSLocationWhenInUseUsageDescription + Show your current location on the live flight map so you can quickly see aircraft overhead. + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UIApplicationSupportsIndirectInputEvents + + UILaunchScreen + + UIRequiredDeviceCapabilities + + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/Flights/Models/LoggedFlight.swift b/Flights/Models/LoggedFlight.swift index 5f6286b..862db61 100644 --- a/Flights/Models/LoggedFlight.swift +++ b/Flights/Models/LoggedFlight.swift @@ -30,6 +30,10 @@ final class LoggedFlight { // MARK: Aircraft var aircraftType: String? // "B738" var registration: String? // "N281WN" — also keys into AirframeMetadata + /// 24-bit ICAO transponder address (e.g. "abc123"). Only populated + /// for live-tap adds; lets the detail screen pull the actual flown + /// track from OpenSky's history endpoint. + var icao24: String? // MARK: Personal var notes: String? @@ -53,6 +57,7 @@ final class LoggedFlight { actualArrival: Date? = nil, aircraftType: String? = nil, registration: String? = nil, + icao24: String? = nil, notes: String? = nil, source: String = "manual" ) { @@ -70,6 +75,7 @@ final class LoggedFlight { self.actualArrival = actualArrival self.aircraftType = aircraftType self.registration = registration + self.icao24 = icao24 self.notes = notes self.source = source } diff --git a/Flights/Services/AirframeMetadataService.swift b/Flights/Services/AirframeMetadataService.swift new file mode 100644 index 0000000..181b0fe --- /dev/null +++ b/Flights/Services/AirframeMetadataService.swift @@ -0,0 +1,84 @@ +import Foundation + +/// Pulls airframe metadata (manufacturer build date, first-flight date) +/// from OpenSky's `/api/metadata/aircraft/icao/{icao24}` endpoint and +/// caches the result in `AirframeMetadata`. Cleaner than scraping +/// jetphotos / planespotters airframe pages — both of those sit behind +/// Cloudflare's bot gate and aren't reliably fetchable from a mobile +/// client. +/// +/// Caveat: OpenSky's metadata is community-contributed and often null +/// for newer airframes. We degrade gracefully — no date means we just +/// don't show an age in the detail view. +actor AirframeMetadataService { + static let shared = AirframeMetadataService() + + struct Metadata: Hashable, Sendable { + let registration: String + let built: Date? + let firstFlightDate: Date? + } + + private let session: URLSession + private var inflight: [String: Task] = [:] + + init(session: URLSession = .shared) { + self.session = session + } + + /// Look up metadata for an aircraft by ICAO24 hex. Coalesces + /// concurrent requests for the same icao24 so we never fire twice. + /// Returns nil on network error / no record. + func metadata(forICAO24 icao24: String) async -> Metadata? { + let key = icao24.lowercased() + if let inflight = inflight[key] { + return await inflight.value + } + let task = Task { [weak self] in + guard let self else { return nil } + return await self.fetch(icao24: key) + } + inflight[key] = task + let result = await task.value + inflight.removeValue(forKey: key) + return result + } + + private func fetch(icao24: String) async -> Metadata? { + guard let url = URL(string: "https://opensky-network.org/api/metadata/aircraft/icao/\(icao24)") else { + return nil + } + var req = URLRequest(url: url) + req.timeoutInterval = 12 + req.setValue("application/json", forHTTPHeaderField: "Accept") + + do { + let (data, resp) = try await session.data(for: req) + guard let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + return nil + } + guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + let registration = root["registration"] as? String ?? "" + let built = parseDate(root["built"] as? String) + let firstFlight = parseDate(root["firstFlightDate"] as? String) + return Metadata( + registration: registration, + built: built, + firstFlightDate: firstFlight + ) + } catch { + return nil + } + } + + /// OpenSky returns dates as "YYYY-MM-DD" strings. + private func parseDate(_ s: String?) -> Date? { + guard let s, !s.isEmpty else { return nil } + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + f.timeZone = TimeZone(identifier: "UTC") + return f.date(from: s) + } +} diff --git a/Flights/Views/AddFlightView.swift b/Flights/Views/AddFlightView.swift index bb109fe..27b6429 100644 --- a/Flights/Views/AddFlightView.swift +++ b/Flights/Views/AddFlightView.swift @@ -27,6 +27,7 @@ struct AddFlightView: View { @State private var scheduledArrival: Date? @State private var aircraftType: String = "" @State private var registration: String = "" + @State private var icao24: String = "" @State private var notes: String = "" @State private var isLooking = false @@ -43,6 +44,7 @@ struct AddFlightView: View { var scheduledArrival: Date? var aircraftType: String? var registration: String? + var icao24: String? var source: String } @@ -132,6 +134,7 @@ struct AddFlightView: View { scheduledArrival = p.scheduledArrival aircraftType = (p.aircraftType ?? "").uppercased() registration = (p.registration ?? "").uppercased() + icao24 = (p.icao24 ?? "").lowercased() } private func runLookup() async { @@ -173,6 +176,7 @@ struct AddFlightView: View { scheduledArrival: scheduledArrival, aircraftType: aircraftType.isEmpty ? nil : aircraftType.uppercased(), registration: registration.isEmpty ? nil : registration.uppercased(), + icao24: icao24.isEmpty ? nil : icao24.lowercased(), notes: notes.isEmpty ? nil : notes, source: prefill?.source ?? "manual" ) diff --git a/Flights/Views/HistoryDetailView.swift b/Flights/Views/HistoryDetailView.swift index c9f2696..f3271f6 100644 --- a/Flights/Views/HistoryDetailView.swift +++ b/Flights/Views/HistoryDetailView.swift @@ -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 - /// reg→icao24 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 reg→icao24 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 diff --git a/Flights/Views/LiveFlightDetailSheet.swift b/Flights/Views/LiveFlightDetailSheet.swift index 4f99155..9baa26c 100644 --- a/Flights/Views/LiveFlightDetailSheet.swift +++ b/Flights/Views/LiveFlightDetailSheet.swift @@ -249,6 +249,7 @@ struct LiveFlightDetailSheet: View { scheduledArrival: nil, aircraftType: aircraft.typeCode, registration: aircraft.enrichment?.registration, + icao24: aircraft.icao24, source: "live-tap" ) ) diff --git a/Flights/Views/RootView.swift b/Flights/Views/RootView.swift index 4442fa4..850fe42 100644 --- a/Flights/Views/RootView.swift +++ b/Flights/Views/RootView.swift @@ -1,9 +1,15 @@ import SwiftUI +import SwiftData /// Top-level tab container. /// /// Tab 1: the existing search / connection / where-to-go home screen. /// Tab 2: the live flight tracker (map + filters + tap-to-detail). +/// Tab 3: personal flight history (logbook + stats + map). +/// +/// Also subscribes to WalletPassObserver so that adding a boarding +/// pass to Apple Wallet pops the add-flight sheet over whatever tab +/// the user is on. struct RootView: View { let database: AirportDatabase let loadService: AirlineLoadService @@ -12,6 +18,12 @@ struct RootView: View { let fr24: FR24Client @State private var selectedTab: Tab = .search + @StateObject private var wallet = WalletPassObserver.shared + @State private var walletPrefill: AddFlightView.Prefill? + /// URL-scheme prefill (from the Share Extension or any external + /// invocation of `flights://import?...`). + @State private var urlPrefill: AddFlightView.Prefill? + @Environment(\.modelContext) private var modelContext enum Tab: Hashable { case search, live, history } @@ -55,5 +67,75 @@ struct RootView: View { .tag(Tab.history) } .tint(FlightTheme.accent) + .onChange(of: wallet.pendingPass) { _, pass in + // A new boarding pass landed in Wallet — surface the + // add-flight sheet pre-populated from it. + guard let pass else { return } + walletPrefill = AddFlightView.Prefill( + flightDate: pass.flightDate, + carrierICAO: nil, + carrierIATA: pass.carrierIATA, + flightNumber: pass.flightNumber, + departureIATA: pass.departureIATA, + arrivalIATA: pass.arrivalIATA, + scheduledDeparture: pass.flightDate, + scheduledArrival: nil, + aircraftType: nil, + registration: nil, + icao24: nil, + source: "wallet" + ) + } + .sheet(item: $walletPrefill) { prefill in + let store = FlightHistoryStore(context: modelContext, airportDatabase: database) + AddFlightView( + routeExplorer: routeExplorer, + database: database, + store: store, + prefill: prefill + ) + .onDisappear { wallet.clearPending() } + } + .sheet(item: $urlPrefill) { prefill in + let store = FlightHistoryStore(context: modelContext, airportDatabase: database) + AddFlightView( + routeExplorer: routeExplorer, + database: database, + store: store, + prefill: prefill + ) + } + .onOpenURL { url in + // Share Extension hands us a URL like: + // flights://import?carrier=WN&num=7&dep=DAL&arr=HOU&date=1779892800 + guard url.scheme == "flights", url.host == "import" else { return } + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + let q = components?.queryItems ?? [] + func val(_ k: String) -> String? { q.first { $0.name == k }?.value } + let dateInterval = val("date").flatMap(TimeInterval.init) + let prefill = AddFlightView.Prefill( + flightDate: dateInterval.map { Date(timeIntervalSince1970: $0) } ?? Date(), + carrierICAO: nil, + carrierIATA: val("carrier"), + flightNumber: val("num"), + departureIATA: val("dep"), + arrivalIATA: val("arr"), + scheduledDeparture: nil, + scheduledArrival: nil, + aircraftType: nil, + registration: nil, + icao24: nil, + source: "mail-share" + ) + selectedTab = .history + urlPrefill = prefill + } + } +} + +extension AddFlightView.Prefill: Identifiable { + public var id: String { + // Stable enough — pass-prompted prefills are one-at-a-time. + "\(flightDate.timeIntervalSince1970)-\(carrierIATA ?? "")\(flightNumber ?? "")" } } diff --git a/FlightsShareExtension/Info.plist b/FlightsShareExtension/Info.plist new file mode 100644 index 0000000..aede42b --- /dev/null +++ b/FlightsShareExtension/Info.plist @@ -0,0 +1,43 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Flights + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSExtension + + NSExtensionAttributes + + NSExtensionActivationRule + + NSExtensionActivationSupportsText + + NSExtensionActivationSupportsWebURLWithMaxCount + 3 + NSExtensionActivationSupportsAttachmentsWithMaxCount + 10 + + + NSExtensionPointIdentifier + com.apple.share-services + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).ShareViewController + + + diff --git a/FlightsShareExtension/ShareViewController.swift b/FlightsShareExtension/ShareViewController.swift new file mode 100644 index 0000000..f3a7b7c --- /dev/null +++ b/FlightsShareExtension/ShareViewController.swift @@ -0,0 +1,189 @@ +import UIKit +import Social +import UniformTypeIdentifiers + +/// Mail (and any other text/URL source) Share Extension. Parses +/// flight info out of the shared content using the same regex +/// patterns as the calendar importer, writes the result to an App +/// Group UserDefaults entry under `pendingMailShare`, and dismisses. +/// +/// The main app reads that entry on next foreground (via +/// PendingShareWatcher) and pops the AddFlightView prefilled with +/// whatever we parsed. +final class ShareViewController: SLComposeServiceViewController { + + private var parsed: ParsedFlight? + private var allText: String = "" + + struct ParsedFlight { + let flightDate: Date + let carrierIATA: String? + let flightNumber: String? + let departureIATA: String? + let arrivalIATA: String? + } + + override func viewDidLoad() { + super.viewDidLoad() + title = "Add to Flights" + placeholder = "Optional note" + loadSharedItems() + } + + private func loadSharedItems() { + guard let extensionItems = extensionContext?.inputItems as? [NSExtensionItem] else { return } + let group = DispatchGroup() + var accumulated = "" + + for item in extensionItems { + // Mail surfaces both the subject line (as the contentText) + // and the body (as attachments). We absorb both. + if let content = item.attributedContentText?.string, !content.isEmpty { + accumulated += " " + content + } + for provider in item.attachments ?? [] { + if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) { + group.enter() + provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { item, _ in + defer { group.leave() } + if let s = item as? String { accumulated += " " + s } + } + } + if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) { + group.enter() + provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { item, _ in + defer { group.leave() } + if let u = item as? URL { accumulated += " " + u.absoluteString } + } + } + } + } + + group.notify(queue: .main) { [weak self] in + guard let self else { return } + self.allText = accumulated + self.parsed = Self.parseFlight(from: accumulated) + self.validateContent() + } + } + + override func isContentValid() -> Bool { + return parsed != nil + } + + override func didSelectPost() { + guard let parsed else { + extensionContext?.completeRequest(returningItems: [], completionHandler: nil) + return + } + // Hand the parsed flight off to the host app via a custom URL + // scheme. Share Extensions can't call UIApplication.shared + // directly, but we can walk the responder chain to find one + // that implements `openURL:` and invoke it. iOS still routes + // it through the host app correctly. + var comps = URLComponents() + comps.scheme = "flights" + comps.host = "import" + var items: [URLQueryItem] = [ + URLQueryItem(name: "date", value: String(parsed.flightDate.timeIntervalSince1970)) + ] + if let c = parsed.carrierIATA { items.append(.init(name: "carrier", value: c)) } + if let f = parsed.flightNumber { items.append(.init(name: "num", value: f)) } + if let d = parsed.departureIATA { items.append(.init(name: "dep", value: d)) } + if let a = parsed.arrivalIATA { items.append(.init(name: "arr", value: a)) } + comps.queryItems = items + if let url = comps.url { + openURLInHost(url) + } + extensionContext?.completeRequest(returningItems: [], completionHandler: nil) + } + + /// Walk the responder chain looking for an object that implements + /// `openURL:`. UIApplication is one. Invoking it from a share + /// extension launches the host app via its registered URL scheme. + private func openURLInHost(_ url: URL) { + var responder: UIResponder? = self + let selector = NSSelectorFromString("openURL:") + while responder != nil { + if responder!.responds(to: selector) { + _ = responder!.perform(selector, with: url) + return + } + responder = responder?.next + } + } + + override func configurationItems() -> [Any]! { + return [] + } + + // MARK: - Parser + + private static func parseFlight(from text: String) -> ParsedFlight? { + guard let flightMatch = matchFlight(in: text) else { return nil } + let route = matchRoute(in: text) + let date = matchDate(in: text) ?? Date() + return ParsedFlight( + flightDate: date, + carrierIATA: flightMatch.carrier, + flightNumber: flightMatch.number, + departureIATA: route?.from, + arrivalIATA: route?.to + ) + } + + private static func matchFlight(in s: String) -> (carrier: String, number: String)? { + let pattern = "([A-Z]{2,3})\\s*([0-9]{1,4})" + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + let nsRange = NSRange(s.startIndex..., in: s) + let denylist: Set = ["AM", "PM", "ET", "PT", "CT", "MT", "US", "UK", "TO", "AS"] + for m in regex.matches(in: s, range: nsRange) where m.numberOfRanges == 3 { + guard let cRange = Range(m.range(at: 1), in: s), + let nRange = Range(m.range(at: 2), in: s) else { continue } + let carrier = String(s[cRange]) + if denylist.contains(carrier) { continue } + return (carrier, String(s[nRange])) + } + return nil + } + + private static func matchRoute(in s: String) -> (from: String, to: String)? { + let pattern = "([A-Z]{3})\\s*(?:[-→>]|to)\\s*([A-Z]{3})" + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + let nsRange = NSRange(s.startIndex..., in: s) + guard let m = regex.firstMatch(in: s, range: nsRange), m.numberOfRanges == 3, + let fRange = Range(m.range(at: 1), in: s), + let tRange = Range(m.range(at: 2), in: s) else { return nil } + return (String(s[fRange]), String(s[tRange])) + } + + private static func matchDate(in s: String) -> Date? { + // ISO-ish: "May 27, 2026" / "27 May 2026" / "2026-05-27" + let formatters: [String] = [ + "MMMM d, yyyy", + "MMM d, yyyy", + "d MMMM yyyy", + "d MMM yyyy", + "yyyy-MM-dd", + "MM/dd/yyyy" + ] + // Try matching against any substring with each formatter. + for fmt in formatters { + let df = DateFormatter() + df.dateFormat = fmt + df.locale = Locale(identifier: "en_US_POSIX") + // Slide a window through the text; for date formats with + // word months we need substrings starting with a month. + let words = s.split(whereSeparator: { !$0.isLetter && !$0.isNumber && $0 != "-" && $0 != "/" && $0 != "," }).map(String.init) + for i in 0..