diff --git a/Flights.xcodeproj/project.pbxproj b/Flights.xcodeproj/project.pbxproj index 8da5740..b3740a4 100644 --- a/Flights.xcodeproj/project.pbxproj +++ b/Flights.xcodeproj/project.pbxproj @@ -14,7 +14,6 @@ 303821C9668A44F38FFA02CA /* AirportSearchField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD303E3EDCC4BF2BCF31722 /* AirportSearchField.swift */; }; 35D016EBA93C40BB873AB304 /* Airline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AC23D8748D42C9A7115FAC /* Airline.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 */; }; 6558A31ADEC740FC8C56EA22 /* FlightSchedule.swift in Sources */ = {isa = PBXBuildFile; fileRef = B913D04A4E51436595308A21 /* FlightSchedule.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 */; }; RE2200002222000022220001 /* RouteExplorerClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE2200002222000022220002 /* RouteExplorerClient.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 */; }; RE6600006666000066660001 /* ConnectionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE6600006666000066660002 /* ConnectionRow.swift */; }; RE7700007777000077770001 /* ConnectionLoadDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE7700007777000077770002 /* ConnectionLoadDetailView.swift */; }; + RE8800008888000088880001 /* SearchRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE8800008888000088880002 /* SearchRoute.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -62,7 +61,6 @@ 7C2EB471F011450DA7BBEFAD /* AirportMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirportMapView.swift; sourceTree = ""; }; 85EC89DEE12942B49DF51984 /* Airport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Airport.swift; sourceTree = ""; }; 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 = ""; }; 9A58C339D6084657B0538E9C /* AirportDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirportDatabase.swift; sourceTree = ""; }; 9BEAC0EBABFD41569FE69B1B /* DestinationsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestinationsViewModel.swift; sourceTree = ""; }; A65682BD902141BAA686D101 /* FlightService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlightService.swift; sourceTree = ""; }; @@ -85,10 +83,10 @@ RE1100001111000011110002 /* RouteExplorerModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteExplorerModels.swift; sourceTree = ""; }; RE2200002222000022220002 /* RouteExplorerClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteExplorerClient.swift; sourceTree = ""; }; RE3300003333000033330002 /* RoutePlannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutePlannerView.swift; sourceTree = ""; }; - RE4400004444000044440002 /* WhereToGoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhereToGoView.swift; sourceTree = ""; }; RE5500005555000055550002 /* IATAAirportPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IATAAirportPicker.swift; sourceTree = ""; }; RE6600006666000066660002 /* ConnectionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionRow.swift; sourceTree = ""; }; RE7700007777000077770002 /* ConnectionLoadDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionLoadDetailView.swift; sourceTree = ""; }; + RE8800008888000088880002 /* SearchRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRoute.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -105,7 +103,6 @@ 1B20C5393D8F432A93097C2C /* Views */ = { isa = PBXGroup; children = ( - 9934B0FCA757403A94AB963C /* ContentView.swift */, 0CD303E3EDCC4BF2BCF31722 /* AirportSearchField.swift */, 300153508F8445B6A78CEC52 /* DestinationsListView.swift */, 1C1176F877BF496ABF079040 /* RouteDetailView.swift */, @@ -115,7 +112,6 @@ 15676B4BD35745D1BD1DC947 /* AirportBrowserSheet.swift */, BB1100001111000011110006 /* FlightLoadDetailView.swift */, RE3300003333000033330002 /* RoutePlannerView.swift */, - RE4400004444000044440002 /* WhereToGoView.swift */, RE7700007777000077770002 /* ConnectionLoadDetailView.swift */, AA5555555555555555555555 /* Styles */, AA6666666666666666666666 /* Components */, @@ -208,6 +204,7 @@ E7987BD4832D44F1A0851933 /* Country.swift */, BB1100001111000011110002 /* FlightLoad.swift */, RE1100001111000011110002 /* RouteExplorerModels.swift */, + RE8800008888000088880002 /* SearchRoute.swift */, ); path = Models; sourceTree = ""; @@ -286,7 +283,6 @@ 7FFD9A2D25F9421D8929C027 /* SearchViewModel.swift in Sources */, C36C490556254AC88EC02C80 /* DestinationsViewModel.swift in Sources */, 9C1300E497B049FE8DA677E0 /* RouteDetailViewModel.swift in Sources */, - 57A463AB3CFD44DC93444E59 /* ContentView.swift in Sources */, 303821C9668A44F38FFA02CA /* AirportSearchField.swift in Sources */, D0EC717347974D668C77B9D2 /* DestinationsListView.swift in Sources */, BB3E647E4A07477F9F37E607 /* RouteDetailView.swift in Sources */, @@ -310,10 +306,10 @@ RE1100001111000011110001 /* RouteExplorerModels.swift in Sources */, RE2200002222000022220001 /* RouteExplorerClient.swift in Sources */, RE3300003333000033330001 /* RoutePlannerView.swift in Sources */, - RE4400004444000044440001 /* WhereToGoView.swift in Sources */, RE5500005555000055550001 /* IATAAirportPicker.swift in Sources */, RE6600006666000066660001 /* ConnectionRow.swift in Sources */, RE7700007777000077770001 /* ConnectionLoadDetailView.swift in Sources */, + RE8800008888000088880001 /* SearchRoute.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Flights/FlightsApp.swift b/Flights/FlightsApp.swift index 5698500..89be555 100644 --- a/Flights/FlightsApp.swift +++ b/Flights/FlightsApp.swift @@ -2,9 +2,7 @@ import SwiftUI @main struct FlightsApp: App { - let service = FlightService() let database: AirportDatabase - let favoritesManager = FavoritesManager() let loadService: AirlineLoadService let routeExplorer = RouteExplorerClient() @@ -16,12 +14,10 @@ struct FlightsApp: App { var body: some Scene { WindowGroup { - ContentView( - service: service, + RoutePlannerView( database: database, - loadService: loadService, - favoritesManager: favoritesManager, - routeExplorer: routeExplorer + client: routeExplorer, + loadService: loadService ) } } diff --git a/Flights/Models/RouteExplorerModels.swift b/Flights/Models/RouteExplorerModels.swift index 9dbff2f..94ffd11 100644 --- a/Flights/Models/RouteExplorerModels.swift +++ b/Flights/Models/RouteExplorerModels.swift @@ -160,14 +160,92 @@ struct RouteSearchResult: Sendable { 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 { - case departureTime = "departure_time" - case duration = "duration" + case departureEarliest + case departureLatest + case fewestStops + case mostStops var label: String { switch self { - case .departureTime: return "Departure" - case .duration: return "Duration" + case .departureEarliest: return "Departure Earliest" + 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 } } } } diff --git a/Flights/Models/SearchRoute.swift b/Flights/Models/SearchRoute.swift new file mode 100644 index 0000000..3799606 --- /dev/null +++ b/Flights/Models/SearchRoute.swift @@ -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) +} diff --git a/Flights/Services/RouteExplorerClient.swift b/Flights/Services/RouteExplorerClient.swift index b42c7fb..6c266dd 100644 --- a/Flights/Services/RouteExplorerClient.swift +++ b/Flights/Services/RouteExplorerClient.swift @@ -64,16 +64,20 @@ actor RouteExplorerClient { date: Date, maxStops: Int = 1, includeInterline: Bool = false, - sortBy: RouteSortOption = .departureTime, + sortBy: RouteSortOption = .departureEarliest, limit: Int = 100 ) async throws -> RouteSearchResult { 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] = [ "departureAirportIata": origin.uppercased(), "arrivalAirportIata": destination.uppercased(), "departureDates": [dateStr], "maxStops": maxStops, - "sortBy": sortBy.rawValue, + "sortBy": serverSort, "includeInterline": includeInterline, "limit": limit, "includeAppendix": true diff --git a/Flights/Views/ContentView.swift b/Flights/Views/ContentView.swift deleted file mode 100644 index f0924ee..0000000 --- a/Flights/Views/ContentView.swift +++ /dev/null @@ -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)) - } - } -} diff --git a/Flights/Views/RoutePlannerView.swift b/Flights/Views/RoutePlannerView.swift index e4c63a1..33cc62b 100644 --- a/Flights/Views/RoutePlannerView.swift +++ b/Flights/Views/RoutePlannerView.swift @@ -1,144 +1,287 @@ import SwiftUI -/// Feature (a): pick origin + destination, find direct *and* multi-stop -/// itineraries via route-explorer.com `/route` with `maxStops`. +/// Home tab. One unified search. +/// +/// - 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 { let database: AirportDatabase let client: RouteExplorerClient let loadService: AirlineLoadService + // MARK: - Inputs + @State private var origin: MapAirport? @State private var destination: MapAirport? @State private var date: Date = Date() + + // Connection-mode controls (visible only when destination is set) @State private var maxStops: Int = 1 - @State private var sortBy: RouteSortOption = .departureTime + @State private var connectionSort: RouteSortOption = .departureEarliest @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 error: String? @State private var connections: [RouteConnection] = [] @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? + private var hasDestination: Bool { destination != nil } + private var canSearch: Bool { origin != nil } + + // MARK: - Body + var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: FlightTheme.sectionSpacing) { - searchForm - resultsHeader - resultsList + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: FlightTheme.sectionSpacing) { + airportsCard + 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) - .sheet(item: $pendingSheet) { req in - ConnectionLoadDetailView( - connection: req.connection, - appendix: req.appendix, - database: database, - loadService: loadService + } + + // MARK: - Airports + date + + private var airportsCard: some View { + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 12) { + Label { + 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 var searchForm: 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) + private func sortPicker( + options: [RouteSortOption], + selection: Binding + ) -> 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) } - 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() - - 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) + .pickerStyle(.menu) + .tint(FlightTheme.accent) } } @@ -148,7 +291,7 @@ struct RoutePlannerView: View { private var resultsHeader: some View { if let error { ContentUnavailableView { - Label("Error", systemImage: "exclamationmark.triangle") + Label("No results", systemImage: "exclamationmark.triangle") } description: { Text(error) } actions: { @@ -158,64 +301,320 @@ struct RoutePlannerView: View { .buttonStyle(.borderedProminent) .tint(FlightTheme.accent) } - } else if !connections.isEmpty { + } else if hasDestination, !sortedConnections.isEmpty { HStack { - Text("\(connections.count) itinerar\(connections.count == 1 ? "y" : "ies")") + Text("\(sortedConnections.count) itinerar\(sortedConnections.count == 1 ? "y" : "ies")") .font(.subheadline.weight(.semibold)) .foregroundStyle(FlightTheme.textPrimary) 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 private var resultsList: some View { - ForEach(connections) { connection in - ConnectionRow(connection: connection, appendix: appendix, database: database) { leg in - openLegDetail(leg, in: connection) + if hasDestination { + ForEach(sortedConnections) { connection in + 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 - - private var canSearch: Bool { - origin != nil && destination != nil + /// Where-can-I-go results: flatten connections (each is a single leg + /// since maxStops:0), filter to the chosen window, and apply the + /// user's chosen sort. + 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 { - guard let origin, let destination else { return } + guard let origin else { return } isLoading = true error = nil connections = [] + appendix = nil do { - let result = try await client.searchRoutes( - from: origin.iata, - to: destination.iata, - date: date, - maxStops: maxStops, - includeInterline: includeInterline, - sortBy: sortBy, - limit: 100 - ) - self.connections = result.connections - self.appendix = result.appendix - if result.connections.isEmpty { - self.error = "No routes found from \(origin.iata) to \(destination.iata) on this date." + if let destination { + // Connection mode → /route + // Hub→hub 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( + from: origin.iata, + to: destination.iata, + date: date, + maxStops: maxStops, + 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 { - self.error = (error as? RouteExplorerClient.ClientError)?.errorDescription ?? error.localizedDescription + self.error = (error as? RouteExplorerClient.ClientError)?.errorDescription + ?? error.localizedDescription } isLoading = false } - /// Single tap path for both directs and multi-stops. The unit of - /// presentation is the whole connection; the tapped leg is incidental. - private func openLegDetail(_ leg: RouteFlight, in connection: RouteConnection) { - pendingSheet = ConnectionLoadRequest( - connection: connection, - appendix: appendix + // MARK: - Tap routing + + /// Tap a connection (multi-stop or direct) → present its full detail. + private func openConnection(_ connection: RouteConnection) { + pendingSheet = ConnectionLoadRequest(connection: connection, appendix: appendix) + } + + /// Tap a single Where-can-I-go leg → wrap it in a one-flight connection + /// so ConnectionLoadDetailView can render it the same way. + private func openSingleLeg(_ leg: RouteFlight) { + let single = RouteConnection( + durationMinutes: leg.durationMinutes, + score: 0, + flights: [leg] ) + pendingSheet = ConnectionLoadRequest(connection: single, appendix: appendix) + } +} + +// MARK: - Departure leg row (Where-can-I-go mode results) + +/// Compact card for a single departure in the Where-can-I-go results list. +/// IATA + airport name, time-of-day with a colored countdown, capacity pills. +/// Tapping opens the same `ConnectionLoadDetailView` as connection rows. +private struct DepartureLegRow: View { + let leg: RouteFlight + let appendix: RouteAppendix? + let database: AirportDatabase + let referenceDate: Date + + private static let timeFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "HH:mm" + return f + }() + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + // verbatim: prevents SwiftUI from running the Int through + // locale formatting and rendering "AA 6,380" with a comma. + Text(verbatim: "\(leg.carrierIata) \(leg.flightNumber)") + .font(.subheadline.weight(.bold)) + .foregroundStyle(FlightTheme.textPrimary) + Text(airlineName) + .font(.caption) + .foregroundStyle(FlightTheme.textSecondary) + .lineLimit(1) + .truncationMode(.tail) + } + + Spacer(minLength: 8) + + VStack(alignment: .trailing, spacing: 2) { + Text(Self.timeFormatter.string(from: leg.departure.dateTime)) + .font(.subheadline.weight(.semibold).monospaced()) + .foregroundStyle(FlightTheme.textPrimary) + Text(leavesIn) + .font(.caption2) + .foregroundStyle(leavesInColor) + } + .fixedSize(horizontal: true, vertical: false) + } + + HStack(spacing: 12) { + Text(leg.departure.airportIata) + .font(FlightTheme.airportCode(22)) + .foregroundStyle(FlightTheme.textPrimary) + Image(systemName: "airplane") + .font(.footnote) + .foregroundStyle(FlightTheme.textTertiary) + .rotationEffect(.degrees(-45)) + Text(leg.arrival.airportIata) + .font(FlightTheme.airportCode(22)) + .foregroundStyle(FlightTheme.textPrimary) + Spacer(minLength: 0) + } + + HStack(spacing: 8) { + Text("\(airportName(for: leg.departure.airportIata)) → \(airportName(for: leg.arrival.airportIata))") + .font(.caption) + .foregroundStyle(FlightTheme.textSecondary) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 8) + if let aircraft = aircraftLabel { + Text(aircraft) + .font(FlightTheme.label(11)) + .foregroundStyle(FlightTheme.textSecondary) + .lineLimit(1) + .truncationMode(.tail) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Color(.quaternarySystemFill), in: Capsule()) + } + } + + HStack(spacing: 8) { + if let total = leg.totalSeats { + metaPill("\(total) seats") + } + if let f = leg.classes?.first?.seats, f > 0 { metaPill("F·\(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 + } } } diff --git a/Flights/Views/WhereToGoView.swift b/Flights/Views/WhereToGoView.swift deleted file mode 100644 index 1ba87e6..0000000 --- a/Flights/Views/WhereToGoView.swift +++ /dev/null @@ -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 - } - } -}