Land local WIP on top of JSX rewrite + wire JSXWebViewFetcher into target

Resolves the working tree that was sitting uncommitted on this machine
when the JSX rewrite (77c59ce, c9992e2) landed on the gitea remote.

- Adds favorites flow (FavoriteRoute model, FavoritesManager service,
  ContentView favorites strip with context-menu remove).
- Adds FlightLoad model + FlightLoadDetailView sheet rendering cabin
  capacity, upgrade list, standby list, and seat-availability summary.
- Adds WebViewFetcher (the generic WKWebView helper used by the load
  service for non-JSX flows).
- Adds RouteMapView for destination map mode and threads it into
  DestinationsListView with a list/map toggle.
- Adds AIRLINE_API_SPEC.md capturing the cross-airline load API surface.
- Wires JSXWebViewFetcher.swift into the Flights target in
  project.pbxproj (file was added to the repo by the JSX rewrite commit
  but never registered with the Xcode target, so the build was broken
  on a fresh checkout).
- Misc Airport/AirportDatabase/FlightsApp/FlightScheduleRow/
  RouteDetailView tweaks that the rest of this WIP depends on.

Build verified clean against the iOS Simulator destination.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-11 11:55:15 -05:00
parent c9992e2d11
commit 847000d059
15 changed files with 1939 additions and 51 deletions

792
AIRLINE_API_SPEC.md Normal file
View File

