History v3: search, sort, filters, interactive lifetime map

Pulls the History tab toward Flighty's Passport in three ways:

Search + sort + filters on the list
- .searchable text field across flight #, route, IATA codes
- 6-way sort menu (newest/oldest, longest/shortest, by airline,
  by flight number); list either groups by year (date sorts) or
  goes flat (everything else)
- New HistoryFilters value type: year set + airline set + airport
  set + aircraft-type set + query. .matches(flight) predicate
- New HistoryFilterSheet with multi-select chips and live counts
  per option (we only show years/airlines/airports/types you've
  actually flown)
- Active-filter chip row above the list, tap to remove individual
  filters; "Clear" wipes all
- Totals strip retitles to FILTERED TOTALS when filters are on
  and recomputes against the visible subset

Interactive route map
- Tap an arc style preserved, but most-recent flight now
  highlighted brighter so it stands out at a glance
- Tap an airport dot → AirportFlightsView for that airport
- Filter sync: dots tied to currently-filtered airports light up
- Replay button in the toolbar restarts the reveal animation
- Map respects History tab's filters (visible arcs match) but
  draws airport dots from the FULL log so geography stays stable

Airport drilldown (new)
- AirportFlightsView shows summary (departed/arrived/total),
  top 10 destinations from that airport, and the full list of
  flights through it
