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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user