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:
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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?
|
||||||
|
|
||||||
/// 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 {
|
||||||
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: $pendingSheet) { req in
|
|
||||||
ConnectionLoadDetailView(
|
private var airportsCard: some View {
|
||||||
connection: req.connection,
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
appendix: req.appendix,
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
database: database,
|
Label {
|
||||||
loadService: loadService
|
Text("FROM")
|
||||||
|
.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,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 {
|
||||||
let result = try await client.searchRoutes(
|
if let destination {
|
||||||
from: origin.iata,
|
// Connection mode → /route
|
||||||
to: destination.iata,
|
// Hub→hub 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
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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·\(f)") }
|
||||||
|
if let j = leg.classes?.business?.seats, j > 0 { metaPill("J·\(j)") }
|
||||||
|
if let w = leg.classes?.premiumEconomy?.seats, w > 0 { metaPill("W·\(w)") }
|
||||||
|
if let y = leg.classes?.economy?.seats, y > 0 { metaPill("Y·\(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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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·\(f)") }
|
|
||||||
if let j = leg.classes?.business?.seats, j > 0 { metaPill("J·\(j)") }
|
|
||||||
if let w = leg.classes?.premiumEconomy?.seats, w > 0 { metaPill("W·\(w)") }
|
|
||||||
if let y = leg.classes?.economy?.seats, y > 0 { metaPill("Y·\(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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user