Files
Flights/Flights/Views/HistoryDetailView.swift
T
Trey T 86582cea4a 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>
2026-05-29 11:13:24 -05:00

503 lines
18 KiB
Swift

import SwiftUI
import MapKit
import CoreLocation
/// 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
let database: AirportDatabase
let openSky: OpenSkyClient
@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
@State private var metadataLoaded = false
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
header
routeCard
photoBanner.padding(.horizontal, -16)
if let cred = photo?.photographer {
photoCredit(name: cred, link: photo?.detailLink)
}
mapSection
aircraftCard
timetableCard
notesSection
deleteButton
}
.padding(16)
}
.background(HistoryStyle.background(scheme).ignoresSafeArea())
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.task {
editedNotes = flight.notes ?? ""
if let reg = flight.registration {
photo = await AircraftPhotoService.shared.photo(registration: reg, icao24: flight.icao24 ?? "")
}
await loadTrackIfRecent()
await loadAirframeMetadata()
}
.alert("Delete this flight?", isPresented: $showDeleteConfirm) {
Button("Delete", role: .destructive) {
store.delete(flight)
dismiss()
}
Button("Cancel", role: .cancel) {}
}
}
// MARK: - Header
private var header: some View {
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)
}
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
}
}
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(spacing: 14) {
HStack(alignment: .top) {
routeEndpoint(iata: flight.departureIATA, label: "From", time: flight.actualDeparture ?? flight.scheduledDeparture)
Spacer()
Image(systemName: "airplane")
.font(.system(size: 24, weight: .heavy))
.foregroundStyle(HistoryStyle.runwayOrange)
.rotationEffect(.degrees(-45))
Spacer()
routeEndpoint(iata: flight.arrivalIATA, label: "To", time: flight.actualArrival ?? flight.scheduledArrival)
}
Rectangle()
.fill(HistoryStyle.hairline(scheme))
.frame(height: 0.5)
HStack(spacing: 18) {
if let mi = store.distanceMiles(for: flight) {
miniStat(label: "Distance", value: "\(numberString(mi)) mi")
}
miniStat(label: "Duration", value: durationDisplay)
Spacer()
}
}
.historyCard(scheme, padding: 18)
}
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(.system(size: 32, weight: .black).monospaced())
.foregroundStyle(HistoryStyle.ink(scheme))
if let m = database.airport(byIATA: iata) {
Text(m.name)
.font(.system(size: 11))
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
.lineLimit(1)
}
if let time {
Text(shortDateTime(time))
.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
private var photoBanner: some View {
if let photo {
AsyncImage(url: photo.largeURL) { phase in
switch phase {
case .success(let img): img.resizable().aspectRatio(contentMode: .fill)
default: Rectangle().fill(HistoryStyle.cardSubtle(scheme))
}
}
.frame(maxWidth: .infinity)
.frame(height: 200)
.clipped()
}
}
private func photoCredit(name: String, link: URL?) -> some View {
HStack(spacing: 4) {
Image(systemName: "camera.fill").font(.system(size: 9))
Text("Photo by \(name) · planespotters.net")
.font(.system(size: 10))
}
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
.contentShape(Rectangle())
.onTapGesture { if let link { openURL(link) } }
}
// MARK: - Map
@ViewBuilder
private var mapSection: some View {
VStack(alignment: .leading, spacing: 8) {
HistorySectionLabel(track == nil ? "Route" : "Flown path")
FlightRouteMap(
departureIATA: flight.departureIATA,
arrivalIATA: flight.arrivalIATA,
track: track,
database: database
)
.frame(height: 220)
.clipShape(RoundedRectangle(cornerRadius: 18))
}
}
private func loadTrackIfRecent() async {
let ageDays = Date().timeIntervalSince(flight.flightDate) / 86400
guard ageDays < 7, let icao24 = flight.icao24, !icao24.isEmpty else { return }
track = await openSky.track(icao24: icao24)
}
private func loadAirframeMetadata() async {
guard let reg = flight.registration, !reg.isEmpty,
let icao24 = flight.icao24, !icao24.isEmpty
else { return }
if let cached = store.airframe(for: reg),
cached.firstFlightDate != nil || cached.deliveryDate != nil {
metadataLoaded.toggle()
return
}
if let meta = await AirframeMetadataService.shared.metadata(forICAO24: icao24) {
store.upsertAirframe(
registration: reg,
firstFlightDate: meta.firstFlightDate,
deliveryDate: meta.built
)
metadataLoaded.toggle()
}
}
// MARK: - Aircraft card
private var aircraftCard: some View {
let repeats = store.repeatCount(for: flight.registration, before: flight.flightDate)
let airframe = flight.registration.flatMap(store.airframe(for:))
let firstFlight = airframe?.firstFlightDate
let ageYears = firstFlight.map { years(since: $0) }
return VStack(alignment: .leading, spacing: 12) {
HistorySectionLabel("Aircraft")
VStack(spacing: 0) {
aircraftRow(
leftLabel: "Type", leftValue: flight.aircraftType ?? "",
rightLabel: "Tail #", rightValue: flight.registration ?? ""
)
divider
aircraftRow(
leftLabel: "First flight",
leftValue: firstFlight.map { yearString($0) } ?? "",
rightLabel: "Age",
rightValue: ageYears.map { "\($0) yr" } ?? ""
)
divider
aircraftRow(
leftLabel: "On this airframe",
leftValue: repeats == 0 ? "First time" : "\(repeats + 1)\(ordinalSuffix(repeats + 1)) time",
rightLabel: "ICAO24",
rightValue: flight.icao24?.uppercased() ?? ""
)
}
.historyCard(scheme, padding: 0)
}
}
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: 4) {
Text(label.uppercased())
.font(HistoryStyle.label(10))
.tracking(1.3)
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
Text(value)
.font(.system(size: 14, weight: .heavy).monospaced())
.foregroundStyle(HistoryStyle.ink(scheme))
}
.frame(maxWidth: .infinity, alignment: .leading)
.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) {
HistorySectionLabel("Notes")
TextEditor(text: $editedNotes)
.scrollContentBackground(.hidden)
.frame(minHeight: 80)
.padding(8)
.background(HistoryStyle.card(scheme), in: RoundedRectangle(cornerRadius: 14))
.onChange(of: editedNotes) { _, newValue in
flight.notes = newValue.isEmpty ? nil : newValue
}
}
}
// MARK: - Delete
private var deleteButton: some View {
Button(role: .destructive) {
showDeleteConfirm = true
} label: {
HStack {
Image(systemName: "trash")
Text("Delete flight")
}
.font(.system(size: 14, weight: .semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.background(Color.red.opacity(0.12), in: RoundedRectangle(cornerRadius: 14))
.foregroundStyle(Color.red)
.padding(.top, 8)
}
// MARK: - Helpers
private var durationDisplay: String {
guard let min = store.durationMinutes(for: flight) else { return "" }
let h = min / 60
let m = min % 60
return h > 0 ? "\(h)h \(m)m" : "\(m)m"
}
private func numberString(_ n: Int) -> String {
let f = NumberFormatter(); f.numberStyle = .decimal
return f.string(from: NSNumber(value: n)) ?? "\(n)"
}
private func years(since: Date) -> Int {
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" }
switch n % 10 {
case 1: return "st"
case 2: return "nd"
case 3: return "rd"
default: return "th"
}
}
private func longDate(_ d: Date) -> String {
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"
return f.string(from: d)
}
private func shortTime(_ d: Date) -> String {
let f = DateFormatter(); f.dateFormat = "h:mm a"
return f.string(from: d)
}
}
/// Map view used by the history detail. Draws the actual flown track
/// when supplied; otherwise a great-circle arc between dep + arr.
private struct FlightRouteMap: View {
let departureIATA: String
let arrivalIATA: String
let track: AircraftTrack?
let database: AirportDatabase
var body: some View {
Map {
if let dep = database.airport(byIATA: departureIATA) {
Marker("From " + departureIATA, systemImage: "airplane.departure", coordinate: dep.coordinate)
.tint(HistoryStyle.stampGreen)
}
if let arr = database.airport(byIATA: arrivalIATA) {
Marker("To " + arrivalIATA, systemImage: "airplane.arrival", coordinate: arr.coordinate)
.tint(HistoryStyle.runwayOrange)
}
if let track {
let coords = track.path.map {
CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude)
}
MapPolyline(coordinates: coords)
.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(HistoryStyle.runwayOrange.opacity(0.7), style: StrokeStyle(lineWidth: 2, dash: [5, 4]))
}
}
}
private func greatCircle(from a: CLLocationCoordinate2D, to b: CLLocationCoordinate2D, segments: Int) -> [CLLocationCoordinate2D] {
let lat1 = a.latitude * .pi / 180
let lon1 = a.longitude * .pi / 180
let lat2 = b.latitude * .pi / 180
let lon2 = b.longitude * .pi / 180
let d = 2 * asin(sqrt(
pow(sin((lat2 - lat1) / 2), 2)
+ cos(lat1) * cos(lat2) * pow(sin((lon2 - lon1) / 2), 2)
))
if d == 0 { return [a, b] }
var out: [CLLocationCoordinate2D] = []
out.reserveCapacity(segments + 1)
for i in 0...segments {
let f = Double(i) / Double(segments)
let A = sin((1 - f) * d) / sin(d)
let B = sin(f * d) / sin(d)
let x = A * cos(lat1) * cos(lon1) + B * cos(lat2) * cos(lon2)
let y = A * cos(lat1) * sin(lon1) + B * cos(lat2) * sin(lon2)
let z = A * sin(lat1) + B * sin(lat2)
let lat = atan2(z, sqrt(x * x + y * y))
let lon = atan2(y, x)
out.append(CLLocationCoordinate2D(latitude: lat * 180 / .pi, longitude: lon * 180 / .pi))
}
return out
}
}