390a158487
Two issues:
1) Menu list stutters. SwiftUI's Menu renders all rows in a
non-virtualized popover. With 30+ airlines and Buttons containing
Labels, scrolling jank starts immediately. Switched the airline +
type filters to a sheet-based picker (LiveFilterPicker) backed by
a real List — virtualized scrolling, plus a search bar that
filters as you type.
2) Type filter was non-functional because OpenSky's anonymous tier
returns ADS-B emitter category as null for most aircraft.
Replaced with a real type-code lookup: bundled aircraftDB.json
(1.5MB slimmed copy of OpenSky's aircraft metadata, 100k
commercial-class airframes, filtered to skip GA / gliders /
ultralights). AircraftDatabase.shared.typeCode(forICAO24:)
returns the ICAO type designator (B738, A21N, etc.).
AircraftDatabase.displayName(forTypeCode:) maps the top ~130
common codes to friendly names ("B738" → "Boeing 737-800").
LiveAircraft now exposes a `typeCode` computed property that
indexes into the DB. The type filter chip → sheet flow uses the
same LiveFilterPicker as airlines, with multi-select + counts +
search.
Both pickers keep the "Selected (N)" group pinned at the top so
the user always sees what they have active without scrolling.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
97 lines
3.3 KiB
Swift
97 lines
3.3 KiB
Swift
import SwiftUI
|
|
|
|
/// Sheet-based filter picker. Backs the Live tab's airline + aircraft-type
|
|
/// filters. Uses a real `List` (virtualized, smooth scrolling) instead of
|
|
/// SwiftUI's `Menu`, which renders all items eagerly in a popover and
|
|
/// stutters once the count goes past ~20.
|
|
///
|
|
/// Multi-select. Tap-to-toggle. Search-as-you-type filters the list.
|
|
struct LiveFilterPicker: View {
|
|
let title: String
|
|
let items: [Item]
|
|
@Binding var selection: Set<String>
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var query: String = ""
|
|
|
|
struct Item: Hashable, Identifiable {
|
|
let id: String
|
|
let label: String
|
|
let count: Int
|
|
}
|
|
|
|
private var filteredItems: [Item] {
|
|
let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
if q.isEmpty { return items }
|
|
return items.filter { $0.label.lowercased().contains(q) || $0.id.lowercased().contains(q) }
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
if !selection.isEmpty {
|
|
Section {
|
|
ForEach(items.filter { selection.contains($0.id) }) { item in
|
|
row(item)
|
|
}
|
|
Button(role: .destructive) {
|
|
selection.removeAll()
|
|
} label: {
|
|
Label("Clear selection", systemImage: "xmark.circle")
|
|
}
|
|
} header: {
|
|
Text("Selected (\(selection.count))")
|
|
}
|
|
}
|
|
|
|
Section {
|
|
ForEach(filteredItems.filter { !selection.contains($0.id) }) { item in
|
|
row(item)
|
|
}
|
|
} header: {
|
|
if !selection.isEmpty {
|
|
Text("All")
|
|
}
|
|
}
|
|
}
|
|
.listStyle(.insetGrouped)
|
|
.searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always),
|
|
prompt: "Search \(title.lowercased())")
|
|
.navigationTitle(title)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("Done") { dismiss() }
|
|
.fontWeight(.semibold)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func row(_ item: Item) -> some View {
|
|
Button {
|
|
toggle(item.id)
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: selection.contains(item.id) ? "checkmark.circle.fill" : "circle")
|
|
.foregroundStyle(selection.contains(item.id) ? FlightTheme.accent : FlightTheme.textTertiary)
|
|
Text(item.label)
|
|
.foregroundStyle(FlightTheme.textPrimary)
|
|
Spacer()
|
|
Text("\(item.count)")
|
|
.font(.caption.monospacedDigit())
|
|
.foregroundStyle(FlightTheme.textSecondary)
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
private func toggle(_ id: String) {
|
|
if selection.contains(id) {
|
|
selection.remove(id)
|
|
} else {
|
|
selection.insert(id)
|
|
}
|
|
}
|
|
}
|