Resolves the working tree that was sitting uncommitted on this machine when the JSX rewrite (77c59ce,c9992e2) landed on the gitea remote. - Adds favorites flow (FavoriteRoute model, FavoritesManager service, ContentView favorites strip with context-menu remove). - Adds FlightLoad model + FlightLoadDetailView sheet rendering cabin capacity, upgrade list, standby list, and seat-availability summary. - Adds WebViewFetcher (the generic WKWebView helper used by the load service for non-JSX flows). - Adds RouteMapView for destination map mode and threads it into DestinationsListView with a list/map toggle. - Adds AIRLINE_API_SPEC.md capturing the cross-airline load API surface. - Wires JSXWebViewFetcher.swift into the Flights target in project.pbxproj (file was added to the repo by the JSX rewrite commit but never registered with the Xcode target, so the build was broken on a fresh checkout). - Misc Airport/AirportDatabase/FlightsApp/FlightScheduleRow/ RouteDetailView tweaks that the rest of this WIP depends on. Build verified clean against the iOS Simulator destination. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
207 lines
7.3 KiB
Swift
207 lines
7.3 KiB
Swift
import SwiftUI
|
|
|
|
struct DestinationsListView: View {
|
|
enum ViewMode: String, CaseIterable {
|
|
case list, map
|
|
}
|
|
|
|
let airport: Airport
|
|
let date: Date
|
|
let service: FlightService
|
|
let isArrival: Bool
|
|
let loadService: AirlineLoadService
|
|
let database: AirportDatabase
|
|
let favoritesManager: FavoritesManager
|
|
|
|
@State private var viewModel: DestinationsViewModel
|
|
@State private var viewMode: ViewMode = .list
|
|
@State private var selectedMapRoute: SearchRoute?
|
|
|
|
init(airport: Airport, date: Date, service: FlightService, isArrival: Bool, loadService: AirlineLoadService, database: AirportDatabase, favoritesManager: FavoritesManager) {
|
|
self.airport = airport
|
|
self.date = date
|
|
self.service = service
|
|
self.isArrival = isArrival
|
|
self.loadService = loadService
|
|
self.database = database
|
|
self.favoritesManager = favoritesManager
|
|
self._viewModel = State(initialValue: DestinationsViewModel(service: service, date: date))
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
Picker("View", selection: $viewMode) {
|
|
Text("List").tag(ViewMode.list)
|
|
Text("Map").tag(ViewMode.map)
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.padding(.horizontal)
|
|
.padding(.vertical, 8)
|
|
|
|
Group {
|
|
if viewModel.isLoading {
|
|
ProgressView("Loading destinations...")
|
|
.foregroundStyle(.secondary)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else if let error = viewModel.error {
|
|
ContentUnavailableView {
|
|
Label("Error", systemImage: "exclamationmark.triangle")
|
|
} description: {
|
|
Text(error)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else if viewModel.filteredRoutes.isEmpty {
|
|
ContentUnavailableView(
|
|
"No Flights",
|
|
systemImage: "airplane.slash",
|
|
description: Text("No nonstop flights available in this month.")
|
|
)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else {
|
|
switch viewMode {
|
|
case .list:
|
|
ScrollView {
|
|
LazyVStack(spacing: 12) {
|
|
ForEach(viewModel.filteredRoutes) { route in
|
|
NavigationLink(value: searchRoute(for: route)) {
|
|
routeCard(route)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding(.horizontal)
|
|
.padding(.vertical, 12)
|
|
}
|
|
case .map:
|
|
RouteMapView(
|
|
origin: airport,
|
|
routes: viewModel.filteredRoutes,
|
|
date: date,
|
|
service: service,
|
|
database: database,
|
|
loadService: loadService,
|
|
onSelectRoute: { route in
|
|
selectedMapRoute = searchRoute(for: route)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.background(FlightTheme.background.ignoresSafeArea())
|
|
.navigationTitle(isArrival ? "To \(airport.iata)" : "From \(airport.iata)")
|
|
.navigationDestination(for: SearchRoute.self) { route in
|
|
switch route {
|
|
case let .routeDetail(departure, arrival, date):
|
|
RouteDetailView(
|
|
departure: departure,
|
|
arrival: arrival,
|
|
date: date,
|
|
service: service,
|
|
loadService: loadService,
|
|
favoritesManager: favoritesManager
|
|
)
|
|
default:
|
|
EmptyView()
|
|
}
|
|
}
|
|
.navigationDestination(item: $selectedMapRoute) { route in
|
|
switch route {
|
|
case let .routeDetail(departure, arrival, date):
|
|
RouteDetailView(
|
|
departure: departure,
|
|
arrival: arrival,
|
|
date: date,
|
|
service: service,
|
|
loadService: loadService,
|
|
favoritesManager: favoritesManager
|
|
)
|
|
default:
|
|
EmptyView()
|
|
}
|
|
}
|
|
.task {
|
|
let resolvedId = await resolveId(for: airport)
|
|
await viewModel.loadDestinations(airportId: resolvedId)
|
|
}
|
|
}
|
|
|
|
// MARK: - Route Card
|
|
|
|
private func routeCard(_ route: Route) -> some View {
|
|
HStack(spacing: 12) {
|
|
// Green dot + IATA code
|
|
HStack(spacing: 8) {
|
|
Circle()
|
|
.fill(FlightTheme.onTime)
|
|
.frame(width: 8, height: 8)
|
|
|
|
Text(route.destinationAirport.iata)
|
|
.font(FlightTheme.airportCode(22))
|
|
.foregroundStyle(.primary)
|
|
}
|
|
|
|
// City & country
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(route.destinationAirport.name)
|
|
.font(.headline)
|
|
.foregroundStyle(.primary)
|
|
.lineLimit(1)
|
|
|
|
if !route.destinationAirport.country.isEmpty {
|
|
Text(route.destinationAirport.country)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Duration & distance
|
|
VStack(alignment: .trailing, spacing: 2) {
|
|
Text(formattedDuration(route.durationMinutes))
|
|
.font(.subheadline.monospacedDigit())
|
|
.foregroundStyle(.secondary)
|
|
|
|
Text("\(route.distanceMiles) mi")
|
|
.font(.caption)
|
|
.foregroundStyle(FlightTheme.textTertiary)
|
|
}
|
|
}
|
|
.flightCard(padding: 14)
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func searchRoute(for route: Route) -> SearchRoute {
|
|
if isArrival {
|
|
return .routeDetail(route.destinationAirport, airport, date)
|
|
} else {
|
|
return .routeDetail(airport, route.destinationAirport, date)
|
|
}
|
|
}
|
|
|
|
private func formattedDuration(_ minutes: Int) -> String {
|
|
let hours = minutes / 60
|
|
let mins = minutes % 60
|
|
if hours > 0 && mins > 0 {
|
|
return "\(hours)h \(mins)m"
|
|
} else if hours > 0 {
|
|
return "\(hours)h"
|
|
} else {
|
|
return "\(mins)m"
|
|
}
|
|
}
|
|
|
|
private func resolveId(for airport: Airport) async -> String {
|
|
guard airport.id.isEmpty else { return airport.id }
|
|
do {
|
|
let results = try await service.searchAirports(term: airport.iata)
|
|
if let match = results.first(where: { $0.iata == airport.iata }) {
|
|
return match.id
|
|
}
|
|
} catch {}
|
|
return airport.id
|
|
}
|
|
}
|