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>
This commit is contained in:
Trey t
2026-04-08 15:01:07 -05:00
commit 3790792040
46 changed files with 5116 additions and 0 deletions

44
.gitignore vendored Normal file
View 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/

View 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 */;
}

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "app-icon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 934 KiB

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

13
Flights/FlightsApp.swift Normal file
View 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)
}
}
}

View 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)")
}
}

View 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 = ""
}

View 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"
}

View 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"
}

View 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)
}
}

View 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)
}
}

View 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
}

View 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
}
}

View 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)"
}
}
}

View 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
}
}
}

View 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
}
}
}

View 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
}
}

View 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
}
}
}
}

View 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
}
}
}
}

View 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
}
}
}
}

View 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)
}

View 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))
}
}
}

View 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
}
}

View 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()
}
}

View 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
}
}

View 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

File diff suppressed because one or more lines are too long

155
api_docs/SUMMARY.md Normal file
View 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
View 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
View 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
View 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

View 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)")

Binary file not shown.

After

Width:  |  Height:  |  Size: 929 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 920 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 613 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 934 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 674 KiB

View 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
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

230
flow.md Normal file
View 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
View 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
View 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
View 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
View 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.");
});