Files
Flights/Flights/Views/DestinationsListView.swift
Trey T 847000d059 Land local WIP on top of JSX rewrite + wire JSXWebViewFetcher into target
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>
2026-04-11 11:55:15 -05:00

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
}
}