Live tab: cached filter lists, tighten airline filter, clearer route fallback
Three fixes from the latest round of testing: 1) Tapping the airline filter froze the app for several seconds. Cause: `visibleAirlines` / `visibleCategories` were computed properties evaluated on every body re-render, each iterating the aircraft array and hitting the 2,695-entry registry. Caching them in @State and refreshing only via .onChange(of: aircraft) takes the menu-open cost to near-zero. 2) Picking "Southwest" in the airline filter still left other carriers on screen. Cause: when no callsign-derivable airline ICAO was present (N-number GA traffic, ad-hoc callsigns), the filter `guard let` silently let the aircraft through. Tightened to require an ICAO match when any airline filter is active. 3) The detail sheet showed no departure / arrival airport on most selections. Cause: OpenSky's /flights/aircraft endpoint only returns flights *after they've landed*, so an in-progress flight has no entry. We were waiting for one to appear forever. Rewrote the route section: now always shows whatever's most recent, labeled "IN FLIGHT" when the snapshot is < 6h old (with a green live-dot), "LAST FLIGHT · 3h AGO" otherwise, and an explicit "Route not available from OpenSky for this aircraft" card when the endpoint returned nothing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,23 +27,9 @@ struct LiveFlightDetailSheet: View {
|
||||
|
||||
liveStateGrid
|
||||
|
||||
if let route = currentRoute {
|
||||
Text("THIS FLIGHT")
|
||||
.font(FlightTheme.label())
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
.tracking(1)
|
||||
routeSection
|
||||
.padding(.top, 4)
|
||||
|
||||
routeCard(route)
|
||||
} else if isLoadingRoute {
|
||||
HStack {
|
||||
ProgressView()
|
||||
Text("Looking up route…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(FlightTheme.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
if recentFlights.count > 1 {
|
||||
Text("RECENT FLIGHTS")
|
||||
.font(FlightTheme.label())
|
||||
@@ -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()
|
||||
|
||||
@@ -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,11 +303,15 @@ 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) {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user