df4a74726c
Single ConnectionLoadDetailView is now the universal detail screen for
both Find Connections (1+ legs) and Where Can I Go (single-leg). For
multi-stop connections it fetches each leg's load in parallel via
withTaskGroup so the slowest carrier doesn't block the rest. Each leg
card shows airline + flight + IATAs + airport names + aircraft + an
open/standby summary, with a "Full details" drill-down to
FlightLoadDetailView for waitlists/passenger lists.
Bug fixes along the way:
- Empty origin/destination in carrier API URLs (HTTP 400 from AA): the
4 separate @State vars feeding .sheet(item:) raced — sheet captured
empty strings before the other writes settled. Bundled into one
Identifiable RouteLoadDetailRequest / ConnectionLoadRequest so updates
are atomic.
- Flight numbers rendered with locale separators ("AA 6,380", "3,189").
Text("\(int)") resolves to the LocalizedStringKey initializer; switched
to Text(verbatim:).
- "Load data not available for {airline}" was misleading when the
airline IS supported but a specific flight has no data. Reworded to
flight-scoped copy.
- AA fetcher had no logging — added URL/status/body/keys diagnostics
matching the UA pattern.
UI cleanup:
- DepartureLegRow: big IATAs on their own row, full airport names on a
middle-truncated subtitle, aircraft pill single-line tail-truncated.
- LegSummary (ConnectionRow): airport-name subtitle line below
times+IATAs row.
- airportName priority: bundled airports.json first ("Dallas-Fort
Worth") over the route-explorer appendix ("Dallas Dallas/Fort Worth
Intl") which truncated to garbage.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
393 lines
15 KiB
Swift
393 lines
15 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|