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
+224 -149
View File
@@ -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))
}
Text(flight.flightLabel)
.font(.title.weight(.bold).monospaced())
.foregroundStyle(FlightTheme.textPrimary)
Spacer()
Text(longDate(flight.flightDate))
.font(.caption.monospaced())
.foregroundStyle(FlightTheme.textTertiary)
VStack(alignment: .leading, spacing: 4) {
Text(flight.flightLabel)
.font(.system(size: 38, weight: .black).monospaced())
.foregroundStyle(HistoryStyle.ink(scheme))
HStack(spacing: 8) {
Text(airlineName)
.font(.system(size: 14, weight: .semibold))
Text("·")
Text(longDate(flight.flightDate).uppercased())
.font(.system(size: 12, weight: .heavy).monospaced())
.tracking(1)
}
Text(airlineName)
.font(.subheadline)
.foregroundStyle(FlightTheme.textSecondary)
.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)
}
if let mi = store.distanceMiles(for: flight) {
Text("\(numberString(mi)) miles · \(durationDisplay)")
.font(.caption.monospaced())
.foregroundStyle(FlightTheme.textTertiary)
Rectangle()
.fill(HistoryStyle.hairline(scheme))
.frame(height: 0.5)
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 {
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() ?? ""
)
}
.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 {
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