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>
This commit is contained in:
Trey t
2026-04-28 11:50:02 -05:00
parent df4a74726c
commit 0c4777216e
8 changed files with 650 additions and 860 deletions
+4 -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,10 +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 */; }; 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 */
@@ -62,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>"; };
@@ -85,10 +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>"; }; 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 */
@@ -105,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 */,
@@ -115,7 +112,6 @@
15676B4BD35745D1BD1DC947 /* AirportBrowserSheet.swift */, 15676B4BD35745D1BD1DC947 /* AirportBrowserSheet.swift */,
BB1100001111000011110006 /* FlightLoadDetailView.swift */, BB1100001111000011110006 /* FlightLoadDetailView.swift */,
RE3300003333000033330002 /* RoutePlannerView.swift */, RE3300003333000033330002 /* RoutePlannerView.swift */,
RE4400004444000044440002 /* WhereToGoView.swift */,
RE7700007777000077770002 /* ConnectionLoadDetailView.swift */, RE7700007777000077770002 /* ConnectionLoadDetailView.swift */,
AA5555555555555555555555 /* Styles */, AA5555555555555555555555 /* Styles */,
AA6666666666666666666666 /* Components */, AA6666666666666666666666 /* Components */,
@@ -208,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>";
@@ -286,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 */,
@@ -310,10 +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 */, 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
) )
} }
} }
+82 -4
View File
@@ -160,14 +160,92 @@ 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 }
} }
} }
} }
+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)
}
+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
-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))
}
}
}
+458 -59
View File
@@ -1,34 +1,64 @@
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?
/// Universal load-detail sheet. Both directs (1 leg) and multi-stop
/// connections route through ConnectionLoadDetailView so the user gets
/// the same per-leg card load summary, capacity pills, and a "Full
/// details" drill-down regardless of trip shape.
@State private var pendingSheet: ConnectionLoadRequest? @State private var pendingSheet: ConnectionLoadRequest?
private var hasDestination: Bool { destination != nil }
private var canSearch: Bool { origin != nil }
// MARK: - Body
var body: some View { var body: some View {
NavigationStack {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: FlightTheme.sectionSpacing) { VStack(alignment: .leading, spacing: FlightTheme.sectionSpacing) {
searchForm airportsCard
dateCard
if hasDestination {
connectionControls
} else {
whereCanIGoControls
}
searchButton
sortBar
resultsHeader resultsHeader
resultsList resultsList
} }
@@ -36,8 +66,7 @@ struct RoutePlannerView: View {
.padding(.vertical, 12) .padding(.vertical, 12)
} }
.background(FlightTheme.background.ignoresSafeArea()) .background(FlightTheme.background.ignoresSafeArea())
.navigationTitle("Connections") .navigationTitle("Flights")
.navigationBarTitleDisplayMode(.inline)
.sheet(item: $pendingSheet) { req in .sheet(item: $pendingSheet) { req in
ConnectionLoadDetailView( ConnectionLoadDetailView(
connection: req.connection, connection: req.connection,
@@ -47,39 +76,78 @@ struct RoutePlannerView: View {
) )
} }
} }
}
// MARK: - Search form // MARK: - Airports + date
private var searchForm: some View { private var airportsCard: some View {
VStack(spacing: 12) { VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Label { Label {
Text("FROM").font(FlightTheme.label()).tracking(1) Text("FROM")
.font(FlightTheme.label())
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.tracking(1)
} icon: { } icon: {
Image(systemName: "airplane.departure").font(.caption).foregroundStyle(.secondary) Image(systemName: "airplane.departure")
.font(.caption)
.foregroundStyle(.secondary)
} }
IATAAirportPicker(label: "Origin (IATA or city)", selection: $origin, database: database) IATAAirportPicker(
label: "Origin (IATA or city)",
selection: $origin,
database: database
)
}
.padding(FlightTheme.cardPadding)
Divider().padding(.horizontal, FlightTheme.cardPadding)
VStack(alignment: .leading, spacing: 12) {
Label { Label {
Text("TO").font(FlightTheme.label()).tracking(1).foregroundStyle(.secondary) Text("TO (OPTIONAL)")
.font(FlightTheme.label())
.foregroundStyle(.secondary)
.tracking(1)
} icon: { } icon: {
Image(systemName: "mappin.and.ellipse").font(.caption).foregroundStyle(.secondary) Image(systemName: "mappin.and.ellipse")
.font(.caption)
.foregroundStyle(.secondary)
} }
IATAAirportPicker(label: "Destination (IATA or city)", selection: $destination, database: database) 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)
} }
.flightCard()
private var dateCard: some View {
HStack(spacing: 10) { HStack(spacing: 10) {
Image(systemName: "calendar").foregroundStyle(FlightTheme.accent) Image(systemName: "calendar")
DatePicker("Travel Date", selection: $date, displayedComponents: .date) .foregroundStyle(FlightTheme.accent)
.font(.body)
DatePicker(
hasDestination ? "Travel Date" : "Day to search",
selection: $date,
displayedComponents: .date
)
.labelsHidden() .labelsHidden()
.datePickerStyle(.compact) .datePickerStyle(.compact)
.tint(FlightTheme.accent) .tint(FlightTheme.accent)
Spacer() Spacer()
} }
.flightCard() .flightCard()
}
// MARK: - Mode-specific controls
private var connectionControls: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Text("MAX STOPS") Text("MAX STOPS")
.font(FlightTheme.label()) .font(FlightTheme.label())
@@ -92,27 +160,59 @@ struct RoutePlannerView: View {
} }
.pickerStyle(.segmented) .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) { Toggle(isOn: $includeInterline) {
Text("Interline carriers only") Text("Interline carriers only").font(.subheadline)
.font(.subheadline)
} }
.padding(.top, 4) .padding(.top, 4)
.tint(FlightTheme.accent) .tint(FlightTheme.accent)
} }
.flightCard() .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 { Button {
Task { await runSearch() } Task { await runSearch() }
} label: { } label: {
@@ -120,10 +220,9 @@ struct RoutePlannerView: View {
if isLoading { if isLoading {
ProgressView().tint(.white) ProgressView().tint(.white)
} else { } else {
Image(systemName: "magnifyingglass") Image(systemName: hasDestination ? "magnifyingglass" : "questionmark.diamond")
} }
Text(isLoading ? "Searching..." : "Search Routes") Text(searchButtonText).fontWeight(.bold)
.fontWeight(.bold)
} }
.foregroundStyle(.white) .foregroundStyle(.white)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@@ -140,6 +239,50 @@ struct RoutePlannerView: View {
.disabled(!canSearch || isLoading) .disabled(!canSearch || isLoading)
.opacity(canSearch && !isLoading ? 1.0 : 0.5) .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
)
}
}
private func sortPicker(
options: [RouteSortOption],
selection: Binding<RouteSortOption>
) -> some View {
HStack(spacing: 8) {
Text("SORT BY")
.font(FlightTheme.label())
.foregroundStyle(.secondary)
.tracking(1)
Spacer()
Picker("Sort by", selection: selection) {
ForEach(options, id: \.self) { option in
Text(option.label).tag(option)
}
}
.pickerStyle(.menu)
.tint(FlightTheme.accent)
}
} }
// MARK: - Results // MARK: - Results
@@ -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,64 +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, database: database) { leg in ForEach(sortedConnections) { connection in
openLegDetail(leg, in: connection) 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 {
if let destination {
// Connection mode /route
// Hubhub with maxStops:2 has hundreds of permutations
// (every connecting hub × every leg combination). Upstream
// returns them sorted earliest-first, so a small cap
// truncates everything past mid-morning. Pull a wide
// window and let the post-fetch filter trim it.
let result = try await client.searchRoutes( let result = try await client.searchRoutes(
from: origin.iata, from: origin.iata,
to: destination.iata, to: destination.iata,
date: date, date: date,
maxStops: maxStops, maxStops: maxStops,
includeInterline: includeInterline, includeInterline: includeInterline,
sortBy: sortBy, sortBy: connectionSort,
limit: 100 limit: 500
) )
self.connections = result.connections self.connections = result.connections
self.appendix = result.appendix self.appendix = result.appendix
let now = Date()
let futureCount = result.connections.filter { $0.firstDeparture > now }.count
if result.connections.isEmpty { if result.connections.isEmpty {
self.error = "No routes found from \(origin.iata) to \(destination.iata) on this date." 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
} }
/// Single tap path for both directs and multi-stops. The unit of // MARK: - Tap routing
/// presentation is the whole connection; the tapped leg is incidental.
private func openLegDetail(_ leg: RouteFlight, in connection: RouteConnection) { /// Tap a connection (multi-stop or direct) present its full detail.
pendingSheet = ConnectionLoadRequest( private func openConnection(_ connection: RouteConnection) {
connection: connection, pendingSheet = ConnectionLoadRequest(connection: connection, appendix: appendix)
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
}
} }
} }
-392
View File
@@ -1,392 +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?
/// Universal load-detail sheet. We wrap the tapped leg in a single-leg
/// RouteConnection so `ConnectionLoadDetailView` can render it the same
/// way it renders multi-stop connections same card, same load
/// summary, same drill-down.
@State private var pendingDetail: ConnectionLoadRequest?
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: $pendingDetail) { req in
ConnectionLoadDetailView(
connection: req.connection,
appendix: req.appendix,
database: database,
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,
database: database,
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) {
// Wrap the single leg in a one-flight connection so we can reuse
// the same detail view that handles multi-stops.
let singleLeg = RouteConnection(
durationMinutes: leg.durationMinutes,
score: 0,
flights: [leg]
)
pendingDetail = ConnectionLoadRequest(
connection: singleLeg,
appendix: appendix
)
}
}
// MARK: - Departure leg row
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) {
// Row 1 flight + airline (left), departure time + countdown (right)
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)
}
// Row 2 big IATA codes only, plenty of room
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)
}
// Row 3 full airport names + aircraft on a single subtitle line
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())
}
}
// Row 4 capacity pills + chevron
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
}
/// Friendly airport name. Prefer the bundled airports.json it stores
/// short, clean city names ("Dallas-Fort Worth", "Tulsa", "Shreveport").
/// The route-explorer appendix's `name` field tends to duplicate the
/// city ("Dallas Dallas/Fort Worth Intl"), which truncates to garbage,
/// so it's the last resort.
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 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
}
}
}