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:
Trey t
2026-04-28 11:19:20 -05:00
parent 4bd7a74042
commit df4a74726c
8 changed files with 655 additions and 53 deletions
+4
View File
@@ -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;
};
+26
View File
@@ -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 {
+29 -4
View File
@@ -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
}
+30 -6
View File
@@ -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()
}
}
}
}
+9 -2
View File
@@ -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 -17
View File
@@ -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
)
}
}
+68 -24
View File
@@ -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