Compare commits

..

2 Commits

Author SHA1 Message Date
Trey t 0c4777216e Make RoutePlannerView the home; merge "Where can I go?" into it
Single unified search at the app root. TO is optional: filled goes through
/route (connections); blank flips to /departures with a time-window picker
("Where can I go?"). Same per-leg load card detail screen for any tap, so
direct flights and multi-stop connections share the same UX.

- Drop ContentView entirely (favorites + browse + entry cards). FlightsApp
  instantiates RoutePlannerView directly.
- Delete WhereToGoView; DepartureLegRow is inlined into RoutePlannerView
  as the where-can-I-go result row.
- SearchRoute enum trimmed to just the cases DestinationsListView still
  references and moved to its own file (Models/SearchRoute.swift).

Sort bar moved out of the controls cards into a dedicated row between the
Search button and the results — only visible once results exist. Switched
from segmented to dropdown menu picker. Options narrowed to the four
the user asked for: Departure Earliest / Departure Latest / Fewest Stops
/ Most Stops in connection mode, just the two time-based options for
where-can-I-go (single-leg, stop-count is meaningless). All sorts apply
client-side; upstream still gets `departure_time` for a stable base order.

Two real bugs fixed in connection search:
- Past flights weren't filtered. Same-day searches return mostly already-
  departed itineraries because the API sorts earliest-first. Added a
  `firstDeparture > now` filter applied before sort. Header surfaces the
  dropped count ("12 itineraries · 38 already departed"). When every
  result is past, the error message says so explicitly instead of going
  blank.
- 100-result limit was way too low for hub→hub with maxStops:2 — the
  combinatorial explosion of valid permutations filled the cap with
  morning flights and never reached afternoon. Bumped to 500.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 11:50:02 -05:00
Trey t df4a74726c Route Explorer: unified per-leg load card + multi-leg fan-out
Single ConnectionLoadDetailView is now the universal detail screen for
both Find Connections (1+ legs) and Where Can I Go (single-leg). For
multi-stop connections it fetches each leg's load in parallel via
withTaskGroup so the slowest carrier doesn't block the rest. Each leg
card shows airline + flight + IATAs + airport names + aircraft + an
open/standby summary, with a "Full details" drill-down to
FlightLoadDetailView for waitlists/passenger lists.

Bug fixes along the way:
- Empty origin/destination in carrier API URLs (HTTP 400 from AA): the
  4 separate @State vars feeding .sheet(item:) raced — sheet captured
  empty strings before the other writes settled. Bundled into one
  Identifiable RouteLoadDetailRequest / ConnectionLoadRequest so updates
  are atomic.
- Flight numbers rendered with locale separators ("AA 6,380", "3,189").
  Text("\(int)") resolves to the LocalizedStringKey initializer; switched
  to Text(verbatim:).
- "Load data not available for {airline}" was misleading when the
  airline IS supported but a specific flight has no data. Reworded to
  flight-scoped copy.
- AA fetcher had no logging — added URL/status/body/keys diagnostics
  matching the UA pattern.

UI cleanup:
- DepartureLegRow: big IATAs on their own row, full airport names on a
  middle-truncated subtitle, aircraft pill single-line tail-truncated.
- LegSummary (ConnectionRow): airport-name subtitle line below
  times+IATAs row.
