Initial commit: Flights iOS app
Flight search app built on FlightConnections.com API data. Features: airport search with autocomplete, browse by country/state/map, flight schedules by route and date, multi-airline support with per-airline schedule loading. Includes 4,561-airport GPS database for map browsing. Adaptive light/dark mode UI inspired by Flighty. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
44
.gitignore
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
# Xcode
|
||||
build/
|
||||
DerivedData/
|
||||
*.xcuserdata
|
||||
*.xcworkspace/xcuserdata/
|
||||
xcuserdata/
|
||||
*.xccheckout
|
||||
*.moved-aside
|
||||
*.pbxuser
|
||||
!default.pbxuser
|
||||
*.mode1v3
|
||||
!default.mode1v3
|
||||
*.mode2v3
|
||||
!default.mode2v3
|
||||
*.perspectivev3
|
||||
!default.perspectivev3
|
||||
|
||||
# Xcode Scheme
|
||||
*.xcscheme
|
||||
|
||||
# Swift Package Manager
|
||||
.build/
|
||||
Packages/
|
||||
Package.pins
|
||||
Package.resolved
|
||||
*.workspace/xcshareddata/swiftpm/
|
||||
|
||||
# CocoaPods
|
||||
Pods/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
._*
|
||||
|
||||
# HAR files (large network captures)
|
||||
*.har
|
||||
|
||||
# APK files
|
||||
apps/
|
||||
|
||||
# Claude
|
||||
.claude/
|
||||
394
Flights.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,394 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 56;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
019A3D900D1B4145AB87B9F7 /* MapAirport.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AC05BFDFDE4A94B360EB05 /* MapAirport.swift */; };
|
||||
0E02E6501AA249229BBC335A /* Route.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EFE025789164A779FC980B0 /* Route.swift */; };
|
||||
1FA279998CFB44949B4188FE /* FlightService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A65682BD902141BAA686D101 /* FlightService.swift */; };
|
||||
2C3FF395E0724FA5A6E33C3A /* FlightScheduleRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9640481120A418EBCD3CE73 /* FlightScheduleRow.swift */; };
|
||||
303821C9668A44F38FFA02CA /* AirportSearchField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD303E3EDCC4BF2BCF31722 /* AirportSearchField.swift */; };
|
||||
35D016EBA93C40BB873AB304 /* Airline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AC23D8748D42C9A7115FAC /* Airline.swift */; };
|
||||
4C770C55CB3643BAB7B9D622 /* AirportBrowserSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15676B4BD35745D1BD1DC947 /* AirportBrowserSheet.swift */; };
|
||||
57A463AB3CFD44DC93444E59 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9934B0FCA757403A94AB963C /* ContentView.swift */; };
|
||||
61F8E3DD7D434DA7854C20E2 /* FlightsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6D822B4ABF741F890A4400C /* FlightsApp.swift */; };
|
||||
6558A31ADEC740FC8C56EA22 /* FlightSchedule.swift in Sources */ = {isa = PBXBuildFile; fileRef = B913D04A4E51436595308A21 /* FlightSchedule.swift */; };
|
||||
7FFD9A2D25F9421D8929C027 /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36862683C4F44A95AFE234EB /* SearchViewModel.swift */; };
|
||||
80D2BC95002A4931B3C10B4C /* airports.json in Resources */ = {isa = PBXBuildFile; fileRef = 53F457716F0642BDBCBA93EA /* airports.json */; };
|
||||
9C1300E497B049FE8DA677E0 /* RouteDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 434DEB1EB08F4F0CBCB91266 /* RouteDetailViewModel.swift */; };
|
||||
BADBC95A6A6D4267A01DA898 /* AirportDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A58C339D6084657B0538E9C /* AirportDatabase.swift */; };
|
||||
BB3E647E4A07477F9F37E607 /* RouteDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C1176F877BF496ABF079040 /* RouteDetailView.swift */; };
|
||||
C36C490556254AC88EC02C80 /* DestinationsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BEAC0EBABFD41569FE69B1B /* DestinationsViewModel.swift */; };
|
||||
C7191E4E1AF84FECAD27CEC9 /* Country.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7987BD4832D44F1A0851933 /* Country.swift */; };
|
||||
D0EC717347974D668C77B9D2 /* DestinationsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 300153508F8445B6A78CEC52 /* DestinationsListView.swift */; };
|
||||
E41FC265B0994D2ABC4DDE67 /* Airport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85EC89DEE12942B49DF51984 /* Airport.swift */; };
|
||||
F79789179F4443FD859BDEF0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D9E26DCDE2904210ABCA7855 /* Assets.xcassets */; };
|
||||
FD853F72EE724922B0E4E235 /* AirportMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C2EB471F011450DA7BBEFAD /* AirportMapView.swift */; };
|
||||
FF74A792115C414CA9AB5B36 /* BrowseAirport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4944338B20BA4AB98F05D4F7 /* BrowseAirport.swift */; };
|
||||
AA1111111111111111111111 /* FlightTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2222222222222222222222 /* FlightTheme.swift */; };
|
||||
AA3333333333333333333333 /* RouteVisualization.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA4444444444444444444444 /* RouteVisualization.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
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>"; };
|
||||
0EFE025789164A779FC980B0 /* Route.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Route.swift; sourceTree = "<group>"; };
|
||||
15676B4BD35745D1BD1DC947 /* AirportBrowserSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirportBrowserSheet.swift; sourceTree = "<group>"; };
|
||||
1C1176F877BF496ABF079040 /* RouteDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteDetailView.swift; sourceTree = "<group>"; };
|
||||
300153508F8445B6A78CEC52 /* DestinationsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestinationsListView.swift; sourceTree = "<group>"; };
|
||||
36862683C4F44A95AFE234EB /* SearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = "<group>"; };
|
||||
434DEB1EB08F4F0CBCB91266 /* RouteDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteDetailViewModel.swift; sourceTree = "<group>"; };
|
||||
4944338B20BA4AB98F05D4F7 /* BrowseAirport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseAirport.swift; sourceTree = "<group>"; };
|
||||
53F457716F0642BDBCBA93EA /* airports.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = airports.json; sourceTree = "<group>"; };
|
||||
7C2EB471F011450DA7BBEFAD /* AirportMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirportMapView.swift; sourceTree = "<group>"; };
|
||||
85EC89DEE12942B49DF51984 /* Airport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Airport.swift; sourceTree = "<group>"; };
|
||||
8A3CB0CCC2524542AFB0D1D2 /* Flights.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Flights.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
9934B0FCA757403A94AB963C /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
9A58C339D6084657B0538E9C /* AirportDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirportDatabase.swift; sourceTree = "<group>"; };
|
||||
9BEAC0EBABFD41569FE69B1B /* DestinationsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestinationsViewModel.swift; sourceTree = "<group>"; };
|
||||
A65682BD902141BAA686D101 /* FlightService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlightService.swift; sourceTree = "<group>"; };
|
||||
B913D04A4E51436595308A21 /* FlightSchedule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlightSchedule.swift; sourceTree = "<group>"; };
|
||||
D9E26DCDE2904210ABCA7855 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
E1AC05BFDFDE4A94B360EB05 /* MapAirport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapAirport.swift; sourceTree = "<group>"; };
|
||||
E6D822B4ABF741F890A4400C /* FlightsApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlightsApp.swift; sourceTree = "<group>"; };
|
||||
E7987BD4832D44F1A0851933 /* Country.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Country.swift; sourceTree = "<group>"; };
|
||||
F9640481120A418EBCD3CE73 /* FlightScheduleRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlightScheduleRow.swift; sourceTree = "<group>"; };
|
||||
AA2222222222222222222222 /* FlightTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlightTheme.swift; sourceTree = "<group>"; };
|
||||
AA4444444444444444444444 /* RouteVisualization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteVisualization.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
EB782B062CA144E2972778DE /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
1B20C5393D8F432A93097C2C /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9934B0FCA757403A94AB963C /* ContentView.swift */,
|
||||
0CD303E3EDCC4BF2BCF31722 /* AirportSearchField.swift */,
|
||||
300153508F8445B6A78CEC52 /* DestinationsListView.swift */,
|
||||
1C1176F877BF496ABF079040 /* RouteDetailView.swift */,
|
||||
F9640481120A418EBCD3CE73 /* FlightScheduleRow.swift */,
|
||||
7C2EB471F011450DA7BBEFAD /* AirportMapView.swift */,
|
||||
15676B4BD35745D1BD1DC947 /* AirportBrowserSheet.swift */,
|
||||
AA5555555555555555555555 /* Styles */,
|
||||
AA6666666666666666666666 /* Components */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
AA5555555555555555555555 /* Styles */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AA2222222222222222222222 /* FlightTheme.swift */,
|
||||
);
|
||||
path = Styles;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
AA6666666666666666666666 /* Components */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AA4444444444444444444444 /* RouteVisualization.swift */,
|
||||
);
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
1D5A2C06B99046F3934D2E59 /* Flights */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E6D822B4ABF741F890A4400C /* FlightsApp.swift */,
|
||||
F35FEFCEEAC24D248AC81678 /* Models */,
|
||||
B6019ED81F39462B92BDC856 /* Services */,
|
||||
6E94DB5F9EB345948E2D5E2A /* ViewModels */,
|
||||
1B20C5393D8F432A93097C2C /* Views */,
|
||||
D9E26DCDE2904210ABCA7855 /* Assets.xcassets */,
|
||||
53F457716F0642BDBCBA93EA /* airports.json */,
|
||||
);
|
||||
path = Flights;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
517CC07B82D949359C6CD4F5 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8A3CB0CCC2524542AFB0D1D2 /* Flights.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
6E94DB5F9EB345948E2D5E2A /* ViewModels */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
36862683C4F44A95AFE234EB /* SearchViewModel.swift */,
|
||||
9BEAC0EBABFD41569FE69B1B /* DestinationsViewModel.swift */,
|
||||
434DEB1EB08F4F0CBCB91266 /* RouteDetailViewModel.swift */,
|
||||
);
|
||||
path = ViewModels;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B6019ED81F39462B92BDC856 /* Services */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A65682BD902141BAA686D101 /* FlightService.swift */,
|
||||
9A58C339D6084657B0538E9C /* AirportDatabase.swift */,
|
||||
);
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
EDB9BC1CC7104FB58C275091 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1D5A2C06B99046F3934D2E59 /* Flights */,
|
||||
517CC07B82D949359C6CD4F5 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F35FEFCEEAC24D248AC81678 /* Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
85EC89DEE12942B49DF51984 /* Airport.swift */,
|
||||
04AC23D8748D42C9A7115FAC /* Airline.swift */,
|
||||
0EFE025789164A779FC980B0 /* Route.swift */,
|
||||
B913D04A4E51436595308A21 /* FlightSchedule.swift */,
|
||||
E1AC05BFDFDE4A94B360EB05 /* MapAirport.swift */,
|
||||
4944338B20BA4AB98F05D4F7 /* BrowseAirport.swift */,
|
||||
E7987BD4832D44F1A0851933 /* Country.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
E373C48C497D48D388BF7657 /* Flights */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 2E22AD8D30AA4AF593618EDA /* Build configuration list for PBXNativeTarget "Flights" */;
|
||||
buildPhases = (
|
||||
A5535283EA784250AAF50064 /* Sources */,
|
||||
EB782B062CA144E2972778DE /* Frameworks */,
|
||||
6B9FCA84AAAA44529A95D7AC /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = Flights;
|
||||
productName = Flights;
|
||||
productReference = 8A3CB0CCC2524542AFB0D1D2 /* Flights.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
5418BEEAEFF644ADA7240CEA /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1540;
|
||||
LastUpgradeCheck = 1540;
|
||||
};
|
||||
buildConfigurationList = AD1FB003D80641D59E37D6A0 /* Build configuration list for PBXProject "Flights" */;
|
||||
compatibilityVersion = "Xcode 14.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = EDB9BC1CC7104FB58C275091;
|
||||
productRefGroup = 517CC07B82D949359C6CD4F5 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
E373C48C497D48D388BF7657 /* Flights */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
6B9FCA84AAAA44529A95D7AC /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
F79789179F4443FD859BDEF0 /* Assets.xcassets in Resources */,
|
||||
80D2BC95002A4931B3C10B4C /* airports.json in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
A5535283EA784250AAF50064 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
61F8E3DD7D434DA7854C20E2 /* FlightsApp.swift in Sources */,
|
||||
E41FC265B0994D2ABC4DDE67 /* Airport.swift in Sources */,
|
||||
35D016EBA93C40BB873AB304 /* Airline.swift in Sources */,
|
||||
0E02E6501AA249229BBC335A /* Route.swift in Sources */,
|
||||
6558A31ADEC740FC8C56EA22 /* FlightSchedule.swift in Sources */,
|
||||
1FA279998CFB44949B4188FE /* FlightService.swift in Sources */,
|
||||
7FFD9A2D25F9421D8929C027 /* SearchViewModel.swift in Sources */,
|
||||
C36C490556254AC88EC02C80 /* DestinationsViewModel.swift in Sources */,
|
||||
9C1300E497B049FE8DA677E0 /* RouteDetailViewModel.swift in Sources */,
|
||||
57A463AB3CFD44DC93444E59 /* ContentView.swift in Sources */,
|
||||
303821C9668A44F38FFA02CA /* AirportSearchField.swift in Sources */,
|
||||
D0EC717347974D668C77B9D2 /* DestinationsListView.swift in Sources */,
|
||||
BB3E647E4A07477F9F37E607 /* RouteDetailView.swift in Sources */,
|
||||
2C3FF395E0724FA5A6E33C3A /* FlightScheduleRow.swift in Sources */,
|
||||
C7191E4E1AF84FECAD27CEC9 /* Country.swift in Sources */,
|
||||
FF74A792115C414CA9AB5B36 /* BrowseAirport.swift in Sources */,
|
||||
4C770C55CB3643BAB7B9D622 /* AirportBrowserSheet.swift in Sources */,
|
||||
019A3D900D1B4145AB87B9F7 /* MapAirport.swift in Sources */,
|
||||
BADBC95A6A6D4267A01DA898 /* AirportDatabase.swift in Sources */,
|
||||
FD853F72EE724922B0E4E235 /* AirportMapView.swift in Sources */,
|
||||
AA1111111111111111111111 /* FlightTheme.swift in Sources */,
|
||||
AA3333333333333333333333 /* RouteVisualization.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
1A8450A5FFF54054AB64D72A /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
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 = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.flights.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
2013CC286F244D4EB2F6844E /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
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 = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.flights.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
586CEED6298F48F5A36032EC /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
CE9F5DD65F2043CB9B39FC35 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
2E22AD8D30AA4AF593618EDA /* Build configuration list for PBXNativeTarget "Flights" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
2013CC286F244D4EB2F6844E /* Debug */,
|
||||
1A8450A5FFF54054AB64D72A /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
AD1FB003D80641D59E37D6A0 /* Build configuration list for PBXProject "Flights" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
CE9F5DD65F2043CB9B39FC35 /* Debug */,
|
||||
586CEED6298F48F5A36032EC /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 5418BEEAEFF644ADA7240CEA /* Project object */;
|
||||
}
|
||||
11
Flights/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
14
Flights/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "app-icon-1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Flights/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png
Normal file
|
After Width: | Height: | Size: 934 KiB |
6
Flights/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
13
Flights/FlightsApp.swift
Normal file
@@ -0,0 +1,13 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct FlightsApp: App {
|
||||
let service = FlightService()
|
||||
let database = AirportDatabase()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView(service: service, database: database)
|
||||
}
|
||||
}
|
||||
}
|
||||
12
Flights/Models/Airline.swift
Normal file
@@ -0,0 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
struct Airline: Identifiable, Hashable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let iata: String
|
||||
let logoFilename: String
|
||||
|
||||
var logoURL: URL? {
|
||||
URL(string: "https://cdn.flightconnections.com/img/airlines_sq/\(logoFilename)")
|
||||
}
|
||||
}
|
||||
9
Flights/Models/Airport.swift
Normal file
@@ -0,0 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
struct Airport: Identifiable, Hashable, Sendable {
|
||||
let id: String
|
||||
let iata: String
|
||||
let name: String
|
||||
var countryCode: String = ""
|
||||
var country: String = ""
|
||||
}
|
||||
7
Flights/Models/BrowseAirport.swift
Normal file
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
struct BrowseAirport: Identifiable, Hashable, Sendable {
|
||||
let id: String // IATA code "DFW"
|
||||
let iata: String // "DFW"
|
||||
let city: String // "Dallas-Fort Worth"
|
||||
}
|
||||
7
Flights/Models/Country.swift
Normal file
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
struct Country: Identifiable, Hashable, Sendable {
|
||||
let id: String // Country code "US"
|
||||
let name: String // "United States"
|
||||
let slug: String // "united-states"
|
||||
}
|
||||
33
Flights/Models/FlightSchedule.swift
Normal file
@@ -0,0 +1,33 @@
|
||||
import Foundation
|
||||
|
||||
struct FlightSchedule: Identifiable, Sendable {
|
||||
let id = UUID()
|
||||
let airline: Airline
|
||||
let flightNumber: String
|
||||
let aircraft: String
|
||||
let aircraftId: String
|
||||
let departureTime: String
|
||||
let arrivalTime: String
|
||||
let dateFrom: Date
|
||||
let dateTo: Date
|
||||
let daysOfWeek: Set<Int>
|
||||
let cabinClasses: CabinClass
|
||||
|
||||
func operatesOn(date: Date) -> Bool {
|
||||
var utc = Calendar(identifier: .gregorian)
|
||||
utc.timeZone = TimeZone(identifier: "UTC")!
|
||||
|
||||
let fromDay = utc.startOfDay(for: dateFrom)
|
||||
let toDay = utc.startOfDay(for: dateTo)
|
||||
|
||||
// Normalize the input date to UTC noon to avoid timezone boundary issues
|
||||
let components = Calendar.current.dateComponents([.year, .month, .day], from: date)
|
||||
guard let normalizedDate = utc.date(from: components) else { return false }
|
||||
|
||||
guard normalizedDate >= fromDay, normalizedDate <= toDay else { return false }
|
||||
|
||||
// Use the local calendar for weekday since the user picked the date locally
|
||||
let weekday = Calendar.current.component(.weekday, from: date)
|
||||
return daysOfWeek.contains(weekday)
|
||||
}
|
||||
}
|
||||
17
Flights/Models/MapAirport.swift
Normal file
@@ -0,0 +1,17 @@
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
struct MapAirport: Codable, Identifiable, Hashable, Sendable {
|
||||
let iata: String
|
||||
let name: String
|
||||
let country: String
|
||||
let region: String // ISO region e.g. "US-TX"
|
||||
let lat: Double
|
||||
let lng: Double
|
||||
|
||||
var id: String { iata }
|
||||
|
||||
var coordinate: CLLocationCoordinate2D {
|
||||
CLLocationCoordinate2D(latitude: lat, longitude: lng)
|
||||
}
|
||||
}
|
||||
62
Flights/Models/Route.swift
Normal file
@@ -0,0 +1,62 @@
|
||||
import Foundation
|
||||
|
||||
struct CabinClass: OptionSet, Hashable, Sendable {
|
||||
let rawValue: Int
|
||||
|
||||
static let economy = CabinClass(rawValue: 1 << 0)
|
||||
static let premiumEcon = CabinClass(rawValue: 1 << 1)
|
||||
static let business = CabinClass(rawValue: 1 << 2)
|
||||
static let first = CabinClass(rawValue: 1 << 3)
|
||||
|
||||
/// Parse from validity.php "classes" string like "0111".
|
||||
/// Position 0 = First, 1 = Business, 2 = PremEcon, 3 = Economy.
|
||||
static func from(classesString: String) -> CabinClass {
|
||||
var result = CabinClass()
|
||||
let chars = Array(classesString)
|
||||
guard chars.count >= 4 else { return result }
|
||||
if chars[0] == "1" { result.insert(.first) }
|
||||
if chars[1] == "1" { result.insert(.business) }
|
||||
if chars[2] == "1" { result.insert(.premiumEcon) }
|
||||
if chars[3] == "1" { result.insert(.economy) }
|
||||
return result
|
||||
}
|
||||
|
||||
/// Parse from rt<id>.json `clss` bitmask integer.
|
||||
/// Bit mapping: 1 = economy, 2 = premEcon, 4 = business, 8 = first.
|
||||
static func from(bitmask: Int) -> CabinClass {
|
||||
CabinClass(rawValue: bitmask)
|
||||
}
|
||||
|
||||
var labels: [String] {
|
||||
var result: [String] = []
|
||||
if contains(.first) { result.append("F") }
|
||||
if contains(.business) { result.append("J") }
|
||||
if contains(.premiumEcon) { result.append("W") }
|
||||
if contains(.economy) { result.append("Y") }
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
struct Route: Identifiable, Hashable, Sendable {
|
||||
var id: String { destinationAirport.id }
|
||||
let destinationAirport: Airport
|
||||
let durationMinutes: Int
|
||||
let distanceMiles: Int
|
||||
let monthsBitmask: Int
|
||||
let cabinClasses: CabinClass
|
||||
let latitude: Double
|
||||
let longitude: Double
|
||||
|
||||
func availableIn(month: Int) -> Bool {
|
||||
guard month >= 1, month <= 12 else { return false }
|
||||
return monthsBitmask & (1 << (month - 1)) != 0
|
||||
}
|
||||
}
|
||||
|
||||
struct RouteAirline: Identifiable, Sendable {
|
||||
var id: String { airline.id }
|
||||
let airline: Airline
|
||||
let distanceMiles: Int
|
||||
let durationMinutes: Int
|
||||
let monthsBitmask: Int
|
||||
}
|
||||
108
Flights/Services/AirportDatabase.swift
Normal file
@@ -0,0 +1,108 @@
|
||||
import Foundation
|
||||
import MapKit
|
||||
|
||||
final class AirportDatabase: Sendable {
|
||||
let airports: [MapAirport]
|
||||
private let regionNames: [String: String] // "US-TX" -> "Texas"
|
||||
|
||||
init() {
|
||||
guard let url = Bundle.main.url(forResource: "airports", withExtension: "json"),
|
||||
let data = try? Data(contentsOf: url),
|
||||
let decoded = try? JSONDecoder().decode([MapAirport].self, from: data)
|
||||
else {
|
||||
airports = []
|
||||
regionNames = [:]
|
||||
return
|
||||
}
|
||||
airports = decoded
|
||||
regionNames = Self.buildRegionNames()
|
||||
}
|
||||
|
||||
/// Returns airports within the given map region, capped at `limit` for performance.
|
||||
func airports(in region: MKCoordinateRegion, limit: Int = 200) -> [MapAirport] {
|
||||
let centerLat = region.center.latitude
|
||||
let centerLng = region.center.longitude
|
||||
let latDelta = region.span.latitudeDelta / 2
|
||||
let lngDelta = region.span.longitudeDelta / 2
|
||||
|
||||
let minLat = centerLat - latDelta
|
||||
let maxLat = centerLat + latDelta
|
||||
let minLng = centerLng - lngDelta
|
||||
let maxLng = centerLng + lngDelta
|
||||
|
||||
var result: [MapAirport] = []
|
||||
for airport in airports {
|
||||
guard airport.lat >= minLat, airport.lat <= maxLat else { continue }
|
||||
|
||||
if minLng <= maxLng {
|
||||
guard airport.lng >= minLng, airport.lng <= maxLng else { continue }
|
||||
} else {
|
||||
guard airport.lng >= minLng || airport.lng <= maxLng else { continue }
|
||||
}
|
||||
|
||||
result.append(airport)
|
||||
if result.count >= limit { break }
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/// Search airports by region/state name (e.g. "Texas" -> all US-TX airports).
|
||||
/// Returns matching region name and airports, or nil if no match.
|
||||
func searchByRegion(term: String) -> (regionName: String, airports: [MapAirport])? {
|
||||
let lowered = term.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard lowered.count >= 3 else { return nil }
|
||||
|
||||
// Find matching region
|
||||
var matchedCode: String?
|
||||
var matchedName: String?
|
||||
|
||||
for (code, name) in regionNames {
|
||||
if name.lowercased().hasPrefix(lowered) || name.lowercased() == lowered {
|
||||
matchedCode = code
|
||||
matchedName = name
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard let code = matchedCode, let name = matchedName else { return nil }
|
||||
|
||||
let results = airports.filter { $0.region == code }
|
||||
.sorted { $0.name.localizedCompare($1.name) == .orderedAscending }
|
||||
|
||||
guard !results.isEmpty else { return nil }
|
||||
return (regionName: name, airports: results)
|
||||
}
|
||||
|
||||
private static func buildRegionNames() -> [String: String] {
|
||||
// US states + territories
|
||||
var names: [String: String] = [
|
||||
"US-AL": "Alabama", "US-AK": "Alaska", "US-AZ": "Arizona", "US-AR": "Arkansas",
|
||||
"US-CA": "California", "US-CO": "Colorado", "US-CT": "Connecticut", "US-DE": "Delaware",
|
||||
"US-FL": "Florida", "US-GA": "Georgia", "US-HI": "Hawaii", "US-ID": "Idaho",
|
||||
"US-IL": "Illinois", "US-IN": "Indiana", "US-IA": "Iowa", "US-KS": "Kansas",
|
||||
"US-KY": "Kentucky", "US-LA": "Louisiana", "US-ME": "Maine", "US-MD": "Maryland",
|
||||
"US-MA": "Massachusetts", "US-MI": "Michigan", "US-MN": "Minnesota", "US-MS": "Mississippi",
|
||||
"US-MO": "Missouri", "US-MT": "Montana", "US-NE": "Nebraska", "US-NV": "Nevada",
|
||||
"US-NH": "New Hampshire", "US-NJ": "New Jersey", "US-NM": "New Mexico", "US-NY": "New York",
|
||||
"US-NC": "North Carolina", "US-ND": "North Dakota", "US-OH": "Ohio", "US-OK": "Oklahoma",
|
||||
"US-OR": "Oregon", "US-PA": "Pennsylvania", "US-RI": "Rhode Island", "US-SC": "South Carolina",
|
||||
"US-SD": "South Dakota", "US-TN": "Tennessee", "US-TX": "Texas", "US-UT": "Utah",
|
||||
"US-VT": "Vermont", "US-VA": "Virginia", "US-WA": "Washington", "US-WV": "West Virginia",
|
||||
"US-WI": "Wisconsin", "US-WY": "Wyoming", "US-DC": "District of Columbia",
|
||||
"US-PR": "Puerto Rico", "US-VI": "U.S. Virgin Islands", "US-GU": "Guam",
|
||||
"US-AS": "American Samoa",
|
||||
// Canadian provinces
|
||||
"CA-AB": "Alberta", "CA-BC": "British Columbia", "CA-MB": "Manitoba",
|
||||
"CA-NB": "New Brunswick", "CA-NL": "Newfoundland and Labrador",
|
||||
"CA-NS": "Nova Scotia", "CA-NT": "Northwest Territories", "CA-NU": "Nunavut",
|
||||
"CA-ON": "Ontario", "CA-PE": "Prince Edward Island", "CA-QC": "Quebec",
|
||||
"CA-SK": "Saskatchewan", "CA-YT": "Yukon",
|
||||
// Australian states
|
||||
"AU-NSW": "New South Wales", "AU-VIC": "Victoria", "AU-QLD": "Queensland",
|
||||
"AU-WA": "Western Australia", "AU-SA": "South Australia", "AU-TAS": "Tasmania",
|
||||
"AU-NT": "Northern Territory", "AU-ACT": "Australian Capital Territory",
|
||||
]
|
||||
return names
|
||||
}
|
||||
}
|
||||
507
Flights/Services/FlightService.swift
Normal file
@@ -0,0 +1,507 @@
|
||||
import Foundation
|
||||
|
||||
actor FlightService {
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
private let session: URLSession
|
||||
private let baseURL = "https://www.flightconnections.com"
|
||||
|
||||
private static let userAgent =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||
|
||||
private var cachedCountries: [Country]?
|
||||
private var cachedAirportsByCountry: [String: [BrowseAirport]] = [:]
|
||||
|
||||
init(session: URLSession = .shared) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Search airports by term (autocomplete).
|
||||
func searchAirports(term: String) async throws -> [Airport] {
|
||||
let results = try await searchAll(term: term)
|
||||
return results.airports
|
||||
}
|
||||
|
||||
/// Search airports, countries, and continents by term.
|
||||
func searchAll(term: String) async throws -> AutocompleteResults {
|
||||
guard let encoded = term.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
||||
let url = URL(string: "\(baseURL)/autocomplete_location.php?lang=en&term=\(encoded)")
|
||||
else { throw FlightServiceError.invalidURL }
|
||||
|
||||
let request = makeRequest(url: url)
|
||||
let data = try await perform(request)
|
||||
let json = try parseJSON(data)
|
||||
|
||||
guard let dict = json as? [String: Any] else {
|
||||
return AutocompleteResults(airports: [], countries: [])
|
||||
}
|
||||
|
||||
let airportEntries = dict["airports"] as? [[String: Any]] ?? []
|
||||
let airports: [Airport] = airportEntries.compactMap { entry in
|
||||
guard let value = entry["value"] as? String,
|
||||
let id = entry["id"] as? String
|
||||
else { return nil }
|
||||
|
||||
let parts = value.split(separator: " - ", maxSplits: 1)
|
||||
guard parts.count == 2 else { return nil }
|
||||
|
||||
let iata = String(parts[0]).trimmingCharacters(in: .whitespaces)
|
||||
let name = String(parts[1]).trimmingCharacters(in: .whitespaces)
|
||||
return Airport(id: id, iata: iata, name: name)
|
||||
}
|
||||
|
||||
let countryEntries = dict["countries"] as? [[String: Any]] ?? []
|
||||
let countries: [Country] = countryEntries.compactMap { entry in
|
||||
guard let name = entry["name"] as? String,
|
||||
let code = entry["code"] as? String
|
||||
else { return nil }
|
||||
let slug = name.lowercased().replacingOccurrences(of: " ", with: "-")
|
||||
return Country(id: code, name: name, slug: slug)
|
||||
}
|
||||
|
||||
return AutocompleteResults(airports: airports, countries: countries)
|
||||
}
|
||||
|
||||
/// Fetch all nonstop destinations from a given airport.
|
||||
func destinations(for airportId: String) async throws -> [Route] {
|
||||
guard let url = URL(string: "\(baseURL)/rt\(airportId).json") else {
|
||||
throw FlightServiceError.invalidURL
|
||||
}
|
||||
|
||||
let request = makeRequest(url: url)
|
||||
let data = try await perform(request)
|
||||
let json = try parseJSON(data)
|
||||
|
||||
guard let dict = json as? [String: Any],
|
||||
let pts = dict["pts"] as? [Int],
|
||||
let crd = dict["crd"] as? [Double],
|
||||
let mths = dict["mths"] as? [Int],
|
||||
let clss = dict["clss"] as? [Int],
|
||||
let lst = dict["lst"] as? [[Any]]
|
||||
else { throw FlightServiceError.invalidResponse }
|
||||
|
||||
// pts[0] is origin; lst[i] maps to pts[i+1]
|
||||
var routes: [Route] = []
|
||||
for (i, item) in lst.enumerated() {
|
||||
guard item.count >= 7,
|
||||
let iata = item[0] as? String,
|
||||
let city = item[1] as? String,
|
||||
let countryCode = item[2] as? String,
|
||||
let country = item[3] as? String
|
||||
else { continue }
|
||||
|
||||
let durationStr = item[4] as? String ?? "\(item[4])"
|
||||
let distanceStr = item[5] as? String ?? "\(item[5])"
|
||||
let stopIndicator = item[6] as? String ?? "\(item[6])"
|
||||
|
||||
let duration = Int(durationStr) ?? 0
|
||||
let distance = Int(distanceStr) ?? 0
|
||||
|
||||
let ptsIndex = i + 1
|
||||
let destId = ptsIndex < pts.count ? String(pts[ptsIndex]) : ""
|
||||
let monthBitmask = ptsIndex < mths.count ? mths[ptsIndex] : 0
|
||||
let cabinBitmask = i < clss.count ? clss[i] : 0
|
||||
|
||||
let crdIndex = 2 * (i + 1)
|
||||
let lat = crdIndex < crd.count ? crd[crdIndex] : 0
|
||||
let lng = (crdIndex + 1) < crd.count ? crd[crdIndex + 1] : 0
|
||||
|
||||
let airport = Airport(
|
||||
id: destId,
|
||||
iata: iata,
|
||||
name: city,
|
||||
countryCode: countryCode,
|
||||
country: country
|
||||
)
|
||||
|
||||
routes.append(Route(
|
||||
destinationAirport: airport,
|
||||
durationMinutes: duration,
|
||||
distanceMiles: distance,
|
||||
monthsBitmask: monthBitmask,
|
||||
cabinClasses: .from(bitmask: cabinBitmask),
|
||||
latitude: lat,
|
||||
longitude: lng
|
||||
))
|
||||
}
|
||||
|
||||
return routes
|
||||
}
|
||||
|
||||
/// Fetch airlines operating a specific route.
|
||||
func airlines(from depId: String, to desId: String) async throws -> [RouteAirline] {
|
||||
guard let url = URL(string: "\(baseURL)/rt\(depId)_\(desId).json") else {
|
||||
throw FlightServiceError.invalidURL
|
||||
}
|
||||
|
||||
let request = makeRequest(url: url)
|
||||
let data = try await perform(request)
|
||||
let json = try parseJSON(data)
|
||||
|
||||
guard let dict = json as? [String: Any],
|
||||
let dataArray = dict["data"] as? [[String: Any]]
|
||||
else { throw FlightServiceError.invalidResponse }
|
||||
|
||||
return dataArray.compactMap { entry in
|
||||
guard let routeArray = entry["route"] as? [Any],
|
||||
routeArray.count >= 5,
|
||||
let dist = entry["dist"] as? Int,
|
||||
let dur = entry["dur"] as? Int,
|
||||
let mths = entry["mths"] as? Int
|
||||
else { return nil }
|
||||
|
||||
let airlineIdRaw = routeArray[0]
|
||||
let airlineId: String
|
||||
if let intId = airlineIdRaw as? Int {
|
||||
airlineId = String(intId)
|
||||
} else if let strId = airlineIdRaw as? String {
|
||||
airlineId = strId
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let name = routeArray[2] as? String,
|
||||
let logoFilename = routeArray[3] as? String,
|
||||
let iata = routeArray[4] as? String
|
||||
else { return nil }
|
||||
|
||||
let airline = Airline(
|
||||
id: airlineId,
|
||||
name: name,
|
||||
iata: iata,
|
||||
logoFilename: logoFilename
|
||||
)
|
||||
|
||||
return RouteAirline(
|
||||
airline: airline,
|
||||
distanceMiles: dist,
|
||||
durationMinutes: dur,
|
||||
monthsBitmask: mths
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch flight schedules for a specific airline on a route.
|
||||
func schedules(
|
||||
dep depId: String,
|
||||
des desId: String,
|
||||
airlineId: String,
|
||||
airline: Airline
|
||||
) async throws -> [FlightSchedule] {
|
||||
guard let url = URL(string: "\(baseURL)/validity.php") else {
|
||||
throw FlightServiceError.invalidURL
|
||||
}
|
||||
|
||||
let calendar = Calendar.current
|
||||
let currentYear = calendar.component(.year, from: Date())
|
||||
let nextYear = currentYear + 1
|
||||
|
||||
var request = makeRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue(
|
||||
"application/x-www-form-urlencoded; charset=UTF-8",
|
||||
forHTTPHeaderField: "Content-Type"
|
||||
)
|
||||
|
||||
let body = "dep=\(depId)&des=\(desId)&id=\(airlineId)&startDate=\(currentYear)&endDate=\(nextYear)&lang=en"
|
||||
request.httpBody = body.data(using: .utf8)
|
||||
|
||||
let data = try await perform(request)
|
||||
let json = try parseJSON(data)
|
||||
|
||||
guard let dict = json as? [String: Any],
|
||||
let flights = dict["flights"] as? [[String: Any]]
|
||||
else { return [] }
|
||||
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = "yyyy-MM-dd"
|
||||
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
dateFormatter.timeZone = TimeZone(identifier: "UTC")
|
||||
|
||||
let dayKeys: [(String, Int)] = [
|
||||
("su", 1), ("mo", 2), ("tu", 3), ("we", 4),
|
||||
("th", 5), ("fr", 6), ("sa", 7)
|
||||
]
|
||||
|
||||
return flights.compactMap { flight in
|
||||
guard let rawFlightNumber = flight["flightnumber"] as? String,
|
||||
let aircraft = flight["aircraft"] as? String,
|
||||
let deptime = flight["deptime"] as? String,
|
||||
let destime = flight["destime"] as? String,
|
||||
let dateFromStr = flight["datefrom"] as? String,
|
||||
let dateToStr = flight["dateto"] as? String,
|
||||
let dateFrom = dateFormatter.date(from: dateFromStr),
|
||||
let dateTo = dateFormatter.date(from: dateToStr)
|
||||
else { return nil }
|
||||
|
||||
let flightNumber = rawFlightNumber
|
||||
.split(separator: " ")
|
||||
.joined(separator: " ")
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
let acId: String
|
||||
if let idVal = flight["ac_id"] {
|
||||
acId = "\(idVal)"
|
||||
} else {
|
||||
acId = ""
|
||||
}
|
||||
|
||||
var days = Set<Int>()
|
||||
for (key, weekday) in dayKeys {
|
||||
if let val = flight[key] as? String, val == "1" {
|
||||
days.insert(weekday)
|
||||
}
|
||||
}
|
||||
|
||||
let classesStr = flight["classes"] as? String ?? "0000"
|
||||
let cabinClasses = CabinClass.from(classesString: classesStr)
|
||||
|
||||
// Strip seconds from time strings: "16:45:00" -> "16:45"
|
||||
let depTimeFormatted = formatTime(deptime)
|
||||
let arrTimeFormatted = formatTime(destime)
|
||||
|
||||
return FlightSchedule(
|
||||
airline: airline,
|
||||
flightNumber: flightNumber,
|
||||
aircraft: aircraft,
|
||||
aircraftId: acId,
|
||||
departureTime: depTimeFormatted,
|
||||
arrivalTime: arrTimeFormatted,
|
||||
dateFrom: dateFrom,
|
||||
dateTo: dateTo,
|
||||
daysOfWeek: days,
|
||||
cabinClasses: cabinClasses
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch all flight schedules for a route across all airlines.
|
||||
/// Reports progress via the `onProgress` closure with (completed, total) counts.
|
||||
func allSchedules(
|
||||
dep depId: String,
|
||||
des desId: String,
|
||||
onProgress: @Sendable @escaping (Int, Int) -> Void
|
||||
) async throws -> [FlightSchedule] {
|
||||
let routeAirlines = try await airlines(from: depId, to: desId)
|
||||
let total = routeAirlines.count
|
||||
|
||||
if total == 0 { return [] }
|
||||
|
||||
let completed = Counter()
|
||||
|
||||
let results: [FlightSchedule] = try await withThrowingTaskGroup(of: [FlightSchedule].self) { group in
|
||||
var allSchedules: [FlightSchedule] = []
|
||||
var launched = 0
|
||||
var index = 0
|
||||
|
||||
// Launch initial batch (up to 3)
|
||||
while index < routeAirlines.count, launched < 3 {
|
||||
let ra = routeAirlines[index]
|
||||
group.addTask { [self] in
|
||||
try await self.schedules(
|
||||
dep: depId,
|
||||
des: desId,
|
||||
airlineId: ra.airline.id,
|
||||
airline: ra.airline
|
||||
)
|
||||
}
|
||||
launched += 1
|
||||
index += 1
|
||||
}
|
||||
|
||||
// As each completes, launch next
|
||||
for try await flights in group {
|
||||
allSchedules.append(contentsOf: flights)
|
||||
let current = await completed.increment()
|
||||
onProgress(current, total)
|
||||
|
||||
if index < routeAirlines.count {
|
||||
let ra = routeAirlines[index]
|
||||
group.addTask { [self] in
|
||||
try await self.schedules(
|
||||
dep: depId,
|
||||
des: desId,
|
||||
airlineId: ra.airline.id,
|
||||
airline: ra.airline
|
||||
)
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
return allSchedules
|
||||
}
|
||||
|
||||
return results.sorted { $0.departureTime < $1.departureTime }
|
||||
}
|
||||
|
||||
// MARK: - Browse API
|
||||
|
||||
/// Fetch all countries that have airports.
|
||||
func fetchCountries() async throws -> [Country] {
|
||||
if let cached = cachedCountries { return cached }
|
||||
|
||||
guard let url = URL(string: "\(baseURL)/airports-by-country") else {
|
||||
throw FlightServiceError.invalidURL
|
||||
}
|
||||
|
||||
let request = makeRequest(url: url)
|
||||
let data = try await perform(request)
|
||||
|
||||
guard let html = String(data: data, encoding: .utf8) else {
|
||||
throw FlightServiceError.invalidResponse
|
||||
}
|
||||
|
||||
// Pattern: href="/airports-in-{slug}-{code}"
|
||||
// Extract country name from slug (convert hyphens to spaces, title case)
|
||||
let pattern = try NSRegularExpression(
|
||||
pattern: #"href="/airports-in-([a-z-]+)-([a-z]{2})""#
|
||||
)
|
||||
let matches = pattern.matches(in: html, range: NSRange(html.startIndex..., in: html))
|
||||
|
||||
var seen = Set<String>()
|
||||
var countries: [Country] = []
|
||||
|
||||
for match in matches {
|
||||
guard let slugRange = Range(match.range(at: 1), in: html),
|
||||
let codeRange = Range(match.range(at: 2), in: html) else { continue }
|
||||
|
||||
let slug = String(html[slugRange])
|
||||
let code = String(html[codeRange]).uppercased()
|
||||
|
||||
guard !seen.contains(code) else { continue }
|
||||
seen.insert(code)
|
||||
|
||||
let name = slug.split(separator: "-")
|
||||
.map { $0.prefix(1).uppercased() + $0.dropFirst() }
|
||||
.joined(separator: " ")
|
||||
|
||||
countries.append(Country(id: code, name: name, slug: slug))
|
||||
}
|
||||
|
||||
countries.sort { $0.name.localizedCompare($1.name) == .orderedAscending }
|
||||
cachedCountries = countries
|
||||
return countries
|
||||
}
|
||||
|
||||
/// Fetch all airports in a country.
|
||||
func fetchAirports(country: Country) async throws -> [BrowseAirport] {
|
||||
if let cached = cachedAirportsByCountry[country.id] { return cached }
|
||||
|
||||
guard let url = URL(string: "\(baseURL)/airports-in-\(country.slug)-\(country.id.lowercased())") else {
|
||||
throw FlightServiceError.invalidURL
|
||||
}
|
||||
|
||||
let request = makeRequest(url: url)
|
||||
let data = try await perform(request)
|
||||
|
||||
guard let html = String(data: data, encoding: .utf8) else {
|
||||
throw FlightServiceError.invalidResponse
|
||||
}
|
||||
|
||||
// Pattern: title="Direct flights from {City} ({IATA})"
|
||||
// or: title="Flights from {City} ({IATA})"
|
||||
let pattern = try NSRegularExpression(
|
||||
pattern: #"title="(?:Direct )?[Ff]lights from ([^(]+)\(([A-Z]{3})\)""#
|
||||
)
|
||||
let matches = pattern.matches(in: html, range: NSRange(html.startIndex..., in: html))
|
||||
|
||||
var seen = Set<String>()
|
||||
var airports: [BrowseAirport] = []
|
||||
|
||||
for match in matches {
|
||||
guard let cityRange = Range(match.range(at: 1), in: html),
|
||||
let iataRange = Range(match.range(at: 2), in: html) else { continue }
|
||||
|
||||
let city = String(html[cityRange]).trimmingCharacters(in: .whitespaces)
|
||||
let iata = String(html[iataRange])
|
||||
|
||||
guard !seen.contains(iata) else { continue }
|
||||
seen.insert(iata)
|
||||
|
||||
airports.append(BrowseAirport(id: iata, iata: iata, city: city))
|
||||
}
|
||||
|
||||
airports.sort { $0.city.localizedCompare($1.city) == .orderedAscending }
|
||||
cachedAirportsByCountry[country.id] = airports
|
||||
return airports
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func makeRequest(url: URL) -> URLRequest {
|
||||
var request = URLRequest(url: url)
|
||||
request.setValue(Self.userAgent, forHTTPHeaderField: "User-Agent")
|
||||
request.setValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With")
|
||||
request.setValue("https://www.flightconnections.com/", forHTTPHeaderField: "Referer")
|
||||
return request
|
||||
}
|
||||
|
||||
private func perform(_ request: URLRequest) async throws -> Data {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw FlightServiceError.invalidResponse
|
||||
}
|
||||
guard (200...299).contains(http.statusCode) else {
|
||||
throw FlightServiceError.httpError(http.statusCode)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
private func parseJSON(_ data: Data) throws -> Any {
|
||||
do {
|
||||
return try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])
|
||||
} catch {
|
||||
throw FlightServiceError.decodingFailed(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func formatTime(_ raw: String) -> String {
|
||||
let components = raw.split(separator: ":")
|
||||
if components.count >= 2 {
|
||||
return "\(components[0]):\(components[1])"
|
||||
}
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Concurrency Helper
|
||||
|
||||
private actor Counter {
|
||||
private var value = 0
|
||||
|
||||
func increment() -> Int {
|
||||
value += 1
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
struct AutocompleteResults: Sendable {
|
||||
let airports: [Airport]
|
||||
let countries: [Country]
|
||||
}
|
||||
|
||||
enum FlightServiceError: LocalizedError {
|
||||
case invalidURL
|
||||
case invalidResponse
|
||||
case httpError(Int)
|
||||
case decodingFailed(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL:
|
||||
return "Invalid URL"
|
||||
case .invalidResponse:
|
||||
return "Invalid response from server"
|
||||
case .httpError(let code):
|
||||
return "HTTP error \(code)"
|
||||
case .decodingFailed(let error):
|
||||
return "Failed to decode response: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
56
Flights/ViewModels/DestinationsViewModel.swift
Normal file
@@ -0,0 +1,56 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class DestinationsViewModel {
|
||||
|
||||
// MARK: - State
|
||||
|
||||
var routes: [Route] = []
|
||||
var isLoading: Bool = false
|
||||
var error: String?
|
||||
var selectedDate: Date
|
||||
|
||||
// MARK: - Computed
|
||||
|
||||
var filteredRoutes: [Route] {
|
||||
let month = Calendar.current.component(.month, from: selectedDate)
|
||||
return routes
|
||||
.filter { $0.availableIn(month: month) }
|
||||
.sorted { $0.destinationAirport.name.localizedCompare($1.destinationAirport.name) == .orderedAscending }
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private let service: FlightService
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
init(service: FlightService, date: Date) {
|
||||
self.service = service
|
||||
self.selectedDate = date
|
||||
}
|
||||
|
||||
// MARK: - Loading
|
||||
|
||||
func loadDestinations(airportId: String) async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
|
||||
do {
|
||||
let results = try await service.destinations(for: airportId)
|
||||
if !Task.isCancelled {
|
||||
routes = results
|
||||
}
|
||||
} catch {
|
||||
if !Task.isCancelled {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
if !Task.isCancelled {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
72
Flights/ViewModels/RouteDetailViewModel.swift
Normal file
@@ -0,0 +1,72 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class RouteDetailViewModel {
|
||||
|
||||
// MARK: - State
|
||||
|
||||
var allSchedules: [FlightSchedule] = []
|
||||
var isLoading: Bool = false
|
||||
var error: String?
|
||||
var selectedDate: Date
|
||||
var loadingProgress: (completed: Int, total: Int)?
|
||||
|
||||
// MARK: - Computed
|
||||
|
||||
var flightsForDate: [FlightSchedule] {
|
||||
allSchedules
|
||||
.filter { $0.operatesOn(date: selectedDate) }
|
||||
.sorted { $0.departureTime < $1.departureTime }
|
||||
}
|
||||
|
||||
var airlineGroups: [(airline: Airline, flights: [FlightSchedule])] {
|
||||
let flights = flightsForDate
|
||||
let grouped = Dictionary(grouping: flights) { $0.airline.id }
|
||||
|
||||
return grouped
|
||||
.map { (_, schedules) in
|
||||
(airline: schedules[0].airline, flights: schedules.sorted { $0.departureTime < $1.departureTime })
|
||||
}
|
||||
.sorted { $0.airline.name.localizedCompare($1.airline.name) == .orderedAscending }
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private let service: FlightService
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
init(service: FlightService, date: Date) {
|
||||
self.service = service
|
||||
self.selectedDate = date
|
||||
}
|
||||
|
||||
// MARK: - Loading
|
||||
|
||||
func loadSchedules(dep: String, des: String) async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
loadingProgress = nil
|
||||
|
||||
do {
|
||||
let results = try await service.allSchedules(dep: dep, des: des) { [weak self] completed, total in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.loadingProgress = (completed: completed, total: total)
|
||||
}
|
||||
}
|
||||
if !Task.isCancelled {
|
||||
allSchedules = results
|
||||
}
|
||||
} catch {
|
||||
if !Task.isCancelled {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
if !Task.isCancelled {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
168
Flights/ViewModels/SearchViewModel.swift
Normal file
@@ -0,0 +1,168 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class SearchViewModel {
|
||||
|
||||
// MARK: - Published State
|
||||
|
||||
var departureAirport: Airport?
|
||||
var arrivalAirport: Airport?
|
||||
var selectedDate: Date = .now
|
||||
|
||||
var departureSearchText: String = ""
|
||||
var arrivalSearchText: String = ""
|
||||
|
||||
var departureSuggestions: [Airport] = []
|
||||
var arrivalSuggestions: [Airport] = []
|
||||
|
||||
var departureCountrySuggestions: [Country] = []
|
||||
var arrivalCountrySuggestions: [Country] = []
|
||||
|
||||
var departureRegionResult: (regionName: String, airports: [MapAirport])?
|
||||
var arrivalRegionResult: (regionName: String, airports: [MapAirport])?
|
||||
|
||||
var isDepartureSearching: Bool = false
|
||||
var isArrivalSearching: Bool = false
|
||||
|
||||
// MARK: - Computed
|
||||
|
||||
var canSearch: Bool {
|
||||
departureAirport != nil || arrivalAirport != nil
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private let service: FlightService
|
||||
private let database: AirportDatabase
|
||||
private var departureSearchTask: Task<Void, Never>?
|
||||
private var arrivalSearchTask: Task<Void, Never>?
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
init(service: FlightService, database: AirportDatabase) {
|
||||
self.service = service
|
||||
self.database = database
|
||||
}
|
||||
|
||||
// MARK: - Departure Search
|
||||
|
||||
func departureTextChanged() {
|
||||
departureSearchTask?.cancel()
|
||||
guard departureAirport == nil else { return }
|
||||
|
||||
let term = departureSearchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !term.isEmpty else {
|
||||
departureSuggestions = []
|
||||
departureCountrySuggestions = []
|
||||
departureRegionResult = nil
|
||||
isDepartureSearching = false
|
||||
return
|
||||
}
|
||||
|
||||
// Immediate local region search
|
||||
departureRegionResult = database.searchByRegion(term: term)
|
||||
|
||||
departureSearchTask = Task {
|
||||
try? await Task.sleep(for: .milliseconds(300))
|
||||
guard !Task.isCancelled else { return }
|
||||
isDepartureSearching = true
|
||||
do {
|
||||
let results = try await service.searchAll(term: term)
|
||||
if !Task.isCancelled {
|
||||
departureSuggestions = results.airports
|
||||
departureCountrySuggestions = results.countries
|
||||
}
|
||||
} catch {
|
||||
if !Task.isCancelled {
|
||||
departureSuggestions = []
|
||||
departureCountrySuggestions = []
|
||||
}
|
||||
}
|
||||
if !Task.isCancelled {
|
||||
isDepartureSearching = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func selectDeparture(_ airport: Airport) {
|
||||
departureAirport = airport
|
||||
departureSearchTask?.cancel()
|
||||
departureSearchText = "\(airport.iata) - \(airport.name)"
|
||||
departureSuggestions = []
|
||||
departureCountrySuggestions = []
|
||||
departureRegionResult = nil
|
||||
isDepartureSearching = false
|
||||
}
|
||||
|
||||
func clearDeparture() {
|
||||
departureAirport = nil
|
||||
departureSearchTask?.cancel()
|
||||
departureSearchText = ""
|
||||
departureSuggestions = []
|
||||
departureCountrySuggestions = []
|
||||
departureRegionResult = nil
|
||||
isDepartureSearching = false
|
||||
}
|
||||
|
||||
// MARK: - Arrival Search
|
||||
|
||||
func arrivalTextChanged() {
|
||||
arrivalSearchTask?.cancel()
|
||||
guard arrivalAirport == nil else { return }
|
||||
|
||||
let term = arrivalSearchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !term.isEmpty else {
|
||||
arrivalSuggestions = []
|
||||
arrivalCountrySuggestions = []
|
||||
arrivalRegionResult = nil
|
||||
isArrivalSearching = false
|
||||
return
|
||||
}
|
||||
|
||||
// Immediate local region search
|
||||
arrivalRegionResult = database.searchByRegion(term: term)
|
||||
|
||||
arrivalSearchTask = Task {
|
||||
try? await Task.sleep(for: .milliseconds(300))
|
||||
guard !Task.isCancelled else { return }
|
||||
isArrivalSearching = true
|
||||
do {
|
||||
let results = try await service.searchAll(term: term)
|
||||
if !Task.isCancelled {
|
||||
arrivalSuggestions = results.airports
|
||||
arrivalCountrySuggestions = results.countries
|
||||
}
|
||||
} catch {
|
||||
if !Task.isCancelled {
|
||||
arrivalSuggestions = []
|
||||
arrivalCountrySuggestions = []
|
||||
}
|
||||
}
|
||||
if !Task.isCancelled {
|
||||
isArrivalSearching = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func selectArrival(_ airport: Airport) {
|
||||
arrivalAirport = airport
|
||||
arrivalSearchTask?.cancel()
|
||||
arrivalSearchText = "\(airport.iata) - \(airport.name)"
|
||||
arrivalSuggestions = []
|
||||
arrivalCountrySuggestions = []
|
||||
arrivalRegionResult = nil
|
||||
isArrivalSearching = false
|
||||
}
|
||||
|
||||
func clearArrival() {
|
||||
arrivalAirport = nil
|
||||
arrivalSearchTask?.cancel()
|
||||
arrivalSearchText = ""
|
||||
arrivalSuggestions = []
|
||||
arrivalCountrySuggestions = []
|
||||
arrivalRegionResult = nil
|
||||
isArrivalSearching = false
|
||||
}
|
||||
}
|
||||
203
Flights/Views/AirportBrowserSheet.swift
Normal file
@@ -0,0 +1,203 @@
|
||||
import SwiftUI
|
||||
|
||||
private enum BrowseMode: String, CaseIterable {
|
||||
case list = "List"
|
||||
case map = "Map"
|
||||
}
|
||||
|
||||
struct AirportBrowserSheet: View {
|
||||
let service: FlightService
|
||||
let database: AirportDatabase
|
||||
let onSelect: (Airport) -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var browseMode: BrowseMode = .list
|
||||
@State private var countries: [Country] = []
|
||||
@State private var isLoadingCountries = true
|
||||
@State private var countrySearch = ""
|
||||
@State private var error: String?
|
||||
|
||||
var filteredCountries: [Country] {
|
||||
guard !countrySearch.isEmpty else { return countries }
|
||||
return countries.filter {
|
||||
$0.name.localizedCaseInsensitiveContains(countrySearch) ||
|
||||
$0.id.localizedCaseInsensitiveContains(countrySearch)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
Picker("Browse Mode", selection: $browseMode) {
|
||||
ForEach(BrowseMode.allCases, id: \.self) { mode in
|
||||
Text(mode.rawValue)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding()
|
||||
|
||||
switch browseMode {
|
||||
case .list:
|
||||
listContent
|
||||
case .map:
|
||||
AirportMapView(
|
||||
database: database,
|
||||
service: service,
|
||||
onSelect: { airport in
|
||||
onSelect(airport)
|
||||
dismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Browse Airports")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await loadCountries()
|
||||
}
|
||||
.navigationDestination(for: Country.self) { country in
|
||||
CountryAirportsView(
|
||||
country: country,
|
||||
service: service,
|
||||
onSelect: { airport in
|
||||
onSelect(airport)
|
||||
dismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var listContent: some View {
|
||||
if isLoadingCountries {
|
||||
Spacer()
|
||||
ProgressView("Loading countries...")
|
||||
Spacer()
|
||||
} else if let error {
|
||||
ContentUnavailableView {
|
||||
Label("Error", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(error)
|
||||
}
|
||||
} else {
|
||||
List(filteredCountries) { country in
|
||||
NavigationLink(value: country) {
|
||||
Text(country.name)
|
||||
}
|
||||
}
|
||||
.searchable(text: $countrySearch, prompt: "Search countries")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
extension AirportBrowserSheet {
|
||||
func loadCountries() async {
|
||||
do {
|
||||
countries = try await service.fetchCountries()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoadingCountries = false
|
||||
}
|
||||
}
|
||||
|
||||
private struct CountryAirportsView: View {
|
||||
let country: Country
|
||||
let service: FlightService
|
||||
let onSelect: (Airport) -> Void
|
||||
|
||||
@State private var airports: [BrowseAirport] = []
|
||||
@State private var isLoading = true
|
||||
@State private var error: String?
|
||||
@State private var airportSearch = ""
|
||||
@State private var selectingIATA: String?
|
||||
|
||||
var filteredAirports: [BrowseAirport] {
|
||||
guard !airportSearch.isEmpty else { return airports }
|
||||
return airports.filter {
|
||||
$0.city.localizedCaseInsensitiveContains(airportSearch) ||
|
||||
$0.iata.localizedCaseInsensitiveContains(airportSearch)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView("Loading airports...")
|
||||
} else if let error {
|
||||
ContentUnavailableView {
|
||||
Label("Error", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(error)
|
||||
}
|
||||
} else if airports.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Airports",
|
||||
systemImage: "airplane.slash",
|
||||
description: Text("No airports found in \(country.name).")
|
||||
)
|
||||
} else {
|
||||
List(filteredAirports) { airport in
|
||||
Button {
|
||||
selectAirport(airport)
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(airport.city)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.primary)
|
||||
Text(airport.iata)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if selectingIATA == airport.iata {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(selectingIATA != nil)
|
||||
}
|
||||
.searchable(text: $airportSearch, prompt: "Search airports")
|
||||
}
|
||||
}
|
||||
.navigationTitle(country.name)
|
||||
.task {
|
||||
do {
|
||||
airports = try await service.fetchAirports(country: country)
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
private func selectAirport(_ browseAirport: BrowseAirport) {
|
||||
selectingIATA = browseAirport.iata
|
||||
Task {
|
||||
do {
|
||||
let results = try await service.searchAirports(term: browseAirport.iata)
|
||||
if let match = results.first(where: { $0.iata == browseAirport.iata }) {
|
||||
onSelect(match)
|
||||
} else {
|
||||
onSelect(Airport(
|
||||
id: "",
|
||||
iata: browseAirport.iata,
|
||||
name: browseAirport.city
|
||||
))
|
||||
}
|
||||
} catch {
|
||||
selectingIATA = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
129
Flights/Views/AirportMapView.swift
Normal file
@@ -0,0 +1,129 @@
|
||||
import SwiftUI
|
||||
import MapKit
|
||||
|
||||
struct AirportMapView: View {
|
||||
let database: AirportDatabase
|
||||
let service: FlightService
|
||||
let onSelect: (Airport) -> Void
|
||||
|
||||
@State private var position: MapCameraPosition = .automatic
|
||||
@State private var visibleRegion: MKCoordinateRegion?
|
||||
@State private var visibleAirports: [MapAirport] = []
|
||||
@State private var selectedAirport: MapAirport?
|
||||
@State private var isSelecting = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Map(position: $position) {
|
||||
ForEach(visibleAirports) { airport in
|
||||
Annotation(airport.iata, coordinate: airport.coordinate) {
|
||||
Button {
|
||||
selectedAirport = airport
|
||||
} label: {
|
||||
Image(systemName: "airplane.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(selectedAirport?.id == airport.id ? .blue : .red)
|
||||
.background(Circle().fill(.white).padding(2))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onMapCameraChange(frequency: .onEnd) { context in
|
||||
visibleRegion = context.region
|
||||
updateVisibleAirports()
|
||||
}
|
||||
|
||||
if let airport = selectedAirport {
|
||||
VStack {
|
||||
Spacer()
|
||||
airportPopup(airport)
|
||||
.padding()
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: selectedAirport)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
let initial = MKCoordinateRegion(
|
||||
center: CLLocationCoordinate2D(latitude: 30, longitude: -40),
|
||||
span: MKCoordinateSpan(latitudeDelta: 120, longitudeDelta: 180)
|
||||
)
|
||||
position = .region(initial)
|
||||
visibleRegion = initial
|
||||
updateVisibleAirports()
|
||||
}
|
||||
}
|
||||
|
||||
private func airportPopup(_ airport: MapAirport) -> some View {
|
||||
HStack(spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(airport.name)
|
||||
.font(.headline)
|
||||
HStack(spacing: 8) {
|
||||
Text(airport.iata)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.blue)
|
||||
Text(airport.country)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if isSelecting {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Button {
|
||||
selectAirport(airport)
|
||||
} label: {
|
||||
Text("Select")
|
||||
.fontWeight(.semibold)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(.blue)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
selectedAirport = nil
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
|
||||
.shadow(radius: 8)
|
||||
}
|
||||
|
||||
private func updateVisibleAirports() {
|
||||
guard let region = visibleRegion else { return }
|
||||
visibleAirports = database.airports(in: region, limit: 200)
|
||||
}
|
||||
|
||||
private func selectAirport(_ mapAirport: MapAirport) {
|
||||
isSelecting = true
|
||||
Task {
|
||||
do {
|
||||
let results = try await service.searchAirports(term: mapAirport.iata)
|
||||
if let match = results.first(where: { $0.iata == mapAirport.iata }) {
|
||||
onSelect(match)
|
||||
} else {
|
||||
onSelect(Airport(
|
||||
id: "",
|
||||
iata: mapAirport.iata,
|
||||
name: mapAirport.name
|
||||
))
|
||||
}
|
||||
} catch {
|
||||
isSelecting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
342
Flights/Views/AirportSearchField.swift
Normal file
@@ -0,0 +1,342 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AirportSearchField: View {
|
||||
let label: String
|
||||
@Binding var searchText: String
|
||||
@Binding var selectedAirport: Airport?
|
||||
let suggestions: [Airport]
|
||||
let countrySuggestions: [Country]
|
||||
let regionResult: (regionName: String, airports: [MapAirport])?
|
||||
let isSearching: Bool
|
||||
let service: FlightService
|
||||
let database: AirportDatabase
|
||||
let onTextChanged: () -> Void
|
||||
let onSelect: (Airport) -> Void
|
||||
let onClear: () -> Void
|
||||
|
||||
@State private var activeSheet: AirportSheet?
|
||||
|
||||
private enum AirportSheet: Identifiable {
|
||||
case browser
|
||||
case country(Country)
|
||||
case region(name: String, airports: [MapAirport])
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .browser: return "browser"
|
||||
case .country(let c): return "country-\(c.id)"
|
||||
case .region(let name, _): return "region-\(name)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// MARK: - Text Field Row
|
||||
HStack {
|
||||
TextField(label, text: $searchText)
|
||||
.foregroundStyle(.primary)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.characters)
|
||||
.onChange(of: searchText) {
|
||||
onTextChanged()
|
||||
}
|
||||
|
||||
if selectedAirport != nil {
|
||||
Button {
|
||||
onClear()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else if isSearching {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
Button {
|
||||
activeSheet = .browser
|
||||
} label: {
|
||||
Image(systemName: "globe")
|
||||
.foregroundStyle(FlightTheme.accent)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// MARK: - Suggestions
|
||||
let hasResults = !suggestions.isEmpty || !countrySuggestions.isEmpty || regionResult != nil
|
||||
if selectedAirport == nil && hasResults {
|
||||
Divider()
|
||||
.padding(.vertical, 6)
|
||||
|
||||
ForEach(suggestions) { airport in
|
||||
Button {
|
||||
onSelect(airport)
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "airplane")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("\(airport.iata) - \(airport.name)")
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
if airport.id != suggestions.last?.id {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(countrySuggestions) { country in
|
||||
if !suggestions.isEmpty || country.id != countrySuggestions.first?.id {
|
||||
Divider()
|
||||
}
|
||||
|
||||
Button {
|
||||
activeSheet = .country(country)
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "flag")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
Text(country.name)
|
||||
.foregroundStyle(.primary)
|
||||
Text("(\(country.id))")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
if let region = regionResult {
|
||||
if !suggestions.isEmpty || !countrySuggestions.isEmpty {
|
||||
Divider()
|
||||
}
|
||||
|
||||
Button {
|
||||
activeSheet = .region(name: region.regionName, airports: region.airports)
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "map")
|
||||
.font(.caption)
|
||||
.foregroundStyle(FlightTheme.onTime)
|
||||
Text(region.regionName)
|
||||
.foregroundStyle(.primary)
|
||||
Text("(\(region.airports.count) airports)")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(item: $activeSheet) { sheet in
|
||||
switch sheet {
|
||||
case .browser:
|
||||
AirportBrowserSheet(service: service, database: database) { airport in
|
||||
onSelect(airport)
|
||||
activeSheet = nil
|
||||
}
|
||||
case .country(let country):
|
||||
CountryAirportPickerSheet(
|
||||
country: country,
|
||||
service: service,
|
||||
onSelect: { airport in
|
||||
onSelect(airport)
|
||||
activeSheet = nil
|
||||
}
|
||||
)
|
||||
case .region(let name, let airports):
|
||||
RegionAirportPickerSheet(
|
||||
regionName: name,
|
||||
airports: airports,
|
||||
service: service,
|
||||
onSelect: { airport in
|
||||
onSelect(airport)
|
||||
activeSheet = nil
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Country Airport Picker Sheet
|
||||
|
||||
/// Sheet that shows airports in a specific country for direct selection
|
||||
private struct CountryAirportPickerSheet: View {
|
||||
let country: Country
|
||||
let service: FlightService
|
||||
let onSelect: (Airport) -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var airports: [BrowseAirport] = []
|
||||
@State private var isLoading = true
|
||||
@State private var error: String?
|
||||
@State private var search = ""
|
||||
@State private var selectingIATA: String?
|
||||
|
||||
var filteredAirports: [BrowseAirport] {
|
||||
guard !search.isEmpty else { return airports }
|
||||
return airports.filter {
|
||||
$0.city.localizedCaseInsensitiveContains(search) ||
|
||||
$0.iata.localizedCaseInsensitiveContains(search)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView("Loading airports...")
|
||||
} else if airports.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Airports",
|
||||
systemImage: "airplane.slash",
|
||||
description: Text("No airports found in \(country.name).")
|
||||
)
|
||||
} else {
|
||||
List(filteredAirports) { airport in
|
||||
Button {
|
||||
selectAirport(airport)
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(airport.city)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.primary)
|
||||
Text(airport.iata)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if selectingIATA == airport.iata {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(selectingIATA != nil)
|
||||
}
|
||||
.searchable(text: $search, prompt: "Search airports")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Airports in \(country.name)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
}
|
||||
.task {
|
||||
do {
|
||||
airports = try await service.fetchAirports(country: country)
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func selectAirport(_ browseAirport: BrowseAirport) {
|
||||
selectingIATA = browseAirport.iata
|
||||
Task {
|
||||
do {
|
||||
let results = try await service.searchAirports(term: browseAirport.iata)
|
||||
if let match = results.first(where: { $0.iata == browseAirport.iata }) {
|
||||
onSelect(match)
|
||||
} else {
|
||||
onSelect(Airport(id: "", iata: browseAirport.iata, name: browseAirport.city))
|
||||
}
|
||||
} catch {
|
||||
selectingIATA = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Region Airport Picker Sheet
|
||||
|
||||
/// Sheet that shows airports in a region/state for direct selection
|
||||
private struct RegionAirportPickerSheet: View {
|
||||
let regionName: String
|
||||
let airports: [MapAirport]
|
||||
let service: FlightService
|
||||
let onSelect: (Airport) -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var search = ""
|
||||
@State private var selectingIATA: String?
|
||||
|
||||
var filteredAirports: [MapAirport] {
|
||||
guard !search.isEmpty else { return airports }
|
||||
return airports.filter {
|
||||
$0.name.localizedCaseInsensitiveContains(search) ||
|
||||
$0.iata.localizedCaseInsensitiveContains(search)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List(filteredAirports) { airport in
|
||||
Button {
|
||||
selectAirport(airport)
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(airport.name)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.primary)
|
||||
Text(airport.iata)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if selectingIATA == airport.iata {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(selectingIATA != nil)
|
||||
}
|
||||
.searchable(text: $search, prompt: "Search airports")
|
||||
.navigationTitle("Airports in \(regionName)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func selectAirport(_ mapAirport: MapAirport) {
|
||||
selectingIATA = mapAirport.iata
|
||||
Task {
|
||||
do {
|
||||
let results = try await service.searchAirports(term: mapAirport.iata)
|
||||
if let match = results.first(where: { $0.iata == mapAirport.iata }) {
|
||||
onSelect(match)
|
||||
} else {
|
||||
// API doesn't know this airport — use bundled data
|
||||
onSelect(Airport(id: "", iata: mapAirport.iata, name: mapAirport.name))
|
||||
}
|
||||
} catch {
|
||||
selectingIATA = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
130
Flights/Views/Components/RouteVisualization.swift
Normal file
@@ -0,0 +1,130 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RouteVisualization: View {
|
||||
let departureCode: String
|
||||
let arrivalCode: String
|
||||
var departureTime: String?
|
||||
var arrivalTime: String?
|
||||
var codeSize: CGFloat = 28
|
||||
var timeSize: CGFloat = 18
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
// Departure info
|
||||
VStack(spacing: 4) {
|
||||
Text(departureCode)
|
||||
.font(FlightTheme.airportCode(codeSize))
|
||||
.foregroundStyle(FlightTheme.textPrimary)
|
||||
|
||||
if let departureTime {
|
||||
Text(departureTime)
|
||||
.font(FlightTheme.time(timeSize))
|
||||
.foregroundStyle(FlightTheme.textSecondary)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 60)
|
||||
|
||||
// Curved arc with airplane
|
||||
RouteArc()
|
||||
.padding(.top, 4)
|
||||
|
||||
// Arrival info
|
||||
VStack(spacing: 4) {
|
||||
Text(arrivalCode)
|
||||
.font(FlightTheme.airportCode(codeSize))
|
||||
.foregroundStyle(FlightTheme.textPrimary)
|
||||
|
||||
if let arrivalTime {
|
||||
Text(arrivalTime)
|
||||
.font(FlightTheme.time(timeSize))
|
||||
.foregroundStyle(FlightTheme.textSecondary)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Curved Arc Shape
|
||||
|
||||
private struct ArcCurve: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
let startPoint = CGPoint(x: 0, y: rect.height)
|
||||
let endPoint = CGPoint(x: rect.width, y: rect.height)
|
||||
let controlPoint = CGPoint(x: rect.midX, y: rect.height - (rect.width * 0.3))
|
||||
|
||||
path.move(to: startPoint)
|
||||
path.addQuadCurve(to: endPoint, control: controlPoint)
|
||||
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Route Arc View
|
||||
|
||||
private struct RouteArc: View {
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let width = geometry.size.width
|
||||
let height = geometry.size.height
|
||||
let arcHeight = width * 0.3
|
||||
let midX = width / 2
|
||||
|
||||
// The peak of the arc: midpoint of the quadratic bezier
|
||||
// For a quad bezier from (0, h) to (w, h) with control (midX, h - arcH),
|
||||
// the midpoint at t=0.5 is at y = h - arcH/2
|
||||
let airplaneY = height - arcHeight / 2
|
||||
|
||||
ZStack {
|
||||
// Dashed curved arc
|
||||
ArcCurve()
|
||||
.stroke(
|
||||
FlightTheme.routeArc,
|
||||
style: StrokeStyle(
|
||||
lineWidth: 1.5,
|
||||
dash: [6, 4]
|
||||
)
|
||||
)
|
||||
|
||||
// Airplane icon at the top of the arc
|
||||
Image(systemName: "airplane")
|
||||
.font(.system(size: 14, weight: .regular))
|
||||
.foregroundStyle(FlightTheme.textSecondary)
|
||||
.rotationEffect(.degrees(-45))
|
||||
.position(x: midX, y: airplaneY)
|
||||
}
|
||||
}
|
||||
.frame(height: 40)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
VStack(spacing: 40) {
|
||||
RouteVisualization(
|
||||
departureCode: "DFW",
|
||||
arrivalCode: "MAD",
|
||||
departureTime: "16:45",
|
||||
arrivalTime: "09:05"
|
||||
)
|
||||
|
||||
RouteVisualization(
|
||||
departureCode: "JFK",
|
||||
arrivalCode: "LHR"
|
||||
)
|
||||
|
||||
RouteVisualization(
|
||||
departureCode: "SFO",
|
||||
arrivalCode: "NRT",
|
||||
departureTime: "11:30",
|
||||
arrivalTime: "14:55",
|
||||
codeSize: 22,
|
||||
timeSize: 14
|
||||
)
|
||||
}
|
||||
.padding(24)
|
||||
.background(FlightTheme.background)
|
||||
}
|
||||
175
Flights/Views/ContentView.swift
Normal file
@@ -0,0 +1,175 @@
|
||||
import SwiftUI
|
||||
|
||||
enum SearchRoute: Hashable {
|
||||
case destinations(Airport, Date, Bool)
|
||||
case routeDetail(Airport, Airport, Date)
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
let service: FlightService
|
||||
let database: AirportDatabase
|
||||
|
||||
@State private var viewModel: SearchViewModel
|
||||
@State private var path = NavigationPath()
|
||||
|
||||
init(service: FlightService, database: AirportDatabase) {
|
||||
self.service = service
|
||||
self.database = database
|
||||
self._viewModel = State(initialValue: SearchViewModel(service: service, database: database))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(path: $path) {
|
||||
ScrollView {
|
||||
VStack(spacing: FlightTheme.sectionSpacing) {
|
||||
// MARK: - Combined FROM / TO Card
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// FROM section
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label {
|
||||
Text("FROM")
|
||||
.font(FlightTheme.label())
|
||||
.foregroundStyle(.secondary)
|
||||
.tracking(1)
|
||||
} icon: {
|
||||
Image(systemName: "airplane.departure")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
AirportSearchField(
|
||||
label: "Departure Airport",
|
||||
searchText: $viewModel.departureSearchText,
|
||||
selectedAirport: $viewModel.departureAirport,
|
||||
suggestions: viewModel.departureSuggestions,
|
||||
countrySuggestions: viewModel.departureCountrySuggestions,
|
||||
regionResult: viewModel.departureRegionResult,
|
||||
isSearching: viewModel.isDepartureSearching,
|
||||
service: service,
|
||||
database: database,
|
||||
onTextChanged: { viewModel.departureTextChanged() },
|
||||
onSelect: { viewModel.selectDeparture($0) },
|
||||
onClear: { viewModel.clearDeparture() }
|
||||
)
|
||||
}
|
||||
.padding(FlightTheme.cardPadding)
|
||||
|
||||
Divider()
|
||||
.padding(.horizontal, FlightTheme.cardPadding)
|
||||
|
||||
// TO section
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label {
|
||||
Text("TO (OPTIONAL)")
|
||||
.font(FlightTheme.label())
|
||||
.foregroundStyle(.secondary)
|
||||
.tracking(1)
|
||||
} icon: {
|
||||
Image(systemName: "mappin.and.ellipse")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
AirportSearchField(
|
||||
label: "Arrival Airport",
|
||||
searchText: $viewModel.arrivalSearchText,
|
||||
selectedAirport: $viewModel.arrivalAirport,
|
||||
suggestions: viewModel.arrivalSuggestions,
|
||||
countrySuggestions: viewModel.arrivalCountrySuggestions,
|
||||
regionResult: viewModel.arrivalRegionResult,
|
||||
isSearching: viewModel.isArrivalSearching,
|
||||
service: service,
|
||||
database: database,
|
||||
onTextChanged: { viewModel.arrivalTextChanged() },
|
||||
onSelect: { viewModel.selectArrival($0) },
|
||||
onClear: { viewModel.clearArrival() }
|
||||
)
|
||||
}
|
||||
.padding(FlightTheme.cardPadding)
|
||||
}
|
||||
.background(FlightTheme.cardBackground)
|
||||
.clipShape(RoundedRectangle(cornerRadius: FlightTheme.cardCornerRadius))
|
||||
.shadow(color: FlightTheme.cardShadow, radius: 8, y: 2)
|
||||
|
||||
// MARK: - Date Card
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "calendar")
|
||||
.foregroundStyle(FlightTheme.accent)
|
||||
.font(.body)
|
||||
|
||||
DatePicker(
|
||||
"Travel Date",
|
||||
selection: $viewModel.selectedDate,
|
||||
displayedComponents: .date
|
||||
)
|
||||
.labelsHidden()
|
||||
.datePickerStyle(.compact)
|
||||
.tint(FlightTheme.accent)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.flightCard()
|
||||
|
||||
// MARK: - Search Button
|
||||
Button {
|
||||
navigateToResults()
|
||||
} label: {
|
||||
Text("Search Flights")
|
||||
.font(.body.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 50)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [FlightTheme.accent, FlightTheme.accentLight],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.disabled(!viewModel.canSearch)
|
||||
.opacity(viewModel.canSearch ? 1.0 : 0.5)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
.background(FlightTheme.background.ignoresSafeArea())
|
||||
.navigationTitle("Flights")
|
||||
.navigationDestination(for: SearchRoute.self) { route in
|
||||
switch route {
|
||||
case let .destinations(airport, date, isArrival):
|
||||
DestinationsListView(
|
||||
airport: airport,
|
||||
date: date,
|
||||
service: service,
|
||||
isArrival: isArrival
|
||||
)
|
||||
case let .routeDetail(departure, arrival, date):
|
||||
RouteDetailView(
|
||||
departure: departure,
|
||||
arrival: arrival,
|
||||
date: date,
|
||||
service: service
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func navigateToResults() {
|
||||
let date = viewModel.selectedDate
|
||||
|
||||
if let departure = viewModel.departureAirport,
|
||||
let arrival = viewModel.arrivalAirport {
|
||||
path.append(SearchRoute.routeDetail(departure, arrival, date))
|
||||
} else if let departure = viewModel.departureAirport {
|
||||
path.append(SearchRoute.destinations(departure, date, false))
|
||||
} else if let arrival = viewModel.arrivalAirport {
|
||||
path.append(SearchRoute.destinations(arrival, date, true))
|
||||
}
|
||||
}
|
||||
}
|
||||
152
Flights/Views/DestinationsListView.swift
Normal file
@@ -0,0 +1,152 @@
|
||||
import SwiftUI
|
||||
|
||||
struct DestinationsListView: View {
|
||||
let airport: Airport
|
||||
let date: Date
|
||||
let service: FlightService
|
||||
let isArrival: Bool
|
||||
|
||||
@State private var viewModel: DestinationsViewModel
|
||||
|
||||
init(airport: Airport, date: Date, service: FlightService, isArrival: Bool) {
|
||||
self.airport = airport
|
||||
self.date = date
|
||||
self.service = service
|
||||
self.isArrival = isArrival
|
||||
self._viewModel = State(initialValue: DestinationsViewModel(service: service, date: date))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if viewModel.isLoading {
|
||||
ProgressView("Loading destinations...")
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if let error = viewModel.error {
|
||||
ContentUnavailableView {
|
||||
Label("Error", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(error)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if viewModel.filteredRoutes.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Flights",
|
||||
systemImage: "airplane.slash",
|
||||
description: Text("No nonstop flights available in this month.")
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(viewModel.filteredRoutes) { route in
|
||||
NavigationLink(value: searchRoute(for: route)) {
|
||||
routeCard(route)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(FlightTheme.background.ignoresSafeArea())
|
||||
.navigationTitle(isArrival ? "To \(airport.iata)" : "From \(airport.iata)")
|
||||
.navigationDestination(for: SearchRoute.self) { route in
|
||||
switch route {
|
||||
case let .routeDetail(departure, arrival, date):
|
||||
RouteDetailView(
|
||||
departure: departure,
|
||||
arrival: arrival,
|
||||
date: date,
|
||||
service: service
|
||||
)
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.task {
|
||||
let resolvedId = await resolveId(for: airport)
|
||||
await viewModel.loadDestinations(airportId: resolvedId)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Route Card
|
||||
|
||||
private func routeCard(_ route: Route) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
// Green dot + IATA code
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(FlightTheme.onTime)
|
||||
.frame(width: 8, height: 8)
|
||||
|
||||
Text(route.destinationAirport.iata)
|
||||
.font(FlightTheme.airportCode(22))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
|
||||
// City & country
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(route.destinationAirport.name)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
|
||||
if !route.destinationAirport.country.isEmpty {
|
||||
Text(route.destinationAirport.country)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Duration & distance
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(formattedDuration(route.durationMinutes))
|
||||
.font(.subheadline.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text("\(route.distanceMiles) mi")
|
||||
.font(.caption)
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
}
|
||||
}
|
||||
.flightCard(padding: 14)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func searchRoute(for route: Route) -> SearchRoute {
|
||||
if isArrival {
|
||||
return .routeDetail(route.destinationAirport, airport, date)
|
||||
} else {
|
||||
return .routeDetail(airport, route.destinationAirport, date)
|
||||
}
|
||||
}
|
||||
|
||||
private func formattedDuration(_ minutes: Int) -> String {
|
||||
let hours = minutes / 60
|
||||
let mins = minutes % 60
|
||||
if hours > 0 && mins > 0 {
|
||||
return "\(hours)h \(mins)m"
|
||||
} else if hours > 0 {
|
||||
return "\(hours)h"
|
||||
} else {
|
||||
return "\(mins)m"
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveId(for airport: Airport) async -> String {
|
||||
guard airport.id.isEmpty else { return airport.id }
|
||||
do {
|
||||
let results = try await service.searchAirports(term: airport.iata)
|
||||
if let match = results.first(where: { $0.iata == airport.iata }) {
|
||||
return match.id
|
||||
}
|
||||
} catch {}
|
||||
return airport.id
|
||||
}
|
||||
}
|
||||
65
Flights/Views/FlightScheduleRow.swift
Normal file
@@ -0,0 +1,65 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FlightScheduleRow: View {
|
||||
let schedule: FlightSchedule
|
||||
let departureCode: String
|
||||
let arrivalCode: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
// MARK: - Top row: airline logo + name + flight number
|
||||
HStack(spacing: 10) {
|
||||
AsyncImage(url: schedule.airline.logoURL) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
default:
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(FlightTheme.accent)
|
||||
.overlay {
|
||||
Text(String(schedule.airline.name.prefix(1)))
|
||||
.font(.system(size: 18, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 40, height: 40)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
|
||||
Text(schedule.airline.name)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(FlightTheme.textPrimary)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(schedule.flightNumber)
|
||||
.font(FlightTheme.flightNumber())
|
||||
.foregroundStyle(FlightTheme.textSecondary)
|
||||
}
|
||||
|
||||
// MARK: - Route visualization (curved arc with codes + times)
|
||||
RouteVisualization(
|
||||
departureCode: departureCode,
|
||||
arrivalCode: arrivalCode,
|
||||
departureTime: schedule.departureTime,
|
||||
arrivalTime: schedule.arrivalTime,
|
||||
codeSize: 28,
|
||||
timeSize: 18
|
||||
)
|
||||
|
||||
// MARK: - Aircraft pill tag
|
||||
if !schedule.aircraft.isEmpty {
|
||||
Text(schedule.aircraft)
|
||||
.font(FlightTheme.label(11))
|
||||
.foregroundStyle(FlightTheme.textSecondary)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color(.quaternarySystemFill), in: Capsule())
|
||||
}
|
||||
}
|
||||
.flightCard()
|
||||
}
|
||||
}
|
||||
164
Flights/Views/RouteDetailView.swift
Normal file
@@ -0,0 +1,164 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RouteDetailView: View {
|
||||
let departure: Airport
|
||||
let arrival: Airport
|
||||
let date: Date
|
||||
let service: FlightService
|
||||
|
||||
@State private var viewModel: RouteDetailViewModel
|
||||
|
||||
init(departure: Airport, arrival: Airport, date: Date, service: FlightService) {
|
||||
self.departure = departure
|
||||
self.arrival = arrival
|
||||
self.date = date
|
||||
self.service = service
|
||||
self._viewModel = State(initialValue: RouteDetailViewModel(service: service, date: date))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
FlightTheme.background
|
||||
.ignoresSafeArea()
|
||||
|
||||
Group {
|
||||
if viewModel.isLoading {
|
||||
loadingView
|
||||
} else if let error = viewModel.error {
|
||||
errorView(error)
|
||||
} else if viewModel.flightsForDate.isEmpty {
|
||||
emptyView
|
||||
} else {
|
||||
scheduleList
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("\(departure.iata) \u{2192} \(arrival.iata)")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
DatePicker(
|
||||
"Date",
|
||||
selection: $viewModel.selectedDate,
|
||||
displayedComponents: .date
|
||||
)
|
||||
.labelsHidden()
|
||||
.datePickerStyle(.compact)
|
||||
.tint(FlightTheme.accent)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
let depId = await resolveId(for: departure)
|
||||
let desId = await resolveId(for: arrival)
|
||||
await viewModel.loadSchedules(dep: depId, des: desId)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Loading
|
||||
|
||||
private var loadingView: some View {
|
||||
VStack(spacing: 12) {
|
||||
ProgressView()
|
||||
.tint(FlightTheme.accent)
|
||||
|
||||
if let progress = viewModel.loadingProgress {
|
||||
Text("Loading airline \(progress.completed)/\(progress.total)...")
|
||||
.font(.caption)
|
||||
.foregroundStyle(FlightTheme.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error
|
||||
|
||||
private func errorView(_ message: String) -> some View {
|
||||
ContentUnavailableView {
|
||||
Label("Error", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(message)
|
||||
} actions: {
|
||||
Button("Retry") {
|
||||
Task {
|
||||
let depId = await resolveId(for: departure)
|
||||
let desId = await resolveId(for: arrival)
|
||||
await viewModel.loadSchedules(dep: depId, des: desId)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(FlightTheme.accent)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty
|
||||
|
||||
private var emptyView: some View {
|
||||
ContentUnavailableView {
|
||||
Label("No Flights", systemImage: "airplane")
|
||||
} description: {
|
||||
Text("No flights operate on this date.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Schedule List
|
||||
|
||||
private var scheduleList: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: FlightTheme.sectionSpacing) {
|
||||
ForEach(viewModel.airlineGroups, id: \.airline.id) { group in
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
// Section header: small logo + airline name
|
||||
HStack(spacing: 8) {
|
||||
AsyncImage(url: group.airline.logoURL) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
default:
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(FlightTheme.accent)
|
||||
.overlay {
|
||||
Text(String(group.airline.name.prefix(1)))
|
||||
.font(.system(size: 12, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 24, height: 24)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
|
||||
Text(group.airline.name)
|
||||
.font(.headline)
|
||||
.foregroundStyle(FlightTheme.textPrimary)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
|
||||
// Flight cards
|
||||
ForEach(group.flights) { schedule in
|
||||
FlightScheduleRow(
|
||||
schedule: schedule,
|
||||
departureCode: departure.iata,
|
||||
arrivalCode: arrival.iata
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ID Resolution
|
||||
|
||||
/// If an airport was selected from browse/map with no FlightConnections ID, look it up.
|
||||
private func resolveId(for airport: Airport) async -> String {
|
||||
guard airport.id.isEmpty else { return airport.id }
|
||||
do {
|
||||
let results = try await service.searchAirports(term: airport.iata)
|
||||
if let match = results.first(where: { $0.iata == airport.iata }) {
|
||||
return match.id
|
||||
}
|
||||
} catch {}
|
||||
return airport.id
|
||||
}
|
||||
}
|
||||
85
Flights/Views/Styles/FlightTheme.swift
Normal file
@@ -0,0 +1,85 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Color {
|
||||
init(hex: String) {
|
||||
let hex = hex.trimmingCharacters(in: .alphanumerics.inverted)
|
||||
var int: UInt64 = 0
|
||||
Scanner(string: hex).scanHexInt64(&int)
|
||||
let r, g, b: Double
|
||||
r = Double((int >> 16) & 0xFF) / 255.0
|
||||
g = Double((int >> 8) & 0xFF) / 255.0
|
||||
b = Double(int & 0xFF) / 255.0
|
||||
self.init(red: r, green: g, blue: b)
|
||||
}
|
||||
}
|
||||
|
||||
enum FlightTheme {
|
||||
// MARK: - Adaptive Backgrounds
|
||||
// These auto-adapt to light/dark mode
|
||||
static let background = Color(.systemGroupedBackground)
|
||||
static let cardBackground = Color(.secondarySystemGroupedBackground)
|
||||
static let elevatedBackground = Color(.tertiarySystemGroupedBackground)
|
||||
|
||||
// MARK: - Adaptive Text
|
||||
static let textPrimary = Color.primary
|
||||
static let textSecondary = Color.secondary
|
||||
static let textTertiary = Color(.tertiaryLabel)
|
||||
|
||||
// MARK: - Accent & Brand
|
||||
static let accent = Color(hex: "6366F1")
|
||||
static let accentLight = Color(hex: "818CF8")
|
||||
|
||||
// MARK: - Status Colors (same in both modes)
|
||||
static let onTime = Color(hex: "10B981")
|
||||
static let delayed = Color(hex: "F59E0B")
|
||||
static let cancelled = Color(hex: "EF4444")
|
||||
static let boarding = Color(hex: "3B82F6")
|
||||
|
||||
// MARK: - Decorative
|
||||
static let routeArc = Color(.separator)
|
||||
static let cardShadow = Color.black.opacity(0.08)
|
||||
|
||||
// MARK: - Fonts (airport signage inspired)
|
||||
|
||||
static func airportCode(_ size: CGFloat = 28) -> Font {
|
||||
.system(size: size, weight: .bold, design: .rounded)
|
||||
}
|
||||
|
||||
static func time(_ size: CGFloat = 20) -> Font {
|
||||
.system(size: size, weight: .semibold, design: .monospaced)
|
||||
}
|
||||
|
||||
static func flightNumber(_ size: CGFloat = 14) -> Font {
|
||||
.system(size: size, weight: .medium, design: .monospaced)
|
||||
}
|
||||
|
||||
static func label(_ size: CGFloat = 12) -> Font {
|
||||
.system(size: size, weight: .semibold, design: .default)
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
static let cardCornerRadius: CGFloat = 16
|
||||
static let cardPadding: CGFloat = 16
|
||||
static let sectionSpacing: CGFloat = 20
|
||||
}
|
||||
|
||||
// MARK: - Card Style Modifier
|
||||
|
||||
struct FlightCardStyle: ViewModifier {
|
||||
var padding: CGFloat = FlightTheme.cardPadding
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.padding(padding)
|
||||
.background(FlightTheme.cardBackground)
|
||||
.clipShape(RoundedRectangle(cornerRadius: FlightTheme.cardCornerRadius))
|
||||
.shadow(color: FlightTheme.cardShadow, radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func flightCard(padding: CGFloat = FlightTheme.cardPadding) -> some View {
|
||||
modifier(FlightCardStyle(padding: padding))
|
||||
}
|
||||
}
|
||||
1
Flights/airports.json
Normal file
155
api_docs/SUMMARY.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# Airline Load Data API Summary
|
||||
|
||||
Goal: Get **number of open seats** and **number of people on standby list** per flight.
|
||||
|
||||
---
|
||||
|
||||
## United Airlines - BEST
|
||||
|
||||
### Endpoints
|
||||
| Endpoint | URL |
|
||||
|----------|-----|
|
||||
| **Standby List** | `POST https://mobileapi.united.com/standbylistservice/api/GetStandbyList` |
|
||||
| **Upgrade List** | `POST https://mobileapi.united.com/upgradelistservice/api/GetUpgradeList` |
|
||||
| Flight Status | `GET https://mobileapi.united.com/flightstatusservice/api/GetFlightStatus_UAandStar` |
|
||||
| Flight Status (segment) | `GET https://mobileapi.united.com/flightstatusservice/api/GetSegmentFlightStatusWithDestination_UAandStar` |
|
||||
| Pass Rider List | `POST https://mobileapi.united.com/passriderlistservice/api/PassRiderList` |
|
||||
|
||||
### Data Fields (confirmed from embedded mock JSON)
|
||||
- `CustomersOnStandbyList` — list of passengers on standby
|
||||
- `SeatsAvailable` — available seat count (nullable)
|
||||
- `AvailabiltyCount` — availability count string
|
||||
- `ShowStandby` — whether standby is shown for segment
|
||||
- `enableStandbyList` — boolean toggle on flight status
|
||||
- `enableUpgradeList` — boolean toggle on flight status
|
||||
- `businessStandByList` — business class standby list
|
||||
- `standByListPassengers` — passenger details
|
||||
- `availableSeat11AMto5PM` / `availableSeatBefore11AM` / `availableSeatAfter5PM` — time-bucketed availability
|
||||
|
||||
### Security
|
||||
- No SSL pinning
|
||||
- No PairIP/anti-tamper
|
||||
- No emulator detection
|
||||
- Auth: session-based (needs login token)
|
||||
|
||||
### Completeness: 95%
|
||||
- Open seats: YES (`SeatsAvailable`, `AvailabiltyCount`, time-bucketed seat fields)
|
||||
- Standby count: YES (`CustomersOnStandbyList`, `standByListPassengers`)
|
||||
- Standby names: YES (passenger objects)
|
||||
- Upgrade list: YES (separate endpoint)
|
||||
- Missing: Need to capture actual API request body format. Can do via emulator + mitmproxy since no protections exist.
|
||||
|
||||
---
|
||||
|
||||
## Delta Air Lines - GOOD
|
||||
|
||||
### Endpoints
|
||||
| Endpoint | URL |
|
||||
|----------|-----|
|
||||
| **Airport Standby List** | `POST https://www.delta.com/api/mobile/asl` |
|
||||
| Upgrade Eligibility | `POST https://www.delta.com/api/mobile/getUpgradeEligibilityInfo` |
|
||||
| Flight Status | `POST https://www.delta.com/api/mobile/getFlightStatus` |
|
||||
| Flight Status by Leg | `POST https://www.delta.com/api/mobile/getFlightStatusByLeg` |
|
||||
|
||||
### Data Fields
|
||||
- `seatsAvailableCount` — number of open seats
|
||||
- `seatsRemainingLabel` — display text for remaining seats
|
||||
- `upgradeCount` — number on upgrade list
|
||||
- `UpgradeSeatRemaining` — seats remaining per cabin class
|
||||
- `getClearedStandbyPassengers` — passengers who cleared
|
||||
- `getWaitingStandbyPassengers` — passengers still waiting
|
||||
- `getStandbyTotalWaitList` — total on standby waitlist
|
||||
- `getStandbySeatRemaining` — seats available for standby
|
||||
- `standbyPriority` / `standbyPriorityCode` — priority info
|
||||
- `hasStandbyFlightNoSeatsAvailable` — boolean indicator
|
||||
|
||||
### Security
|
||||
- No SSL pinning
|
||||
- No PairIP/anti-tamper
|
||||
- No emulator detection
|
||||
- Auth: session-based via `/api/mobile/login`
|
||||
|
||||
### Completeness: 90%
|
||||
- Open seats: YES (`seatsAvailableCount`, `UpgradeSeatRemaining` per cabin)
|
||||
- Standby count: YES (`getStandbyTotalWaitList`, cleared vs waiting)
|
||||
- Standby names: YES (`StandbyPassengerPositionView`, `StandbyPassengerStatusAdapter`)
|
||||
- Upgrade list: YES (`upgradeCount`, `upgradeList`)
|
||||
- Missing: Need to capture actual request/response JSON format. Can do via emulator + mitmproxy. Need to determine if auth is required or if ASL endpoint works without login.
|
||||
|
||||
---
|
||||
|
||||
## Spirit Airlines - PARTIAL
|
||||
|
||||
### Endpoints
|
||||
| Endpoint | URL |
|
||||
|----------|-----|
|
||||
| **BOA Status** | `POST https://api.spirit.com/customermobileprod/2.8.0/v1/getboastatus` |
|
||||
| BOA Parameters | `GET https://api.spirit.com/customermobileprod/2.8.0/v1/getboaparameters` |
|
||||
| Flight Search | `POST https://api.spirit.com/customermobileprod/2.8.0/v5/Flight/Search` |
|
||||
|
||||
### Data Fields
|
||||
- `isStandby` — boolean standby flag
|
||||
- `standby` — standby details on segment
|
||||
- `boaStatus` — Board of Availability status
|
||||
- `capacity` — flight capacity
|
||||
- `availableUnits` — available units
|
||||
|
||||
### Security
|
||||
- No SSL pinning
|
||||
- No PairIP/anti-tamper
|
||||
- Akamai Bot Manager present (may block non-app requests)
|
||||
- Auth: token-based via `/v2/Token`
|
||||
|
||||
### Completeness: 50%
|
||||
- Open seats: UNCLEAR — `availableUnits` and `capacity` exist but unclear if they represent seat counts. Spirit may not expose granular seat availability.
|
||||
- Standby count: UNCLEAR — `boaStatus` exists but the response format is unknown. Spirit's BOA system is simpler than Delta/United.
|
||||
- Standby names: UNCLEAR — no `StandbyPassenger` model found. May only show status, not individual passengers.
|
||||
- Missing: Need actual API call to determine what `getboastatus` returns. Emulator capture needed. Spirit may have a more limited standby system (being an ULCC).
|
||||
|
||||
---
|
||||
|
||||
## American Airlines - BLOCKED
|
||||
|
||||
### Endpoints
|
||||
| Endpoint | URL |
|
||||
|----------|-----|
|
||||
| Flight Status (web) | `POST https://www.aa.com/flightinfo/v1.2/` |
|
||||
| Standby/Waitlist | **UNKNOWN** — mobile app only, SSL pinned |
|
||||
|
||||
### Data Fields (from iOS app screenshot)
|
||||
- "Available seats: 23" — seat count per cabin
|
||||
- Passenger names on standby list
|
||||
- Standby vs Upgrade lists
|
||||
- Cabin-specific breakdown
|
||||
|
||||
### Security
|
||||
- SSL pinning on key domains
|
||||
- PairIP anti-tamper on Android (crashes on emulators)
|
||||
- Akamai WAF on web endpoints
|
||||
|
||||
### Completeness: 20%
|
||||
- Open seats: CONFIRMED EXISTS (screenshot shows "Available seats 23") but API endpoint unknown
|
||||
- Standby count: CONFIRMED EXISTS (screenshot shows passenger list) but API endpoint unknown
|
||||
- Flight status: CAPTURED (`/flightinfo/v1.2/` — gates, times, status, equipment)
|
||||
- Missing: The standby/waitlist API endpoint is mobile-app-only, protected by SSL pinning (iOS) and PairIP (Android). Cannot be captured without jailbroken iOS device or rooted physical Android with Magisk+Shamiko.
|
||||
|
||||
---
|
||||
|
||||
## Action Items
|
||||
|
||||
### Immediately actionable (no additional capture needed):
|
||||
1. **AA Flight Status** — `/flightinfo/v1.2/` is working. Can get gates, times, delays, equipment.
|
||||
|
||||
### Needs emulator + mitmproxy capture (no protections, straightforward):
|
||||
2. **United Standby List** — Boot emulator, install APK, login, call `GetStandbyList`. No pinning/anti-tamper. Full data including seat counts and passenger names.
|
||||
3. **Delta ASL** — Same approach. Boot emulator, install APK, login, call `/api/mobile/asl`. Full data.
|
||||
4. **Spirit BOA** — Boot emulator, install APK, login, call `/v1/getboastatus`. Determine what data is actually returned.
|
||||
|
||||
### Requires physical device:
|
||||
5. **AA Standby/Waitlist** — Needs jailbroken iPhone + SSL Kill Switch, or rooted physical Android + Magisk + Shamiko + Frida.
|
||||
|
||||
### Priority order for implementation:
|
||||
1. **United** (most data, easiest to capture, dedicated standby service endpoint)
|
||||
2. **Delta** (rich data model, no protections)
|
||||
3. **Spirit** (uncertain data granularity)
|
||||
4. **AA** (blocked without specialized hardware)
|
||||
154
api_docs/delta_api.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Delta Air Lines Mobile API
|
||||
|
||||
Extracted from `com.delta.mobile.android` v6.7 (build 24019)
|
||||
|
||||
## Base URLs
|
||||
|
||||
| Environment | URL |
|
||||
|-------------|-----|
|
||||
| Production | `https://www.delta.com` |
|
||||
| CDN | `https://content.delta.com` |
|
||||
| Embed Web | `https://api.delta.com/embedweb` |
|
||||
|
||||
## Key Endpoints
|
||||
|
||||
### Airport Standby List (ASL)
|
||||
```
|
||||
POST https://www.delta.com/api/mobile/asl
|
||||
```
|
||||
**This is the standby/upgrade list endpoint.** Uses `AirportStandbyListRequest` with fields:
|
||||
- `departureDate` — flight departure date
|
||||
- Request likely includes flight number, origin, destination
|
||||
- Returns `AirportUpgradeStandbyModel` with upgrade list and standby list data
|
||||
|
||||
### Flight Status
|
||||
```
|
||||
POST https://www.delta.com/api/mobile/getFlightStatus
|
||||
POST https://www.delta.com/api/mobile/getFlightStatusByLeg
|
||||
```
|
||||
|
||||
### Upgrade Eligibility
|
||||
```
|
||||
POST https://www.delta.com/api/mobile/getUpgradeEligibilityInfo
|
||||
POST https://www.delta.com/api/mobile/purchaseEfirst
|
||||
```
|
||||
|
||||
### Complimentary Upgrade (from DEX strings)
|
||||
```
|
||||
/getComplimentaryUpgrade
|
||||
/processComplimentaryUpgrade
|
||||
/purchaseUpgrade
|
||||
```
|
||||
|
||||
## All Mobile API Endpoints
|
||||
|
||||
| Endpoint | Path |
|
||||
|----------|------|
|
||||
| **Standby List** | `/api/mobile/asl` |
|
||||
| Flight Status | `/api/mobile/getFlightStatus` |
|
||||
| Flight Status By Leg | `/api/mobile/getFlightStatusByLeg` |
|
||||
| Flight Schedule | `/api/mobile/getFlightSchedule` |
|
||||
| Login | `/api/mobile/login` |
|
||||
| Logout | `/api/mobile/logout` |
|
||||
| Check Login | `/api/mobile/checkLogin` |
|
||||
| Get Profile | `/api/mobile/getprofile` |
|
||||
| Manage Profile | `/api/mobile/manageProfile` |
|
||||
| Get PNR | `/api/mobile/getPnr` |
|
||||
| Validate PNR | `/api/mobile/validatePnr` |
|
||||
| Dashboard | `/api/mobile/getDashboard` |
|
||||
| Check-in | `/api/mobile/checkin` |
|
||||
| Seat Map | `/api/mobile/getSeatMap` |
|
||||
| Change Seat | `/api/mobile/changeSeat` |
|
||||
| Upgrade Eligibility | `/api/mobile/getUpgradeEligibilityInfo` |
|
||||
| Purchase E-First | `/api/mobile/purchaseEfirst` |
|
||||
| Bag Info | `/api/mobile/getBagInfo` |
|
||||
| Add Bags | `/api/mobile/addBags` |
|
||||
| Get Bags | `/api/mobile/getBags` |
|
||||
| Bag Carousel | `/api/mobile/getBagCarousel` |
|
||||
| Weather | `/api/mobile/getWeather` |
|
||||
| Account Activity | `/api/mobile/getAcctActivity` |
|
||||
| SkyClub Info | `/api/mobile/getSkyclubInfo` |
|
||||
| Purchase SkyClub | `/api/mobile/purchaseSkyclub` |
|
||||
| Receipts | `/api/mobile/getReceipts` |
|
||||
| Email Receipt | `/api/mobile/processEmailReceipt` |
|
||||
| Merchandise | `/api/mobile/getMerchandise` |
|
||||
| Promotions | `/api/mobile/getPromotions` |
|
||||
| Airport Mode | `/api/mobile/getAirportMode` |
|
||||
| Passenger Info | `/api/mobile/getPaxInfo` |
|
||||
| Manage Passenger | `/api/mobile/managePaxInfo` |
|
||||
| Travel Documents | `/api/mobile/getTravelDoc` |
|
||||
| Add Travel Doc | `/api/mobile/addTravelDoc` |
|
||||
| Emergency Contact | `/api/mobile/addEmergencyContact` |
|
||||
| Manage Cart | `/api/mobile/manageCart` |
|
||||
| Get Cart | `/api/mobile/getCart` |
|
||||
| Clear Cart | `/api/mobile/clearCart` |
|
||||
| Eligible FOP | `/api/mobile/getEligibleFop` |
|
||||
| SSR | `/api/mobile/getSsr` |
|
||||
| Available SSR | `/api/mobile/getAvailableSsr` |
|
||||
| Manage SSR | `/api/mobile/manageSsr` |
|
||||
| Manage FF | `/api/mobile/manageFF` |
|
||||
| Validate Address | `/api/mobile/validateAddress` |
|
||||
| Forgot Username | `/api/mobile/forgotUserName` |
|
||||
| Country Reference | `/api/mobile/getCountryReferenceData` |
|
||||
| Address Fields | `/api/mobile/getAddressFields` |
|
||||
| Enroll SkyMiles | `/api/mobile/enrollSM` |
|
||||
| Upsell Info | `/api/mobile/getPnrUpsellInfo` |
|
||||
| Upsell Fare Rules | `/api/mobile/getUpsellFareRules` |
|
||||
| Purchase Upsell | `/api/mobile/purchaseUpsell` |
|
||||
| Supported Version | `/api/mobile/supportedVersion` |
|
||||
| SkyMiles Info | `/api/mobile/getSMInfo` |
|
||||
| SkyMiles Pass | `/api/mobile/getSMPass` |
|
||||
| SC Info | `/api/mobile/getSCInfo` |
|
||||
| SC Pass | `/api/mobile/getSCPass` |
|
||||
| Membership Status | `/api/mobile/getMembershipStatusInfo` |
|
||||
| EOD Eligibility | `/api/mobile/getEODEligibility` |
|
||||
| Store Password | `/api/mobile/storePassword` |
|
||||
| Store Email | `/api/mobile/storeEmail` |
|
||||
| Update Password | `/api/mobile/updatePassword` |
|
||||
| Security Questions | `/api/mobile/getSecurityQuestions` |
|
||||
| Get SQA | `/api/mobile/getSQA` |
|
||||
| Store SQA | `/api/mobile/storeSQA` |
|
||||
| App Info | `/api/mobile/getinfo` |
|
||||
|
||||
## Request Headers (from DEX analysis)
|
||||
|
||||
The app uses an `AirlineRequest` pattern with these common headers:
|
||||
- `Content-Type: application/json`
|
||||
- Custom auth headers (session-based after `/api/mobile/login`)
|
||||
|
||||
## ASL Data Model (from DEX class analysis)
|
||||
|
||||
### AirportStandbyListRequest
|
||||
- `requestInfo` — flight identification
|
||||
- `departureDate` — date string
|
||||
|
||||
### AirportUpgradeStandbyModel
|
||||
Contains:
|
||||
- Upgrade list (by cabin class)
|
||||
- Standby list
|
||||
- `standbyPriority` / `standbyPriorityCode`
|
||||
- `upgradeList` entries
|
||||
- `ASLStandby` — individual standby entries
|
||||
- `ASLUpgrade` — individual upgrade entries
|
||||
- `UpgradeSeatRemaining` — available seats per cabin
|
||||
- `PassengerChiclet` — passenger display data
|
||||
|
||||
### UpgradeStandbyParams
|
||||
- `airportModeResponse`
|
||||
- Flight leg details
|
||||
- Eligibility flags
|
||||
|
||||
## Security Notes
|
||||
|
||||
- **No SSL pinning** in `network_security_config.xml` (only trusts system CAs)
|
||||
- **No PairIP or anti-tamper** — app runs on emulators
|
||||
- Auth is session-based via `/api/mobile/login`
|
||||
- The ASL endpoint likely requires an authenticated session
|
||||
- No Akamai bot detection observed on the mobile API path
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Call `/api/mobile/login` with Delta credentials to get a session
|
||||
2. Call `/api/mobile/getFlightStatus` with a flight number
|
||||
3. Call `/api/mobile/asl` with the flight details from the status response
|
||||
4. The ASL response should contain upgrade and standby lists
|
||||
176
api_docs/spirit_api.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# Spirit Airlines Mobile API
|
||||
|
||||
Extracted from `com.spirit.customerapp` v4.7.0 (build 1340)
|
||||
|
||||
## Base URL
|
||||
|
||||
| Environment | URL |
|
||||
|-------------|-----|
|
||||
| **Production** | `https://api.spirit.com/customermobileprod/2.8.0/` |
|
||||
| QA1 | `https://apiqa.spirit.com/qa01-customermobileapi/` |
|
||||
| QA2 | `https://apiqa.spirit.com/qa02-customermobileapi/` |
|
||||
| Dev1 | `https://apiqa.spirit.com/dev01-customermobileapi/` |
|
||||
| Dev2 | `https://apiqa.spirit.com/dev02-customermobileapi/` |
|
||||
| Stage | `https://api.spirit.com/stage-customermobileapi/` |
|
||||
| CMS | `https://content.spirit.com/api/content/` |
|
||||
|
||||
## Standby / Board of Availability (BOA)
|
||||
|
||||
Spirit uses "BOA" (Board of Availability) as their standby system:
|
||||
|
||||
```
|
||||
GET https://api.spirit.com/customermobileprod/2.8.0/v1/getboaparameters
|
||||
POST https://api.spirit.com/customermobileprod/2.8.0/v1/getboastatus
|
||||
```
|
||||
|
||||
### BOA Status Request
|
||||
- **Method:** POST
|
||||
- **Body:** `BoaStatusRequestDto` (JSON)
|
||||
- **Response:** `BoaStatusResponseDto` containing `BoaStatus` objects
|
||||
|
||||
### BOA Data Model
|
||||
- `BoaStatusInfo` — status of standby position
|
||||
- `boaStatusChecker` — polls for status updates using a `boaUniqueSessionToken`
|
||||
- `isStandby` — boolean flag on trip/flight objects
|
||||
- `standby` — standby details on trip segments
|
||||
|
||||
### BOA Flow
|
||||
1. Call `v1/getboaparameters` to get BOA config
|
||||
2. Call `v1/getboastatus` with flight/booking details
|
||||
3. Poll using `boaStatusChecker` with session token for updates
|
||||
|
||||
## All API Endpoints
|
||||
|
||||
### Authentication
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `v2/Token` | Fetch auth token |
|
||||
| POST | `v3/Token` | Fetch v3 auth token |
|
||||
| PUT | `v3/Token` | Refresh v3 token |
|
||||
|
||||
### Init & Config
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `v1/init` | App initialization |
|
||||
| GET | `v1/stations` | Airport station list |
|
||||
| GET | `v1/OnD/Countries` | Country reference data |
|
||||
|
||||
### Flight Search & Status
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `v5/Flight/Search` | Search flights |
|
||||
| POST | `v3/GetFlightInfoBI` | Flight info |
|
||||
| POST | `v1/booking/flightdetails` | Flight details |
|
||||
| POST | `v1/calendar/availabledates` | Available dates calendar |
|
||||
|
||||
### Booking
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `v1/booking/retrieve` | Retrieve booking (params: RecordLocator, LastName) |
|
||||
| GET | `v1/booking` | Get booking state |
|
||||
| GET | `v1/booking?screenType=ReviewTempStay` | Booking review |
|
||||
| PUT | `v2/booking` | Update booking |
|
||||
| POST | `v1/booking/book` | Confirm booking |
|
||||
| POST | `v1/trip/sell` | Sell trip |
|
||||
| POST | `v2/ValidateBookingRequest` | Validate booking |
|
||||
|
||||
### Standby / BOA
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| **GET** | **`v1/getboaparameters`** | **Get BOA (standby) parameters** |
|
||||
| **POST** | **`v1/getboastatus`** | **Get standby status** |
|
||||
|
||||
### Check-in & Boarding
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `v1/booking/checkin/journey` | Check in for journey |
|
||||
| POST | `v2/booking/boardingpasses/journey` | Get boarding passes |
|
||||
|
||||
### Trip Management
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `v1/managetrip` | Manage trip |
|
||||
| POST | `v2/tripdetails` | Get trip details |
|
||||
| POST | `v3/mytrips` | Get my trips |
|
||||
| POST | `v1/booking/passengers/passengerreturndates` | Passenger return dates |
|
||||
|
||||
### Seats
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `v1/booking/seatmap` | Get seat map |
|
||||
| GET | `v4/booking/seatmaps/` | Get seat maps v4 |
|
||||
| POST | `v4/booking/passengers/{passengerKey}/seats/{unitKey}` | Assign seat |
|
||||
|
||||
### Bags
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `v1/bags` | Get bags |
|
||||
| PUT | `v1/bags/update` | Update bags |
|
||||
|
||||
### Bundles & Upsell
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `v3/options` | Get options |
|
||||
| PUT | `v1/options/update` | Update options |
|
||||
| POST | `v1/bundle/UpsellAvailability` | Upsell availability |
|
||||
| POST | `v3/bundle/ssrs` | Bundle SSRs |
|
||||
| POST | `v4/bundle/ssrs` | Bundle SSRs v4 |
|
||||
| POST | `v2/cart` | Manage cart |
|
||||
|
||||
### Payments
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `v1/booking/payments/fetchredemptiondetail` | Fetch redemption |
|
||||
| POST | `v1/booking/payments/redeem` | Redeem points |
|
||||
| DELETE | `V3/User/Person/StoredPayments/{key}` | Delete stored payment |
|
||||
|
||||
### Account
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `v1/FreeSpirit/CreateAccount` | Create Free Spirit account |
|
||||
| POST | `v1/FreeSpirit/Booking/CreateAccount` | Create account during booking |
|
||||
| GET | `v1/User/Person` | Get user profile |
|
||||
| POST | `v1/account/password/reset` | Reset password |
|
||||
| POST | `v1/account/updateexpiredpassword` | Update expired password |
|
||||
| POST | `v1/account/points` | Account points |
|
||||
| POST | `v1/points` | Points |
|
||||
| POST | `v1/MemberInfo/ValidateFSNumber` | Validate Free Spirit number |
|
||||
| POST | `v1/registerForPromotion` | Register for promotion |
|
||||
|
||||
### Documents & SSR
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `v1/booking/passengers/{key}/documents` | Add travel doc |
|
||||
| PUT | `v1/booking/passengers/{key}/documents/{docKey}` | Update travel doc |
|
||||
| POST | `v1/booking/passengers/{key}/infant/documents` | Add infant doc |
|
||||
| POST | `v1/document/validate` | Validate document |
|
||||
| POST | `v1/booking/ssrs/add/acia` | Add SSR |
|
||||
| POST | `v2/trip/specialassistance` | Special assistance |
|
||||
| POST | `v3/ssrs/health-ack/accept` | Accept health acknowledgment |
|
||||
| POST | `v3/ssrs/health-ack/decline` | Decline health acknowledgment |
|
||||
|
||||
### Other
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `v1/GetContent/help` | Help content |
|
||||
| POST | `v1/getdynamiccontent` | Dynamic content |
|
||||
| POST | `v1/booking/touristtax` | Tourist tax |
|
||||
| POST | `v1/booking/passengers/temporarystay/address` | Temp stay address |
|
||||
| POST | `v1/travelguard/getquote` | Travel insurance quote |
|
||||
|
||||
## Security Notes
|
||||
|
||||
- **No SSL pinning** in network_security_config.xml
|
||||
- **No PairIP or anti-tamper** protection
|
||||
- Uses Retrofit2 for HTTP
|
||||
- Auth via token-based system (`v2/Token`, `v3/Token`)
|
||||
- `libakamaibmp.so` present — Akamai Bot Manager for bot detection
|
||||
- App runs fine on emulators
|
||||
|
||||
## CMS Endpoints
|
||||
|
||||
| Path | Description |
|
||||
|------|-------------|
|
||||
| `en-US?path=mobile/book/paxtravelingui` | Passenger traveling UI content |
|
||||
| `en-US?path=mobile/localnotification` | Local notification content |
|
||||
| `en-US?path=mobile/mytripui` | My trips UI content |
|
||||
137
api_docs/united_api.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# United Airlines Mobile API
|
||||
|
||||
Extracted from `united-airlines.apk` (11 DEX files, ~100MB)
|
||||
|
||||
## Base URLs
|
||||
|
||||
| Environment | URL |
|
||||
|-------------|-----|
|
||||
| **Production** | `https://mobileapi.united.com` |
|
||||
| QA | `https://mobileapi.qa.united.com` |
|
||||
| Dev | `https://mobileapi.dev.united.com` |
|
||||
| Stage | `https://mobileapi.stage.united.com` |
|
||||
| PreProd | `https://mobileapi.preprod.united.com` |
|
||||
| Preview | `https://mobileapi.preview.united.com` |
|
||||
|
||||
## Load Data Endpoints
|
||||
|
||||
### Standby List
|
||||
```
|
||||
POST https://mobileapi.united.com/standbylistservice/api/GetStandbyList
|
||||
```
|
||||
Returns `MOBStandByListResponse`:
|
||||
- `standByListPassengers` — passenger list with names, status
|
||||
- `CustomersOnStandbyList` — customers on standby
|
||||
- `businessStandByList` — business class standby
|
||||
- `isStandByListAvailable` — availability flag
|
||||
- `showStandbyListButton` — UI toggle
|
||||
|
||||
### Upgrade List
|
||||
```
|
||||
POST https://mobileapi.united.com/upgradelistservice/api/GetUpgradeList
|
||||
```
|
||||
Returns `MOBUpgradeListResponse`:
|
||||
- Upgrade passengers with status
|
||||
- Cabin eligibility
|
||||
|
||||
### Pass Rider List (Employee/Non-Rev)
|
||||
```
|
||||
POST https://mobileapi.united.com/passriderlistservice/api/PassRiderList
|
||||
POST https://mobileapi.united.com/passriderlistservice/api/TravelerMisConnect
|
||||
```
|
||||
|
||||
## Flight Status Data Fields (from embedded JSON)
|
||||
|
||||
Per segment in the response:
|
||||
- `SeatsAvailable` — nullable seat count
|
||||
- `AvailabiltyCount` — availability string
|
||||
- `CustomersOnStandbyList` — standby passengers
|
||||
- `ShowStandby` — whether standby is displayed
|
||||
- `enableStandbyList` — boolean
|
||||
- `enableUpgradeList` — boolean
|
||||
- `availableSeat11AMto5PM` — time-bucketed availability
|
||||
- `availableSeatBefore11AM` — morning availability
|
||||
- `availableSeatAfter5PM` — evening availability
|
||||
|
||||
## All 60+ Microservices
|
||||
|
||||
| Service | Base Path |
|
||||
|---------|-----------|
|
||||
| **standbylistservice** | `/standbylistservice/api/` |
|
||||
| **upgradelistservice** | `/upgradelistservice/api/` |
|
||||
| **flightstatusservice** | `/flightstatusservice/api/` |
|
||||
| passriderlistservice | `/passriderlistservice/api/` |
|
||||
| passridersservice | `/passridersservice/api/` |
|
||||
| checkinservice | `/checkinservice/api/` |
|
||||
| checkinebpservice | `/checkinebpservice/api/` |
|
||||
| checkinmerchservice | `/checkinmerchservice/api/` |
|
||||
| seatmapservice | `/seatmapservice/api/` |
|
||||
| seatengineservice | `/seatengineservice/api/` |
|
||||
| bagcalculatorservice | `/bagcalculatorservice/api/` |
|
||||
| bagtrackingservice | `/bagtrackingservice/api/` |
|
||||
| bookingtripsservice | `/bookingtripsservice/api/` |
|
||||
| completebookingservice | `/completebookingservice/api/` |
|
||||
| cancelreservationservice | `/cancelreservationservice/api/` |
|
||||
| shoppingservice | `/shoppingservice/api/` |
|
||||
| shoptripsservice | `/shoptripsservice/api/` |
|
||||
| shopbundlesservice | `/shopbundlesservice/api/` |
|
||||
| shopflightdetailsservice | `/shopflightdetailsservice/api/` |
|
||||
| shopfarewheelservice | `/shopfarewheelservice/api/` |
|
||||
| shopawardservice | `/shopawardservice/api/` |
|
||||
| shopseatsservice | `/shopseatsservice/api/` |
|
||||
| flightsearchresultservice | `/flightsearchresultservice/api/` |
|
||||
| myunitedservice | `/myunitedservice/api/` |
|
||||
| customerprofileservice | `/customerprofileservice/api/` |
|
||||
| memberprofileservice | `/memberprofileservice/api/` |
|
||||
| memberinformationservice | `/memberinformationservice/api/` |
|
||||
| memberbenefitsservice | `/memberbenefitsservice/api/` |
|
||||
| updatememberprofileservice | `/updatememberprofileservice/api/` |
|
||||
| enrollmentservice | `/enrollmentservice/api/` |
|
||||
| premieractivityservice | `/premieractivityservice/api/` |
|
||||
| recentactivityservice | `/recentactivityservice/api/` |
|
||||
| balanceservice | `/balanceservice/api/` |
|
||||
| mywalletservice | `/mywalletservice/api/` |
|
||||
| etcservice | `/etcservice/api/` |
|
||||
| travelcreditservice | `/travelcreditservice/api/` |
|
||||
| clubservice | `/clubservice/api/` |
|
||||
| unitedclubservice | `/unitedclubservice/api/` |
|
||||
| productservice | `/productservice/api/` |
|
||||
| msccheckoutservice | `/msccheckoutservice/api/` |
|
||||
| mscpaymentservice | `/mscpaymentservice/api/` |
|
||||
| mscregisterservice | `/mscregisterservice/api/` |
|
||||
| postbookingservice | `/postbookingservice/api/` |
|
||||
| tripplannerservice | `/tripplannerservice/api/` |
|
||||
| tripplannergetservice | `/tripplannergetservice/api/` |
|
||||
| savetripservice | `/savetripservice/api/` |
|
||||
| travelersservice | `/travelersservice/api/` |
|
||||
| traveloffersservice | `/traveloffersservice/api/` |
|
||||
| homescreenservice | `/homescreenservice/api/` |
|
||||
| inboxservice | `/inboxservice/api/` |
|
||||
| receiptservice | `/receiptservice/api/` |
|
||||
| inflightamenityservice | `/inflightamenityservice/api/` |
|
||||
| locationservice | `/locationservice/api/` |
|
||||
| airportsservice | `/airportsservice/api/` |
|
||||
| securityquestionsservice | `/securityquestionsservice/api/` |
|
||||
| addressvalidationservice | `/addressvalidationservice/api/` |
|
||||
| syncservice | `/syncservice/api/` |
|
||||
| subscriptionsservice | `/subscriptionsservice/api/` |
|
||||
| paymentoptionservice | `/paymentoptionservice/api/` |
|
||||
| otpandchasecardsservice | `/otpandchasecardsservice/api/` |
|
||||
| promocodeservice | `/promocodeservice/api/` |
|
||||
| moneyplusmilesservice | `/moneyplusmilesservice/api/` |
|
||||
| alertcheckfsrservice | `/alertcheckfsrservice/api/` |
|
||||
| cceservice | `/cceservice/api/` |
|
||||
| mpcservice | `/mpcservice/api/` |
|
||||
| trcservice | `/trcservice/api/` |
|
||||
| employeeprofileservice | `/employeeprofileservice/api/` |
|
||||
| employeepassbalanceservice | `/employeepassbalanceservice/api/` |
|
||||
| unfinishedbookingservice | `/unfinishedbookingservice/api/` |
|
||||
| viewresseatmapservice | `/viewresseatmapservice/api/` |
|
||||
|
||||
## Security Notes
|
||||
|
||||
- **No SSL pinning** — `network_security_config.xml` only allows cleartext for inflight WiFi domains
|
||||
- **No PairIP or anti-tamper**
|
||||
- **No emulator detection**
|
||||
- Auth: session/token based
|
||||
- Can be fully captured via emulator + mitmproxy
|
||||
723
design/generate_icon_options.swift
Normal file
@@ -0,0 +1,723 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
let canvasSize = CGSize(width: 1024, height: 1024)
|
||||
|
||||
enum Palette {
|
||||
static let accent = NSColor(hex: 0x6366F1)
|
||||
static let accentLight = NSColor(hex: 0x818CF8)
|
||||
static let boarding = NSColor(hex: 0x3B82F6)
|
||||
static let mint = NSColor(hex: 0x10B981)
|
||||
static let slate = NSColor(hex: 0x0F172A)
|
||||
static let sky = NSColor(hex: 0x38BDF8)
|
||||
static let cloud = NSColor(hex: 0xE2E8F0)
|
||||
static let mist = NSColor(hex: 0xF8FAFC)
|
||||
static let ink = NSColor(hex: 0x111827)
|
||||
}
|
||||
|
||||
struct IconOption {
|
||||
let fileName: String
|
||||
let label: String
|
||||
let draw: (CGRect) -> Void
|
||||
}
|
||||
|
||||
extension NSColor {
|
||||
convenience init(hex: UInt32, alpha: CGFloat = 1.0) {
|
||||
self.init(
|
||||
calibratedRed: CGFloat((hex >> 16) & 0xFF) / 255.0,
|
||||
green: CGFloat((hex >> 8) & 0xFF) / 255.0,
|
||||
blue: CGFloat(hex & 0xFF) / 255.0,
|
||||
alpha: alpha
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension CGRect {
|
||||
var center: CGPoint { CGPoint(x: midX, y: midY) }
|
||||
}
|
||||
|
||||
func roundedRectPath(_ rect: CGRect, radius: CGFloat) -> NSBezierPath {
|
||||
NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius)
|
||||
}
|
||||
|
||||
func circleRect(center: CGPoint, radius: CGFloat) -> CGRect {
|
||||
CGRect(x: center.x - radius, y: center.y - radius, width: radius * 2, height: radius * 2)
|
||||
}
|
||||
|
||||
func withGraphicsState(_ draw: () -> Void) {
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
draw()
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
}
|
||||
|
||||
func applyShadow(
|
||||
color: NSColor,
|
||||
blur: CGFloat,
|
||||
x: CGFloat = 0,
|
||||
y: CGFloat = 0,
|
||||
draw: () -> Void
|
||||
) {
|
||||
withGraphicsState {
|
||||
let shadow = NSShadow()
|
||||
shadow.shadowColor = color
|
||||
shadow.shadowBlurRadius = blur
|
||||
shadow.shadowOffset = NSSize(width: x, height: y)
|
||||
shadow.set()
|
||||
draw()
|
||||
}
|
||||
}
|
||||
|
||||
func fillPath(_ path: NSBezierPath, colors: [NSColor], angle: CGFloat) {
|
||||
guard let gradient = NSGradient(colors: colors) else { return }
|
||||
gradient.draw(in: path, angle: angle)
|
||||
}
|
||||
|
||||
func strokePath(_ path: NSBezierPath, color: NSColor, lineWidth: CGFloat) {
|
||||
color.setStroke()
|
||||
path.lineWidth = lineWidth
|
||||
path.stroke()
|
||||
}
|
||||
|
||||
func fillCircle(center: CGPoint, radius: CGFloat, color: NSColor) {
|
||||
let path = NSBezierPath(ovalIn: circleRect(center: center, radius: radius))
|
||||
color.setFill()
|
||||
path.fill()
|
||||
}
|
||||
|
||||
func fillRoundedRect(_ rect: CGRect, radius: CGFloat, colors: [NSColor], angle: CGFloat) {
|
||||
let path = roundedRectPath(rect, radius: radius)
|
||||
fillPath(path, colors: colors, angle: angle)
|
||||
}
|
||||
|
||||
func drawSymbol(
|
||||
_ name: String,
|
||||
in rect: CGRect,
|
||||
pointSize: CGFloat,
|
||||
tint: NSColor,
|
||||
weight: NSFont.Weight = .regular,
|
||||
scale: NSImage.SymbolScale = .large,
|
||||
rotationDegrees: CGFloat = 0,
|
||||
alpha: CGFloat = 1.0
|
||||
) {
|
||||
guard
|
||||
let symbol = NSImage(systemSymbolName: name, accessibilityDescription: nil),
|
||||
let configured = symbol.withSymbolConfiguration(.init(pointSize: pointSize, weight: weight, scale: scale))
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
let tinted = NSImage(size: configured.size)
|
||||
tinted.lockFocus()
|
||||
let imageRect = NSRect(origin: .zero, size: configured.size)
|
||||
configured.draw(in: imageRect, from: NSRect.zero, operation: NSCompositingOperation.sourceOver, fraction: 1.0)
|
||||
tint.withAlphaComponent(alpha).set()
|
||||
imageRect.fill(using: NSCompositingOperation.sourceAtop)
|
||||
tinted.unlockFocus()
|
||||
|
||||
withGraphicsState {
|
||||
let transform = NSAffineTransform()
|
||||
transform.translateX(by: rect.midX, yBy: rect.midY)
|
||||
transform.rotate(byDegrees: rotationDegrees)
|
||||
transform.translateX(by: -rect.midX, yBy: -rect.midY)
|
||||
transform.concat()
|
||||
tinted.draw(in: rect, from: NSRect.zero, operation: NSCompositingOperation.sourceOver, fraction: 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
func quadraticPath(start: CGPoint, control: CGPoint, end: CGPoint) -> NSBezierPath {
|
||||
let cp1 = CGPoint(
|
||||
x: start.x + ((control.x - start.x) * 2.0 / 3.0),
|
||||
y: start.y + ((control.y - start.y) * 2.0 / 3.0)
|
||||
)
|
||||
let cp2 = CGPoint(
|
||||
x: end.x + ((control.x - end.x) * 2.0 / 3.0),
|
||||
y: end.y + ((control.y - end.y) * 2.0 / 3.0)
|
||||
)
|
||||
let path = NSBezierPath()
|
||||
path.move(to: start)
|
||||
path.curve(to: end, controlPoint1: cp1, controlPoint2: cp2)
|
||||
return path
|
||||
}
|
||||
|
||||
func quadraticPoint(start: CGPoint, control: CGPoint, end: CGPoint, t: CGFloat) -> CGPoint {
|
||||
let oneMinusT = 1 - t
|
||||
let x = (oneMinusT * oneMinusT * start.x) + (2 * oneMinusT * t * control.x) + (t * t * end.x)
|
||||
let y = (oneMinusT * oneMinusT * start.y) + (2 * oneMinusT * t * control.y) + (t * t * end.y)
|
||||
return CGPoint(x: x, y: y)
|
||||
}
|
||||
|
||||
func quadraticAngle(start: CGPoint, control: CGPoint, end: CGPoint, t: CGFloat) -> CGFloat {
|
||||
let dx = (2 * (1 - t) * (control.x - start.x)) + (2 * t * (end.x - control.x))
|
||||
let dy = (2 * (1 - t) * (control.y - start.y)) + (2 * t * (end.y - control.y))
|
||||
return atan2(dy, dx) * 180 / .pi
|
||||
}
|
||||
|
||||
func drawRouteArc(
|
||||
start: CGPoint,
|
||||
control: CGPoint,
|
||||
end: CGPoint,
|
||||
color: NSColor,
|
||||
lineWidth: CGFloat,
|
||||
dash: [CGFloat] = [22, 18]
|
||||
) {
|
||||
let path = quadraticPath(start: start, control: control, end: end)
|
||||
color.setStroke()
|
||||
path.lineWidth = lineWidth
|
||||
path.lineCapStyle = .round
|
||||
path.setLineDash(dash, count: dash.count, phase: 0)
|
||||
path.stroke()
|
||||
}
|
||||
|
||||
func drawRing(center: CGPoint, radius: CGFloat, width: CGFloat, color: NSColor, alpha: CGFloat = 1.0) {
|
||||
let path = NSBezierPath(ovalIn: circleRect(center: center, radius: radius))
|
||||
strokePath(path, color: color.withAlphaComponent(alpha), lineWidth: width)
|
||||
}
|
||||
|
||||
func drawGlobe(
|
||||
center: CGPoint,
|
||||
radius: CGFloat,
|
||||
fillColors: [NSColor],
|
||||
landTint: NSColor,
|
||||
gridColor: NSColor,
|
||||
highlightColor: NSColor,
|
||||
landAlpha: CGFloat = 0.35,
|
||||
strokeColor: NSColor? = nil,
|
||||
glowColor: NSColor? = nil
|
||||
) {
|
||||
let globeRect = circleRect(center: center, radius: radius)
|
||||
let globePath = NSBezierPath(ovalIn: globeRect)
|
||||
|
||||
if let glowColor {
|
||||
applyShadow(color: glowColor, blur: 48) {
|
||||
fillCircle(center: center, radius: radius * 0.96, color: glowColor.withAlphaComponent(0.35))
|
||||
}
|
||||
}
|
||||
|
||||
fillPath(globePath, colors: fillColors, angle: 90)
|
||||
|
||||
withGraphicsState {
|
||||
globePath.addClip()
|
||||
|
||||
let continentsRect = globeRect.insetBy(dx: radius * 0.12, dy: radius * 0.12)
|
||||
drawSymbol(
|
||||
"globe.americas.fill",
|
||||
in: continentsRect.offsetBy(dx: radius * 0.03, dy: radius * -0.02),
|
||||
pointSize: radius * 1.3,
|
||||
tint: landTint,
|
||||
weight: .regular,
|
||||
rotationDegrees: 0,
|
||||
alpha: landAlpha
|
||||
)
|
||||
|
||||
let meridians: [CGFloat] = [0.42, 0.72]
|
||||
for scale in meridians {
|
||||
let ellipse = NSBezierPath(
|
||||
ovalIn: CGRect(
|
||||
x: center.x - radius * scale,
|
||||
y: center.y - radius,
|
||||
width: radius * scale * 2,
|
||||
height: radius * 2
|
||||
)
|
||||
)
|
||||
strokePath(ellipse, color: gridColor.withAlphaComponent(0.9), lineWidth: 5)
|
||||
}
|
||||
|
||||
let parallels: [CGFloat] = [0.30, 0.62, 0.86]
|
||||
for scale in parallels {
|
||||
let ellipse = NSBezierPath(
|
||||
ovalIn: CGRect(
|
||||
x: center.x - radius,
|
||||
y: center.y - radius * scale,
|
||||
width: radius * 2,
|
||||
height: radius * scale * 2
|
||||
)
|
||||
)
|
||||
strokePath(ellipse, color: gridColor.withAlphaComponent(0.68), lineWidth: 4)
|
||||
}
|
||||
|
||||
let specular = NSBezierPath(
|
||||
ovalIn: CGRect(
|
||||
x: center.x - radius * 0.62,
|
||||
y: center.y + radius * 0.16,
|
||||
width: radius * 0.70,
|
||||
height: radius * 0.36
|
||||
)
|
||||
)
|
||||
highlightColor.withAlphaComponent(0.18).setFill()
|
||||
specular.fill()
|
||||
}
|
||||
|
||||
if let strokeColor {
|
||||
strokePath(globePath, color: strokeColor, lineWidth: 6)
|
||||
}
|
||||
}
|
||||
|
||||
func drawTopographicLines(rect: CGRect, color: NSColor, count: Int, inset: CGFloat) {
|
||||
for index in 0..<count {
|
||||
let y = rect.minY + inset + (CGFloat(index) * ((rect.height - inset * 2) / CGFloat(count)))
|
||||
let path = NSBezierPath()
|
||||
path.move(to: CGPoint(x: rect.minX + inset, y: y))
|
||||
path.curve(
|
||||
to: CGPoint(x: rect.maxX - inset, y: y + 24),
|
||||
controlPoint1: CGPoint(x: rect.minX + rect.width * 0.28, y: y - 38),
|
||||
controlPoint2: CGPoint(x: rect.minX + rect.width * 0.68, y: y + 64)
|
||||
)
|
||||
path.lineWidth = 3
|
||||
path.setLineDash([18, 14], count: 2, phase: CGFloat(index) * 6)
|
||||
color.setStroke()
|
||||
path.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
func drawStarField(rect: CGRect, stars: [(CGFloat, CGFloat, CGFloat, CGFloat)]) {
|
||||
for (x, y, size, alpha) in stars {
|
||||
fillCircle(
|
||||
center: CGPoint(x: rect.minX + x * rect.width, y: rect.minY + y * rect.height),
|
||||
radius: size,
|
||||
color: NSColor.white.withAlphaComponent(alpha)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func drawOrbitalRoute(rect: CGRect) {
|
||||
fillRoundedRect(
|
||||
rect,
|
||||
radius: 220,
|
||||
colors: [
|
||||
NSColor(hex: 0x08111F),
|
||||
NSColor(hex: 0x1D2A64),
|
||||
NSColor(hex: 0x4F46E5)
|
||||
],
|
||||
angle: 42
|
||||
)
|
||||
|
||||
fillCircle(center: CGPoint(x: 214, y: 860), radius: 170, color: Palette.accentLight.withAlphaComponent(0.14))
|
||||
fillCircle(center: CGPoint(x: 832, y: 248), radius: 200, color: Palette.sky.withAlphaComponent(0.18))
|
||||
|
||||
drawGlobe(
|
||||
center: CGPoint(x: 332, y: 368),
|
||||
radius: 302,
|
||||
fillColors: [NSColor(hex: 0x183B6C), NSColor(hex: 0x0C1834)],
|
||||
landTint: NSColor.white,
|
||||
gridColor: NSColor.white.withAlphaComponent(0.34),
|
||||
highlightColor: NSColor.white,
|
||||
landAlpha: 0.18,
|
||||
strokeColor: NSColor.white.withAlphaComponent(0.12),
|
||||
glowColor: Palette.sky.withAlphaComponent(0.2)
|
||||
)
|
||||
|
||||
let start = CGPoint(x: 272, y: 464)
|
||||
let control = CGPoint(x: 500, y: 940)
|
||||
let end = CGPoint(x: 830, y: 744)
|
||||
drawRouteArc(
|
||||
start: start,
|
||||
control: control,
|
||||
end: end,
|
||||
color: NSColor.white.withAlphaComponent(0.50),
|
||||
lineWidth: 16
|
||||
)
|
||||
|
||||
fillCircle(center: start, radius: 22, color: Palette.mint)
|
||||
applyShadow(color: NSColor.black.withAlphaComponent(0.28), blur: 24, y: -8) {
|
||||
let planeCenter = quadraticPoint(start: start, control: control, end: end, t: 0.58)
|
||||
let planeAngle = quadraticAngle(start: start, control: control, end: end, t: 0.58) - 6
|
||||
drawSymbol(
|
||||
"airplane",
|
||||
in: CGRect(x: planeCenter.x - 92, y: planeCenter.y - 64, width: 184, height: 128),
|
||||
pointSize: 150,
|
||||
tint: NSColor.white,
|
||||
weight: .semibold,
|
||||
rotationDegrees: planeAngle
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func drawWindowBadge(rect: CGRect) {
|
||||
fillRoundedRect(
|
||||
rect,
|
||||
radius: 220,
|
||||
colors: [
|
||||
NSColor(hex: 0xE0F2FE),
|
||||
NSColor(hex: 0xC7D2FE),
|
||||
NSColor(hex: 0x93C5FD)
|
||||
],
|
||||
angle: 65
|
||||
)
|
||||
|
||||
drawRing(center: CGPoint(x: 300, y: 832), radius: 116, width: 30, color: NSColor.white, alpha: 0.20)
|
||||
fillCircle(center: CGPoint(x: 796, y: 226), radius: 180, color: Palette.accent.withAlphaComponent(0.12))
|
||||
|
||||
let badgeCenter = CGPoint(x: 512, y: 528)
|
||||
let badgeRadius: CGFloat = 356
|
||||
applyShadow(color: NSColor.black.withAlphaComponent(0.18), blur: 38, y: -14) {
|
||||
drawGlobe(
|
||||
center: badgeCenter,
|
||||
radius: badgeRadius,
|
||||
fillColors: [NSColor(hex: 0x5046E5), NSColor(hex: 0x1E1B4B)],
|
||||
landTint: NSColor.white,
|
||||
gridColor: NSColor.white.withAlphaComponent(0.55),
|
||||
highlightColor: NSColor.white,
|
||||
landAlpha: 0.15,
|
||||
strokeColor: NSColor.white.withAlphaComponent(0.14),
|
||||
glowColor: Palette.accent.withAlphaComponent(0.2)
|
||||
)
|
||||
}
|
||||
|
||||
fillCircle(center: CGPoint(x: 406, y: 662), radius: 104, color: NSColor.white.withAlphaComponent(0.10))
|
||||
|
||||
let orbit = NSBezierPath(ovalIn: circleRect(center: badgeCenter, radius: badgeRadius + 58))
|
||||
orbit.lineWidth = 22
|
||||
orbit.setLineDash([170, 44], count: 2, phase: 82)
|
||||
NSColor.white.withAlphaComponent(0.58).setStroke()
|
||||
orbit.stroke()
|
||||
|
||||
let planeRect = CGRect(x: 674, y: 680, width: 220, height: 160)
|
||||
applyShadow(color: NSColor.black.withAlphaComponent(0.28), blur: 24, y: -10) {
|
||||
drawSymbol(
|
||||
"airplane",
|
||||
in: planeRect,
|
||||
pointSize: 170,
|
||||
tint: NSColor.white,
|
||||
weight: .semibold,
|
||||
rotationDegrees: 32
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func drawAtlasTile(rect: CGRect) {
|
||||
fillRoundedRect(
|
||||
rect,
|
||||
radius: 220,
|
||||
colors: [
|
||||
NSColor(hex: 0xF8FAFC),
|
||||
NSColor(hex: 0xEEF2FF),
|
||||
NSColor(hex: 0xDBEAFE)
|
||||
],
|
||||
angle: 90
|
||||
)
|
||||
|
||||
drawTopographicLines(rect: rect, color: Palette.accent.withAlphaComponent(0.14), count: 10, inset: 84)
|
||||
|
||||
let roundelRect = CGRect(x: 176, y: 176, width: 672, height: 672)
|
||||
let roundelPath = NSBezierPath(roundedRect: roundelRect, xRadius: 190, yRadius: 190)
|
||||
applyShadow(color: NSColor.black.withAlphaComponent(0.12), blur: 24, y: -10) {
|
||||
fillPath(
|
||||
roundelPath,
|
||||
colors: [
|
||||
NSColor(hex: 0x0F172A),
|
||||
NSColor(hex: 0x1E293B),
|
||||
NSColor(hex: 0x312E81)
|
||||
],
|
||||
angle: 50
|
||||
)
|
||||
}
|
||||
|
||||
drawGlobe(
|
||||
center: CGPoint(x: 512, y: 512),
|
||||
radius: 244,
|
||||
fillColors: [NSColor(hex: 0x1D4ED8), NSColor(hex: 0x1E3A8A)],
|
||||
landTint: NSColor.white,
|
||||
gridColor: NSColor.white.withAlphaComponent(0.55),
|
||||
highlightColor: NSColor.white,
|
||||
landAlpha: 0.20,
|
||||
strokeColor: NSColor.white.withAlphaComponent(0.10),
|
||||
glowColor: Palette.boarding.withAlphaComponent(0.10)
|
||||
)
|
||||
|
||||
let orbit = NSBezierPath()
|
||||
orbit.move(to: CGPoint(x: 228, y: 384))
|
||||
orbit.curve(
|
||||
to: CGPoint(x: 786, y: 670),
|
||||
controlPoint1: CGPoint(x: 322, y: 804),
|
||||
controlPoint2: CGPoint(x: 650, y: 870)
|
||||
)
|
||||
orbit.lineWidth = 18
|
||||
orbit.lineCapStyle = .round
|
||||
orbit.setLineDash([28, 22], count: 2, phase: 0)
|
||||
Palette.accentLight.withAlphaComponent(0.82).setStroke()
|
||||
orbit.stroke()
|
||||
|
||||
fillCircle(center: CGPoint(x: 786, y: 670), radius: 18, color: Palette.mint)
|
||||
|
||||
applyShadow(color: NSColor.black.withAlphaComponent(0.28), blur: 24, y: -6) {
|
||||
drawSymbol(
|
||||
"airplane",
|
||||
in: CGRect(x: 356, y: 408, width: 320, height: 230),
|
||||
pointSize: 246,
|
||||
tint: NSColor.white,
|
||||
weight: .bold,
|
||||
rotationDegrees: 28
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func drawNightRing(rect: CGRect) {
|
||||
fillRoundedRect(
|
||||
rect,
|
||||
radius: 220,
|
||||
colors: [
|
||||
NSColor(hex: 0x020617),
|
||||
NSColor(hex: 0x111827),
|
||||
NSColor(hex: 0x172554)
|
||||
],
|
||||
angle: 50
|
||||
)
|
||||
|
||||
drawStarField(
|
||||
rect: rect,
|
||||
stars: [
|
||||
(0.18, 0.82, 5, 0.70),
|
||||
(0.29, 0.74, 3, 0.42),
|
||||
(0.77, 0.81, 4, 0.58),
|
||||
(0.67, 0.69, 3, 0.36),
|
||||
(0.86, 0.34, 5, 0.62),
|
||||
(0.15, 0.30, 4, 0.44)
|
||||
]
|
||||
)
|
||||
|
||||
let center = CGPoint(x: 512, y: 512)
|
||||
fillCircle(center: center, radius: 350, color: Palette.sky.withAlphaComponent(0.07))
|
||||
|
||||
drawRing(center: center, radius: 338, width: 28, color: Palette.sky, alpha: 0.92)
|
||||
drawRing(center: center, radius: 378, width: 6, color: NSColor.white, alpha: 0.20)
|
||||
|
||||
drawGlobe(
|
||||
center: center,
|
||||
radius: 252,
|
||||
fillColors: [NSColor(hex: 0x0F766E), NSColor(hex: 0x0F172A)],
|
||||
landTint: NSColor.white,
|
||||
gridColor: NSColor.white.withAlphaComponent(0.44),
|
||||
highlightColor: NSColor.white,
|
||||
landAlpha: 0.16,
|
||||
strokeColor: NSColor.white.withAlphaComponent(0.10),
|
||||
glowColor: Palette.sky.withAlphaComponent(0.18)
|
||||
)
|
||||
|
||||
fillCircle(center: CGPoint(x: 512, y: 512), radius: 178, color: Palette.mint.withAlphaComponent(0.10))
|
||||
|
||||
applyShadow(color: NSColor.black.withAlphaComponent(0.30), blur: 20, y: -6) {
|
||||
drawSymbol(
|
||||
"airplane",
|
||||
in: CGRect(x: 668, y: 676, width: 194, height: 140),
|
||||
pointSize: 148,
|
||||
tint: NSColor.white,
|
||||
weight: .semibold,
|
||||
rotationDegrees: 36
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func drawRouteCard(rect: CGRect) {
|
||||
fillRoundedRect(
|
||||
rect,
|
||||
radius: 220,
|
||||
colors: [
|
||||
NSColor(hex: 0xF8FAFC),
|
||||
NSColor(hex: 0xEEF2FF),
|
||||
NSColor(hex: 0xE0E7FF)
|
||||
],
|
||||
angle: 70
|
||||
)
|
||||
|
||||
let backCard = CGRect(x: 176, y: 204, width: 684, height: 614)
|
||||
let frontCard = CGRect(x: 132, y: 246, width: 760, height: 560)
|
||||
|
||||
let backPath = roundedRectPath(backCard, radius: 88)
|
||||
let frontPath = roundedRectPath(frontCard, radius: 96)
|
||||
|
||||
applyShadow(color: NSColor.black.withAlphaComponent(0.10), blur: 18, y: -8) {
|
||||
fillPath(backPath, colors: [Palette.accentLight.withAlphaComponent(0.30), Palette.boarding.withAlphaComponent(0.18)], angle: 12)
|
||||
}
|
||||
|
||||
applyShadow(color: NSColor.black.withAlphaComponent(0.12), blur: 24, y: -10) {
|
||||
fillPath(frontPath, colors: [NSColor.white, NSColor(hex: 0xEEF2FF)], angle: 90)
|
||||
}
|
||||
strokePath(frontPath, color: Palette.cloud.withAlphaComponent(0.88), lineWidth: 4)
|
||||
|
||||
let globeCenter = CGPoint(x: 314, y: 584)
|
||||
drawGlobe(
|
||||
center: globeCenter,
|
||||
radius: 156,
|
||||
fillColors: [Palette.accentLight, Palette.accent],
|
||||
landTint: NSColor.white,
|
||||
gridColor: NSColor.white.withAlphaComponent(0.52),
|
||||
highlightColor: NSColor.white,
|
||||
landAlpha: 0.18,
|
||||
strokeColor: NSColor.white.withAlphaComponent(0.10),
|
||||
glowColor: Palette.accent.withAlphaComponent(0.12)
|
||||
)
|
||||
|
||||
let start = CGPoint(x: 346, y: 438)
|
||||
let control = CGPoint(x: 548, y: 754)
|
||||
let end = CGPoint(x: 780, y: 538)
|
||||
drawRouteArc(
|
||||
start: start,
|
||||
control: control,
|
||||
end: end,
|
||||
color: Palette.ink.withAlphaComponent(0.32),
|
||||
lineWidth: 16,
|
||||
dash: [20, 18]
|
||||
)
|
||||
fillCircle(center: start, radius: 20, color: Palette.mint)
|
||||
fillCircle(center: end, radius: 20, color: Palette.boarding)
|
||||
|
||||
let planeCenter = quadraticPoint(start: start, control: control, end: end, t: 0.58)
|
||||
let planeAngle = quadraticAngle(start: start, control: control, end: end, t: 0.58) - 10
|
||||
applyShadow(color: NSColor.black.withAlphaComponent(0.18), blur: 16, y: -4) {
|
||||
drawSymbol(
|
||||
"airplane",
|
||||
in: CGRect(x: planeCenter.x - 102, y: planeCenter.y - 72, width: 204, height: 144),
|
||||
pointSize: 166,
|
||||
tint: Palette.ink,
|
||||
weight: .bold,
|
||||
rotationDegrees: planeAngle
|
||||
)
|
||||
}
|
||||
|
||||
let detailPill = roundedRectPath(CGRect(x: 226, y: 318, width: 360, height: 28), radius: 14)
|
||||
fillPath(detailPill, colors: [Palette.cloud.withAlphaComponent(0.92), Palette.cloud.withAlphaComponent(0.92)], angle: 0)
|
||||
|
||||
let accentPill = roundedRectPath(CGRect(x: 226, y: 272, width: 220, height: 28), radius: 14)
|
||||
fillPath(
|
||||
accentPill,
|
||||
colors: [Palette.accent.withAlphaComponent(0.16), Palette.boarding.withAlphaComponent(0.16)],
|
||||
angle: 0
|
||||
)
|
||||
|
||||
let actionChip = roundedRectPath(CGRect(x: 620, y: 270, width: 164, height: 54), radius: 27)
|
||||
fillPath(actionChip, colors: [Palette.accent, Palette.boarding], angle: 0)
|
||||
|
||||
drawSymbol(
|
||||
"airplane.departure",
|
||||
in: CGRect(x: 662, y: 282, width: 82, height: 34),
|
||||
pointSize: 46,
|
||||
tint: NSColor.white,
|
||||
weight: .bold
|
||||
)
|
||||
}
|
||||
|
||||
func renderBitmap(size: CGSize = canvasSize, draw: (CGRect) -> Void) -> NSBitmapImageRep {
|
||||
let rep = NSBitmapImageRep(
|
||||
bitmapDataPlanes: nil,
|
||||
pixelsWide: Int(size.width),
|
||||
pixelsHigh: Int(size.height),
|
||||
bitsPerSample: 8,
|
||||
samplesPerPixel: 4,
|
||||
hasAlpha: true,
|
||||
isPlanar: false,
|
||||
colorSpaceName: .deviceRGB,
|
||||
bytesPerRow: 0,
|
||||
bitsPerPixel: 0
|
||||
)!
|
||||
|
||||
rep.size = size
|
||||
|
||||
let graphicsContext = NSGraphicsContext(bitmapImageRep: rep)!
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
NSGraphicsContext.current = graphicsContext
|
||||
draw(CGRect(origin: .zero, size: size))
|
||||
graphicsContext.flushGraphics()
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
return rep
|
||||
}
|
||||
|
||||
func savePNG(_ rep: NSBitmapImageRep, to url: URL) throws {
|
||||
guard let data = rep.representation(using: .png, properties: [:]) else {
|
||||
throw NSError(domain: "IconGenerator", code: 1)
|
||||
}
|
||||
try data.write(to: url)
|
||||
}
|
||||
|
||||
func drawComparisonSheet(options: [IconOption], imageDirectory: URL, outputURL: URL) throws {
|
||||
let sheetSize = CGSize(width: 2190, height: 1620)
|
||||
let rep = renderBitmap(size: sheetSize) { rect in
|
||||
fillRoundedRect(
|
||||
rect,
|
||||
radius: 120,
|
||||
colors: [NSColor(hex: 0xF8FAFC), NSColor(hex: 0xEEF2FF)],
|
||||
angle: 90
|
||||
)
|
||||
|
||||
let titleAttributes: [NSAttributedString.Key: Any] = [
|
||||
.font: NSFont.systemFont(ofSize: 82, weight: .bold),
|
||||
.foregroundColor: Palette.slate
|
||||
]
|
||||
let subtitleAttributes: [NSAttributedString.Key: Any] = [
|
||||
.font: NSFont.systemFont(ofSize: 34, weight: .medium),
|
||||
.foregroundColor: Palette.ink.withAlphaComponent(0.70)
|
||||
]
|
||||
NSAttributedString(string: "Flights App Icon Concepts", attributes: titleAttributes)
|
||||
.draw(at: CGPoint(x: 112, y: 1470))
|
||||
NSAttributedString(
|
||||
string: "1 to 5. Same palette, different attitude.",
|
||||
attributes: subtitleAttributes
|
||||
)
|
||||
.draw(at: CGPoint(x: 118, y: 1412))
|
||||
|
||||
let cardWidth: CGFloat = 354
|
||||
let cardHeight: CGFloat = 474
|
||||
let horizontalGap: CGFloat = 52
|
||||
let verticalGap: CGFloat = 50
|
||||
let startX: CGFloat = 112
|
||||
let startY: CGFloat = 856
|
||||
|
||||
for (index, option) in options.enumerated() {
|
||||
let row = index / 3
|
||||
let column = index % 3
|
||||
let x = startX + CGFloat(column) * (cardWidth + horizontalGap)
|
||||
let y = startY - CGFloat(row) * (cardHeight + verticalGap)
|
||||
let cardRect = CGRect(x: x, y: y, width: cardWidth, height: cardHeight)
|
||||
|
||||
let cardPath = roundedRectPath(cardRect, radius: 52)
|
||||
applyShadow(color: NSColor.black.withAlphaComponent(0.10), blur: 20, y: -8) {
|
||||
fillPath(cardPath, colors: [NSColor.white, NSColor(hex: 0xF8FAFC)], angle: 90)
|
||||
}
|
||||
|
||||
let imageURL = imageDirectory.appendingPathComponent(option.fileName)
|
||||
if let image = NSImage(contentsOf: imageURL) {
|
||||
image.draw(in: CGRect(x: x + 27, y: y + 116, width: 300, height: 300))
|
||||
}
|
||||
|
||||
let labelAttributes: [NSAttributedString.Key: Any] = [
|
||||
.font: NSFont.monospacedSystemFont(ofSize: 30, weight: .bold),
|
||||
.foregroundColor: Palette.accent
|
||||
]
|
||||
let titleAttributes: [NSAttributedString.Key: Any] = [
|
||||
.font: NSFont.systemFont(ofSize: 34, weight: .semibold),
|
||||
.foregroundColor: Palette.slate
|
||||
]
|
||||
NSAttributedString(string: "\(index + 1).", attributes: labelAttributes)
|
||||
.draw(at: CGPoint(x: x + 28, y: y + 58))
|
||||
NSAttributedString(string: option.label, attributes: titleAttributes)
|
||||
.draw(at: CGPoint(x: x + 82, y: y + 54))
|
||||
}
|
||||
}
|
||||
|
||||
try savePNG(rep, to: outputURL)
|
||||
}
|
||||
|
||||
let options: [IconOption] = [
|
||||
.init(fileName: "01_orbital-route.png", label: "Orbital Route", draw: drawOrbitalRoute),
|
||||
.init(fileName: "02_window-badge.png", label: "Window Badge", draw: drawWindowBadge),
|
||||
.init(fileName: "03_atlas-tile.png", label: "Atlas Tile", draw: drawAtlasTile),
|
||||
.init(fileName: "04_night-ring.png", label: "Night Ring", draw: drawNightRing),
|
||||
.init(fileName: "05_route-card.png", label: "Route Card", draw: drawRouteCard)
|
||||
]
|
||||
|
||||
let fileManager = FileManager.default
|
||||
let root = URL(fileURLWithPath: fileManager.currentDirectoryPath)
|
||||
let outputDirectory = root.appendingPathComponent("design/icon-options", isDirectory: true)
|
||||
try fileManager.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
|
||||
|
||||
for option in options {
|
||||
let rep = renderBitmap(draw: option.draw)
|
||||
try savePNG(rep, to: outputDirectory.appendingPathComponent(option.fileName))
|
||||
}
|
||||
|
||||
try drawComparisonSheet(
|
||||
options: options,
|
||||
imageDirectory: outputDirectory,
|
||||
outputURL: outputDirectory.appendingPathComponent("comparison-sheet.png")
|
||||
)
|
||||
|
||||
print("Generated \(options.count) icons in \(outputDirectory.path)")
|
||||
BIN
design/icon-options/01_orbital-route.png
Normal file
|
After Width: | Height: | Size: 929 KiB |
BIN
design/icon-options/02_window-badge.png
Normal file
|
After Width: | Height: | Size: 920 KiB |
BIN
design/icon-options/03_atlas-tile.png
Normal file
|
After Width: | Height: | Size: 613 KiB |
BIN
design/icon-options/04_night-ring.png
Normal file
|
After Width: | Height: | Size: 934 KiB |
BIN
design/icon-options/05_route-card.png
Normal file
|
After Width: | Height: | Size: 674 KiB |
14
design/icon-options/README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
Generated app icon concepts for `Flights`.
|
||||
|
||||
Files:
|
||||
- `01_orbital-route.png`: dark, route-led, strongest "fly everywhere" story.
|
||||
- `02_window-badge.png`: bright and premium, centered globe with orbital motion.
|
||||
- `03_atlas-tile.png`: editorial/lightweight, dark inset tile on an airy base.
|
||||
- `04_night-ring.png`: bold and futuristic, orbit-ring treatment on a dark field.
|
||||
- `05_route-card.png`: clean iOS card language using the app's UI motif.
|
||||
- `comparison-sheet.png`: all five options on one board.
|
||||
|
||||
Regenerate:
|
||||
```sh
|
||||
swift -module-cache-path /tmp/swift-module-cache design/generate_icon_options.swift
|
||||
```
|
||||
BIN
design/icon-options/comparison-sheet.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
230
flow.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# FlightConnections.com - API Flow
|
||||
|
||||
> Captured 2026-04-07 from HAR file. User flow: Homepage -> Select DFW -> View destinations -> Select MAD -> View flight calendar for American Airlines.
|
||||
|
||||
## User Flow Summary
|
||||
|
||||
```
|
||||
Homepage (flightconnections.com/)
|
||||
|
|
||||
|-- User types "d", "dfw" in airport search
|
||||
| GET autocomplete_location.php?term=d -> returns DFW, DXB, DEN + countries
|
||||
| GET autocomplete_location.php?term=dfw -> returns DFW (id=2138)
|
||||
|
|
||||
|-- User selects DFW -> navigates to /flights-from-dallas-fort-worth-dfw
|
||||
| GET aircrafts_url.php?depAps=2138 -> 45 aircraft types (filter options)
|
||||
| GET airlines_url.php?depAps=2138 -> 34 airlines (filter options)
|
||||
| Map tiles load showing all destinations from DFW
|
||||
|
|
||||
|-- User selects Madrid (MAD, id=186) -> /flights-from-dfw-to-mad
|
||||
| POST validity.php dep=2138 & des=186 & id=300 (AA)
|
||||
| -> Returns 19 schedule entries for AA flight 36 (DFW->MAD)
|
||||
| -> Calendar datepicker renders available dates
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### 1. Airport Autocomplete
|
||||
|
||||
```
|
||||
GET https://www.flightconnections.com/autocomplete_location.php
|
||||
```
|
||||
|
||||
| Param | Type | Example | Description |
|
||||
|--------|--------|---------|-----------------------|
|
||||
| `lang` | string | `en` | Language |
|
||||
| `term` | string | `dfw` | Search term (as-you-type) |
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"airports": [
|
||||
{
|
||||
"value": "DFW - Dallas-Fort Worth",
|
||||
"airport": "Dallas-Fort Worth (DFW)",
|
||||
"id": "2138"
|
||||
}
|
||||
],
|
||||
"countries": [
|
||||
{ "name": "Denmark", "code": "DK", "id": "57" }
|
||||
],
|
||||
"continents": []
|
||||
}
|
||||
```
|
||||
|
||||
- Returns airports, countries, and continents matching the search term
|
||||
- Airport `id` is the internal numeric identifier used in all subsequent calls
|
||||
|
||||
---
|
||||
|
||||
### 2. Airlines Filter List
|
||||
|
||||
```
|
||||
GET https://www.flightconnections.com/airlines_url.php
|
||||
```
|
||||
|
||||
| Param | Type | Example | Description |
|
||||
|----------|--------|---------|----------------------------|
|
||||
| `lang` | string | `en` | Language |
|
||||
| `ids` | string | (empty) | Filter by airline IDs |
|
||||
| `cl` | string | (empty) | Class filter |
|
||||
| `depAps` | int | `2138` | Departure airport ID |
|
||||
| `desAps` | int | (empty) | Destination airport ID |
|
||||
|
||||
**Response:** HTML fragment of `<div>` elements, each with:
|
||||
- `id` - internal airline ID (e.g. `300` = American Airlines)
|
||||
- `data-name` - airline name
|
||||
- `data-iata` - IATA code
|
||||
- `data-fleet` - comma-separated aircraft type IDs
|
||||
- CSS classes: `cl-f` (first), `cl-b` (business), `cl-p` (premium economy), `cl-e` (economy)
|
||||
|
||||
**Airlines serving DFW (34 total):**
|
||||
Aerolineas Argentinas (AR), Aeromexico (AM), Air Canada (AC), Air France (AF), Alaska (AS), American Airlines (AA), Avelo Airlines (XP), Avianca (AV), Breeze Airways (MX), British Airways (BA), Cathay Pacific (CX), Contour Aviation (LF), Delta (DL), Emirates (EK), EVA Air (BR), Fiji Airways (FJ), Finnair (AY), Frontier Airlines (F9), Iberia (IB), Japan Airlines (JL), JetBlue (B6), Key Lime Air (KG), Korean Air (KE), Lufthansa (LH), Qantas (QF), Qatar Airways (QR), Royal Jordanian (RJ), Southern Airways Express (9X), Spirit Airlines (NK), Sun Country Airlines (SY), Turkish Airlines (TK), United Airlines (UA), VivaAerobus (VB), Volaris (Y4)
|
||||
|
||||
---
|
||||
|
||||
### 3. Aircraft Filter List
|
||||
|
||||
```
|
||||
GET https://www.flightconnections.com/aircrafts_url.php
|
||||
```
|
||||
|
||||
| Param | Type | Example | Description |
|
||||
|----------|--------|---------|----------------------------|
|
||||
| `lang` | string | `en` | Language |
|
||||
| `ids` | string | (empty) | Filter by aircraft IDs |
|
||||
| `cl` | string | (empty) | Class filter |
|
||||
| `depAps` | int | `2138` | Departure airport ID |
|
||||
| `desAps` | int | (empty) | Destination airport ID |
|
||||
|
||||
**Response:** HTML fragment of `<div>` elements, each with:
|
||||
- `id` - e.g. `ac-44` (Boeing 777-200)
|
||||
- `data-name` - aircraft name
|
||||
- `data-code` - ICAO type code
|
||||
- `data-id` - internal aircraft ID
|
||||
- `data-airlines` - comma-separated airline IDs that operate this type
|
||||
- `data-alliances` - alliance IDs
|
||||
|
||||
**45 aircraft types** serving DFW including A220, A319, A320neo, A321neo, 737 MAX, 777-200, 787-9, etc.
|
||||
|
||||
---
|
||||
|
||||
### 4. Flight Schedule / Calendar Data
|
||||
|
||||
```
|
||||
POST https://www.flightconnections.com/validity.php
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
```
|
||||
|
||||
| Param | Type | Example | Description |
|
||||
|-------------|--------|---------|--------------------------------|
|
||||
| `dep` | int | `2138` | Departure airport ID (DFW) |
|
||||
| `des` | int | `186` | Destination airport ID (MAD) |
|
||||
| `id` | int | `300` | Airline ID (American Airlines) |
|
||||
| `startDate` | int | `2026` | Start year |
|
||||
| `endDate` | int | `2027` | End year |
|
||||
| `lang` | string | `en` | Language |
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"airline": "American Airlines",
|
||||
"flights": [
|
||||
{
|
||||
"flightnumber": "AA 36",
|
||||
"ac_id": "44",
|
||||
"aircraft": "Boeing 777-200",
|
||||
"deptime": "16:45:00",
|
||||
"destime": "09:05:00",
|
||||
"datefrom": "2026-04-07",
|
||||
"dateto": "2026-05-06",
|
||||
"su": "0", "mo": "0", "tu": "1", "we": "1",
|
||||
"th": "0", "fr": "0", "sa": "0",
|
||||
"classes": "0111"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Flight fields:**
|
||||
| Field | Description |
|
||||
|----------------|----------------------------------------------------------|
|
||||
| `flightnumber` | Flight number (e.g. "AA 36") |
|
||||
| `ac_id` | Aircraft type ID (maps to aircrafts_url.php) |
|
||||
| `aircraft` | Aircraft name |
|
||||
| `deptime` | Departure time (local) |
|
||||
| `destime` | Arrival time (local) |
|
||||
| `datefrom` | Schedule validity start date |
|
||||
| `dateto` | Schedule validity end date |
|
||||
| `su`-`sa` | Day-of-week flags ("1" = operates, "0" = does not) |
|
||||
| `classes` | 4-char string: First/Business/PremEcon/Economy ("0"/"1") |
|
||||
|
||||
**DFW -> MAD captured schedule (AA 36, 19 entries):**
|
||||
|
||||
| Depart | Arrive | Dates | Days of Week | Aircraft |
|
||||
|--------|--------|--------------------------|----------------------|---------------|
|
||||
| 16:45 | 09:05 | 2026-04-07 to 2026-05-06 | Tu We | Boeing 777-200 |
|
||||
| 16:45 | 09:05 | 2026-04-09 to 2026-05-04 | Su Mo Th Fr Sa | Boeing 777-200 |
|
||||
| 17:20 | 09:35 | 2026-05-07 to 2026-05-18 | Su Mo Th Fr Sa | Boeing 777-200 |
|
||||
| 17:20 | 09:35 | 2026-05-12 to 2026-05-20 | Tu We | Boeing 777-200 |
|
||||
| 16:40 | 09:05 | 2026-05-21 to 2026-06-29 | Su Mo Th Fr Sa | Boeing 777-200 |
|
||||
| 16:40 | 09:05 | 2026-05-26 to 2026-07-01 | Tu We | Boeing 777-200 |
|
||||
| 16:55 | 09:05 | 2026-07-02 to 2026-10-23 | Su Mo Th Fr Sa | Boeing 777-200 |
|
||||
| 16:55 | 09:05 | 2026-07-07 to 2026-10-21 | Tu We | Boeing 777-200 |
|
||||
| 18:10 | 09:20 | 2026-10-24 to 2026-10-24 | Sa (DST transition) | Boeing 777-200 |
|
||||
| 18:20 | 09:20 | 2026-10-25 to 2026-10-31 | Su Tu Th Fr Sa | Boeing 777-200 |
|
||||
| 18:20 | 09:20 | 2026-10-26 to 2026-10-28 | Mo We | Boeing 777-200 |
|
||||
| 17:20 | 09:20 | 2026-11-01 to 2026-12-01 | Su Tu Th Fr Sa | Boeing 777-200 |
|
||||
| 17:20 | 09:20 | 2026-11-02 to 2026-11-30 | Mo We | Boeing 777-200 |
|
||||
| 17:20 | 09:20 | 2026-12-02 to 2027-03-03 | Mo We | Boeing 787-9 |
|
||||
| 17:20 | 09:20 | 2026-12-03 to 2027-03-02 | Su Tu Th Fr Sa | Boeing 787-9 |
|
||||
| 17:20 | 09:20 | 2027-03-04 to 2027-03-13 | Su Tu Th Fr Sa | Boeing 777-200 |
|
||||
| 17:20 | 09:20 | 2027-03-08 to 2027-03-10 | Mo We | Boeing 777-200 |
|
||||
| 18:20 | 09:20 | 2027-03-14 to 2027-03-27 | Su Tu Th Fr Sa | Boeing 777-200 |
|
||||
| 18:20 | 09:20 | 2027-03-15 to 2027-03-24 | Mo We | Boeing 777-200 |
|
||||
|
||||
---
|
||||
|
||||
## URL Patterns
|
||||
|
||||
| Page | URL Pattern |
|
||||
|------------------------|-----------------------------------------------------|
|
||||
| Homepage | `https://www.flightconnections.com/` |
|
||||
| Flights from airport | `/flights-from-dallas-fort-worth-dfw` |
|
||||
| Flights between cities | `/flights-from-dfw-to-mad` |
|
||||
|
||||
---
|
||||
|
||||
## Map Tiles
|
||||
|
||||
```
|
||||
GET https://cdn.flightconnections.com/maptiles/en/{z}/{x}/{y}.webp
|
||||
GET https://cdn.flightconnections.com/maptiles/en/{z}/{x}/{y}@2x.webp (retina)
|
||||
```
|
||||
|
||||
Custom map tiles showing airport locations and route lines. Zoom level 2 tiles observed in this capture.
|
||||
|
||||
---
|
||||
|
||||
## Third-Party Services (ads/analytics)
|
||||
|
||||
| Domain | Purpose |
|
||||
|-----------------------------------|------------------------|
|
||||
| `compare.flightconnections.com` | Ad serving (ClickTripz integration) |
|
||||
| `compare.flightconnections.com/x/pas` | Partner ad auction |
|
||||
| `compare.flightconnections.com/c11g` | Ad slot configuration |
|
||||
| `compare.flightconnections.com/b9s` | Event logging |
|
||||
|
||||
---
|
||||
|
||||
## Key Internal IDs
|
||||
|
||||
| Entity | ID |
|
||||
|------------------|--------|
|
||||
| DFW airport | `2138` |
|
||||
| MAD airport | `186` |
|
||||
| American Airlines| `300` |
|
||||
| Boeing 777-200 | `44` |
|
||||
| Boeing 787-9 | `59` |
|
||||
206
frida/okhttp_hook.js
Normal file
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
* Universal OkHttp Response Interceptor
|
||||
* Hooks OkHttp's response chain to capture ALL HTTP responses.
|
||||
* Works with Spirit, Delta, United (all use OkHttp/Retrofit).
|
||||
*
|
||||
* Sends captured data to a local server via Frida's send().
|
||||
*/
|
||||
|
||||
Java.perform(function() {
|
||||
console.log("[*] OkHttp Response Interceptor starting...");
|
||||
|
||||
// Filter: only capture responses from these domains
|
||||
var targetDomains = [
|
||||
"api.spirit.com",
|
||||
"www.delta.com",
|
||||
"mobileapi.united.com",
|
||||
"content.spirit.com",
|
||||
"content.delta.com"
|
||||
];
|
||||
|
||||
// Filter: only capture these API paths
|
||||
var targetPaths = [
|
||||
// Spirit
|
||||
"/customermobileprod/",
|
||||
"/v1/getboastatus",
|
||||
"/v1/getboaparameters",
|
||||
"/v2/Token",
|
||||
"/v3/mytrips",
|
||||
"/v1/booking",
|
||||
"/v3/GetFlightInfoBI",
|
||||
"/v5/Flight/Search",
|
||||
|
||||
// Delta
|
||||
"/api/mobile/asl",
|
||||
"/api/mobile/getFlightStatus",
|
||||
"/api/mobile/getFlightStatusByLeg",
|
||||
"/api/mobile/login",
|
||||
"/api/mobile/getDashboard",
|
||||
"/api/mobile/getUpgradeEligibilityInfo",
|
||||
|
||||
// United
|
||||
"/standbylistservice/",
|
||||
"/upgradelistservice/",
|
||||
"/flightstatusservice/",
|
||||
"/checkinservice/",
|
||||
"/passriderlistservice/"
|
||||
];
|
||||
|
||||
function shouldCapture(url) {
|
||||
var domainMatch = false;
|
||||
var pathMatch = false;
|
||||
|
||||
for (var i = 0; i < targetDomains.length; i++) {
|
||||
if (url.indexOf(targetDomains[i]) !== -1) {
|
||||
domainMatch = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!domainMatch) return false;
|
||||
|
||||
for (var j = 0; j < targetPaths.length; j++) {
|
||||
if (url.indexOf(targetPaths[j]) !== -1) {
|
||||
pathMatch = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return pathMatch;
|
||||
}
|
||||
|
||||
// === Hook OkHttp3 RealCall.getResponseWithInterceptorChain ===
|
||||
try {
|
||||
var OkHttpClient = Java.use("okhttp3.OkHttpClient");
|
||||
var Request = Java.use("okhttp3.Request");
|
||||
var Response = Java.use("okhttp3.Response");
|
||||
var ResponseBody = Java.use("okhttp3.ResponseBody");
|
||||
var BufferClass = Java.use("okio.Buffer");
|
||||
var MediaType = Java.use("okhttp3.MediaType");
|
||||
|
||||
// Hook the Interceptor.Chain.proceed to capture request+response
|
||||
var RealInterceptorChain = Java.use("okhttp3.internal.http.RealInterceptorChain");
|
||||
|
||||
RealInterceptorChain.proceed.overload("okhttp3.Request").implementation = function(request) {
|
||||
var url = request.url().toString();
|
||||
var response = this.proceed(request);
|
||||
|
||||
if (shouldCapture(url)) {
|
||||
try {
|
||||
var method = request.method();
|
||||
var reqBody = null;
|
||||
|
||||
// Capture request body
|
||||
if (request.body() !== null) {
|
||||
var reqBuffer = BufferClass.$new();
|
||||
request.body().writeTo(reqBuffer);
|
||||
reqBody = reqBuffer.readUtf8();
|
||||
}
|
||||
|
||||
// Capture request headers
|
||||
var reqHeaders = {};
|
||||
var headerNames = request.headers();
|
||||
for (var i = 0; i < headerNames.size(); i++) {
|
||||
reqHeaders[headerNames.name(i)] = headerNames.value(i);
|
||||
}
|
||||
|
||||
// Capture response
|
||||
var statusCode = response.code();
|
||||
var respBody = null;
|
||||
var respHeaders = {};
|
||||
|
||||
// Response headers
|
||||
var respHeaderObj = response.headers();
|
||||
for (var j = 0; j < respHeaderObj.size(); j++) {
|
||||
respHeaders[respHeaderObj.name(j)] = respHeaderObj.value(j);
|
||||
}
|
||||
|
||||
// Response body (need to peek without consuming)
|
||||
var body = response.body();
|
||||
if (body !== null) {
|
||||
var source = body.source();
|
||||
source.request(Long.MAX_VALUE);
|
||||
var buffer = source.getBuffer().clone();
|
||||
respBody = buffer.readUtf8();
|
||||
}
|
||||
|
||||
var captured = {
|
||||
type: "HTTP_RESPONSE",
|
||||
timestamp: new Date().toISOString(),
|
||||
method: method,
|
||||
url: url,
|
||||
status: statusCode,
|
||||
requestHeaders: reqHeaders,
|
||||
requestBody: reqBody,
|
||||
responseHeaders: respHeaders,
|
||||
responseBody: respBody
|
||||
};
|
||||
|
||||
// Send to Frida host
|
||||
send(captured);
|
||||
|
||||
console.log("[+] CAPTURED: " + method + " " + url + " -> " + statusCode + " (" + (respBody ? respBody.length : 0) + " chars)");
|
||||
|
||||
} catch(e) {
|
||||
console.log("[-] Capture error for " + url + ": " + e);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
console.log("[+] OkHttp RealInterceptorChain.proceed hooked");
|
||||
|
||||
} catch(e) {
|
||||
console.log("[-] OkHttp3 hook failed: " + e);
|
||||
console.log("[*] Trying alternative hook...");
|
||||
|
||||
// Alternative: Hook at a higher level
|
||||
try {
|
||||
var Interceptor = Java.use("okhttp3.Interceptor");
|
||||
// This approach hooks via adding our own interceptor
|
||||
console.log("[*] Alternative approach needed - see app-specific hooks");
|
||||
} catch(e2) {
|
||||
console.log("[-] Alternative also failed: " + e2);
|
||||
}
|
||||
}
|
||||
|
||||
// === Also hook Retrofit response callbacks ===
|
||||
try {
|
||||
var CallbackClass = Java.use("retrofit2.OkHttpCall");
|
||||
CallbackClass.parseResponse.implementation = function(rawResponse) {
|
||||
var response = this.parseResponse(rawResponse);
|
||||
try {
|
||||
var url = rawResponse.request().url().toString();
|
||||
if (shouldCapture(url)) {
|
||||
var body = response.body();
|
||||
if (body !== null) {
|
||||
console.log("[+] Retrofit response: " + url);
|
||||
console.log("[+] Body class: " + body.getClass().getName());
|
||||
console.log("[+] Body: " + body.toString().substring(0, Math.min(500, body.toString().length)));
|
||||
|
||||
send({
|
||||
type: "RETROFIT_RESPONSE",
|
||||
timestamp: new Date().toISOString(),
|
||||
url: url,
|
||||
bodyClass: body.getClass().getName(),
|
||||
body: body.toString()
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
return response;
|
||||
};
|
||||
console.log("[+] Retrofit parseResponse hooked");
|
||||
} catch(e) {
|
||||
console.log("[-] Retrofit hook failed: " + e);
|
||||
}
|
||||
|
||||
// === Java Long for buffer request ===
|
||||
var Long = Java.use("java.lang.Long");
|
||||
Long.MAX_VALUE.value;
|
||||
|
||||
console.log("[*] OkHttp Response Interceptor ready. Waiting for traffic...");
|
||||
});
|
||||
102
frida/run.sh
Executable file
@@ -0,0 +1,102 @@
|
||||
#!/bin/bash
|
||||
# Boot emulator, setup Frida, and run the capture server for airline apps.
|
||||
# Usage: ./run.sh [spirit|delta|united|all]
|
||||
|
||||
set -e
|
||||
|
||||
EMU=/Users/treyt/Library/Android/sdk/emulator/emulator
|
||||
ADB=/Users/treyt/Library/Android/sdk/platform-tools/adb
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
FRIDA_SERVER="/data/local/tmp/frida-server"
|
||||
|
||||
AIRLINE=${1:-spirit}
|
||||
|
||||
# Map airline to package name
|
||||
case "$AIRLINE" in
|
||||
spirit) PKG="com.spirit.customerapp" ;;
|
||||
delta) PKG="com.delta.mobile.android" ;;
|
||||
united) PKG="com.united.mobile.android" ;;
|
||||
all) PKG="all" ;;
|
||||
*) echo "Usage: $0 [spirit|delta|united|all]"; exit 1 ;;
|
||||
esac
|
||||
|
||||
echo "========================================="
|
||||
echo "Airline API Capture via Frida"
|
||||
echo "Target: $AIRLINE ($PKG)"
|
||||
echo "========================================="
|
||||
|
||||
# 1. Check if emulator is running
|
||||
if ! $ADB devices 2>/dev/null | grep -q "emulator"; then
|
||||
echo "[1/5] Booting emulator..."
|
||||
$EMU -avd Pixel_6_API_28 -writable-system -no-snapshot-load -no-audio -gpu swiftshader_indirect &
|
||||
sleep 30
|
||||
$ADB wait-for-device
|
||||
$ADB root
|
||||
sleep 3
|
||||
$ADB remount
|
||||
else
|
||||
echo "[1/5] Emulator already running"
|
||||
fi
|
||||
|
||||
# 2. Start Frida server
|
||||
echo "[2/5] Starting Frida server..."
|
||||
$ADB root 2>/dev/null
|
||||
sleep 1
|
||||
if ! $ADB shell "ps -A | grep frida-server" 2>/dev/null | grep -q frida; then
|
||||
if ! $ADB shell "test -f $FRIDA_SERVER" 2>/dev/null; then
|
||||
echo " Pushing frida-server..."
|
||||
$ADB push /tmp/frida-server $FRIDA_SERVER
|
||||
$ADB shell chmod 755 $FRIDA_SERVER
|
||||
fi
|
||||
$ADB shell "$FRIDA_SERVER -D &"
|
||||
sleep 3
|
||||
fi
|
||||
echo " Frida server running"
|
||||
|
||||
# 3. Verify Frida connection
|
||||
echo "[3/5] Verifying Frida..."
|
||||
frida-ps -U | head -3
|
||||
echo " Connected"
|
||||
|
||||
# 4. Install app if needed
|
||||
echo "[4/5] Checking app installation..."
|
||||
if [ "$PKG" = "all" ]; then
|
||||
for pkg in com.spirit.customerapp com.delta.mobile.android com.united.mobile.android; do
|
||||
if ! $ADB shell pm list packages | grep -q "$pkg"; then
|
||||
echo " $pkg not installed - install manually first"
|
||||
else
|
||||
echo " $pkg OK"
|
||||
fi
|
||||
done
|
||||
else
|
||||
if ! $ADB shell pm list packages | grep -q "$PKG"; then
|
||||
echo " Installing $PKG..."
|
||||
case "$AIRLINE" in
|
||||
spirit) $ADB install ~/Desktop/code/flights/apps/com.spirit.customerapp*.apk ;;
|
||||
delta) $ADB install-multiple /tmp/delta_apk/base.apk /tmp/delta_apk/split_config.arm64_v8a.apk /tmp/delta_apk/split_config.xxhdpi.apk ;;
|
||||
united) $ADB install ~/Desktop/code/flights/apps/united-airlines.apk ;;
|
||||
esac
|
||||
else
|
||||
echo " $PKG installed"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 5. Launch app and start capture
|
||||
echo "[5/5] Launching capture..."
|
||||
if [ "$PKG" = "all" ]; then
|
||||
echo "Run this script separately for each airline"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Launch the app
|
||||
$ADB shell monkey -p $PKG -c android.intent.category.LAUNCHER 1 2>/dev/null
|
||||
sleep 5
|
||||
|
||||
echo ""
|
||||
echo "========================================="
|
||||
echo "Starting Frida capture server..."
|
||||
echo "Interact with the $AIRLINE app to trigger API calls."
|
||||
echo "========================================="
|
||||
echo ""
|
||||
|
||||
python3 "$SCRIPT_DIR/server.py" "$PKG" "$SCRIPT_DIR/okhttp_hook.js"
|
||||
132
frida/server.py
Normal file
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Frida data receiver server.
|
||||
Receives captured HTTP responses from the Frida hook and saves them.
|
||||
Also provides a simple API to query captured data.
|
||||
"""
|
||||
|
||||
import json
|
||||
import frida
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
import threading
|
||||
|
||||
CAPTURE_DIR = Path(__file__).parent / "captures"
|
||||
CAPTURE_DIR.mkdir(exist_ok=True)
|
||||
|
||||
captured_data = []
|
||||
|
||||
|
||||
def on_message(message, data):
|
||||
"""Handle messages from Frida hook."""
|
||||
if message["type"] == "send":
|
||||
payload = message["payload"]
|
||||
captured_data.append(payload)
|
||||
|
||||
url = payload.get("url", "unknown")
|
||||
status = payload.get("status", "?")
|
||||
method = payload.get("method", "?")
|
||||
body_len = len(payload.get("responseBody", "") or "")
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"[CAPTURED] {method} {url}")
|
||||
print(f" Status: {status} | Response: {body_len} chars")
|
||||
|
||||
# Save to file
|
||||
ts = time.strftime("%Y%m%d_%H%M%S")
|
||||
domain = url.split("/")[2] if "/" in url else "unknown"
|
||||
filename = f"{ts}_{domain}_{method}_{status}.json"
|
||||
filepath = CAPTURE_DIR / filename
|
||||
|
||||
with open(filepath, "w") as f:
|
||||
json.dump(payload, f, indent=2)
|
||||
|
||||
print(f" Saved: {filepath.name}")
|
||||
|
||||
# Print response body preview
|
||||
resp_body = payload.get("responseBody", "")
|
||||
if resp_body:
|
||||
try:
|
||||
parsed = json.loads(resp_body)
|
||||
print(f" Response preview: {json.dumps(parsed, indent=2)[:500]}")
|
||||
except:
|
||||
print(f" Response preview: {resp_body[:300]}")
|
||||
|
||||
elif message["type"] == "error":
|
||||
print(f"[ERROR] {message['stack']}")
|
||||
|
||||
|
||||
def attach_to_app(package_name, script_path):
|
||||
"""Attach Frida to a running app and load the hook script."""
|
||||
device = frida.get_usb_device()
|
||||
print(f"[*] Connected to: {device.name}")
|
||||
|
||||
# Try to attach to running process first
|
||||
try:
|
||||
session = device.attach(package_name)
|
||||
print(f"[*] Attached to running process: {package_name}")
|
||||
except frida.ProcessNotFoundError:
|
||||
# Spawn it
|
||||
print(f"[*] Spawning: {package_name}")
|
||||
pid = device.spawn([package_name])
|
||||
session = device.attach(pid)
|
||||
device.resume(pid)
|
||||
print(f"[*] Spawned and attached: PID {pid}")
|
||||
|
||||
with open(script_path) as f:
|
||||
script_code = f.read()
|
||||
|
||||
script = session.create_script(script_code)
|
||||
script.on("message", on_message)
|
||||
script.load()
|
||||
|
||||
print(f"[*] Script loaded. Intercepting traffic for {package_name}...")
|
||||
return session
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python server.py <package_name> [hook_script.js]")
|
||||
print("")
|
||||
print("Examples:")
|
||||
print(" python server.py com.spirit.customerapp")
|
||||
print(" python server.py com.delta.mobile.android")
|
||||
print(" python server.py com.united.mobile.android")
|
||||
print("")
|
||||
print("Default hook script: okhttp_hook.js")
|
||||
sys.exit(1)
|
||||
|
||||
package_name = sys.argv[1]
|
||||
script_path = sys.argv[2] if len(sys.argv) > 2 else str(Path(__file__).parent / "okhttp_hook.js")
|
||||
|
||||
print(f"[*] Target: {package_name}")
|
||||
print(f"[*] Script: {script_path}")
|
||||
print(f"[*] Captures: {CAPTURE_DIR}")
|
||||
print("")
|
||||
|
||||
session = attach_to_app(package_name, script_path)
|
||||
|
||||
print("")
|
||||
print("[*] Ready. Interact with the app to trigger API calls.")
|
||||
print("[*] Press Ctrl+C to stop.")
|
||||
print("")
|
||||
|
||||
try:
|
||||
sys.stdin.read()
|
||||
except KeyboardInterrupt:
|
||||
print("\n[*] Stopping...")
|
||||
session.detach()
|
||||
|
||||
# Save all captured data
|
||||
if captured_data:
|
||||
summary_path = CAPTURE_DIR / f"all_captures_{time.strftime('%Y%m%d_%H%M%S')}.json"
|
||||
with open(summary_path, "w") as f:
|
||||
json.dump(captured_data, f, indent=2)
|
||||
print(f"[*] Saved {len(captured_data)} captures to {summary_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
101
frida/simple_hook.js
Normal file
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Simple URL connection logger.
|
||||
* Hooks at the lowest level to catch ALL HTTP traffic regardless of OkHttp version.
|
||||
*/
|
||||
Java.perform(function() {
|
||||
console.log("[*] Simple HTTP hook starting...");
|
||||
|
||||
// Hook HttpURLConnection
|
||||
try {
|
||||
var HttpURLConnection = Java.use("java.net.HttpURLConnection");
|
||||
HttpURLConnection.getInputStream.implementation = function() {
|
||||
var url = this.getURL().toString();
|
||||
var code = this.getResponseCode();
|
||||
console.log("[HTTP] " + code + " " + url);
|
||||
return this.getInputStream();
|
||||
};
|
||||
console.log("[+] HttpURLConnection.getInputStream hooked");
|
||||
} catch(e) {
|
||||
console.log("[-] HttpURLConnection hook failed: " + e);
|
||||
}
|
||||
|
||||
// Hook OkHttp Response - try multiple class paths
|
||||
var okHttpClasses = [
|
||||
"okhttp3.internal.http.RealInterceptorChain",
|
||||
"okhttp3.internal.connection.RealInterceptorChain",
|
||||
"okhttp3.RealCall"
|
||||
];
|
||||
|
||||
for (var i = 0; i < okHttpClasses.length; i++) {
|
||||
try {
|
||||
var cls = Java.use(okHttpClasses[i]);
|
||||
var methods = cls.class.getDeclaredMethods();
|
||||
console.log("[*] Found " + okHttpClasses[i] + " with " + methods.length + " methods:");
|
||||
for (var j = 0; j < methods.length; j++) {
|
||||
console.log(" " + methods[j].getName());
|
||||
}
|
||||
} catch(e) {
|
||||
console.log("[-] " + okHttpClasses[i] + ": not found");
|
||||
}
|
||||
}
|
||||
|
||||
// Hook OkHttp Response.body() to see what responses come back
|
||||
try {
|
||||
var Response = Java.use("okhttp3.Response");
|
||||
Response.body.implementation = function() {
|
||||
var body = this.body();
|
||||
try {
|
||||
var url = this.request().url().toString();
|
||||
var code = this.code();
|
||||
console.log("[OkHttp] " + code + " " + url);
|
||||
|
||||
if (body !== null && url.indexOf("spirit.com") !== -1 ||
|
||||
url.indexOf("delta.com") !== -1 || url.indexOf("united.com") !== -1) {
|
||||
|
||||
// Try to peek at the body
|
||||
try {
|
||||
var source = body.source();
|
||||
source.request(java_lang_Long.MAX_VALUE.value);
|
||||
var buffer = source.getBuffer().clone();
|
||||
var bodyStr = buffer.readUtf8();
|
||||
|
||||
if (bodyStr.length > 0) {
|
||||
console.log("[BODY] (" + bodyStr.length + " chars) " + bodyStr.substring(0, Math.min(2000, bodyStr.length)));
|
||||
|
||||
send({
|
||||
type: "RESPONSE",
|
||||
url: url,
|
||||
status: code,
|
||||
body: bodyStr
|
||||
});
|
||||
}
|
||||
} catch(be) {
|
||||
console.log("[BODY-ERR] " + be);
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
// ignore
|
||||
}
|
||||
return body;
|
||||
};
|
||||
console.log("[+] OkHttp Response.body() hooked");
|
||||
} catch(e) {
|
||||
console.log("[-] OkHttp Response hook failed: " + e);
|
||||
}
|
||||
|
||||
var java_lang_Long = Java.use("java.lang.Long");
|
||||
|
||||
// Also hook URL.openConnection for non-OkHttp traffic
|
||||
try {
|
||||
var URL = Java.use("java.net.URL");
|
||||
URL.openConnection.overload().implementation = function() {
|
||||
console.log("[URL] " + this.toString());
|
||||
return this.openConnection();
|
||||
};
|
||||
console.log("[+] URL.openConnection hooked");
|
||||
} catch(e) {
|
||||
console.log("[-] URL hook failed: " + e);
|
||||
}
|
||||
|
||||
console.log("[*] Simple HTTP hook ready.");
|
||||
});
|
||||