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:
@@ -75,6 +75,9 @@
|
|||||||
HX0D000DDDD000DDDD000001 /* LifetimeStatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0D000DDDD000DDDD000002 /* LifetimeStatsView.swift */; };
|
HX0D000DDDD000DDDD000001 /* LifetimeStatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0D000DDDD000DDDD000002 /* LifetimeStatsView.swift */; };
|
||||||
HX0E000EEEE000EEEE000001 /* HistoryRouteMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0E000EEEE000EEEE000002 /* HistoryRouteMapView.swift */; };
|
HX0E000EEEE000EEEE000001 /* HistoryRouteMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0E000EEEE000EEEE000002 /* HistoryRouteMapView.swift */; };
|
||||||
HX0F000FFFF000FFFF000001 /* YearInReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0F000FFFF000FFFF000002 /* YearInReviewView.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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -85,8 +88,29 @@
|
|||||||
remoteGlobalIDString = E373C48C497D48D388BF7657;
|
remoteGlobalIDString = E373C48C497D48D388BF7657;
|
||||||
remoteInfo = Flights;
|
remoteInfo = Flights;
|
||||||
};
|
};
|
||||||
|
SX0100000000000000000DA1 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = 5418BEEAEFF644ADA7240CEA /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = SX01000000000000000009A1;
|
||||||
|
remoteInfo = FlightsShareExtension;
|
||||||
|
};
|
||||||
/* End PBXContainerItemProxy section */
|
/* 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 */
|
/* Begin PBXFileReference section */
|
||||||
04AC23D8748D42C9A7115FAC /* Airline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Airline.swift; sourceTree = "<group>"; };
|
04AC23D8748D42C9A7115FAC /* Airline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Airline.swift; sourceTree = "<group>"; };
|
||||||
0CD303E3EDCC4BF2BCF31722 /* AirportSearchField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirportSearchField.swift; sourceTree = "<group>"; };
|
0CD303E3EDCC4BF2BCF31722 /* AirportSearchField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirportSearchField.swift; sourceTree = "<group>"; };
|
||||||
@@ -159,6 +183,10 @@
|
|||||||
HX0D000DDDD000DDDD000002 /* LifetimeStatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LifetimeStatsView.swift; sourceTree = "<group>"; };
|
HX0D000DDDD000DDDD000002 /* LifetimeStatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LifetimeStatsView.swift; sourceTree = "<group>"; };
|
||||||
HX0E000EEEE000EEEE000002 /* HistoryRouteMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryRouteMapView.swift; sourceTree = "<group>"; };
|
HX0E000EEEE000EEEE000002 /* HistoryRouteMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryRouteMapView.swift; sourceTree = "<group>"; };
|
||||||
HX0F000FFFF000FFFF000002 /* YearInReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YearInReviewView.swift; sourceTree = "<group>"; };
|
HX0F000FFFF000FFFF000002 /* YearInReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YearInReviewView.swift; sourceTree = "<group>"; };
|
||||||
|
HX1000001000000010000002 /* AirframeMetadataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirframeMetadataService.swift; sourceTree = "<group>"; };
|
||||||
|
SX01000000000000000001B1 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
|
||||||
|
SX01000000000000000002B1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
SX01000000000000000003B1 /* FlightsShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FlightsShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -176,6 +204,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
SX01000000000000000007A1 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
@@ -250,10 +285,20 @@
|
|||||||
children = (
|
children = (
|
||||||
8A3CB0CCC2524542AFB0D1D2 /* Flights.app */,
|
8A3CB0CCC2524542AFB0D1D2 /* Flights.app */,
|
||||||
T1000000000000000000003A /* FlightsTests.xctest */,
|
T1000000000000000000003A /* FlightsTests.xctest */,
|
||||||
|
SX01000000000000000003B1 /* FlightsShareExtension.appex */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
SX01000000000000000005A1 /* FlightsShareExtension */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
SX01000000000000000001B1 /* ShareViewController.swift */,
|
||||||
|
SX01000000000000000002B1 /* Info.plist */,
|
||||||
|
);
|
||||||
|
path = FlightsShareExtension;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
T1000000000000000000005A /* FlightsTests */ = {
|
T1000000000000000000005A /* FlightsTests */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -293,6 +338,7 @@
|
|||||||
HX0500005555000055550002 /* StatsEngine.swift */,
|
HX0500005555000055550002 /* StatsEngine.swift */,
|
||||||
HX0600006666000066660002 /* CalendarFlightImporter.swift */,
|
HX0600006666000066660002 /* CalendarFlightImporter.swift */,
|
||||||
HX0700007777000077770002 /* WalletPassObserver.swift */,
|
HX0700007777000077770002 /* WalletPassObserver.swift */,
|
||||||
|
HX1000001000000010000002 /* AirframeMetadataService.swift */,
|
||||||
);
|
);
|
||||||
path = Services;
|
path = Services;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -301,6 +347,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
1D5A2C06B99046F3934D2E59 /* Flights */,
|
1D5A2C06B99046F3934D2E59 /* Flights */,
|
||||||
|
SX01000000000000000005A1 /* FlightsShareExtension */,
|
||||||
T1000000000000000000005A /* FlightsTests */,
|
T1000000000000000000005A /* FlightsTests */,
|
||||||
517CC07B82D949359C6CD4F5 /* Products */,
|
517CC07B82D949359C6CD4F5 /* Products */,
|
||||||
);
|
);
|
||||||
@@ -337,10 +384,12 @@
|
|||||||
A5535283EA784250AAF50064 /* Sources */,
|
A5535283EA784250AAF50064 /* Sources */,
|
||||||
EB782B062CA144E2972778DE /* Frameworks */,
|
EB782B062CA144E2972778DE /* Frameworks */,
|
||||||
6B9FCA84AAAA44529A95D7AC /* Resources */,
|
6B9FCA84AAAA44529A95D7AC /* Resources */,
|
||||||
|
SX0100000000000000000FA1 /* Embed Foundation Extensions */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
|
SX0100000000000000000EA1 /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
name = Flights;
|
name = Flights;
|
||||||
productName = Flights;
|
productName = Flights;
|
||||||
@@ -364,6 +413,23 @@
|
|||||||
productReference = T1000000000000000000003A /* FlightsTests.xctest */;
|
productReference = T1000000000000000000003A /* FlightsTests.xctest */;
|
||||||
productType = "com.apple.product-type.bundle.unit-test";
|
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 */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
@@ -388,6 +454,7 @@
|
|||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
E373C48C497D48D388BF7657 /* Flights */,
|
E373C48C497D48D388BF7657 /* Flights */,
|
||||||
|
SX01000000000000000009A1 /* FlightsShareExtension */,
|
||||||
T1000000000000000000006A /* FlightsTests */,
|
T1000000000000000000006A /* FlightsTests */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -399,6 +466,11 @@
|
|||||||
target = E373C48C497D48D388BF7657 /* Flights */;
|
target = E373C48C497D48D388BF7657 /* Flights */;
|
||||||
targetProxy = T1000000000000000000002A /* PBXContainerItemProxy */;
|
targetProxy = T1000000000000000000002A /* PBXContainerItemProxy */;
|
||||||
};
|
};
|
||||||
|
SX0100000000000000000EA1 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = SX01000000000000000009A1 /* FlightsShareExtension */;
|
||||||
|
targetProxy = SX0100000000000000000DA1 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
/* End PBXTargetDependency section */
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin PBXResourcesBuildPhase section */
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
@@ -413,6 +485,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
SX01000000000000000008A1 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
@@ -483,6 +562,7 @@
|
|||||||
HX0D000DDDD000DDDD000001 /* LifetimeStatsView.swift in Sources */,
|
HX0D000DDDD000DDDD000001 /* LifetimeStatsView.swift in Sources */,
|
||||||
HX0E000EEEE000EEEE000001 /* HistoryRouteMapView.swift in Sources */,
|
HX0E000EEEE000EEEE000001 /* HistoryRouteMapView.swift in Sources */,
|
||||||
HX0F000FFFF000FFFF000001 /* YearInReviewView.swift in Sources */,
|
HX0F000FFFF000FFFF000001 /* YearInReviewView.swift in Sources */,
|
||||||
|
HX1000001000000010000001 /* AirframeMetadataService.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -494,6 +574,14 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
SX01000000000000000006A1 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
SX01000000000000000001A1 /* ShareViewController.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
@@ -506,13 +594,8 @@
|
|||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Show your current location on the live flight map so you can quickly see aircraft overhead.";
|
INFOPLIST_FILE = Flights/Info.plist;
|
||||||
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";
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -537,13 +620,8 @@
|
|||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Show your current location on the live flight map so you can quickly see aircraft overhead.";
|
INFOPLIST_FILE = Flights/Info.plist;
|
||||||
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";
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -646,6 +724,54 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
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 */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
@@ -676,6 +802,15 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
|
SX0100000000000000000CA1 /* Build configuration list for PBXNativeTarget "FlightsShareExtension" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
SX0100000000000000000AA1 /* Debug */,
|
||||||
|
SX0100000000000000000BA1 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
};
|
};
|
||||||
rootObject = 5418BEEAEFF644ADA7240CEA /* Project object */;
|
rootObject = 5418BEEAEFF644ADA7240CEA /* Project object */;
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>Editor</string>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>com.flights.app</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>flights</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
|
<string>Show your current location on the live flight map so you can quickly see aircraft overhead.</string>
|
||||||
|
<key>UIApplicationSceneManifest</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
|
<true/>
|
||||||
|
<key>UILaunchScreen</key>
|
||||||
|
<dict/>
|
||||||
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
|
<array>
|
||||||
|
<string>arm64</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -30,6 +30,10 @@ final class LoggedFlight {
|
|||||||
// MARK: Aircraft
|
// MARK: Aircraft
|
||||||
var aircraftType: String? // "B738"
|
var aircraftType: String? // "B738"
|
||||||
var registration: String? // "N281WN" — also keys into AirframeMetadata
|
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
|
// MARK: Personal
|
||||||
var notes: String?
|
var notes: String?
|
||||||
@@ -53,6 +57,7 @@ final class LoggedFlight {
|
|||||||
actualArrival: Date? = nil,
|
actualArrival: Date? = nil,
|
||||||
aircraftType: String? = nil,
|
aircraftType: String? = nil,
|
||||||
registration: String? = nil,
|
registration: String? = nil,
|
||||||
|
icao24: String? = nil,
|
||||||
notes: String? = nil,
|
notes: String? = nil,
|
||||||
source: String = "manual"
|
source: String = "manual"
|
||||||
) {
|
) {
|
||||||
@@ -70,6 +75,7 @@ final class LoggedFlight {
|
|||||||
self.actualArrival = actualArrival
|
self.actualArrival = actualArrival
|
||||||
self.aircraftType = aircraftType
|
self.aircraftType = aircraftType
|
||||||
self.registration = registration
|
self.registration = registration
|
||||||
|
self.icao24 = icao24
|
||||||
self.notes = notes
|
self.notes = notes
|
||||||
self.source = source
|
self.source = source
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Metadata?, Never>] = [:]
|
||||||
|
|
||||||
|
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<Metadata?, Never> { [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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ struct AddFlightView: View {
|
|||||||
@State private var scheduledArrival: Date?
|
@State private var scheduledArrival: Date?
|
||||||
@State private var aircraftType: String = ""
|
@State private var aircraftType: String = ""
|
||||||
@State private var registration: String = ""
|
@State private var registration: String = ""
|
||||||
|
@State private var icao24: String = ""
|
||||||
@State private var notes: String = ""
|
@State private var notes: String = ""
|
||||||
|
|
||||||
@State private var isLooking = false
|
@State private var isLooking = false
|
||||||
@@ -43,6 +44,7 @@ struct AddFlightView: View {
|
|||||||
var scheduledArrival: Date?
|
var scheduledArrival: Date?
|
||||||
var aircraftType: String?
|
var aircraftType: String?
|
||||||
var registration: String?
|
var registration: String?
|
||||||
|
var icao24: String?
|
||||||
var source: String
|
var source: String
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +134,7 @@ struct AddFlightView: View {
|
|||||||
scheduledArrival = p.scheduledArrival
|
scheduledArrival = p.scheduledArrival
|
||||||
aircraftType = (p.aircraftType ?? "").uppercased()
|
aircraftType = (p.aircraftType ?? "").uppercased()
|
||||||
registration = (p.registration ?? "").uppercased()
|
registration = (p.registration ?? "").uppercased()
|
||||||
|
icao24 = (p.icao24 ?? "").lowercased()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func runLookup() async {
|
private func runLookup() async {
|
||||||
@@ -173,6 +176,7 @@ struct AddFlightView: View {
|
|||||||
scheduledArrival: scheduledArrival,
|
scheduledArrival: scheduledArrival,
|
||||||
aircraftType: aircraftType.isEmpty ? nil : aircraftType.uppercased(),
|
aircraftType: aircraftType.isEmpty ? nil : aircraftType.uppercased(),
|
||||||
registration: registration.isEmpty ? nil : registration.uppercased(),
|
registration: registration.isEmpty ? nil : registration.uppercased(),
|
||||||
|
icao24: icao24.isEmpty ? nil : icao24.lowercased(),
|
||||||
notes: notes.isEmpty ? nil : notes,
|
notes: notes.isEmpty ? nil : notes,
|
||||||
source: prefill?.source ?? "manual"
|
source: prefill?.source ?? "manual"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ struct HistoryDetailView: View {
|
|||||||
@State private var track: AircraftTrack?
|
@State private var track: AircraftTrack?
|
||||||
@State private var editedNotes: String = ""
|
@State private var editedNotes: String = ""
|
||||||
@State private var showDeleteConfirm = false
|
@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 {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
@@ -44,9 +47,10 @@ struct HistoryDetailView: View {
|
|||||||
.task {
|
.task {
|
||||||
editedNotes = flight.notes ?? ""
|
editedNotes = flight.notes ?? ""
|
||||||
if let reg = flight.registration {
|
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 loadTrackIfRecent()
|
||||||
|
await loadAirframeMetadata()
|
||||||
}
|
}
|
||||||
.alert("Delete this flight?", isPresented: $showDeleteConfirm) {
|
.alert("Delete this flight?", isPresented: $showDeleteConfirm) {
|
||||||
Button("Delete", role: .destructive) {
|
Button("Delete", role: .destructive) {
|
||||||
@@ -186,23 +190,38 @@ struct HistoryDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func loadTrackIfRecent() async {
|
private func loadTrackIfRecent() async {
|
||||||
// OpenSky's anonymous track endpoint goes back roughly 7 days
|
// OpenSky's anonymous track endpoint trims history after ~7
|
||||||
// before they trim history. Older logs get the great-circle
|
// days. Older logs get the great-circle fallback drawn by
|
||||||
// fallback drawn by FlightRouteMap.
|
// FlightRouteMap.
|
||||||
let ageDays = Date().timeIntervalSince(flight.flightDate) / 86400
|
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)
|
track = await openSky.track(icao24: icao24)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// We don't store icao24 on the LoggedFlight (we store registration
|
/// Hit OpenSky's metadata endpoint for first-flight / built dates.
|
||||||
/// instead) — but for track replay we need icao24. Future work: pull
|
/// We persist the result so subsequent views of the same airframe
|
||||||
/// reg→icao24 mapping from a fresh OpenSky lookup. For now, only the
|
/// don't re-query the network. Best-effort — many newer airframes
|
||||||
/// most-recently-logged airframe gets a replay attempt.
|
/// have no metadata yet.
|
||||||
private func guessICAO24() -> String? {
|
private func loadAirframeMetadata() async {
|
||||||
// TODO: tie this to a reg→icao24 resolution. For v1 the
|
guard let reg = flight.registration,
|
||||||
// track replay only fires when icao24 is in notes or we
|
!reg.isEmpty,
|
||||||
// resolve via aircraft DB.
|
let icao24 = flight.icao24,
|
||||||
return nil
|
!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
|
// MARK: - Aircraft card
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ struct LiveFlightDetailSheet: View {
|
|||||||
scheduledArrival: nil,
|
scheduledArrival: nil,
|
||||||
aircraftType: aircraft.typeCode,
|
aircraftType: aircraft.typeCode,
|
||||||
registration: aircraft.enrichment?.registration,
|
registration: aircraft.enrichment?.registration,
|
||||||
|
icao24: aircraft.icao24,
|
||||||
source: "live-tap"
|
source: "live-tap"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
/// Top-level tab container.
|
/// Top-level tab container.
|
||||||
///
|
///
|
||||||
/// Tab 1: the existing search / connection / where-to-go home screen.
|
/// Tab 1: the existing search / connection / where-to-go home screen.
|
||||||
/// Tab 2: the live flight tracker (map + filters + tap-to-detail).
|
/// 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 {
|
struct RootView: View {
|
||||||
let database: AirportDatabase
|
let database: AirportDatabase
|
||||||
let loadService: AirlineLoadService
|
let loadService: AirlineLoadService
|
||||||
@@ -12,6 +18,12 @@ struct RootView: View {
|
|||||||
let fr24: FR24Client
|
let fr24: FR24Client
|
||||||
|
|
||||||
@State private var selectedTab: Tab = .search
|
@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 }
|
enum Tab: Hashable { case search, live, history }
|
||||||
|
|
||||||
@@ -55,5 +67,75 @@ struct RootView: View {
|
|||||||
.tag(Tab.history)
|
.tag(Tab.history)
|
||||||
}
|
}
|
||||||
.tint(FlightTheme.accent)
|
.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 ?? "")"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Flights</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionAttributes</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionActivationRule</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionActivationSupportsText</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
|
||||||
|
<integer>3</integer>
|
||||||
|
<key>NSExtensionActivationSupportsAttachmentsWithMaxCount</key>
|
||||||
|
<integer>10</integer>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.share-services</string>
|
||||||
|
<key>NSExtensionPrincipalClass</key>
|
||||||
|
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -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<String> = ["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..<words.count {
|
||||||
|
for end in min(i + 4, words.count)...(min(i + 4, words.count)) {
|
||||||
|
let candidate = words[i..<end].joined(separator: " ")
|
||||||
|
if let date = df.date(from: candidate) {
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user