86582cea4a
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>
503 lines
18 KiB
Swift
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
|
|
}
|
|
}
|