- airportName priority: bundled airports.json first ("Dallas-Fort
  Worth") over the route-explorer appendix ("Dallas Dallas/Fort Worth
  Intl") which truncated to garbage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 11:19:20 -05:00
12 changed files with 1220 additions and 828 deletions
+8 -8
View File
@@ -14,7 +14,6 @@
303821C9668A44F38FFA02CA /* AirportSearchField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD303E3EDCC4BF2BCF31722 /* AirportSearchField.swift */; }; 303821C9668A44F38FFA02CA /* AirportSearchField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD303E3EDCC4BF2BCF31722 /* AirportSearchField.swift */; };
35D016EBA93C40BB873AB304 /* Airline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AC23D8748D42C9A7115FAC /* Airline.swift */; }; 35D016EBA93C40BB873AB304 /* Airline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AC23D8748D42C9A7115FAC /* Airline.swift */; };
4C770C55CB3643BAB7B9D622 /* AirportBrowserSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15676B4BD35745D1BD1DC947 /* AirportBrowserSheet.swift */; }; 4C770C55CB3643BAB7B9D622 /* AirportBrowserSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15676B4BD35745D1BD1DC947 /* AirportBrowserSheet.swift */; };
57A463AB3CFD44DC93444E59 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9934B0FCA757403A94AB963C /* ContentView.swift */; };
61F8E3DD7D434DA7854C20E2 /* FlightsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6D822B4ABF741F890A4400C /* FlightsApp.swift */; }; 61F8E3DD7D434DA7854C20E2 /* FlightsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6D822B4ABF741F890A4400C /* FlightsApp.swift */; };
6558A31ADEC740FC8C56EA22 /* FlightSchedule.swift in Sources */ = {isa = PBXBuildFile; fileRef = B913D04A4E51436595308A21 /* FlightSchedule.swift */; }; 6558A31ADEC740FC8C56EA22 /* FlightSchedule.swift in Sources */ = {isa = PBXBuildFile; fileRef = B913D04A4E51436595308A21 /* FlightSchedule.swift */; };
7FFD9A2D25F9421D8929C027 /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36862683C4F44A95AFE234EB /* SearchViewModel.swift */; }; 7FFD9A2D25F9421D8929C027 /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36862683C4F44A95AFE234EB /* SearchViewModel.swift */; };
@@ -42,9 +41,10 @@
RE1100001111000011110001 /* RouteExplorerModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE1100001111000011110002 /* RouteExplorerModels.swift */; }; RE1100001111000011110001 /* RouteExplorerModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE1100001111000011110002 /* RouteExplorerModels.swift */; };
RE2200002222000022220001 /* RouteExplorerClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE2200002222000022220002 /* RouteExplorerClient.swift */; }; RE2200002222000022220001 /* RouteExplorerClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE2200002222000022220002 /* RouteExplorerClient.swift */; };
RE3300003333000033330001 /* RoutePlannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE3300003333000033330002 /* RoutePlannerView.swift */; }; RE3300003333000033330001 /* RoutePlannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE3300003333000033330002 /* RoutePlannerView.swift */; };
RE4400004444000044440001 /* WhereToGoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE4400004444000044440002 /* WhereToGoView.swift */; };
RE5500005555000055550001 /* IATAAirportPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE5500005555000055550002 /* IATAAirportPicker.swift */; }; RE5500005555000055550001 /* IATAAirportPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE5500005555000055550002 /* IATAAirportPicker.swift */; };
RE6600006666000066660001 /* ConnectionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE6600006666000066660002 /* ConnectionRow.swift */; }; RE6600006666000066660001 /* ConnectionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE6600006666000066660002 /* ConnectionRow.swift */; };
RE7700007777000077770001 /* ConnectionLoadDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE7700007777000077770002 /* ConnectionLoadDetailView.swift */; };
RE8800008888000088880001 /* SearchRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE8800008888000088880002 /* SearchRoute.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
@@ -61,7 +61,6 @@
7C2EB471F011450DA7BBEFAD /* AirportMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirportMapView.swift; sourceTree = "<group>"; }; 7C2EB471F011450DA7BBEFAD /* AirportMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirportMapView.swift; sourceTree = "<group>"; };
85EC89DEE12942B49DF51984 /* Airport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Airport.swift; sourceTree = "<group>"; }; 85EC89DEE12942B49DF51984 /* Airport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Airport.swift; sourceTree = "<group>"; };
8A3CB0CCC2524542AFB0D1D2 /* Flights.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Flights.app; sourceTree = BUILT_PRODUCTS_DIR; }; 8A3CB0CCC2524542AFB0D1D2 /* Flights.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Flights.app; sourceTree = BUILT_PRODUCTS_DIR; };
9934B0FCA757403A94AB963C /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
9A58C339D6084657B0538E9C /* AirportDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirportDatabase.swift; sourceTree = "<group>"; }; 9A58C339D6084657B0538E9C /* AirportDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirportDatabase.swift; sourceTree = "<group>"; };
9BEAC0EBABFD41569FE69B1B /* DestinationsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestinationsViewModel.swift; sourceTree = "<group>"; }; 9BEAC0EBABFD41569FE69B1B /* DestinationsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestinationsViewModel.swift; sourceTree = "<group>"; };
A65682BD902141BAA686D101 /* FlightService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlightService.swift; sourceTree = "<group>"; }; A65682BD902141BAA686D101 /* FlightService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlightService.swift; sourceTree = "<group>"; };
@@ -84,9 +83,10 @@
RE1100001111000011110002 /* RouteExplorerModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteExplorerModels.swift; sourceTree = "<group>"; }; RE1100001111000011110002 /* RouteExplorerModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteExplorerModels.swift; sourceTree = "<group>"; };
RE2200002222000022220002 /* RouteExplorerClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteExplorerClient.swift; sourceTree = "<group>"; }; RE2200002222000022220002 /* RouteExplorerClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteExplorerClient.swift; sourceTree = "<group>"; };
RE3300003333000033330002 /* RoutePlannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutePlannerView.swift; sourceTree = "<group>"; }; RE3300003333000033330002 /* RoutePlannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutePlannerView.swift; sourceTree = "<group>"; };
RE4400004444000044440002 /* WhereToGoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhereToGoView.swift; sourceTree = "<group>"; };
RE5500005555000055550002 /* IATAAirportPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IATAAirportPicker.swift; sourceTree = "<group>"; }; RE5500005555000055550002 /* IATAAirportPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IATAAirportPicker.swift; sourceTree = "<group>"; };
RE6600006666000066660002 /* ConnectionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionRow.swift; sourceTree = "<group>"; }; RE6600006666000066660002 /* ConnectionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionRow.swift; sourceTree = "<group>"; };
RE7700007777000077770002 /* ConnectionLoadDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionLoadDetailView.swift; sourceTree = "<group>"; };
RE8800008888000088880002 /* SearchRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRoute.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -103,7 +103,6 @@
1B20C5393D8F432A93097C2C /* Views */ = { 1B20C5393D8F432A93097C2C /* Views */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
9934B0FCA757403A94AB963C /* ContentView.swift */,
0CD303E3EDCC4BF2BCF31722 /* AirportSearchField.swift */, 0CD303E3EDCC4BF2BCF31722 /* AirportSearchField.swift */,
300153508F8445B6A78CEC52 /* DestinationsListView.swift */, 300153508F8445B6A78CEC52 /* DestinationsListView.swift */,
1C1176F877BF496ABF079040 /* RouteDetailView.swift */, 1C1176F877BF496ABF079040 /* RouteDetailView.swift */,
@@ -113,7 +112,7 @@
15676B4BD35745D1BD1DC947 /* AirportBrowserSheet.swift */, 15676B4BD35745D1BD1DC947 /* AirportBrowserSheet.swift */,
BB1100001111000011110006 /* FlightLoadDetailView.swift */, BB1100001111000011110006 /* FlightLoadDetailView.swift */,
RE3300003333000033330002 /* RoutePlannerView.swift */, RE3300003333000033330002 /* RoutePlannerView.swift */,
RE4400004444000044440002 /* WhereToGoView.swift */, RE7700007777000077770002 /* ConnectionLoadDetailView.swift */,
AA5555555555555555555555 /* Styles */, AA5555555555555555555555 /* Styles */,
AA6666666666666666666666 /* Components */, AA6666666666666666666666 /* Components */,
); );
@@ -205,6 +204,7 @@
E7987BD4832D44F1A0851933 /* Country.swift */, E7987BD4832D44F1A0851933 /* Country.swift */,
BB1100001111000011110002 /* FlightLoad.swift */, BB1100001111000011110002 /* FlightLoad.swift */,
RE1100001111000011110002 /* RouteExplorerModels.swift */, RE1100001111000011110002 /* RouteExplorerModels.swift */,
RE8800008888000088880002 /* SearchRoute.swift */,
); );
path = Models; path = Models;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -283,7 +283,6 @@
7FFD9A2D25F9421D8929C027 /* SearchViewModel.swift in Sources */, 7FFD9A2D25F9421D8929C027 /* SearchViewModel.swift in Sources */,
C36C490556254AC88EC02C80 /* DestinationsViewModel.swift in Sources */, C36C490556254AC88EC02C80 /* DestinationsViewModel.swift in Sources */,
9C1300E497B049FE8DA677E0 /* RouteDetailViewModel.swift in Sources */, 9C1300E497B049FE8DA677E0 /* RouteDetailViewModel.swift in Sources */,
57A463AB3CFD44DC93444E59 /* ContentView.swift in Sources */,
303821C9668A44F38FFA02CA /* AirportSearchField.swift in Sources */, 303821C9668A44F38FFA02CA /* AirportSearchField.swift in Sources */,
D0EC717347974D668C77B9D2 /* DestinationsListView.swift in Sources */, D0EC717347974D668C77B9D2 /* DestinationsListView.swift in Sources */,
BB3E647E4A07477F9F37E607 /* RouteDetailView.swift in Sources */, BB3E647E4A07477F9F37E607 /* RouteDetailView.swift in Sources */,
@@ -307,9 +306,10 @@
RE1100001111000011110001 /* RouteExplorerModels.swift in Sources */, RE1100001111000011110001 /* RouteExplorerModels.swift in Sources */,
RE2200002222000022220001 /* RouteExplorerClient.swift in Sources */, RE2200002222000022220001 /* RouteExplorerClient.swift in Sources */,
RE3300003333000033330001 /* RoutePlannerView.swift in Sources */, RE3300003333000033330001 /* RoutePlannerView.swift in Sources */,
RE4400004444000044440001 /* WhereToGoView.swift in Sources */,
RE5500005555000055550001 /* IATAAirportPicker.swift in Sources */, RE5500005555000055550001 /* IATAAirportPicker.swift in Sources */,
RE6600006666000066660001 /* ConnectionRow.swift in Sources */, RE6600006666000066660001 /* ConnectionRow.swift in Sources */,
RE7700007777000077770001 /* ConnectionLoadDetailView.swift in Sources */,
RE8800008888000088880001 /* SearchRoute.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
+3 -7
View File
@@ -2,9 +2,7 @@ import SwiftUI
@main @main
struct FlightsApp: App { struct FlightsApp: App {
let service = FlightService()
let database: AirportDatabase let database: AirportDatabase
let favoritesManager = FavoritesManager()
let loadService: AirlineLoadService let loadService: AirlineLoadService
let routeExplorer = RouteExplorerClient() let routeExplorer = RouteExplorerClient()
@@ -16,12 +14,10 @@ struct FlightsApp: App {
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView( RoutePlannerView(
service: service,
database: database, database: database,
loadService: loadService, client: routeExplorer,
favoritesManager: favoritesManager, loadService: loadService
routeExplorer: routeExplorer
) )
} }
} }
+108 -4
View File
@@ -160,16 +160,120 @@ struct RouteSearchResult: Sendable {
let appendix: RouteAppendix? let appendix: RouteAppendix?
} }
/// Sort options for results lists. All applied client-side after fetch
/// upstream is always told to sort by `departure_time` so we get a stable
/// base order, then we reorder in `RoutePlannerView` (or in
/// `filteredFlights` for the departures list).
enum RouteSortOption: String, CaseIterable, Sendable { enum RouteSortOption: String, CaseIterable, Sendable {
case departureTime = "departure_time" case departureEarliest
case duration = "duration" case departureLatest
case fewestStops
case mostStops
var label: String { var label: String {
switch self { switch self {
case .departureTime: return "Departure" case .departureEarliest: return "Departure Earliest"
case .duration: return "Duration" case .departureLatest: return "Departure Latest"
case .fewestStops: return "Fewest Stops"
case .mostStops: return "Most Stops"
} }
} }
/// String value the upstream API accepts. `nil` option is purely
/// client-side; the client falls back to `departure_time`.
var apiValue: String? {
switch self {
case .departureEarliest: return "departure_time"
default: return nil
}
}
/// Sort options shown in connection mode (TO is set).
static let connectionOptions: [RouteSortOption] = [
.departureEarliest, .departureLatest, .fewestStops, .mostStops
]
/// Sort options shown in "where can I go?" mode (TO is empty). All
/// results are direct, so the stop-count options aren't meaningful
/// keep just the two time-based options.
static let departureOptions: [RouteSortOption] = [
.departureEarliest, .departureLatest
]
}
// MARK: - Client-side sort comparators
extension RouteConnection {
/// First-leg departure time, used as a stable tiebreaker so equal-stop
/// connections still come out chronologically within their group.
var firstDeparture: Date {
flights.first?.departure.dateTime ?? .distantFuture
}
}
extension Array where Element == RouteConnection {
func sorted(by option: RouteSortOption) -> [RouteConnection] {
switch option {
case .departureEarliest:
return sorted { $0.firstDeparture < $1.firstDeparture }
case .departureLatest:
return sorted { $0.firstDeparture > $1.firstDeparture }
case .fewestStops:
return sorted {
if $0.stopCount != $1.stopCount {
return $0.stopCount < $1.stopCount
}
return $0.firstDeparture < $1.firstDeparture
}
case .mostStops:
return sorted {
if $0.stopCount != $1.stopCount {
return $0.stopCount > $1.stopCount
}
return $0.firstDeparture < $1.firstDeparture
}
}
}
}
extension Array where Element == RouteFlight {
/// Apply a sort to a flat list of legs (the where-can-I-go results
/// after window filtering). Stop-count options collapse to chronological
/// since departures are always single-leg.
func sorted(by option: RouteSortOption) -> [RouteFlight] {
switch option {
case .departureEarliest, .fewestStops, .mostStops:
return sorted { $0.departure.dateTime < $1.departure.dateTime }
case .departureLatest:
return sorted { $0.departure.dateTime > $1.departure.dateTime }
}
}
}
// MARK: - Sheet payload
/// Identifiable bundle of everything FlightLoadDetailView needs from a
/// RouteFlight tap. Use this as a single `@State` so `.sheet(item:)` sees
/// schedule + origin + destination + date atomically. Separate @State
/// properties race: setting `selectedFlight` non-nil materializes the sheet
/// before the other writes settle, and the sheet captures empty strings
/// which then hit the AA endpoint as `originAirportCode=&destinationAirportCode=`
/// and bounce as HTTP 400.
struct RouteLoadDetailRequest: Identifiable {
let id = UUID()
let schedule: FlightSchedule
let departureCode: String
let arrivalCode: String
let date: Date
}
/// Identifiable wrapper for presenting a multi-leg connection as a sheet.
/// Carries the connection itself plus the appendix (so the view can resolve
/// airline / equipment names and airport metadata).
struct ConnectionLoadRequest: Identifiable {
let id = UUID()
let connection: RouteConnection
let appendix: RouteAppendix?
} }
// MARK: - Bridge to existing FlightSchedule (for FlightLoadDetailView reuse) // MARK: - Bridge to existing FlightSchedule (for FlightLoadDetailView reuse)
+13
View File
@@ -0,0 +1,13 @@
import Foundation
/// Navigation destinations used by `DestinationsListView`.
///
/// Originally defined alongside `ContentView`, which is now removed.
/// `RoutePlannerView` is the home and uses sheet-based detail presentation
/// rather than `navigationDestination(for:)`, so the only remaining caller
/// is the orphan path inside DestinationsListView. The enum stays so that
/// view still compiles in case it gets re-introduced as a feature later.
enum SearchRoute: Hashable {
case destinations(Airport, Date, Bool)
case routeDetail(Airport, Airport, Date)
}
+29 -4
View File
@@ -302,7 +302,12 @@ actor AirlineLoadService {
URLQueryItem(name: "destinationAirportCode", value: destination.uppercased()) URLQueryItem(name: "destinationAirportCode", value: destination.uppercased())
] ]
guard let url = components?.url else { return nil } guard let url = components?.url else {
print("[AA] Invalid URL components")
return nil
}
print("[AA] GET \(url)")
do { do {
var request = URLRequest(url: url) var request = URLRequest(url: url)
@@ -314,10 +319,30 @@ actor AirlineLoadService {
request.setValue("fs", forHTTPHeaderField: "x-referrer") request.setValue("fs", forHTTPHeaderField: "x-referrer")
let (data, response) = try await session.data(for: request) let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil } let http = response as? HTTPURLResponse
print("[AA] HTTP status: \(http?.statusCode ?? -1), \(data.count) bytes")
if let bodyStr = String(data: data, encoding: .utf8) {
print("[AA] body (first 1000): \(bodyStr.prefix(1000))")
}
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], guard http?.statusCode == 200 else {
let waitListArray = json["waitList"] as? [[String: Any]] else { print("[AA] Non-200; giving up")
return nil
}
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
print("[AA] JSON parse failed")
return nil
}
print("[AA] top-level keys: \(json.keys.sorted())")
guard let waitListArray = json["waitList"] as? [[String: Any]] else {
// 200 OK but no `waitList` typical for AA Eagle 4-digit
// regional flights (marketed as AA but the mobile waitlist
// endpoint doesn't track them). The keys logged above will
// tell us if the response actually carries data under a
// different name worth parsing.
print("[AA] no 'waitList' in response")
return nil return nil
} }
+6 -2
View File
@@ -64,16 +64,20 @@ actor RouteExplorerClient {
date: Date, date: Date,
maxStops: Int = 1, maxStops: Int = 1,
includeInterline: Bool = false, includeInterline: Bool = false,
sortBy: RouteSortOption = .departureTime, sortBy: RouteSortOption = .departureEarliest,
limit: Int = 100 limit: Int = 100
) async throws -> RouteSearchResult { ) async throws -> RouteSearchResult {
let dateStr = dateFormatter.string(from: date) let dateStr = dateFormatter.string(from: date)
// All sorts apply client-side. Upstream is told to use
// `departure_time` so the result order is stable; RoutePlannerView
// reorders after the fetch returns.
let serverSort = sortBy.apiValue ?? "departure_time"
let payload: [String: Any] = [ let payload: [String: Any] = [
"departureAirportIata": origin.uppercased(), "departureAirportIata": origin.uppercased(),
"arrivalAirportIata": destination.uppercased(), "arrivalAirportIata": destination.uppercased(),
"departureDates": [dateStr], "departureDates": [dateStr],
"maxStops": maxStops, "maxStops": maxStops,
"sortBy": sortBy.rawValue, "sortBy": serverSort,
"includeInterline": includeInterline, "includeInterline": includeInterline,
"limit": limit, "limit": limit,
"includeAppendix": true "includeAppendix": true
+30 -6
View File
@@ -7,6 +7,7 @@ import SwiftUI
struct ConnectionRow: View { struct ConnectionRow: View {
let connection: RouteConnection let connection: RouteConnection
let appendix: RouteAppendix? let appendix: RouteAppendix?
let database: AirportDatabase
let onLegTap: (RouteFlight) -> Void let onLegTap: (RouteFlight) -> Void
var body: some View { var body: some View {
@@ -25,7 +26,7 @@ struct ConnectionRow: View {
Button { Button {
onLegTap(leg) onLegTap(leg)
} label: { } label: {
LegSummary(leg: leg, appendix: appendix) LegSummary(leg: leg, appendix: appendix, database: database)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
@@ -118,6 +119,7 @@ struct ConnectionRow: View {
private struct LegSummary: View { private struct LegSummary: View {
let leg: RouteFlight let leg: RouteFlight
let appendix: RouteAppendix? let appendix: RouteAppendix?
let database: AirportDatabase
private static let timeFormatter: DateFormatter = { private static let timeFormatter: DateFormatter = {
let f = DateFormatter() let f = DateFormatter()
@@ -126,40 +128,54 @@ private struct LegSummary: View {
}() }()
var body: some View { var body: some View {
HStack(alignment: .center, spacing: 10) { HStack(alignment: .top, spacing: 10) {
// Airline + flight number // Airline + flight number (fixed-width left column)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(leg.carrierIata) Text(leg.carrierIata)
.font(.caption.weight(.bold)) .font(.caption.weight(.bold))
.foregroundStyle(FlightTheme.textPrimary) .foregroundStyle(FlightTheme.textPrimary)
Text("\(leg.flightNumber)") // verbatim: prevents SwiftUI from rendering Int as "3,189".
Text(verbatim: "\(leg.flightNumber)")
.font(FlightTheme.flightNumber(11)) .font(FlightTheme.flightNumber(11))
.foregroundStyle(FlightTheme.textSecondary) .foregroundStyle(FlightTheme.textSecondary)
} }
.frame(width: 44, alignment: .leading) .frame(width: 44, alignment: .leading)
// Times + airports // Times + airports + names + aircraft
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
// Row A times and IATAs (compact)
HStack(spacing: 8) { HStack(spacing: 8) {
timeAirport(leg.departure) timeAirport(leg.departure)
Image(systemName: "arrow.right") Image(systemName: "arrow.right")
.font(.caption2) .font(.caption2)
.foregroundStyle(FlightTheme.textTertiary) .foregroundStyle(FlightTheme.textTertiary)
timeAirport(leg.arrival) timeAirport(leg.arrival)
Spacer(minLength: 0)
} }
// Row B full airport names, single line, middle-truncated
Text("\(airportName(for: leg.departure.airportIata))\(airportName(for: leg.arrival.airportIata))")
.font(.caption2)
.foregroundStyle(FlightTheme.textSecondary)
.lineLimit(1)
.truncationMode(.middle)
// Row C aircraft (if known), single line, tail-truncated
if let aircraft = aircraftLabel { if let aircraft = aircraftLabel {
Text(aircraft) Text(aircraft)
.font(.caption2) .font(.caption2)
.foregroundStyle(FlightTheme.textTertiary) .foregroundStyle(FlightTheme.textTertiary)
.lineLimit(1) .lineLimit(1)
.truncationMode(.tail)
} }
} }
Spacer() Spacer(minLength: 4)
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.caption2) .font(.caption2)
.foregroundStyle(FlightTheme.textTertiary) .foregroundStyle(FlightTheme.textTertiary)
.padding(.top, 4)
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 10) .padding(.vertical, 10)
@@ -183,4 +199,12 @@ private struct LegSummary: View {
guard let iata = leg.equipmentIata else { return nil } guard let iata = leg.equipmentIata else { return nil }
return appendix?.equipment(iata: iata)?.name ?? iata return appendix?.equipment(iata: iata)?.name ?? iata
} }
/// Bundled DB first (clean city names), then route-explorer appendix.
private func airportName(for iata: String) -> String {
if let m = database.airport(byIATA: iata) { return m.name }
if let n = appendix?.airport(iata: iata)?.cityName, !n.isEmpty { return n }
if let n = appendix?.airport(iata: iata)?.name, !n.isEmpty { return n }
return iata
}
} }
@@ -0,0 +1,470 @@
import SwiftUI
/// Presents load data for ALL legs of a multi-stop connection at once.
///
/// Each leg's `AirlineLoadService.fetchLoad(...)` runs in parallel inside a
/// TaskGroup so the slowest carrier doesn't block the others the user sees
/// the fastest leg's open/standby summary as soon as it lands. Per-leg
/// "Full details" buttons drill into the existing `FlightLoadDetailView`
/// for the upgrade/standby passenger lists.
struct ConnectionLoadDetailView: View {
let connection: RouteConnection
let appendix: RouteAppendix?
let database: AirportDatabase
let loadService: AirlineLoadService
@Environment(\.dismiss) private var dismiss
@State private var legStates: [LegLoadState]
@State private var drillDown: RouteLoadDetailRequest?
init(
connection: RouteConnection,
appendix: RouteAppendix?,
database: AirportDatabase,
loadService: AirlineLoadService
) {
self.connection = connection
self.appendix = appendix
self.database = database
self.loadService = loadService
self._legStates = State(initialValue: connection.flights.map { LegLoadState(leg: $0) })
}
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: FlightTheme.sectionSpacing) {
// Multi-leg only: stops + carriers + total duration. For
// a single-leg presentation (direct or Where-Can-I-Go),
// the leg card itself carries all the same info.
if connection.flights.count > 1 {
headerCard
}
ForEach(Array(legStates.enumerated()), id: \.element.id) { index, state in
if index > 0, let mins = layoverMinutes(at: index) {
layoverRow(minutes: mins, at: state.leg.departure.airportIata)
}
legCard(for: state)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.background(FlightTheme.background.ignoresSafeArea())
.navigationTitle(navTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button {
dismiss()
} label: {
Image(systemName: "xmark")
.font(.subheadline.weight(.semibold))
.foregroundStyle(FlightTheme.textSecondary)
}
}
}
.task {
await fetchAllLegs()
}
.sheet(item: $drillDown) { req in
FlightLoadDetailView(
schedule: req.schedule,
departureCode: req.departureCode,
arrivalCode: req.arrivalCode,
date: req.date,
loadService: loadService
)
}
}
}
// MARK: - Header card
private var headerCard: some View {
HStack(alignment: .top, spacing: 10) {
VStack(alignment: .leading, spacing: 4) {
Text(stopsLabel)
.font(.caption.weight(.semibold))
.foregroundStyle(FlightTheme.accent)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(FlightTheme.accent.opacity(0.12), in: Capsule())
Text(carriersLabel)
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
.lineLimit(2)
}
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 2) {
Text(formatDuration(connection.durationMinutes))
.font(.subheadline.weight(.bold))
.foregroundStyle(FlightTheme.textPrimary)
Text("total")
.font(.caption2)
.foregroundStyle(FlightTheme.textTertiary)
}
.fixedSize(horizontal: true, vertical: false)
}
.flightCard()
}
// MARK: - Per-leg card
private func legCard(for state: LegLoadState) -> some View {
VStack(alignment: .leading, spacing: 12) {
// Flight header
HStack(alignment: .top, spacing: 10) {
VStack(alignment: .leading, spacing: 2) {
Text(verbatim: "\(state.leg.carrierIata) \(state.leg.flightNumber)")
.font(.subheadline.weight(.bold))
.foregroundStyle(FlightTheme.textPrimary)
Text(airlineName(for: state.leg))
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
.lineLimit(1)
}
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 2) {
Text("\(timeFmt(state.leg.departure.dateTime))\(timeFmt(state.leg.arrival.dateTime))")
.font(.subheadline.weight(.semibold).monospaced())
.foregroundStyle(FlightTheme.textPrimary)
Text(formatDuration(state.leg.durationMinutes))
.font(.caption2)
.foregroundStyle(FlightTheme.textTertiary)
}
.fixedSize(horizontal: true, vertical: false)
}
// IATAs
HStack(spacing: 12) {
Text(state.leg.departure.airportIata)
.font(FlightTheme.airportCode(22))
.foregroundStyle(FlightTheme.textPrimary)
Image(systemName: "airplane")
.font(.footnote)
.foregroundStyle(FlightTheme.textTertiary)
.rotationEffect(.degrees(-45))
Text(state.leg.arrival.airportIata)
.font(FlightTheme.airportCode(22))
.foregroundStyle(FlightTheme.textPrimary)
Spacer(minLength: 0)
if let aircraft = aircraftLabel(for: state.leg) {
Text(aircraft)
.font(FlightTheme.label(11))
.foregroundStyle(FlightTheme.textSecondary)
.lineLimit(1)
.truncationMode(.tail)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color(.quaternarySystemFill), in: Capsule())
}
}
Text("\(airportName(for: state.leg.departure.airportIata))\(airportName(for: state.leg.arrival.airportIata))")
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
.lineLimit(1)
.truncationMode(.middle)
Divider()
// Load content (loading / data / unavailable)
loadContent(for: state)
// Drill into full details
Button {
drillDown = RouteLoadDetailRequest(
schedule: state.leg.toFlightSchedule(appendix: appendix, on: state.leg.departure.dateTime),
departureCode: state.leg.departure.airportIata,
arrivalCode: state.leg.arrival.airportIata,
date: state.leg.departure.dateTime
)
} label: {
HStack {
Text("Full details")
.font(.caption.weight(.semibold))
Spacer()
Image(systemName: "chevron.right").font(.caption2)
}
.foregroundStyle(FlightTheme.accent)
}
.buttonStyle(.plain)
}
.flightCard()
}
@ViewBuilder
private func loadContent(for state: LegLoadState) -> some View {
if state.isLoading {
HStack(spacing: 8) {
ProgressView().tint(FlightTheme.accent)
Text("Loading load data…")
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
Spacer()
}
.frame(minHeight: 44)
} else if let load = state.load {
loadSummary(load)
} else {
HStack(spacing: 8) {
Image(systemName: "info.circle")
.foregroundStyle(FlightTheme.textTertiary)
Text("Load data isn't available for this flight.")
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
Spacer()
}
.frame(minHeight: 44)
}
}
private func loadSummary(_ load: FlightLoad) -> some View {
let openSeats: Int
let standbyCount: Int
if load.hasCabinData {
openSeats = load.totalAvailable
standbyCount = load.totalStandbyFromPBTS
} else {
openSeats = load.seatAvailability.reduce(0) { $0 + $1.available }
standbyCount = load.standbyList.count
}
return VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 0) {
VStack(spacing: 2) {
Text(verbatim: "\(openSeats)")
.font(.system(size: 28, weight: .bold, design: .rounded))
.foregroundStyle(FlightTheme.onTime)
Text("Open")
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textSecondary)
}
.frame(maxWidth: .infinity)
Divider().frame(height: 36)
VStack(spacing: 2) {
Text(verbatim: "\(standbyCount)")
.font(.system(size: 28, weight: .bold, design: .rounded))
.foregroundStyle(standbyCount > openSeats ? FlightTheme.cancelled : FlightTheme.delayed)
Text("Standby")
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textSecondary)
}
.frame(maxWidth: .infinity)
}
if !load.cabins.isEmpty {
cabinPills(load.cabins)
} else if !load.seatAvailability.isEmpty {
seatPills(load.seatAvailability)
}
}
}
private func cabinPills(_ cabins: [CabinLoad]) -> some View {
FlowLayoutHStack(spacing: 6) {
ForEach(cabins) { cabin in
pill("\(cabinShort(cabin.name)) \(cabin.available)/\(cabin.capacity)")
}
}
}
private func seatPills(_ items: [SeatAvailability]) -> some View {
FlowLayoutHStack(spacing: 6) {
ForEach(items) { item in
pill("\(item.label): \(item.available)")
}
}
}
private func pill(_ text: String) -> some View {
Text(text)
.font(.caption2.monospaced())
.foregroundStyle(FlightTheme.textSecondary)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(FlightTheme.accent.opacity(0.10), in: Capsule())
}
// MARK: - Layover
private func layoverMinutes(at index: Int) -> Int? {
guard index >= 1, index < connection.flights.count else { return nil }
let arr = connection.flights[index - 1].arrival.dateTime
let dep = connection.flights[index].departure.dateTime
let mins = Int(dep.timeIntervalSince(arr) / 60)
return mins > 0 ? mins : nil
}
private func layoverRow(minutes: Int, at iata: String) -> some View {
HStack(spacing: 8) {
Image(systemName: "arrow.down")
.font(.caption2)
.foregroundStyle(FlightTheme.textTertiary)
Text("Layover at \(iata) · \(formatDuration(minutes))")
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
Spacer()
}
.padding(.leading, 24)
}
// MARK: - Fetching
private func fetchAllLegs() async {
await withTaskGroup(of: (Int, FlightLoad?).self) { group in
for (i, leg) in connection.flights.enumerated() {
let airlineCode = leg.carrierIata
let flightNumber = "\(leg.flightNumber)"
let date = leg.departure.dateTime
let origin = leg.departure.airportIata
let destination = leg.arrival.airportIata
let depTime = Self.timeFormatter.string(from: leg.departure.dateTime)
group.addTask { [loadService] in
let load = await loadService.fetchLoad(
airlineCode: airlineCode,
flightNumber: flightNumber,
date: date,
origin: origin,
destination: destination,
departureTime: depTime
)
return (i, load)
}
}
for await (i, load) in group {
guard i < legStates.count else { continue }
legStates[i].load = load
legStates[i].isLoading = false
}
}
}
// MARK: - Helpers
private static let timeFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "HH:mm"
return f
}()
private func timeFmt(_ d: Date) -> String { Self.timeFormatter.string(from: d) }
private var stopsLabel: String {
switch connection.stopCount {
case 0: return "Direct"
case 1: return "1-stop Connection"
default: return "\(connection.stopCount)-stop Connection"
}
}
/// Nav-bar title. Single legs get the route ("DFW SHV"); multi-stops
/// get the stops label so the user can tell at a glance.
private var navTitle: String {
if connection.flights.count == 1, let leg = connection.flights.first {
return "\(leg.departure.airportIata)\(leg.arrival.airportIata)"
}
return stopsLabel
}
private var carriersLabel: String {
let codes = connection.carrierIatas
if codes.count == 1, let app = appendix?.airline(iata: codes[0])?.name {
return app
}
let names = codes.map { appendix?.airline(iata: $0)?.name ?? $0 }
return names.joined(separator: " · ")
}
private func airlineName(for leg: RouteFlight) -> String {
appendix?.airline(iata: leg.carrierIata)?.name ?? leg.carrierIata
}
private func aircraftLabel(for leg: RouteFlight) -> String? {
guard let iata = leg.equipmentIata else { return nil }
return appendix?.equipment(iata: iata)?.name ?? iata
}
/// Bundled DB first (clean city names), then route-explorer appendix.
private func airportName(for iata: String) -> String {
if let m = database.airport(byIATA: iata) { return m.name }
if let n = appendix?.airport(iata: iata)?.cityName, !n.isEmpty { return n }
if let n = appendix?.airport(iata: iata)?.name, !n.isEmpty { return n }
return iata
}
/// Map a cabin name to a short fare-class letter for compact pills.
private func cabinShort(_ name: String) -> String {
let lower = name.lowercased()
if lower.contains("first") { return "F" }
if lower.contains("polaris") || lower.contains("business") { return "J" }
if lower.contains("premium") { return "W" }
if lower.contains("economy") || lower.contains("main") || lower.contains("rear") { return "Y" }
if lower.contains("front") { return "F" }
if lower.contains("middle") { return "J" }
return String(name.prefix(3)).uppercased()
}
private func formatDuration(_ minutes: Int) -> String {
let h = minutes / 60
let m = minutes % 60
if h > 0, m > 0 { return "\(h)h \(m)m" }
if h > 0 { return "\(h)h" }
return "\(m)m"
}
}
// MARK: - Per-leg load state
private struct LegLoadState: Identifiable {
let id: String
let leg: RouteFlight
var load: FlightLoad?
var isLoading: Bool
init(leg: RouteFlight) {
self.id = leg.id
self.leg = leg
self.load = nil
self.isLoading = true
}
}
// MARK: - Wrapping HStack for pills
/// Lightweight wrapping HStack so cabin pills flow onto multiple lines on
/// narrow widths instead of clipping or pushing past the card edge.
private struct FlowLayoutHStack<Content: View>: View {
let spacing: CGFloat
@ViewBuilder var content: () -> Content
init(spacing: CGFloat = 6, @ViewBuilder content: @escaping () -> Content) {
self.spacing = spacing
self.content = content
}
var body: some View {
// Use SwiftUI's iOS 16+ Layout via `ViewThatFits` over single-line and
// multi-line variants. For the small pill counts we have, a simple
// horizontal stack with wrapping is enough; if the pill row overflows
// we fall back to stacking each pill on its own row.
ViewThatFits(in: .horizontal) {
HStack(spacing: spacing) {
content()
Spacer(minLength: 0)
}
VStack(alignment: .leading, spacing: spacing) {
content()
}
}
}
}
-304
View File
@@ -1,304 +0,0 @@
import SwiftUI
enum SearchRoute: Hashable {
case destinations(Airport, Date, Bool)
case routeDetail(Airport, Airport, Date)
case routePlanner
case whereToGo
}
struct ContentView: View {
let service: FlightService
let database: AirportDatabase
let loadService: AirlineLoadService
let favoritesManager: FavoritesManager
let routeExplorer: RouteExplorerClient
@State private var viewModel: SearchViewModel
@State private var path = NavigationPath()
init(
service: FlightService,
database: AirportDatabase,
loadService: AirlineLoadService = AirlineLoadService(),
favoritesManager: FavoritesManager,
routeExplorer: RouteExplorerClient = RouteExplorerClient()
) {
self.service = service
self.database = database
self.loadService = loadService
self.favoritesManager = favoritesManager
self.routeExplorer = routeExplorer
self._viewModel = State(initialValue: SearchViewModel(service: service, database: database))
}
var body: some View {
NavigationStack(path: $path) {
ScrollView {
VStack(spacing: FlightTheme.sectionSpacing) {
// MARK: - Combined FROM / TO Card
VStack(alignment: .leading, spacing: 0) {
// FROM section
VStack(alignment: .leading, spacing: 8) {
Label {
Text("FROM")
.font(FlightTheme.label())
.foregroundStyle(.secondary)
.tracking(1)
} icon: {
Image(systemName: "airplane.departure")
.font(.caption)
.foregroundStyle(.secondary)
}
AirportSearchField(
label: "Departure Airport",
searchText: $viewModel.departureSearchText,
selectedAirport: $viewModel.departureAirport,
suggestions: viewModel.departureSuggestions,
countrySuggestions: viewModel.departureCountrySuggestions,
regionResult: viewModel.departureRegionResult,
isSearching: viewModel.isDepartureSearching,
service: service,
database: database,
onTextChanged: { viewModel.departureTextChanged() },
onSelect: { viewModel.selectDeparture($0) },
onClear: { viewModel.clearDeparture() }
)
}
.padding(FlightTheme.cardPadding)
Divider()
.padding(.horizontal, FlightTheme.cardPadding)
// TO section
VStack(alignment: .leading, spacing: 8) {
Label {
Text("TO (OPTIONAL)")
.font(FlightTheme.label())
.foregroundStyle(.secondary)
.tracking(1)
} icon: {
Image(systemName: "mappin.and.ellipse")
.font(.caption)
.foregroundStyle(.secondary)
}
AirportSearchField(
label: "Arrival Airport",
searchText: $viewModel.arrivalSearchText,
selectedAirport: $viewModel.arrivalAirport,
suggestions: viewModel.arrivalSuggestions,
countrySuggestions: viewModel.arrivalCountrySuggestions,
regionResult: viewModel.arrivalRegionResult,
isSearching: viewModel.isArrivalSearching,
service: service,
database: database,
onTextChanged: { viewModel.arrivalTextChanged() },
onSelect: { viewModel.selectArrival($0) },
onClear: { viewModel.clearArrival() }
)
}
.padding(FlightTheme.cardPadding)
}
.background(FlightTheme.cardBackground)
.clipShape(RoundedRectangle(cornerRadius: FlightTheme.cardCornerRadius))
.shadow(color: FlightTheme.cardShadow, radius: 8, y: 2)
// MARK: - Date Card
HStack(spacing: 10) {
Image(systemName: "calendar")
.foregroundStyle(FlightTheme.accent)
.font(.body)
DatePicker(
"Travel Date",
selection: $viewModel.selectedDate,
displayedComponents: .date
)
.labelsHidden()
.datePickerStyle(.compact)
.tint(FlightTheme.accent)
Spacer()
}
.flightCard()
// MARK: - Search Button
Button {
navigateToResults()
} label: {
Text("Search Flights")
.font(.body.weight(.bold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(
LinearGradient(
colors: [FlightTheme.accent, FlightTheme.accentLight],
startPoint: .leading,
endPoint: .trailing
)
)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.disabled(!viewModel.canSearch)
.opacity(viewModel.canSearch ? 1.0 : 0.5)
// MARK: - Multi-stop / Where-to-go entry points
VStack(spacing: 10) {
Button {
path.append(SearchRoute.routePlanner)
} label: {
HStack(spacing: 12) {
Image(systemName: "arrow.triangle.branch")
.font(.title3)
.foregroundStyle(FlightTheme.accent)
.frame(width: 36, height: 36)
.background(FlightTheme.accent.opacity(0.12), in: RoundedRectangle(cornerRadius: 10))
VStack(alignment: .leading, spacing: 2) {
Text("Find Connections")
.font(.subheadline.weight(.semibold))
.foregroundStyle(FlightTheme.textPrimary)
Text("Direct + multi-stop A→B routing")
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(FlightTheme.textTertiary)
}
.flightCard()
}
.buttonStyle(.plain)
Button {
path.append(SearchRoute.whereToGo)
} label: {
HStack(spacing: 12) {
Image(systemName: "questionmark.diamond")
.font(.title3)
.foregroundStyle(FlightTheme.accent)
.frame(width: 36, height: 36)
.background(FlightTheme.accent.opacity(0.12), in: RoundedRectangle(cornerRadius: 10))
VStack(alignment: .leading, spacing: 2) {
Text("Where can I go?")
.font(.subheadline.weight(.semibold))
.foregroundStyle(FlightTheme.textPrimary)
Text("All departures in the next few hours")
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(FlightTheme.textTertiary)
}
.flightCard()
}
.buttonStyle(.plain)
}
// 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)
.padding(.bottom, 32)
}
.background(FlightTheme.background.ignoresSafeArea())
.navigationTitle("Flights")
.navigationDestination(for: SearchRoute.self) { route in
switch route {
case let .destinations(airport, date, isArrival):
DestinationsListView(
airport: airport,
date: date,
service: service,
isArrival: isArrival,
loadService: loadService,
database: database,
favoritesManager: favoritesManager
)
case let .routeDetail(departure, arrival, date):
RouteDetailView(
departure: departure,
arrival: arrival,
date: date,
service: service,
loadService: loadService,
favoritesManager: favoritesManager
)
case .routePlanner:
RoutePlannerView(
database: database,
client: routeExplorer,
loadService: loadService
)
case .whereToGo:
WhereToGoView(
database: database,
client: routeExplorer,
loadService: loadService
)
}
}
}
}
// MARK: - Helpers
private func navigateToResults() {
let date = viewModel.selectedDate
if let departure = viewModel.departureAirport,
let arrival = viewModel.arrivalAirport {
path.append(SearchRoute.routeDetail(departure, arrival, date))
} else if let departure = viewModel.departureAirport {
path.append(SearchRoute.destinations(departure, date, false))
} else if let arrival = viewModel.arrivalAirport {
path.append(SearchRoute.destinations(arrival, date, true))
}
}
}
+9 -2
View File
@@ -126,11 +126,18 @@ struct FlightLoadDetailView: View {
// MARK: - Unsupported Airline // MARK: - Unsupported Airline
/// Shown when fetchLoad returns nil. That can be either:
/// - the airline is one we don't have a fetcher for (DL, WN, etc.), or
/// - the airline IS supported but the carrier's API has no data for
/// this specific flight (typical for regional codeshares AA Eagle
/// 4-digit flights, UA Express, etc.).
/// Without knowing which case we hit, the message stays flight-scoped
/// rather than blaming the whole airline.
private var unsupportedAirlineView: some View { private var unsupportedAirlineView: some View {
ContentUnavailableView { ContentUnavailableView {
Label("Not Available", systemImage: "info.circle") Label("Load Data Unavailable", systemImage: "info.circle")
} description: { } description: {
Text("Load data not available for \(schedule.airline.name).") Text("Load data isn't available for this flight on \(schedule.airline.name).")
} }
} }
+544 -143
View File
@@ -1,144 +1,287 @@
import SwiftUI import SwiftUI
/// Feature (a): pick origin + destination, find direct *and* multi-stop /// Home tab. One unified search.
/// itineraries via route-explorer.com `/route` with `maxStops`. ///
/// - With a destination set: route-explorer `/route` returns directs + 1/2-stop
/// connections; results render as `ConnectionRow`s.
/// - With destination blank: route-explorer `/departures` (maxStops:0) returns
/// every flight leaving the origin; results render as compact `DepartureLegRow`s
/// filtered by the chosen time window.
///
/// Either way, tapping a result opens `ConnectionLoadDetailView`, which fans
/// load fetches across each leg in parallel and offers per-leg drill-down to
/// `FlightLoadDetailView` for waitlists / passenger lists.
struct RoutePlannerView: View { struct RoutePlannerView: View {
let database: AirportDatabase let database: AirportDatabase
let client: RouteExplorerClient let client: RouteExplorerClient
let loadService: AirlineLoadService let loadService: AirlineLoadService
// MARK: - Inputs
@State private var origin: MapAirport? @State private var origin: MapAirport?
@State private var destination: MapAirport? @State private var destination: MapAirport?
@State private var date: Date = Date() @State private var date: Date = Date()
// Connection-mode controls (visible only when destination is set)
@State private var maxStops: Int = 1 @State private var maxStops: Int = 1
@State private var sortBy: RouteSortOption = .departureTime @State private var connectionSort: RouteSortOption = .departureEarliest
@State private var includeInterline: Bool = false @State private var includeInterline: Bool = false
// "Where can I go?" controls (visible only when destination is blank)
@State private var windowHours: Int = 6
@State private var referenceDate: Date = Date()
@State private var departureSort: RouteSortOption = .departureEarliest
// MARK: - Search state
@State private var isLoading: Bool = false @State private var isLoading: Bool = false
@State private var error: String? @State private var error: String?
@State private var connections: [RouteConnection] = [] @State private var connections: [RouteConnection] = []
@State private var appendix: RouteAppendix? @State private var appendix: RouteAppendix?
@State private var selectedFlight: FlightSchedule? @State private var pendingSheet: ConnectionLoadRequest?
@State private var selectedDepCode: String = ""
@State private var selectedArrCode: String = "" private var hasDestination: Bool { destination != nil }
@State private var selectedDate: Date = Date() private var canSearch: Bool { origin != nil }
// MARK: - Body
var body: some View { var body: some View {
ScrollView { NavigationStack {
VStack(alignment: .leading, spacing: FlightTheme.sectionSpacing) { ScrollView {
searchForm VStack(alignment: .leading, spacing: FlightTheme.sectionSpacing) {
resultsHeader airportsCard
resultsList dateCard
if hasDestination {
connectionControls
} else {
whereCanIGoControls
}
searchButton
sortBar
resultsHeader
resultsList
}
.padding(.horizontal)
.padding(.vertical, 12)
}
.background(FlightTheme.background.ignoresSafeArea())
.navigationTitle("Flights")
.sheet(item: $pendingSheet) { req in
ConnectionLoadDetailView(
connection: req.connection,
appendix: req.appendix,
database: database,
loadService: loadService
)
} }
.padding(.horizontal)
.padding(.vertical, 12)
} }
.background(FlightTheme.background.ignoresSafeArea()) }
.navigationTitle("Connections")
.navigationBarTitleDisplayMode(.inline) // MARK: - Airports + date
.sheet(item: $selectedFlight) { flight in
FlightLoadDetailView( private var airportsCard: some View {
schedule: flight, VStack(alignment: .leading, spacing: 0) {
departureCode: selectedDepCode, VStack(alignment: .leading, spacing: 12) {
arrivalCode: selectedArrCode, Label {
date: selectedDate, Text("FROM")
loadService: loadService .font(FlightTheme.label())
.foregroundStyle(.secondary)
.tracking(1)
} icon: {
Image(systemName: "airplane.departure")
.font(.caption)
.foregroundStyle(.secondary)
}
IATAAirportPicker(
label: "Origin (IATA or city)",
selection: $origin,
database: database
)
}
.padding(FlightTheme.cardPadding)
Divider().padding(.horizontal, FlightTheme.cardPadding)
VStack(alignment: .leading, spacing: 12) {
Label {
Text("TO (OPTIONAL)")
.font(FlightTheme.label())
.foregroundStyle(.secondary)
.tracking(1)
} icon: {
Image(systemName: "mappin.and.ellipse")
.font(.caption)
.foregroundStyle(.secondary)
}
IATAAirportPicker(
label: "Leave blank for \"where can I go?\"",
selection: $destination,
database: database
)
}
.padding(FlightTheme.cardPadding)
}
.background(FlightTheme.cardBackground)
.clipShape(RoundedRectangle(cornerRadius: FlightTheme.cardCornerRadius))
.shadow(color: FlightTheme.cardShadow, radius: 8, y: 2)
}
private var dateCard: some View {
HStack(spacing: 10) {
Image(systemName: "calendar")
.foregroundStyle(FlightTheme.accent)
.font(.body)
DatePicker(
hasDestination ? "Travel Date" : "Day to search",
selection: $date,
displayedComponents: .date
)
.labelsHidden()
.datePickerStyle(.compact)
.tint(FlightTheme.accent)
Spacer()
}
.flightCard()
}
// MARK: - Mode-specific controls
private var connectionControls: some View {
VStack(alignment: .leading, spacing: 10) {
Text("MAX STOPS")
.font(FlightTheme.label())
.foregroundStyle(.secondary)
.tracking(1)
Picker("Max stops", selection: $maxStops) {
Text("Direct").tag(0)
Text("1 stop").tag(1)
Text("2 stops").tag(2)
}
.pickerStyle(.segmented)
Toggle(isOn: $includeInterline) {
Text("Interline carriers only").font(.subheadline)
}
.padding(.top, 4)
.tint(FlightTheme.accent)
}
.flightCard()
}
private var whereCanIGoControls: some View {
VStack(alignment: .leading, spacing: 10) {
Text("DEPARTING WITHIN")
.font(FlightTheme.label())
.foregroundStyle(.secondary)
.tracking(1)
Picker("Window", selection: $windowHours) {
Text("2h").tag(2)
Text("4h").tag(4)
Text("6h").tag(6)
Text("12h").tag(12)
Text("24h").tag(24)
}
.pickerStyle(.segmented)
HStack(spacing: 10) {
Image(systemName: "clock")
.foregroundStyle(FlightTheme.accent)
.font(.body)
DatePicker(
"From",
selection: $referenceDate,
displayedComponents: [.date, .hourAndMinute]
)
.labelsHidden()
.datePickerStyle(.compact)
.tint(FlightTheme.accent)
Spacer()
Button("Now") {
referenceDate = Date()
}
.font(.caption.weight(.semibold))
.buttonStyle(.borderedProminent)
.tint(FlightTheme.accent.opacity(0.2))
.foregroundStyle(FlightTheme.accent)
}
.padding(.top, 6)
}
.flightCard()
}
// MARK: - Search button
private var searchButton: some View {
Button {
Task { await runSearch() }
} label: {
HStack {
if isLoading {
ProgressView().tint(.white)
} else {
Image(systemName: hasDestination ? "magnifyingglass" : "questionmark.diamond")
}
Text(searchButtonText).fontWeight(.bold)
}
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(
LinearGradient(
colors: [FlightTheme.accent, FlightTheme.accentLight],
startPoint: .leading,
endPoint: .trailing
)
)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.disabled(!canSearch || isLoading)
.opacity(canSearch && !isLoading ? 1.0 : 0.5)
}
private var searchButtonText: String {
if isLoading { return "Loading..." }
return hasDestination ? "Search Routes" : "Where can I go?"
}
// MARK: - Sort bar
/// SORT BY picker, slotted between the search button and the results.
/// Hidden until there's something to reorder so the empty home isn't
/// cluttered with a control that doesn't apply yet.
@ViewBuilder
private var sortBar: some View {
if hasDestination, !sortedConnections.isEmpty {
sortPicker(
options: RouteSortOption.connectionOptions,
selection: $connectionSort
)
} else if !hasDestination, !filteredFlights.isEmpty {
sortPicker(
options: RouteSortOption.departureOptions,
selection: $departureSort
) )
} }
} }
// MARK: - Search form private func sortPicker(
options: [RouteSortOption],
private var searchForm: some View { selection: Binding<RouteSortOption>
VStack(spacing: 12) { ) -> some View {
VStack(alignment: .leading, spacing: 12) { HStack(spacing: 8) {
Label { Text("SORT BY")
Text("FROM").font(FlightTheme.label()).tracking(1) .font(FlightTheme.label())
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} icon: { .tracking(1)
Image(systemName: "airplane.departure").font(.caption).foregroundStyle(.secondary) Spacer()
Picker("Sort by", selection: selection) {
ForEach(options, id: \.self) { option in
Text(option.label).tag(option)
} }
IATAAirportPicker(label: "Origin (IATA or city)", selection: $origin, database: database)
Label {
Text("TO").font(FlightTheme.label()).tracking(1).foregroundStyle(.secondary)
} icon: {
Image(systemName: "mappin.and.ellipse").font(.caption).foregroundStyle(.secondary)
}
IATAAirportPicker(label: "Destination (IATA or city)", selection: $destination, database: database)
} }
.flightCard() .pickerStyle(.menu)
.tint(FlightTheme.accent)
HStack(spacing: 10) {
Image(systemName: "calendar").foregroundStyle(FlightTheme.accent)
DatePicker("Travel Date", selection: $date, displayedComponents: .date)
.labelsHidden()
.datePickerStyle(.compact)
.tint(FlightTheme.accent)
Spacer()
}
.flightCard()
VStack(alignment: .leading, spacing: 10) {
Text("MAX STOPS")
.font(FlightTheme.label())
.foregroundStyle(.secondary)
.tracking(1)
Picker("Max stops", selection: $maxStops) {
Text("Direct").tag(0)
Text("1 stop").tag(1)
Text("2 stops").tag(2)
}
.pickerStyle(.segmented)
Text("SORT BY")
.font(FlightTheme.label())
.foregroundStyle(.secondary)
.tracking(1)
.padding(.top, 4)
Picker("Sort by", selection: $sortBy) {
ForEach(RouteSortOption.allCases, id: \.self) { option in
Text(option.label).tag(option)
}
}
.pickerStyle(.segmented)
Toggle(isOn: $includeInterline) {
Text("Interline carriers only")
.font(.subheadline)
}
.padding(.top, 4)
.tint(FlightTheme.accent)
}
.flightCard()
Button {
Task { await runSearch() }
} label: {
HStack {
if isLoading {
ProgressView().tint(.white)
} else {
Image(systemName: "magnifyingglass")
}
Text(isLoading ? "Searching..." : "Search Routes")
.fontWeight(.bold)
}
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(
LinearGradient(
colors: [FlightTheme.accent, FlightTheme.accentLight],
startPoint: .leading,
endPoint: .trailing
)
)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.disabled(!canSearch || isLoading)
.opacity(canSearch && !isLoading ? 1.0 : 0.5)
} }
} }
@@ -148,7 +291,7 @@ struct RoutePlannerView: View {
private var resultsHeader: some View { private var resultsHeader: some View {
if let error { if let error {
ContentUnavailableView { ContentUnavailableView {
Label("Error", systemImage: "exclamationmark.triangle") Label("No results", systemImage: "exclamationmark.triangle")
} description: { } description: {
Text(error) Text(error)
} actions: { } actions: {
@@ -158,62 +301,320 @@ struct RoutePlannerView: View {
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.tint(FlightTheme.accent) .tint(FlightTheme.accent)
} }
} else if !connections.isEmpty { } else if hasDestination, !sortedConnections.isEmpty {
HStack { HStack {
Text("\(connections.count) itinerar\(connections.count == 1 ? "y" : "ies")") Text("\(sortedConnections.count) itinerar\(sortedConnections.count == 1 ? "y" : "ies")")
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
.foregroundStyle(FlightTheme.textPrimary) .foregroundStyle(FlightTheme.textPrimary)
Spacer() Spacer()
let pastDropped = connections.count - sortedConnections.count
if pastDropped > 0 {
Text("\(pastDropped) already departed")
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
}
}
} else if !hasDestination, !filteredFlights.isEmpty {
HStack {
Text("\(filteredFlights.count) departure\(filteredFlights.count == 1 ? "" : "s")")
.font(.subheadline.weight(.semibold))
.foregroundStyle(FlightTheme.textPrimary)
Spacer()
Text("next \(windowHours)h")
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
} }
} }
} }
/// Connection-mode results: drop any connection whose first leg has
/// already departed (the API doesn't accept a from-time floor it
/// just returns the earliest 500 of the calendar day, which on a
/// same-day search is mostly already-past flights), then re-sort
/// per the user's pick.
private var sortedConnections: [RouteConnection] {
let now = Date()
return connections
.filter { $0.firstDeparture > now }
.sorted(by: connectionSort)
}
@ViewBuilder @ViewBuilder
private var resultsList: some View { private var resultsList: some View {
ForEach(connections) { connection in if hasDestination {
ConnectionRow(connection: connection, appendix: appendix) { leg in ForEach(sortedConnections) { connection in
openLegDetail(leg) ConnectionRow(
connection: connection,
appendix: appendix,
database: database
) { _ in
openConnection(connection)
}
}
} else {
ForEach(filteredFlights, id: \.id) { leg in
Button {
openSingleLeg(leg)
} label: {
DepartureLegRow(
leg: leg,
appendix: appendix,
database: database,
referenceDate: referenceDate
)
}
.buttonStyle(.plain)
} }
} }
} }
// MARK: - Helpers /// Where-can-I-go results: flatten connections (each is a single leg
/// since maxStops:0), filter to the chosen window, and apply the
private var canSearch: Bool { /// user's chosen sort.
origin != nil && destination != nil private var filteredFlights: [RouteFlight] {
let windowEnd = referenceDate.addingTimeInterval(TimeInterval(windowHours * 3600))
return connections
.flatMap { $0.flights }
.filter { leg in
let dep = leg.departure.dateTime
return dep >= referenceDate && dep <= windowEnd
}
.sorted(by: departureSort)
} }
// MARK: - Search action
private func runSearch() async { private func runSearch() async {
guard let origin, let destination else { return } guard let origin else { return }
isLoading = true isLoading = true
error = nil error = nil
connections = [] connections = []
appendix = nil
do { do {
let result = try await client.searchRoutes( if let destination {
from: origin.iata, // Connection mode /route
to: destination.iata, // Hubhub with maxStops:2 has hundreds of permutations
date: date, // (every connecting hub × every leg combination). Upstream
maxStops: maxStops, // returns them sorted earliest-first, so a small cap
includeInterline: includeInterline, // truncates everything past mid-morning. Pull a wide
sortBy: sortBy, // window and let the post-fetch filter trim it.
limit: 100 let result = try await client.searchRoutes(
) from: origin.iata,
self.connections = result.connections to: destination.iata,
self.appendix = result.appendix date: date,
if result.connections.isEmpty { maxStops: maxStops,
self.error = "No routes found from \(origin.iata) to \(destination.iata) on this date." includeInterline: includeInterline,
sortBy: connectionSort,
limit: 500
)
self.connections = result.connections
self.appendix = result.appendix
let now = Date()
let futureCount = result.connections.filter { $0.firstDeparture > now }.count
if result.connections.isEmpty {
self.error = "No routes found from \(origin.iata) to \(destination.iata) on this date."
} else if futureCount == 0 {
self.error = "All routes from \(origin.iata) to \(destination.iata) on this date have already departed."
}
} else {
// Where-can-I-go mode /departures, plus a follow-up call
// for the next calendar day if the window crosses midnight.
let windowEnd = referenceDate.addingTimeInterval(TimeInterval(windowHours * 3600))
var allConnections: [RouteConnection] = []
var capturedAppendix: RouteAppendix?
let day1 = try await client.searchDepartures(
from: origin.iata,
date: referenceDate,
maxStops: 0,
limit: 200
)
allConnections.append(contentsOf: day1.connections)
capturedAppendix = day1.appendix
let cal = Calendar.current
if !cal.isDate(referenceDate, inSameDayAs: windowEnd) {
let day2 = try await client.searchDepartures(
from: origin.iata,
date: windowEnd,
maxStops: 0,
limit: 200
)
allConnections.append(contentsOf: day2.connections)
if capturedAppendix == nil { capturedAppendix = day2.appendix }
}
self.connections = allConnections
self.appendix = capturedAppendix
if filteredFlights.isEmpty {
self.error = "Nothing leaving \(origin.iata) in the next \(windowHours)h."
}
} }
} catch { } catch {
self.error = (error as? RouteExplorerClient.ClientError)?.errorDescription ?? error.localizedDescription self.error = (error as? RouteExplorerClient.ClientError)?.errorDescription
?? error.localizedDescription
} }
isLoading = false isLoading = false
} }
private func openLegDetail(_ leg: RouteFlight) { // MARK: - Tap routing
selectedDepCode = leg.departure.airportIata
selectedArrCode = leg.arrival.airportIata /// Tap a connection (multi-stop or direct) present its full detail.
selectedDate = leg.departure.dateTime private func openConnection(_ connection: RouteConnection) {
selectedFlight = leg.toFlightSchedule(appendix: appendix, on: leg.departure.dateTime) pendingSheet = ConnectionLoadRequest(connection: connection, appendix: appendix)
}
/// Tap a single Where-can-I-go leg wrap it in a one-flight connection
/// so ConnectionLoadDetailView can render it the same way.
private func openSingleLeg(_ leg: RouteFlight) {
let single = RouteConnection(
durationMinutes: leg.durationMinutes,
score: 0,
flights: [leg]
)
pendingSheet = ConnectionLoadRequest(connection: single, appendix: appendix)
}
}
// MARK: - Departure leg row (Where-can-I-go mode results)
/// Compact card for a single departure in the Where-can-I-go results list.
/// IATA + airport name, time-of-day with a colored countdown, capacity pills.
/// Tapping opens the same `ConnectionLoadDetailView` as connection rows.
private struct DepartureLegRow: View {
let leg: RouteFlight
let appendix: RouteAppendix?
let database: AirportDatabase
let referenceDate: Date
private static let timeFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "HH:mm"
return f
}()
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 10) {
VStack(alignment: .leading, spacing: 2) {
// verbatim: prevents SwiftUI from running the Int through
// locale formatting and rendering "AA 6,380" with a comma.
Text(verbatim: "\(leg.carrierIata) \(leg.flightNumber)")
.font(.subheadline.weight(.bold))
.foregroundStyle(FlightTheme.textPrimary)
Text(airlineName)
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
.lineLimit(1)
.truncationMode(.tail)
}
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 2) {
Text(Self.timeFormatter.string(from: leg.departure.dateTime))
.font(.subheadline.weight(.semibold).monospaced())
.foregroundStyle(FlightTheme.textPrimary)
Text(leavesIn)
.font(.caption2)
.foregroundStyle(leavesInColor)
}
.fixedSize(horizontal: true, vertical: false)
}
HStack(spacing: 12) {
Text(leg.departure.airportIata)
.font(FlightTheme.airportCode(22))
.foregroundStyle(FlightTheme.textPrimary)
Image(systemName: "airplane")
.font(.footnote)
.foregroundStyle(FlightTheme.textTertiary)
.rotationEffect(.degrees(-45))
Text(leg.arrival.airportIata)
.font(FlightTheme.airportCode(22))
.foregroundStyle(FlightTheme.textPrimary)
Spacer(minLength: 0)
}
HStack(spacing: 8) {
Text("\(airportName(for: leg.departure.airportIata))\(airportName(for: leg.arrival.airportIata))")
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 8)
if let aircraft = aircraftLabel {
Text(aircraft)
.font(FlightTheme.label(11))
.foregroundStyle(FlightTheme.textSecondary)
.lineLimit(1)
.truncationMode(.tail)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color(.quaternarySystemFill), in: Capsule())
}
}
HStack(spacing: 8) {
if let total = leg.totalSeats {
metaPill("\(total) seats")
}
if let f = leg.classes?.first?.seats, f > 0 { metaPill("\(f)") }
if let j = leg.classes?.business?.seats, j > 0 { metaPill("\(j)") }
if let w = leg.classes?.premiumEconomy?.seats, w > 0 { metaPill("\(w)") }
if let y = leg.classes?.economy?.seats, y > 0 { metaPill("\(y)") }
Spacer()
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundStyle(FlightTheme.textTertiary)
}
}
.flightCard()
}
private func metaPill(_ text: String) -> some View {
Text(text)
.font(.caption2.monospaced())
.foregroundStyle(FlightTheme.textSecondary)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(FlightTheme.accent.opacity(0.10), in: Capsule())
}
private var airlineName: String {
appendix?.airline(iata: leg.carrierIata)?.name ?? leg.carrierIata
}
private var aircraftLabel: String? {
guard let iata = leg.equipmentIata else { return nil }
return appendix?.equipment(iata: iata)?.name ?? iata
}
/// Bundled DB first (clean city names), then route-explorer appendix.
private func airportName(for iata: String) -> String {
if let m = database.airport(byIATA: iata) { return m.name }
if let n = appendix?.airport(iata: iata)?.cityName, !n.isEmpty { return n }
if let n = appendix?.airport(iata: iata)?.name, !n.isEmpty { return n }
return iata
}
private var leavesIn: String {
let mins = Int(leg.departure.dateTime.timeIntervalSince(referenceDate) / 60)
if mins < 0 { return "departed" }
if mins < 60 { return "in \(mins)m" }
let h = mins / 60
let m = mins % 60
if m == 0 { return "in \(h)h" }
return "in \(h)h \(m)m"
}
private var leavesInColor: Color {
let mins = Int(leg.departure.dateTime.timeIntervalSince(referenceDate) / 60)
switch mins {
case ..<30: return FlightTheme.cancelled // hurry
case 30..<90: return FlightTheme.delayed // soon
default: return FlightTheme.textSecondary
}
} }
} }
-348
View File
@@ -1,348 +0,0 @@
import SwiftUI
/// Feature (b): "Where tf do I go" pick an airport and see all departures
/// in the next N hours, ranked by departure time.
struct WhereToGoView: View {
let database: AirportDatabase
let client: RouteExplorerClient
let loadService: AirlineLoadService
@State private var origin: MapAirport?
@State private var windowHours: Int = 6
@State private var referenceDate: Date = Date()
@State private var isLoading: Bool = false
@State private var error: String?
@State private var connections: [RouteConnection] = []
@State private var appendix: RouteAppendix?
@State private var selectedFlight: FlightSchedule?
@State private var selectedDepCode: String = ""
@State private var selectedArrCode: String = ""
@State private var selectedDate: Date = Date()
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: FlightTheme.sectionSpacing) {
pickerForm
resultsHeader
resultsList
}
.padding(.horizontal)
.padding(.vertical, 12)
}
.background(FlightTheme.background.ignoresSafeArea())
.navigationTitle("Where can I go?")
.navigationBarTitleDisplayMode(.inline)
.sheet(item: $selectedFlight) { flight in
FlightLoadDetailView(
schedule: flight,
departureCode: selectedDepCode,
arrivalCode: selectedArrCode,
date: selectedDate,
loadService: loadService
)
}
}
// MARK: - Picker form
private var pickerForm: some View {
VStack(spacing: 12) {
VStack(alignment: .leading, spacing: 12) {
Label {
Text("FROM").font(FlightTheme.label()).tracking(1)
.foregroundStyle(.secondary)
} icon: {
Image(systemName: "airplane.departure").font(.caption).foregroundStyle(.secondary)
}
IATAAirportPicker(label: "Airport (IATA or city)", selection: $origin, database: database)
}
.flightCard()
VStack(alignment: .leading, spacing: 10) {
Text("DEPARTING WITHIN")
.font(FlightTheme.label())
.foregroundStyle(.secondary)
.tracking(1)
Picker("Window", selection: $windowHours) {
Text("2h").tag(2)
Text("4h").tag(4)
Text("6h").tag(6)
Text("12h").tag(12)
Text("24h").tag(24)
}
.pickerStyle(.segmented)
HStack(spacing: 10) {
Image(systemName: "calendar")
.foregroundStyle(FlightTheme.accent)
.font(.body)
DatePicker("From", selection: $referenceDate, displayedComponents: [.date, .hourAndMinute])
.labelsHidden()
.datePickerStyle(.compact)
.tint(FlightTheme.accent)
Spacer()
Button("Now") {
referenceDate = Date()
}
.font(.caption.weight(.semibold))
.buttonStyle(.borderedProminent)
.tint(FlightTheme.accent.opacity(0.2))
.foregroundStyle(FlightTheme.accent)
}
.padding(.top, 6)
}
.flightCard()
Button {
Task { await runSearch() }
} label: {
HStack {
if isLoading {
ProgressView().tint(.white)
} else {
Image(systemName: "questionmark.diamond")
}
Text(isLoading ? "Loading..." : "Where can I go?")
.fontWeight(.bold)
}
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(
LinearGradient(
colors: [FlightTheme.accent, FlightTheme.accentLight],
startPoint: .leading,
endPoint: .trailing
)
)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.disabled(origin == nil || isLoading)
.opacity((origin != nil && !isLoading) ? 1.0 : 0.5)
}
}
// MARK: - Results
@ViewBuilder
private var resultsHeader: some View {
if let error {
ContentUnavailableView {
Label("Error", systemImage: "exclamationmark.triangle")
} description: {
Text(error)
} actions: {
Button("Retry") {
Task { await runSearch() }
}
.buttonStyle(.borderedProminent)
.tint(FlightTheme.accent)
}
} else if !filteredFlights.isEmpty {
HStack {
Text("\(filteredFlights.count) departure\(filteredFlights.count == 1 ? "" : "s")")
.font(.subheadline.weight(.semibold))
.foregroundStyle(FlightTheme.textPrimary)
Spacer()
Text(windowDescription)
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
}
}
}
@ViewBuilder
private var resultsList: some View {
ForEach(filteredFlights, id: \.id) { leg in
Button {
openLegDetail(leg)
} label: {
DepartureLegRow(leg: leg, appendix: appendix, referenceDate: referenceDate)
}
.buttonStyle(.plain)
}
}
// MARK: - Filtering
/// Flatten connections (each is a single leg here since we requested
/// /departures with maxStops:0) and filter by departure-time window.
private var filteredFlights: [RouteFlight] {
let windowEnd = referenceDate.addingTimeInterval(TimeInterval(windowHours * 3600))
let allLegs = connections.flatMap { $0.flights }
return allLegs
.filter { leg in
let dep = leg.departure.dateTime
return dep >= referenceDate && dep <= windowEnd
}
.sorted { $0.departure.dateTime < $1.departure.dateTime }
}
private var windowDescription: String {
"next \(windowHours)h"
}
private func runSearch() async {
guard let origin else { return }
isLoading = true
error = nil
connections = []
appendix = nil
do {
// /departures returns one connection per single-leg flight when
// maxStops:0. We pass the calendar date that includes our window;
// if the window crosses midnight we'll fall back to also fetching
// the next day in a follow-up call.
let windowEnd = referenceDate.addingTimeInterval(TimeInterval(windowHours * 3600))
var allConnections: [RouteConnection] = []
var capturedAppendix: RouteAppendix?
let day1 = try await client.searchDepartures(from: origin.iata, date: referenceDate, maxStops: 0, limit: 200)
allConnections.append(contentsOf: day1.connections)
capturedAppendix = day1.appendix
// Cross-midnight: fetch next day too.
let cal = Calendar.current
if !cal.isDate(referenceDate, inSameDayAs: windowEnd) {
let day2 = try await client.searchDepartures(from: origin.iata, date: windowEnd, maxStops: 0, limit: 200)
allConnections.append(contentsOf: day2.connections)
if capturedAppendix == nil { capturedAppendix = day2.appendix }
}
self.connections = allConnections
self.appendix = capturedAppendix
if filteredFlights.isEmpty {
self.error = "Nothing leaving \(origin.iata) in the next \(windowHours)h."
}
} catch {
self.error = (error as? RouteExplorerClient.ClientError)?.errorDescription ?? error.localizedDescription
}
isLoading = false
}
private func openLegDetail(_ leg: RouteFlight) {
selectedDepCode = leg.departure.airportIata
selectedArrCode = leg.arrival.airportIata
selectedDate = leg.departure.dateTime
selectedFlight = leg.toFlightSchedule(appendix: appendix, on: leg.departure.dateTime)
}
}
// MARK: - Departure leg row
private struct DepartureLegRow: View {
let leg: RouteFlight
let appendix: RouteAppendix?
let referenceDate: Date
private static let timeFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "HH:mm"
return f
}()
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .center, spacing: 10) {
VStack(alignment: .leading, spacing: 2) {
Text("\(leg.carrierIata) \(leg.flightNumber)")
.font(.subheadline.weight(.bold))
.foregroundStyle(FlightTheme.textPrimary)
Text(airlineName)
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
.lineLimit(1)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(Self.timeFormatter.string(from: leg.departure.dateTime))
.font(.subheadline.weight(.semibold).monospaced())
.foregroundStyle(FlightTheme.textPrimary)
Text(leavesIn)
.font(.caption2)
.foregroundStyle(leavesInColor)
}
}
HStack(spacing: 8) {
Text(leg.departure.airportIata)
.font(FlightTheme.airportCode(20))
Image(systemName: "airplane")
.font(.caption)
.foregroundStyle(FlightTheme.textTertiary)
.rotationEffect(.degrees(-45))
Text(leg.arrival.airportIata)
.font(FlightTheme.airportCode(20))
Spacer()
if let aircraft = aircraftLabel {
Text(aircraft)
.font(FlightTheme.label(11))
.foregroundStyle(FlightTheme.textSecondary)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color(.quaternarySystemFill), in: Capsule())
}
}
HStack(spacing: 8) {
if let total = leg.totalSeats {
metaPill("\(total) seats")
}
if let f = leg.classes?.first?.seats, f > 0 { metaPill("\(f)") }
if let j = leg.classes?.business?.seats, j > 0 { metaPill("\(j)") }
if let w = leg.classes?.premiumEconomy?.seats, w > 0 { metaPill("\(w)") }
if let y = leg.classes?.economy?.seats, y > 0 { metaPill("\(y)") }
Spacer()
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundStyle(FlightTheme.textTertiary)
}
}
.flightCard()
}
private func metaPill(_ text: String) -> some View {
Text(text)
.font(.caption2.monospaced())
.foregroundStyle(FlightTheme.textSecondary)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(FlightTheme.accent.opacity(0.10), in: Capsule())
}
private var airlineName: String {
appendix?.airline(iata: leg.carrierIata)?.name ?? leg.carrierIata
}
private var aircraftLabel: String? {
guard let iata = leg.equipmentIata else { return nil }
return appendix?.equipment(iata: iata)?.name ?? iata
}
private var leavesIn: String {
let mins = Int(leg.departure.dateTime.timeIntervalSince(referenceDate) / 60)
if mins < 0 { return "departed" }
if mins < 60 { return "in \(mins)m" }
let h = mins / 60
let m = mins % 60
if m == 0 { return "in \(h)h" }
return "in \(h)h \(m)m"
}
private var leavesInColor: Color {
let mins = Int(leg.departure.dateTime.timeIntervalSince(referenceDate) / 60)
switch mins {
case ..<30: return FlightTheme.cancelled // hurry
case 30..<90: return FlightTheme.delayed // soon
default: return FlightTheme.textSecondary
}
}
}