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 */; };
|
RE4400004444000044440001 /* WhereToGoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE4400004444000044440002 /* WhereToGoView.swift */; };
|
||||||
RE5500005555000055550001 /* IATAAirportPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE5500005555000055550002 /* IATAAirportPicker.swift */; };
|
RE5500005555000055550001 /* IATAAirportPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE5500005555000055550002 /* IATAAirportPicker.swift */; };
|
||||||
RE6600006666000066660001 /* ConnectionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE6600006666000066660002 /* ConnectionRow.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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
@@ -87,6 +88,7 @@
|
|||||||
RE4400004444000044440002 /* WhereToGoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhereToGoView.swift; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -114,6 +116,7 @@
|
|||||||
BB1100001111000011110006 /* FlightLoadDetailView.swift */,
|
BB1100001111000011110006 /* FlightLoadDetailView.swift */,
|
||||||
RE3300003333000033330002 /* RoutePlannerView.swift */,
|
RE3300003333000033330002 /* RoutePlannerView.swift */,
|
||||||
RE4400004444000044440002 /* WhereToGoView.swift */,
|
RE4400004444000044440002 /* WhereToGoView.swift */,
|
||||||
|
RE7700007777000077770002 /* ConnectionLoadDetailView.swift */,
|
||||||
AA5555555555555555555555 /* Styles */,
|
AA5555555555555555555555 /* Styles */,
|
||||||
AA6666666666666666666666 /* Components */,
|
AA6666666666666666666666 /* Components */,
|
||||||
);
|
);
|
||||||
@@ -310,6 +313,7 @@
|
|||||||
RE4400004444000044440001 /* WhereToGoView.swift in Sources */,
|
RE4400004444000044440001 /* WhereToGoView.swift in Sources */,
|
||||||
RE5500005555000055550001 /* IATAAirportPicker.swift in Sources */,
|
RE5500005555000055550001 /* IATAAirportPicker.swift in Sources */,
|
||||||
RE6600006666000066660001 /* ConnectionRow.swift in Sources */,
|
RE6600006666000066660001 /* ConnectionRow.swift in Sources */,
|
||||||
|
RE7700007777000077770001 /* ConnectionLoadDetailView.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
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)
|
// MARK: - Bridge to existing FlightSchedule (for FlightLoadDetailView reuse)
|
||||||
|
|
||||||
extension RouteFlight {
|
extension RouteFlight {
|
||||||
|
|||||||
@@ -302,7 +302,12 @@ actor AirlineLoadService {
|
|||||||
URLQueryItem(name: "destinationAirportCode", value: destination.uppercased())
|
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 {
|
do {
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
@@ -314,10 +319,30 @@ actor AirlineLoadService {
|
|||||||
request.setValue("fs", forHTTPHeaderField: "x-referrer")
|
request.setValue("fs", forHTTPHeaderField: "x-referrer")
|
||||||
|
|
||||||
let (data, response) = try await session.data(for: request)
|
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],
|
guard http?.statusCode == 200 else {
|
||||||
let waitListArray = json["waitList"] as? [[String: Any]] 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import SwiftUI
|
|||||||
struct ConnectionRow: View {
|
struct ConnectionRow: View {
|
||||||
let connection: RouteConnection
|
let connection: RouteConnection
|
||||||
let appendix: RouteAppendix?
|
let appendix: RouteAppendix?
|
||||||
|
let database: AirportDatabase
|
||||||
let onLegTap: (RouteFlight) -> Void
|
let onLegTap: (RouteFlight) -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -25,7 +26,7 @@ struct ConnectionRow: View {
|
|||||||
Button {
|
Button {
|
||||||
onLegTap(leg)
|
onLegTap(leg)
|
||||||
} label: {
|
} label: {
|
||||||
LegSummary(leg: leg, appendix: appendix)
|
LegSummary(leg: leg, appendix: appendix, database: database)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
@@ -118,6 +119,7 @@ struct ConnectionRow: View {
|
|||||||
private struct LegSummary: View {
|
private struct LegSummary: View {
|
||||||
let leg: RouteFlight
|
let leg: RouteFlight
|
||||||
let appendix: RouteAppendix?
|
let appendix: RouteAppendix?
|
||||||
|
let database: AirportDatabase
|
||||||
|
|
||||||
private static let timeFormatter: DateFormatter = {
|
private static let timeFormatter: DateFormatter = {
|
||||||
let f = DateFormatter()
|
let f = DateFormatter()
|
||||||
@@ -126,40 +128,54 @@ private struct LegSummary: View {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .center, spacing: 10) {
|
HStack(alignment: .top, spacing: 10) {
|
||||||
// Airline + flight number
|
// Airline + flight number (fixed-width left column)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(leg.carrierIata)
|
Text(leg.carrierIata)
|
||||||
.font(.caption.weight(.bold))
|
.font(.caption.weight(.bold))
|
||||||
.foregroundStyle(FlightTheme.textPrimary)
|
.foregroundStyle(FlightTheme.textPrimary)
|
||||||
Text("\(leg.flightNumber)")
|
// verbatim: prevents SwiftUI from rendering Int as "3,189".
|
||||||
|
Text(verbatim: "\(leg.flightNumber)")
|
||||||
.font(FlightTheme.flightNumber(11))
|
.font(FlightTheme.flightNumber(11))
|
||||||
.foregroundStyle(FlightTheme.textSecondary)
|
.foregroundStyle(FlightTheme.textSecondary)
|
||||||
}
|
}
|
||||||
.frame(width: 44, alignment: .leading)
|
.frame(width: 44, alignment: .leading)
|
||||||
|
|
||||||
// Times + airports
|
// Times + airports + names + aircraft
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
// Row A — times and IATAs (compact)
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
timeAirport(leg.departure)
|
timeAirport(leg.departure)
|
||||||
Image(systemName: "arrow.right")
|
Image(systemName: "arrow.right")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(FlightTheme.textTertiary)
|
.foregroundStyle(FlightTheme.textTertiary)
|
||||||
timeAirport(leg.arrival)
|
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 {
|
if let aircraft = aircraftLabel {
|
||||||
Text(aircraft)
|
Text(aircraft)
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(FlightTheme.textTertiary)
|
.foregroundStyle(FlightTheme.textTertiary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer(minLength: 4)
|
||||||
|
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(FlightTheme.textTertiary)
|
.foregroundStyle(FlightTheme.textTertiary)
|
||||||
|
.padding(.top, 4)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
@@ -183,4 +199,12 @@ private struct LegSummary: View {
|
|||||||
guard let iata = leg.equipmentIata else { return nil }
|
guard let iata = leg.equipmentIata else { return nil }
|
||||||
return appendix?.equipment(iata: iata)?.name ?? iata
|
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
|
// 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 {
|
private var unsupportedAirlineView: some View {
|
||||||
ContentUnavailableView {
|
ContentUnavailableView {
|
||||||
Label("Not Available", systemImage: "info.circle")
|
Label("Load Data Unavailable", systemImage: "info.circle")
|
||||||
} description: {
|
} 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 connections: [RouteConnection] = []
|
||||||
@State private var appendix: RouteAppendix?
|
@State private var appendix: RouteAppendix?
|
||||||
|
|
||||||
@State private var selectedFlight: FlightSchedule?
|
/// Universal load-detail sheet. Both directs (1 leg) and multi-stop
|
||||||
@State private var selectedDepCode: String = ""
|
/// connections route through ConnectionLoadDetailView so the user gets
|
||||||
@State private var selectedArrCode: String = ""
|
/// the same per-leg card — load summary, capacity pills, and a "Full
|
||||||
@State private var selectedDate: Date = Date()
|
/// details" drill-down — regardless of trip shape.
|
||||||
|
@State private var pendingSheet: ConnectionLoadRequest?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
@@ -37,12 +38,11 @@ struct RoutePlannerView: View {
|
|||||||
.background(FlightTheme.background.ignoresSafeArea())
|
.background(FlightTheme.background.ignoresSafeArea())
|
||||||
.navigationTitle("Connections")
|
.navigationTitle("Connections")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.sheet(item: $selectedFlight) { flight in
|
.sheet(item: $pendingSheet) { req in
|
||||||
FlightLoadDetailView(
|
ConnectionLoadDetailView(
|
||||||
schedule: flight,
|
connection: req.connection,
|
||||||
departureCode: selectedDepCode,
|
appendix: req.appendix,
|
||||||
arrivalCode: selectedArrCode,
|
database: database,
|
||||||
date: selectedDate,
|
|
||||||
loadService: loadService
|
loadService: loadService
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -171,8 +171,8 @@ struct RoutePlannerView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var resultsList: some View {
|
private var resultsList: some View {
|
||||||
ForEach(connections) { connection in
|
ForEach(connections) { connection in
|
||||||
ConnectionRow(connection: connection, appendix: appendix) { leg in
|
ConnectionRow(connection: connection, appendix: appendix, database: database) { leg in
|
||||||
openLegDetail(leg)
|
openLegDetail(leg, in: connection)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,10 +210,12 @@ struct RoutePlannerView: View {
|
|||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private func openLegDetail(_ leg: RouteFlight) {
|
/// Single tap path for both directs and multi-stops. The unit of
|
||||||
selectedDepCode = leg.departure.airportIata
|
/// presentation is the whole connection; the tapped leg is incidental.
|
||||||
selectedArrCode = leg.arrival.airportIata
|
private func openLegDetail(_ leg: RouteFlight, in connection: RouteConnection) {
|
||||||
selectedDate = leg.departure.dateTime
|
pendingSheet = ConnectionLoadRequest(
|
||||||
selectedFlight = leg.toFlightSchedule(appendix: appendix, on: leg.departure.dateTime)
|
connection: connection,
|
||||||
|
appendix: appendix
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,11 @@ struct WhereToGoView: View {
|
|||||||
@State private var connections: [RouteConnection] = []
|
@State private var connections: [RouteConnection] = []
|
||||||
@State private var appendix: RouteAppendix?
|
@State private var appendix: RouteAppendix?
|
||||||
|
|
||||||
@State private var selectedFlight: FlightSchedule?
|
/// Universal load-detail sheet. We wrap the tapped leg in a single-leg
|
||||||
@State private var selectedDepCode: String = ""
|
/// RouteConnection so `ConnectionLoadDetailView` can render it the same
|
||||||
@State private var selectedArrCode: String = ""
|
/// way it renders multi-stop connections — same card, same load
|
||||||
@State private var selectedDate: Date = Date()
|
/// summary, same drill-down.
|
||||||
|
@State private var pendingDetail: ConnectionLoadRequest?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
@@ -34,12 +35,11 @@ struct WhereToGoView: View {
|
|||||||
.background(FlightTheme.background.ignoresSafeArea())
|
.background(FlightTheme.background.ignoresSafeArea())
|
||||||
.navigationTitle("Where can I go?")
|
.navigationTitle("Where can I go?")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.sheet(item: $selectedFlight) { flight in
|
.sheet(item: $pendingDetail) { req in
|
||||||
FlightLoadDetailView(
|
ConnectionLoadDetailView(
|
||||||
schedule: flight,
|
connection: req.connection,
|
||||||
departureCode: selectedDepCode,
|
appendix: req.appendix,
|
||||||
arrivalCode: selectedArrCode,
|
database: database,
|
||||||
date: selectedDate,
|
|
||||||
loadService: loadService
|
loadService: loadService
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -159,7 +159,12 @@ struct WhereToGoView: View {
|
|||||||
Button {
|
Button {
|
||||||
openLegDetail(leg)
|
openLegDetail(leg)
|
||||||
} label: {
|
} label: {
|
||||||
DepartureLegRow(leg: leg, appendix: appendix, referenceDate: referenceDate)
|
DepartureLegRow(
|
||||||
|
leg: leg,
|
||||||
|
appendix: appendix,
|
||||||
|
database: database,
|
||||||
|
referenceDate: referenceDate
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
@@ -225,10 +230,17 @@ struct WhereToGoView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func openLegDetail(_ leg: RouteFlight) {
|
private func openLegDetail(_ leg: RouteFlight) {
|
||||||
selectedDepCode = leg.departure.airportIata
|
// Wrap the single leg in a one-flight connection so we can reuse
|
||||||
selectedArrCode = leg.arrival.airportIata
|
// the same detail view that handles multi-stops.
|
||||||
selectedDate = leg.departure.dateTime
|
let singleLeg = RouteConnection(
|
||||||
selectedFlight = leg.toFlightSchedule(appendix: appendix, on: leg.departure.dateTime)
|
durationMinutes: leg.durationMinutes,
|
||||||
|
score: 0,
|
||||||
|
flights: [leg]
|
||||||
|
)
|
||||||
|
pendingDetail = ConnectionLoadRequest(
|
||||||
|
connection: singleLeg,
|
||||||
|
appendix: appendix
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,6 +249,7 @@ struct WhereToGoView: View {
|
|||||||
private struct DepartureLegRow: View {
|
private struct DepartureLegRow: View {
|
||||||
let leg: RouteFlight
|
let leg: RouteFlight
|
||||||
let appendix: RouteAppendix?
|
let appendix: RouteAppendix?
|
||||||
|
let database: AirportDatabase
|
||||||
let referenceDate: Date
|
let referenceDate: Date
|
||||||
|
|
||||||
private static let timeFormatter: DateFormatter = {
|
private static let timeFormatter: DateFormatter = {
|
||||||
@@ -247,18 +260,22 @@ private struct DepartureLegRow: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
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) {
|
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))
|
.font(.subheadline.weight(.bold))
|
||||||
.foregroundStyle(FlightTheme.textPrimary)
|
.foregroundStyle(FlightTheme.textPrimary)
|
||||||
Text(airlineName)
|
Text(airlineName)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(FlightTheme.textSecondary)
|
.foregroundStyle(FlightTheme.textSecondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
VStack(alignment: .trailing, spacing: 2) {
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
Text(Self.timeFormatter.string(from: leg.departure.dateTime))
|
Text(Self.timeFormatter.string(from: leg.departure.dateTime))
|
||||||
@@ -268,30 +285,45 @@ private struct DepartureLegRow: View {
|
|||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(leavesInColor)
|
.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)
|
Text(leg.departure.airportIata)
|
||||||
.font(FlightTheme.airportCode(20))
|
.font(FlightTheme.airportCode(22))
|
||||||
|
.foregroundStyle(FlightTheme.textPrimary)
|
||||||
Image(systemName: "airplane")
|
Image(systemName: "airplane")
|
||||||
.font(.caption)
|
.font(.footnote)
|
||||||
.foregroundStyle(FlightTheme.textTertiary)
|
.foregroundStyle(FlightTheme.textTertiary)
|
||||||
.rotationEffect(.degrees(-45))
|
.rotationEffect(.degrees(-45))
|
||||||
Text(leg.arrival.airportIata)
|
Text(leg.arrival.airportIata)
|
||||||
.font(FlightTheme.airportCode(20))
|
.font(FlightTheme.airportCode(22))
|
||||||
|
.foregroundStyle(FlightTheme.textPrimary)
|
||||||
Spacer()
|
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 {
|
if let aircraft = aircraftLabel {
|
||||||
Text(aircraft)
|
Text(aircraft)
|
||||||
.font(FlightTheme.label(11))
|
.font(FlightTheme.label(11))
|
||||||
.foregroundStyle(FlightTheme.textSecondary)
|
.foregroundStyle(FlightTheme.textSecondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 8)
|
||||||
.padding(.vertical, 3)
|
.padding(.vertical, 3)
|
||||||
.background(Color(.quaternarySystemFill), in: Capsule())
|
.background(Color(.quaternarySystemFill), in: Capsule())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Row 4 — capacity pills + chevron
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
if let total = leg.totalSeats {
|
if let total = leg.totalSeats {
|
||||||
metaPill("\(total) seats")
|
metaPill("\(total) seats")
|
||||||
@@ -322,6 +354,18 @@ private struct DepartureLegRow: View {
|
|||||||
appendix?.airline(iata: leg.carrierIata)?.name ?? leg.carrierIata
|
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? {
|
private var aircraftLabel: String? {
|
||||||
guard let iata = leg.equipmentIata else { return nil }
|
guard let iata = leg.equipmentIata else { return nil }
|
||||||
return appendix?.equipment(iata: iata)?.name ?? iata
|
return appendix?.equipment(iata: iata)?.name ?? iata
|
||||||
|
|||||||
Reference in New Issue
Block a user