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:
@@ -78,6 +78,9 @@
|
|||||||
HX1000001000000010000001 /* AirframeMetadataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1000001000000010000002 /* AirframeMetadataService.swift */; };
|
HX1000001000000010000001 /* AirframeMetadataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1000001000000010000002 /* AirframeMetadataService.swift */; };
|
||||||
HX1100001100000011000001 /* CSVFlightImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1100001100000011000002 /* CSVFlightImporter.swift */; };
|
HX1100001100000011000001 /* CSVFlightImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1100001100000011000002 /* CSVFlightImporter.swift */; };
|
||||||
HX1200001200000012000001 /* ImportCSVView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1200001200000012000002 /* ImportCSVView.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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -165,6 +168,9 @@
|
|||||||
HX1000001000000010000002 /* AirframeMetadataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirframeMetadataService.swift; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -212,6 +218,8 @@
|
|||||||
HX0E000EEEE000EEEE000002 /* HistoryRouteMapView.swift */,
|
HX0E000EEEE000EEEE000002 /* HistoryRouteMapView.swift */,
|
||||||
HX0F000FFFF000FFFF000002 /* YearInReviewView.swift */,
|
HX0F000FFFF000FFFF000002 /* YearInReviewView.swift */,
|
||||||
HX1200001200000012000002 /* ImportCSVView.swift */,
|
HX1200001200000012000002 /* ImportCSVView.swift */,
|
||||||
|
HX1400001400000014000002 /* HistoryFilterSheet.swift */,
|
||||||
|
HX1500001500000015000002 /* AirportFlightsView.swift */,
|
||||||
AA5555555555555555555555 /* Styles */,
|
AA5555555555555555555555 /* Styles */,
|
||||||
AA6666666666666666666666 /* Components */,
|
AA6666666666666666666666 /* Components */,
|
||||||
);
|
);
|
||||||
@@ -302,6 +310,7 @@
|
|||||||
HX0700007777000077770002 /* WalletPassObserver.swift */,
|
HX0700007777000077770002 /* WalletPassObserver.swift */,
|
||||||
HX1000001000000010000002 /* AirframeMetadataService.swift */,
|
HX1000001000000010000002 /* AirframeMetadataService.swift */,
|
||||||
HX1100001100000011000002 /* CSVFlightImporter.swift */,
|
HX1100001100000011000002 /* CSVFlightImporter.swift */,
|
||||||
|
HX1300001300000013000002 /* HistoryFilters.swift */,
|
||||||
);
|
);
|
||||||
path = Services;
|
path = Services;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -495,6 +504,9 @@
|
|||||||
HX1000001000000010000001 /* AirframeMetadataService.swift in Sources */,
|
HX1000001000000010000001 /* AirframeMetadataService.swift in Sources */,
|
||||||
HX1100001100000011000001 /* CSVFlightImporter.swift in Sources */,
|
HX1100001100000011000001 /* CSVFlightImporter.swift in Sources */,
|
||||||
HX1200001200000012000001 /* ImportCSVView.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;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,81 +2,151 @@ import SwiftUI
|
|||||||
import MapKit
|
import MapKit
|
||||||
import CoreLocation
|
import CoreLocation
|
||||||
|
|
||||||
/// Lifetime route map — every great-circle arc the user has flown,
|
/// Interactive lifetime route map.
|
||||||
/// with airport dots sized by visit count. Arcs animate in oldest
|
/// - Animated great-circle arcs for every flight in the current
|
||||||
/// → newest on first appear.
|
/// (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 {
|
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 database: AirportDatabase
|
||||||
|
let openSky: OpenSkyClient
|
||||||
|
let store: FlightHistoryStore
|
||||||
|
@Binding var filters: HistoryFilters
|
||||||
|
|
||||||
@State private var revealCount: Int = 0
|
@State private var revealCount: Int = 0
|
||||||
@State private var position: MapCameraPosition = .automatic
|
@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 {
|
var body: some View {
|
||||||
let arcs = self.arcs
|
let arcs = self.arcs
|
||||||
|
|
||||||
return Map(position: $position) {
|
return Map(position: $position) {
|
||||||
// Airport dots sized by visit count
|
// Airport dots
|
||||||
ForEach(airportItems, id: \.iata) { item in
|
ForEach(airportItems, id: \.iata) { item in
|
||||||
Annotation(item.iata, coordinate: item.coord) {
|
Annotation(item.iata, coordinate: item.coord) {
|
||||||
Circle()
|
AirportDot(
|
||||||
.fill(FlightTheme.accent)
|
size: dotSize(for: item.count),
|
||||||
.frame(width: dotSize(for: item.count), height: dotSize(for: item.count))
|
isSelected: filters.airports.contains(item.iata)
|
||||||
.overlay(Circle().stroke(.white, lineWidth: 1))
|
)
|
||||||
|
.onTapGesture { selectedAirportSheet = AirportSheet(iata: item.iata) }
|
||||||
}
|
}
|
||||||
.annotationTitles(.hidden)
|
.annotationTitles(.hidden)
|
||||||
}
|
}
|
||||||
// Animated great-circle arcs
|
// Animated arcs
|
||||||
ForEach(arcs.prefix(revealCount)) { arc in
|
ForEach(arcs.prefix(revealCount)) { arc in
|
||||||
MapPolyline(coordinates: arc.coords)
|
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))
|
.mapStyle(.standard(elevation: .flat))
|
||||||
.navigationTitle("Routes")
|
.navigationTitle(filters.isEmpty ? "Lifetime Routes" : "Filtered Routes")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.task {
|
.toolbar {
|
||||||
// Stagger reveal animation: ~30 ms per arc, capped so 200+
|
ToolbarItem(placement: .primaryAction) {
|
||||||
// flights still finish revealing in a reasonable time.
|
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)))
|
let step = max(0.012, min(0.04, 4.0 / Double(arcs.count + 1)))
|
||||||
for i in 0...arcs.count {
|
for i in 0...arcs.count {
|
||||||
revealCount = i
|
revealCount = i
|
||||||
try? await Task.sleep(nanoseconds: UInt64(step * 1_000_000_000))
|
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 {
|
private struct Arc: Identifiable {
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
let coords: [CLLocationCoordinate2D]
|
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 {
|
private struct AirportItem: Hashable {
|
||||||
let iata: String
|
let iata: String
|
||||||
let coord: CLLocationCoordinate2D
|
let coord: CLLocationCoordinate2D
|
||||||
let count: Int
|
let count: Int
|
||||||
|
|
||||||
static func == (lhs: AirportItem, rhs: AirportItem) -> Bool {
|
static func == (lhs: AirportItem, rhs: AirportItem) -> Bool { lhs.iata == rhs.iata }
|
||||||
lhs.iata == rhs.iata
|
|
||||||
}
|
|
||||||
func hash(into hasher: inout Hasher) { hasher.combine(iata) }
|
func hash(into hasher: inout Hasher) { hasher.combine(iata) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var arcs: [Arc] {
|
/// Airport dot universe is the FULL flight log (not the filtered
|
||||||
let sorted = flights.sorted { $0.flightDate < $1.flightDate }
|
/// view) so panning around with filters on doesn't make airports
|
||||||
return sorted.compactMap { f in
|
/// pop in and out as the user toggles airlines.
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var airportItems: [AirportItem] {
|
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)
|
let counts = Dictionary(grouping: codes) { $0 }.mapValues(\.count)
|
||||||
return counts.compactMap { code, count in
|
return counts.compactMap { code, count in
|
||||||
guard let m = database.airport(byIATA: code) else { return nil }
|
guard let m = database.airport(byIATA: code) else { return nil }
|
||||||
@@ -86,7 +156,7 @@ struct HistoryRouteMapView: View {
|
|||||||
|
|
||||||
private func dotSize(for count: Int) -> CGFloat {
|
private func dotSize(for count: Int) -> CGFloat {
|
||||||
let v = log(Double(count) + 1) * 4 + 6
|
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
|
/// Polyline samples along the great-circle path between two
|
||||||
@@ -120,3 +190,21 @@ struct HistoryRouteMapView: View {
|
|||||||
return out
|
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
@@ -1,10 +1,9 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
/// Top-level history tab. Shows a totals strip + grouped-by-year list
|
/// Top-level history tab. Totals strip + sortable / filterable / searchable
|
||||||
/// of every flight the user has logged. Toolbar provides add paths
|
/// list of every flight you've logged, plus entry points to the lifetime
|
||||||
/// (manual, calendar scan) plus a button to drill into lifetime stats
|
/// stats / route map / year-in-review screens.
|
||||||
/// and the lifetime route map.
|
|
||||||
struct HistoryView: View {
|
struct HistoryView: View {
|
||||||
let database: AirportDatabase
|
let database: AirportDatabase
|
||||||
let routeExplorer: RouteExplorerClient
|
let routeExplorer: RouteExplorerClient
|
||||||
@@ -15,27 +14,39 @@ struct HistoryView: View {
|
|||||||
@Query(sort: \LoggedFlight.flightDate, order: .reverse)
|
@Query(sort: \LoggedFlight.flightDate, order: .reverse)
|
||||||
private var flights: [LoggedFlight]
|
private var flights: [LoggedFlight]
|
||||||
|
|
||||||
|
@State private var filters: HistoryFilters = .init()
|
||||||
|
@State private var sort: HistorySort = .newestFirst
|
||||||
|
|
||||||
@State private var showingAdd = false
|
@State private var showingAdd = false
|
||||||
@State private var showingStats = false
|
@State private var showingStats = false
|
||||||
@State private var showingMap = false
|
@State private var showingMap = false
|
||||||
@State private var showingCalendarImport = false
|
@State private var showingCalendarImport = false
|
||||||
@State private var showingCSVImport = false
|
@State private var showingCSVImport = false
|
||||||
@State private var showingYearInReview = false
|
@State private var showingYearInReview = false
|
||||||
|
@State private var showingFilterSheet = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let store = FlightHistoryStore(context: modelContext, airportDatabase: database)
|
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 {
|
return List {
|
||||||
if !flights.isEmpty {
|
if !flights.isEmpty {
|
||||||
Section {
|
Section {
|
||||||
totalsStrip(stats: stats)
|
totalsStrip(stats: stats, isFiltered: !filters.isEmpty)
|
||||||
.listRowInsets(EdgeInsets())
|
.listRowInsets(EdgeInsets())
|
||||||
.listRowBackground(Color.clear)
|
.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
|
ForEach(group.flights) { flight in
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
HistoryDetailView(
|
HistoryDetailView(
|
||||||
@@ -55,11 +66,42 @@ struct HistoryView: View {
|
|||||||
}
|
}
|
||||||
if flights.isEmpty {
|
if flights.isEmpty {
|
||||||
emptyState
|
emptyState
|
||||||
|
} else if visible.isEmpty {
|
||||||
|
noMatchState
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.insetGrouped)
|
.listStyle(.insetGrouped)
|
||||||
.navigationTitle("History")
|
.navigationTitle("History")
|
||||||
|
.searchable(text: $filters.query, placement: .navigationBarDrawer(displayMode: .always), prompt: "Flight #, airport, route")
|
||||||
.toolbar {
|
.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) {
|
ToolbarItem(placement: .primaryAction) {
|
||||||
Menu {
|
Menu {
|
||||||
Button { showingAdd = true } label: {
|
Button { showingAdd = true } label: {
|
||||||
@@ -88,29 +130,25 @@ struct HistoryView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingAdd) {
|
.sheet(isPresented: $showingAdd) {
|
||||||
AddFlightView(
|
AddFlightView(routeExplorer: routeExplorer, database: database, store: store, prefill: nil)
|
||||||
routeExplorer: routeExplorer,
|
|
||||||
database: database,
|
|
||||||
store: store,
|
|
||||||
prefill: nil
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingStats) {
|
.sheet(isPresented: $showingStats) {
|
||||||
NavigationStack {
|
NavigationStack { LifetimeStatsView(stats: stats) }
|
||||||
LifetimeStatsView(stats: stats)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingMap) {
|
.sheet(isPresented: $showingMap) {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
HistoryRouteMapView(flights: flights, database: database)
|
HistoryRouteMapView(
|
||||||
|
flights: visible,
|
||||||
|
allFlights: flights,
|
||||||
|
database: database,
|
||||||
|
openSky: openSky,
|
||||||
|
store: store,
|
||||||
|
filters: $filters
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingCalendarImport) {
|
.sheet(isPresented: $showingCalendarImport) {
|
||||||
CalendarImportView(
|
CalendarImportView(routeExplorer: routeExplorer, database: database, store: store)
|
||||||
routeExplorer: routeExplorer,
|
|
||||||
database: database,
|
|
||||||
store: store
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingCSVImport) {
|
.sheet(isPresented: $showingCSVImport) {
|
||||||
ImportCSVView(store: store)
|
ImportCSVView(store: store)
|
||||||
@@ -118,26 +156,103 @@ struct HistoryView: View {
|
|||||||
.sheet(isPresented: $showingYearInReview) {
|
.sheet(isPresented: $showingYearInReview) {
|
||||||
YearInReviewView(stats: stats, year: Calendar.current.component(.year, from: Date()))
|
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 {
|
private func filteredSorted(store: FlightHistoryStore) -> [LoggedFlight] {
|
||||||
let year: Int
|
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]
|
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 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
|
return grouped
|
||||||
.map { YearGroup(year: $0.key, flights: $0.value) }
|
.map { Group(key: String($0.key), flights: $0.value) }
|
||||||
.sorted { $0.year > $1.year }
|
.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
|
// 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) {
|
HStack(spacing: 12) {
|
||||||
statTile(value: "\(stats.totalFlights)", label: "flights")
|
statTile(value: "\(stats.totalFlights)", label: "flights")
|
||||||
statTile(value: stats.shortDistance, label: "miles")
|
statTile(value: stats.shortDistance, label: "miles")
|
||||||
@@ -145,7 +260,8 @@ struct HistoryView: View {
|
|||||||
statTile(value: "\(stats.uniqueAirports)", label: "airports")
|
statTile(value: "\(stats.uniqueAirports)", label: "airports")
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func statTile(value: String, label: String) -> some View {
|
private func statTile(value: String, label: String) -> some View {
|
||||||
@@ -163,7 +279,7 @@ struct HistoryView: View {
|
|||||||
.background(FlightTheme.cardBackground, in: RoundedRectangle(cornerRadius: 10))
|
.background(FlightTheme.cardBackground, in: RoundedRectangle(cornerRadius: 10))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Empty state
|
// MARK: - Empty states
|
||||||
|
|
||||||
private var emptyState: some View {
|
private var emptyState: some View {
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
@@ -173,7 +289,7 @@ struct HistoryView: View {
|
|||||||
Text("No flights logged yet")
|
Text("No flights logged yet")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundStyle(FlightTheme.textSecondary)
|
.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)
|
.font(.caption)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.foregroundStyle(FlightTheme.textTertiary)
|
.foregroundStyle(FlightTheme.textTertiary)
|
||||||
@@ -183,4 +299,20 @@ struct HistoryView: View {
|
|||||||
.padding(.vertical, 40)
|
.padding(.vertical, 40)
|
||||||
.listRowBackground(Color.clear)
|
.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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user