diff --git a/AIRLINE_API_SPEC.md b/AIRLINE_API_SPEC.md new file mode 100644 index 0000000..df12197 --- /dev/null +++ b/AIRLINE_API_SPEC.md @@ -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. diff --git a/Flights.xcodeproj/project.pbxproj b/Flights.xcodeproj/project.pbxproj index 3803c2a..4ae9cd4 100644 --- a/Flights.xcodeproj/project.pbxproj +++ b/Flights.xcodeproj/project.pbxproj @@ -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 = ""; }; AA2222222222222222222222 /* FlightTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlightTheme.swift; sourceTree = ""; }; AA4444444444444444444444 /* RouteVisualization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteVisualization.swift; sourceTree = ""; }; + BB1100001111000011110002 /* FlightLoad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlightLoad.swift; sourceTree = ""; }; + BB1100001111000011110004 /* AirlineLoadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirlineLoadService.swift; sourceTree = ""; }; + BB1100001111000011110006 /* FlightLoadDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlightLoadDetailView.swift; sourceTree = ""; }; + 59848F598CC941C393B23604 /* FavoriteRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteRoute.swift; sourceTree = ""; }; + 44E75295BB044D13AD26563D /* FavoritesManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesManager.swift; sourceTree = ""; }; + 2574CDD727284621BBB56145 /* RouteMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteMapView.swift; sourceTree = ""; }; + 22867394CDCC423891007AE1 /* WebViewFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewFetcher.swift; sourceTree = ""; }; + BB2200002222000022220002 /* JSXWebViewFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSXWebViewFetcher.swift; sourceTree = ""; }; /* 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 = ""; @@ -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 = ""; @@ -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; }; diff --git a/Flights/FlightsApp.swift b/Flights/FlightsApp.swift index 9704cee..d6d40c1 100644 --- a/Flights/FlightsApp.swift +++ b/Flights/FlightsApp.swift @@ -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) } } } diff --git a/Flights/Models/Airport.swift b/Flights/Models/Airport.swift index e17d3f3..0c8dff0 100644 --- a/Flights/Models/Airport.swift +++ b/Flights/Models/Airport.swift @@ -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 diff --git a/Flights/Models/FavoriteRoute.swift b/Flights/Models/FavoriteRoute.swift new file mode 100644 index 0000000..7c61062 --- /dev/null +++ b/Flights/Models/FavoriteRoute.swift @@ -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 + } +} diff --git a/Flights/Models/FlightLoad.swift b/Flights/Models/FlightLoad.swift new file mode 100644 index 0000000..944b0d0 --- /dev/null +++ b/Flights/Models/FlightLoad.swift @@ -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. +} diff --git a/Flights/Services/AirportDatabase.swift b/Flights/Services/AirportDatabase.swift index f6f3047..bdb3ff9 100644 --- a/Flights/Services/AirportDatabase.swift +++ b/Flights/Services/AirportDatabase.swift @@ -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] = [ diff --git a/Flights/Services/FavoritesManager.swift b/Flights/Services/FavoritesManager.swift new file mode 100644 index 0000000..ce9eda3 --- /dev/null +++ b/Flights/Services/FavoritesManager.swift @@ -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 + } +} diff --git a/Flights/Services/WebViewFetcher.swift b/Flights/Services/WebViewFetcher.swift new file mode 100644 index 0000000..dfa6d14 --- /dev/null +++ b/Flights/Services/WebViewFetcher.swift @@ -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) 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? + + init(continuation: CheckedContinuation) { + 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 + } +} diff --git a/Flights/Views/ContentView.swift b/Flights/Views/ContentView.swift index 5deb1c6..7a55696 100644 --- a/Flights/Views/ContentView.swift +++ b/Flights/Views/ContentView.swift @@ -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 ) } } diff --git a/Flights/Views/DestinationsListView.swift b/Flights/Views/DestinationsListView.swift index 78320b0..0f12931 100644 --- a/Flights/Views/DestinationsListView.swift +++ b/Flights/Views/DestinationsListView.swift @@ -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() diff --git a/Flights/Views/FlightLoadDetailView.swift b/Flights/Views/FlightLoadDetailView.swift new file mode 100644 index 0000000..832a1be --- /dev/null +++ b/Flights/Views/FlightLoadDetailView.swift @@ -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) + } +} diff --git a/Flights/Views/FlightScheduleRow.swift b/Flights/Views/FlightScheduleRow.swift index 8cd8cca..4e5be41 100644 --- a/Flights/Views/FlightScheduleRow.swift +++ b/Flights/Views/FlightScheduleRow.swift @@ -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() diff --git a/Flights/Views/RouteDetailView.swift b/Flights/Views/RouteDetailView.swift index 3d44a0e..577a1ec 100644 --- a/Flights/Views/RouteDetailView.swift +++ b/Flights/Views/RouteDetailView.swift @@ -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) } } } diff --git a/Flights/Views/RouteMapView.swift b/Flights/Views/RouteMapView.swift new file mode 100644 index 0000000..80ab0b5 --- /dev/null +++ b/Flights/Views/RouteMapView.swift @@ -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" + } +}