@@ -0,0 +1,792 @@
# Airline Flight Load API Specification
Integration-ready API reference for querying flight loads, standby lists, and seat availability across 7 airlines. All endpoints confirmed working as of April 2026.
---
## Architecture Overview
All airlines except Spirit require **Playwright** (headless browser) to bypass Akamai TLS fingerprinting. The pattern is:
1. Navigate to the airline's domain to establish a browser session
2. Use `page.evaluate(async () => { const r = await fetch(...); return r.json(); })` to call APIs from within the browser context
Spirit works with plain HTTP requests (curl/fetch).
---
## UNITED AIRLINES
### Base Setup
```
Domain: https://www.united.com
Auth: Anonymous token (no login required)
Method: Playwright → navigate to united.com → page.evaluate(fetch)
```
### Step 1: Get Token
```
GET /api/auth/anonymous-token
Headers: none required
```
**Response:**
```json
{
"data": {
"token": {
"hash": "DAAAA...",
"expiresAt": "2026-04-08T19:42:57Z"
}
}
}
```
Token is valid ~30 minutes. Refresh as needed.
### Step 2: Search Flights by Route
```
GET /api/flightstatus/status/{flightNumber}/{date}?carrierCode=UA&useLegDestDate=true
Path params:
flightNumber: "2238"
date: "2026-04-08"
Headers:
x-authorization-api: bearer {token.hash}
Accept: application/json
```
**Response:** Flight status with departure/arrival times, gates, terminals, aircraft type, tail number, delays.
### Step 3: Get Flight Loads + Standby List
```
GET /api/flightstatus/upgradeListExtended?flightNumber={num}&flightDate={YYYY-MM-DD}&fromAirportCode={origin}
Headers:
x-authorization-api: bearer {token.hash}
Accept: application/json
```
**Response:**
```json
{
"segment": {
"airlineCode": "UA",
"flightNumber": 2238,
"flightDate": "20260408",
"departureAirportCode": "EWR",
"arrivalAirportCode": "LAX",
"equipmentDescriptionLong": "Boeing 777-200ER",
"departed": false
},
"pbts": [
{
"cabin": "Front",
"capacity": 50,
"authorized": 50,
"booked": 50,
"held": 0,
"reserved": 0,
"revenueStandby": 0,
"waitList": 0,
"jump": 0,
"group": 0,
"ps": 1,
"sa": 5
},
{
"cabin": "Middle",
"capacity": 24,
"authorized": 24,
"booked": 16,
"held": 0,
"reserved": 0,
"revenueStandby": 0,
"waitList": 0,
"jump": 0,
"group": 0,
"ps": 0,
"sa": 0
},
{
"cabin": "Rear",
"capacity": 202,
"authorized": 202,
"booked": 164,
"held": 0,
"reserved": 0,
"revenueStandby": 2,
"waitList": 0,
"jump": 0,
"group": 0,
"ps": 0,
"sa": 4
}
],
"checkInSummaries": [
{
"cabin": "Front",
"capacity": 50,
"total": 50,
"etktPassengersCheckedIn": 50,
"revStandbyCheckedInWithoutSeats": 0,
"nonRevStandbyCheckedInWithoutSeats": 0,
"children": 0,
"infants": 0,
"bags": 0
}
],
"numberOfCabins": 3,
"front": {
"cleared": [
{
"currentCabin": "Front",
"bookedCabin": "Rear",
"firstName": "T",
"lastName": "JEN",
"passengerName": "T/JEN",
"seatNumber": "1G",
"clearanceType": "Upgrade",
"skipped": false
}
],
"standby": []
}
}
```
### Field Reference
| Field | Description |
|---|---|
| `pbts[].cabin` | "Front" (First/Polaris), "Middle" (Premium+), "Rear" (Economy) |
| `pbts[].capacity` | Total seats in cabin |
| `pbts[].booked` | Seats sold/assigned |
| `pbts[].revenueStandby` | Revenue standby passengers |
| `pbts[].sa` | Space available (non-rev standby) |
| `pbts[].ps` | Positive space |
| `pbts[].waitList` | Waitlisted passengers |
| `front.cleared[]` | Passengers cleared for upgrade |
| `front.standby[]` | Passengers on standby |
### Derived Values
```
availableSeats = capacity - booked
loadFactor = booked / capacity
```
---
## AMERICAN AIRLINES
### Base Setup
```
Domain: https://cdn.flyaa.aa.com
Auth: None required
Method: Playwright → set mobile UA headers → navigate to cdn.flyaa.aa.com → page.evaluate(fetch)
Required context headers (set via page.context().setExtraHTTPHeaders):
User-Agent: Android/2025.31 Pixel 7|14|1080|2400|1.0|AmericanAirlines
x-clientid: MOBILE
Accept: application/json
Content-Type: application/json
Device-ID: {any-uuid}
```
### Step 1: Search Flights by Route
```
GET /apiv2/mobile-flifo/flightSchedules/v1.0?origin={ORIG}&destination={DEST}&departureDay={D}&departureMonth={M}&searchType=schedule&noOfFlightsToDisplay=20
```
**Response:**
```json
{
"flightSchedules": {
"flights": [
[{
"flightKey": "AA:3390:2026-04-09:DFW:0",
"operatingCarrierCode": "AA",
"operatingCarrierName": "AMERICAN EAGLE",
"marketingCarrierCode": "AA",
"flightNumber": "3390",
"originAirportCode": "DFW",
"originCity": "Dallas/ Fort Worth",
"destinationAirportCode": "IAH",
"destinationCity": "Houston",
"departDate": "2026-04-09T07:01:00.000-05:00",
"arrivalDate": "2026-04-09T08:20:00.000-05:00",
"showUpgradeStandbyList": false,
"allowFSN": false
}]
]
}
}
```
### Step 2: Get Waitlist + Available Seats
```
GET /api/mobile/loyalty/waitlist/v1.2?carrierCode=AA&flightNumber={NUM}&departureDate={YYYY-MM-DD}&originAirportCode={ORIG}&destinationAirportCode={DEST}
Additional headers:
x-referrer: fs
```
**Response:**
```json
{
"relevantList": "First",
"footer": [
"If your upgrade has cleared or you clear the waitlist, please refresh your mobile boarding pass.",
"The order of names may change as additional customers check in."
],
"waitList": [
{
"listName": "First",
"seatsAvailableLabel": "Available seats",
"seatsAvailableValue": 1,
"seatsAvailableSemanticColor": "failure",
"passengers": [
{"order": 1, "displayName": "BRI, K", "cleared": false, "seat": null, "highlighted": false},
{"order": 2, "displayName": "MAT, R", "cleared": false, "seat": null, "highlighted": false}
]
},
{
"listName": "Standby",
"seatsAvailableLabel": "Available seats",
"seatsAvailableValue": 45,
"seatsAvailableSemanticColor": "success",
"passengers": [
{"order": 1, "displayName": "MIT, R", "cleared": false, "seat": null, "highlighted": false},
{"order": 2, "displayName": "MAR, M", "cleared": false, "seat": null, "highlighted": false}
]
}
]
}
```
### Field Reference
| Field | Description |
|---|---|
| `waitList[].listName` | "First", "Standby", etc. |
| `waitList[].seatsAvailableValue` | Number of open seats for that class |
| `waitList[].seatsAvailableSemanticColor` | "success" (green, many), "warning" (yellow, few), "failure" (red, <=1) |
| `waitList[].passengers[].displayName` | Passenger name (LAST, F) |
| `waitList[].passengers[].order` | Position on list (1-based) |
| `waitList[].passengers[].cleared` | true if cleared from list |
| `waitList[].passengers[].seat` | Seat number if cleared |
---
## SPIRIT AIRLINES
### Base Setup
```
Domain: https://api.spirit.com
Auth: APIM subscription key (no login required)
Method: Plain HTTP (curl/fetch) — no Playwright needed
```
### Step 1: Get Flight Status
```
POST https://api.spirit.com/customermobileprod/2.8.0/v3/GetFlightInfoBI
Headers:
Content-Type: application/json
Ocp-Apim-Subscription-Key: c6567af50d544dfbb3bc5dd99c6bb177
Platform: Android
Body:
{
"departureStation": "FLL",
"arrivalStation": "ATL",
"departureDate": "2026-04-08"
}
```
**Response:**
```json
{
"getFlightInfoBIResult": [
{
"flightNumber": "NK204",
"journeyID": 1,
"departureStationCode": "FLL",
"arrivalStationCode": "ATL",
"departureGate": "F4",
"arrivalGate": "C4",
"departureTerminal": "4",
"arrivalTerminal": "N",
"legStatus": "Arrived",
"departureTime": "8:57am",
"arrivalTime": "11:04am",
"scheduledDeparture": "scheduled at 8:24am",
"scheduledArrival": "scheduled at 10:22am",
"departureCity": "Fort Lauderdale, FL",
"arrivalCity": "Atlanta, GA",
"flightStatusColor": "#FF9500",
"totalDurationMinutes": 127,
"departureDateTime": "2026-04-08T08:57:00",
"arrivalDateTime": "2026-04-08T11:04:00"
}
]
}
```
### Step 2: Get Station/Route Network
```
GET https://api.spirit.com/customermobileprod/2.8.0/v1/stations
Headers:
Ocp-Apim-Subscription-Key: c6567af50d544dfbb3bc5dd99c6bb177
```
Returns all Spirit stations with airport codes, coordinates, city names, and which routes connect to each station.
### Notes
- Spirit is a ULCC — no standby lists or upgrade waitlists
- Seat-level availability requires a booking session + Akamai bypass (not publicly accessible)
- The APIM key was decrypted from the native library in the Android APK
---
## KOREAN AIR
### Base Setup
```
Domain: https://www.koreanair.com
Auth: None required for flight status; minimal headers
Method: Playwright → navigate to koreanair.com → page.evaluate(fetch)
```
### Step 1: Search Flights
```
POST /api/fs/scheduleFlightSearch/flight/status/app
Headers:
Content-Type: application/json
Accept: application/json
channel: app
Body (by flight number):
{
"departureDate": "20260408",
"departureLocationCode": "",
"arrivalLocationCode": "",
"flightNumber": "017",
"searchOption": "FLTNUM"
}
Body (by route):
{
"departureDate": "20260408",
"departureLocationCode": "ICN",
"arrivalLocationCode": "LAX",
"flightNumber": "",
"searchOption": "ROUTE"
}
```
**Response:**
```json
{
"scheduleInformation": {
"flightInformation": {
"flightDetailsList": [
{
"departureAirport": "ICN",
"arrivalAirport": "LAX",
"departureDate": "20260408",
"departureTime": "1430",
"arrivalTime": "0940",
"flightNumber": "017",
"flyingTime": "1110",
"status": {"code": "ARV", "codeByUI": "ARV"},
"cabinClassInfoList": [
{"cabinClassOfService": "1"},
{"cabinClassOfService": "2"},
{"cabinClassOfService": "3"}
],
"scheduleFlightUIInfoMsOutVo": {
"flightStatus": "arrived",
"departureUIInfo": {
"scheduledTime": "14:30",
"actualTime": "14:27"
},
"arrivalUIInfo": {
"scheduledTime": "09:40",
"actualTime": "09:47"
}
}
}
]
}
}
}
```
### Step 2: Get Seat Count
```
POST /api/et/ibeSupport/flightSeatCount
Headers:
Content-Type: application/json
Accept: application/json
channel: pc
Body:
{
"carrierCode": "KE",
"flightNumber": "017",
"departureAirport": "ICN",
"arrivalAirport": "LAX",
"departureDate": "20260409"
}
```
**Response:**
```json
{
"seatCount": 0,
"carrierCode": "KE",
"flightNumber": "017"
}
```
Note: `seatCount` returns available seats. Returns 0 for dates too far out — works best within 24-48 hours of departure.
### Step 3: Get Availability
```
POST /api/fs/scheduleFlightSearch/sdcAirMultiAvailability
Headers:
Content-Type: application/json
Accept: application/json
channel: pc
Body:
{
"departureDate": "20260409",
"departureLocationCode": "ICN",
"arrivalLocationCode": "LAX",
"flightNumber": "001",
"searchOption": "FLTNUM"
}
```
---
## Playwright Integration Pattern
All airlines except Spirit use the same Playwright wrapper pattern:
```javascript
const { chromium } = require('playwright');
async function queryAirline(airlineDomain, setupHeaders, apiCall) {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
if (setupHeaders) {
await context.setExtraHTTPHeaders(setupHeaders);
}
const page = await context.newPage();
await page.goto(airlineDomain);
await page.waitForTimeout(5000);
const result = await page.evaluate(apiCall);
await browser.close();
return result;
}
// UNITED example:
const unitedLoads = await queryAirline(
'https://www.united.com/en/us/flightstatus',
null,
async () => {
const tokenResp = await fetch('/api/auth/anonymous-token');
const { data } = await tokenResp.json();
const token = data.token.hash;
const resp = await fetch(
'/api/flightstatus/upgradeListExtended?flightNumber=2238&flightDate=2026-04-08&fromAirportCode=EWR',
{ headers: { 'Accept': 'application/json', 'x-authorization-api': 'bearer ' + token } }
);
return resp.json();
}
);
// AMERICAN example:
const aaLoads = await queryAirline(
'https://cdn.flyaa.aa.com/apiv2/mobile-flifo/flightSchedules/v1.0?origin=DFW&destination=IAH&departureDay=9&departureMonth=4&searchType=schedule&noOfFlightsToDisplay=20',
{
'User-Agent': 'Android/2025.31 Pixel 7|14|1080|2400|1.0|AmericanAirlines',
'x-clientid': 'MOBILE',
'Accept': 'application/json',
'Device-ID': 'device-001'
},
async () => {
const resp = await fetch(
'https://cdn.flyaa.aa.com/api/mobile/loyalty/waitlist/v1.2?carrierCode=AA&flightNumber=2209&departureDate=2026-04-08&originAirportCode=DFW&destinationAirportCode=IAH',
{ headers: { 'Accept': 'application/json', 'x-referrer': 'fs' } }
);
return resp.json();
}
);
// KOREAN AIR example:
const keStatus = await queryAirline(
'https://www.koreanair.com',
null,
async () => {
const resp = await fetch('/api/fs/scheduleFlightSearch/flight/status/app', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'channel': 'app' },
body: JSON.stringify({
departureDate: '20260408',
flightNumber: '017',
searchOption: 'FLTNUM',
departureLocationCode: '',
arrivalLocationCode: ''
})
});
return resp.json();
}
);
```
### Spirit (plain HTTP, no Playwright):
```javascript
const spiritStatus = await fetch('https://api.spirit.com/customermobileprod/2.8.0/v3/GetFlightInfoBI', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Ocp-Apim-Subscription-Key': 'c6567af50d544dfbb3bc5dd99c6bb177',
'Platform': 'Android'
},
body: JSON.stringify({
departureStation: 'FLL',
arrivalStation: 'ATL',
departureDate: '2026-04-08'
})
}).then(r => r.json());
```
---
## EMIRATES
### Base Setup
```
Domain: https://www.emirates.com
Auth: None required for flight status
Method: Plain HTTP (curl/fetch) — no Playwright needed
```
### Step 1: Flight Status
```
GET https://www.emirates.com/service/flight-status?departureDate={YYYY-MM-DD}&flight={flightNumber}
Headers: none required
```
**Response:**
```json
{
"results": [{
"airlineDesignator": "EK",
"flightNumber": "0221",
"flightId": "2026040700221DXB",
"flightDate": "2026-04-07",
"flightRoute": [{
"legNumber": "1",
"originActualAirportCode": "DXB",
"destinationActualAirportCode": "DFW",
"originPlannedAirportCode": "DXB",
"destinationPlannedAirportCode": "DFW",
"statusCode": "ARVD",
"flightPosition": 100,
"totalTravelDuration": "17:30",
"isIrregular": "false",
"departureTime": {
"schedule": "2026-04-08T01:10:00Z",
"estimated": "2026-04-08T01:15:00Z",
"actual": "2026-04-08T01:12:00Z"
},
"arrivalTime": {
"schedule": "2026-04-08T09:40:00Z",
"estimated": "2026-04-08T09:34:00Z",
"actual": "2026-04-08T09:32:00Z"
},
"departureTerminal": "Terminal 3",
"arrivalTerminal": "Terminal D",
"flightOutageType": 0
}]
}],
"metaLinks": []
}
```
### Step 2: Flight Load / Staff Standby (requires PNR)
```
Mobile API base: https://mobileapp.emirates.com/
GET /olci/v1/checkin/staffinformation/{pnr}/{lastName}
```
Returns `FlightLoadResponse` with:
- `isStaffSubLoadTableAvl` - whether subload table is available
- `staffPax.passengers[]` - staff passenger list with check-in status
- `flights[]` - per-flight load data
- Per passenger: `currentPriority`, `totalPriority`, `status`, `flightNumber`
### Notes
- Flight status works from **plain curl** with zero auth — simplest of all airlines
- Staff standby/flight load data requires PNR + last name (mobile app only)
- The app has a full staff travel system: standby priority tracking, class downgrade acceptance, subload questionnaires
- Internal backend leaked in response: `business-services-cache-bex-prod.dub.prd01.digitalattract.aws.emirates.prd`
---
## ALASKA AIRLINES
### Base Setup
```
Domain: https://www.alaskaair.com
Mobile API: /1/guestservices/customermobile/
Auth: Requires booking confirmation code for load data
Method: Playwright (website uses shadow DOM web components)
```
### Step 1: Flight Status
```
GET /1/guestservices/customermobile/flights/status/{airlineCode}/{flightNumber}/{departureDate}
Example: /1/guestservices/customermobile/flights/status/AS/1084/2026-04-08
```
### Step 2: Flight Status v2 (by route)
```
POST /1/guestservices/customermobile/mobileservices/reservation/flights/status
Body:
{
"airlineCode": "AS",
"flightNumber": "1084",
"departureDate": "2026-04-08",
"departureCityCode": "SEA",
"arrivalCityCode": "LAX"
}
```
Response includes `showPriorityList` boolean indicating if standby list is available for that flight.
### Step 3: Seat Availability / Remaining Seats (requires confirmation code)
```
POST /1/guestservices/customermobile/seats/SeatUpgradesByCabinRec/{confirmationCode}
Body:
{
"adobeMarketingCloudVisitorID": "{visitor_id}"
}
```
**Response:**
```json
[
{
"flightNumber": 1084,
"origin": "SEA",
"destination": "LAX",
"cabinType": "First",
"remainingSeats": 4,
"upgradePrice": 149.00,
"equipment": "Boeing 737-900ER"
}
]
```
### Notes
- Flight status is public, but `remainingSeats` data requires a valid confirmation code
- Mobile API paths start with `/1/guestservices/customermobile/`
- Uses Ktor HTTP client (modern Kotlin)
- Website uses deep shadow DOM -- Playwright automation is complex
---
## JETBLUE
### Base Setup
```
API Domain: https://az-api.jetblue.com
Auth: API key (no login required)
Method: Plain HTTP (curl/fetch) — no Playwright needed
API Key: 49fc015f1ba44abf892d2b8961612378
```
### Step 1: Flight Status by Number
```
GET https://az-api.jetblue.com/flight-status/get-by-number?number={flightNumber}&date={YYYY-MM-DD}
Headers:
apikey: 49fc015f1ba44abf892d2b8961612378
Accept: application/json
```
**Response:**
```json
{
"flights": [{
"tripOrigin": "LAX",
"tripDestination": "JFK",
"isConnecting": false,
"isThroughFlight": false,
"legs": [{
"flightNo": "524",
"flightStatus": "IN FLIGHT",
"flightStatusGroup": "standardPostDeparture",
"originAirport": "LAX",
"originGate": "16",
"originTerminal": "1",
"actualDeparture": "2026-04-08T13:19:00-07:00",
"scheduledDeparture": "2026-04-08T13:27:00-07:00",
"doorCloseTime": "2026-04-08T13:12:00-07:00",
"boardingTime": "2026-04-08T12:42:00-07:00",
"destinationAirport": "JFK",
"destinationGate": "518",
"destinationTerminal": "5",
"actualArrival": "2026-04-08T21:37:00-04:00",
"scheduledArrival": "2026-04-08T21:55:00-04:00",
"baggageClaim": "4",
"equipmentType": "3NL",
"tailNumber": "4074"
}]
}]
}
```
### Step 2: Priority List (Standby/Upgrade - requires check-in session)
The app has `retrievePriorityList` which returns `PriorityListPassenger`:
```json
{
"shortLastName": "DOE",
"shortFirstName": "J",
"code": "SA",
"order": 1,
"hasSeat": false
}
```
This requires an active check-in session (Cookie header). Accessible during check-in flow only.
### Step 3: Crystal Blue Seat Map
```
POST https://az-api.jetblue.com/mobile_seatmap
Headers:
Ocp-Apim-Subscription-Key: a5ee654e981b4577a58264fed9b1669c
Content-Type: application/json
```
### Notes
- Flight status works from **plain curl** with just the API key
- Priority list requires check-in session
- Second APIM key `a5ee654e981b4577a58264fed9b1669c` used for seat map and logging
---
## FRONTIER AIRLINES
APK not available for download from any third-party source. Frontier is a ULCC like Spirit -- minimal standby/upgrade features expected. Not yet analyzed.
---
## Rate Limits & Best Practices
- **United**: Token expires in ~30min. Cache and refresh. No known rate limit.
- **American**: No token needed. Akamai may throttle if too many requests from same browser session. Rotate browser contexts.
- **Spirit**: APIM key is shared across all app users. No known rate limit but don't abuse.
- **Korean Air**: No auth needed for status endpoints. `channel` header is required.
- **All Playwright airlines**: Reuse browser context across multiple queries to avoid re-establishing sessions. Close and recreate if you get 403s.

