Route Explorer: unified per-leg load card + multi-leg fan-out
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>
This commit is contained in:
@@ -45,6 +45,7 @@
|
||||
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 */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
@@ -87,6 +88,7 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -114,6 +116,7 @@
|
||||
BB1100001111000011110006 /* FlightLoadDetailView.swift */,
|
||||
RE3300003333000033330002 /* RoutePlannerView.swift */,
|
||||
RE4400004444000044440002 /* WhereToGoView.swift */,
|
||||
RE7700007777000077770002 /* ConnectionLoadDetailView.swift */,
|
||||
AA5555555555555555555555 /* Styles */,
|
||||
AA6666666666666666666666 /* Components */,
|
||||
);
|
||||
@@ -310,6 +313,7 @@
|
||||
RE4400004444000044440001 /* WhereToGoView.swift in Sources */,
|
||||
RE5500005555000055550001 /* IATAAirportPicker.swift in Sources */,
|
||||
RE6600006666000066660001 /* ConnectionRow.swift in Sources */,
|
||||
RE7700007777000077770001 /* ConnectionLoadDetailView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -172,6 +172,32 @@ enum RouteSortOption: String, CaseIterable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sheet payload
|
||||
|
||||
/// Identifiable bundle of everything FlightLoadDetailView needs from a
|
||||
/// RouteFlight tap. Use this as a single `@State` so `.sheet(item:)` sees
|
||||
/// schedule + origin + destination + date atomically. Separate @State
|
||||
/// properties race: setting `selectedFlight` non-nil materializes the sheet
|
||||
/// before the other writes settle, and the sheet captures empty strings —
|
||||
/// which then hit the AA endpoint as `originAirportCode=&destinationAirportCode=`
|
||||
/// and bounce as HTTP 400.
|
||||
struct RouteLoadDetailRequest: Identifiable {
|
||||
let id = UUID()
|
||||
let schedule: FlightSchedule
|
||||
let departureCode: String
|
||||
let arrivalCode: String
|
||||
let date: Date
|
||||
}
|
||||
|
||||
/// Identifiable wrapper for presenting a multi-leg connection as a sheet.
|
||||
/// Carries the connection itself plus the appendix (so the view can resolve
|
||||
/// airline / equipment names and airport metadata).
|
||||
struct ConnectionLoadRequest: Identifiable {
|
||||
let id = UUID()
|
||||
let connection: RouteConnection
|
||||
let appendix: RouteAppendix?
|
||||
}
|
||||
|
||||
// MARK: - Bridge to existing FlightSchedule (for FlightLoadDetailView reuse)
|
||||
|
||||
extension RouteFlight {
|
||||
|
||||
@@ -302,7 +302,12 @@ actor AirlineLoadService {
|
||||
URLQueryItem(name: "destinationAirportCode", value: destination.uppercased())
|
||||
]
|
||||
|
||||
guard let url = components?.url else { return nil }
|
||||
guard let url = components?.url else {
|
||||
print("[AA] Invalid URL components")
|
||||
return nil
|
||||
}
|
||||
|
||||
print("[AA] GET \(url)")
|
||||
|
||||
do {
|
||||
var request = URLRequest(url: url)
|
||||
@@ -314,10 +319,30 @@ actor AirlineLoadService {
|
||||
request.setValue("fs", forHTTPHeaderField: "x-referrer")
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
|
||||
let http = response as? HTTPURLResponse
|
||||
print("[AA] HTTP status: \(http?.statusCode ?? -1), \(data.count) bytes")
|
||||
if let bodyStr = String(data: data, encoding: .utf8) {
|
||||
print("[AA] body (first 1000): \(bodyStr.prefix(1000))")
|
||||
}
|
||||
|
||||
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let waitListArray = json["waitList"] as? [[String: Any]] else {
|
||||
guard http?.statusCode == 200 else {
|
||||
print("[AA] Non-200; giving up")
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
print("[AA] JSON parse failed")
|
||||
return nil
|
||||
}
|
||||
print("[AA] top-level keys: \(json.keys.sorted())")
|
||||
|
||||
guard let waitListArray = json["waitList"] as? [[String: Any]] else {
|
||||
// 200 OK but no `waitList` — typical for AA Eagle 4-digit
|
||||
// regional flights (marketed as AA but the mobile waitlist
|
||||
// endpoint doesn't track them). The keys logged above will
|
||||
// tell us if the response actually carries data under a
|
||||
// different name worth parsing.
|
||||
print("[AA] no 'waitList' in response")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import SwiftUI
|
||||
struct ConnectionRow: View {
|
||||
let connection: RouteConnection
|
||||
let appendix: RouteAppendix?
|
||||
let database: AirportDatabase
|
||||
let onLegTap: (RouteFlight) -> Void
|
||||
|
||||
var body: some View {
|
||||
@@ -25,7 +26,7 @@ struct ConnectionRow: View {
|
||||
Button {
|
||||
onLegTap(leg)
|
||||
} label: {
|
||||
LegSummary(leg: leg, appendix: appendix)
|
||||
LegSummary(leg: leg, appendix: appendix, database: database)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -118,6 +119,7 @@ struct ConnectionRow: View {
|
||||
private struct LegSummary: View {
|
||||
let leg: RouteFlight
|
||||
let appendix: RouteAppendix?
|
||||
let database: AirportDatabase
|
||||
|
||||
private static let timeFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
@@ -126,40 +128,54 @@ private struct LegSummary: View {
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
// Airline + flight number
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
// Airline + flight number (fixed-width left column)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(leg.carrierIata)
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(FlightTheme.textPrimary)
|
||||
Text("\(leg.flightNumber)")
|
||||
// verbatim: prevents SwiftUI from rendering Int as "3,189".
|
||||
Text(verbatim: "\(leg.flightNumber)")
|
||||
.font(FlightTheme.flightNumber(11))
|
||||
.foregroundStyle(FlightTheme.textSecondary)
|
||||
}
|
||||
.frame(width: 44, alignment: .leading)
|
||||
|
||||
// Times + airports
|
||||
// Times + airports + names + aircraft
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
// Row A — times and IATAs (compact)
|
||||
HStack(spacing: 8) {
|
||||
timeAirport(leg.departure)
|
||||
Image(systemName: "arrow.right")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
timeAirport(leg.arrival)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
|
||||
// Row B — full airport names, single line, middle-truncated
|
||||
Text("\(airportName(for: leg.departure.airportIata)) → \(airportName(for: leg.arrival.airportIata))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(FlightTheme.textSecondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
|
||||
// Row C — aircraft (if known), single line, tail-truncated
|
||||
if let aircraft = aircraftLabel {
|
||||
Text(aircraft)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
Spacer(minLength: 4)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
@@ -183,4 +199,12 @@ private struct LegSummary: View {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Presents load data for ALL legs of a multi-stop connection at once.
|
||||
///
|
||||
/// Each leg's `AirlineLoadService.fetchLoad(...)` runs in parallel inside a
|
||||
/// TaskGroup so the slowest carrier doesn't block the others — the user sees
|
||||
/// the fastest leg's open/standby summary as soon as it lands. Per-leg
|
||||
/// "Full details" buttons drill into the existing `FlightLoadDetailView`
|
||||
/// for the upgrade/standby passenger lists.
|
||||
struct ConnectionLoadDetailView: View {
|
||||
let connection: RouteConnection
|
||||
let appendix: RouteAppendix?
|
||||
let database: AirportDatabase
|
||||
let loadService: AirlineLoadService
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var legStates: [LegLoadState]
|
||||
@State private var drillDown: RouteLoadDetailRequest?
|
||||
|
||||
init(
|
||||
connection: RouteConnection,
|
||||
appendix: RouteAppendix?,
|
||||
database: AirportDatabase,
|
||||
loadService: AirlineLoadService
|
||||
) {
|
||||
self.connection = connection
|
||||
self.appendix = appendix
|
||||
self.database = database
|
||||
self.loadService = loadService
|
||||
self._legStates = State(initialValue: connection.flights.map { LegLoadState(leg: $0) })
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: FlightTheme.sectionSpacing) {
|
||||
// Multi-leg only: stops + carriers + total duration. For
|
||||
// a single-leg presentation (direct or Where-Can-I-Go),
|
||||
// the leg card itself carries all the same info.
|
||||
if connection.flights.count > 1 {
|
||||
headerCard
|
||||
}
|
||||
|
||||
ForEach(Array(legStates.enumerated()), id: \.element.id) { index, state in
|
||||
if index > 0, let mins = layoverMinutes(at: index) {
|
||||
layoverRow(minutes: mins, at: state.leg.departure.airportIata)
|
||||
}
|
||||
legCard(for: state)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.background(FlightTheme.background.ignoresSafeArea())
|
||||
.navigationTitle(navTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(FlightTheme.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await fetchAllLegs()
|
||||
}
|
||||
.sheet(item: $drillDown) { req in
|
||||
FlightLoadDetailView(
|
||||
schedule: req.schedule,
|
||||
departureCode: req.departureCode,
|
||||
arrivalCode: req.arrivalCode,
|
||||
date: req.date,
|
||||
loadService: loadService
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header card
|
||||
|
||||
private var headerCard: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(stopsLabel)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(FlightTheme.accent)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(FlightTheme.accent.opacity(0.12), in: Capsule())
|
||||
Text(carriersLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(FlightTheme.textSecondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(formatDuration(connection.durationMinutes))
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundStyle(FlightTheme.textPrimary)
|
||||
Text("total")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
}
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
.flightCard()
|
||||
}
|
||||
|
||||
// MARK: - Per-leg card
|
||||
|
||||
private func legCard(for state: LegLoadState) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
// Flight header
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(verbatim: "\(state.leg.carrierIata) \(state.leg.flightNumber)")
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundStyle(FlightTheme.textPrimary)
|
||||
Text(airlineName(for: state.leg))
|
||||
.font(.caption)
|
||||
.foregroundStyle(FlightTheme.textSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text("\(timeFmt(state.leg.departure.dateTime)) → \(timeFmt(state.leg.arrival.dateTime))")
|
||||
.font(.subheadline.weight(.semibold).monospaced())
|
||||
.foregroundStyle(FlightTheme.textPrimary)
|
||||
Text(formatDuration(state.leg.durationMinutes))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
}
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
|
||||
// IATAs
|
||||
HStack(spacing: 12) {
|
||||
Text(state.leg.departure.airportIata)
|
||||
.font(FlightTheme.airportCode(22))
|
||||
.foregroundStyle(FlightTheme.textPrimary)
|
||||
Image(systemName: "airplane")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
.rotationEffect(.degrees(-45))
|
||||
Text(state.leg.arrival.airportIata)
|
||||
.font(FlightTheme.airportCode(22))
|
||||
.foregroundStyle(FlightTheme.textPrimary)
|
||||
Spacer(minLength: 0)
|
||||
if let aircraft = aircraftLabel(for: state.leg) {
|
||||
Text(aircraft)
|
||||
.font(FlightTheme.label(11))
|
||||
.foregroundStyle(FlightTheme.textSecondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color(.quaternarySystemFill), in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
Text("\(airportName(for: state.leg.departure.airportIata)) → \(airportName(for: state.leg.arrival.airportIata))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(FlightTheme.textSecondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
|
||||
Divider()
|
||||
|
||||
// Load content (loading / data / unavailable)
|
||||
loadContent(for: state)
|
||||
|
||||
// Drill into full details
|
||||
Button {
|
||||
drillDown = RouteLoadDetailRequest(
|
||||
schedule: state.leg.toFlightSchedule(appendix: appendix, on: state.leg.departure.dateTime),
|
||||
departureCode: state.leg.departure.airportIata,
|
||||
arrivalCode: state.leg.arrival.airportIata,
|
||||
date: state.leg.departure.dateTime
|
||||
)
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Full details")
|
||||
.font(.caption.weight(.semibold))
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right").font(.caption2)
|
||||
}
|
||||
.foregroundStyle(FlightTheme.accent)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.flightCard()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func loadContent(for state: LegLoadState) -> some View {
|
||||
if state.isLoading {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView().tint(FlightTheme.accent)
|
||||
Text("Loading load data…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(FlightTheme.textSecondary)
|
||||
Spacer()
|
||||
}
|
||||
.frame(minHeight: 44)
|
||||
} else if let load = state.load {
|
||||
loadSummary(load)
|
||||
} else {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
Text("Load data isn't available for this flight.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(FlightTheme.textSecondary)
|
||||
Spacer()
|
||||
}
|
||||
.frame(minHeight: 44)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadSummary(_ load: FlightLoad) -> some View {
|
||||
let openSeats: Int
|
||||
let standbyCount: Int
|
||||
if load.hasCabinData {
|
||||
openSeats = load.totalAvailable
|
||||
standbyCount = load.totalStandbyFromPBTS
|
||||
} else {
|
||||
openSeats = load.seatAvailability.reduce(0) { $0 + $1.available }
|
||||
standbyCount = load.standbyList.count
|
||||
}
|
||||
|
||||
return VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 0) {
|
||||
VStack(spacing: 2) {
|
||||
Text(verbatim: "\(openSeats)")
|
||||
.font(.system(size: 28, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(FlightTheme.onTime)
|
||||
Text("Open")
|
||||
.font(FlightTheme.label())
|
||||
.foregroundStyle(FlightTheme.textSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
Divider().frame(height: 36)
|
||||
|
||||
VStack(spacing: 2) {
|
||||
Text(verbatim: "\(standbyCount)")
|
||||
.font(.system(size: 28, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(standbyCount > openSeats ? FlightTheme.cancelled : FlightTheme.delayed)
|
||||
Text("Standby")
|
||||
.font(FlightTheme.label())
|
||||
.foregroundStyle(FlightTheme.textSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
if !load.cabins.isEmpty {
|
||||
cabinPills(load.cabins)
|
||||
} else if !load.seatAvailability.isEmpty {
|
||||
seatPills(load.seatAvailability)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cabinPills(_ cabins: [CabinLoad]) -> some View {
|
||||
FlowLayoutHStack(spacing: 6) {
|
||||
ForEach(cabins) { cabin in
|
||||
pill("\(cabinShort(cabin.name)) \(cabin.available)/\(cabin.capacity)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func seatPills(_ items: [SeatAvailability]) -> some View {
|
||||
FlowLayoutHStack(spacing: 6) {
|
||||
ForEach(items) { item in
|
||||
pill("\(item.label): \(item.available)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func pill(_ 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())
|
||||
}
|
||||
|
||||
// MARK: - Layover
|
||||
|
||||
private func layoverMinutes(at index: Int) -> Int? {
|
||||
guard index >= 1, index < connection.flights.count else { return nil }
|
||||
let arr = connection.flights[index - 1].arrival.dateTime
|
||||
let dep = connection.flights[index].departure.dateTime
|
||||
let mins = Int(dep.timeIntervalSince(arr) / 60)
|
||||
return mins > 0 ? mins : nil
|
||||
}
|
||||
|
||||
private func layoverRow(minutes: Int, at iata: String) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "arrow.down")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
Text("Layover at \(iata) · \(formatDuration(minutes))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(FlightTheme.textSecondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.leading, 24)
|
||||
}
|
||||
|
||||
// MARK: - Fetching
|
||||
|
||||
private func fetchAllLegs() async {
|
||||
await withTaskGroup(of: (Int, FlightLoad?).self) { group in
|
||||
for (i, leg) in connection.flights.enumerated() {
|
||||
let airlineCode = leg.carrierIata
|
||||
let flightNumber = "\(leg.flightNumber)"
|
||||
let date = leg.departure.dateTime
|
||||
let origin = leg.departure.airportIata
|
||||
let destination = leg.arrival.airportIata
|
||||
let depTime = Self.timeFormatter.string(from: leg.departure.dateTime)
|
||||
|
||||
group.addTask { [loadService] in
|
||||
let load = await loadService.fetchLoad(
|
||||
airlineCode: airlineCode,
|
||||
flightNumber: flightNumber,
|
||||
date: date,
|
||||
origin: origin,
|
||||
destination: destination,
|
||||
departureTime: depTime
|
||||
)
|
||||
return (i, load)
|
||||
}
|
||||
}
|
||||
|
||||
for await (i, load) in group {
|
||||
guard i < legStates.count else { continue }
|
||||
legStates[i].load = load
|
||||
legStates[i].isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private static let timeFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "HH:mm"
|
||||
return f
|
||||
}()
|
||||
|
||||
private func timeFmt(_ d: Date) -> String { Self.timeFormatter.string(from: d) }
|
||||
|
||||
private var stopsLabel: String {
|
||||
switch connection.stopCount {
|
||||
case 0: return "Direct"
|
||||
case 1: return "1-stop Connection"
|
||||
default: return "\(connection.stopCount)-stop Connection"
|
||||
}
|
||||
}
|
||||
|
||||
/// Nav-bar title. Single legs get the route ("DFW → SHV"); multi-stops
|
||||
/// get the stops label so the user can tell at a glance.
|
||||
private var navTitle: String {
|
||||
if connection.flights.count == 1, let leg = connection.flights.first {
|
||||
return "\(leg.departure.airportIata) → \(leg.arrival.airportIata)"
|
||||
}
|
||||
return stopsLabel
|
||||
}
|
||||
|
||||
private var carriersLabel: String {
|
||||
let codes = connection.carrierIatas
|
||||
if codes.count == 1, let app = appendix?.airline(iata: codes[0])?.name {
|
||||
return app
|
||||
}
|
||||
let names = codes.map { appendix?.airline(iata: $0)?.name ?? $0 }
|
||||
return names.joined(separator: " · ")
|
||||
}
|
||||
|
||||
private func airlineName(for leg: RouteFlight) -> String {
|
||||
appendix?.airline(iata: leg.carrierIata)?.name ?? leg.carrierIata
|
||||
}
|
||||
|
||||
private func aircraftLabel(for leg: RouteFlight) -> 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
|
||||
}
|
||||
|
||||
/// Map a cabin name to a short fare-class letter for compact pills.
|
||||
private func cabinShort(_ name: String) -> String {
|
||||
let lower = name.lowercased()
|
||||
if lower.contains("first") { return "F" }
|
||||
if lower.contains("polaris") || lower.contains("business") { return "J" }
|
||||
if lower.contains("premium") { return "W" }
|
||||
if lower.contains("economy") || lower.contains("main") || lower.contains("rear") { return "Y" }
|
||||
if lower.contains("front") { return "F" }
|
||||
if lower.contains("middle") { return "J" }
|
||||
return String(name.prefix(3)).uppercased()
|
||||
}
|
||||
|
||||
private func formatDuration(_ minutes: Int) -> String {
|
||||
let h = minutes / 60
|
||||
let m = minutes % 60
|
||||
if h > 0, m > 0 { return "\(h)h \(m)m" }
|
||||
if h > 0 { return "\(h)h" }
|
||||
return "\(m)m"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Per-leg load state
|
||||
|
||||
private struct LegLoadState: Identifiable {
|
||||
let id: String
|
||||
let leg: RouteFlight
|
||||
var load: FlightLoad?
|
||||
var isLoading: Bool
|
||||
|
||||
init(leg: RouteFlight) {
|
||||
self.id = leg.id
|
||||
self.leg = leg
|
||||
self.load = nil
|
||||
self.isLoading = true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Wrapping HStack for pills
|
||||
|
||||
/// Lightweight wrapping HStack so cabin pills flow onto multiple lines on
|
||||
/// narrow widths instead of clipping or pushing past the card edge.
|
||||
private struct FlowLayoutHStack<Content: View>: View {
|
||||
let spacing: CGFloat
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
init(spacing: CGFloat = 6, @ViewBuilder content: @escaping () -> Content) {
|
||||
self.spacing = spacing
|
||||
self.content = content
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// Use SwiftUI's iOS 16+ Layout via `ViewThatFits` over single-line and
|
||||
// multi-line variants. For the small pill counts we have, a simple
|
||||
// horizontal stack with wrapping is enough; if the pill row overflows
|
||||
// we fall back to stacking each pill on its own row.
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack(spacing: spacing) {
|
||||
content()
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: spacing) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,11 +126,18 @@ struct FlightLoadDetailView: View {
|
||||
|
||||
// MARK: - Unsupported Airline
|
||||
|
||||
/// Shown when fetchLoad returns nil. That can be either:
|
||||
/// - the airline is one we don't have a fetcher for (DL, WN, etc.), or
|
||||
/// - the airline IS supported but the carrier's API has no data for
|
||||
/// this specific flight (typical for regional codeshares — AA Eagle
|
||||
/// 4-digit flights, UA Express, etc.).
|
||||
/// Without knowing which case we hit, the message stays flight-scoped
|
||||
/// rather than blaming the whole airline.
|
||||
private var unsupportedAirlineView: some View {
|
||||
ContentUnavailableView {
|
||||
Label("Not Available", systemImage: "info.circle")
|
||||
Label("Load Data Unavailable", systemImage: "info.circle")
|
||||
} description: {
|
||||
Text("Load data not available for \(schedule.airline.name).")
|
||||
Text("Load data isn't available for this flight on \(schedule.airline.name).")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,10 +19,11 @@ struct RoutePlannerView: View {
|
||||
@State private var connections: [RouteConnection] = []
|
||||
@State private var appendix: RouteAppendix?
|
||||
|
||||
@State private var selectedFlight: FlightSchedule?
|
||||
@State private var selectedDepCode: String = ""
|
||||
@State private var selectedArrCode: String = ""
|
||||
@State private var selectedDate: Date = Date()
|
||||
/// 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?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@@ -37,12 +38,11 @@ struct RoutePlannerView: View {
|
||||
.background(FlightTheme.background.ignoresSafeArea())
|
||||
.navigationTitle("Connections")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.sheet(item: $selectedFlight) { flight in
|
||||
FlightLoadDetailView(
|
||||
schedule: flight,
|
||||
departureCode: selectedDepCode,
|
||||
arrivalCode: selectedArrCode,
|
||||
date: selectedDate,
|
||||
.sheet(item: $pendingSheet) { req in
|
||||
ConnectionLoadDetailView(
|
||||
connection: req.connection,
|
||||
appendix: req.appendix,
|
||||
database: database,
|
||||
loadService: loadService
|
||||
)
|
||||
}
|
||||
@@ -171,8 +171,8 @@ struct RoutePlannerView: View {
|
||||
@ViewBuilder
|
||||
private var resultsList: some View {
|
||||
ForEach(connections) { connection in
|
||||
ConnectionRow(connection: connection, appendix: appendix) { leg in
|
||||
openLegDetail(leg)
|
||||
ConnectionRow(connection: connection, appendix: appendix, database: database) { leg in
|
||||
openLegDetail(leg, in: connection)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,10 +210,12 @@ struct RoutePlannerView: View {
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private func openLegDetail(_ leg: RouteFlight) {
|
||||
selectedDepCode = leg.departure.airportIata
|
||||
selectedArrCode = leg.arrival.airportIata
|
||||
selectedDate = leg.departure.dateTime
|
||||
selectedFlight = leg.toFlightSchedule(appendix: appendix, on: leg.departure.dateTime)
|
||||
/// 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,10 +16,11 @@ struct WhereToGoView: View {
|
||||
@State private var connections: [RouteConnection] = []
|
||||
@State private var appendix: RouteAppendix?
|
||||
|
||||
@State private var selectedFlight: FlightSchedule?
|
||||
@State private var selectedDepCode: String = ""
|
||||
@State private var selectedArrCode: String = ""
|
||||
@State private var selectedDate: Date = Date()
|
||||
/// 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 {
|
||||
@@ -34,12 +35,11 @@ struct WhereToGoView: View {
|
||||
.background(FlightTheme.background.ignoresSafeArea())
|
||||
.navigationTitle("Where can I go?")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.sheet(item: $selectedFlight) { flight in
|
||||
FlightLoadDetailView(
|
||||
schedule: flight,
|
||||
departureCode: selectedDepCode,
|
||||
arrivalCode: selectedArrCode,
|
||||
date: selectedDate,
|
||||
.sheet(item: $pendingDetail) { req in
|
||||
ConnectionLoadDetailView(
|
||||
connection: req.connection,
|
||||
appendix: req.appendix,
|
||||
database: database,
|
||||
loadService: loadService
|
||||
)
|
||||
}
|
||||
@@ -159,7 +159,12 @@ struct WhereToGoView: View {
|
||||
Button {
|
||||
openLegDetail(leg)
|
||||
} label: {
|
||||
DepartureLegRow(leg: leg, appendix: appendix, referenceDate: referenceDate)
|
||||
DepartureLegRow(
|
||||
leg: leg,
|
||||
appendix: appendix,
|
||||
database: database,
|
||||
referenceDate: referenceDate
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -225,10 +230,17 @@ struct WhereToGoView: View {
|
||||
}
|
||||
|
||||
private func openLegDetail(_ leg: RouteFlight) {
|
||||
selectedDepCode = leg.departure.airportIata
|
||||
selectedArrCode = leg.arrival.airportIata
|
||||
selectedDate = leg.departure.dateTime
|
||||
selectedFlight = leg.toFlightSchedule(appendix: appendix, on: leg.departure.dateTime)
|
||||
// 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,6 +249,7 @@ struct WhereToGoView: View {
|
||||
private struct DepartureLegRow: View {
|
||||
let leg: RouteFlight
|
||||
let appendix: RouteAppendix?
|
||||
let database: AirportDatabase
|
||||
let referenceDate: Date
|
||||
|
||||
private static let timeFormatter: DateFormatter = {
|
||||
@@ -247,18 +260,22 @@ private struct DepartureLegRow: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
// Row 1 — flight + airline (left), departure time + countdown (right)
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("\(leg.carrierIata) \(leg.flightNumber)")
|
||||
// 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()
|
||||
Spacer(minLength: 8)
|
||||
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(Self.timeFormatter.string(from: leg.departure.dateTime))
|
||||
@@ -268,30 +285,45 @@ private struct DepartureLegRow: View {
|
||||
.font(.caption2)
|
||||
.foregroundStyle(leavesInColor)
|
||||
}
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
// Row 2 — big IATA codes only, plenty of room
|
||||
HStack(spacing: 12) {
|
||||
Text(leg.departure.airportIata)
|
||||
.font(FlightTheme.airportCode(20))
|
||||
.font(FlightTheme.airportCode(22))
|
||||
.foregroundStyle(FlightTheme.textPrimary)
|
||||
Image(systemName: "airplane")
|
||||
.font(.caption)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
.rotationEffect(.degrees(-45))
|
||||
Text(leg.arrival.airportIata)
|
||||
.font(FlightTheme.airportCode(20))
|
||||
|
||||
Spacer()
|
||||
.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")
|
||||
@@ -322,6 +354,18 @@ private struct DepartureLegRow: View {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user