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,22 +27,8 @@ struct LiveFlightDetailSheet: View {
|
|||||||
|
|
||||||
liveStateGrid
|
liveStateGrid
|
||||||
|
|
||||||
if let route = currentRoute {
|
routeSection
|
||||||
Text("THIS FLIGHT")
|
.padding(.top, 4)
|
||||||
.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if recentFlights.count > 1 {
|
if recentFlights.count > 1 {
|
||||||
Text("RECENT FLIGHTS")
|
Text("RECENT FLIGHTS")
|
||||||
@@ -178,32 +164,99 @@ struct LiveFlightDetailSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Route
|
// 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 enum RouteStatus { case loading, inFlight, recent(hoursAgo: Int), none }
|
||||||
private var currentRoute: OpenSkyFlight? {
|
|
||||||
recentFlights.first { f in
|
@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
|
let now = Date().timeIntervalSince1970
|
||||||
return Double(f.lastSeen) > now - 6 * 3600
|
let hoursAgo = max(0, Int((now - Double(mostRecent.lastSeen)) / 3600))
|
||||||
} ?? recentFlights.first
|
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 {
|
private func routeHeader(_ status: RouteStatus) -> String {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
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) {
|
HStack(spacing: 16) {
|
||||||
routeEndpoint(
|
routeEndpoint(code: f.estDepartureAirport, label: depLabel, time: f.departureDate)
|
||||||
code: f.estDepartureAirport,
|
|
||||||
label: "Departed",
|
|
||||||
time: f.departureDate
|
|
||||||
)
|
|
||||||
Image(systemName: "airplane")
|
Image(systemName: "airplane")
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.foregroundStyle(FlightTheme.accent)
|
.foregroundStyle(FlightTheme.accent)
|
||||||
.rotationEffect(.degrees(-45))
|
.rotationEffect(.degrees(-45))
|
||||||
routeEndpoint(
|
routeEndpoint(code: f.estArrivalAirport, label: arrLabel, time: f.arrivalDate)
|
||||||
code: f.estArrivalAirport,
|
|
||||||
label: aircraft.onGround ? "Arrived" : "Heading to",
|
|
||||||
time: f.arrivalDate
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.flightCard()
|
.flightCard()
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ struct LiveFlightsView: View {
|
|||||||
@State private var activeSheet: ActiveSheet?
|
@State private var activeSheet: ActiveSheet?
|
||||||
@State private var selectedTrack: AircraftTrack?
|
@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 {
|
enum ActiveSheet: Identifiable {
|
||||||
case aircraft(LiveAircraft)
|
case aircraft(LiveAircraft)
|
||||||
case settings
|
case settings
|
||||||
@@ -69,6 +76,7 @@ struct LiveFlightsView: View {
|
|||||||
.task(id: selectedAircraft?.icao24) {
|
.task(id: selectedAircraft?.icao24) {
|
||||||
await loadTrackForSelection()
|
await loadTrackForSelection()
|
||||||
}
|
}
|
||||||
|
.onChange(of: aircraft) { _, _ in rebuildFilterItems() }
|
||||||
.sheet(item: $activeSheet) { sheet in
|
.sheet(item: $activeSheet) { sheet in
|
||||||
switch sheet {
|
switch sheet {
|
||||||
case .aircraft(let ac):
|
case .aircraft(let ac):
|
||||||
@@ -161,7 +169,7 @@ struct LiveFlightsView: View {
|
|||||||
|
|
||||||
Menu {
|
Menu {
|
||||||
Section("Airline") {
|
Section("Airline") {
|
||||||
ForEach(visibleAirlines, id: \.icao) { item in
|
ForEach(cachedAirlineItems, id: \.icao) { item in
|
||||||
Button {
|
Button {
|
||||||
toggle(&selectedAirlineICAO, item.icao)
|
toggle(&selectedAirlineICAO, item.icao)
|
||||||
} label: {
|
} label: {
|
||||||
@@ -188,7 +196,7 @@ struct LiveFlightsView: View {
|
|||||||
|
|
||||||
Menu {
|
Menu {
|
||||||
Section("Aircraft type") {
|
Section("Aircraft type") {
|
||||||
ForEach(visibleCategories, id: \.code) { item in
|
ForEach(cachedCategoryItems, id: \.code) { item in
|
||||||
Button {
|
Button {
|
||||||
toggle(&selectedCategories, item.code)
|
toggle(&selectedCategories, item.code)
|
||||||
} label: {
|
} label: {
|
||||||
@@ -295,10 +303,14 @@ struct LiveFlightsView: View {
|
|||||||
let s = searchText.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
let s = searchText.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||||
return aircraft.filter { ac in
|
return aircraft.filter { ac in
|
||||||
if hideOnGround && ac.onGround { return false }
|
if hideOnGround && ac.onGround { return false }
|
||||||
if !selectedAirlineICAO.isEmpty,
|
if !selectedAirlineICAO.isEmpty {
|
||||||
let code = ac.airlineICAO,
|
// Tightened: require an airline match. Aircraft without a
|
||||||
!selectedAirlineICAO.contains(code) {
|
// parseable ICAO airline prefix (N-numbers, ad-hoc callsigns)
|
||||||
return false
|
// 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 {
|
if !selectedCategories.isEmpty {
|
||||||
guard let cat = ac.category, selectedCategories.contains(cat) else { return false }
|
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 icao: String
|
||||||
let name: String
|
let name: String
|
||||||
let count: Int
|
let count: Int
|
||||||
var label: String { "\(name) (\(count))" }
|
var label: String { "\(name) (\(count))" }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var visibleAirlines: [AirlineFilterItem] {
|
struct CategoryFilterItem: Hashable {
|
||||||
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 {
|
|
||||||
let code: Int
|
let code: Int
|
||||||
let name: String
|
let name: String
|
||||||
let count: Int
|
let count: Int
|
||||||
var label: String { "\(name) (\(count))" }
|
var label: String { "\(name) (\(count))" }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var visibleCategories: [CategoryFilterItem] {
|
/// Rebuilds the cached filter items. Called from a .task tied to the
|
||||||
var seen: [Int: Int] = [:]
|
/// 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 {
|
for ac in aircraft {
|
||||||
|
if let code = ac.airlineICAO {
|
||||||
|
airlines[code, default: 0] += 1
|
||||||
|
}
|
||||||
if let c = ac.category, c > 0 {
|
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 }
|
guard let name = aircraftCategoryName(code) else { return nil }
|
||||||
return CategoryFilterItem(code: code, name: name, count: count)
|
return CategoryFilterItem(code: code, name: name, count: count)
|
||||||
}
|
}.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||||
// Sort by the displayed name, A→Z.
|
|
||||||
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Fetch
|
// MARK: - Fetch
|
||||||
|
|||||||
Reference in New Issue
Block a user