diff --git a/Flights.xcodeproj/project.pbxproj b/Flights.xcodeproj/project.pbxproj index 148063f..197b0ab 100644 --- a/Flights.xcodeproj/project.pbxproj +++ b/Flights.xcodeproj/project.pbxproj @@ -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 = ""; }; HX1100001100000011000002 /* CSVFlightImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CSVFlightImporter.swift; sourceTree = ""; }; HX1200001200000012000002 /* ImportCSVView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportCSVView.swift; sourceTree = ""; }; + HX1300001300000013000002 /* HistoryFilters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryFilters.swift; sourceTree = ""; }; + HX1400001400000014000002 /* HistoryFilterSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryFilterSheet.swift; sourceTree = ""; }; + HX1500001500000015000002 /* AirportFlightsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirportFlightsView.swift; sourceTree = ""; }; /* 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 = ""; @@ -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; }; diff --git a/Flights/Services/HistoryFilters.swift b/Flights/Services/HistoryFilters.swift new file mode 100644 index 0000000..2fd49ef --- /dev/null +++ b/Flights/Services/HistoryFilters.swift @@ -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 = [] + var airlines: Set = [] // ICAO codes ("SWA") + var airports: Set = [] // IATA codes ("DAL") + var aircraftTypes: Set = [] // 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 + } + } + } +} diff --git a/Flights/Views/AirportFlightsView.swift b/Flights/Views/AirportFlightsView.swift new file mode 100644 index 0000000..8537e47 --- /dev/null +++ b/Flights/Views/AirportFlightsView.swift @@ -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 } + } +} diff --git a/Flights/Views/HistoryFilterSheet.swift b/Flights/Views/HistoryFilterSheet.swift new file mode 100644 index 0000000..bd2fdf2 --- /dev/null +++ b/Flights/Views/HistoryFilterSheet.swift @@ -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] { + 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] { + 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] { + 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] { + 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 { + 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(_ set: inout Set, _ v: T, _ on: Bool) { + if on { set.insert(v) } else { set.remove(v) } + } +} diff --git a/Flights/Views/HistoryRouteMapView.swift b/Flights/Views/HistoryRouteMapView.swift index fad8ca6..4c96ad5 100644 --- a/Flights/Views/HistoryRouteMapView.swift +++ b/Flights/Views/HistoryRouteMapView.swift @@ -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()) + } +} diff --git a/Flights/Views/HistoryView.swift b/Flights/Views/HistoryView.swift index 5d5b680..da8281f 100644 --- a/Flights/Views/HistoryView.swift +++ b/Flights/Views/HistoryView.swift @@ -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,34 +156,112 @@ 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] { - let cal = Calendar.current - let grouped = Dictionary(grouping: flights) { cal.component(.year, from: $0.flightDate) } - return grouped - .map { YearGroup(year: $0.key, flights: $0.value) } - .sorted { $0.year > $1.year } + /// 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: list) { cal.component(.year, from: $0.flightDate) } + let order: (Int, Int) -> Bool = sort == .newestFirst ? (>) : (<) + return grouped + .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 { - HStack(spacing: 12) { - statTile(value: "\(stats.totalFlights)", label: "flights") - statTile(value: stats.shortDistance, label: "miles") - statTile(value: stats.shortDuration, label: "hours") - statTile(value: "\(stats.uniqueAirports)", label: "airports") + 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") + statTile(value: stats.shortDuration, label: "hours") + statTile(value: "\(stats.uniqueAirports)", label: "airports") + } + .padding(.horizontal, 16) + .padding(.vertical, 8) } - .padding(.horizontal, 16) - .padding(.vertical, 10) } 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) + } }