History tab: passport redesign
Replaces the History tab end-to-end with a passport-styled experience modeled on Flighty's Passport but with its own identity: - New HistoryStyle palette: runway orange (#FF5722) + midnight navy + warm cream paper. Adaptive light/dark surfaces, mono-digit display numbers, card chrome modifier. Scoped to History so the rest of the app's FlightTheme stays untouched. - New PassportComponents library: HeroStatCard (orange / navy / gold / green / photo variants), YearTabStrip, OCRPassportFooter (the OCR passport-bottom flex text), StatColumn, HistorySectionLabel. Screens rewritten: - HistoryView — ScrollView feed with title header, year tab strip, stacked hero cards (this-year passport, most-flown aircraft, quick links to map/aircraft/year-in-review), and passport-styled flight rows in cards. Search, sort, filter, and add affordances live in the toolbar. - PassportView (was LifetimeStatsView) — stacked colored hero cards for flights, distance, time aloft, top route, top airline, longest flight, plus repeated-airframes list. Year tabs at top scope everything. OCR-passport flex footer at the bottom. - AircraftStatsView (new) — Total / Newest / Oldest header tiles, ranked list of types with the airframe photo as the row background, "Repeat Offender" hero card with the most-flown tail's photo full-bleed. - HistoryRouteMapView — satellite map style (.imagery), brighter arcs in runway orange with the most-recent leg in fluorescent yellow, persistent bottom navy drawer showing the passport summary + active filter chips + replay button. - YearInReviewView — horizontal TabView paged card deck, each card a full-bleed hero composition optimized for screenshot share. Cover card with year number set in 140pt monospaced bold. - HistoryDetailView — restyled with passport palette. Aircraft card uses a labeled grid (Type/Tail #/First Flight/Age/Repeats/ICAO24) with em-dashes for missing data. New Detailed Timetable card with Scheduled vs Actual columns, late times in red. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -81,6 +81,10 @@
|
|||||||
HX1300001300000013000001 /* HistoryFilters.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1300001300000013000002 /* HistoryFilters.swift */; };
|
HX1300001300000013000001 /* HistoryFilters.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1300001300000013000002 /* HistoryFilters.swift */; };
|
||||||
HX1400001400000014000001 /* HistoryFilterSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1400001400000014000002 /* HistoryFilterSheet.swift */; };
|
HX1400001400000014000001 /* HistoryFilterSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1400001400000014000002 /* HistoryFilterSheet.swift */; };
|
||||||
HX1500001500000015000001 /* AirportFlightsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1500001500000015000002 /* AirportFlightsView.swift */; };
|
HX1500001500000015000001 /* AirportFlightsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1500001500000015000002 /* AirportFlightsView.swift */; };
|
||||||
|
HX1600001600000016000001 /* HistoryStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1600001600000016000002 /* HistoryStyle.swift */; };
|
||||||
|
HX1700001700000017000001 /* PassportComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1700001700000017000002 /* PassportComponents.swift */; };
|
||||||
|
HX1800001800000018000001 /* PassportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1800001800000018000002 /* PassportView.swift */; };
|
||||||
|
HX1900001900000019000001 /* AircraftStatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1900001900000019000002 /* AircraftStatsView.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -171,6 +175,10 @@
|
|||||||
HX1300001300000013000002 /* HistoryFilters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryFilters.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>"; };
|
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>"; };
|
HX1500001500000015000002 /* AirportFlightsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirportFlightsView.swift; sourceTree = "<group>"; };
|
||||||
|
HX1600001600000016000002 /* HistoryStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryStyle.swift; sourceTree = "<group>"; };
|
||||||
|
HX1700001700000017000002 /* PassportComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PassportComponents.swift; sourceTree = "<group>"; };
|
||||||
|
HX1800001800000018000002 /* PassportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PassportView.swift; sourceTree = "<group>"; };
|
||||||
|
HX1900001900000019000002 /* AircraftStatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AircraftStatsView.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -220,6 +228,9 @@
|
|||||||
HX1200001200000012000002 /* ImportCSVView.swift */,
|
HX1200001200000012000002 /* ImportCSVView.swift */,
|
||||||
HX1400001400000014000002 /* HistoryFilterSheet.swift */,
|
HX1400001400000014000002 /* HistoryFilterSheet.swift */,
|
||||||
HX1500001500000015000002 /* AirportFlightsView.swift */,
|
HX1500001500000015000002 /* AirportFlightsView.swift */,
|
||||||
|
HX1700001700000017000002 /* PassportComponents.swift */,
|
||||||
|
HX1800001800000018000002 /* PassportView.swift */,
|
||||||
|
HX1900001900000019000002 /* AircraftStatsView.swift */,
|
||||||
AA5555555555555555555555 /* Styles */,
|
AA5555555555555555555555 /* Styles */,
|
||||||
AA6666666666666666666666 /* Components */,
|
AA6666666666666666666666 /* Components */,
|
||||||
);
|
);
|
||||||
@@ -230,6 +241,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
AA2222222222222222222222 /* FlightTheme.swift */,
|
AA2222222222222222222222 /* FlightTheme.swift */,
|
||||||
|
HX1600001600000016000002 /* HistoryStyle.swift */,
|
||||||
);
|
);
|
||||||
path = Styles;
|
path = Styles;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -507,6 +519,10 @@
|
|||||||
HX1300001300000013000001 /* HistoryFilters.swift in Sources */,
|
HX1300001300000013000001 /* HistoryFilters.swift in Sources */,
|
||||||
HX1400001400000014000001 /* HistoryFilterSheet.swift in Sources */,
|
HX1400001400000014000001 /* HistoryFilterSheet.swift in Sources */,
|
||||||
HX1500001500000015000001 /* AirportFlightsView.swift in Sources */,
|
HX1500001500000015000001 /* AirportFlightsView.swift in Sources */,
|
||||||
|
HX1600001600000016000001 /* HistoryStyle.swift in Sources */,
|
||||||
|
HX1700001700000017000001 /* PassportComponents.swift in Sources */,
|
||||||
|
HX1800001800000018000001 /* PassportView.swift in Sources */,
|
||||||
|
HX1900001900000019000001 /* AircraftStatsView.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Aircraft Stats screen — Total / Newest / Oldest header row, then a
|
||||||
|
/// ranked list of types you've flown with real airframe photos as row
|
||||||
|
/// backgrounds, plus a "Most Flown Tail" hero card at the bottom.
|
||||||
|
struct AircraftStatsView: View {
|
||||||
|
let allFlights: [LoggedFlight]
|
||||||
|
let store: FlightHistoryStore
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.colorScheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
header
|
||||||
|
topStatsRow
|
||||||
|
typeListSection
|
||||||
|
mostFlownTailSection
|
||||||
|
Spacer(minLength: 60)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
.background(HistoryStyle.background(scheme).ignoresSafeArea())
|
||||||
|
.navigationTitle("")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button { dismiss() } label: { Image(systemName: "xmark") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text("AIRCRAFT")
|
||||||
|
.font(.system(size: 40, weight: .black))
|
||||||
|
.tracking(-0.5)
|
||||||
|
.foregroundStyle(HistoryStyle.ink(scheme))
|
||||||
|
Rectangle()
|
||||||
|
.fill(HistoryStyle.runwayOrange)
|
||||||
|
.frame(width: 38, height: 3)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Top stats row (3 column tile bar)
|
||||||
|
|
||||||
|
private var topStatsRow: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
statTile(label: "Total", value: "\(uniqueTypeCodes.count)", subtitle: "types flown")
|
||||||
|
statTile(
|
||||||
|
label: "Newest",
|
||||||
|
value: newestAirframeAgeLabel(),
|
||||||
|
subtitle: newestAirframeYearLabel()
|
||||||
|
)
|
||||||
|
statTile(
|
||||||
|
label: "Oldest",
|
||||||
|
value: oldestAirframeAgeLabel(),
|
||||||
|
subtitle: oldestAirframeYearLabel()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statTile(label: String, value: String, subtitle: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(label.uppercased())
|
||||||
|
.font(HistoryStyle.label(10))
|
||||||
|
.tracking(1.3)
|
||||||
|
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
|
||||||
|
Text(value)
|
||||||
|
.font(HistoryStyle.displayNumber(22))
|
||||||
|
.foregroundStyle(HistoryStyle.ink(scheme))
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.6)
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(14)
|
||||||
|
.background(HistoryStyle.card(scheme), in: RoundedRectangle(cornerRadius: 16))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Type list
|
||||||
|
|
||||||
|
private var typeListSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HistorySectionLabel("By type")
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
ForEach(rankedTypes, id: \.code) { item in
|
||||||
|
AircraftTypeCard(type: item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TypeRanked {
|
||||||
|
let code: String
|
||||||
|
let displayName: String
|
||||||
|
let count: Int
|
||||||
|
let sampleRegistration: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private var rankedTypes: [TypeRanked] {
|
||||||
|
let byType = Dictionary(grouping: allFlights.filter { $0.aircraftType != nil }) { $0.aircraftType! }
|
||||||
|
return byType.map { code, list in
|
||||||
|
TypeRanked(
|
||||||
|
code: code,
|
||||||
|
displayName: AircraftDatabase.shared.displayName(forTypeCode: code),
|
||||||
|
count: list.count,
|
||||||
|
sampleRegistration: list.compactMap { $0.registration }.first
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.sorted { $0.count > $1.count }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Most-flown tail hero
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var mostFlownTailSection: some View {
|
||||||
|
if let top = mostFlownTail {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HistorySectionLabel("Most flown airframe")
|
||||||
|
MostFlownTailCard(reg: top.reg, count: top.count, sample: top.sampleFlight)
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MostFlownTail {
|
||||||
|
let reg: String
|
||||||
|
let count: Int
|
||||||
|
let sampleFlight: LoggedFlight
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mostFlownTail: MostFlownTail? {
|
||||||
|
let byReg = Dictionary(grouping: allFlights.filter { $0.registration != nil }) { $0.registration! }
|
||||||
|
guard let top = byReg.max(by: { $0.value.count < $1.value.count }),
|
||||||
|
let sample = top.value.first
|
||||||
|
else { return nil }
|
||||||
|
return MostFlownTail(reg: top.key, count: top.value.count, sampleFlight: sample)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Computed metadata helpers
|
||||||
|
|
||||||
|
private var uniqueTypeCodes: Set<String> {
|
||||||
|
Set(allFlights.compactMap { $0.aircraftType })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up airframes with metadata and find the youngest one we've
|
||||||
|
/// flown on. Falls back to a "—" if no airframe has firstFlight
|
||||||
|
/// data cached yet.
|
||||||
|
private func newestAirframeAgeLabel() -> String {
|
||||||
|
guard let m = airframeWith(.newest), let date = m.firstFlightDate else { return "—" }
|
||||||
|
let years = Calendar.current.dateComponents([.year], from: date, to: Date()).year ?? 0
|
||||||
|
return "\(years)y"
|
||||||
|
}
|
||||||
|
private func newestAirframeYearLabel() -> String {
|
||||||
|
guard let m = airframeWith(.newest), let date = m.firstFlightDate else { return "—" }
|
||||||
|
let f = DateFormatter(); f.dateFormat = "yyyy"
|
||||||
|
return f.string(from: date)
|
||||||
|
}
|
||||||
|
private func oldestAirframeAgeLabel() -> String {
|
||||||
|
guard let m = airframeWith(.oldest), let date = m.firstFlightDate else { return "—" }
|
||||||
|
let years = Calendar.current.dateComponents([.year], from: date, to: Date()).year ?? 0
|
||||||
|
return "\(years)y"
|
||||||
|
}
|
||||||
|
private func oldestAirframeYearLabel() -> String {
|
||||||
|
guard let m = airframeWith(.oldest), let date = m.firstFlightDate else { return "—" }
|
||||||
|
let f = DateFormatter(); f.dateFormat = "yyyy"
|
||||||
|
return f.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum AirframePick { case newest, oldest }
|
||||||
|
|
||||||
|
private func airframeWith(_ pick: AirframePick) -> AirframeMetadata? {
|
||||||
|
let regs = Set(allFlights.compactMap { $0.registration })
|
||||||
|
let metas = regs.compactMap { store.airframe(for: $0) }
|
||||||
|
.filter { $0.firstFlightDate != nil }
|
||||||
|
switch pick {
|
||||||
|
case .newest: return metas.max(by: { ($0.firstFlightDate ?? .distantPast) < ($1.firstFlightDate ?? .distantPast) })
|
||||||
|
case .oldest: return metas.min(by: { ($0.firstFlightDate ?? .distantPast) < ($1.firstFlightDate ?? .distantPast) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Aircraft type card with photo background
|
||||||
|
|
||||||
|
struct AircraftTypeCard: View {
|
||||||
|
let type: AircraftStatsView.TypeRanked
|
||||||
|
@State private var photo: AircraftPhotoService.Photo?
|
||||||
|
@Environment(\.colorScheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .bottomLeading) {
|
||||||
|
background
|
||||||
|
.frame(height: 120)
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.black.opacity(0.0), Color.black.opacity(0.85)],
|
||||||
|
startPoint: .top, endPoint: .bottom
|
||||||
|
)
|
||||||
|
.frame(height: 120)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(type.displayName.uppercased())
|
||||||
|
.font(.system(size: 13, weight: .heavy))
|
||||||
|
.tracking(1.5)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
Text(type.code)
|
||||||
|
.font(.system(size: 26, weight: .black).monospaced())
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
Text("\(type.count)")
|
||||||
|
.font(HistoryStyle.displayNumber(28))
|
||||||
|
.foregroundStyle(HistoryStyle.runwayOrange)
|
||||||
|
Text("FLIGHTS")
|
||||||
|
.font(.system(size: 9, weight: .bold))
|
||||||
|
.tracking(1.3)
|
||||||
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||||
|
}
|
||||||
|
.frame(height: 120)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
.task(id: type.sampleRegistration ?? type.code) {
|
||||||
|
guard let reg = type.sampleRegistration else { return }
|
||||||
|
photo = await AircraftPhotoService.shared.photo(registration: reg, icao24: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var background: some View {
|
||||||
|
if let url = photo?.largeURL {
|
||||||
|
AsyncImage(url: url) { phase in
|
||||||
|
switch phase {
|
||||||
|
case .success(let img): img.resizable().aspectRatio(contentMode: .fill)
|
||||||
|
default: HistoryStyle.cardSubtle(scheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ZStack {
|
||||||
|
HistoryStyle.cardSubtle(scheme)
|
||||||
|
Image(systemName: "airplane")
|
||||||
|
.font(.system(size: 60, weight: .heavy))
|
||||||
|
.foregroundStyle(HistoryStyle.runwayOrange.opacity(0.25))
|
||||||
|
.rotationEffect(.degrees(-15))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Most flown tail hero
|
||||||
|
|
||||||
|
struct MostFlownTailCard: View {
|
||||||
|
let reg: String
|
||||||
|
let count: Int
|
||||||
|
let sample: LoggedFlight
|
||||||
|
@State private var photo: AircraftPhotoService.Photo?
|
||||||
|
@Environment(\.colorScheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .bottomLeading) {
|
||||||
|
background.frame(height: 240)
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.black.opacity(0.0), Color.black.opacity(0.85)],
|
||||||
|
startPoint: .center, endPoint: .bottom
|
||||||
|
)
|
||||||
|
.frame(height: 240)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("REPEAT OFFENDER")
|
||||||
|
.font(.system(size: 10, weight: .heavy))
|
||||||
|
.tracking(1.8)
|
||||||
|
.foregroundStyle(HistoryStyle.runwayOrange)
|
||||||
|
Text(reg)
|
||||||
|
.font(.system(size: 42, weight: .black).monospaced())
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
if let type = sample.aircraftType {
|
||||||
|
kvp(value: type, label: "Type")
|
||||||
|
}
|
||||||
|
kvp(value: "\(count)×", label: "Flown")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(18)
|
||||||
|
}
|
||||||
|
.frame(height: 240)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||||
|
.task(id: reg) {
|
||||||
|
photo = await AircraftPhotoService.shared.photo(registration: reg, icao24: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var background: some View {
|
||||||
|
if let url = photo?.largeURL {
|
||||||
|
AsyncImage(url: url) { phase in
|
||||||
|
switch phase {
|
||||||
|
case .success(let img): img.resizable().aspectRatio(contentMode: .fill)
|
||||||
|
default: HistoryStyle.heroNavyGradient
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HistoryStyle.heroNavyGradient
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func kvp(value: String, label: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text(value)
|
||||||
|
.font(.system(size: 14, weight: .heavy).monospaced())
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
Text(label.uppercased())
|
||||||
|
.font(.system(size: 9, weight: .bold))
|
||||||
|
.tracking(0.8)
|
||||||
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,11 @@ import SwiftUI
|
|||||||
import MapKit
|
import MapKit
|
||||||
import CoreLocation
|
import CoreLocation
|
||||||
|
|
||||||
/// Single-flight detail screen. Layout follows the live detail sheet
|
/// Single-flight detail screen — restyled with the passport palette.
|
||||||
/// pattern (title → route → photo → map → aircraft) but adds notes
|
/// Aircraft card now uses Flighty's labeled-grid pattern with
|
||||||
/// and a delete button. Pulls a track replay from OpenSky for flights
|
/// em-dashes for missing data. New "Detailed Timetable" card shows
|
||||||
/// flown in the last ~7 days; everything older falls back to a clean
|
/// scheduled vs actual when we have actual times, with late actuals
|
||||||
/// great-circle arc.
|
/// in red.
|
||||||
struct HistoryDetailView: View {
|
struct HistoryDetailView: View {
|
||||||
let flight: LoggedFlight
|
let flight: LoggedFlight
|
||||||
let store: FlightHistoryStore
|
let store: FlightHistoryStore
|
||||||
@@ -15,34 +15,33 @@ struct HistoryDetailView: View {
|
|||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(\.openURL) private var openURL
|
@Environment(\.openURL) private var openURL
|
||||||
|
@Environment(\.colorScheme) private var scheme
|
||||||
|
|
||||||
@State private var photo: AircraftPhotoService.Photo?
|
@State private var photo: AircraftPhotoService.Photo?
|
||||||
@State private var track: AircraftTrack?
|
@State private var track: AircraftTrack?
|
||||||
@State private var editedNotes: String = ""
|
@State private var editedNotes: String = ""
|
||||||
@State private var showDeleteConfirm = false
|
@State private var showDeleteConfirm = false
|
||||||
/// Re-render trigger after we upsert airframe metadata. SwiftData
|
|
||||||
/// changes don't auto-invalidate non-@Query views.
|
|
||||||
@State private var metadataLoaded = false
|
@State private var metadataLoaded = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
header
|
header
|
||||||
routeCard
|
routeCard
|
||||||
photoBanner
|
photoBanner.padding(.horizontal, -16)
|
||||||
.padding(.horizontal, -16)
|
|
||||||
if let cred = photo?.photographer {
|
if let cred = photo?.photographer {
|
||||||
photoCredit(name: cred, link: photo?.detailLink)
|
photoCredit(name: cred, link: photo?.detailLink)
|
||||||
}
|
}
|
||||||
mapSection
|
mapSection
|
||||||
aircraftCard
|
aircraftCard
|
||||||
|
timetableCard
|
||||||
notesSection
|
notesSection
|
||||||
deleteButton
|
deleteButton
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
}
|
}
|
||||||
.background(FlightTheme.background.ignoresSafeArea())
|
.background(HistoryStyle.background(scheme).ignoresSafeArea())
|
||||||
.navigationTitle(flight.flightLabel)
|
.navigationTitle("")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.task {
|
.task {
|
||||||
editedNotes = flight.notes ?? ""
|
editedNotes = flight.notes ?? ""
|
||||||
@@ -64,81 +63,92 @@ struct HistoryDetailView: View {
|
|||||||
// MARK: - Header
|
// MARK: - Header
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
HStack(spacing: 10) {
|
|
||||||
if let logo = airlineLogoURL {
|
|
||||||
AsyncImage(url: logo) { phase in
|
|
||||||
switch phase {
|
|
||||||
case .success(let img): img.resizable().scaledToFit()
|
|
||||||
default: RoundedRectangle(cornerRadius: 8).fill(FlightTheme.accent.opacity(0.2))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(width: 36, height: 36)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
}
|
|
||||||
Text(flight.flightLabel)
|
Text(flight.flightLabel)
|
||||||
.font(.title.weight(.bold).monospaced())
|
.font(.system(size: 38, weight: .black).monospaced())
|
||||||
.foregroundStyle(FlightTheme.textPrimary)
|
.foregroundStyle(HistoryStyle.ink(scheme))
|
||||||
Spacer()
|
HStack(spacing: 8) {
|
||||||
Text(longDate(flight.flightDate))
|
|
||||||
.font(.caption.monospaced())
|
|
||||||
.foregroundStyle(FlightTheme.textTertiary)
|
|
||||||
}
|
|
||||||
Text(airlineName)
|
Text(airlineName)
|
||||||
.font(.subheadline)
|
.font(.system(size: 14, weight: .semibold))
|
||||||
.foregroundStyle(FlightTheme.textSecondary)
|
Text("·")
|
||||||
|
Text(longDate(flight.flightDate).uppercased())
|
||||||
|
.font(.system(size: 12, weight: .heavy).monospaced())
|
||||||
|
.tracking(1)
|
||||||
|
}
|
||||||
|
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Route card
|
private var airlineName: String {
|
||||||
|
AircraftRegistry.shared.lookup(icao: flight.carrierICAO)?.name
|
||||||
|
?? AircraftRegistry.shared.lookup(iata: flight.carrierIATA)?.name
|
||||||
|
?? flight.carrierIATA ?? "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Route
|
||||||
|
|
||||||
private var routeCard: some View {
|
private var routeCard: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(spacing: 14) {
|
||||||
Text("ROUTE")
|
HStack(alignment: .top) {
|
||||||
.font(FlightTheme.label())
|
routeEndpoint(iata: flight.departureIATA, label: "From", time: flight.actualDeparture ?? flight.scheduledDeparture)
|
||||||
.foregroundStyle(FlightTheme.textTertiary)
|
Spacer()
|
||||||
.tracking(1)
|
|
||||||
HStack(spacing: 16) {
|
|
||||||
endpoint(iata: flight.departureIATA, label: "Departed", time: flight.actualDeparture ?? flight.scheduledDeparture)
|
|
||||||
Image(systemName: "airplane")
|
Image(systemName: "airplane")
|
||||||
.font(.title3)
|
.font(.system(size: 24, weight: .heavy))
|
||||||
.foregroundStyle(FlightTheme.accent)
|
.foregroundStyle(HistoryStyle.runwayOrange)
|
||||||
.rotationEffect(.degrees(-45))
|
.rotationEffect(.degrees(-45))
|
||||||
endpoint(iata: flight.arrivalIATA, label: "Arrived", time: flight.actualArrival ?? flight.scheduledArrival)
|
Spacer()
|
||||||
|
routeEndpoint(iata: flight.arrivalIATA, label: "To", time: flight.actualArrival ?? flight.scheduledArrival)
|
||||||
}
|
}
|
||||||
|
Rectangle()
|
||||||
|
.fill(HistoryStyle.hairline(scheme))
|
||||||
|
.frame(height: 0.5)
|
||||||
|
HStack(spacing: 18) {
|
||||||
if let mi = store.distanceMiles(for: flight) {
|
if let mi = store.distanceMiles(for: flight) {
|
||||||
Text("\(numberString(mi)) miles · \(durationDisplay)")
|
miniStat(label: "Distance", value: "\(numberString(mi)) mi")
|
||||||
.font(.caption.monospaced())
|
}
|
||||||
.foregroundStyle(FlightTheme.textTertiary)
|
miniStat(label: "Duration", value: durationDisplay)
|
||||||
|
Spacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.flightCard()
|
.historyCard(scheme, padding: 18)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func endpoint(iata: String, label: String, time: Date?) -> some View {
|
private func routeEndpoint(iata: String, label: String, time: Date?) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(label)
|
Text(label.uppercased())
|
||||||
.font(.caption2)
|
.font(HistoryStyle.label(10))
|
||||||
.foregroundStyle(FlightTheme.textTertiary)
|
.tracking(1.3)
|
||||||
.tracking(0.5)
|
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
|
||||||
Text(iata.isEmpty ? "—" : iata)
|
Text(iata.isEmpty ? "—" : iata)
|
||||||
.font(FlightTheme.airportCode(28))
|
.font(.system(size: 32, weight: .black).monospaced())
|
||||||
.foregroundStyle(FlightTheme.textPrimary)
|
.foregroundStyle(HistoryStyle.ink(scheme))
|
||||||
if let m = database.airport(byIATA: iata) {
|
if let m = database.airport(byIATA: iata) {
|
||||||
Text(m.name)
|
Text(m.name)
|
||||||
.font(.caption2)
|
.font(.system(size: 11))
|
||||||
.foregroundStyle(FlightTheme.textSecondary)
|
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
if let time {
|
if let time {
|
||||||
Text(shortDateTime(time))
|
Text(shortDateTime(time))
|
||||||
.font(.caption2.monospaced())
|
.font(.system(size: 11, weight: .semibold).monospaced())
|
||||||
.foregroundStyle(FlightTheme.textTertiary)
|
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func miniStat(label: String, value: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(label.uppercased())
|
||||||
|
.font(HistoryStyle.label(9))
|
||||||
|
.tracking(1.2)
|
||||||
|
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
|
||||||
|
Text(value)
|
||||||
|
.font(.system(size: 14, weight: .heavy).monospaced())
|
||||||
|
.foregroundStyle(HistoryStyle.ink(scheme))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Photo
|
// MARK: - Photo
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -146,10 +156,8 @@ struct HistoryDetailView: View {
|
|||||||
if let photo {
|
if let photo {
|
||||||
AsyncImage(url: photo.largeURL) { phase in
|
AsyncImage(url: photo.largeURL) { phase in
|
||||||
switch phase {
|
switch phase {
|
||||||
case .success(let img):
|
case .success(let img): img.resizable().aspectRatio(contentMode: .fill)
|
||||||
img.resizable().aspectRatio(contentMode: .fill)
|
default: Rectangle().fill(HistoryStyle.cardSubtle(scheme))
|
||||||
default:
|
|
||||||
Rectangle().fill(FlightTheme.cardBackground)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@@ -160,11 +168,11 @@ struct HistoryDetailView: View {
|
|||||||
|
|
||||||
private func photoCredit(name: String, link: URL?) -> some View {
|
private func photoCredit(name: String, link: URL?) -> some View {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: "camera.fill").font(.caption2)
|
Image(systemName: "camera.fill").font(.system(size: 9))
|
||||||
Text("Photo by \(name) · planespotters.net")
|
Text("Photo by \(name) · planespotters.net")
|
||||||
.font(.caption2)
|
.font(.system(size: 10))
|
||||||
}
|
}
|
||||||
.foregroundStyle(FlightTheme.textTertiary)
|
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture { if let link { openURL(link) } }
|
.onTapGesture { if let link { openURL(link) } }
|
||||||
}
|
}
|
||||||
@@ -174,10 +182,7 @@ struct HistoryDetailView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var mapSection: some View {
|
private var mapSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text(track == nil ? "ROUTE MAP" : "FLOWN PATH")
|
HistorySectionLabel(track == nil ? "Route" : "Flown path")
|
||||||
.font(FlightTheme.label())
|
|
||||||
.foregroundStyle(FlightTheme.textTertiary)
|
|
||||||
.tracking(1)
|
|
||||||
FlightRouteMap(
|
FlightRouteMap(
|
||||||
departureIATA: flight.departureIATA,
|
departureIATA: flight.departureIATA,
|
||||||
arrivalIATA: flight.arrivalIATA,
|
arrivalIATA: flight.arrivalIATA,
|
||||||
@@ -185,30 +190,20 @@ struct HistoryDetailView: View {
|
|||||||
database: database
|
database: database
|
||||||
)
|
)
|
||||||
.frame(height: 220)
|
.frame(height: 220)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
.clipShape(RoundedRectangle(cornerRadius: 18))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadTrackIfRecent() async {
|
private func loadTrackIfRecent() async {
|
||||||
// OpenSky's anonymous track endpoint trims history after ~7
|
|
||||||
// days. Older logs get the great-circle fallback drawn by
|
|
||||||
// FlightRouteMap.
|
|
||||||
let ageDays = Date().timeIntervalSince(flight.flightDate) / 86400
|
let ageDays = Date().timeIntervalSince(flight.flightDate) / 86400
|
||||||
guard ageDays < 7, let icao24 = flight.icao24, !icao24.isEmpty else { return }
|
guard ageDays < 7, let icao24 = flight.icao24, !icao24.isEmpty else { return }
|
||||||
track = await openSky.track(icao24: icao24)
|
track = await openSky.track(icao24: icao24)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hit OpenSky's metadata endpoint for first-flight / built dates.
|
|
||||||
/// We persist the result so subsequent views of the same airframe
|
|
||||||
/// don't re-query the network. Best-effort — many newer airframes
|
|
||||||
/// have no metadata yet.
|
|
||||||
private func loadAirframeMetadata() async {
|
private func loadAirframeMetadata() async {
|
||||||
guard let reg = flight.registration,
|
guard let reg = flight.registration, !reg.isEmpty,
|
||||||
!reg.isEmpty,
|
let icao24 = flight.icao24, !icao24.isEmpty
|
||||||
let icao24 = flight.icao24,
|
|
||||||
!icao24.isEmpty
|
|
||||||
else { return }
|
else { return }
|
||||||
// Skip if we already have a cached entry with at least one date.
|
|
||||||
if let cached = store.airframe(for: reg),
|
if let cached = store.airframe(for: reg),
|
||||||
cached.firstFlightDate != nil || cached.deliveryDate != nil {
|
cached.firstFlightDate != nil || cached.deliveryDate != nil {
|
||||||
metadataLoaded.toggle()
|
metadataLoaded.toggle()
|
||||||
@@ -229,63 +224,147 @@ struct HistoryDetailView: View {
|
|||||||
private var aircraftCard: some View {
|
private var aircraftCard: some View {
|
||||||
let repeats = store.repeatCount(for: flight.registration, before: flight.flightDate)
|
let repeats = store.repeatCount(for: flight.registration, before: flight.flightDate)
|
||||||
let airframe = flight.registration.flatMap(store.airframe(for:))
|
let airframe = flight.registration.flatMap(store.airframe(for:))
|
||||||
let ageYears = airframe?.firstFlightDate.map { years(since: $0) }
|
let firstFlight = airframe?.firstFlightDate
|
||||||
|
let ageYears = firstFlight.map { years(since: $0) }
|
||||||
|
|
||||||
return VStack(alignment: .leading, spacing: 8) {
|
return VStack(alignment: .leading, spacing: 12) {
|
||||||
Text("AIRCRAFT")
|
HistorySectionLabel("Aircraft")
|
||||||
.font(FlightTheme.label())
|
|
||||||
.foregroundStyle(FlightTheme.textTertiary)
|
|
||||||
.tracking(1)
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
HStack(spacing: 0) {
|
aircraftRow(
|
||||||
cell(label: "Type", value: flight.aircraftType ?? "—")
|
leftLabel: "Type", leftValue: flight.aircraftType ?? "—",
|
||||||
cell(label: "Tail", value: flight.registration ?? "—")
|
rightLabel: "Tail #", rightValue: flight.registration ?? "—"
|
||||||
}
|
)
|
||||||
if ageYears != nil || repeats > 0 {
|
divider
|
||||||
Divider()
|
aircraftRow(
|
||||||
HStack(spacing: 0) {
|
leftLabel: "First flight",
|
||||||
if let yrs = ageYears {
|
leftValue: firstFlight.map { yearString($0) } ?? "—",
|
||||||
cell(label: "Age", value: "\(yrs)y")
|
rightLabel: "Age",
|
||||||
} else {
|
rightValue: ageYears.map { "\($0) yr" } ?? "—"
|
||||||
cell(label: "Age", value: "—")
|
)
|
||||||
}
|
divider
|
||||||
cell(
|
aircraftRow(
|
||||||
label: "On this airframe",
|
leftLabel: "On this airframe",
|
||||||
value: repeats == 0 ? "First time" : "\(repeats + 1)\(ordinalSuffix(repeats + 1)) time"
|
leftValue: repeats == 0 ? "First time" : "\(repeats + 1)\(ordinalSuffix(repeats + 1)) time",
|
||||||
|
rightLabel: "ICAO24",
|
||||||
|
rightValue: flight.icao24?.uppercased() ?? "—"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.historyCard(scheme, padding: 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.flightCard(padding: 0)
|
|
||||||
|
private var divider: some View {
|
||||||
|
Rectangle()
|
||||||
|
.fill(HistoryStyle.hairline(scheme))
|
||||||
|
.frame(height: 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func aircraftRow(leftLabel: String, leftValue: String, rightLabel: String, rightValue: String) -> some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
cell(label: leftLabel, value: leftValue)
|
||||||
|
cell(label: rightLabel, value: rightValue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func cell(label: String, value: String) -> some View {
|
private func cell(label: String, value: String) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(label)
|
Text(label.uppercased())
|
||||||
.font(.caption2)
|
.font(HistoryStyle.label(10))
|
||||||
.foregroundStyle(FlightTheme.textTertiary)
|
.tracking(1.3)
|
||||||
.tracking(0.5)
|
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
|
||||||
Text(value)
|
Text(value)
|
||||||
.font(.subheadline.weight(.semibold).monospaced())
|
.font(.system(size: 14, weight: .heavy).monospaced())
|
||||||
.foregroundStyle(FlightTheme.textPrimary)
|
.foregroundStyle(HistoryStyle.ink(scheme))
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(16)
|
.padding(14)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Timetable
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var timetableCard: some View {
|
||||||
|
if hasTimetableData {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HistorySectionLabel("Detailed timetable")
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
timetableHeader
|
||||||
|
divider
|
||||||
|
timetableRow(
|
||||||
|
label: "Departure",
|
||||||
|
scheduled: flight.scheduledDeparture,
|
||||||
|
actual: flight.actualDeparture
|
||||||
|
)
|
||||||
|
divider
|
||||||
|
timetableRow(
|
||||||
|
label: "Arrival",
|
||||||
|
scheduled: flight.scheduledArrival,
|
||||||
|
actual: flight.actualArrival
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.historyCard(scheme, padding: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasTimetableData: Bool {
|
||||||
|
flight.scheduledDeparture != nil
|
||||||
|
|| flight.scheduledArrival != nil
|
||||||
|
|| flight.actualDeparture != nil
|
||||||
|
|| flight.actualArrival != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private var timetableHeader: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Text("")
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
Text("SCHEDULED")
|
||||||
|
.font(HistoryStyle.label(10))
|
||||||
|
.tracking(1.2)
|
||||||
|
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
Text("ACTUAL")
|
||||||
|
.font(HistoryStyle.label(10))
|
||||||
|
.tracking(1.2)
|
||||||
|
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.top, 14)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func timetableRow(label: String, scheduled: Date?, actual: Date?) -> some View {
|
||||||
|
let isLate: Bool = {
|
||||||
|
guard let scheduled, let actual else { return false }
|
||||||
|
return actual.timeIntervalSince(scheduled) > 5 * 60
|
||||||
|
}()
|
||||||
|
return HStack(spacing: 0) {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(HistoryStyle.ink(scheme))
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
Text(scheduled.map(shortTime) ?? "—")
|
||||||
|
.font(.system(size: 13, weight: .heavy).monospaced())
|
||||||
|
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
Text(actual.map(shortTime) ?? "—")
|
||||||
|
.font(.system(size: 13, weight: .heavy).monospaced())
|
||||||
|
.foregroundStyle(isLate ? Color(red: 0.85, green: 0.15, blue: 0.15) : HistoryStyle.ink(scheme))
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Notes
|
// MARK: - Notes
|
||||||
|
|
||||||
private var notesSection: some View {
|
private var notesSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("NOTES")
|
HistorySectionLabel("Notes")
|
||||||
.font(FlightTheme.label())
|
|
||||||
.foregroundStyle(FlightTheme.textTertiary)
|
|
||||||
.tracking(1)
|
|
||||||
TextEditor(text: $editedNotes)
|
TextEditor(text: $editedNotes)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
.frame(minHeight: 80)
|
.frame(minHeight: 80)
|
||||||
.padding(8)
|
.padding(8)
|
||||||
.background(FlightTheme.cardBackground, in: RoundedRectangle(cornerRadius: 10))
|
.background(HistoryStyle.card(scheme), in: RoundedRectangle(cornerRadius: 14))
|
||||||
.onChange(of: editedNotes) { _, newValue in
|
.onChange(of: editedNotes) { _, newValue in
|
||||||
flight.notes = newValue.isEmpty ? nil : newValue
|
flight.notes = newValue.isEmpty ? nil : newValue
|
||||||
}
|
}
|
||||||
@@ -302,25 +381,17 @@ struct HistoryDetailView: View {
|
|||||||
Image(systemName: "trash")
|
Image(systemName: "trash")
|
||||||
Text("Delete flight")
|
Text("Delete flight")
|
||||||
}
|
}
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 14)
|
||||||
}
|
}
|
||||||
.background(FlightTheme.cancelled.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
|
.background(Color.red.opacity(0.12), in: RoundedRectangle(cornerRadius: 14))
|
||||||
.foregroundStyle(FlightTheme.cancelled)
|
.foregroundStyle(Color.red)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
private var airlineEntry: AircraftRegistry.Entry? {
|
|
||||||
AircraftRegistry.shared.lookup(icao: flight.carrierICAO)
|
|
||||||
?? AircraftRegistry.shared.lookup(iata: flight.carrierIATA)
|
|
||||||
}
|
|
||||||
private var airlineLogoURL: URL? { airlineEntry?.logoURL }
|
|
||||||
private var airlineName: String {
|
|
||||||
airlineEntry?.name ?? flight.carrierICAO ?? flight.carrierIATA ?? "Unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
private var durationDisplay: String {
|
private var durationDisplay: String {
|
||||||
guard let min = store.durationMinutes(for: flight) else { return "—" }
|
guard let min = store.durationMinutes(for: flight) else { return "—" }
|
||||||
let h = min / 60
|
let h = min / 60
|
||||||
@@ -329,8 +400,7 @@ struct HistoryDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func numberString(_ n: Int) -> String {
|
private func numberString(_ n: Int) -> String {
|
||||||
let f = NumberFormatter()
|
let f = NumberFormatter(); f.numberStyle = .decimal
|
||||||
f.numberStyle = .decimal
|
|
||||||
return f.string(from: NSNumber(value: n)) ?? "\(n)"
|
return f.string(from: NSNumber(value: n)) ?? "\(n)"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,6 +408,11 @@ struct HistoryDetailView: View {
|
|||||||
Calendar.current.dateComponents([.year], from: since, to: Date()).year ?? 0
|
Calendar.current.dateComponents([.year], from: since, to: Date()).year ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func yearString(_ d: Date) -> String {
|
||||||
|
let f = DateFormatter(); f.dateFormat = "yyyy"
|
||||||
|
return f.string(from: d)
|
||||||
|
}
|
||||||
|
|
||||||
private func ordinalSuffix(_ n: Int) -> String {
|
private func ordinalSuffix(_ n: Int) -> String {
|
||||||
let r = n % 100
|
let r = n % 100
|
||||||
if r >= 11 && r <= 13 { return "th" }
|
if r >= 11 && r <= 13 { return "th" }
|
||||||
@@ -350,14 +425,17 @@ struct HistoryDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func longDate(_ d: Date) -> String {
|
private func longDate(_ d: Date) -> String {
|
||||||
let f = DateFormatter()
|
let f = DateFormatter(); f.dateFormat = "EEE, MMM d, yyyy"
|
||||||
f.dateFormat = "MMM d, yyyy"
|
|
||||||
return f.string(from: d)
|
return f.string(from: d)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func shortDateTime(_ d: Date) -> String {
|
private func shortDateTime(_ d: Date) -> String {
|
||||||
let f = DateFormatter()
|
let f = DateFormatter(); f.dateFormat = "MMM d, HH:mm"
|
||||||
f.dateFormat = "MMM d, HH:mm"
|
return f.string(from: d)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shortTime(_ d: Date) -> String {
|
||||||
|
let f = DateFormatter(); f.dateFormat = "h:mm a"
|
||||||
return f.string(from: d)
|
return f.string(from: d)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -374,29 +452,26 @@ private struct FlightRouteMap: View {
|
|||||||
Map {
|
Map {
|
||||||
if let dep = database.airport(byIATA: departureIATA) {
|
if let dep = database.airport(byIATA: departureIATA) {
|
||||||
Marker("From " + departureIATA, systemImage: "airplane.departure", coordinate: dep.coordinate)
|
Marker("From " + departureIATA, systemImage: "airplane.departure", coordinate: dep.coordinate)
|
||||||
.tint(FlightTheme.onTime)
|
.tint(HistoryStyle.stampGreen)
|
||||||
}
|
}
|
||||||
if let arr = database.airport(byIATA: arrivalIATA) {
|
if let arr = database.airport(byIATA: arrivalIATA) {
|
||||||
Marker("To " + arrivalIATA, systemImage: "airplane.arrival", coordinate: arr.coordinate)
|
Marker("To " + arrivalIATA, systemImage: "airplane.arrival", coordinate: arr.coordinate)
|
||||||
.tint(FlightTheme.accent)
|
.tint(HistoryStyle.runwayOrange)
|
||||||
}
|
}
|
||||||
if let track {
|
if let track {
|
||||||
let coords = track.path.map {
|
let coords = track.path.map {
|
||||||
CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude)
|
CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude)
|
||||||
}
|
}
|
||||||
MapPolyline(coordinates: coords)
|
MapPolyline(coordinates: coords)
|
||||||
.stroke(FlightTheme.accent, lineWidth: 3)
|
.stroke(HistoryStyle.runwayOrange, lineWidth: 3)
|
||||||
} else if let dep = database.airport(byIATA: departureIATA),
|
} else if let dep = database.airport(byIATA: departureIATA),
|
||||||
let arr = database.airport(byIATA: arrivalIATA) {
|
let arr = database.airport(byIATA: arrivalIATA) {
|
||||||
MapPolyline(coordinates: greatCircle(from: dep.coordinate, to: arr.coordinate, segments: 64))
|
MapPolyline(coordinates: greatCircle(from: dep.coordinate, to: arr.coordinate, segments: 64))
|
||||||
.stroke(FlightTheme.accent.opacity(0.6), style: StrokeStyle(lineWidth: 2, dash: [5, 4]))
|
.stroke(HistoryStyle.runwayOrange.opacity(0.7), style: StrokeStyle(lineWidth: 2, dash: [5, 4]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Polyline samples along the great-circle path between two
|
|
||||||
/// coordinates. MapKit doesn't draw GC paths natively — we
|
|
||||||
/// approximate with N straight segments along the GC route.
|
|
||||||
private func greatCircle(from a: CLLocationCoordinate2D, to b: CLLocationCoordinate2D, segments: Int) -> [CLLocationCoordinate2D] {
|
private func greatCircle(from a: CLLocationCoordinate2D, to b: CLLocationCoordinate2D, segments: Int) -> [CLLocationCoordinate2D] {
|
||||||
let lat1 = a.latitude * .pi / 180
|
let lat1 = a.latitude * .pi / 180
|
||||||
let lon1 = a.longitude * .pi / 180
|
let lon1 = a.longitude * .pi / 180
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ struct HistoryRouteMapView: View {
|
|||||||
@State private var selectedAirportSheet: AirportSheet?
|
@State private var selectedAirportSheet: AirportSheet?
|
||||||
@State private var selectedFlight: LoggedFlight?
|
@State private var selectedFlight: LoggedFlight?
|
||||||
@State private var revealKey: Int = 0 // bump to retrigger the reveal animation
|
@State private var revealKey: Int = 0 // bump to retrigger the reveal animation
|
||||||
|
@State private var drawerExpanded: Bool = false
|
||||||
|
|
||||||
struct AirportSheet: Identifiable {
|
struct AirportSheet: Identifiable {
|
||||||
let iata: String
|
let iata: String
|
||||||
@@ -32,7 +33,8 @@ struct HistoryRouteMapView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
let arcs = self.arcs
|
let arcs = self.arcs
|
||||||
|
|
||||||
return Map(position: $position) {
|
return ZStack(alignment: .bottom) {
|
||||||
|
Map(position: $position) {
|
||||||
// Airport dots
|
// 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) {
|
||||||
@@ -47,10 +49,17 @@ struct HistoryRouteMapView: View {
|
|||||||
// Animated arcs
|
// Animated arcs
|
||||||
ForEach(arcs.prefix(revealCount)) { arc in
|
ForEach(arcs.prefix(revealCount)) { arc in
|
||||||
MapPolyline(coordinates: arc.coords)
|
MapPolyline(coordinates: arc.coords)
|
||||||
.stroke(arcColor(for: arc), lineWidth: arc.isMostRecent ? 2.5 : 1.5)
|
.stroke(arcColor(for: arc), lineWidth: arc.isMostRecent ? 3.0 : 1.6)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.mapStyle(.standard(elevation: .flat))
|
.mapStyle(.imagery(elevation: .flat))
|
||||||
|
.ignoresSafeArea(edges: .bottom)
|
||||||
|
|
||||||
|
// Bottom passport drawer — always-visible card peeking up
|
||||||
|
// from the bottom of the map, showing the scoped passport
|
||||||
|
// summary and the active filter set. Tap to expand.
|
||||||
|
passportDrawer
|
||||||
|
}
|
||||||
.navigationTitle(filters.isEmpty ? "Lifetime Routes" : "Filtered Routes")
|
.navigationTitle(filters.isEmpty ? "Lifetime Routes" : "Filtered Routes")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@@ -125,10 +134,101 @@ struct HistoryRouteMapView: View {
|
|||||||
|
|
||||||
private func arcColor(for arc: Arc) -> Color {
|
private func arcColor(for arc: Arc) -> Color {
|
||||||
if arc.isMostRecent {
|
if arc.isMostRecent {
|
||||||
return FlightTheme.onTime
|
return Color(red: 1.0, green: 1.0, blue: 0.0) // fluorescent yellow for the latest leg
|
||||||
}
|
}
|
||||||
// Slight transparency on bulk lines so the most-recent stands out.
|
// Bulk lines in vivid runway orange.
|
||||||
return FlightTheme.accent.opacity(0.55)
|
return HistoryStyle.runwayOrange
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Passport drawer
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var passportDrawer: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
// Tab handle
|
||||||
|
Capsule()
|
||||||
|
.fill(.white.opacity(0.4))
|
||||||
|
.frame(width: 36, height: 5)
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(filters.isEmpty ? "ALL TIME" : "FILTERED")
|
||||||
|
.font(.system(size: 10, weight: .heavy))
|
||||||
|
.tracking(2)
|
||||||
|
.foregroundStyle(HistoryStyle.runwayOrange)
|
||||||
|
Text("\(flights.count) flights · \(numberStringMiles())")
|
||||||
|
.font(.system(size: 16, weight: .heavy))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
revealCount = 0
|
||||||
|
revealKey += 1
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "play.fill")
|
||||||
|
.font(.system(size: 12, weight: .bold))
|
||||||
|
.padding(10)
|
||||||
|
.background(.white.opacity(0.18), in: Circle())
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 18)
|
||||||
|
|
||||||
|
// Filter chips (live)
|
||||||
|
if !filters.airports.isEmpty || !filters.airlines.isEmpty || !filters.years.isEmpty {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
ForEach(Array(filters.years).sorted(by: >), id: \.self) { y in
|
||||||
|
drawerChip("\(y)") { filters.years.remove(y) }
|
||||||
|
}
|
||||||
|
ForEach(Array(filters.airlines).sorted(), id: \.self) { a in
|
||||||
|
drawerChip(a) { filters.airlines.remove(a) }
|
||||||
|
}
|
||||||
|
ForEach(Array(filters.airports).sorted(), id: \.self) { a in
|
||||||
|
drawerChip(a) { filters.airports.remove(a) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 18)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, 22)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [HistoryStyle.midnightNavy.opacity(0.92), HistoryStyle.midnightNavy],
|
||||||
|
startPoint: .top, endPoint: .bottom
|
||||||
|
)
|
||||||
|
.clipShape(UnevenRoundedRectangle(topLeadingRadius: 22, topTrailingRadius: 22))
|
||||||
|
.ignoresSafeArea(edges: .bottom)
|
||||||
|
)
|
||||||
|
.shadow(color: .black.opacity(0.4), radius: 12, y: -4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func drawerChip(_ label: String, onRemove: @escaping () -> Void) -> some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 11, weight: .bold).monospaced())
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 8, weight: .bold))
|
||||||
|
}
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
.background(HistoryStyle.runwayOrange, in: Capsule())
|
||||||
|
.onTapGesture(perform: onRemove)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func numberStringMiles() -> String {
|
||||||
|
let total = flights.reduce(0) { acc, f in
|
||||||
|
acc + (store.distanceMiles(for: f) ?? 0)
|
||||||
|
}
|
||||||
|
let f = NumberFormatter()
|
||||||
|
f.numberStyle = .decimal
|
||||||
|
return (f.string(from: NSNumber(value: total)) ?? "\(total)") + " mi"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Airports
|
// MARK: - Airports
|
||||||
@@ -199,12 +299,10 @@ private struct AirportDot: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(isSelected ? FlightTheme.onTime : FlightTheme.accent)
|
.fill(isSelected ? Color.yellow : HistoryStyle.runwayOrange)
|
||||||
.frame(width: size, height: size)
|
.frame(width: size, height: size)
|
||||||
.overlay(
|
.overlay(Circle().stroke(.white, lineWidth: 2))
|
||||||
Circle().stroke(.white, lineWidth: 1.5)
|
.shadow(color: .black.opacity(0.5), radius: 2, y: 1)
|
||||||
)
|
|
||||||
.shadow(color: .black.opacity(0.2), radius: 1, y: 1)
|
|
||||||
.contentShape(Circle())
|
.contentShape(Circle())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+395
-162
@@ -1,25 +1,31 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
/// Top-level history tab. Totals strip + sortable / filterable / searchable
|
/// History tab — redesigned as a "passport" experience.
|
||||||
/// list of every flight you've logged, plus entry points to the lifetime
|
///
|
||||||
/// stats / route map / year-in-review screens.
|
/// Stacked hero cards at the top (current-year passport, all-time
|
||||||
|
/// passport, most-flown airframe), a horizontal year tab strip that
|
||||||
|
/// scopes everything, and a flight feed below. Sort + filter + search
|
||||||
|
/// + add affordances all live in the toolbar.
|
||||||
struct HistoryView: View {
|
struct HistoryView: View {
|
||||||
let database: AirportDatabase
|
let database: AirportDatabase
|
||||||
let routeExplorer: RouteExplorerClient
|
let routeExplorer: RouteExplorerClient
|
||||||
let openSky: OpenSkyClient
|
let openSky: OpenSkyClient
|
||||||
|
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Environment(\.colorScheme) private var scheme
|
||||||
|
|
||||||
@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 filters: HistoryFilters = .init()
|
||||||
@State private var sort: HistorySort = .newestFirst
|
@State private var sort: HistorySort = .newestFirst
|
||||||
|
@State private var selectedYear: Int? = nil // nil = ALL
|
||||||
|
|
||||||
@State private var showingAdd = false
|
@State private var showingAdd = false
|
||||||
@State private var showingStats = false
|
@State private var showingPassport = false
|
||||||
@State private var showingMap = false
|
@State private var showingMap = false
|
||||||
|
@State private var showingAircraftStats = 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
|
||||||
@@ -27,54 +33,43 @@ struct HistoryView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let store = FlightHistoryStore(context: modelContext, airportDatabase: database)
|
let store = FlightHistoryStore(context: modelContext, airportDatabase: database)
|
||||||
let visible = filteredSorted(store: store)
|
let scoped = scopedFlights(store: store)
|
||||||
let stats = StatsEngine(store: store, database: database, flights: visible)
|
let stats = StatsEngine(store: store, database: database, flights: scoped)
|
||||||
|
|
||||||
return List {
|
ScrollView {
|
||||||
if !flights.isEmpty {
|
LazyVStack(spacing: 0, pinnedViews: []) {
|
||||||
Section {
|
titleHeader
|
||||||
totalsStrip(stats: stats, isFiltered: !filters.isEmpty)
|
|
||||||
.listRowInsets(EdgeInsets())
|
YearTabStrip(years: yearsList, selection: $selectedYear)
|
||||||
.listRowBackground(Color.clear)
|
.padding(.vertical, 12)
|
||||||
|
|
||||||
|
if filters.isEmpty {
|
||||||
|
heroDeck(store: store, stats: stats)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.bottom, 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !filters.isEmpty {
|
if !filters.isEmpty {
|
||||||
Section {
|
|
||||||
activeChips
|
activeChips
|
||||||
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 8, trailing: 16))
|
.padding(.horizontal, 16)
|
||||||
.listRowBackground(Color.clear)
|
.padding(.bottom, 8)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
if scoped.isEmpty {
|
||||||
ForEach(groups(visible), id: \.key) { group in
|
|
||||||
Section(header: Text(group.key)) {
|
|
||||||
ForEach(group.flights) { flight in
|
|
||||||
NavigationLink {
|
|
||||||
HistoryDetailView(
|
|
||||||
flight: flight,
|
|
||||||
store: store,
|
|
||||||
database: database,
|
|
||||||
openSky: openSky
|
|
||||||
)
|
|
||||||
} label: {
|
|
||||||
HistoryRowView(flight: flight, database: database)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onDelete { offsets in
|
|
||||||
for i in offsets { store.delete(group.flights[i]) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if flights.isEmpty {
|
|
||||||
emptyState
|
emptyState
|
||||||
} else if visible.isEmpty {
|
} else {
|
||||||
noMatchState
|
flightFeed(scoped, store: store)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 80)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.insetGrouped)
|
.background(HistoryStyle.background(scheme).ignoresSafeArea())
|
||||||
.navigationTitle("History")
|
.navigationTitle("")
|
||||||
.searchable(text: $filters.query, placement: .navigationBarDrawer(displayMode: .always), prompt: "Flight #, airport, route")
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.searchable(text: $filters.query, prompt: "Flight #, airport, route")
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .secondaryAction) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Menu {
|
Menu {
|
||||||
ForEach(HistorySort.allCases) { option in
|
ForEach(HistorySort.allCases) { option in
|
||||||
Button {
|
Button {
|
||||||
@@ -88,57 +83,39 @@ struct HistoryView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Label("Sort", systemImage: "arrow.up.arrow.down")
|
Image(systemName: "arrow.up.arrow.down")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .secondaryAction) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Button {
|
Button {
|
||||||
showingFilterSheet = true
|
showingFilterSheet = true
|
||||||
} label: {
|
} label: {
|
||||||
if filters.activeCount > 0 {
|
Image(systemName: filters.activeCount > 0 ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
|
||||||
Label("Filters (\(filters.activeCount))", systemImage: "line.3.horizontal.decrease.circle.fill")
|
|
||||||
} else {
|
|
||||||
Label("Filters", systemImage: "line.3.horizontal.decrease.circle")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
ToolbarItem(placement: .primaryAction) {
|
|
||||||
Menu {
|
Menu {
|
||||||
Button { showingAdd = true } label: {
|
Button { showingAdd = true } label: { Label("Add manually", systemImage: "plus") }
|
||||||
Label("Add manually", systemImage: "plus")
|
Button { showingCalendarImport = true } label: { Label("Scan Calendar", systemImage: "calendar") }
|
||||||
}
|
Button { showingCSVImport = true } label: { Label("Import CSV…", systemImage: "doc.text") }
|
||||||
Button { showingCalendarImport = true } label: {
|
|
||||||
Label("Scan Calendar", systemImage: "calendar")
|
|
||||||
}
|
|
||||||
Button { showingCSVImport = true } label: {
|
|
||||||
Label("Import CSV…", systemImage: "doc.text")
|
|
||||||
}
|
|
||||||
Divider()
|
|
||||||
Button { showingStats = true } label: {
|
|
||||||
Label("Lifetime stats", systemImage: "chart.bar.fill")
|
|
||||||
}
|
|
||||||
Button { showingMap = true } label: {
|
|
||||||
Label("Route map", systemImage: "map.fill")
|
|
||||||
}
|
|
||||||
Button { showingYearInReview = true } label: {
|
|
||||||
Label("Year in Review", systemImage: "sparkles")
|
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "plus.circle.fill")
|
Image(systemName: "plus.circle.fill")
|
||||||
.font(.title3)
|
.foregroundStyle(HistoryStyle.runwayOrange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingAdd) {
|
.sheet(isPresented: $showingAdd) {
|
||||||
AddFlightView(routeExplorer: routeExplorer, database: database, store: store, prefill: nil)
|
AddFlightView(routeExplorer: routeExplorer, database: database, store: store, prefill: nil)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingStats) {
|
.sheet(isPresented: $showingPassport) {
|
||||||
NavigationStack { LifetimeStatsView(stats: stats) }
|
NavigationStack {
|
||||||
|
PassportView(stats: stats, allFlights: flights, database: database, store: store, selectedYear: $selectedYear)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingMap) {
|
.sheet(isPresented: $showingMap) {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
HistoryRouteMapView(
|
HistoryRouteMapView(
|
||||||
flights: visible,
|
flights: scoped,
|
||||||
allFlights: flights,
|
allFlights: flights,
|
||||||
database: database,
|
database: database,
|
||||||
openSky: openSky,
|
openSky: openSky,
|
||||||
@@ -147,6 +124,11 @@ struct HistoryView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showingAircraftStats) {
|
||||||
|
NavigationStack {
|
||||||
|
AircraftStatsView(allFlights: flights, store: store)
|
||||||
|
}
|
||||||
|
}
|
||||||
.sheet(isPresented: $showingCalendarImport) {
|
.sheet(isPresented: $showingCalendarImport) {
|
||||||
CalendarImportView(routeExplorer: routeExplorer, database: database, store: store)
|
CalendarImportView(routeExplorer: routeExplorer, database: database, store: store)
|
||||||
}
|
}
|
||||||
@@ -154,7 +136,7 @@ struct HistoryView: View {
|
|||||||
ImportCSVView(store: store)
|
ImportCSVView(store: store)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingYearInReview) {
|
.sheet(isPresented: $showingYearInReview) {
|
||||||
YearInReviewView(stats: stats, year: Calendar.current.component(.year, from: Date()))
|
YearInReviewView(stats: stats, year: selectedYear ?? Calendar.current.component(.year, from: Date()))
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingFilterSheet) {
|
.sheet(isPresented: $showingFilterSheet) {
|
||||||
HistoryFilterSheet(allFlights: flights, filters: $filters)
|
HistoryFilterSheet(allFlights: flights, filters: $filters)
|
||||||
@@ -162,34 +144,169 @@ struct HistoryView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Sort/filter pipeline
|
// MARK: - Pipeline
|
||||||
|
|
||||||
private func filteredSorted(store: FlightHistoryStore) -> [LoggedFlight] {
|
private var yearsList: [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]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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: list) { cal.component(.year, from: $0.flightDate) }
|
let ys = Set(flights.map { cal.component(.year, from: $0.flightDate) })
|
||||||
let order: (Int, Int) -> Bool = sort == .newestFirst ? (>) : (<)
|
return ys.sorted(by: >)
|
||||||
return grouped
|
}
|
||||||
.map { Group(key: String($0.key), flights: $0.value) }
|
|
||||||
.sorted { order(Int($0.key) ?? 0, Int($1.key) ?? 0) }
|
private func scopedFlights(store: FlightHistoryStore) -> [LoggedFlight] {
|
||||||
case .longestFirst, .shortestFirst, .airline, .flightNumber:
|
var scoped = flights
|
||||||
return [Group(key: sort.rawValue, flights: list)]
|
if let y = selectedYear {
|
||||||
|
let cal = Calendar.current
|
||||||
|
scoped = scoped.filter { cal.component(.year, from: $0.flightDate) == y }
|
||||||
|
}
|
||||||
|
scoped = scoped.filter { filters.matches($0) }
|
||||||
|
let cmp = sort.comparator { store.distanceMiles(for: $0) ?? 0 }
|
||||||
|
return scoped.sorted(by: cmp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Title
|
||||||
|
|
||||||
|
private var titleHeader: some View {
|
||||||
|
HStack(alignment: .firstTextBaseline) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("PASSPORT")
|
||||||
|
.font(.system(size: 34, weight: .black))
|
||||||
|
.tracking(-0.5)
|
||||||
|
.foregroundStyle(HistoryStyle.ink(scheme))
|
||||||
|
if !flights.isEmpty {
|
||||||
|
Text("\(flights.count) flights · \(years(of: flights)) years")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "airplane")
|
||||||
|
.font(.system(size: 22, weight: .heavy))
|
||||||
|
.foregroundStyle(HistoryStyle.runwayOrange)
|
||||||
|
.rotationEffect(.degrees(-45))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func years(of list: [LoggedFlight]) -> Int {
|
||||||
|
let yrs = Set(list.map { Calendar.current.component(.year, from: $0.flightDate) })
|
||||||
|
return yrs.count
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Hero deck
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func heroDeck(store: FlightHistoryStore, stats: StatsEngine) -> some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
// 1) Scoped passport — either current year or all-time
|
||||||
|
let year = selectedYear ?? Calendar.current.component(.year, from: Date())
|
||||||
|
let yearFlights = stats.flights(for: year)
|
||||||
|
let yearStats = StatsEngine(store: store, database: database, flights: yearFlights)
|
||||||
|
|
||||||
|
Button { showingPassport = true } label: {
|
||||||
|
if selectedYear == nil {
|
||||||
|
HeroStatCard(
|
||||||
|
label: "ALL TIME PASSPORT",
|
||||||
|
value: numberString(stats.totalFlights),
|
||||||
|
subtitle: "\(stats.shortDistance) miles · \(stats.shortDuration)h in air",
|
||||||
|
variant: .orange
|
||||||
|
) {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
kvp(value: "\(stats.uniqueAirports)", label: "airports")
|
||||||
|
kvp(value: "\(stats.uniqueAirlines)", label: "airlines")
|
||||||
|
kvp(value: "\(stats.uniqueCountries)", label: "countries")
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 13, weight: .bold))
|
||||||
|
.foregroundStyle(.white.opacity(0.6))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HeroStatCard(
|
||||||
|
label: "\(year) PASSPORT",
|
||||||
|
value: numberString(yearStats.totalFlights),
|
||||||
|
subtitle: "\(yearStats.shortDistance) miles · \(yearStats.shortDuration)h aloft",
|
||||||
|
variant: .orange
|
||||||
|
) {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
kvp(value: "\(yearStats.uniqueAirports)", label: "airports")
|
||||||
|
kvp(value: "\(yearStats.uniqueAirlines)", label: "airlines")
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 13, weight: .bold))
|
||||||
|
.foregroundStyle(.white.opacity(0.6))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
// 2) Most-flown aircraft, if we know it
|
||||||
|
mostFlownCard(stats: stats)
|
||||||
|
|
||||||
|
// 3) Quick links row
|
||||||
|
quickLinks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func mostFlownCard(stats: StatsEngine) -> some View {
|
||||||
|
let typeCounts = Dictionary(grouping: stats.flights.compactMap { $0.aircraftType }) { $0 }
|
||||||
|
.mapValues(\.count)
|
||||||
|
if let top = typeCounts.max(by: { $0.value < $1.value }) {
|
||||||
|
let typeName = AircraftDatabase.shared.displayName(forTypeCode: top.key)
|
||||||
|
Button { showingAircraftStats = true } label: {
|
||||||
|
HeroStatCard(
|
||||||
|
label: "MOST FLOWN AIRCRAFT",
|
||||||
|
value: typeName == top.key ? top.key : typeName,
|
||||||
|
subtitle: "\(top.value) flights",
|
||||||
|
variant: .navy
|
||||||
|
) {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 13, weight: .bold))
|
||||||
|
.foregroundStyle(.white.opacity(0.6))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var quickLinks: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
quickLink(title: "Map", icon: "map.fill") { showingMap = true }
|
||||||
|
quickLink(title: "Aircraft", icon: "airplane.circle.fill") { showingAircraftStats = true }
|
||||||
|
quickLink(title: "Year", icon: "sparkles") { showingYearInReview = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func quickLink(title: String, icon: String, action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 18, weight: .semibold))
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.tracking(0.5)
|
||||||
|
}
|
||||||
|
.foregroundStyle(HistoryStyle.ink(scheme))
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 64)
|
||||||
|
.background(HistoryStyle.card(scheme), in: RoundedRectangle(cornerRadius: 16))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func kvp(value: String, label: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text(value)
|
||||||
|
.font(.system(size: 18, weight: .heavy).monospacedDigit())
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
Text(label.uppercased())
|
||||||
|
.font(.system(size: 9, weight: .bold))
|
||||||
|
.tracking(0.8)
|
||||||
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,13 +332,11 @@ struct HistoryView: View {
|
|||||||
ForEach(Array(filters.aircraftTypes).sorted(), id: \.self) { t in
|
ForEach(Array(filters.aircraftTypes).sorted(), id: \.self) { t in
|
||||||
chip(t, systemImage: "airplane.departure") { filters.aircraftTypes.remove(t) }
|
chip(t, systemImage: "airplane.departure") { filters.aircraftTypes.remove(t) }
|
||||||
}
|
}
|
||||||
Button {
|
Button { filters = HistoryFilters() } label: {
|
||||||
filters = HistoryFilters()
|
|
||||||
} label: {
|
|
||||||
Text("Clear")
|
Text("Clear")
|
||||||
.font(.caption.weight(.semibold))
|
.font(.caption.weight(.semibold))
|
||||||
.foregroundStyle(FlightTheme.accent)
|
.foregroundStyle(HistoryStyle.runwayOrange)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 10)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -237,82 +352,200 @@ struct HistoryView: View {
|
|||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
.padding(.vertical, 5)
|
.padding(.vertical, 5)
|
||||||
.background(FlightTheme.accent, in: Capsule())
|
.background(HistoryStyle.runwayOrange, in: Capsule())
|
||||||
.onTapGesture(perform: onRemove)
|
.onTapGesture(perform: onRemove)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Totals strip
|
// MARK: - Flight feed
|
||||||
|
|
||||||
private func totalsStrip(stats: StatsEngine, isFiltered: Bool) -> some View {
|
@ViewBuilder
|
||||||
VStack(spacing: 6) {
|
private func flightFeed(_ scoped: [LoggedFlight], store: FlightHistoryStore) -> some View {
|
||||||
if isFiltered {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
Text("FILTERED TOTALS")
|
HStack {
|
||||||
.font(.caption2.weight(.semibold))
|
HistorySectionLabel(selectedYear == nil ? "Recent flights" : "Flights in \(selectedYear!)")
|
||||||
.tracking(0.8)
|
Spacer()
|
||||||
.foregroundStyle(FlightTheme.textTertiary)
|
Text("\(scoped.count)")
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.font(.system(size: 12, weight: .bold).monospacedDigit())
|
||||||
.padding(.horizontal, 16)
|
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
|
||||||
}
|
|
||||||
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(.horizontal, 16)
|
||||||
.padding(.vertical, 8)
|
.padding(.top, 8)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func statTile(value: String, label: String) -> some View {
|
ForEach(groupedFeed(scoped), id: \.key) { group in
|
||||||
VStack(spacing: 2) {
|
if groupedFeed(scoped).count > 1 {
|
||||||
Text(value)
|
Text(group.key)
|
||||||
.font(.title3.weight(.bold).monospacedDigit())
|
.font(.system(size: 12, weight: .bold).monospacedDigit())
|
||||||
.foregroundStyle(FlightTheme.textPrimary)
|
|
||||||
Text(label.uppercased())
|
|
||||||
.font(.caption2.weight(.semibold))
|
|
||||||
.tracking(0.6)
|
.tracking(0.6)
|
||||||
.foregroundStyle(FlightTheme.textTertiary)
|
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
ForEach(group.flights) { f in
|
||||||
|
NavigationLink {
|
||||||
|
HistoryDetailView(flight: f, store: store, database: database, openSky: openSky)
|
||||||
|
} label: {
|
||||||
|
PassportFlightRow(flight: f, database: database)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
.background(FlightTheme.cardBackground, in: RoundedRectangle(cornerRadius: 10))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Empty states
|
private struct FeedGroup {
|
||||||
|
let key: String
|
||||||
|
let flights: [LoggedFlight]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func groupedFeed(_ list: [LoggedFlight]) -> [FeedGroup] {
|
||||||
|
let cal = Calendar.current
|
||||||
|
switch sort {
|
||||||
|
case .newestFirst, .oldestFirst:
|
||||||
|
// When scoped to a single year, sub-group by month for
|
||||||
|
// visual rhythm; when ALL is selected, group by year.
|
||||||
|
if selectedYear != nil {
|
||||||
|
let grouped = Dictionary(grouping: list) { f -> String in
|
||||||
|
let comps = cal.dateComponents([.year, .month], from: f.flightDate)
|
||||||
|
let m = DateFormatter()
|
||||||
|
m.dateFormat = "MMMM"
|
||||||
|
return m.string(from: cal.date(from: comps) ?? f.flightDate).uppercased()
|
||||||
|
}
|
||||||
|
return grouped.map { FeedGroup(key: $0.key, flights: $0.value.sorted { sort == .newestFirst ? $0.flightDate > $1.flightDate : $0.flightDate < $1.flightDate }) }
|
||||||
|
.sorted { firstFlightDate($0.flights) > firstFlightDate($1.flights) }
|
||||||
|
} else {
|
||||||
|
let grouped = Dictionary(grouping: list) { String(cal.component(.year, from: $0.flightDate)) }
|
||||||
|
let order: (String, String) -> Bool = sort == .newestFirst ? (>) : (<)
|
||||||
|
return grouped.map { FeedGroup(key: $0.key, flights: $0.value) }
|
||||||
|
.sorted { order($0.key, $1.key) }
|
||||||
|
}
|
||||||
|
case .longestFirst, .shortestFirst, .airline, .flightNumber:
|
||||||
|
return [FeedGroup(key: "", flights: list)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func firstFlightDate(_ list: [LoggedFlight]) -> Date {
|
||||||
|
list.first?.flightDate ?? .distantPast
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Empty state
|
||||||
|
|
||||||
private var emptyState: some View {
|
private var emptyState: some View {
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 12) {
|
||||||
Image(systemName: "airplane.circle")
|
Image(systemName: "airplane.circle")
|
||||||
.font(.system(size: 48))
|
.font(.system(size: 48))
|
||||||
.foregroundStyle(FlightTheme.textTertiary)
|
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
|
||||||
Text("No flights logged yet")
|
Text(flights.isEmpty ? "No flights logged yet" : "No matches in \(selectedYear.map(String.init) ?? "this filter")")
|
||||||
.font(.headline)
|
.font(.system(size: 15, weight: .semibold))
|
||||||
.foregroundStyle(FlightTheme.textSecondary)
|
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
|
||||||
Text("Tap + to add a flight manually, scan your calendar, import a CSV, or tap an aircraft on the Live tab.")
|
if flights.isEmpty {
|
||||||
|
Text("Tap + to add a flight, scan your calendar, or import a CSV.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.foregroundStyle(FlightTheme.textTertiary)
|
.padding(.horizontal, 32)
|
||||||
.padding(.horizontal, 24)
|
} else {
|
||||||
|
Button("Clear filter") {
|
||||||
|
selectedYear = nil
|
||||||
|
filters = HistoryFilters()
|
||||||
|
}
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(HistoryStyle.runwayOrange)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 40)
|
.padding(.vertical, 60)
|
||||||
.listRowBackground(Color.clear)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var noMatchState: some View {
|
private func numberString(_ n: Int) -> String {
|
||||||
VStack(spacing: 10) {
|
let f = NumberFormatter()
|
||||||
Image(systemName: "line.3.horizontal.decrease.circle")
|
f.numberStyle = .decimal
|
||||||
.font(.system(size: 36))
|
return f.string(from: NSNumber(value: n)) ?? "\(n)"
|
||||||
.foregroundStyle(FlightTheme.textTertiary)
|
}
|
||||||
Text("No flights match these filters")
|
}
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(FlightTheme.textSecondary)
|
// MARK: - Passport-styled flight row
|
||||||
Button("Clear filters") { filters = HistoryFilters() }
|
|
||||||
.font(.subheadline)
|
struct PassportFlightRow: View {
|
||||||
}
|
let flight: LoggedFlight
|
||||||
.frame(maxWidth: .infinity)
|
let database: AirportDatabase
|
||||||
.padding(.vertical, 28)
|
@State private var photo: AircraftPhotoService.Photo?
|
||||||
.listRowBackground(Color.clear)
|
@Environment(\.colorScheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
thumbnail
|
||||||
|
.frame(width: 56, height: 44)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(flight.flightLabel)
|
||||||
|
.font(.system(size: 14, weight: .heavy).monospaced())
|
||||||
|
.foregroundStyle(HistoryStyle.ink(scheme))
|
||||||
|
if let type = flight.aircraftType {
|
||||||
|
Text(type)
|
||||||
|
.font(.system(size: 10, weight: .bold).monospaced())
|
||||||
|
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
|
||||||
|
.padding(.horizontal, 5)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(HistoryStyle.cardSubtle(scheme), in: RoundedRectangle(cornerRadius: 4))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(flight.departureIATA)
|
||||||
|
.font(.system(size: 13, weight: .heavy).monospaced())
|
||||||
|
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
|
||||||
|
Image(systemName: "arrow.right")
|
||||||
|
.font(.system(size: 9, weight: .bold))
|
||||||
|
.foregroundStyle(HistoryStyle.runwayOrange)
|
||||||
|
Text(flight.arrivalIATA)
|
||||||
|
.font(.system(size: 13, weight: .heavy).monospaced())
|
||||||
|
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text(shortDate(flight.flightDate))
|
||||||
|
.font(.system(size: 11, weight: .semibold).monospacedDigit())
|
||||||
|
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
|
||||||
|
}
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.background(HistoryStyle.card(scheme), in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
.task(id: flight.registration ?? flight.id.uuidString) {
|
||||||
|
guard let reg = flight.registration, !reg.isEmpty else { return }
|
||||||
|
photo = await AircraftPhotoService.shared.photo(registration: reg, icao24: flight.icao24 ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var thumbnail: some View {
|
||||||
|
if let url = photo?.thumbnailURL {
|
||||||
|
AsyncImage(url: url) { phase in
|
||||||
|
switch phase {
|
||||||
|
case .success(let img):
|
||||||
|
img.resizable().aspectRatio(contentMode: .fill)
|
||||||
|
default: placeholder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
placeholder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var placeholder: some View {
|
||||||
|
ZStack {
|
||||||
|
HistoryStyle.cardSubtle(scheme)
|
||||||
|
Image(systemName: "airplane")
|
||||||
|
.font(.system(size: 14, weight: .heavy))
|
||||||
|
.foregroundStyle(HistoryStyle.runwayOrange)
|
||||||
|
.rotationEffect(.degrees(-45))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shortDate(_ d: Date) -> String {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "d MMM"
|
||||||
|
return f.string(from: d).uppercased()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Hero stat card
|
||||||
|
//
|
||||||
|
// Full-bleed colored card with one big number + label + optional
|
||||||
|
// subtitle. The variant determines the background treatment (orange,
|
||||||
|
// navy, gold, or a photo). Shared by HistoryView, PassportView,
|
||||||
|
// AircraftStatsView, and YearInReviewView so cards feel consistent
|
||||||
|
// wherever they appear.
|
||||||
|
|
||||||
|
struct HeroStatCard<Footer: View>: View {
|
||||||
|
let label: String
|
||||||
|
let value: String
|
||||||
|
let subtitle: String?
|
||||||
|
let variant: Variant
|
||||||
|
let onForeground: Color
|
||||||
|
@ViewBuilder var footer: () -> Footer
|
||||||
|
|
||||||
|
enum Variant {
|
||||||
|
case orange
|
||||||
|
case navy
|
||||||
|
case gold
|
||||||
|
case green
|
||||||
|
case photo(URL?)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
label: String,
|
||||||
|
value: String,
|
||||||
|
subtitle: String? = nil,
|
||||||
|
variant: Variant,
|
||||||
|
onForeground: Color = .white,
|
||||||
|
@ViewBuilder footer: @escaping () -> Footer = { EmptyView() }
|
||||||
|
) {
|
||||||
|
self.label = label
|
||||||
|
self.value = value
|
||||||
|
self.subtitle = subtitle
|
||||||
|
self.variant = variant
|
||||||
|
self.onForeground = onForeground
|
||||||
|
self.footer = footer
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
|
background
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(label)
|
||||||
|
.font(HistoryStyle.label(11))
|
||||||
|
.tracking(1.6)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
.foregroundStyle(onForeground.opacity(0.7))
|
||||||
|
|
||||||
|
Text(value)
|
||||||
|
.font(HistoryStyle.displayNumber(46))
|
||||||
|
.foregroundStyle(onForeground)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.55)
|
||||||
|
|
||||||
|
if let subtitle {
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(onForeground.opacity(0.82))
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
footer()
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
}
|
||||||
|
.frame(minHeight: 152)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 22))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var background: some View {
|
||||||
|
switch variant {
|
||||||
|
case .orange:
|
||||||
|
HistoryStyle.heroOrangeGradient
|
||||||
|
case .navy:
|
||||||
|
HistoryStyle.heroNavyGradient
|
||||||
|
case .gold:
|
||||||
|
HistoryStyle.heroGoldGradient
|
||||||
|
case .green:
|
||||||
|
HistoryStyle.heroGreenGradient
|
||||||
|
case .photo(let url):
|
||||||
|
ZStack {
|
||||||
|
Color.black
|
||||||
|
if let url {
|
||||||
|
AsyncImage(url: url) { phase in
|
||||||
|
switch phase {
|
||||||
|
case .success(let img):
|
||||||
|
img.resizable().aspectRatio(contentMode: .fill)
|
||||||
|
default:
|
||||||
|
Color.black.opacity(0.6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.black.opacity(0.0), Color.black.opacity(0.85)],
|
||||||
|
startPoint: .top, endPoint: .bottom
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Year tab strip
|
||||||
|
//
|
||||||
|
// Horizontal scroll of `ALL · 2026 · 2025 · 2024 ...`. Tapping a year
|
||||||
|
// updates a binding the parent view filters against. Active year is
|
||||||
|
// pill-highlighted in runway orange.
|
||||||
|
|
||||||
|
struct YearTabStrip: View {
|
||||||
|
let years: [Int]
|
||||||
|
@Binding var selection: Int?
|
||||||
|
@Environment(\.colorScheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
tab(label: "ALL", id: -1, selected: selection == nil) {
|
||||||
|
selection = nil
|
||||||
|
}
|
||||||
|
.id("ALL")
|
||||||
|
ForEach(years, id: \.self) { y in
|
||||||
|
tab(label: String(y), id: y, selected: selection == y) {
|
||||||
|
selection = y
|
||||||
|
}
|
||||||
|
.id(y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.onAppear {
|
||||||
|
if let sel = selection {
|
||||||
|
withAnimation { proxy.scrollTo(sel, anchor: .center) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tab(label: String, id: Int, selected: Bool, action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 13, weight: .bold).monospacedDigit())
|
||||||
|
.tracking(0.6)
|
||||||
|
.foregroundStyle(selected ? .white : HistoryStyle.inkSecondary(scheme))
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(
|
||||||
|
Capsule()
|
||||||
|
.fill(selected ? HistoryStyle.runwayOrange : HistoryStyle.card(scheme))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Passport stamp badge
|
||||||
|
//
|
||||||
|
// Faux rubber-stamp circular badge used on cards to add flavor (e.g.
|
||||||
|
// "VERIFIED", date stamps).
|
||||||
|
|
||||||
|
struct PassportStamp: View {
|
||||||
|
let text: String
|
||||||
|
let color: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Circle()
|
||||||
|
.stroke(color, lineWidth: 1.2)
|
||||||
|
.frame(width: 56, height: 56)
|
||||||
|
.overlay(
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: 10, weight: .black))
|
||||||
|
.tracking(1.5)
|
||||||
|
.foregroundStyle(color)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(6)
|
||||||
|
)
|
||||||
|
.rotationEffect(.degrees(-6))
|
||||||
|
.opacity(0.85)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - OCR-passport flex footer
|
||||||
|
//
|
||||||
|
// The deeply unserious passport-bottom OCR text Flighty uses. We
|
||||||
|
// generate ours from the user's display name + a synthetic issue
|
||||||
|
// date. Pure flavor, fully optional.
|
||||||
|
|
||||||
|
struct OCRPassportFooter: View {
|
||||||
|
let owner: String // "TARTT, GARY"
|
||||||
|
let issued: Date
|
||||||
|
|
||||||
|
@Environment(\.colorScheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
line(prefix: "P<USA<", trailing: nameLine)
|
||||||
|
line(prefix: "ISSUED<", trailing: tailLine)
|
||||||
|
}
|
||||||
|
.font(HistoryStyle.ocrFont)
|
||||||
|
.foregroundStyle(HistoryStyle.inkSecondary(scheme).opacity(0.9))
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.stroke(HistoryStyle.inkSecondary(scheme).opacity(0.3), style: .init(lineWidth: 0.5, dash: [3, 3]))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func line(prefix: String, trailing: String) -> some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Text(prefix + trailing)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var nameLine: String {
|
||||||
|
let upper = owner.uppercased()
|
||||||
|
let padded = (upper + String(repeating: "<", count: 40))
|
||||||
|
return String(padded.prefix(40)) + "<<<<<<<<"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var tailLine: String {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "ddMMMyy"
|
||||||
|
f.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
let date = f.string(from: issued).uppercased()
|
||||||
|
return date + "<<MEMBER<<@FLIGHTAPP.COM<<<<<<<<<<<<<<<<<<<<<<"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Big stat numbers row
|
||||||
|
|
||||||
|
struct StatColumn: View {
|
||||||
|
let label: String
|
||||||
|
let value: String
|
||||||
|
var subtitle: String? = nil
|
||||||
|
@Environment(\.colorScheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(label)
|
||||||
|
.font(HistoryStyle.label(10))
|
||||||
|
.tracking(1.3)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
|
||||||
|
Text(value)
|
||||||
|
.font(HistoryStyle.displayNumber(28))
|
||||||
|
.foregroundStyle(HistoryStyle.ink(scheme))
|
||||||
|
if let subtitle {
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Section header
|
||||||
|
|
||||||
|
struct HistorySectionLabel: View {
|
||||||
|
let text: String
|
||||||
|
@Environment(\.colorScheme) private var scheme
|
||||||
|
|
||||||
|
init(_ text: String) { self.text = text }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(text)
|
||||||
|
.font(HistoryStyle.label(11))
|
||||||
|
.tracking(1.6)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// The Passport screen. Replaces the old "Lifetime Stats" sheet —
|
||||||
|
/// stacked colored hero cards, each one feature-sized for a single
|
||||||
|
/// stat, year tabs at the top to re-scope, OCR-passport flex footer
|
||||||
|
/// at the bottom. Pure read-only.
|
||||||
|
struct PassportView: View {
|
||||||
|
let stats: StatsEngine
|
||||||
|
let allFlights: [LoggedFlight]
|
||||||
|
let database: AirportDatabase
|
||||||
|
let store: FlightHistoryStore
|
||||||
|
@Binding var selectedYear: Int?
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.colorScheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
header
|
||||||
|
YearTabStrip(years: yearsList, selection: $selectedYear)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
cards
|
||||||
|
OCRPassportFooter(owner: "TARTT GARY", issued: stats.flights.first?.flightDate ?? Date())
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 12)
|
||||||
|
Spacer(minLength: 60)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
.background(HistoryStyle.background(scheme).ignoresSafeArea())
|
||||||
|
.navigationTitle("")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button { dismiss() } label: { Image(systemName: "xmark") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text(selectedYear == nil ? "ALL TIME" : String(selectedYear!))
|
||||||
|
.font(.system(size: 12, weight: .heavy))
|
||||||
|
.tracking(2.5)
|
||||||
|
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
|
||||||
|
Text("PASSPORT")
|
||||||
|
.font(.system(size: 40, weight: .black))
|
||||||
|
.tracking(-0.5)
|
||||||
|
.foregroundStyle(HistoryStyle.ink(scheme))
|
||||||
|
Rectangle()
|
||||||
|
.fill(HistoryStyle.runwayOrange)
|
||||||
|
.frame(width: 38, height: 3)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var yearsList: [Int] {
|
||||||
|
let cal = Calendar.current
|
||||||
|
return Array(Set(allFlights.map { cal.component(.year, from: $0.flightDate) })).sorted(by: >)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stats for the current scope — either lifetime or one year.
|
||||||
|
private var scopedStats: StatsEngine {
|
||||||
|
guard let y = selectedYear else { return stats }
|
||||||
|
let filtered = allFlights.filter { Calendar.current.component(.year, from: $0.flightDate) == y }
|
||||||
|
return StatsEngine(store: store, database: database, flights: filtered)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var cards: some View {
|
||||||
|
let s = scopedStats
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
HeroStatCard(
|
||||||
|
label: "FLIGHTS",
|
||||||
|
value: numberString(s.totalFlights),
|
||||||
|
subtitle: "across \(s.uniqueAirports) airports",
|
||||||
|
variant: .orange
|
||||||
|
) {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
StatColumn(label: "Airlines", value: "\(s.uniqueAirlines)")
|
||||||
|
StatColumn(label: "Aircraft", value: "\(s.uniqueAircraftTypes)")
|
||||||
|
StatColumn(label: "Countries", value: "\(s.uniqueCountries)")
|
||||||
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
HeroStatCard(
|
||||||
|
label: "DISTANCE",
|
||||||
|
value: s.shortDistance + " mi",
|
||||||
|
subtitle: equatorComparison(miles: s.totalMiles),
|
||||||
|
variant: .navy
|
||||||
|
) { EmptyView() }
|
||||||
|
|
||||||
|
HeroStatCard(
|
||||||
|
label: "TIME ALOFT",
|
||||||
|
value: hoursAloftDisplay(s.totalMinutes),
|
||||||
|
subtitle: timeAloftSubtitle(s.totalMinutes),
|
||||||
|
variant: .gold,
|
||||||
|
onForeground: .white
|
||||||
|
) { EmptyView() }
|
||||||
|
|
||||||
|
if let top = s.topRoute {
|
||||||
|
HeroStatCard(
|
||||||
|
label: "TOP ROUTE",
|
||||||
|
value: top.label.replacingOccurrences(of: "↔", with: " ↔ "),
|
||||||
|
subtitle: "\(top.count) trips",
|
||||||
|
variant: .green
|
||||||
|
) { EmptyView() }
|
||||||
|
}
|
||||||
|
|
||||||
|
if let topAirline = s.topAirline {
|
||||||
|
let name = AircraftRegistry.shared.lookup(icao: topAirline.icao)?.name ?? topAirline.icao
|
||||||
|
HeroStatCard(
|
||||||
|
label: "TOP AIRLINE",
|
||||||
|
value: name,
|
||||||
|
subtitle: "\(topAirline.count) flights",
|
||||||
|
variant: .orange
|
||||||
|
) { EmptyView() }
|
||||||
|
}
|
||||||
|
|
||||||
|
if let longest = s.longestFlight,
|
||||||
|
let miles = store.distanceMiles(for: longest) {
|
||||||
|
HeroStatCard(
|
||||||
|
label: "LONGEST FLIGHT",
|
||||||
|
value: "\(longest.departureIATA) → \(longest.arrivalIATA)",
|
||||||
|
subtitle: "\(numberString(miles)) mi · \(shortDate(longest.flightDate))",
|
||||||
|
variant: .navy
|
||||||
|
) { EmptyView() }
|
||||||
|
}
|
||||||
|
|
||||||
|
repeatedTailsCard(s)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func repeatedTailsCard(_ s: StatsEngine) -> some View {
|
||||||
|
let tails = s.repeatedTails.prefix(5)
|
||||||
|
if !tails.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HistorySectionLabel("Airframes you've repeated")
|
||||||
|
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ForEach(Array(tails.enumerated()), id: \.offset) { index, item in
|
||||||
|
HStack {
|
||||||
|
Text(item.reg)
|
||||||
|
.font(.system(size: 14, weight: .bold).monospaced())
|
||||||
|
.foregroundStyle(HistoryStyle.ink(scheme))
|
||||||
|
Spacer()
|
||||||
|
Text("\(item.count)×")
|
||||||
|
.font(.system(size: 14, weight: .black).monospacedDigit())
|
||||||
|
.foregroundStyle(HistoryStyle.runwayOrange)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
if index < tails.count - 1 {
|
||||||
|
Rectangle()
|
||||||
|
.fill(HistoryStyle.hairline(scheme))
|
||||||
|
.frame(height: 0.5)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.background(HistoryStyle.card(scheme), in: RoundedRectangle(cornerRadius: 22))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func numberString(_ n: Int) -> String {
|
||||||
|
let f = NumberFormatter()
|
||||||
|
f.numberStyle = .decimal
|
||||||
|
return f.string(from: NSNumber(value: n)) ?? "\(n)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func equatorComparison(miles: Int) -> String {
|
||||||
|
let equator = 24_901
|
||||||
|
let ratio = Double(miles) / Double(equator)
|
||||||
|
if miles == 0 { return "—" }
|
||||||
|
if ratio < 0.05 { return "miles flown" }
|
||||||
|
if ratio < 1 { return String(format: "%.0f%% of the way around earth", ratio * 100) }
|
||||||
|
return String(format: "%.1f× around the equator", ratio)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hoursAloftDisplay(_ minutes: Int) -> String {
|
||||||
|
let days = minutes / (60 * 24)
|
||||||
|
let hours = (minutes % (60 * 24)) / 60
|
||||||
|
if days > 0 {
|
||||||
|
return "\(days)d \(hours)h"
|
||||||
|
}
|
||||||
|
return "\(hours)h"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func timeAloftSubtitle(_ minutes: Int) -> String {
|
||||||
|
if minutes <= 0 { return "—" }
|
||||||
|
return "≈ \(numberString(minutes / 60)) total hours airborne"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shortDate(_ d: Date) -> String {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "MMM d, yyyy"
|
||||||
|
return f.string(from: d)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Design system scoped to the History tab. The rest of the app uses
|
||||||
|
/// `FlightTheme`; the redesigned passport-style history uses its own
|
||||||
|
/// warm aerospace palette so the visual identity doesn't bleed into
|
||||||
|
/// Search or Live.
|
||||||
|
///
|
||||||
|
/// Palette commitment:
|
||||||
|
/// - Runway orange as identity color (vivid, hi-vis, aviation-coded)
|
||||||
|
/// - Midnight navy as the dark surface
|
||||||
|
/// - Warm cream paper as the light surface (passport-stock feel)
|
||||||
|
/// - Stamp green + foil gold as accents on dressed elements
|
||||||
|
enum HistoryStyle {
|
||||||
|
|
||||||
|
// MARK: - Palette
|
||||||
|
|
||||||
|
static let runwayOrange = Color(red: 1.00, green: 0.34, blue: 0.13) // #FF5722
|
||||||
|
static let runwayOrangeDeep = Color(red: 0.85, green: 0.27, blue: 0.07) // #D9461A
|
||||||
|
static let runwayOrangeSoft = Color(red: 1.00, green: 0.55, blue: 0.35) // #FF8C59
|
||||||
|
|
||||||
|
static let midnightNavy = Color(red: 0.04, green: 0.08, blue: 0.14) // #0A1424
|
||||||
|
static let inkNavy = Color(red: 0.08, green: 0.14, blue: 0.25) // #142440
|
||||||
|
static let nightSky = Color(red: 0.06, green: 0.12, blue: 0.22) // #0F1E38
|
||||||
|
|
||||||
|
static let creamPaper = Color(red: 0.96, green: 0.93, blue: 0.85) // #F4ECD8
|
||||||
|
static let creamPaperDeep = Color(red: 0.91, green: 0.87, blue: 0.77) // #E8DDC4
|
||||||
|
static let creamPaperSoft = Color(red: 0.98, green: 0.96, blue: 0.91) // #FAF5E8
|
||||||
|
|
||||||
|
static let stampGreen = Color(red: 0.18, green: 0.35, blue: 0.24) // #2D5A3D
|
||||||
|
static let goldFoil = Color(red: 0.78, green: 0.66, blue: 0.32) // #C8A951
|
||||||
|
|
||||||
|
// MARK: - Adaptive surfaces (dark/light aware)
|
||||||
|
|
||||||
|
/// Top-level page background.
|
||||||
|
static func background(_ scheme: ColorScheme) -> Color {
|
||||||
|
scheme == .dark ? midnightNavy : creamPaper
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Standard card / panel.
|
||||||
|
static func card(_ scheme: ColorScheme) -> Color {
|
||||||
|
scheme == .dark ? inkNavy : creamPaperSoft
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Secondary card (less prominence).
|
||||||
|
static func cardSubtle(_ scheme: ColorScheme) -> Color {
|
||||||
|
scheme == .dark ? nightSky : creamPaperDeep
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Primary text color on the background.
|
||||||
|
static func ink(_ scheme: ColorScheme) -> Color {
|
||||||
|
scheme == .dark ? Color(red: 0.96, green: 0.93, blue: 0.85) : Color(red: 0.06, green: 0.10, blue: 0.18)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func inkSecondary(_ scheme: ColorScheme) -> Color {
|
||||||
|
scheme == .dark ? Color.white.opacity(0.65) : Color.black.opacity(0.55)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func inkTertiary(_ scheme: ColorScheme) -> Color {
|
||||||
|
scheme == .dark ? Color.white.opacity(0.4) : Color.black.opacity(0.35)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func hairline(_ scheme: ColorScheme) -> Color {
|
||||||
|
scheme == .dark ? Color.white.opacity(0.08) : Color.black.opacity(0.08)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Hero card gradients
|
||||||
|
|
||||||
|
static let heroOrangeGradient = LinearGradient(
|
||||||
|
colors: [runwayOrange, runwayOrangeDeep],
|
||||||
|
startPoint: .topLeading, endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
|
||||||
|
static let heroNavyGradient = LinearGradient(
|
||||||
|
colors: [inkNavy, midnightNavy],
|
||||||
|
startPoint: .topLeading, endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
|
||||||
|
static let heroGoldGradient = LinearGradient(
|
||||||
|
colors: [goldFoil, Color(red: 0.55, green: 0.46, blue: 0.20)],
|
||||||
|
startPoint: .top, endPoint: .bottom
|
||||||
|
)
|
||||||
|
|
||||||
|
static let heroGreenGradient = LinearGradient(
|
||||||
|
colors: [stampGreen, Color(red: 0.10, green: 0.22, blue: 0.15)],
|
||||||
|
startPoint: .topLeading, endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
|
||||||
|
// MARK: - Typography
|
||||||
|
|
||||||
|
/// Display weight used for hero numbers like "47,200 mi".
|
||||||
|
static func displayNumber(_ size: CGFloat) -> Font {
|
||||||
|
.system(size: size, weight: .heavy, design: .default)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func label(_ size: CGFloat = 11) -> Font {
|
||||||
|
.system(size: size, weight: .semibold, design: .default)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OCR-passport flavor text font — monospaced, slightly condensed feel.
|
||||||
|
static let ocrFont: Font = .system(size: 11, weight: .regular, design: .monospaced)
|
||||||
|
|
||||||
|
static let cardTitleFont: Font = .system(size: 13, weight: .semibold, design: .default)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - View modifiers
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
/// Bevel-style card chrome used across history surfaces.
|
||||||
|
func historyCard(_ scheme: ColorScheme, padding: CGFloat = 16, cornerRadius: CGFloat = 18) -> some View {
|
||||||
|
self
|
||||||
|
.padding(padding)
|
||||||
|
.background(HistoryStyle.card(scheme), in: RoundedRectangle(cornerRadius: cornerRadius))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: cornerRadius)
|
||||||
|
.stroke(HistoryStyle.hairline(scheme), lineWidth: 0.5)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tracking + uppercase wrapping for section labels and "FLIGHTS" etc.
|
||||||
|
func historyLabel() -> some View {
|
||||||
|
self
|
||||||
|
.font(HistoryStyle.label())
|
||||||
|
.tracking(1.2)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,111 +1,211 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Spotify-Wrapped-style year-in-review deck. Paged horizontal scroller
|
/// Year in Review — horizontal-paged deck of share-ready hero cards
|
||||||
/// of cards, each highlighting one stat for the chosen year. Long-press
|
/// for the chosen year. Each card is a full-screen composition: huge
|
||||||
/// any card to copy a render-ready PNG.
|
/// stat number, small subtitle, footer brand mark.
|
||||||
struct YearInReviewView: View {
|
struct YearInReviewView: View {
|
||||||
let stats: StatsEngine
|
let stats: StatsEngine
|
||||||
let year: Int
|
let year: Int
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.colorScheme) private var scheme
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let yearFlights = stats.flights(for: year)
|
let yearFlights = stats.flights(for: year)
|
||||||
let yearStats = StatsEngine(store: stats.store, database: stats.database, flights: yearFlights)
|
let yearStats = StatsEngine(store: stats.store, database: stats.database, flights: yearFlights)
|
||||||
|
|
||||||
return NavigationStack {
|
NavigationStack {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
TabView {
|
||||||
HStack(spacing: 16) {
|
|
||||||
coverCard(year: year, flights: yearFlights.count)
|
coverCard(year: year, flights: yearFlights.count)
|
||||||
statCard(headline: yearStats.shortDistance, subhead: "miles flown", footer: "\(yearFlights.count) flights")
|
if yearStats.totalMiles > 0 {
|
||||||
statCard(headline: "\(yearStats.uniqueAirports)", subhead: "airports visited", footer: "across \(yearStats.uniqueCountries) countries")
|
distanceCard(yearStats)
|
||||||
statCard(headline: "\(yearStats.shortDuration) h", subhead: "in the air", footer: "≈ \(yearStats.totalMinutes / 60) hours of cruise")
|
}
|
||||||
|
airportsCard(yearStats)
|
||||||
|
hoursCard(yearStats)
|
||||||
if let top = yearStats.topAirline {
|
if let top = yearStats.topAirline {
|
||||||
statCard(
|
topAirlineCard(top)
|
||||||
headline: AircraftRegistry.shared.lookup(icao: top.icao)?.name ?? top.icao,
|
|
||||||
subhead: "Top airline",
|
|
||||||
footer: "\(top.count) flights"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if let route = yearStats.topRoute {
|
if let route = yearStats.topRoute {
|
||||||
statCard(headline: route.label, subhead: "Top route", footer: "\(route.count) trips")
|
topRouteCard(route)
|
||||||
}
|
}
|
||||||
if let longest = yearStats.longestFlight {
|
if let longest = yearStats.longestFlight {
|
||||||
statCard(
|
longestCard(longest, yearStats: yearStats)
|
||||||
headline: "\(longest.departureIATA) → \(longest.arrivalIATA)",
|
|
||||||
subhead: "Longest flight",
|
|
||||||
footer: "your endurance record"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.tabViewStyle(.page(indexDisplayMode: .always))
|
||||||
}
|
.background(HistoryStyle.midnightNavy.ignoresSafeArea())
|
||||||
.padding(.vertical, 24)
|
.navigationTitle("\(year) Year in Flight")
|
||||||
.background(FlightTheme.background.ignoresSafeArea())
|
|
||||||
.navigationTitle("Your \(year)")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
Button("Done") { dismiss() }
|
Button { dismiss() } label: { Image(systemName: "xmark") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Card variants
|
||||||
|
|
||||||
private func coverCard(year: Int, flights: Int) -> some View {
|
private func coverCard(year: Int, flights: Int) -> some View {
|
||||||
VStack {
|
HeroComposition(background: HistoryStyle.heroOrangeGradient) {
|
||||||
Spacer()
|
|
||||||
Text("\(year)")
|
|
||||||
.font(.system(size: 80, weight: .black).monospacedDigit())
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
Text("in flight")
|
|
||||||
.font(.title3.weight(.semibold))
|
|
||||||
.foregroundStyle(.white.opacity(0.8))
|
|
||||||
Spacer()
|
|
||||||
Text("\(flights) flights logged")
|
|
||||||
.font(.caption.weight(.semibold))
|
|
||||||
.foregroundStyle(.white.opacity(0.6))
|
|
||||||
}
|
|
||||||
.frame(width: 320, height: 480)
|
|
||||||
.background(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [FlightTheme.accent, FlightTheme.accent.opacity(0.6)],
|
|
||||||
startPoint: .topLeading,
|
|
||||||
endPoint: .bottomTrailing
|
|
||||||
),
|
|
||||||
in: RoundedRectangle(cornerRadius: 24)
|
|
||||||
)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func statCard(headline: String, subhead: String, footer: String) -> some View {
|
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(headline)
|
Text("\(year)")
|
||||||
.font(.system(size: 56, weight: .bold).monospacedDigit())
|
.font(.system(size: 140, weight: .black).monospacedDigit())
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.lineLimit(2)
|
|
||||||
.minimumScaleFactor(0.5)
|
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.padding(.horizontal, 16)
|
Text("YEAR IN FLIGHT")
|
||||||
Text(subhead)
|
.font(.system(size: 18, weight: .heavy))
|
||||||
.font(.title3.weight(.semibold))
|
.tracking(2.5)
|
||||||
.foregroundStyle(.white.opacity(0.85))
|
.foregroundStyle(.white.opacity(0.85))
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(footer)
|
Text("\(flights) flights logged")
|
||||||
.font(.caption)
|
.font(.system(size: 14, weight: .semibold))
|
||||||
.foregroundStyle(.white.opacity(0.65))
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
}
|
}
|
||||||
.frame(width: 320, height: 480)
|
}
|
||||||
.background(
|
}
|
||||||
LinearGradient(
|
|
||||||
colors: [FlightTheme.accent.opacity(0.85), FlightTheme.accent.opacity(0.45)],
|
private func distanceCard(_ s: StatsEngine) -> some View {
|
||||||
startPoint: .top,
|
HeroComposition(background: HistoryStyle.heroNavyGradient) {
|
||||||
endPoint: .bottom
|
cardBody(
|
||||||
),
|
eyebrow: "DISTANCE",
|
||||||
in: RoundedRectangle(cornerRadius: 24)
|
hero: s.shortDistance,
|
||||||
|
heroAccent: "mi",
|
||||||
|
subtitle: equatorBlurb(miles: s.totalMiles)
|
||||||
)
|
)
|
||||||
.padding(.vertical, 8)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func airportsCard(_ s: StatsEngine) -> some View {
|
||||||
|
HeroComposition(background: HistoryStyle.heroGreenGradient) {
|
||||||
|
cardBody(
|
||||||
|
eyebrow: "PASSPORT STAMPS",
|
||||||
|
hero: "\(s.uniqueAirports)",
|
||||||
|
heroAccent: "airports",
|
||||||
|
subtitle: "\(s.uniqueCountries) countries"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hoursCard(_ s: StatsEngine) -> some View {
|
||||||
|
HeroComposition(background: HistoryStyle.heroGoldGradient) {
|
||||||
|
cardBody(
|
||||||
|
eyebrow: "TIME ALOFT",
|
||||||
|
hero: hoursDisplay(s.totalMinutes),
|
||||||
|
heroAccent: "",
|
||||||
|
subtitle: "≈ \(s.totalMinutes / 60) hours airborne"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func topAirlineCard(_ top: (icao: String, count: Int)) -> some View {
|
||||||
|
let name = AircraftRegistry.shared.lookup(icao: top.icao)?.name ?? top.icao
|
||||||
|
return HeroComposition(background: HistoryStyle.heroOrangeGradient) {
|
||||||
|
cardBody(
|
||||||
|
eyebrow: "TOP AIRLINE",
|
||||||
|
hero: name,
|
||||||
|
heroAccent: "",
|
||||||
|
subtitle: "\(top.count) flights"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func topRouteCard(_ route: (label: String, count: Int)) -> some View {
|
||||||
|
HeroComposition(background: HistoryStyle.heroNavyGradient) {
|
||||||
|
cardBody(
|
||||||
|
eyebrow: "TOP ROUTE",
|
||||||
|
hero: route.label.replacingOccurrences(of: "↔", with: " ↔ "),
|
||||||
|
heroAccent: "",
|
||||||
|
subtitle: "\(route.count) trips"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func longestCard(_ longest: LoggedFlight, yearStats: StatsEngine) -> some View {
|
||||||
|
let miles = yearStats.store.distanceMiles(for: longest) ?? 0
|
||||||
|
return HeroComposition(background: HistoryStyle.heroGreenGradient) {
|
||||||
|
cardBody(
|
||||||
|
eyebrow: "ENDURANCE RECORD",
|
||||||
|
hero: "\(longest.departureIATA) → \(longest.arrivalIATA)",
|
||||||
|
heroAccent: "",
|
||||||
|
subtitle: "\(numberString(miles)) miles"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Card body template
|
||||||
|
|
||||||
|
private func cardBody(eyebrow: String, hero: String, heroAccent: String, subtitle: String) -> some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Spacer()
|
||||||
|
Text(eyebrow)
|
||||||
|
.font(.system(size: 14, weight: .heavy))
|
||||||
|
.tracking(3)
|
||||||
|
.foregroundStyle(.white.opacity(0.8))
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||||
|
Text(hero)
|
||||||
|
.font(.system(size: 64, weight: .black).monospacedDigit())
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.lineLimit(2)
|
||||||
|
.minimumScaleFactor(0.4)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
if !heroAccent.isEmpty {
|
||||||
|
Text(heroAccent)
|
||||||
|
.font(.system(size: 22, weight: .heavy))
|
||||||
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
.foregroundStyle(.white.opacity(0.85))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
Spacer()
|
||||||
|
Text("FLIGHTS · \(year)")
|
||||||
|
.font(.system(size: 10, weight: .heavy))
|
||||||
|
.tracking(2)
|
||||||
|
.foregroundStyle(.white.opacity(0.55))
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func equatorBlurb(miles: Int) -> String {
|
||||||
|
let equator = 24_901
|
||||||
|
let ratio = Double(miles) / Double(equator)
|
||||||
|
if ratio < 0.05 { return "miles flown" }
|
||||||
|
if ratio < 1 { return String(format: "%.0f%% of earth's equator", ratio * 100) }
|
||||||
|
return String(format: "%.1f× around the equator", ratio)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hoursDisplay(_ minutes: Int) -> String {
|
||||||
|
let days = minutes / (60 * 24)
|
||||||
|
let hours = (minutes % (60 * 24)) / 60
|
||||||
|
if days > 0 { return "\(days)d \(hours)h" }
|
||||||
|
return "\(hours)h"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func numberString(_ n: Int) -> String {
|
||||||
|
let f = NumberFormatter(); f.numberStyle = .decimal
|
||||||
|
return f.string(from: NSNumber(value: n)) ?? "\(n)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Card frame for the Year in Review deck — generic background +
|
||||||
|
/// foreground content. Used to keep page-to-page motion + sizing
|
||||||
|
/// consistent.
|
||||||
|
private struct HeroComposition<Content: View>: View {
|
||||||
|
let background: LinearGradient
|
||||||
|
@ViewBuilder var content: () -> Content
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
background
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 28))
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.vertical, 32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user