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:
Trey T
2026-05-29 11:13:24 -05:00
parent a33a56176d
commit 86582cea4a
9 changed files with 1882 additions and 417 deletions
+16
View File
@@ -81,6 +81,10 @@
HX1300001300000013000001 /* HistoryFilters.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1300001300000013000002 /* HistoryFilters.swift */; }; HX1300001300000013000001 /* HistoryFilters.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1300001300000013000002 /* HistoryFilters.swift */; };
HX1400001400000014000001 /* HistoryFilterSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1400001400000014000002 /* HistoryFilterSheet.swift */; }; HX1400001400000014000001 /* HistoryFilterSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1400001400000014000002 /* HistoryFilterSheet.swift */; };
HX1500001500000015000001 /* AirportFlightsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1500001500000015000002 /* AirportFlightsView.swift */; }; HX1500001500000015000001 /* AirportFlightsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1500001500000015000002 /* AirportFlightsView.swift */; };
HX1600001600000016000001 /* HistoryStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1600001600000016000002 /* HistoryStyle.swift */; };
HX1700001700000017000001 /* PassportComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1700001700000017000002 /* PassportComponents.swift */; };
HX1800001800000018000001 /* PassportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1800001800000018000002 /* PassportView.swift */; };
HX1900001900000019000001 /* AircraftStatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1900001900000019000002 /* AircraftStatsView.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -171,6 +175,10 @@
HX1300001300000013000002 /* HistoryFilters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryFilters.swift; sourceTree = "<group>"; }; HX1300001300000013000002 /* HistoryFilters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryFilters.swift; sourceTree = "<group>"; };
HX1400001400000014000002 /* HistoryFilterSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryFilterSheet.swift; sourceTree = "<group>"; }; HX1400001400000014000002 /* HistoryFilterSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryFilterSheet.swift; sourceTree = "<group>"; };
HX1500001500000015000002 /* AirportFlightsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirportFlightsView.swift; sourceTree = "<group>"; }; HX1500001500000015000002 /* AirportFlightsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirportFlightsView.swift; sourceTree = "<group>"; };
HX1600001600000016000002 /* HistoryStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryStyle.swift; sourceTree = "<group>"; };
HX1700001700000017000002 /* PassportComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PassportComponents.swift; sourceTree = "<group>"; };
HX1800001800000018000002 /* PassportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PassportView.swift; sourceTree = "<group>"; };
HX1900001900000019000002 /* AircraftStatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AircraftStatsView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -220,6 +228,9 @@
HX1200001200000012000002 /* ImportCSVView.swift */, HX1200001200000012000002 /* ImportCSVView.swift */,
HX1400001400000014000002 /* HistoryFilterSheet.swift */, HX1400001400000014000002 /* HistoryFilterSheet.swift */,
HX1500001500000015000002 /* AirportFlightsView.swift */, HX1500001500000015000002 /* AirportFlightsView.swift */,
HX1700001700000017000002 /* PassportComponents.swift */,
HX1800001800000018000002 /* PassportView.swift */,
HX1900001900000019000002 /* AircraftStatsView.swift */,
AA5555555555555555555555 /* Styles */, AA5555555555555555555555 /* Styles */,
AA6666666666666666666666 /* Components */, AA6666666666666666666666 /* Components */,
); );
@@ -230,6 +241,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
AA2222222222222222222222 /* FlightTheme.swift */, AA2222222222222222222222 /* FlightTheme.swift */,
HX1600001600000016000002 /* HistoryStyle.swift */,
); );
path = Styles; path = Styles;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -507,6 +519,10 @@
HX1300001300000013000001 /* HistoryFilters.swift in Sources */, HX1300001300000013000001 /* HistoryFilters.swift in Sources */,
HX1400001400000014000001 /* HistoryFilterSheet.swift in Sources */, HX1400001400000014000001 /* HistoryFilterSheet.swift in Sources */,
HX1500001500000015000001 /* AirportFlightsView.swift in Sources */, HX1500001500000015000001 /* AirportFlightsView.swift in Sources */,
HX1600001600000016000001 /* HistoryStyle.swift in Sources */,
HX1700001700000017000001 /* PassportComponents.swift in Sources */,
HX1800001800000018000001 /* PassportView.swift in Sources */,
HX1900001900000019000001 /* AircraftStatsView.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
+327
View File
@@ -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))
}
}
}
+224 -149
View File
@@ -2,11 +2,11 @@ import SwiftUI
import MapKit import MapKit
import CoreLocation import CoreLocation
/// Single-flight detail screen. Layout follows the live detail sheet /// Single-flight detail screen restyled with the passport palette.
/// pattern (title route photo map aircraft) but adds notes /// Aircraft card now uses Flighty's labeled-grid pattern with
/// and a delete button. Pulls a track replay from OpenSky for flights /// em-dashes for missing data. New "Detailed Timetable" card shows
/// flown in the last ~7 days; everything older falls back to a clean /// scheduled vs actual when we have actual times, with late actuals
/// great-circle arc. /// in red.
struct HistoryDetailView: View { struct HistoryDetailView: View {
let flight: LoggedFlight let flight: LoggedFlight
let store: FlightHistoryStore let store: FlightHistoryStore
@@ -15,34 +15,33 @@ struct HistoryDetailView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL @Environment(\.openURL) private var openURL
@Environment(\.colorScheme) private var scheme
@State private var photo: AircraftPhotoService.Photo? @State private var photo: AircraftPhotoService.Photo?
@State private var track: AircraftTrack? @State private var track: AircraftTrack?
@State private var editedNotes: String = "" @State private var editedNotes: String = ""
@State private var showDeleteConfirm = false @State private var showDeleteConfirm = false
/// Re-render trigger after we upsert airframe metadata. SwiftData
/// changes don't auto-invalidate non-@Query views.
@State private var metadataLoaded = false @State private var metadataLoaded = false
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 14) {
header header
routeCard routeCard
photoBanner photoBanner.padding(.horizontal, -16)
.padding(.horizontal, -16)
if let cred = photo?.photographer { if let cred = photo?.photographer {
photoCredit(name: cred, link: photo?.detailLink) photoCredit(name: cred, link: photo?.detailLink)
} }
mapSection mapSection
aircraftCard aircraftCard
timetableCard
notesSection notesSection
deleteButton deleteButton
} }
.padding(16) .padding(16)
} }
.background(FlightTheme.background.ignoresSafeArea()) .background(HistoryStyle.background(scheme).ignoresSafeArea())
.navigationTitle(flight.flightLabel) .navigationTitle("")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.task { .task {
editedNotes = flight.notes ?? "" editedNotes = flight.notes ?? ""
@@ -64,81 +63,92 @@ struct HistoryDetailView: View {
// MARK: - Header // MARK: - Header
private var header: some View { private var header: some View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 10) { Text(flight.flightLabel)
if let logo = airlineLogoURL { .font(.system(size: 38, weight: .black).monospaced())
AsyncImage(url: logo) { phase in .foregroundStyle(HistoryStyle.ink(scheme))
switch phase { HStack(spacing: 8) {
case .success(let img): img.resizable().scaledToFit() Text(airlineName)
default: RoundedRectangle(cornerRadius: 8).fill(FlightTheme.accent.opacity(0.2)) .font(.system(size: 14, weight: .semibold))
} Text("·")
} Text(longDate(flight.flightDate).uppercased())
.frame(width: 36, height: 36) .font(.system(size: 12, weight: .heavy).monospaced())
.clipShape(RoundedRectangle(cornerRadius: 8)) .tracking(1)
}
Text(flight.flightLabel)
.font(.title.weight(.bold).monospaced())
.foregroundStyle(FlightTheme.textPrimary)
Spacer()
Text(longDate(flight.flightDate))
.font(.caption.monospaced())
.foregroundStyle(FlightTheme.textTertiary)
} }
Text(airlineName) .foregroundStyle(HistoryStyle.inkSecondary(scheme))
.font(.subheadline)
.foregroundStyle(FlightTheme.textSecondary)
} }
} }
// MARK: - Route card private var airlineName: String {
AircraftRegistry.shared.lookup(icao: flight.carrierICAO)?.name
?? AircraftRegistry.shared.lookup(iata: flight.carrierIATA)?.name
?? flight.carrierIATA ?? "Unknown"
}
// MARK: - Route
private var routeCard: some View { private var routeCard: some View {
VStack(alignment: .leading, spacing: 8) { VStack(spacing: 14) {
Text("ROUTE") HStack(alignment: .top) {
.font(FlightTheme.label()) routeEndpoint(iata: flight.departureIATA, label: "From", time: flight.actualDeparture ?? flight.scheduledDeparture)
.foregroundStyle(FlightTheme.textTertiary) Spacer()
.tracking(1)
HStack(spacing: 16) {
endpoint(iata: flight.departureIATA, label: "Departed", time: flight.actualDeparture ?? flight.scheduledDeparture)
Image(systemName: "airplane") Image(systemName: "airplane")
.font(.title3) .font(.system(size: 24, weight: .heavy))
.foregroundStyle(FlightTheme.accent) .foregroundStyle(HistoryStyle.runwayOrange)
.rotationEffect(.degrees(-45)) .rotationEffect(.degrees(-45))
endpoint(iata: flight.arrivalIATA, label: "Arrived", time: flight.actualArrival ?? flight.scheduledArrival) Spacer()
routeEndpoint(iata: flight.arrivalIATA, label: "To", time: flight.actualArrival ?? flight.scheduledArrival)
} }
if let mi = store.distanceMiles(for: flight) { Rectangle()
Text("\(numberString(mi)) miles · \(durationDisplay)") .fill(HistoryStyle.hairline(scheme))
.font(.caption.monospaced()) .frame(height: 0.5)
.foregroundStyle(FlightTheme.textTertiary) HStack(spacing: 18) {
if let mi = store.distanceMiles(for: flight) {
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 { private func routeEndpoint(iata: String, label: String, time: Date?) -> some View {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 4) {
Text(label) Text(label.uppercased())
.font(.caption2) .font(HistoryStyle.label(10))
.foregroundStyle(FlightTheme.textTertiary) .tracking(1.3)
.tracking(0.5) .foregroundStyle(HistoryStyle.inkTertiary(scheme))
Text(iata.isEmpty ? "" : iata) Text(iata.isEmpty ? "" : iata)
.font(FlightTheme.airportCode(28)) .font(.system(size: 32, weight: .black).monospaced())
.foregroundStyle(FlightTheme.textPrimary) .foregroundStyle(HistoryStyle.ink(scheme))
if let m = database.airport(byIATA: iata) { if let m = database.airport(byIATA: iata) {
Text(m.name) Text(m.name)
.font(.caption2) .font(.system(size: 11))
.foregroundStyle(FlightTheme.textSecondary) .foregroundStyle(HistoryStyle.inkSecondary(scheme))
.lineLimit(1) .lineLimit(1)
} }
if let time { if let time {
Text(shortDateTime(time)) Text(shortDateTime(time))
.font(.caption2.monospaced()) .font(.system(size: 11, weight: .semibold).monospaced())
.foregroundStyle(FlightTheme.textTertiary) .foregroundStyle(HistoryStyle.inkTertiary(scheme))
} }
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
private func miniStat(label: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(label.uppercased())
.font(HistoryStyle.label(9))
.tracking(1.2)
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
Text(value)
.font(.system(size: 14, weight: .heavy).monospaced())
.foregroundStyle(HistoryStyle.ink(scheme))
}
}
// MARK: - Photo // MARK: - Photo
@ViewBuilder @ViewBuilder
@@ -146,10 +156,8 @@ struct HistoryDetailView: View {
if let photo { if let photo {
AsyncImage(url: photo.largeURL) { phase in AsyncImage(url: photo.largeURL) { phase in
switch phase { switch phase {
case .success(let img): case .success(let img): img.resizable().aspectRatio(contentMode: .fill)
img.resizable().aspectRatio(contentMode: .fill) default: Rectangle().fill(HistoryStyle.cardSubtle(scheme))
default:
Rectangle().fill(FlightTheme.cardBackground)
} }
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@@ -160,11 +168,11 @@ struct HistoryDetailView: View {
private func photoCredit(name: String, link: URL?) -> some View { private func photoCredit(name: String, link: URL?) -> some View {
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: "camera.fill").font(.caption2) Image(systemName: "camera.fill").font(.system(size: 9))
Text("Photo by \(name) · planespotters.net") Text("Photo by \(name) · planespotters.net")
.font(.caption2) .font(.system(size: 10))
} }
.foregroundStyle(FlightTheme.textTertiary) .foregroundStyle(HistoryStyle.inkTertiary(scheme))
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { if let link { openURL(link) } } .onTapGesture { if let link { openURL(link) } }
} }
@@ -174,10 +182,7 @@ struct HistoryDetailView: View {
@ViewBuilder @ViewBuilder
private var mapSection: some View { private var mapSection: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text(track == nil ? "ROUTE MAP" : "FLOWN PATH") HistorySectionLabel(track == nil ? "Route" : "Flown path")
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textTertiary)
.tracking(1)
FlightRouteMap( FlightRouteMap(
departureIATA: flight.departureIATA, departureIATA: flight.departureIATA,
arrivalIATA: flight.arrivalIATA, arrivalIATA: flight.arrivalIATA,
@@ -185,30 +190,20 @@ struct HistoryDetailView: View {
database: database database: database
) )
.frame(height: 220) .frame(height: 220)
.clipShape(RoundedRectangle(cornerRadius: 12)) .clipShape(RoundedRectangle(cornerRadius: 18))
} }
} }
private func loadTrackIfRecent() async { private func loadTrackIfRecent() async {
// OpenSky's anonymous track endpoint trims history after ~7
// days. Older logs get the great-circle fallback drawn by
// FlightRouteMap.
let ageDays = Date().timeIntervalSince(flight.flightDate) / 86400 let ageDays = Date().timeIntervalSince(flight.flightDate) / 86400
guard ageDays < 7, let icao24 = flight.icao24, !icao24.isEmpty else { return } guard ageDays < 7, let icao24 = flight.icao24, !icao24.isEmpty else { return }
track = await openSky.track(icao24: icao24) track = await openSky.track(icao24: icao24)
} }
/// Hit OpenSky's metadata endpoint for first-flight / built dates.
/// We persist the result so subsequent views of the same airframe
/// don't re-query the network. Best-effort many newer airframes
/// have no metadata yet.
private func loadAirframeMetadata() async { private func loadAirframeMetadata() async {
guard let reg = flight.registration, guard let reg = flight.registration, !reg.isEmpty,
!reg.isEmpty, let icao24 = flight.icao24, !icao24.isEmpty
let icao24 = flight.icao24,
!icao24.isEmpty
else { return } else { return }
// Skip if we already have a cached entry with at least one date.
if let cached = store.airframe(for: reg), if let cached = store.airframe(for: reg),
cached.firstFlightDate != nil || cached.deliveryDate != nil { cached.firstFlightDate != nil || cached.deliveryDate != nil {
metadataLoaded.toggle() metadataLoaded.toggle()
@@ -229,63 +224,147 @@ struct HistoryDetailView: View {
private var aircraftCard: some View { private var aircraftCard: some View {
let repeats = store.repeatCount(for: flight.registration, before: flight.flightDate) let repeats = store.repeatCount(for: flight.registration, before: flight.flightDate)
let airframe = flight.registration.flatMap(store.airframe(for:)) let airframe = flight.registration.flatMap(store.airframe(for:))
let ageYears = airframe?.firstFlightDate.map { years(since: $0) } let firstFlight = airframe?.firstFlightDate
let ageYears = firstFlight.map { years(since: $0) }
return VStack(alignment: .leading, spacing: 8) { return VStack(alignment: .leading, spacing: 12) {
Text("AIRCRAFT") HistorySectionLabel("Aircraft")
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textTertiary)
.tracking(1)
VStack(spacing: 0) { VStack(spacing: 0) {
HStack(spacing: 0) { aircraftRow(
cell(label: "Type", value: flight.aircraftType ?? "") leftLabel: "Type", leftValue: flight.aircraftType ?? "",
cell(label: "Tail", value: flight.registration ?? "") rightLabel: "Tail #", rightValue: flight.registration ?? ""
} )
if ageYears != nil || repeats > 0 { divider
Divider() aircraftRow(
HStack(spacing: 0) { leftLabel: "First flight",
if let yrs = ageYears { leftValue: firstFlight.map { yearString($0) } ?? "",
cell(label: "Age", value: "\(yrs)y") rightLabel: "Age",
} else { rightValue: ageYears.map { "\($0) yr" } ?? ""
cell(label: "Age", value: "") )
} divider
cell( aircraftRow(
label: "On this airframe", leftLabel: "On this airframe",
value: repeats == 0 ? "First time" : "\(repeats + 1)\(ordinalSuffix(repeats + 1)) time" leftValue: repeats == 0 ? "First time" : "\(repeats + 1)\(ordinalSuffix(repeats + 1)) time",
) rightLabel: "ICAO24",
} rightValue: flight.icao24?.uppercased() ?? ""
} )
} }
.flightCard(padding: 0) .historyCard(scheme, padding: 0)
}
}
private var divider: some View {
Rectangle()
.fill(HistoryStyle.hairline(scheme))
.frame(height: 0.5)
}
private func aircraftRow(leftLabel: String, leftValue: String, rightLabel: String, rightValue: String) -> some View {
HStack(spacing: 0) {
cell(label: leftLabel, value: leftValue)
cell(label: rightLabel, value: rightValue)
} }
} }
private func cell(label: String, value: String) -> some View { private func cell(label: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 4) {
Text(label) Text(label.uppercased())
.font(.caption2) .font(HistoryStyle.label(10))
.foregroundStyle(FlightTheme.textTertiary) .tracking(1.3)
.tracking(0.5) .foregroundStyle(HistoryStyle.inkTertiary(scheme))
Text(value) Text(value)
.font(.subheadline.weight(.semibold).monospaced()) .font(.system(size: 14, weight: .heavy).monospaced())
.foregroundStyle(FlightTheme.textPrimary) .foregroundStyle(HistoryStyle.ink(scheme))
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(16) .padding(14)
}
// MARK: - Timetable
@ViewBuilder
private var timetableCard: some View {
if hasTimetableData {
VStack(alignment: .leading, spacing: 12) {
HistorySectionLabel("Detailed timetable")
VStack(spacing: 0) {
timetableHeader
divider
timetableRow(
label: "Departure",
scheduled: flight.scheduledDeparture,
actual: flight.actualDeparture
)
divider
timetableRow(
label: "Arrival",
scheduled: flight.scheduledArrival,
actual: flight.actualArrival
)
}
.historyCard(scheme, padding: 0)
}
}
}
private var hasTimetableData: Bool {
flight.scheduledDeparture != nil
|| flight.scheduledArrival != nil
|| flight.actualDeparture != nil
|| flight.actualArrival != nil
}
private var timetableHeader: some View {
HStack(spacing: 0) {
Text("")
.frame(maxWidth: .infinity, alignment: .leading)
Text("SCHEDULED")
.font(HistoryStyle.label(10))
.tracking(1.2)
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
.frame(maxWidth: .infinity, alignment: .leading)
Text("ACTUAL")
.font(HistoryStyle.label(10))
.tracking(1.2)
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.horizontal, 14)
.padding(.top, 14)
}
private func timetableRow(label: String, scheduled: Date?, actual: Date?) -> some View {
let isLate: Bool = {
guard let scheduled, let actual else { return false }
return actual.timeIntervalSince(scheduled) > 5 * 60
}()
return HStack(spacing: 0) {
Text(label)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(HistoryStyle.ink(scheme))
.frame(maxWidth: .infinity, alignment: .leading)
Text(scheduled.map(shortTime) ?? "")
.font(.system(size: 13, weight: .heavy).monospaced())
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
.frame(maxWidth: .infinity, alignment: .leading)
Text(actual.map(shortTime) ?? "")
.font(.system(size: 13, weight: .heavy).monospaced())
.foregroundStyle(isLate ? Color(red: 0.85, green: 0.15, blue: 0.15) : HistoryStyle.ink(scheme))
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(14)
} }
// MARK: - Notes // MARK: - Notes
private var notesSection: some View { private var notesSection: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("NOTES") HistorySectionLabel("Notes")
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textTertiary)
.tracking(1)
TextEditor(text: $editedNotes) TextEditor(text: $editedNotes)
.scrollContentBackground(.hidden)
.frame(minHeight: 80) .frame(minHeight: 80)
.padding(8) .padding(8)
.background(FlightTheme.cardBackground, in: RoundedRectangle(cornerRadius: 10)) .background(HistoryStyle.card(scheme), in: RoundedRectangle(cornerRadius: 14))
.onChange(of: editedNotes) { _, newValue in .onChange(of: editedNotes) { _, newValue in
flight.notes = newValue.isEmpty ? nil : newValue flight.notes = newValue.isEmpty ? nil : newValue
} }
@@ -302,25 +381,17 @@ struct HistoryDetailView: View {
Image(systemName: "trash") Image(systemName: "trash")
Text("Delete flight") Text("Delete flight")
} }
.font(.system(size: 14, weight: .semibold))
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 12) .padding(.vertical, 14)
} }
.background(FlightTheme.cancelled.opacity(0.1), in: RoundedRectangle(cornerRadius: 10)) .background(Color.red.opacity(0.12), in: RoundedRectangle(cornerRadius: 14))
.foregroundStyle(FlightTheme.cancelled) .foregroundStyle(Color.red)
.padding(.top, 8) .padding(.top, 8)
} }
// MARK: - Helpers // MARK: - Helpers
private var airlineEntry: AircraftRegistry.Entry? {
AircraftRegistry.shared.lookup(icao: flight.carrierICAO)
?? AircraftRegistry.shared.lookup(iata: flight.carrierIATA)
}
private var airlineLogoURL: URL? { airlineEntry?.logoURL }
private var airlineName: String {
airlineEntry?.name ?? flight.carrierICAO ?? flight.carrierIATA ?? "Unknown"
}
private var durationDisplay: String { private var durationDisplay: String {
guard let min = store.durationMinutes(for: flight) else { return "" } guard let min = store.durationMinutes(for: flight) else { return "" }
let h = min / 60 let h = min / 60
@@ -329,8 +400,7 @@ struct HistoryDetailView: View {
} }
private func numberString(_ n: Int) -> String { private func numberString(_ n: Int) -> String {
let f = NumberFormatter() let f = NumberFormatter(); f.numberStyle = .decimal
f.numberStyle = .decimal
return f.string(from: NSNumber(value: n)) ?? "\(n)" return f.string(from: NSNumber(value: n)) ?? "\(n)"
} }
@@ -338,6 +408,11 @@ struct HistoryDetailView: View {
Calendar.current.dateComponents([.year], from: since, to: Date()).year ?? 0 Calendar.current.dateComponents([.year], from: since, to: Date()).year ?? 0
} }
private func yearString(_ d: Date) -> String {
let f = DateFormatter(); f.dateFormat = "yyyy"
return f.string(from: d)
}
private func ordinalSuffix(_ n: Int) -> String { private func ordinalSuffix(_ n: Int) -> String {
let r = n % 100 let r = n % 100
if r >= 11 && r <= 13 { return "th" } if r >= 11 && r <= 13 { return "th" }
@@ -350,14 +425,17 @@ struct HistoryDetailView: View {
} }
private func longDate(_ d: Date) -> String { private func longDate(_ d: Date) -> String {
let f = DateFormatter() let f = DateFormatter(); f.dateFormat = "EEE, MMM d, yyyy"
f.dateFormat = "MMM d, yyyy"
return f.string(from: d) return f.string(from: d)
} }
private func shortDateTime(_ d: Date) -> String { private func shortDateTime(_ d: Date) -> String {
let f = DateFormatter() let f = DateFormatter(); f.dateFormat = "MMM d, HH:mm"
f.dateFormat = "MMM d, HH:mm" return f.string(from: d)
}
private func shortTime(_ d: Date) -> String {
let f = DateFormatter(); f.dateFormat = "h:mm a"
return f.string(from: d) return f.string(from: d)
} }
} }
@@ -374,29 +452,26 @@ private struct FlightRouteMap: View {
Map { Map {
if let dep = database.airport(byIATA: departureIATA) { if let dep = database.airport(byIATA: departureIATA) {
Marker("From " + departureIATA, systemImage: "airplane.departure", coordinate: dep.coordinate) Marker("From " + departureIATA, systemImage: "airplane.departure", coordinate: dep.coordinate)
.tint(FlightTheme.onTime) .tint(HistoryStyle.stampGreen)
} }
if let arr = database.airport(byIATA: arrivalIATA) { if let arr = database.airport(byIATA: arrivalIATA) {
Marker("To " + arrivalIATA, systemImage: "airplane.arrival", coordinate: arr.coordinate) Marker("To " + arrivalIATA, systemImage: "airplane.arrival", coordinate: arr.coordinate)
.tint(FlightTheme.accent) .tint(HistoryStyle.runwayOrange)
} }
if let track { if let track {
let coords = track.path.map { let coords = track.path.map {
CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude) CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude)
} }
MapPolyline(coordinates: coords) MapPolyline(coordinates: coords)
.stroke(FlightTheme.accent, lineWidth: 3) .stroke(HistoryStyle.runwayOrange, lineWidth: 3)
} else if let dep = database.airport(byIATA: departureIATA), } else if let dep = database.airport(byIATA: departureIATA),
let arr = database.airport(byIATA: arrivalIATA) { let arr = database.airport(byIATA: arrivalIATA) {
MapPolyline(coordinates: greatCircle(from: dep.coordinate, to: arr.coordinate, segments: 64)) MapPolyline(coordinates: greatCircle(from: dep.coordinate, to: arr.coordinate, segments: 64))
.stroke(FlightTheme.accent.opacity(0.6), style: StrokeStyle(lineWidth: 2, dash: [5, 4])) .stroke(HistoryStyle.runwayOrange.opacity(0.7), style: StrokeStyle(lineWidth: 2, dash: [5, 4]))
} }
} }
} }
/// Polyline samples along the great-circle path between two
/// coordinates. MapKit doesn't draw GC paths natively we
/// approximate with N straight segments along the GC route.
private func greatCircle(from a: CLLocationCoordinate2D, to b: CLLocationCoordinate2D, segments: Int) -> [CLLocationCoordinate2D] { private func greatCircle(from a: CLLocationCoordinate2D, to b: CLLocationCoordinate2D, segments: Int) -> [CLLocationCoordinate2D] {
let lat1 = a.latitude * .pi / 180 let lat1 = a.latitude * .pi / 180
let lon1 = a.longitude * .pi / 180 let lon1 = a.longitude * .pi / 180
+122 -24
View File
@@ -23,6 +23,7 @@ struct HistoryRouteMapView: View {
@State private var selectedAirportSheet: AirportSheet? @State private var selectedAirportSheet: AirportSheet?
@State private var selectedFlight: LoggedFlight? @State private var selectedFlight: LoggedFlight?
@State private var revealKey: Int = 0 // bump to retrigger the reveal animation @State private var revealKey: Int = 0 // bump to retrigger the reveal animation
@State private var drawerExpanded: Bool = false
struct AirportSheet: Identifiable { struct AirportSheet: Identifiable {
let iata: String let iata: String
@@ -32,25 +33,33 @@ struct HistoryRouteMapView: View {
var body: some View { var body: some View {
let arcs = self.arcs let arcs = self.arcs
return Map(position: $position) { return ZStack(alignment: .bottom) {
// Airport dots Map(position: $position) {
ForEach(airportItems, id: \.iata) { item in // Airport dots
Annotation(item.iata, coordinate: item.coord) { ForEach(airportItems, id: \.iata) { item in
AirportDot( Annotation(item.iata, coordinate: item.coord) {
size: dotSize(for: item.count), AirportDot(
isSelected: filters.airports.contains(item.iata) size: dotSize(for: item.count),
) isSelected: filters.airports.contains(item.iata)
.onTapGesture { selectedAirportSheet = AirportSheet(iata: item.iata) } )
.onTapGesture { selectedAirportSheet = AirportSheet(iata: item.iata) }
}
.annotationTitles(.hidden)
}
// Animated arcs
ForEach(arcs.prefix(revealCount)) { arc in
MapPolyline(coordinates: arc.coords)
.stroke(arcColor(for: arc), lineWidth: arc.isMostRecent ? 3.0 : 1.6)
} }
.annotationTitles(.hidden)
}
// Animated arcs
ForEach(arcs.prefix(revealCount)) { arc in
MapPolyline(coordinates: arc.coords)
.stroke(arcColor(for: arc), lineWidth: arc.isMostRecent ? 2.5 : 1.5)
} }
.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
} }
.mapStyle(.standard(elevation: .flat))
.navigationTitle(filters.isEmpty ? "Lifetime Routes" : "Filtered Routes") .navigationTitle(filters.isEmpty ? "Lifetime Routes" : "Filtered Routes")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
@@ -125,10 +134,101 @@ struct HistoryRouteMapView: View {
private func arcColor(for arc: Arc) -> Color { private func arcColor(for arc: Arc) -> Color {
if arc.isMostRecent { if arc.isMostRecent {
return FlightTheme.onTime return Color(red: 1.0, green: 1.0, blue: 0.0) // fluorescent yellow for the latest leg
} }
// Slight transparency on bulk lines so the most-recent stands out. // Bulk lines in vivid runway orange.
return FlightTheme.accent.opacity(0.55) return HistoryStyle.runwayOrange
}
// MARK: - Passport drawer
@ViewBuilder
private var passportDrawer: some View {
VStack(spacing: 8) {
// Tab handle
Capsule()
.fill(.white.opacity(0.4))
.frame(width: 36, height: 5)
.padding(.top, 8)
VStack(spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(filters.isEmpty ? "ALL TIME" : "FILTERED")
.font(.system(size: 10, weight: .heavy))
.tracking(2)
.foregroundStyle(HistoryStyle.runwayOrange)
Text("\(flights.count) flights · \(numberStringMiles())")
.font(.system(size: 16, weight: .heavy))
.foregroundStyle(.white)
}
Spacer()
Button {
revealCount = 0
revealKey += 1
} label: {
Image(systemName: "play.fill")
.font(.system(size: 12, weight: .bold))
.padding(10)
.background(.white.opacity(0.18), in: Circle())
.foregroundStyle(.white)
}
}
.padding(.horizontal, 18)
// Filter chips (live)
if !filters.airports.isEmpty || !filters.airlines.isEmpty || !filters.years.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(Array(filters.years).sorted(by: >), id: \.self) { y in
drawerChip("\(y)") { filters.years.remove(y) }
}
ForEach(Array(filters.airlines).sorted(), id: \.self) { a in
drawerChip(a) { filters.airlines.remove(a) }
}
ForEach(Array(filters.airports).sorted(), id: \.self) { a in
drawerChip(a) { filters.airports.remove(a) }
}
}
.padding(.horizontal, 18)
}
}
}
.padding(.bottom, 22)
}
.frame(maxWidth: .infinity)
.background(
LinearGradient(
colors: [HistoryStyle.midnightNavy.opacity(0.92), HistoryStyle.midnightNavy],
startPoint: .top, endPoint: .bottom
)
.clipShape(UnevenRoundedRectangle(topLeadingRadius: 22, topTrailingRadius: 22))
.ignoresSafeArea(edges: .bottom)
)
.shadow(color: .black.opacity(0.4), radius: 12, y: -4)
}
private func drawerChip(_ label: String, onRemove: @escaping () -> Void) -> some View {
HStack(spacing: 4) {
Text(label)
.font(.system(size: 11, weight: .bold).monospaced())
Image(systemName: "xmark")
.font(.system(size: 8, weight: .bold))
}
.foregroundStyle(.white)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(HistoryStyle.runwayOrange, in: Capsule())
.onTapGesture(perform: onRemove)
}
private func numberStringMiles() -> String {
let total = flights.reduce(0) { acc, f in
acc + (store.distanceMiles(for: f) ?? 0)
}
let f = NumberFormatter()
f.numberStyle = .decimal
return (f.string(from: NSNumber(value: total)) ?? "\(total)") + " mi"
} }
// MARK: - Airports // MARK: - Airports
@@ -199,12 +299,10 @@ private struct AirportDot: View {
var body: some View { var body: some View {
Circle() Circle()
.fill(isSelected ? FlightTheme.onTime : FlightTheme.accent) .fill(isSelected ? Color.yellow : HistoryStyle.runwayOrange)
.frame(width: size, height: size) .frame(width: size, height: size)
.overlay( .overlay(Circle().stroke(.white, lineWidth: 2))
Circle().stroke(.white, lineWidth: 1.5) .shadow(color: .black.opacity(0.5), radius: 2, y: 1)
)
.shadow(color: .black.opacity(0.2), radius: 1, y: 1)
.contentShape(Circle()) .contentShape(Circle())
} }
} }
+396 -163
View File
@@ -1,25 +1,31 @@
import SwiftUI import SwiftUI
import SwiftData import SwiftData
/// Top-level history tab. Totals strip + sortable / filterable / searchable /// History tab redesigned as a "passport" experience.
/// list of every flight you've logged, plus entry points to the lifetime ///
/// stats / route map / year-in-review screens. /// Stacked hero cards at the top (current-year passport, all-time
/// passport, most-flown airframe), a horizontal year tab strip that
/// scopes everything, and a flight feed below. Sort + filter + search
/// + add affordances all live in the toolbar.
struct HistoryView: View { struct HistoryView: View {
let database: AirportDatabase let database: AirportDatabase
let routeExplorer: RouteExplorerClient let routeExplorer: RouteExplorerClient
let openSky: OpenSkyClient let openSky: OpenSkyClient
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(\.colorScheme) private var scheme
@Query(sort: \LoggedFlight.flightDate, order: .reverse) @Query(sort: \LoggedFlight.flightDate, order: .reverse)
private var flights: [LoggedFlight] private var flights: [LoggedFlight]
@State private var filters: HistoryFilters = .init() @State private var filters: HistoryFilters = .init()
@State private var sort: HistorySort = .newestFirst @State private var sort: HistorySort = .newestFirst
@State private var selectedYear: Int? = nil // nil = ALL
@State private var showingAdd = false @State private var showingAdd = false
@State private var showingStats = false @State private var showingPassport = false
@State private var showingMap = false @State private var showingMap = false
@State private var showingAircraftStats = false
@State private var showingCalendarImport = false @State private var showingCalendarImport = false
@State private var showingCSVImport = false @State private var showingCSVImport = false
@State private var showingYearInReview = false @State private var showingYearInReview = false
@@ -27,54 +33,43 @@ struct HistoryView: View {
var body: some View { var body: some View {
let store = FlightHistoryStore(context: modelContext, airportDatabase: database) let store = FlightHistoryStore(context: modelContext, airportDatabase: database)
let visible = filteredSorted(store: store) let scoped = scopedFlights(store: store)
let stats = StatsEngine(store: store, database: database, flights: visible) let stats = StatsEngine(store: store, database: database, flights: scoped)
return List { ScrollView {
if !flights.isEmpty { LazyVStack(spacing: 0, pinnedViews: []) {
Section { titleHeader
totalsStrip(stats: stats, isFiltered: !filters.isEmpty)
.listRowInsets(EdgeInsets()) YearTabStrip(years: yearsList, selection: $selectedYear)
.listRowBackground(Color.clear) .padding(.vertical, 12)
if filters.isEmpty {
heroDeck(store: store, stats: stats)
.padding(.horizontal, 16)
.padding(.bottom, 8)
} }
if !filters.isEmpty { if !filters.isEmpty {
Section { activeChips
activeChips .padding(.horizontal, 16)
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 8, trailing: 16)) .padding(.bottom, 8)
.listRowBackground(Color.clear)
}
} }
}
ForEach(groups(visible), id: \.key) { group in if scoped.isEmpty {
Section(header: Text(group.key)) { emptyState
ForEach(group.flights) { flight in } else {
NavigationLink { flightFeed(scoped, store: store)
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 { Spacer(minLength: 80)
emptyState
} else if visible.isEmpty {
noMatchState
} }
} }
.listStyle(.insetGrouped) .background(HistoryStyle.background(scheme).ignoresSafeArea())
.navigationTitle("History") .navigationTitle("")
.searchable(text: $filters.query, placement: .navigationBarDrawer(displayMode: .always), prompt: "Flight #, airport, route") .navigationBarTitleDisplayMode(.inline)
.searchable(text: $filters.query, prompt: "Flight #, airport, route")
.toolbar { .toolbar {
ToolbarItem(placement: .secondaryAction) { ToolbarItem(placement: .topBarTrailing) {
Menu { Menu {
ForEach(HistorySort.allCases) { option in ForEach(HistorySort.allCases) { option in
Button { Button {
@@ -88,57 +83,39 @@ struct HistoryView: View {
} }
} }
} label: { } label: {
Label("Sort", systemImage: "arrow.up.arrow.down") Image(systemName: "arrow.up.arrow.down")
} }
} }
ToolbarItem(placement: .secondaryAction) { ToolbarItem(placement: .topBarTrailing) {
Button { Button {
showingFilterSheet = true showingFilterSheet = true
} label: { } label: {
if filters.activeCount > 0 { Image(systemName: filters.activeCount > 0 ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
Label("Filters (\(filters.activeCount))", systemImage: "line.3.horizontal.decrease.circle.fill")
} else {
Label("Filters", systemImage: "line.3.horizontal.decrease.circle")
}
} }
} }
ToolbarItem(placement: .primaryAction) { ToolbarItem(placement: .topBarTrailing) {
Menu { Menu {
Button { showingAdd = true } label: { Button { showingAdd = true } label: { Label("Add manually", systemImage: "plus") }
Label("Add manually", systemImage: "plus") Button { showingCalendarImport = true } label: { Label("Scan Calendar", systemImage: "calendar") }
} Button { showingCSVImport = true } label: { Label("Import CSV…", systemImage: "doc.text") }
Button { showingCalendarImport = true } label: {
Label("Scan Calendar", systemImage: "calendar")
}
Button { showingCSVImport = true } label: {
Label("Import CSV…", systemImage: "doc.text")
}
Divider()
Button { showingStats = true } label: {
Label("Lifetime stats", systemImage: "chart.bar.fill")
}
Button { showingMap = true } label: {
Label("Route map", systemImage: "map.fill")
}
Button { showingYearInReview = true } label: {
Label("Year in Review", systemImage: "sparkles")
}
} label: { } label: {
Image(systemName: "plus.circle.fill") Image(systemName: "plus.circle.fill")
.font(.title3) .foregroundStyle(HistoryStyle.runwayOrange)
} }
} }
} }
.sheet(isPresented: $showingAdd) { .sheet(isPresented: $showingAdd) {
AddFlightView(routeExplorer: routeExplorer, database: database, store: store, prefill: nil) AddFlightView(routeExplorer: routeExplorer, database: database, store: store, prefill: nil)
} }
.sheet(isPresented: $showingStats) { .sheet(isPresented: $showingPassport) {
NavigationStack { LifetimeStatsView(stats: stats) } NavigationStack {
PassportView(stats: stats, allFlights: flights, database: database, store: store, selectedYear: $selectedYear)
}
} }
.sheet(isPresented: $showingMap) { .sheet(isPresented: $showingMap) {
NavigationStack { NavigationStack {
HistoryRouteMapView( HistoryRouteMapView(
flights: visible, flights: scoped,
allFlights: flights, allFlights: flights,
database: database, database: database,
openSky: openSky, openSky: openSky,
@@ -147,6 +124,11 @@ struct HistoryView: View {
) )
} }
} }
.sheet(isPresented: $showingAircraftStats) {
NavigationStack {
AircraftStatsView(allFlights: flights, store: store)
}
}
.sheet(isPresented: $showingCalendarImport) { .sheet(isPresented: $showingCalendarImport) {
CalendarImportView(routeExplorer: routeExplorer, database: database, store: store) CalendarImportView(routeExplorer: routeExplorer, database: database, store: store)
} }
@@ -154,7 +136,7 @@ struct HistoryView: View {
ImportCSVView(store: store) ImportCSVView(store: store)
} }
.sheet(isPresented: $showingYearInReview) { .sheet(isPresented: $showingYearInReview) {
YearInReviewView(stats: stats, year: Calendar.current.component(.year, from: Date())) YearInReviewView(stats: stats, year: selectedYear ?? Calendar.current.component(.year, from: Date()))
} }
.sheet(isPresented: $showingFilterSheet) { .sheet(isPresented: $showingFilterSheet) {
HistoryFilterSheet(allFlights: flights, filters: $filters) HistoryFilterSheet(allFlights: flights, filters: $filters)
@@ -162,34 +144,169 @@ struct HistoryView: View {
} }
} }
// MARK: - Sort/filter pipeline // MARK: - Pipeline
private func filteredSorted(store: FlightHistoryStore) -> [LoggedFlight] { private var yearsList: [Int] {
let filtered = flights.filter { filters.matches($0) } let cal = Calendar.current
let comparator = sort.comparator { store.distanceMiles(for: $0) ?? 0 } let ys = Set(flights.map { cal.component(.year, from: $0.flightDate) })
return filtered.sorted(by: comparator) return ys.sorted(by: >)
} }
// MARK: - Grouping private func scopedFlights(store: FlightHistoryStore) -> [LoggedFlight] {
var scoped = flights
private struct Group { if let y = selectedYear {
let key: String
let flights: [LoggedFlight]
}
/// Group by year when the sort is date-based; flat list otherwise so
/// "By airline" doesn't shred a continuous airline run across sections.
private func groups(_ list: [LoggedFlight]) -> [Group] {
switch sort {
case .newestFirst, .oldestFirst:
let cal = Calendar.current let cal = Calendar.current
let grouped = Dictionary(grouping: list) { cal.component(.year, from: $0.flightDate) } scoped = scoped.filter { cal.component(.year, from: $0.flightDate) == y }
let order: (Int, Int) -> Bool = sort == .newestFirst ? (>) : (<) }
return grouped scoped = scoped.filter { filters.matches($0) }
.map { Group(key: String($0.key), flights: $0.value) } let cmp = sort.comparator { store.distanceMiles(for: $0) ?? 0 }
.sorted { order(Int($0.key) ?? 0, Int($1.key) ?? 0) } return scoped.sorted(by: cmp)
case .longestFirst, .shortestFirst, .airline, .flightNumber: }
return [Group(key: sort.rawValue, flights: list)]
// MARK: - Title
private var titleHeader: some View {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 2) {
Text("PASSPORT")
.font(.system(size: 34, weight: .black))
.tracking(-0.5)
.foregroundStyle(HistoryStyle.ink(scheme))
if !flights.isEmpty {
Text("\(flights.count) flights · \(years(of: flights)) years")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
}
}
Spacer()
Image(systemName: "airplane")
.font(.system(size: 22, weight: .heavy))
.foregroundStyle(HistoryStyle.runwayOrange)
.rotationEffect(.degrees(-45))
}
.padding(.horizontal, 16)
.padding(.top, 8)
}
private func years(of list: [LoggedFlight]) -> Int {
let yrs = Set(list.map { Calendar.current.component(.year, from: $0.flightDate) })
return yrs.count
}
// MARK: - Hero deck
@ViewBuilder
private func heroDeck(store: FlightHistoryStore, stats: StatsEngine) -> some View {
VStack(spacing: 12) {
// 1) Scoped passport either current year or all-time
let year = selectedYear ?? Calendar.current.component(.year, from: Date())
let yearFlights = stats.flights(for: year)
let yearStats = StatsEngine(store: store, database: database, flights: yearFlights)
Button { showingPassport = true } label: {
if selectedYear == nil {
HeroStatCard(
label: "ALL TIME PASSPORT",
value: numberString(stats.totalFlights),
subtitle: "\(stats.shortDistance) miles · \(stats.shortDuration)h in air",
variant: .orange
) {
HStack(spacing: 14) {
kvp(value: "\(stats.uniqueAirports)", label: "airports")
kvp(value: "\(stats.uniqueAirlines)", label: "airlines")
kvp(value: "\(stats.uniqueCountries)", label: "countries")
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(.white.opacity(0.6))
}
}
} else {
HeroStatCard(
label: "\(year) PASSPORT",
value: numberString(yearStats.totalFlights),
subtitle: "\(yearStats.shortDistance) miles · \(yearStats.shortDuration)h aloft",
variant: .orange
) {
HStack(spacing: 14) {
kvp(value: "\(yearStats.uniqueAirports)", label: "airports")
kvp(value: "\(yearStats.uniqueAirlines)", label: "airlines")
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(.white.opacity(0.6))
}
}
}
}
.buttonStyle(.plain)
// 2) Most-flown aircraft, if we know it
mostFlownCard(stats: stats)
// 3) Quick links row
quickLinks
}
}
@ViewBuilder
private func mostFlownCard(stats: StatsEngine) -> some View {
let typeCounts = Dictionary(grouping: stats.flights.compactMap { $0.aircraftType }) { $0 }
.mapValues(\.count)
if let top = typeCounts.max(by: { $0.value < $1.value }) {
let typeName = AircraftDatabase.shared.displayName(forTypeCode: top.key)
Button { showingAircraftStats = true } label: {
HeroStatCard(
label: "MOST FLOWN AIRCRAFT",
value: typeName == top.key ? top.key : typeName,
subtitle: "\(top.value) flights",
variant: .navy
) {
HStack {
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(.white.opacity(0.6))
}
}
}
.buttonStyle(.plain)
}
}
@ViewBuilder
private var quickLinks: some View {
HStack(spacing: 10) {
quickLink(title: "Map", icon: "map.fill") { showingMap = true }
quickLink(title: "Aircraft", icon: "airplane.circle.fill") { showingAircraftStats = true }
quickLink(title: "Year", icon: "sparkles") { showingYearInReview = true }
}
}
private func quickLink(title: String, icon: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
VStack(spacing: 6) {
Image(systemName: icon)
.font(.system(size: 18, weight: .semibold))
Text(title)
.font(.system(size: 12, weight: .semibold))
.tracking(0.5)
}
.foregroundStyle(HistoryStyle.ink(scheme))
.frame(maxWidth: .infinity, minHeight: 64)
.background(HistoryStyle.card(scheme), in: RoundedRectangle(cornerRadius: 16))
}
}
private func kvp(value: String, label: String) -> some View {
VStack(alignment: .leading, spacing: 0) {
Text(value)
.font(.system(size: 18, weight: .heavy).monospacedDigit())
.foregroundStyle(.white)
Text(label.uppercased())
.font(.system(size: 9, weight: .bold))
.tracking(0.8)
.foregroundStyle(.white.opacity(0.7))
} }
} }
@@ -215,13 +332,11 @@ struct HistoryView: View {
ForEach(Array(filters.aircraftTypes).sorted(), id: \.self) { t in ForEach(Array(filters.aircraftTypes).sorted(), id: \.self) { t in
chip(t, systemImage: "airplane.departure") { filters.aircraftTypes.remove(t) } chip(t, systemImage: "airplane.departure") { filters.aircraftTypes.remove(t) }
} }
Button { Button { filters = HistoryFilters() } label: {
filters = HistoryFilters()
} label: {
Text("Clear") Text("Clear")
.font(.caption.weight(.semibold)) .font(.caption.weight(.semibold))
.foregroundStyle(FlightTheme.accent) .foregroundStyle(HistoryStyle.runwayOrange)
.padding(.horizontal, 12) .padding(.horizontal, 10)
.padding(.vertical, 6) .padding(.vertical, 6)
} }
} }
@@ -237,82 +352,200 @@ struct HistoryView: View {
.foregroundStyle(.white) .foregroundStyle(.white)
.padding(.horizontal, 10) .padding(.horizontal, 10)
.padding(.vertical, 5) .padding(.vertical, 5)
.background(FlightTheme.accent, in: Capsule()) .background(HistoryStyle.runwayOrange, in: Capsule())
.onTapGesture(perform: onRemove) .onTapGesture(perform: onRemove)
} }
// MARK: - Totals strip // MARK: - Flight feed
private func totalsStrip(stats: StatsEngine, isFiltered: Bool) -> some View { @ViewBuilder
VStack(spacing: 6) { private func flightFeed(_ scoped: [LoggedFlight], store: FlightHistoryStore) -> some View {
if isFiltered { VStack(alignment: .leading, spacing: 12) {
Text("FILTERED TOTALS") HStack {
.font(.caption2.weight(.semibold)) HistorySectionLabel(selectedYear == nil ? "Recent flights" : "Flights in \(selectedYear!)")
.tracking(0.8) Spacer()
.foregroundStyle(FlightTheme.textTertiary) Text("\(scoped.count)")
.frame(maxWidth: .infinity, alignment: .leading) .font(.system(size: 12, weight: .bold).monospacedDigit())
.padding(.horizontal, 16) .foregroundStyle(HistoryStyle.inkTertiary(scheme))
}
HStack(spacing: 12) {
statTile(value: "\(stats.totalFlights)", label: "flights")
statTile(value: stats.shortDistance, label: "miles")
statTile(value: stats.shortDuration, label: "hours")
statTile(value: "\(stats.uniqueAirports)", label: "airports")
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 8) .padding(.top, 8)
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(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)
}
}
} }
} }
private func statTile(value: String, label: String) -> some View { private struct FeedGroup {
VStack(spacing: 2) { let key: String
Text(value) let flights: [LoggedFlight]
.font(.title3.weight(.bold).monospacedDigit())
.foregroundStyle(FlightTheme.textPrimary)
Text(label.uppercased())
.font(.caption2.weight(.semibold))
.tracking(0.6)
.foregroundStyle(FlightTheme.textTertiary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(FlightTheme.cardBackground, in: RoundedRectangle(cornerRadius: 10))
} }
// MARK: - Empty states private func groupedFeed(_ list: [LoggedFlight]) -> [FeedGroup] {
let cal = Calendar.current
switch sort {
case .newestFirst, .oldestFirst:
// When scoped to a single year, sub-group by month for
// visual rhythm; when ALL is selected, group by year.
if selectedYear != nil {
let grouped = Dictionary(grouping: list) { f -> String in
let comps = cal.dateComponents([.year, .month], from: f.flightDate)
let m = DateFormatter()
m.dateFormat = "MMMM"
return m.string(from: cal.date(from: comps) ?? f.flightDate).uppercased()
}
return grouped.map { FeedGroup(key: $0.key, flights: $0.value.sorted { sort == .newestFirst ? $0.flightDate > $1.flightDate : $0.flightDate < $1.flightDate }) }
.sorted { firstFlightDate($0.flights) > firstFlightDate($1.flights) }
} else {
let grouped = Dictionary(grouping: list) { String(cal.component(.year, from: $0.flightDate)) }
let order: (String, String) -> Bool = sort == .newestFirst ? (>) : (<)
return grouped.map { FeedGroup(key: $0.key, flights: $0.value) }
.sorted { order($0.key, $1.key) }
}
case .longestFirst, .shortestFirst, .airline, .flightNumber:
return [FeedGroup(key: "", flights: list)]
}
}
private func firstFlightDate(_ list: [LoggedFlight]) -> Date {
list.first?.flightDate ?? .distantPast
}
// MARK: - Empty state
private var emptyState: some View { private var emptyState: some View {
VStack(spacing: 10) { VStack(spacing: 12) {
Image(systemName: "airplane.circle") Image(systemName: "airplane.circle")
.font(.system(size: 48)) .font(.system(size: 48))
.foregroundStyle(FlightTheme.textTertiary) .foregroundStyle(HistoryStyle.inkTertiary(scheme))
Text("No flights logged yet") Text(flights.isEmpty ? "No flights logged yet" : "No matches in \(selectedYear.map(String.init) ?? "this filter")")
.font(.headline) .font(.system(size: 15, weight: .semibold))
.foregroundStyle(FlightTheme.textSecondary) .foregroundStyle(HistoryStyle.inkSecondary(scheme))
Text("Tap + to add a flight manually, scan your calendar, import a CSV, or tap an aircraft on the Live tab.") if flights.isEmpty {
.font(.caption) Text("Tap + to add a flight, scan your calendar, or import a CSV.")
.multilineTextAlignment(.center) .font(.caption)
.foregroundStyle(FlightTheme.textTertiary) .foregroundStyle(HistoryStyle.inkTertiary(scheme))
.padding(.horizontal, 24) .multilineTextAlignment(.center)
.padding(.horizontal, 32)
} else {
Button("Clear filter") {
selectedYear = nil
filters = HistoryFilters()
}
.font(.subheadline.weight(.semibold))
.foregroundStyle(HistoryStyle.runwayOrange)
}
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 40) .padding(.vertical, 60)
.listRowBackground(Color.clear)
} }
private var noMatchState: some View { private func numberString(_ n: Int) -> String {
VStack(spacing: 10) { let f = NumberFormatter()
Image(systemName: "line.3.horizontal.decrease.circle") f.numberStyle = .decimal
.font(.system(size: 36)) return f.string(from: NSNumber(value: n)) ?? "\(n)"
.foregroundStyle(FlightTheme.textTertiary) }
Text("No flights match these filters") }
.font(.subheadline)
.foregroundStyle(FlightTheme.textSecondary) // MARK: - Passport-styled flight row
Button("Clear filters") { filters = HistoryFilters() }
.font(.subheadline) struct PassportFlightRow: View {
} let flight: LoggedFlight
.frame(maxWidth: .infinity) let database: AirportDatabase
.padding(.vertical, 28) @State private var photo: AircraftPhotoService.Photo?
.listRowBackground(Color.clear) @Environment(\.colorScheme) private var scheme
var body: some View {
HStack(spacing: 12) {
thumbnail
.frame(width: 56, height: 44)
.clipShape(RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Text(flight.flightLabel)
.font(.system(size: 14, weight: .heavy).monospaced())
.foregroundStyle(HistoryStyle.ink(scheme))
if let type = flight.aircraftType {
Text(type)
.font(.system(size: 10, weight: .bold).monospaced())
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
.padding(.horizontal, 5)
.padding(.vertical, 2)
.background(HistoryStyle.cardSubtle(scheme), in: RoundedRectangle(cornerRadius: 4))
}
}
HStack(spacing: 6) {
Text(flight.departureIATA)
.font(.system(size: 13, weight: .heavy).monospaced())
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
Image(systemName: "arrow.right")
.font(.system(size: 9, weight: .bold))
.foregroundStyle(HistoryStyle.runwayOrange)
Text(flight.arrivalIATA)
.font(.system(size: 13, weight: .heavy).monospaced())
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
}
}
Spacer()
Text(shortDate(flight.flightDate))
.font(.system(size: 11, weight: .semibold).monospacedDigit())
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
}
.padding(.vertical, 10)
.padding(.horizontal, 12)
.background(HistoryStyle.card(scheme), in: RoundedRectangle(cornerRadius: 14))
.task(id: flight.registration ?? flight.id.uuidString) {
guard let reg = flight.registration, !reg.isEmpty else { return }
photo = await AircraftPhotoService.shared.photo(registration: reg, icao24: flight.icao24 ?? "")
}
}
@ViewBuilder
private var thumbnail: some View {
if let url = photo?.thumbnailURL {
AsyncImage(url: url) { phase in
switch phase {
case .success(let img):
img.resizable().aspectRatio(contentMode: .fill)
default: placeholder
}
}
} else {
placeholder
}
}
private var placeholder: some View {
ZStack {
HistoryStyle.cardSubtle(scheme)
Image(systemName: "airplane")
.font(.system(size: 14, weight: .heavy))
.foregroundStyle(HistoryStyle.runwayOrange)
.rotationEffect(.degrees(-45))
}
}
private func shortDate(_ d: Date) -> String {
let f = DateFormatter()
f.dateFormat = "d MMM"
return f.string(from: d).uppercased()
} }
} }
+280
View File
@@ -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))
}
}
+209
View File
@@ -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)
}
}
+127
View File
@@ -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)
}
}
+181 -81
View File
@@ -1,111 +1,211 @@
import SwiftUI import SwiftUI
/// Spotify-Wrapped-style year-in-review deck. Paged horizontal scroller /// Year in Review horizontal-paged deck of share-ready hero cards
/// of cards, each highlighting one stat for the chosen year. Long-press /// for the chosen year. Each card is a full-screen composition: huge
/// any card to copy a render-ready PNG. /// stat number, small subtitle, footer brand mark.
struct YearInReviewView: View { struct YearInReviewView: View {
let stats: StatsEngine let stats: StatsEngine
let year: Int let year: Int
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var scheme
var body: some View { var body: some View {
let yearFlights = stats.flights(for: year) let yearFlights = stats.flights(for: year)
let yearStats = StatsEngine(store: stats.store, database: stats.database, flights: yearFlights) let yearStats = StatsEngine(store: stats.store, database: stats.database, flights: yearFlights)
return NavigationStack { NavigationStack {
ScrollView(.horizontal, showsIndicators: false) { TabView {
HStack(spacing: 16) { coverCard(year: year, flights: yearFlights.count)
coverCard(year: year, flights: yearFlights.count) if yearStats.totalMiles > 0 {
statCard(headline: yearStats.shortDistance, subhead: "miles flown", footer: "\(yearFlights.count) flights") distanceCard(yearStats)
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") airportsCard(yearStats)
if let top = yearStats.topAirline { hoursCard(yearStats)
statCard( if let top = yearStats.topAirline {
headline: AircraftRegistry.shared.lookup(icao: top.icao)?.name ?? top.icao, topAirlineCard(top)
subhead: "Top airline", }
footer: "\(top.count) flights" if let route = yearStats.topRoute {
) topRouteCard(route)
} }
if let route = yearStats.topRoute { if let longest = yearStats.longestFlight {
statCard(headline: route.label, subhead: "Top route", footer: "\(route.count) trips") longestCard(longest, yearStats: yearStats)
}
if let longest = yearStats.longestFlight {
statCard(
headline: "\(longest.departureIATA)\(longest.arrivalIATA)",
subhead: "Longest flight",
footer: "your endurance record"
)
}
} }
.padding(.horizontal, 16)
} }
.padding(.vertical, 24) .tabViewStyle(.page(indexDisplayMode: .always))
.background(FlightTheme.background.ignoresSafeArea()) .background(HistoryStyle.midnightNavy.ignoresSafeArea())
.navigationTitle("Your \(year)") .navigationTitle("\(year) Year in Flight")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
Button("Done") { dismiss() } Button { dismiss() } label: { Image(systemName: "xmark") }
} }
} }
} }
} }
// MARK: - Card variants
private func coverCard(year: Int, flights: Int) -> some View { private func coverCard(year: Int, flights: Int) -> some View {
VStack { HeroComposition(background: HistoryStyle.heroOrangeGradient) {
Spacer() VStack(spacing: 12) {
Text("\(year)") Spacer()
.font(.system(size: 80, weight: .black).monospacedDigit()) Text("\(year)")
.foregroundStyle(.white) .font(.system(size: 140, weight: .black).monospacedDigit())
Text("in flight") .foregroundStyle(.white)
.font(.title3.weight(.semibold)) Text("YEAR IN FLIGHT")
.foregroundStyle(.white.opacity(0.8)) .font(.system(size: 18, weight: .heavy))
Spacer() .tracking(2.5)
Text("\(flights) flights logged") .foregroundStyle(.white.opacity(0.85))
.font(.caption.weight(.semibold)) Spacer()
.foregroundStyle(.white.opacity(0.6)) 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, 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 { private func distanceCard(_ s: StatsEngine) -> some View {
VStack(spacing: 12) { HeroComposition(background: HistoryStyle.heroNavyGradient) {
Spacer() cardBody(
Text(headline) eyebrow: "DISTANCE",
.font(.system(size: 56, weight: .bold).monospacedDigit()) hero: s.shortDistance,
.multilineTextAlignment(.center) heroAccent: "mi",
.lineLimit(2) subtitle: equatorBlurb(miles: s.totalMiles)
.minimumScaleFactor(0.5) )
.foregroundStyle(.white)
.padding(.horizontal, 16)
Text(subhead)
.font(.title3.weight(.semibold))
.foregroundStyle(.white.opacity(0.85))
Spacer()
Text(footer)
.font(.caption)
.foregroundStyle(.white.opacity(0.65))
} }
.frame(width: 320, height: 480) }
.background(
LinearGradient( private func airportsCard(_ s: StatsEngine) -> some View {
colors: [FlightTheme.accent.opacity(0.85), FlightTheme.accent.opacity(0.45)], HeroComposition(background: HistoryStyle.heroGreenGradient) {
startPoint: .top, cardBody(
endPoint: .bottom eyebrow: "PASSPORT STAMPS",
), hero: "\(s.uniqueAirports)",
in: RoundedRectangle(cornerRadius: 24) heroAccent: "airports",
) subtitle: "\(s.uniqueCountries) countries"
.padding(.vertical, 8) )
}
}
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)
}
}