Files
Flights/Flights/Views/LiveFilterPicker.swift
T
Trey T 390a158487 Live tab: restore type filter via bundled aircraft DB + sheet pickers
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>
2026-05-27 07:21:49 -05:00

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)
}
}
}