diff --git a/Flights/Views/LiveFlightDetailSheet.swift b/Flights/Views/LiveFlightDetailSheet.swift index 78af02c..89d7945 100644 --- a/Flights/Views/LiveFlightDetailSheet.swift +++ b/Flights/Views/LiveFlightDetailSheet.swift @@ -27,22 +27,8 @@ struct LiveFlightDetailSheet: View { liveStateGrid - if let route = currentRoute { - Text("THIS FLIGHT") - .font(FlightTheme.label()) - .foregroundStyle(FlightTheme.textTertiary) - .tracking(1) - .padding(.top, 4) - - routeCard(route) - } else if isLoadingRoute { - HStack { - ProgressView() - Text("Looking up route…") - .font(.caption) - .foregroundStyle(FlightTheme.textSecondary) - } - } + routeSection + .padding(.top, 4) if recentFlights.count > 1 { Text("RECENT FLIGHTS") @@ -178,32 +164,99 @@ struct LiveFlightDetailSheet: View { } // MARK: - Route + // + // OpenSky's /flights/aircraft endpoint only returns *landed* flights, so + // for a plane currently in flight we usually get nothing relevant — the + // most recent entry would be its previous leg (often from yesterday). + // + // To still show something useful, we display whatever's most recent and + // label it clearly: "In flight" if the snapshot is fresh, "Last flight" + // if it's older, "—" only when we genuinely have nothing. - /// The most recent flight (could be in-progress or just-landed). - private var currentRoute: OpenSkyFlight? { - recentFlights.first { f in + private enum RouteStatus { case loading, inFlight, recent(hoursAgo: Int), none } + + @ViewBuilder + private var routeSection: some View { + if isLoadingRoute && recentFlights.isEmpty { + HStack { + ProgressView() + Text("Looking up route…") + .font(.caption) + .foregroundStyle(FlightTheme.textSecondary) + } + } else if let mostRecent = recentFlights.first { let now = Date().timeIntervalSince1970 - return Double(f.lastSeen) > now - 6 * 3600 - } ?? recentFlights.first + let hoursAgo = max(0, Int((now - Double(mostRecent.lastSeen)) / 3600)) + let status: RouteStatus = { + if aircraft.onGround { return .recent(hoursAgo: hoursAgo) } + if hoursAgo < 6 { return .inFlight } + return .recent(hoursAgo: hoursAgo) + }() + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Text(routeHeader(status)) + .font(FlightTheme.label()) + .foregroundStyle(FlightTheme.textTertiary) + .tracking(1) + if case .inFlight = status { + Circle() + .fill(FlightTheme.onTime) + .frame(width: 6, height: 6) + } + } + routeCard(mostRecent, status: status) + } + } else { + VStack(alignment: .leading, spacing: 6) { + Text("THIS FLIGHT") + .font(FlightTheme.label()) + .foregroundStyle(FlightTheme.textTertiary) + .tracking(1) + Text("Route not available from OpenSky for this aircraft.") + .font(.caption) + .foregroundStyle(FlightTheme.textSecondary) + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(FlightTheme.cardBackground, in: RoundedRectangle(cornerRadius: 12)) + } + } } - private func routeCard(_ f: OpenSkyFlight) -> some View { - VStack(alignment: .leading, spacing: 12) { + private func routeHeader(_ status: RouteStatus) -> String { + switch status { + case .loading: return "THIS FLIGHT" + case .inFlight: return "IN FLIGHT" + case .recent(let hoursAgo): + if hoursAgo < 1 { return "LAST FLIGHT · JUST LANDED" } + if hoursAgo < 24 { return "LAST FLIGHT · \(hoursAgo)h AGO" } + let days = hoursAgo / 24 + return "LAST FLIGHT · \(days)d AGO" + case .none: return "THIS FLIGHT" + } + } + + private func routeCard(_ f: OpenSkyFlight, status: RouteStatus) -> some View { + let depLabel: String + let arrLabel: String + switch status { + case .inFlight: + depLabel = "Departed" + arrLabel = "Heading to" + case .recent: + depLabel = "Departed" + arrLabel = "Arrived" + case .loading, .none: + depLabel = "From" + arrLabel = "To" + } + return VStack(alignment: .leading, spacing: 12) { HStack(spacing: 16) { - routeEndpoint( - code: f.estDepartureAirport, - label: "Departed", - time: f.departureDate - ) + routeEndpoint(code: f.estDepartureAirport, label: depLabel, time: f.departureDate) Image(systemName: "airplane") .font(.title3) .foregroundStyle(FlightTheme.accent) .rotationEffect(.degrees(-45)) - routeEndpoint( - code: f.estArrivalAirport, - label: aircraft.onGround ? "Arrived" : "Heading to", - time: f.arrivalDate - ) + routeEndpoint(code: f.estArrivalAirport, label: arrLabel, time: f.arrivalDate) } } .flightCard() diff --git a/Flights/Views/LiveFlightsView.swift b/Flights/Views/LiveFlightsView.swift index b948ef9..8c44d2e 100644 --- a/Flights/Views/LiveFlightsView.swift +++ b/Flights/Views/LiveFlightsView.swift @@ -36,6 +36,13 @@ struct LiveFlightsView: View { @State private var activeSheet: ActiveSheet? @State private var selectedTrack: AircraftTrack? + // Cached filter-menu items — recomputed only when `aircraft` changes. + // Computing them on every body re-render caused the menu-tap freeze + // (each refresh fed the airlines registry an N×M lookup on the main + // thread). + @State private var cachedAirlineItems: [AirlineFilterItem] = [] + @State private var cachedCategoryItems: [CategoryFilterItem] = [] + enum ActiveSheet: Identifiable { case aircraft(LiveAircraft) case settings @@ -69,6 +76,7 @@ struct LiveFlightsView: View { .task(id: selectedAircraft?.icao24) { await loadTrackForSelection() } + .onChange(of: aircraft) { _, _ in rebuildFilterItems() } .sheet(item: $activeSheet) { sheet in switch sheet { case .aircraft(let ac): @@ -161,7 +169,7 @@ struct LiveFlightsView: View { Menu { Section("Airline") { - ForEach(visibleAirlines, id: \.icao) { item in + ForEach(cachedAirlineItems, id: \.icao) { item in Button { toggle(&selectedAirlineICAO, item.icao) } label: { @@ -188,7 +196,7 @@ struct LiveFlightsView: View { Menu { Section("Aircraft type") { - ForEach(visibleCategories, id: \.code) { item in + ForEach(cachedCategoryItems, id: \.code) { item in Button { toggle(&selectedCategories, item.code) } label: { @@ -295,10 +303,14 @@ struct LiveFlightsView: View { let s = searchText.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() return aircraft.filter { ac in if hideOnGround && ac.onGround { return false } - if !selectedAirlineICAO.isEmpty, - let code = ac.airlineICAO, - !selectedAirlineICAO.contains(code) { - return false + if !selectedAirlineICAO.isEmpty { + // Tightened: require an airline match. Aircraft without a + // parseable ICAO airline prefix (N-numbers, ad-hoc callsigns) + // were slipping through as "uncategorized" — that's why + // selecting Southwest still left other carriers on screen. + guard let code = ac.airlineICAO, selectedAirlineICAO.contains(code) else { + return false + } } if !selectedCategories.isEmpty { guard let cat = ac.category, selectedCategories.contains(cat) else { return false } @@ -311,52 +323,45 @@ struct LiveFlightsView: View { } } - private struct AirlineFilterItem: Hashable { + struct AirlineFilterItem: Hashable { let icao: String let name: String let count: Int var label: String { "\(name) (\(count))" } } - private var visibleAirlines: [AirlineFilterItem] { - var seen: [String: Int] = [:] - for ac in aircraft { - if let code = ac.airlineICAO { - seen[code, default: 0] += 1 - } - } - // Sort by the displayed airline name (alphabetical), not the ICAO code. - // This is what the user expects when scrolling the filter menu. - return seen.map { (icao, count) in - AirlineFilterItem( - icao: icao, - name: AircraftRegistry.shared.displayName(icao: icao), - count: count - ) - } - .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } - } - - private struct CategoryFilterItem: Hashable { + struct CategoryFilterItem: Hashable { let code: Int let name: String let count: Int var label: String { "\(name) (\(count))" } } - private var visibleCategories: [CategoryFilterItem] { - var seen: [Int: Int] = [:] + /// Rebuilds the cached filter items. Called from a .task tied to the + /// aircraft array so it doesn't run on every body re-render. + private func rebuildFilterItems() { + var airlines: [String: Int] = [:] + var cats: [Int: Int] = [:] for ac in aircraft { + if let code = ac.airlineICAO { + airlines[code, default: 0] += 1 + } if let c = ac.category, c > 0 { - seen[c, default: 0] += 1 + cats[c, default: 0] += 1 } } - return seen.compactMap { (code, count) -> CategoryFilterItem? in + cachedAirlineItems = airlines.map { (icao, count) in + AirlineFilterItem( + icao: icao, + name: AircraftRegistry.shared.displayName(icao: icao), + count: count + ) + }.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + + cachedCategoryItems = cats.compactMap { (code, count) -> CategoryFilterItem? in guard let name = aircraftCategoryName(code) else { return nil } return CategoryFilterItem(code: code, name: name, count: count) - } - // Sort by the displayed name, A→Z. - .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + }.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } } // MARK: - Fetch