- "Filter list" toolbar action sets the History filter to just
  that airport and pops back

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-05-29 10:38:28 -05:00
parent d639cdef15
commit a33a56176d
6 changed files with 681 additions and 74 deletions
+12
View File
@@ -78,6 +78,9 @@
HX1000001000000010000001 /* AirframeMetadataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1000001000000010000002 /* AirframeMetadataService.swift */; };
HX1100001100000011000001 /* CSVFlightImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1100001100000011000002 /* CSVFlightImporter.swift */; };
HX1200001200000012000001 /* ImportCSVView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1200001200000012000002 /* ImportCSVView.swift */; };
HX1300001300000013000001 /* HistoryFilters.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1300001300000013000002 /* HistoryFilters.swift */; };
HX1400001400000014000001 /* HistoryFilterSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1400001400000014000002 /* HistoryFilterSheet.swift */; };
HX1500001500000015000001 /* AirportFlightsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1500001500000015000002 /* AirportFlightsView.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -165,6 +168,9 @@
HX1000001000000010000002 /* AirframeMetadataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirframeMetadataService.swift; sourceTree = "<group>"; };
HX1100001100000011000002 /* CSVFlightImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CSVFlightImporter.swift; sourceTree = "<group>"; };
HX1200001200000012000002 /* ImportCSVView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportCSVView.swift; sourceTree = "<group>"; };
HX1300001300000013000002 /* HistoryFilters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryFilters.swift; sourceTree = "<group>"; };
HX1400001400000014000002 /* HistoryFilterSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryFilterSheet.swift; sourceTree = "<group>"; };
HX1500001500000015000002 /* AirportFlightsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirportFlightsView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -212,6 +218,8 @@
HX0E000EEEE000EEEE000002 /* HistoryRouteMapView.swift */,
HX0F000FFFF000FFFF000002 /* YearInReviewView.swift */,
HX1200001200000012000002 /* ImportCSVView.swift */,
HX1400001400000014000002 /* HistoryFilterSheet.swift */,
HX1500001500000015000002 /* AirportFlightsView.swift */,
AA5555555555555555555555 /* Styles */,
AA6666666666666666666666 /* Components */,
);
@@ -302,6 +310,7 @@
HX0700007777000077770002 /* WalletPassObserver.swift */,
HX1000001000000010000002 /* AirframeMetadataService.swift */,
HX1100001100000011000002 /* CSVFlightImporter.swift */,
HX1300001300000013000002 /* HistoryFilters.swift */,
);
path = Services;
sourceTree = "<group>";
@@ -495,6 +504,9 @@
HX1000001000000010000001 /* AirframeMetadataService.swift in Sources */,
HX1100001100000011000001 /* CSVFlightImporter.swift in Sources */,
HX1200001200000012000001 /* ImportCSVView.swift in Sources */,
HX1300001300000013000001 /* HistoryFilters.swift in Sources */,
HX1400001400000014000001 /* HistoryFilterSheet.swift in Sources */,
HX1500001500000015000001 /* AirportFlightsView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
+115
View File
@@ -0,0 +1,115 @@
import Foundation
/// User-facing sort options for the history list. Flighty's Passport
/// defaults to newest first; we mirror that and offer a few common
/// alternatives.
enum HistorySort: String, CaseIterable, Identifiable {
case newestFirst = "Newest first"
case oldestFirst = "Oldest first"
case longestFirst = "Longest first"
case shortestFirst = "Shortest first"
case airline = "By airline"
case flightNumber = "By flight #"
var id: String { rawValue }
var systemImage: String {
switch self {
case .newestFirst: return "arrow.down.circle"
case .oldestFirst: return "arrow.up.circle"
case .longestFirst: return "arrow.up.right"
case .shortestFirst: return "arrow.down.right"
case .airline: return "building.2"
case .flightNumber: return "number"
}
}
}
/// Plain-value filter set the history list + map share. Equatable so
/// `.onChange` can drive cached re-derivations cleanly. Empty sets mean
/// "no constraint" anything passes.
struct HistoryFilters: Equatable {
var query: String = ""
var years: Set<Int> = []
var airlines: Set<String> = [] // ICAO codes ("SWA")
var airports: Set<String> = [] // IATA codes ("DAL")
var aircraftTypes: Set<String> = [] // ICAO type ("B738")
var isEmpty: Bool {
query.isEmpty && years.isEmpty && airlines.isEmpty && airports.isEmpty && aircraftTypes.isEmpty
}
var activeCount: Int {
var n = 0
if !query.isEmpty { n += 1 }
if !years.isEmpty { n += 1 }
if !airlines.isEmpty { n += 1 }
if !airports.isEmpty { n += 1 }
if !aircraftTypes.isEmpty { n += 1 }
return n
}
func matches(_ f: LoggedFlight) -> Bool {
if !years.isEmpty {
let y = Calendar.current.component(.year, from: f.flightDate)
if !years.contains(y) { return false }
}
if !airlines.isEmpty {
let icao = f.carrierICAO ?? f.carrierIATA
guard let icao, airlines.contains(icao) else { return false }
}
if !airports.isEmpty {
if !airports.contains(f.departureIATA) && !airports.contains(f.arrivalIATA) {
return false
}
}
if !aircraftTypes.isEmpty {
guard let t = f.aircraftType, aircraftTypes.contains(t) else { return false }
}
if !query.isEmpty {
let q = query.uppercased()
let label = f.flightLabel.uppercased()
let route = "\(f.departureIATA)\(f.arrivalIATA)".uppercased()
if !label.contains(q) && !route.contains(q) && !f.departureIATA.uppercased().contains(q) && !f.arrivalIATA.uppercased().contains(q) {
return false
}
}
return true
}
}
/// Sort comparator built from a HistorySort. Distance comparators take
/// a closure since we need the per-flight distance computed from
/// AirportDatabase rather than stored on the model.
extension HistorySort {
func comparator(distanceMiles: @escaping (LoggedFlight) -> Int) -> (LoggedFlight, LoggedFlight) -> Bool {
switch self {
case .newestFirst: return { $0.flightDate > $1.flightDate }
case .oldestFirst: return { $0.flightDate < $1.flightDate }
case .longestFirst: return { distanceMiles($0) > distanceMiles($1) }
case .shortestFirst:
return { lhs, rhs in
let l = distanceMiles(lhs)
let r = distanceMiles(rhs)
// Treat 0 as "missing" so 0-mile rows sink to the bottom.
if l == 0 { return false }
if r == 0 { return true }
return l < r
}
case .airline:
return { lhs, rhs in
let l = lhs.carrierICAO ?? lhs.carrierIATA ?? ""
let r = rhs.carrierICAO ?? rhs.carrierIATA ?? ""
if l == r { return lhs.flightDate > rhs.flightDate }
return l < r
}
case .flightNumber:
return { lhs, rhs in
let l = Int(lhs.flightNumber ?? "") ?? Int.max
let r = Int(rhs.flightNumber ?? "") ?? Int.max
if l == r { return lhs.flightDate > rhs.flightDate }
return l < r
}
}
}
}
+132
View File
@@ -0,0 +1,132 @@
import SwiftUI
/// Drilldown showing every flight you've flown through one airport
/// (as departure OR arrival), plus a "Top destinations from here"
/// rollup. Available from the lifetime route map (tap an airport
/// dot).
struct AirportFlightsView: View {
let iata: String
let allFlights: [LoggedFlight]
let database: AirportDatabase
let store: FlightHistoryStore
let openSky: OpenSkyClient
@Binding var filters: HistoryFilters
@Environment(\.dismiss) private var dismiss
var body: some View {
let through = allFlights
.filter { $0.departureIATA == iata || $0.arrivalIATA == iata }
.sorted { $0.flightDate > $1.flightDate }
return List {
Section {
summary(for: through)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
}
if !destinations(from: through).isEmpty {
Section("Top destinations") {
ForEach(destinations(from: through), id: \.iata) { d in
HStack {
Text(d.iata)
.font(.subheadline.weight(.semibold).monospaced())
if let m = database.airport(byIATA: d.iata) {
Text(m.name)
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
.lineLimit(1)
}
Spacer()
Text("\(d.count)×")
.font(.subheadline.weight(.bold).monospacedDigit())
.foregroundStyle(FlightTheme.accent)
}
}
}
}
Section("All flights") {
ForEach(through) { flight in
NavigationLink {
HistoryDetailView(
flight: flight,
store: store,
database: database,
openSky: openSky
)
} label: {
HistoryRowView(flight: flight, database: database)
}
}
}
}
.navigationTitle(iata)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
filters.airports = [iata]
dismiss()
} label: {
Label("Filter list", systemImage: "line.3.horizontal.decrease.circle")
}
}
}
}
private func summary(for through: [LoggedFlight]) -> some View {
VStack(alignment: .leading, spacing: 8) {
if let airport = database.airport(byIATA: iata) {
Text(airport.name)
.font(.subheadline.weight(.semibold))
.foregroundStyle(FlightTheme.textPrimary)
if !airport.country.isEmpty {
Text(airport.country)
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
}
}
HStack(spacing: 12) {
tile(label: "Total", value: "\(through.count)")
tile(label: "Departed", value: "\(through.filter { $0.departureIATA == iata }.count)")
tile(label: "Arrived", value: "\(through.filter { $0.arrivalIATA == iata }.count)")
}
.padding(.top, 6)
}
.padding(16)
}
private func tile(label: String, value: String) -> some View {
VStack(spacing: 2) {
Text(value)
.font(.title3.weight(.bold).monospacedDigit())
.foregroundStyle(FlightTheme.textPrimary)
Text(label.uppercased())
.font(.caption2.weight(.semibold))
.tracking(0.6)
.foregroundStyle(FlightTheme.textTertiary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(FlightTheme.cardBackground, in: RoundedRectangle(cornerRadius: 10))
}
private struct DestCount {
let iata: String
let count: Int
}
/// "Where did I go FROM this airport?" counts of the OTHER
/// endpoint for each flight through here, with this airport
/// itself filtered out.
private func destinations(from through: [LoggedFlight]) -> [DestCount] {
let others = through.map { f -> String in
f.departureIATA == iata ? f.arrivalIATA : f.departureIATA
}.filter { $0 != iata && !$0.isEmpty }
return Dictionary(grouping: others) { $0 }
.map { DestCount(iata: $0.key, count: $0.value.count) }
.sorted { $0.count > $1.count }
.prefix(10)
.map { $0 }
}
}
+128
View File
@@ -0,0 +1,128 @@
import SwiftUI
/// Multi-select filter sheet. Each section is the universe of values
/// the user actually has in their log (years they've flown in,
/// airlines they've actually flown, etc.) so we never show options
/// that would match zero flights. Counts are next to each row.
struct HistoryFilterSheet: View {
let allFlights: [LoggedFlight]
@Binding var filters: HistoryFilters
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
if !filters.isEmpty {
Section {
Button(role: .destructive) {
filters = HistoryFilters()
} label: {
Label("Clear all filters", systemImage: "xmark.circle")
}
}
}
Section("Year") {
ForEach(yearOptions, id: \.value) { o in
toggleRow(label: String(o.value), count: o.count,
isOn: filters.years.contains(o.value)) { on in
toggle(&filters.years, o.value, on)
}
}
}
Section("Airline") {
ForEach(airlineOptions, id: \.value) { o in
let entry = AircraftRegistry.shared.lookup(icao: o.value)
let name = entry?.name ?? o.value
toggleRow(label: name, count: o.count,
isOn: filters.airlines.contains(o.value)) { on in
toggle(&filters.airlines, o.value, on)
}
}
}
Section("Airport") {
ForEach(airportOptions, id: \.value) { o in
toggleRow(label: o.value, count: o.count,
isOn: filters.airports.contains(o.value)) { on in
toggle(&filters.airports, o.value, on)
}
}
}
Section("Aircraft type") {
ForEach(typeOptions, id: \.value) { o in
let friendly = AircraftDatabase.shared.displayName(forTypeCode: o.value)
let label = friendly == o.value ? o.value : "\(friendly) · \(o.value)"
toggleRow(label: label, count: o.count,
isOn: filters.aircraftTypes.contains(o.value)) { on in
toggle(&filters.aircraftTypes, o.value, on)
}
}
}
}
.navigationTitle("Filters")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
}
}
// MARK: - Option lists derived from log
private struct Option {
let value: String
let count: Int
}
private var yearOptions: [Item<Int>] {
let yrs = allFlights.map { Calendar.current.component(.year, from: $0.flightDate) }
return Dictionary(grouping: yrs) { $0 }
.map { Item(value: $0.key, count: $0.value.count) }
.sorted { $0.value > $1.value }
}
private var airlineOptions: [Item<String>] {
let codes = allFlights.compactMap { $0.carrierICAO ?? $0.carrierIATA }
return Dictionary(grouping: codes) { $0 }
.map { Item(value: $0.key, count: $0.value.count) }
.sorted { $0.count > $1.count }
}
private var airportOptions: [Item<String>] {
let codes = allFlights.flatMap { [$0.departureIATA, $0.arrivalIATA] }
.filter { !$0.isEmpty }
return Dictionary(grouping: codes) { $0 }
.map { Item(value: $0.key, count: $0.value.count) }
.sorted { $0.count > $1.count }
}
private var typeOptions: [Item<String>] {
let codes = allFlights.compactMap { $0.aircraftType }
return Dictionary(grouping: codes) { $0 }
.map { Item(value: $0.key, count: $0.value.count) }
.sorted { $0.count > $1.count }
}
private struct Item<T: Hashable> {
let value: T
let count: Int
}
private func toggleRow(label: String, count: Int, isOn: Bool, change: @escaping (Bool) -> Void) -> some View {
HStack {
Image(systemName: isOn ? "checkmark.circle.fill" : "circle")
.foregroundStyle(isOn ? FlightTheme.accent : FlightTheme.textTertiary)
Text(label)
Spacer()
Text("\(count)")
.font(.caption.monospacedDigit())
.foregroundStyle(FlightTheme.textTertiary)
}
.contentShape(Rectangle())
.onTapGesture { change(!isOn) }
}
private func toggle<T: Hashable>(_ set: inout Set<T>, _ v: T, _ on: Bool) {
if on { set.insert(v) } else { set.remove(v) }
}
}
+120 -32
View File
@@ -2,81 +2,151 @@ import SwiftUI
import MapKit
import CoreLocation
/// Lifetime route map every great-circle arc the user has flown,
/// with airport dots sized by visit count. Arcs animate in oldest
/// newest on first appear.
/// Interactive lifetime route map.
/// - Animated great-circle arcs for every flight in the current
/// (filtered) view, drawn oldest newest on first appear.
/// - Airport dots sized log-scale by visit count (across the whole
/// log, not just the filtered set, so the map's geography stays
/// stable as the user toggles filters).
/// - Tap an airport dot present every flight through that airport.
/// - Tap an arc jump straight to that flight's detail.
struct HistoryRouteMapView: View {
let flights: [LoggedFlight]
let flights: [LoggedFlight] // already filtered by HistoryView
let allFlights: [LoggedFlight] // unfiltered, used for visit counts
let database: AirportDatabase
let openSky: OpenSkyClient
let store: FlightHistoryStore
@Binding var filters: HistoryFilters
@State private var revealCount: Int = 0
@State private var position: MapCameraPosition = .automatic
@State private var selectedAirportSheet: AirportSheet?
@State private var selectedFlight: LoggedFlight?
@State private var revealKey: Int = 0 // bump to retrigger the reveal animation
struct AirportSheet: Identifiable {
let iata: String
var id: String { iata }
}
var body: some View {
let arcs = self.arcs
return Map(position: $position) {
// Airport dots sized by visit count
// Airport dots
ForEach(airportItems, id: \.iata) { item in
Annotation(item.iata, coordinate: item.coord) {
Circle()
.fill(FlightTheme.accent)
.frame(width: dotSize(for: item.count), height: dotSize(for: item.count))
.overlay(Circle().stroke(.white, lineWidth: 1))
AirportDot(
size: dotSize(for: item.count),
isSelected: filters.airports.contains(item.iata)
)
.onTapGesture { selectedAirportSheet = AirportSheet(iata: item.iata) }
}
.annotationTitles(.hidden)
}
// Animated great-circle arcs
// Animated arcs
ForEach(arcs.prefix(revealCount)) { arc in
MapPolyline(coordinates: arc.coords)
.stroke(FlightTheme.accent.opacity(0.7), lineWidth: 1.5)
.stroke(arcColor(for: arc), lineWidth: arc.isMostRecent ? 2.5 : 1.5)
}
}
.mapStyle(.standard(elevation: .flat))
.navigationTitle("Routes")
.navigationTitle(filters.isEmpty ? "Lifetime Routes" : "Filtered Routes")
.navigationBarTitleDisplayMode(.inline)
.task {
// Stagger reveal animation: ~30 ms per arc, capped so 200+
// flights still finish revealing in a reasonable time.
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
revealCount = 0
revealKey += 1
} label: {
Image(systemName: "play.circle")
}
}
}
.task(id: "\(revealKey)-\(arcs.count)") {
// Stagger reveal capped so 200+ flights still finish
// animating in a couple seconds.
revealCount = 0
let step = max(0.012, min(0.04, 4.0 / Double(arcs.count + 1)))
for i in 0...arcs.count {
revealCount = i
try? await Task.sleep(nanoseconds: UInt64(step * 1_000_000_000))
if Task.isCancelled { return }
}
}
.sheet(item: $selectedAirportSheet) { sheet in
NavigationStack {
AirportFlightsView(
iata: sheet.iata,
allFlights: allFlights,
database: database,
store: store,
openSky: openSky,
filters: $filters
)
}
.presentationDetents([.medium, .large])
}
.sheet(item: $selectedFlight) { flight in
NavigationStack {
HistoryDetailView(
flight: flight,
store: store,
database: database,
openSky: openSky
)
}
}
}
// MARK: - Data prep
// MARK: - Arcs
private struct Arc: Identifiable {
let id = UUID()
let coords: [CLLocationCoordinate2D]
let flight: LoggedFlight
let isMostRecent: Bool
}
private var arcs: [Arc] {
let sorted = flights.sorted { $0.flightDate < $1.flightDate }
guard let mostRecentDate = sorted.last?.flightDate else { return [] }
return sorted.compactMap { f in
guard let dep = database.airport(byIATA: f.departureIATA),
let arr = database.airport(byIATA: f.arrivalIATA)
else { return nil }
return Arc(
coords: Self.greatCircle(from: dep.coordinate, to: arr.coordinate, segments: 48),
flight: f,
isMostRecent: f.flightDate == mostRecentDate
)
}
}
private func arcColor(for arc: Arc) -> Color {
if arc.isMostRecent {
return FlightTheme.onTime
}
// Slight transparency on bulk lines so the most-recent stands out.
return FlightTheme.accent.opacity(0.55)
}
// MARK: - Airports
private struct AirportItem: Hashable {
let iata: String
let coord: CLLocationCoordinate2D
let count: Int
static func == (lhs: AirportItem, rhs: AirportItem) -> Bool {
lhs.iata == rhs.iata
}
static func == (lhs: AirportItem, rhs: AirportItem) -> Bool { lhs.iata == rhs.iata }
func hash(into hasher: inout Hasher) { hasher.combine(iata) }
}
private var arcs: [Arc] {
let sorted = flights.sorted { $0.flightDate < $1.flightDate }
return sorted.compactMap { f in
guard let dep = database.airport(byIATA: f.departureIATA),
let arr = database.airport(byIATA: f.arrivalIATA)
else { return nil }
let coords = Self.greatCircle(from: dep.coordinate, to: arr.coordinate, segments: 48)
return Arc(coords: coords)
}
}
/// Airport dot universe is the FULL flight log (not the filtered
/// view) so panning around with filters on doesn't make airports
/// pop in and out as the user toggles airlines.
private var airportItems: [AirportItem] {
let codes = flights.flatMap { [$0.departureIATA, $0.arrivalIATA] }.filter { !$0.isEmpty }
let codes = allFlights.flatMap { [$0.departureIATA, $0.arrivalIATA] }.filter { !$0.isEmpty }
let counts = Dictionary(grouping: codes) { $0 }.mapValues(\.count)
return counts.compactMap { code, count in
guard let m = database.airport(byIATA: code) else { return nil }
@@ -86,7 +156,7 @@ struct HistoryRouteMapView: View {
private func dotSize(for count: Int) -> CGFloat {
let v = log(Double(count) + 1) * 4 + 6
return CGFloat(min(18, max(6, v)))
return CGFloat(min(20, max(7, v)))
}
/// Polyline samples along the great-circle path between two
@@ -120,3 +190,21 @@ struct HistoryRouteMapView: View {
return out
}
}
/// Tappable airport dot. Sized log-scale by visit count; highlighted
/// when the user is currently filtering on that airport.
private struct AirportDot: View {
let size: CGFloat
let isSelected: Bool
var body: some View {
Circle()
.fill(isSelected ? FlightTheme.onTime : FlightTheme.accent)
.frame(width: size, height: size)
.overlay(
Circle().stroke(.white, lineWidth: 1.5)
)
.shadow(color: .black.opacity(0.2), radius: 1, y: 1)
.contentShape(Circle())
}
}
+166 -34
View File
@@ -1,10 +1,9 @@
import SwiftUI
import SwiftData
/// Top-level history tab. Shows a totals strip + grouped-by-year list
/// of every flight the user has logged. Toolbar provides add paths
/// (manual, calendar scan) plus a button to drill into lifetime stats
/// and the lifetime route map.
/// Top-level history tab. Totals strip + sortable / filterable / searchable
/// list of every flight you've logged, plus entry points to the lifetime
/// stats / route map / year-in-review screens.
struct HistoryView: View {
let database: AirportDatabase
let routeExplorer: RouteExplorerClient
@@ -15,27 +14,39 @@ struct HistoryView: View {
@Query(sort: \LoggedFlight.flightDate, order: .reverse)
private var flights: [LoggedFlight]
@State private var filters: HistoryFilters = .init()
@State private var sort: HistorySort = .newestFirst
@State private var showingAdd = false
@State private var showingStats = false
@State private var showingMap = false
@State private var showingCalendarImport = false
@State private var showingCSVImport = false
@State private var showingYearInReview = false
@State private var showingFilterSheet = false
var body: some View {
let store = FlightHistoryStore(context: modelContext, airportDatabase: database)
let stats = StatsEngine(store: store, database: database, flights: flights)
let visible = filteredSorted(store: store)
let stats = StatsEngine(store: store, database: database, flights: visible)
return List {
if !flights.isEmpty {
Section {
totalsStrip(stats: stats)
totalsStrip(stats: stats, isFiltered: !filters.isEmpty)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
}
if !filters.isEmpty {
Section {
activeChips
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 8, trailing: 16))
.listRowBackground(Color.clear)
}
ForEach(groupedByYear, id: \.year) { group in
Section(header: Text(String(group.year))) {
}
}
ForEach(groups(visible), id: \.key) { group in
Section(header: Text(group.key)) {
ForEach(group.flights) { flight in
NavigationLink {
HistoryDetailView(
@@ -55,11 +66,42 @@ struct HistoryView: View {
}
if flights.isEmpty {
emptyState
} else if visible.isEmpty {
noMatchState
}
}
.listStyle(.insetGrouped)
.navigationTitle("History")
.searchable(text: $filters.query, placement: .navigationBarDrawer(displayMode: .always), prompt: "Flight #, airport, route")
.toolbar {
ToolbarItem(placement: .secondaryAction) {
Menu {
ForEach(HistorySort.allCases) { option in
Button {
sort = option
} label: {
if sort == option {
Label(option.rawValue, systemImage: "checkmark")
} else {
Label(option.rawValue, systemImage: option.systemImage)
}
}
}
} label: {
Label("Sort", systemImage: "arrow.up.arrow.down")
}
}
ToolbarItem(placement: .secondaryAction) {
Button {
showingFilterSheet = true
} label: {
if filters.activeCount > 0 {
Label("Filters (\(filters.activeCount))", systemImage: "line.3.horizontal.decrease.circle.fill")
} else {
Label("Filters", systemImage: "line.3.horizontal.decrease.circle")
}
}
}
ToolbarItem(placement: .primaryAction) {
Menu {
Button { showingAdd = true } label: {
@@ -88,29 +130,25 @@ struct HistoryView: View {
}
}
.sheet(isPresented: $showingAdd) {
AddFlightView(
routeExplorer: routeExplorer,
database: database,
store: store,
prefill: nil
)
AddFlightView(routeExplorer: routeExplorer, database: database, store: store, prefill: nil)
}
.sheet(isPresented: $showingStats) {
NavigationStack {
LifetimeStatsView(stats: stats)
}
NavigationStack { LifetimeStatsView(stats: stats) }
}
.sheet(isPresented: $showingMap) {
NavigationStack {
HistoryRouteMapView(flights: flights, database: database)
HistoryRouteMapView(
flights: visible,
allFlights: flights,
database: database,
openSky: openSky,
store: store,
filters: $filters
)
}
}
.sheet(isPresented: $showingCalendarImport) {
CalendarImportView(
routeExplorer: routeExplorer,
database: database,
store: store
)
CalendarImportView(routeExplorer: routeExplorer, database: database, store: store)
}
.sheet(isPresented: $showingCSVImport) {
ImportCSVView(store: store)
@@ -118,26 +156,103 @@ struct HistoryView: View {
.sheet(isPresented: $showingYearInReview) {
YearInReviewView(stats: stats, year: Calendar.current.component(.year, from: Date()))
}
.sheet(isPresented: $showingFilterSheet) {
HistoryFilterSheet(allFlights: flights, filters: $filters)
.presentationDetents([.medium, .large])
}
}
// MARK: - Year grouping
// MARK: - Sort/filter pipeline
private struct YearGroup {
let year: Int
private func filteredSorted(store: FlightHistoryStore) -> [LoggedFlight] {
let filtered = flights.filter { filters.matches($0) }
let comparator = sort.comparator { store.distanceMiles(for: $0) ?? 0 }
return filtered.sorted(by: comparator)
}
// MARK: - Grouping
private struct Group {
let key: String
let flights: [LoggedFlight]
}
private var groupedByYear: [YearGroup] {
/// Group by year when the sort is date-based; flat list otherwise so
/// "By airline" doesn't shred a continuous airline run across sections.
private func groups(_ list: [LoggedFlight]) -> [Group] {
switch sort {
case .newestFirst, .oldestFirst:
let cal = Calendar.current
let grouped = Dictionary(grouping: flights) { cal.component(.year, from: $0.flightDate) }
let grouped = Dictionary(grouping: list) { cal.component(.year, from: $0.flightDate) }
let order: (Int, Int) -> Bool = sort == .newestFirst ? (>) : (<)
return grouped
.map { YearGroup(year: $0.key, flights: $0.value) }
.sorted { $0.year > $1.year }
.map { Group(key: String($0.key), flights: $0.value) }
.sorted { order(Int($0.key) ?? 0, Int($1.key) ?? 0) }
case .longestFirst, .shortestFirst, .airline, .flightNumber:
return [Group(key: sort.rawValue, flights: list)]
}
}
// MARK: - Active filter chips
private var activeChips: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
if !filters.query.isEmpty {
chip("\(filters.query)", systemImage: "magnifyingglass") { filters.query = "" }
}
ForEach(Array(filters.years).sorted(by: >), id: \.self) { y in
chip("\(y)", systemImage: "calendar") { filters.years.remove(y) }
}
ForEach(Array(filters.airlines).sorted(), id: \.self) { a in
chip(AircraftRegistry.shared.lookup(icao: a)?.name ?? a, systemImage: "building.2") {
filters.airlines.remove(a)
}
}
ForEach(Array(filters.airports).sorted(), id: \.self) { a in
chip(a, systemImage: "airplane") { filters.airports.remove(a) }
}
ForEach(Array(filters.aircraftTypes).sorted(), id: \.self) { t in
chip(t, systemImage: "airplane.departure") { filters.aircraftTypes.remove(t) }
}
Button {
filters = HistoryFilters()
} label: {
Text("Clear")
.font(.caption.weight(.semibold))
.foregroundStyle(FlightTheme.accent)
.padding(.horizontal, 12)
.padding(.vertical, 6)
}
}
}
}
private func chip(_ label: String, systemImage: String, onRemove: @escaping () -> Void) -> some View {
HStack(spacing: 4) {
Image(systemName: systemImage).font(.caption2)
Text(label).font(.caption.weight(.semibold))
Image(systemName: "xmark").font(.caption2)
}
.foregroundStyle(.white)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(FlightTheme.accent, in: Capsule())
.onTapGesture(perform: onRemove)
}
// MARK: - Totals strip
private func totalsStrip(stats: StatsEngine) -> some View {
private func totalsStrip(stats: StatsEngine, isFiltered: Bool) -> some View {
VStack(spacing: 6) {
if isFiltered {
Text("FILTERED TOTALS")
.font(.caption2.weight(.semibold))
.tracking(0.8)
.foregroundStyle(FlightTheme.textTertiary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
}
HStack(spacing: 12) {
statTile(value: "\(stats.totalFlights)", label: "flights")
statTile(value: stats.shortDistance, label: "miles")
@@ -145,7 +260,8 @@ struct HistoryView: View {
statTile(value: "\(stats.uniqueAirports)", label: "airports")
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.padding(.vertical, 8)
}
}
private func statTile(value: String, label: String) -> some View {
@@ -163,7 +279,7 @@ struct HistoryView: View {
.background(FlightTheme.cardBackground, in: RoundedRectangle(cornerRadius: 10))
}
// MARK: - Empty state
// MARK: - Empty states
private var emptyState: some View {
VStack(spacing: 10) {
@@ -173,7 +289,7 @@ struct HistoryView: View {
Text("No flights logged yet")
.font(.headline)
.foregroundStyle(FlightTheme.textSecondary)
Text("Tap + to add a flight manually, scan your calendar, or tap an aircraft on the Live tab and add it from there.")
Text("Tap + to add a flight manually, scan your calendar, import a CSV, or tap an aircraft on the Live tab.")
.font(.caption)
.multilineTextAlignment(.center)
.foregroundStyle(FlightTheme.textTertiary)
@@ -183,4 +299,20 @@ struct HistoryView: View {
.padding(.vertical, 40)
.listRowBackground(Color.clear)
}
private var noMatchState: some View {
VStack(spacing: 10) {
Image(systemName: "line.3.horizontal.decrease.circle")
.font(.system(size: 36))
.foregroundStyle(FlightTheme.textTertiary)
Text("No flights match these filters")
.font(.subheadline)
.foregroundStyle(FlightTheme.textSecondary)
Button("Clear filters") { filters = HistoryFilters() }
.font(.subheadline)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 28)
.listRowBackground(Color.clear)
}
}