diff --git a/Flights.xcodeproj/project.pbxproj b/Flights.xcodeproj/project.pbxproj index 40da5a1..8da5740 100644 --- a/Flights.xcodeproj/project.pbxproj +++ b/Flights.xcodeproj/project.pbxproj @@ -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 = ""; }; RE5500005555000055550002 /* IATAAirportPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IATAAirportPicker.swift; sourceTree = ""; }; RE6600006666000066660002 /* ConnectionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionRow.swift; sourceTree = ""; }; + RE7700007777000077770002 /* ConnectionLoadDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionLoadDetailView.swift; sourceTree = ""; }; /* 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; }; diff --git a/Flights/Models/RouteExplorerModels.swift b/Flights/Models/RouteExplorerModels.swift index 6dc37d8..9dbff2f 100644 --- a/Flights/Models/RouteExplorerModels.swift +++ b/Flights/Models/RouteExplorerModels.swift @@ -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 { diff --git a/Flights/Services/AirlineLoadService.swift b/Flights/Services/AirlineLoadService.swift index 17d3bb2..058ae78 100644 --- a/Flights/Services/AirlineLoadService.swift +++ b/Flights/Services/AirlineLoadService.swift @@ -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 } diff --git a/Flights/Views/Components/ConnectionRow.swift b/Flights/Views/Components/ConnectionRow.swift index f4ab196..d532eae 100644 --- a/Flights/Views/Components/ConnectionRow.swift +++ b/Flights/Views/Components/ConnectionRow.swift @@ -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 + } } diff --git a/Flights/Views/ConnectionLoadDetailView.swift b/Flights/Views/ConnectionLoadDetailView.swift new file mode 100644 index 0000000..bab36a8 --- /dev/null +++ b/Flights/Views/ConnectionLoadDetailView.swift @@ -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: 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() + } + } + } +} diff --git a/Flights/Views/FlightLoadDetailView.swift b/Flights/Views/FlightLoadDetailView.swift index b0dfea3..6c423ce 100644 --- a/Flights/Views/FlightLoadDetailView.swift +++ b/Flights/Views/FlightLoadDetailView.swift @@ -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).") } } diff --git a/Flights/Views/RoutePlannerView.swift b/Flights/Views/RoutePlannerView.swift index d61ef77..e4c63a1 100644 --- a/Flights/Views/RoutePlannerView.swift +++ b/Flights/Views/RoutePlannerView.swift @@ -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 + ) } } diff --git a/Flights/Views/WhereToGoView.swift b/Flights/Views/WhereToGoView.swift index d5b6189..1ba87e6 100644 --- a/Flights/Views/WhereToGoView.swift +++ b/Flights/Views/WhereToGoView.swift @@ -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