View File

@@ -31,6 +31,14 @@
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 */; };
BB1100001111000011110001 /* FlightLoad.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB1100001111000011110002 /* FlightLoad.swift */; };
BB1100001111000011110003 /* AirlineLoadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB1100001111000011110004 /* AirlineLoadService.swift */; };
BB1100001111000011110005 /* FlightLoadDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB1100001111000011110006 /* FlightLoadDetailView.swift */; };
D3AFA3F4A9AF4CA4BD2BA5BE /* FavoriteRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59848F598CC941C393B23604 /* FavoriteRoute.swift */; };
AB9AB52419104B73A81B81A8 /* FavoritesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44E75295BB044D13AD26563D /* FavoritesManager.swift */; };
E62A922EC7924273BDF14005 /* RouteMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2574CDD727284621BBB56145 /* RouteMapView.swift */; };
9124DA69A89F4E90A35DD13C /* WebViewFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22867394CDCC423891007AE1 /* WebViewFetcher.swift */; };
BB2200002222000022220001 /* JSXWebViewFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB2200002222000022220002 /* JSXWebViewFetcher.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@@ -59,6 +67,14 @@
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>"; };
BB1100001111000011110002 /* FlightLoad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlightLoad.swift; sourceTree = "<group>"; };
BB1100001111000011110004 /* AirlineLoadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirlineLoadService.swift; sourceTree = "<group>"; };
BB1100001111000011110006 /* FlightLoadDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlightLoadDetailView.swift; sourceTree = "<group>"; };
59848F598CC941C393B23604 /* FavoriteRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteRoute.swift; sourceTree = "<group>"; };
44E75295BB044D13AD26563D /* FavoritesManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesManager.swift; sourceTree = "<group>"; };
2574CDD727284621BBB56145 /* RouteMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteMapView.swift; sourceTree = "<group>"; };
22867394CDCC423891007AE1 /* WebViewFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewFetcher.swift; sourceTree = "<group>"; };
BB2200002222000022220002 /* JSXWebViewFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSXWebViewFetcher.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -79,9 +95,11 @@
0CD303E3EDCC4BF2BCF31722 /* AirportSearchField.swift */,
300153508F8445B6A78CEC52 /* DestinationsListView.swift */,
1C1176F877BF496ABF079040 /* RouteDetailView.swift */,
2574CDD727284621BBB56145 /* RouteMapView.swift */,
F9640481120A418EBCD3CE73 /* FlightScheduleRow.swift */,
7C2EB471F011450DA7BBEFAD /* AirportMapView.swift */,
15676B4BD35745D1BD1DC947 /* AirportBrowserSheet.swift */,
BB1100001111000011110006 /* FlightLoadDetailView.swift */,
AA5555555555555555555555 /* Styles */,
AA6666666666666666666666 /* Components */,
);
@@ -140,7 +158,11 @@
isa = PBXGroup;
children = (
A65682BD902141BAA686D101 /* FlightService.swift */,
22867394CDCC423891007AE1 /* WebViewFetcher.swift */,
44E75295BB044D13AD26563D /* FavoritesManager.swift */,
9A58C339D6084657B0538E9C /* AirportDatabase.swift */,
BB1100001111000011110004 /* AirlineLoadService.swift */,
BB2200002222000022220002 /* JSXWebViewFetcher.swift */,
);
path = Services;
sourceTree = "<group>";
@@ -160,9 +182,11 @@
04AC23D8748D42C9A7115FAC /* Airline.swift */,
0EFE025789164A779FC980B0 /* Route.swift */,
B913D04A4E51436595308A21 /* FlightSchedule.swift */,
59848F598CC941C393B23604 /* FavoriteRoute.swift */,
E1AC05BFDFDE4A94B360EB05 /* MapAirport.swift */,
4944338B20BA4AB98F05D4F7 /* BrowseAirport.swift */,
E7987BD4832D44F1A0851933 /* Country.swift */,
BB1100001111000011110002 /* FlightLoad.swift */,
);
path = Models;
sourceTree = "<group>";
@@ -254,6 +278,14 @@
FD853F72EE724922B0E4E235 /* AirportMapView.swift in Sources */,
AA1111111111111111111111 /* FlightTheme.swift in Sources */,
AA3333333333333333333333 /* RouteVisualization.swift in Sources */,
BB1100001111000011110001 /* FlightLoad.swift in Sources */,
BB1100001111000011110003 /* AirlineLoadService.swift in Sources */,
BB1100001111000011110005 /* FlightLoadDetailView.swift in Sources */,
D3AFA3F4A9AF4CA4BD2BA5BE /* FavoriteRoute.swift in Sources */,
AB9AB52419104B73A81B81A8 /* FavoritesManager.swift in Sources */,
E62A922EC7924273BDF14005 /* RouteMapView.swift in Sources */,
9124DA69A89F4E90A35DD13C /* WebViewFetcher.swift in Sources */,
BB2200002222000022220001 /* JSXWebViewFetcher.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@@ -4,10 +4,11 @@ import SwiftUI
struct FlightsApp: App {
let service = FlightService()
let database = AirportDatabase()
let favoritesManager = FavoritesManager()
var body: some Scene {
WindowGroup {
ContentView(service: service, database: database)
ContentView(service: service, database: database, favoritesManager: favoritesManager)
}
}
}

View File

@@ -1,6 +1,6 @@
import Foundation
struct Airport: Identifiable, Hashable, Sendable {
struct Airport: Identifiable, Hashable, Sendable, Codable {
let id: String
let iata: String
let name: String

View File

@@ -0,0 +1,12 @@
import Foundation
struct FavoriteRoute: Codable, Identifiable, Hashable, Sendable {
var id: String { "\(departure.iata)-\(arrival.iata)" }
let departure: Airport
let arrival: Airport
let addedDate: Date
enum CodingKeys: String, CodingKey {
case departure, arrival, addedDate
}
}

View File

@@ -0,0 +1,82 @@
import Foundation
/// Flight load data from airline APIs
struct FlightLoad: Sendable {
let airlineCode: String // "UA", "AA", "KE", "NK"
let flightNumber: String // "UA2238"
let cabins: [CabinLoad] // Full cabin data (United)
let standbyList: [StandbyPassenger]
let upgradeList: [StandbyPassenger]
/// For airlines that only report available seat counts without capacity (AA)
/// Keys: "First Class Upgrades", "Economy Standby", etc.
let seatAvailability: [SeatAvailability]
/// Total available seats across all cabins
var totalAvailable: Int { cabins.reduce(0) { $0 + $1.available } }
/// Total capacity across all cabins
var totalCapacity: Int { cabins.reduce(0) { $0 + $1.capacity } }
/// Total standby count from pbts data (sa + revenueStandby + waitList per cabin)
var totalStandbyFromPBTS: Int {
cabins.reduce(0) { $0 + $1.revenueStandby + $1.nonRevStandby + $1.waitListCount }
}
/// Whether this load has full cabin capacity data (vs just availability counts)
var hasCabinData: Bool { !cabins.isEmpty }
}
/// Simple available seat count for airlines that don't provide full cabin data
struct SeatAvailability: Identifiable, Sendable {
let id = UUID()
let label: String // "First Class Upgrades", "Economy Standby"
let available: Int
let color: SeatAvailabilityColor // success/warning/failure from API
}
enum SeatAvailabilityColor: String, Sendable {
case success, warning, failure
}
/// Cabin-level seat data
struct CabinLoad: Identifiable, Sendable {
let id = UUID()
let name: String // "Front", "Middle", "Rear", "First", "Standby"
let capacity: Int
let booked: Int
let revenueStandby: Int // pbts.revenueStandby
let nonRevStandby: Int // pbts.sa (space available)
var waitListCount: Int = 0 // pbts.waitList
var jumpSeat: Int = 0 // pbts.jump
var available: Int { max(0, capacity - booked) }
var loadFactor: Double {
guard capacity > 0 else { return 0 }
return Double(booked) / Double(capacity)
}
/// Color name for the load factor
var loadColor: LoadColor {
switch loadFactor {
case ..<0.7: return .green
case ..<0.9: return .yellow
default: return .red
}
}
}
enum LoadColor {
case green, yellow, red
}
/// A passenger on a standby or upgrade list
struct StandbyPassenger: Identifiable, Sendable {
let id = UUID()
let order: Int // 1-based position
let displayName: String // "BRI, K"
let cleared: Bool
let seat: String? // seat number if cleared
let listName: String // "First", "Standby", "Front", etc.
}

View File

@@ -74,6 +74,11 @@ final class AirportDatabase: Sendable {
return (regionName: name, airports: results)
}
/// Look up a single airport by IATA code
func airport(byIATA code: String) -> MapAirport? {
airports.first { $0.iata == code }
}
private static func buildRegionNames() -> [String: String] {
// US states + territories
var names: [String: String] = [

View File

@@ -0,0 +1,50 @@
import Foundation
import Observation
@Observable
@MainActor
final class FavoritesManager {
private static let storageKey = "savedFavoriteRoutes"
var favorites: [FavoriteRoute] = []
init() {
load()
}
func add(departure: Airport, arrival: Airport) {
guard !isFavorite(departure: departure, arrival: arrival) else { return }
let route = FavoriteRoute(departure: departure, arrival: arrival, addedDate: Date())
favorites.append(route)
save()
}
func remove(_ route: FavoriteRoute) {
favorites.removeAll { $0.id == route.id }
save()
}
func isFavorite(departure: Airport, arrival: Airport) -> Bool {
favorites.contains { $0.departure.iata == departure.iata && $0.arrival.iata == arrival.iata }
}
func toggle(departure: Airport, arrival: Airport) {
if isFavorite(departure: departure, arrival: arrival) {
favorites.removeAll { $0.departure.iata == departure.iata && $0.arrival.iata == arrival.iata }
} else {
favorites.append(FavoriteRoute(departure: departure, arrival: arrival, addedDate: Date()))
}
save()
}
private func save() {
guard let data = try? JSONEncoder().encode(favorites) else { return }
UserDefaults.standard.set(data, forKey: Self.storageKey)
}
private func load() {
guard let data = UserDefaults.standard.data(forKey: Self.storageKey),
let decoded = try? JSONDecoder().decode([FavoriteRoute].self, from: data) else { return }
favorites = decoded
}
}

View File

@@ -0,0 +1,149 @@
import Foundation
import WebKit
/// Uses a hidden WKWebView to execute fetch() calls with a real browser TLS fingerprint.
/// This bypasses Akamai bot detection that rejects URLSession requests.
@MainActor
final class WebViewFetcher {
private var webView: WKWebView?
func runJavaScript(
navigateTo pageURL: String,
userAgent: String? = nil,
waitBeforeExecutingMs: UInt64 = 2000,
script: String
) async -> (value: Any?, error: String?) {
let webView = WKWebView(frame: .zero)
self.webView = webView
webView.customUserAgent = userAgent
guard let url = URL(string: pageURL) else {
return (nil, "Invalid page URL")
}
print("[WebViewFetcher] Navigating to \(pageURL)")
let navResult = await withCheckedContinuation { (continuation: CheckedContinuation<Bool, Never>) in
let delegate = NavigationDelegate(continuation: continuation)
webView.navigationDelegate = delegate
webView.load(URLRequest(url: url))
objc_setAssociatedObject(webView, "navDelegate", delegate, .OBJC_ASSOCIATION_RETAIN)
}
guard navResult else {
self.webView = nil
return (nil, "Failed to load page")
}
try? await Task.sleep(for: .milliseconds(waitBeforeExecutingMs))
let cookieNames = await currentCookieNames(for: webView)
if !cookieNames.isEmpty {
print("[WebViewFetcher] Cookies after navigation: \(cookieNames.sorted())")
}
do {
let result = try await webView.callAsyncJavaScript(script, contentWorld: .page)
self.webView = nil
return (result, nil)
} catch {
print("[WebViewFetcher] callAsyncJavaScript error: \(error)")
self.webView = nil
return (nil, error.localizedDescription)
}
}
/// Navigate to a domain to establish cookies/session, then execute a fetch from that context.
func fetch(
navigateTo pageURL: String,
fetchURL: String,
method: String = "POST",
headers: [String: String] = [:],
body: String? = nil,
userAgent: String? = nil,
includeCredentials: Bool = false
) async -> (data: String?, error: String?) {
let js = """
return await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("\(method)", "\(fetchURL)", true);
xhr.withCredentials = \(includeCredentials ? "true" : "false");
\(headers.map { "xhr.setRequestHeader(\"\($0.key)\", \"\($0.value)\");" }.joined(separator: "\n "))
xhr.onload = function() {
resolve(JSON.stringify({ status: xhr.status, body: xhr.responseText }));
};
xhr.onerror = function() {
resolve(JSON.stringify({ status: -1, body: "XHR error" }));
};
xhr.send(\(body ?? "null"));
});
"""
print("[WebViewFetcher] Executing fetch to \(fetchURL)")
let result: (data: String?, error: String?)
let evalResult = await runJavaScript(
navigateTo: pageURL,
userAgent: userAgent,
waitBeforeExecutingMs: 2000,
script: js
)
guard let jsValue = evalResult.value else {
return (nil, evalResult.error ?? "JavaScript execution failed")
}
guard let resultStr = jsValue as? String else {
print("[WebViewFetcher] Unexpected result type: \(type(of: jsValue))")
return (nil, "No string result from JS")
}
if let data = resultStr.data(using: .utf8),
let wrapper = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
let status = wrapper["status"] as? Int ?? -1
let body = wrapper["body"] as? String ?? ""
print("[WebViewFetcher] Response status: \(status), body length: \(body.count)")
if status == 200 {
result = (body, nil)
} else {
result = (nil, "HTTP \(status): \(String(body.prefix(200)))")
}
} else {
result = (resultStr, nil)
}
return result
}
private func currentCookieNames(for webView: WKWebView) async -> [String] {
await withCheckedContinuation { continuation in
webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { cookies in
continuation.resume(returning: cookies.map(\.name))
}
}
}
}
// MARK: - Navigation Delegate
private class NavigationDelegate: NSObject, WKNavigationDelegate {
private var continuation: CheckedContinuation<Bool, Never>?
init(continuation: CheckedContinuation<Bool, Never>) {
self.continuation = continuation
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
continuation?.resume(returning: true)
continuation = nil
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
print("[WebViewFetcher] Navigation failed: \(error)")
continuation?.resume(returning: false)
continuation = nil
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
print("[WebViewFetcher] Provisional navigation failed: \(error)")
continuation?.resume(returning: false)
continuation = nil
}
}

View File

@@ -8,13 +8,17 @@ enum SearchRoute: Hashable {
struct ContentView: View {
let service: FlightService
let database: AirportDatabase
let loadService: AirlineLoadService
let favoritesManager: FavoritesManager
@State private var viewModel: SearchViewModel
@State private var path = NavigationPath()
init(service: FlightService, database: AirportDatabase) {
init(service: FlightService, database: AirportDatabase, loadService: AirlineLoadService = AirlineLoadService(), favoritesManager: FavoritesManager) {
self.service = service
self.database = database
self.loadService = loadService
self.favoritesManager = favoritesManager
self._viewModel = State(initialValue: SearchViewModel(service: service, database: database))
}
@@ -130,6 +134,49 @@ struct ContentView: View {
}
.disabled(!viewModel.canSearch)
.opacity(viewModel.canSearch ? 1.0 : 0.5)
// MARK: - Favorites
if !favoritesManager.favorites.isEmpty {
VStack(alignment: .leading, spacing: 12) {
Text("FAVORITES")
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textTertiary)
.tracking(1)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(favoritesManager.favorites) { fav in
Button {
path.append(SearchRoute.routeDetail(fav.departure, fav.arrival, viewModel.selectedDate))
} label: {
HStack(spacing: 6) {
Text(fav.departure.iata)
.fontWeight(.bold)
Image(systemName: "arrow.right")
.font(.caption2)
Text(fav.arrival.iata)
.fontWeight(.bold)
}
.font(.subheadline)
.foregroundStyle(.primary)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(FlightTheme.cardBackground)
.clipShape(Capsule())
.shadow(color: FlightTheme.cardShadow, radius: 4, y: 1)
}
.contextMenu {
Button(role: .destructive) {
favoritesManager.remove(fav)
} label: {
Label("Remove", systemImage: "trash")
}
}
}
}
}
}
}
}
.padding(.horizontal)
.padding(.top, 8)
@@ -144,14 +191,19 @@ struct ContentView: View {
airport: airport,
date: date,
service: service,
isArrival: isArrival
isArrival: isArrival,
loadService: loadService,
database: database,
favoritesManager: favoritesManager
)
case let .routeDetail(departure, arrival, date):
RouteDetailView(
departure: departure,
arrival: arrival,
date: date,
service: service
service: service,
loadService: loadService,
favoritesManager: favoritesManager
)
}
}

View File

@@ -1,53 +1,90 @@
import SwiftUI
struct DestinationsListView: View {
enum ViewMode: String, CaseIterable {
case list, map
}
let airport: Airport
let date: Date
let service: FlightService
let isArrival: Bool
let loadService: AirlineLoadService
let database: AirportDatabase
let favoritesManager: FavoritesManager
@State private var viewModel: DestinationsViewModel
@State private var viewMode: ViewMode = .list
@State private var selectedMapRoute: SearchRoute?
init(airport: Airport, date: Date, service: FlightService, isArrival: Bool) {
init(airport: Airport, date: Date, service: FlightService, isArrival: Bool, loadService: AirlineLoadService, database: AirportDatabase, favoritesManager: FavoritesManager) {
self.airport = airport
self.date = date
self.service = service
self.isArrival = isArrival
self.loadService = loadService
self.database = database
self.favoritesManager = favoritesManager
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)
}
VStack(spacing: 0) {
Picker("View", selection: $viewMode) {
Text("List").tag(ViewMode.list)
Text("Map").tag(ViewMode.map)
}
.pickerStyle(.segmented)
.padding(.horizontal)
.padding(.vertical, 8)
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 {
switch viewMode {
case .list:
ScrollView {
LazyVStack(spacing: 12) {
ForEach(viewModel.filteredRoutes) { route in
NavigationLink(value: searchRoute(for: route)) {
routeCard(route)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal)
.padding(.vertical, 12)
}
case .map:
RouteMapView(
origin: airport,
routes: viewModel.filteredRoutes,
date: date,
service: service,
database: database,
loadService: loadService,
onSelectRoute: { route in
selectedMapRoute = searchRoute(for: route)
}
)
}
.padding(.horizontal)
.padding(.vertical, 12)
}
}
}
@@ -60,7 +97,24 @@ struct DestinationsListView: View {
departure: departure,
arrival: arrival,
date: date,
service: service
service: service,
loadService: loadService,
favoritesManager: favoritesManager
)
default:
EmptyView()
}
}
.navigationDestination(item: $selectedMapRoute) { route in
switch route {
case let .routeDetail(departure, arrival, date):
RouteDetailView(
departure: departure,
arrival: arrival,
date: date,
service: service,
loadService: loadService,
favoritesManager: favoritesManager
)
default:
EmptyView()

View File

@@ -0,0 +1,416 @@
import SwiftUI
struct FlightLoadDetailView: View {
let schedule: FlightSchedule
let departureCode: String
let arrivalCode: String
let date: Date
let loadService: AirlineLoadService
@Environment(\.dismiss) private var dismiss
@State private var load: FlightLoad?
@State private var isLoading = true
@State private var error: String?
@State private var isUpgradeListExpanded = false
@State private var isStandbyListExpanded = false
var body: some View {
NavigationStack {
ZStack {
FlightTheme.background
.ignoresSafeArea()
if isLoading {
ProgressView()
.tint(FlightTheme.accent)
} else if let error {
errorView(error)
} else if schedule.airline.iata.uppercased() == "NK" {
spiritUnavailableView
} else if let load {
loadContent(load)
} else {
unsupportedAirlineView
}
}
.navigationTitle("Flight Load")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button {
dismiss()
} label: {
Image(systemName: "xmark")
.font(.subheadline.weight(.semibold))
.foregroundStyle(FlightTheme.textSecondary)
}
}
}
.task {
await fetchLoadData()
}
}
}
// MARK: - Data Fetching
private func fetchLoadData() async {
isLoading = true
error = nil
let flightNum = extractFlightNumber(from: schedule.flightNumber)
let result = await loadService.fetchLoad(
airlineCode: schedule.airline.iata,
flightNumber: flightNum,
date: date,
origin: departureCode,
destination: arrivalCode
)
load = result
isLoading = false
}
// MARK: - Flight Number Extraction
/// Strips the airline prefix from a flight number.
/// "AA 2209" -> "2209", "UA2238" -> "2238", "2209" -> "2209"
private func extractFlightNumber(from raw: String) -> String {
let trimmed = raw.trimmingCharacters(in: .whitespaces)
var start = trimmed.startIndex
while start < trimmed.endIndex && (trimmed[start].isLetter || trimmed[start] == " ") {
start = trimmed.index(after: start)
}
let result = String(trimmed[start...])
return result.isEmpty ? trimmed : result
}
// MARK: - Error View
private func errorView(_ message: String) -> some View {
ContentUnavailableView {
Label("Unable to Load", systemImage: "exclamationmark.triangle")
} description: {
Text("Unable to load flight data.\n\(message)")
}
}
// MARK: - Spirit Unavailable
private var spiritUnavailableView: some View {
ContentUnavailableView {
Label("Not Available", systemImage: "info.circle")
} description: {
Text("Spirit Airlines does not provide standby or load data.")
}
}
// MARK: - Unsupported Airline
private var unsupportedAirlineView: some View {
ContentUnavailableView {
Label("Not Available", systemImage: "info.circle")
} description: {
Text("Load data not available for \(schedule.airline.name).")
}
}
// MARK: - Load Content
private func loadContent(_ load: FlightLoad) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
flightHeader
// MARK: - Summary Numbers (hero stats)
summaryCardUnified(load)
if !load.cabins.isEmpty {
cabinSection(load.cabins)
}
if !load.seatAvailability.isEmpty {
seatAvailabilitySection(load.seatAvailability)
}
if !load.upgradeList.isEmpty {
collapsiblePassengerSection(
title: "Upgrade Waitlist",
count: load.upgradeList.count,
passengers: load.upgradeList
)
}
if !load.standbyList.isEmpty || load.totalStandbyFromPBTS > 0 {
collapsiblePassengerSection(
title: "Standby List",
count: max(load.standbyList.count, load.totalStandbyFromPBTS),
passengers: load.standbyList
)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
// MARK: - Flight Header
private var flightHeader: some View {
VStack(alignment: .leading, spacing: 4) {
Text("\(schedule.airline.iata) \(extractFlightNumber(from: schedule.flightNumber)) \u{00B7} \(departureCode) \u{2192} \(arrivalCode)")
.font(.title3.weight(.bold))
.foregroundStyle(FlightTheme.textPrimary)
Text(schedule.airline.name)
.font(.subheadline)
.foregroundStyle(FlightTheme.textSecondary)
}
.padding(.bottom, 4)
}
// MARK: - Cabin Section
private func cabinSection(_ cabins: [CabinLoad]) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text("CABIN AVAILABILITY")
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textTertiary)
VStack(spacing: 0) {
ForEach(Array(cabins.enumerated()), id: \.element.id) { index, cabin in
cabinRow(cabin)
if index < cabins.count - 1 {
Divider()
.padding(.horizontal, FlightTheme.cardPadding)
}
}
}
.background(FlightTheme.cardBackground)
.clipShape(RoundedRectangle(cornerRadius: FlightTheme.cardCornerRadius))
}
}
private func cabinRow(_ cabin: CabinLoad) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(cabin.name)
.font(.subheadline.weight(.semibold))
.foregroundStyle(FlightTheme.textPrimary)
Spacer()
Text("\(cabin.available) available")
.font(.subheadline)
.foregroundStyle(FlightTheme.textSecondary)
}
loadProgressBar(loadFactor: cabin.loadFactor, loadColor: cabin.loadColor)
if cabin.capacity > 0 {
Text("\(cabin.capacity - cabin.available) of \(cabin.capacity) seats \u{00B7} \(Int(cabin.loadFactor * 100))%")
.font(FlightTheme.label(11))
.foregroundStyle(FlightTheme.textTertiary)
}
}
.padding(FlightTheme.cardPadding)
}
// MARK: - Summary Card (unified for all airlines)
private func summaryCardUnified(_ load: FlightLoad) -> some View {
let openSeats: Int
let standbyCount: Int
if load.hasCabinData {
// United-style: derive from pbts
openSeats = load.totalAvailable
standbyCount = load.totalStandbyFromPBTS
} else {
// AA-style: sum from seatAvailability
openSeats = load.seatAvailability.reduce(0) { $0 + $1.available }
standbyCount = load.standbyList.count
}
return HStack(spacing: 0) {
VStack(spacing: 4) {
Text("\(openSeats)")
.font(.system(size: 36, weight: .bold, design: .rounded))
.foregroundStyle(FlightTheme.onTime)
Text("Open Seats")
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textSecondary)
}
.frame(maxWidth: .infinity)
Divider()
.frame(height: 50)
VStack(spacing: 4) {
Text("\(standbyCount)")
.font(.system(size: 36, weight: .bold, design: .rounded))
.foregroundStyle(standbyCount > openSeats ? FlightTheme.cancelled : FlightTheme.delayed)
Text("On Standby")
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textSecondary)
}
.frame(maxWidth: .infinity)
}
.padding(.vertical, 16)
.flightCard()
}
// MARK: - Collapsible Passenger Section
private func collapsiblePassengerSection(title: String, count: Int, passengers: [StandbyPassenger]) -> some View {
VStack(alignment: .leading, spacing: 0) {
let isExpanded = title.contains("Upgrade") ? isUpgradeListExpanded : isStandbyListExpanded
Button {
withAnimation(.easeInOut(duration: 0.2)) {
if title.contains("Upgrade") {
isUpgradeListExpanded.toggle()
} else {
isStandbyListExpanded.toggle()
}
}
} label: {
HStack {
Text("\(title.uppercased()) (\(count))")
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textTertiary)
Spacer()
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.caption)
.foregroundStyle(FlightTheme.textTertiary)
}
}
.buttonStyle(.plain)
.padding(.bottom, isExpanded ? 12 : 0)
if isExpanded {
if passengers.isEmpty {
Text("\(count) passenger\(count == 1 ? "" : "s") on \(title.lowercased())")
.font(.subheadline)
.foregroundStyle(FlightTheme.textSecondary)
.padding(FlightTheme.cardPadding)
.frame(maxWidth: .infinity, alignment: .leading)
.background(FlightTheme.cardBackground)
.clipShape(RoundedRectangle(cornerRadius: FlightTheme.cardCornerRadius))
} else {
VStack(spacing: 0) {
ForEach(Array(passengers.enumerated()), id: \.element.id) { index, passenger in
passengerRow(passenger)
if index < passengers.count - 1 {
Divider()
.padding(.horizontal, FlightTheme.cardPadding)
}
}
}
.background(FlightTheme.cardBackground)
.clipShape(RoundedRectangle(cornerRadius: FlightTheme.cardCornerRadius))
}
}
}
}
// MARK: - Seat Availability (AA-style, no capacity data)
private func seatAvailabilitySection(_ items: [SeatAvailability]) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text("SEAT AVAILABILITY")
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textTertiary)
VStack(spacing: 0) {
ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
HStack {
Text(item.label)
.font(.subheadline.weight(.semibold))
.foregroundStyle(FlightTheme.textPrimary)
Spacer()
Text("\(item.available) available")
.font(.subheadline.weight(.medium))
.foregroundStyle(colorForAvailability(item.color))
}
.padding(FlightTheme.cardPadding)
if index < items.count - 1 {
Divider()
.padding(.horizontal, FlightTheme.cardPadding)
}
}
}
.background(FlightTheme.cardBackground)
.clipShape(RoundedRectangle(cornerRadius: FlightTheme.cardCornerRadius))
}
}
private func colorForAvailability(_ color: SeatAvailabilityColor) -> Color {
switch color {
case .success: return FlightTheme.onTime
case .warning: return FlightTheme.delayed
case .failure: return FlightTheme.cancelled
}
}
// MARK: - Progress Bar
private func loadProgressBar(loadFactor: Double, loadColor: LoadColor) -> some View {
GeometryReader { geometry in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 4)
.fill(Color(.quaternarySystemFill))
RoundedRectangle(cornerRadius: 4)
.fill(colorForLoadColor(loadColor))
.frame(width: max(0, geometry.size.width * min(loadFactor, 1.0)))
}
}
.frame(height: 8)
}
private func colorForLoadColor(_ loadColor: LoadColor) -> Color {
switch loadColor {
case .green: return FlightTheme.onTime
case .yellow: return FlightTheme.delayed
case .red: return FlightTheme.cancelled
}
}
private func passengerRow(_ passenger: StandbyPassenger) -> some View {
HStack(spacing: 8) {
Text("\(passenger.order).")
.font(.subheadline.weight(.bold))
.foregroundStyle(FlightTheme.textPrimary)
.frame(width: 28, alignment: .trailing)
Text(passenger.displayName)
.font(.subheadline)
.foregroundStyle(FlightTheme.textPrimary)
Spacer()
if passenger.cleared {
HStack(spacing: 4) {
Image(systemName: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(FlightTheme.onTime)
if let seat = passenger.seat {
Text(seat)
.font(FlightTheme.label(11))
.foregroundStyle(FlightTheme.textSecondary)
}
}
}
}
.padding(.horizontal, FlightTheme.cardPadding)
.padding(.vertical, 10)
}
}

View File

@@ -50,14 +50,22 @@ struct FlightScheduleRow: View {
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())
// MARK: - Aircraft pill tag + tap hint
HStack {
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())
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(FlightTheme.textTertiary)
}
}
.flightCard()

View File

@@ -5,14 +5,19 @@ struct RouteDetailView: View {
let arrival: Airport
let date: Date
let service: FlightService
let loadService: AirlineLoadService
let favoritesManager: FavoritesManager
@State private var viewModel: RouteDetailViewModel
@State private var selectedFlight: FlightSchedule?
init(departure: Airport, arrival: Airport, date: Date, service: FlightService) {
init(departure: Airport, arrival: Airport, date: Date, service: FlightService, loadService: AirlineLoadService, favoritesManager: FavoritesManager) {
self.departure = departure
self.arrival = arrival
self.date = date
self.service = service
self.loadService = loadService
self.favoritesManager = favoritesManager
self._viewModel = State(initialValue: RouteDetailViewModel(service: service, date: date))
}
@@ -35,6 +40,14 @@ struct RouteDetailView: View {
}
.navigationTitle("\(departure.iata) \u{2192} \(arrival.iata)")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
favoritesManager.toggle(departure: departure, arrival: arrival)
} label: {
Image(systemName: favoritesManager.isFavorite(departure: departure, arrival: arrival) ? "heart.fill" : "heart")
.foregroundStyle(favoritesManager.isFavorite(departure: departure, arrival: arrival) ? .red : FlightTheme.textSecondary)
}
}
ToolbarItem(placement: .topBarTrailing) {
DatePicker(
"Date",
@@ -51,6 +64,15 @@ struct RouteDetailView: View {
let desId = await resolveId(for: arrival)
await viewModel.loadSchedules(dep: depId, des: desId)
}
.sheet(item: $selectedFlight) { flight in
FlightLoadDetailView(
schedule: flight,
departureCode: departure.iata,
arrivalCode: arrival.iata,
date: viewModel.selectedDate,
loadService: loadService
)
}
}
// MARK: - Loading
@@ -134,11 +156,16 @@ struct RouteDetailView: View {
// Flight cards
ForEach(group.flights) { schedule in
FlightScheduleRow(
schedule: schedule,
departureCode: departure.iata,
arrivalCode: arrival.iata
)
Button {
selectedFlight = schedule
} label: {
FlightScheduleRow(
schedule: schedule,
departureCode: departure.iata,
arrivalCode: arrival.iata
)
}
.buttonStyle(.plain)
}
}
}

View File

@@ -0,0 +1,208 @@
import SwiftUI
import MapKit
struct RouteMapView: View {
let origin: Airport
let routes: [Route]
let date: Date
let service: FlightService
let database: AirportDatabase
let loadService: AirlineLoadService
let onSelectRoute: (Route) -> Void
@State private var selectedRoute: Route?
@State private var position: MapCameraPosition
init(
origin: Airport,
routes: [Route],
date: Date,
service: FlightService,
database: AirportDatabase,
loadService: AirlineLoadService,
onSelectRoute: @escaping (Route) -> Void
) {
self.origin = origin
self.routes = routes
self.date = date
self.service = service
self.database = database
self.loadService = loadService
self.onSelectRoute = onSelectRoute
let originCoord = database.airport(byIATA: origin.iata)?.coordinate
?? CLLocationCoordinate2D(latitude: 0, longitude: 0)
// Determine a span that covers the farthest destination
var maxDelta: Double = 20
for route in routes {
let latDelta = abs(route.latitude - originCoord.latitude)
let lngDelta = abs(route.longitude - originCoord.longitude)
maxDelta = max(maxDelta, max(latDelta, lngDelta) * 2.5)
}
maxDelta = min(maxDelta, 160)
_position = State(initialValue: .region(MKCoordinateRegion(
center: originCoord,
span: MKCoordinateSpan(latitudeDelta: maxDelta, longitudeDelta: maxDelta)
)))
}
private var originCoordinate: CLLocationCoordinate2D {
database.airport(byIATA: origin.iata)?.coordinate
?? CLLocationCoordinate2D(latitude: 0, longitude: 0)
}
var body: some View {
ZStack {
Map(position: $position) {
// Origin annotation
Annotation(origin.iata, coordinate: originCoordinate) {
Image(systemName: "airplane.circle.fill")
.font(.system(size: 24))
.foregroundStyle(.blue)
.background(Circle().fill(.white).padding(2))
}
// Destination annotations
ForEach(routes) { route in
let coord = CLLocationCoordinate2D(
latitude: route.latitude,
longitude: route.longitude
)
Annotation(route.destinationAirport.iata, coordinate: coord) {
Button {
withAnimation(.easeInOut(duration: 0.2)) {
selectedRoute = route
}
} label: {
Image(systemName: "airplane.circle.fill")
.font(.system(size: 16))
.foregroundStyle(
selectedRoute?.id == route.id
? .blue
: FlightTheme.onTime
)
.background(Circle().fill(.white).padding(1))
}
}
// Arc line from origin to destination
MapPolyline(coordinates: arcPoints(
from: originCoordinate,
to: coord
))
.stroke(
selectedRoute?.id == route.id
? Color.blue
: FlightTheme.onTime.opacity(0.5),
lineWidth: selectedRoute?.id == route.id ? 2.5 : 1.5
)
}
}
// Bottom popup for selected route
if let route = selectedRoute {
VStack {
Spacer()
routePopup(route)
.padding()
.transition(.move(edge: .bottom).combined(with: .opacity))
}
.animation(.easeInOut(duration: 0.2), value: selectedRoute)
}
}
}
// MARK: - Route Popup
private func routePopup(_ route: Route) -> some View {
HStack(spacing: 16) {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Text(route.destinationAirport.iata)
.font(FlightTheme.airportCode(20))
.foregroundStyle(FlightTheme.accent)
Text(route.destinationAirport.name)
.font(.headline)
.lineLimit(1)
}
HStack(spacing: 12) {
Label(formatDuration(route.durationMinutes), systemImage: "clock")
.font(FlightTheme.label(13))
.foregroundStyle(.secondary)
Label(formatDistance(route.distanceMiles), systemImage: "arrow.left.and.right")
.font(FlightTheme.label(13))
.foregroundStyle(.secondary)
}
}
Spacer()
Button {
onSelectRoute(route)
} label: {
Text("View Flights")
.fontWeight(.semibold)
.font(.subheadline)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(FlightTheme.accent)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
Button {
withAnimation(.easeInOut(duration: 0.2)) {
selectedRoute = nil
}
} label: {
Image(systemName: "xmark.circle.fill")
.font(.title3)
.foregroundStyle(.secondary)
}
}
.padding()
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: FlightTheme.cardCornerRadius))
.shadow(radius: 8)
}
// MARK: - Helpers
private func arcPoints(
from: CLLocationCoordinate2D,
to: CLLocationCoordinate2D,
segments: Int = 30
) -> [CLLocationCoordinate2D] {
var points: [CLLocationCoordinate2D] = []
for i in 0...segments {
let f = Double(i) / Double(segments)
let lat = from.latitude + (to.latitude - from.latitude) * f
let lng = from.longitude + (to.longitude - from.longitude) * f
points.append(CLLocationCoordinate2D(latitude: lat, longitude: lng))
}
return points
}
private func formatDuration(_ minutes: Int) -> String {
let h = minutes / 60
let m = minutes % 60
if h > 0 && m > 0 {
return "\(h)h \(m)m"
} else if h > 0 {
return "\(h)h"
} else {
return "\(m)m"
}
}
private func formatDistance(_ miles: Int) -> String {
if miles >= 1000 {
let k = Double(miles) / 1000.0
return String(format: "%.1fk mi", k)
}
return "\(miles) mi"
}
}