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:
792
AIRLINE_API_SPEC.md
Normal file
792
AIRLINE_API_SPEC.md
Normal 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.
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
12
Flights/Models/FavoriteRoute.swift
Normal file
12
Flights/Models/FavoriteRoute.swift
Normal 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
|
||||
}
|
||||
}
|
||||
82
Flights/Models/FlightLoad.swift
Normal file
82
Flights/Models/FlightLoad.swift
Normal 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.
|
||||
}
|
||||
@@ -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] = [
|
||||
|
||||
50
Flights/Services/FavoritesManager.swift
Normal file
50
Flights/Services/FavoritesManager.swift
Normal 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
|
||||
}
|
||||
}
|
||||
149
Flights/Services/WebViewFetcher.swift
Normal file
149
Flights/Services/WebViewFetcher.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
416
Flights/Views/FlightLoadDetailView.swift
Normal file
416
Flights/Views/FlightLoadDetailView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
208
Flights/Views/RouteMapView.swift
Normal file
208
Flights/Views/RouteMapView.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user