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 */; };
|
||||
HX1400001400000014000001 /* HistoryFilterSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1400001400000014000002 /* HistoryFilterSheet.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 */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -171,6 +175,10 @@
|
||||
HX1300001300000013000002 /* HistoryFilters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryFilters.swift; sourceTree = "<group>"; };
|
||||
HX1400001400000014000002 /* HistoryFilterSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryFilterSheet.swift; sourceTree = "<group>"; };
|
||||
HX1500001500000015000002 /* AirportFlightsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirportFlightsView.swift; sourceTree = "<group>"; };
|
||||
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 */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -220,6 +228,9 @@
|
||||
HX1200001200000012000002 /* ImportCSVView.swift */,
|
||||
HX1400001400000014000002 /* HistoryFilterSheet.swift */,
|
||||
HX1500001500000015000002 /* AirportFlightsView.swift */,
|
||||
HX1700001700000017000002 /* PassportComponents.swift */,
|
||||
HX1800001800000018000002 /* PassportView.swift */,
|
||||
HX1900001900000019000002 /* AircraftStatsView.swift */,
|
||||
AA5555555555555555555555 /* Styles */,
|
||||
AA6666666666666666666666 /* Components */,
|
||||
);
|
||||
@@ -230,6 +241,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AA2222222222222222222222 /* FlightTheme.swift */,
|
||||
HX1600001600000016000002 /* HistoryStyle.swift */,
|
||||
);
|
||||
path = Styles;
|
||||
sourceTree = "<group>";
|
||||
@@ -507,6 +519,10 @@
|
||||
HX1300001300000013000001 /* HistoryFilters.swift in Sources */,
|
||||
HX1400001400000014000001 /* HistoryFilterSheet.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;
|
||||
};
|
||||
|
||||
@@ -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 CoreLocation
|
||||
|
||||
/// Single-flight detail screen. Layout follows the live detail sheet
|
||||
/// pattern (title → route → photo → map → aircraft) but adds notes
|
||||
/// and a delete button. Pulls a track replay from OpenSky for flights
|
||||
/// flown in the last ~7 days; everything older falls back to a clean
|
||||
/// great-circle arc.
|
||||
/// Single-flight detail screen — restyled with the passport palette.
|
||||
/// Aircraft card now uses Flighty's labeled-grid pattern with
|
||||
/// em-dashes for missing data. New "Detailed Timetable" card shows
|
||||
/// scheduled vs actual when we have actual times, with late actuals
|
||||
/// in red.
|
||||
struct HistoryDetailView: View {
|
||||
let flight: LoggedFlight
|
||||
let store: FlightHistoryStore
|
||||
@@ -15,34 +15,33 @@ struct HistoryDetailView: View {
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.openURL) private var openURL
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
@State private var photo: AircraftPhotoService.Photo?
|
||||
@State private var track: AircraftTrack?
|
||||
@State private var editedNotes: String = ""
|
||||
@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
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
header
|
||||
routeCard
|
||||
photoBanner
|
||||
.padding(.horizontal, -16)
|
||||
photoBanner.padding(.horizontal, -16)
|
||||
if let cred = photo?.photographer {
|
||||
photoCredit(name: cred, link: photo?.detailLink)
|
||||
}
|
||||
mapSection
|
||||
aircraftCard
|
||||
timetableCard
|
||||
notesSection
|
||||
deleteButton
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.background(FlightTheme.background.ignoresSafeArea())
|
||||
.navigationTitle(flight.flightLabel)
|
||||
.background(HistoryStyle.background(scheme).ignoresSafeArea())
|
||||
.navigationTitle("")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
editedNotes = flight.notes ?? ""
|
||||
@@ -64,81 +63,92 @@ struct HistoryDetailView: View {
|
||||
// MARK: - Header
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
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))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(flight.flightLabel)
|
||||
.font(.title.weight(.bold).monospaced())
|
||||
.foregroundStyle(FlightTheme.textPrimary)
|
||||
Spacer()
|
||||
Text(longDate(flight.flightDate))
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
}
|
||||
.font(.system(size: 38, weight: .black).monospaced())
|
||||
.foregroundStyle(HistoryStyle.ink(scheme))
|
||||
HStack(spacing: 8) {
|
||||
Text(airlineName)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(FlightTheme.textSecondary)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
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 {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("ROUTE")
|
||||
.font(FlightTheme.label())
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
.tracking(1)
|
||||
HStack(spacing: 16) {
|
||||
endpoint(iata: flight.departureIATA, label: "Departed", time: flight.actualDeparture ?? flight.scheduledDeparture)
|
||||
VStack(spacing: 14) {
|
||||
HStack(alignment: .top) {
|
||||
routeEndpoint(iata: flight.departureIATA, label: "From", time: flight.actualDeparture ?? flight.scheduledDeparture)
|
||||
Spacer()
|
||||
Image(systemName: "airplane")
|
||||
.font(.title3)
|
||||
.foregroundStyle(FlightTheme.accent)
|
||||
.font(.system(size: 24, weight: .heavy))
|
||||
.foregroundStyle(HistoryStyle.runwayOrange)
|
||||
.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) {
|
||||
Text("\(numberString(mi)) miles · \(durationDisplay)")
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
miniStat(label: "Distance", value: "\(numberString(mi)) mi")
|
||||
}
|
||||
miniStat(label: "Duration", value: durationDisplay)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.flightCard()
|
||||
.historyCard(scheme, padding: 18)
|
||||
}
|
||||
|
||||
private func endpoint(iata: String, label: String, time: Date?) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
.tracking(0.5)
|
||||
private func routeEndpoint(iata: String, label: String, time: Date?) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(label.uppercased())
|
||||
.font(HistoryStyle.label(10))
|
||||
.tracking(1.3)
|
||||
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
|
||||
Text(iata.isEmpty ? "—" : iata)
|
||||
.font(FlightTheme.airportCode(28))
|
||||
.foregroundStyle(FlightTheme.textPrimary)
|
||||
.font(.system(size: 32, weight: .black).monospaced())
|
||||
.foregroundStyle(HistoryStyle.ink(scheme))
|
||||
if let m = database.airport(byIATA: iata) {
|
||||
Text(m.name)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(FlightTheme.textSecondary)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
|
||||
.lineLimit(1)
|
||||
}
|
||||
if let time {
|
||||
Text(shortDateTime(time))
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
.font(.system(size: 11, weight: .semibold).monospaced())
|
||||
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
|
||||
}
|
||||
}
|
||||
.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
|
||||
|
||||
@ViewBuilder
|
||||
@@ -146,10 +156,8 @@ struct HistoryDetailView: View {
|
||||
if let photo {
|
||||
AsyncImage(url: photo.largeURL) { phase in
|
||||
switch phase {
|
||||
case .success(let img):
|
||||
img.resizable().aspectRatio(contentMode: .fill)
|
||||
default:
|
||||
Rectangle().fill(FlightTheme.cardBackground)
|
||||
case .success(let img): img.resizable().aspectRatio(contentMode: .fill)
|
||||
default: Rectangle().fill(HistoryStyle.cardSubtle(scheme))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -160,11 +168,11 @@ struct HistoryDetailView: View {
|
||||
|
||||
private func photoCredit(name: String, link: URL?) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "camera.fill").font(.caption2)
|
||||
Image(systemName: "camera.fill").font(.system(size: 9))
|
||||
Text("Photo by \(name) · planespotters.net")
|
||||
.font(.caption2)
|
||||
.font(.system(size: 10))
|
||||
}
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { if let link { openURL(link) } }
|
||||
}
|
||||
@@ -174,10 +182,7 @@ struct HistoryDetailView: View {
|
||||
@ViewBuilder
|
||||
private var mapSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(track == nil ? "ROUTE MAP" : "FLOWN PATH")
|
||||
.font(FlightTheme.label())
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
.tracking(1)
|
||||
HistorySectionLabel(track == nil ? "Route" : "Flown path")
|
||||
FlightRouteMap(
|
||||
departureIATA: flight.departureIATA,
|
||||
arrivalIATA: flight.arrivalIATA,
|
||||
@@ -185,30 +190,20 @@ struct HistoryDetailView: View {
|
||||
database: database
|
||||
)
|
||||
.frame(height: 220)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18))
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
guard ageDays < 7, let icao24 = flight.icao24, !icao24.isEmpty else { return }
|
||||
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 {
|
||||
guard let reg = flight.registration,
|
||||
!reg.isEmpty,
|
||||
let icao24 = flight.icao24,
|
||||
!icao24.isEmpty
|
||||
guard let reg = flight.registration, !reg.isEmpty,
|
||||
let icao24 = flight.icao24, !icao24.isEmpty
|
||||
else { return }
|
||||
// Skip if we already have a cached entry with at least one date.
|
||||
if let cached = store.airframe(for: reg),
|
||||
cached.firstFlightDate != nil || cached.deliveryDate != nil {
|
||||
metadataLoaded.toggle()
|
||||
@@ -229,63 +224,147 @@ struct HistoryDetailView: View {
|
||||
private var aircraftCard: some View {
|
||||
let repeats = store.repeatCount(for: flight.registration, before: flight.flightDate)
|
||||
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) {
|
||||
Text("AIRCRAFT")
|
||||
.font(FlightTheme.label())
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
.tracking(1)
|
||||
return VStack(alignment: .leading, spacing: 12) {
|
||||
HistorySectionLabel("Aircraft")
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 0) {
|
||||
cell(label: "Type", value: flight.aircraftType ?? "—")
|
||||
cell(label: "Tail", value: flight.registration ?? "—")
|
||||
}
|
||||
if ageYears != nil || repeats > 0 {
|
||||
Divider()
|
||||
HStack(spacing: 0) {
|
||||
if let yrs = ageYears {
|
||||
cell(label: "Age", value: "\(yrs)y")
|
||||
} else {
|
||||
cell(label: "Age", value: "—")
|
||||
}
|
||||
cell(
|
||||
label: "On this airframe",
|
||||
value: repeats == 0 ? "First time" : "\(repeats + 1)\(ordinalSuffix(repeats + 1)) time"
|
||||
aircraftRow(
|
||||
leftLabel: "Type", leftValue: flight.aircraftType ?? "—",
|
||||
rightLabel: "Tail #", rightValue: flight.registration ?? "—"
|
||||
)
|
||||
divider
|
||||
aircraftRow(
|
||||
leftLabel: "First flight",
|
||||
leftValue: firstFlight.map { yearString($0) } ?? "—",
|
||||
rightLabel: "Age",
|
||||
rightValue: ageYears.map { "\($0) yr" } ?? "—"
|
||||
)
|
||||
divider
|
||||
aircraftRow(
|
||||
leftLabel: "On this airframe",
|
||||
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 {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
.tracking(0.5)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(label.uppercased())
|
||||
.font(HistoryStyle.label(10))
|
||||
.tracking(1.3)
|
||||
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.semibold).monospaced())
|
||||
.foregroundStyle(FlightTheme.textPrimary)
|
||||
.font(.system(size: 14, weight: .heavy).monospaced())
|
||||
.foregroundStyle(HistoryStyle.ink(scheme))
|
||||
}
|
||||
.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
|
||||
|
||||
private var notesSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("NOTES")
|
||||
.font(FlightTheme.label())
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
.tracking(1)
|
||||
HistorySectionLabel("Notes")
|
||||
TextEditor(text: $editedNotes)
|
||||
.scrollContentBackground(.hidden)
|
||||
.frame(minHeight: 80)
|
||||
.padding(8)
|
||||
.background(FlightTheme.cardBackground, in: RoundedRectangle(cornerRadius: 10))
|
||||
.background(HistoryStyle.card(scheme), in: RoundedRectangle(cornerRadius: 14))
|
||||
.onChange(of: editedNotes) { _, newValue in
|
||||
flight.notes = newValue.isEmpty ? nil : newValue
|
||||
}
|
||||
@@ -302,25 +381,17 @@ struct HistoryDetailView: View {
|
||||
Image(systemName: "trash")
|
||||
Text("Delete flight")
|
||||
}
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.background(FlightTheme.cancelled.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
|
||||
.foregroundStyle(FlightTheme.cancelled)
|
||||
.background(Color.red.opacity(0.12), in: RoundedRectangle(cornerRadius: 14))
|
||||
.foregroundStyle(Color.red)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
guard let min = store.durationMinutes(for: flight) else { return "—" }
|
||||
let h = min / 60
|
||||
@@ -329,8 +400,7 @@ struct HistoryDetailView: View {
|
||||
}
|
||||
|
||||
private func numberString(_ n: Int) -> String {
|
||||
let f = NumberFormatter()
|
||||
f.numberStyle = .decimal
|
||||
let f = NumberFormatter(); f.numberStyle = .decimal
|
||||
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
|
||||
}
|
||||
|
||||
private func yearString(_ d: Date) -> String {
|
||||
let f = DateFormatter(); f.dateFormat = "yyyy"
|
||||
return f.string(from: d)
|
||||
}
|
||||
|
||||
private func ordinalSuffix(_ n: Int) -> String {
|
||||
let r = n % 100
|
||||
if r >= 11 && r <= 13 { return "th" }
|
||||
@@ -350,14 +425,17 @@ struct HistoryDetailView: View {
|
||||
}
|
||||
|
||||
private func longDate(_ d: Date) -> String {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "MMM d, yyyy"
|
||||
let f = DateFormatter(); f.dateFormat = "EEE, MMM d, yyyy"
|
||||
return f.string(from: d)
|
||||
}
|
||||
|
||||
private func shortDateTime(_ d: Date) -> String {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "MMM d, HH:mm"
|
||||
let f = DateFormatter(); 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)
|
||||
}
|
||||
}
|
||||
@@ -374,29 +452,26 @@ private struct FlightRouteMap: View {
|
||||
Map {
|
||||
if let dep = database.airport(byIATA: departureIATA) {
|
||||
Marker("From " + departureIATA, systemImage: "airplane.departure", coordinate: dep.coordinate)
|
||||
.tint(FlightTheme.onTime)
|
||||
.tint(HistoryStyle.stampGreen)
|
||||
}
|
||||
if let arr = database.airport(byIATA: arrivalIATA) {
|
||||
Marker("To " + arrivalIATA, systemImage: "airplane.arrival", coordinate: arr.coordinate)
|
||||
.tint(FlightTheme.accent)
|
||||
.tint(HistoryStyle.runwayOrange)
|
||||
}
|
||||
if let track {
|
||||
let coords = track.path.map {
|
||||
CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude)
|
||||
}
|
||||
MapPolyline(coordinates: coords)
|
||||
.stroke(FlightTheme.accent, lineWidth: 3)
|
||||
.stroke(HistoryStyle.runwayOrange, lineWidth: 3)
|
||||
} else if let dep = database.airport(byIATA: departureIATA),
|
||||
let arr = database.airport(byIATA: arrivalIATA) {
|
||||
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] {
|
||||
let lat1 = a.latitude * .pi / 180
|
||||
let lon1 = a.longitude * .pi / 180
|
||||
|
||||
@@ -23,6 +23,7 @@ struct HistoryRouteMapView: View {
|
||||
@State private var selectedAirportSheet: AirportSheet?
|
||||
@State private var selectedFlight: LoggedFlight?
|
||||
@State private var revealKey: Int = 0 // bump to retrigger the reveal animation
|
||||
@State private var drawerExpanded: Bool = false
|
||||
|
||||
struct AirportSheet: Identifiable {
|
||||
let iata: String
|
||||
@@ -32,7 +33,8 @@ struct HistoryRouteMapView: View {
|
||||
var body: some View {
|
||||
let arcs = self.arcs
|
||||
|
||||
return Map(position: $position) {
|
||||
return ZStack(alignment: .bottom) {
|
||||
Map(position: $position) {
|
||||
// Airport dots
|
||||
ForEach(airportItems, id: \.iata) { item in
|
||||
Annotation(item.iata, coordinate: item.coord) {
|
||||
@@ -47,10 +49,17 @@ struct HistoryRouteMapView: View {
|
||||
// Animated arcs
|
||||
ForEach(arcs.prefix(revealCount)) { arc in
|
||||
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")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
@@ -125,10 +134,101 @@ struct HistoryRouteMapView: View {
|
||||
|
||||
private func arcColor(for arc: Arc) -> Color {
|
||||
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.
|
||||
return FlightTheme.accent.opacity(0.55)
|
||||
// Bulk lines in vivid runway orange.
|
||||
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
|
||||
@@ -199,12 +299,10 @@ private struct AirportDot: View {
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.fill(isSelected ? FlightTheme.onTime : FlightTheme.accent)
|
||||
.fill(isSelected ? Color.yellow : HistoryStyle.runwayOrange)
|
||||
.frame(width: size, height: size)
|
||||
.overlay(
|
||||
Circle().stroke(.white, lineWidth: 1.5)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.2), radius: 1, y: 1)
|
||||
.overlay(Circle().stroke(.white, lineWidth: 2))
|
||||
.shadow(color: .black.opacity(0.5), radius: 2, y: 1)
|
||||
.contentShape(Circle())
|
||||
}
|
||||
}
|
||||
|
||||
+395
-162
@@ -1,25 +1,31 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// Top-level history tab. Totals strip + sortable / filterable / searchable
|
||||
/// list of every flight you've logged, plus entry points to the lifetime
|
||||
/// stats / route map / year-in-review screens.
|
||||
/// History tab — redesigned as a "passport" experience.
|
||||
///
|
||||
/// 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 {
|
||||
let database: AirportDatabase
|
||||
let routeExplorer: RouteExplorerClient
|
||||
let openSky: OpenSkyClient
|
||||
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
@Query(sort: \LoggedFlight.flightDate, order: .reverse)
|
||||
private var flights: [LoggedFlight]
|
||||
|
||||
@State private var filters: HistoryFilters = .init()
|
||||
@State private var sort: HistorySort = .newestFirst
|
||||
@State private var selectedYear: Int? = nil // nil = ALL
|
||||
|
||||
@State private var showingAdd = false
|
||||
@State private var showingStats = false
|
||||
@State private var showingPassport = false
|
||||
@State private var showingMap = false
|
||||
@State private var showingAircraftStats = false
|
||||
@State private var showingCalendarImport = false
|
||||
@State private var showingCSVImport = false
|
||||
@State private var showingYearInReview = false
|
||||
@@ -27,54 +33,43 @@ struct HistoryView: View {
|
||||
|
||||
var body: some View {
|
||||
let store = FlightHistoryStore(context: modelContext, airportDatabase: database)
|
||||
let visible = filteredSorted(store: store)
|
||||
let stats = StatsEngine(store: store, database: database, flights: visible)
|
||||
let scoped = scopedFlights(store: store)
|
||||
let stats = StatsEngine(store: store, database: database, flights: scoped)
|
||||
|
||||
return List {
|
||||
if !flights.isEmpty {
|
||||
Section {
|
||||
totalsStrip(stats: stats, isFiltered: !filters.isEmpty)
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(Color.clear)
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0, pinnedViews: []) {
|
||||
titleHeader
|
||||
|
||||
YearTabStrip(years: yearsList, selection: $selectedYear)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
if filters.isEmpty {
|
||||
heroDeck(store: store, stats: stats)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
if !filters.isEmpty {
|
||||
Section {
|
||||
activeChips
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 8, trailing: 16))
|
||||
.listRowBackground(Color.clear)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
|
||||
if scoped.isEmpty {
|
||||
emptyState
|
||||
} else if visible.isEmpty {
|
||||
noMatchState
|
||||
} else {
|
||||
flightFeed(scoped, store: store)
|
||||
}
|
||||
|
||||
Spacer(minLength: 80)
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.navigationTitle("History")
|
||||
.searchable(text: $filters.query, placement: .navigationBarDrawer(displayMode: .always), prompt: "Flight #, airport, route")
|
||||
.background(HistoryStyle.background(scheme).ignoresSafeArea())
|
||||
.navigationTitle("")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.searchable(text: $filters.query, prompt: "Flight #, airport, route")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .secondaryAction) {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
ForEach(HistorySort.allCases) { option in
|
||||
Button {
|
||||
@@ -88,57 +83,39 @@ struct HistoryView: View {
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Sort", systemImage: "arrow.up.arrow.down")
|
||||
Image(systemName: "arrow.up.arrow.down")
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .secondaryAction) {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
showingFilterSheet = true
|
||||
} label: {
|
||||
if filters.activeCount > 0 {
|
||||
Label("Filters (\(filters.activeCount))", systemImage: "line.3.horizontal.decrease.circle.fill")
|
||||
} else {
|
||||
Label("Filters", systemImage: "line.3.horizontal.decrease.circle")
|
||||
Image(systemName: filters.activeCount > 0 ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
Button { showingAdd = true } label: {
|
||||
Label("Add manually", systemImage: "plus")
|
||||
}
|
||||
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")
|
||||
}
|
||||
Button { showingAdd = true } label: { Label("Add manually", systemImage: "plus") }
|
||||
Button { showingCalendarImport = true } label: { Label("Scan Calendar", systemImage: "calendar") }
|
||||
Button { showingCSVImport = true } label: { Label("Import CSV…", systemImage: "doc.text") }
|
||||
} label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(HistoryStyle.runwayOrange)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingAdd) {
|
||||
AddFlightView(routeExplorer: routeExplorer, database: database, store: store, prefill: nil)
|
||||
}
|
||||
.sheet(isPresented: $showingStats) {
|
||||
NavigationStack { LifetimeStatsView(stats: stats) }
|
||||
.sheet(isPresented: $showingPassport) {
|
||||
NavigationStack {
|
||||
PassportView(stats: stats, allFlights: flights, database: database, store: store, selectedYear: $selectedYear)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingMap) {
|
||||
NavigationStack {
|
||||
HistoryRouteMapView(
|
||||
flights: visible,
|
||||
flights: scoped,
|
||||
allFlights: flights,
|
||||
database: database,
|
||||
openSky: openSky,
|
||||
@@ -147,6 +124,11 @@ struct HistoryView: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingAircraftStats) {
|
||||
NavigationStack {
|
||||
AircraftStatsView(allFlights: flights, store: store)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingCalendarImport) {
|
||||
CalendarImportView(routeExplorer: routeExplorer, database: database, store: store)
|
||||
}
|
||||
@@ -154,7 +136,7 @@ struct HistoryView: View {
|
||||
ImportCSVView(store: store)
|
||||
}
|
||||
.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) {
|
||||
HistoryFilterSheet(allFlights: flights, filters: $filters)
|
||||
@@ -162,34 +144,169 @@ struct HistoryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sort/filter pipeline
|
||||
// MARK: - Pipeline
|
||||
|
||||
private func filteredSorted(store: FlightHistoryStore) -> [LoggedFlight] {
|
||||
let filtered = flights.filter { filters.matches($0) }
|
||||
let comparator = sort.comparator { store.distanceMiles(for: $0) ?? 0 }
|
||||
return filtered.sorted(by: comparator)
|
||||
}
|
||||
|
||||
// MARK: - Grouping
|
||||
|
||||
private struct Group {
|
||||
let key: String
|
||||
let flights: [LoggedFlight]
|
||||
}
|
||||
|
||||
/// 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:
|
||||
private var yearsList: [Int] {
|
||||
let cal = Calendar.current
|
||||
let grouped = Dictionary(grouping: list) { cal.component(.year, from: $0.flightDate) }
|
||||
let order: (Int, Int) -> Bool = sort == .newestFirst ? (>) : (<)
|
||||
return grouped
|
||||
.map { Group(key: String($0.key), flights: $0.value) }
|
||||
.sorted { order(Int($0.key) ?? 0, Int($1.key) ?? 0) }
|
||||
case .longestFirst, .shortestFirst, .airline, .flightNumber:
|
||||
return [Group(key: sort.rawValue, flights: list)]
|
||||
let ys = Set(flights.map { cal.component(.year, from: $0.flightDate) })
|
||||
return ys.sorted(by: >)
|
||||
}
|
||||
|
||||
private func scopedFlights(store: FlightHistoryStore) -> [LoggedFlight] {
|
||||
var scoped = flights
|
||||
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
|
||||
chip(t, systemImage: "airplane.departure") { filters.aircraftTypes.remove(t) }
|
||||
}
|
||||
Button {
|
||||
filters = HistoryFilters()
|
||||
} label: {
|
||||
Button { filters = HistoryFilters() } label: {
|
||||
Text("Clear")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(FlightTheme.accent)
|
||||
.padding(.horizontal, 12)
|
||||
.foregroundStyle(HistoryStyle.runwayOrange)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
}
|
||||
@@ -237,82 +352,200 @@ struct HistoryView: View {
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(FlightTheme.accent, in: Capsule())
|
||||
.background(HistoryStyle.runwayOrange, in: Capsule())
|
||||
.onTapGesture(perform: onRemove)
|
||||
}
|
||||
|
||||
// MARK: - Totals strip
|
||||
// MARK: - Flight feed
|
||||
|
||||
private func totalsStrip(stats: StatsEngine, isFiltered: Bool) -> some View {
|
||||
VStack(spacing: 6) {
|
||||
if isFiltered {
|
||||
Text("FILTERED TOTALS")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.tracking(0.8)
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
HStack(spacing: 12) {
|
||||
statTile(value: "\(stats.totalFlights)", label: "flights")
|
||||
statTile(value: stats.shortDistance, label: "miles")
|
||||
statTile(value: stats.shortDuration, label: "hours")
|
||||
statTile(value: "\(stats.uniqueAirports)", label: "airports")
|
||||
@ViewBuilder
|
||||
private func flightFeed(_ scoped: [LoggedFlight], store: FlightHistoryStore) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
HistorySectionLabel(selectedYear == nil ? "Recent flights" : "Flights in \(selectedYear!)")
|
||||
Spacer()
|
||||
Text("\(scoped.count)")
|
||||
.font(.system(size: 12, weight: .bold).monospacedDigit())
|
||||
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
|
||||
private func statTile(value: String, label: String) -> some View {
|
||||
VStack(spacing: 2) {
|
||||
Text(value)
|
||||
.font(.title3.weight(.bold).monospacedDigit())
|
||||
.foregroundStyle(FlightTheme.textPrimary)
|
||||
Text(label.uppercased())
|
||||
.font(.caption2.weight(.semibold))
|
||||
ForEach(groupedFeed(scoped), id: \.key) { group in
|
||||
if groupedFeed(scoped).count > 1 {
|
||||
Text(group.key)
|
||||
.font(.system(size: 12, weight: .bold).monospacedDigit())
|
||||
.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 {
|
||||
VStack(spacing: 10) {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "airplane.circle")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
Text("No flights logged yet")
|
||||
.font(.headline)
|
||||
.foregroundStyle(FlightTheme.textSecondary)
|
||||
Text("Tap + to add a flight manually, scan your calendar, import a CSV, or tap an aircraft on the Live tab.")
|
||||
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
|
||||
Text(flights.isEmpty ? "No flights logged yet" : "No matches in \(selectedYear.map(String.init) ?? "this filter")")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
|
||||
if flights.isEmpty {
|
||||
Text("Tap + to add a flight, scan your calendar, or import a CSV.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.horizontal, 32)
|
||||
} else {
|
||||
Button("Clear filter") {
|
||||
selectedYear = nil
|
||||
filters = HistoryFilters()
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(HistoryStyle.runwayOrange)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 40)
|
||||
.listRowBackground(Color.clear)
|
||||
.padding(.vertical, 60)
|
||||
}
|
||||
|
||||
private var noMatchState: some View {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "line.3.horizontal.decrease.circle")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
Text("No flights match these filters")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(FlightTheme.textSecondary)
|
||||
Button("Clear filters") { filters = HistoryFilters() }
|
||||
.font(.subheadline)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 28)
|
||||
.listRowBackground(Color.clear)
|
||||
private func numberString(_ n: Int) -> String {
|
||||
let f = NumberFormatter()
|
||||
f.numberStyle = .decimal
|
||||
return f.string(from: NSNumber(value: n)) ?? "\(n)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Passport-styled flight row
|
||||
|
||||
struct PassportFlightRow: View {
|
||||
let flight: LoggedFlight
|
||||
let database: AirportDatabase
|
||||
@State private var photo: AircraftPhotoService.Photo?
|
||||
@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
|
||||
|
||||
/// Spotify-Wrapped-style year-in-review deck. Paged horizontal scroller
|
||||
/// of cards, each highlighting one stat for the chosen year. Long-press
|
||||
/// any card to copy a render-ready PNG.
|
||||
/// Year in Review — horizontal-paged deck of share-ready hero cards
|
||||
/// for the chosen year. Each card is a full-screen composition: huge
|
||||
/// stat number, small subtitle, footer brand mark.
|
||||
struct YearInReviewView: View {
|
||||
let stats: StatsEngine
|
||||
let year: Int
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
let yearFlights = stats.flights(for: year)
|
||||
let yearStats = StatsEngine(store: stats.store, database: stats.database, flights: yearFlights)
|
||||
|
||||
return NavigationStack {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 16) {
|
||||
NavigationStack {
|
||||
TabView {
|
||||
coverCard(year: year, flights: yearFlights.count)
|
||||
statCard(headline: yearStats.shortDistance, subhead: "miles flown", footer: "\(yearFlights.count) flights")
|
||||
statCard(headline: "\(yearStats.uniqueAirports)", subhead: "airports visited", footer: "across \(yearStats.uniqueCountries) countries")
|
||||
statCard(headline: "\(yearStats.shortDuration) h", subhead: "in the air", footer: "≈ \(yearStats.totalMinutes / 60) hours of cruise")
|
||||
if yearStats.totalMiles > 0 {
|
||||
distanceCard(yearStats)
|
||||
}
|
||||
airportsCard(yearStats)
|
||||
hoursCard(yearStats)
|
||||
if let top = yearStats.topAirline {
|
||||
statCard(
|
||||
headline: AircraftRegistry.shared.lookup(icao: top.icao)?.name ?? top.icao,
|
||||
subhead: "Top airline",
|
||||
footer: "\(top.count) flights"
|
||||
)
|
||||
topAirlineCard(top)
|
||||
}
|
||||
if let route = yearStats.topRoute {
|
||||
statCard(headline: route.label, subhead: "Top route", footer: "\(route.count) trips")
|
||||
topRouteCard(route)
|
||||
}
|
||||
if let longest = yearStats.longestFlight {
|
||||
statCard(
|
||||
headline: "\(longest.departureIATA) → \(longest.arrivalIATA)",
|
||||
subhead: "Longest flight",
|
||||
footer: "your endurance record"
|
||||
)
|
||||
longestCard(longest, yearStats: yearStats)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
.padding(.vertical, 24)
|
||||
.background(FlightTheme.background.ignoresSafeArea())
|
||||
.navigationTitle("Your \(year)")
|
||||
.tabViewStyle(.page(indexDisplayMode: .always))
|
||||
.background(HistoryStyle.midnightNavy.ignoresSafeArea())
|
||||
.navigationTitle("\(year) Year in Flight")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
Button { dismiss() } label: { Image(systemName: "xmark") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Card variants
|
||||
|
||||
private func coverCard(year: Int, flights: Int) -> some View {
|
||||
VStack {
|
||||
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 {
|
||||
HeroComposition(background: HistoryStyle.heroOrangeGradient) {
|
||||
VStack(spacing: 12) {
|
||||
Spacer()
|
||||
Text(headline)
|
||||
.font(.system(size: 56, weight: .bold).monospacedDigit())
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.5)
|
||||
Text("\(year)")
|
||||
.font(.system(size: 140, weight: .black).monospacedDigit())
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 16)
|
||||
Text(subhead)
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("YEAR IN FLIGHT")
|
||||
.font(.system(size: 18, weight: .heavy))
|
||||
.tracking(2.5)
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
Spacer()
|
||||
Text(footer)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.white.opacity(0.65))
|
||||
Text("\(flights) flights logged")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
}
|
||||
.frame(width: 320, height: 480)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [FlightTheme.accent.opacity(0.85), FlightTheme.accent.opacity(0.45)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
),
|
||||
in: RoundedRectangle(cornerRadius: 24)
|
||||
}
|
||||
}
|
||||
|
||||
private func distanceCard(_ s: StatsEngine) -> some View {
|
||||
HeroComposition(background: HistoryStyle.heroNavyGradient) {
|
||||
cardBody(
|
||||
eyebrow: "DISTANCE",
|
||||
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