e5333ff965
The user re-imported the CSV expecting the new enrichment-during-import
to kick in, but every row dedupe-matched their existing log so nothing
got updated. Adds a dedicated "look up missing types" flow for
already-saved flights.
- EnrichAircraftTypesView: scans every LoggedFlight where
aircraftType == nil and carrierIATA + flightNumber are present,
walks them sequentially through routeExplorer.searchSchedule for
that carrier/flight/date window, patches in equipmentIata when the
schedule has a match. Live progress (N / total), found count,
Stop to cancel. Saves once at the end so SwiftData batches the
writes.
- AircraftStatsView now takes a RouteExplorerClient and:
- Surfaces a "Look up missing types" CTA button in the empty
state (the most useful place to discover this).
- Adds a wand.and.stars toolbar button so the flow is reachable
even when stats are already populated (for top-ups after
new imports).
- HistoryView passes the existing routeExplorer through to
AircraftStatsView.
Net behavior: user opens Aircraft Stats → sees empty state → taps
"Look up missing types" → modal scans the log, queries
route-explorer per flight, fills in types, hits Done. Stats screen
populates without needing a re-import.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
758 lines
29 KiB
Swift
758 lines
29 KiB
Swift
import SwiftUI
|
|
import SwiftData
|
|
|
|
/// History tab — redesigned as a "passport" experience.
|
|
///
|
|
/// 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 {
|
|
let database: AirportDatabase
|
|
let routeExplorer: RouteExplorerClient
|
|
let openSky: OpenSkyClient
|
|
|
|
@Environment(\.modelContext) private var modelContext
|
|
@Environment(\.colorScheme) private var scheme
|
|
|
|
@Query(sort: \LoggedFlight.flightDate, order: .reverse)
|
|
private var flights: [LoggedFlight]
|
|
|
|
@State private var filters: HistoryFilters = .init()
|
|
@State private var sort: HistorySort = .newestFirst
|
|
@State private var selectedYear: Int? = nil // nil = ALL
|
|
|
|
@State private var showingAdd = false
|
|
@State private var showingPassport = false
|
|
@State private var showingMap = false
|
|
@State private var showingAircraftStats = false
|
|
@State private var showingCalendarImport = false
|
|
@State private var showingCSVImport = false
|
|
@State private var showingYearInReview = false
|
|
@State private var showingFilterSheet = false
|
|
|
|
var body: some View {
|
|
let store = FlightHistoryStore(context: modelContext, airportDatabase: database)
|
|
let scoped = scopedFlights(store: store)
|
|
let stats = StatsEngine(store: store, database: database, flights: scoped)
|
|
|
|
ScrollView {
|
|
LazyVStack(spacing: 0, pinnedViews: []) {
|
|
titleHeader
|
|
|
|
YearTabStrip(years: yearsList, selection: $selectedYear)
|
|
.padding(.vertical, 12)
|
|
|
|
if filters.isEmpty {
|
|
heroDeck(store: store, stats: stats)
|
|
.padding(.horizontal, 16)
|
|
.padding(.bottom, 8)
|
|
}
|
|
|
|
if !filters.isEmpty {
|
|
activeChips
|
|
.padding(.horizontal, 16)
|
|
.padding(.bottom, 8)
|
|
}
|
|
|
|
if scoped.isEmpty {
|
|
emptyState
|
|
} else {
|
|
flightFeed(scoped, store: store)
|
|
}
|
|
|
|
Spacer(minLength: 80)
|
|
}
|
|
}
|
|
.background(HistoryStyle.background(scheme).ignoresSafeArea())
|
|
.navigationTitle("")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.searchable(text: $filters.query, prompt: "Flight #, airport, route")
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Menu {
|
|
ForEach(HistorySort.allCases) { option in
|
|
Button {
|
|
sort = option
|
|
} label: {
|
|
if sort == option {
|
|
Label(option.rawValue, systemImage: "checkmark")
|
|
} else {
|
|
Label(option.rawValue, systemImage: option.systemImage)
|
|
}
|
|
}
|
|
}
|
|
} label: {
|
|
Image(systemName: "arrow.up.arrow.down")
|
|
}
|
|
}
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button {
|
|
showingFilterSheet = true
|
|
} label: {
|
|
Image(systemName: filters.activeCount > 0 ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
|
|
}
|
|
}
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Menu {
|
|
Section("Add") {
|
|
Button { showingAdd = true } label: { Label("Add manually", systemImage: "plus") }
|
|
Button { showingCalendarImport = true } label: { Label("Scan Calendar", systemImage: "calendar") }
|
|
Button { showingCSVImport = true } label: { Label("Import CSV…", systemImage: "doc.text") }
|
|
}
|
|
Section("Explore") {
|
|
Button { showingPassport = true } label: { Label("Passport", systemImage: "book.closed") }
|
|
Button { showingMap = true } label: { Label("Route map", systemImage: "map.fill") }
|
|
Button { showingAircraftStats = true } label: { Label("Aircraft stats", systemImage: "airplane.circle") }
|
|
Button { showingYearInReview = true } label: { Label("Year in Review", systemImage: "sparkles") }
|
|
}
|
|
} label: {
|
|
Image(systemName: "plus.circle.fill")
|
|
.foregroundStyle(HistoryStyle.runwayOrange)
|
|
}
|
|
}
|
|
}
|
|
.sheet(isPresented: $showingAdd) {
|
|
AddFlightView(routeExplorer: routeExplorer, database: database, store: store, prefill: nil)
|
|
}
|
|
.sheet(isPresented: $showingPassport) {
|
|
NavigationStack {
|
|
PassportView(stats: stats, allFlights: flights, database: database, store: store, selectedYear: $selectedYear)
|
|
}
|
|
}
|
|
.sheet(isPresented: $showingMap) {
|
|
NavigationStack {
|
|
HistoryRouteMapView(
|
|
flights: scoped,
|
|
allFlights: flights,
|
|
database: database,
|
|
openSky: openSky,
|
|
store: store,
|
|
filters: $filters
|
|
)
|
|
}
|
|
}
|
|
.sheet(isPresented: $showingAircraftStats) {
|
|
NavigationStack {
|
|
AircraftStatsView(allFlights: flights, store: store, routeExplorer: routeExplorer)
|
|
}
|
|
}
|
|
.sheet(isPresented: $showingCalendarImport) {
|
|
CalendarImportView(routeExplorer: routeExplorer, database: database, store: store)
|
|
}
|
|
.sheet(isPresented: $showingCSVImport) {
|
|
ImportCSVView(store: store, routeExplorer: routeExplorer)
|
|
}
|
|
.sheet(isPresented: $showingYearInReview) {
|
|
YearInReviewView(stats: stats, year: selectedYear ?? Calendar.current.component(.year, from: Date()))
|
|
}
|
|
.sheet(isPresented: $showingFilterSheet) {
|
|
HistoryFilterSheet(allFlights: flights, filters: $filters)
|
|
.presentationDetents([.medium, .large])
|
|
}
|
|
}
|
|
|
|
// MARK: - Pipeline
|
|
|
|
private var yearsList: [Int] {
|
|
let cal = Calendar.current
|
|
let ys = Set(flights.map { cal.component(.year, from: $0.flightDate) })
|
|
return ys.sorted(by: >)
|
|
}
|
|
|
|
private func scopedFlights(store: FlightHistoryStore) -> [LoggedFlight] {
|
|
var scoped = flights
|
|
if let y = selectedYear {
|
|
let cal = Calendar.current
|
|
scoped = scoped.filter { cal.component(.year, from: $0.flightDate) == y }
|
|
}
|
|
scoped = scoped.filter { filters.matches($0) }
|
|
let cmp = sort.comparator { store.distanceMiles(for: $0) ?? 0 }
|
|
return scoped.sorted(by: cmp)
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
}
|
|
|
|
// MARK: - Active filter chips
|
|
|
|
private var activeChips: some View {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 8) {
|
|
if !filters.query.isEmpty {
|
|
chip("\(filters.query)", systemImage: "magnifyingglass") { filters.query = "" }
|
|
}
|
|
ForEach(Array(filters.years).sorted(by: >), id: \.self) { y in
|
|
chip("\(y)", systemImage: "calendar") { filters.years.remove(y) }
|
|
}
|
|
ForEach(Array(filters.airlines).sorted(), id: \.self) { a in
|
|
chip(AircraftRegistry.shared.lookup(icao: a)?.name ?? a, systemImage: "building.2") {
|
|
filters.airlines.remove(a)
|
|
}
|
|
}
|
|
ForEach(Array(filters.airports).sorted(), id: \.self) { a in
|
|
chip(a, systemImage: "airplane") { filters.airports.remove(a) }
|
|
}
|
|
ForEach(Array(filters.aircraftTypes).sorted(), id: \.self) { t in
|
|
chip(t, systemImage: "airplane.departure") { filters.aircraftTypes.remove(t) }
|
|
}
|
|
Button { filters = HistoryFilters() } label: {
|
|
Text("Clear")
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(HistoryStyle.runwayOrange)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func chip(_ label: String, systemImage: String, onRemove: @escaping () -> Void) -> some View {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: systemImage).font(.caption2)
|
|
Text(label).font(.caption.weight(.semibold))
|
|
Image(systemName: "xmark").font(.caption2)
|
|
}
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 5)
|
|
.background(HistoryStyle.runwayOrange, in: Capsule())
|
|
.onTapGesture(perform: onRemove)
|
|
}
|
|
|
|
// MARK: - Flight feed
|
|
|
|
@ViewBuilder
|
|
private func flightFeed(_ scoped: [LoggedFlight], store: FlightHistoryStore) -> some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack {
|
|
HistorySectionLabel(selectedYear == nil ? "Recent flights" : "Flights in \(selectedYear!)")
|
|
Spacer()
|
|
Text("\(scoped.count)")
|
|
.font(.system(size: 12, weight: .bold).monospacedDigit())
|
|
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.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 struct FeedGroup {
|
|
let key: String
|
|
let flights: [LoggedFlight]
|
|
}
|
|
|
|
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 {
|
|
VStack(spacing: 12) {
|
|
Image(systemName: "airplane.circle")
|
|
.font(.system(size: 48))
|
|
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
|
|
Text(flights.isEmpty ? "No flights logged yet" : "No matches in \(selectedYear.map(String.init) ?? "this filter")")
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
|
|
if flights.isEmpty {
|
|
Text("Tap + to add a flight, scan your calendar, or import a CSV.")
|
|
.font(.caption)
|
|
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal, 32)
|
|
} else {
|
|
Button("Clear filter") {
|
|
selectedYear = nil
|
|
filters = HistoryFilters()
|
|
}
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(HistoryStyle.runwayOrange)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 60)
|
|
}
|
|
|
|
private func numberString(_ n: Int) -> String {
|
|
let f = NumberFormatter()
|
|
f.numberStyle = .decimal
|
|
return f.string(from: NSNumber(value: n)) ?? "\(n)"
|
|
}
|
|
}
|
|
|
|
// MARK: - Passport-styled flight row
|
|
//
|
|
// "The Classic" boarding pass — orange stub on the left, dashed
|
|
// perforation with semicircular cutouts at top and bottom, body with
|
|
// IATA route + date + meta data line. Designed in HTML mockups
|
|
// (design/boarding-pass-variants.html, variant 01) then ported here.
|
|
|
|
struct PassportFlightRow: View {
|
|
let flight: LoggedFlight
|
|
let database: AirportDatabase
|
|
@Environment(\.colorScheme) private var scheme
|
|
|
|
private let cornerRadius: CGFloat = 14
|
|
private let stubWidth: CGFloat = 88
|
|
private let punchRadius: CGFloat = 7
|
|
private let rowHeight: CGFloat = 108
|
|
|
|
var body: some View {
|
|
HStack(spacing: 0) {
|
|
stub
|
|
.frame(width: stubWidth)
|
|
body_
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: rowHeight)
|
|
.clipShape(BoardingPassShape(
|
|
cornerRadius: cornerRadius,
|
|
perforationX: stubWidth,
|
|
punchRadius: punchRadius
|
|
))
|
|
.overlay(perforationLine)
|
|
}
|
|
|
|
// MARK: - Stub
|
|
|
|
private var stub: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Text(flight.carrierIATA ?? flight.carrierICAO ?? "—")
|
|
.font(.system(size: 9, weight: .heavy).monospaced())
|
|
.tracking(2.2)
|
|
.foregroundStyle(.white.opacity(0.85))
|
|
Spacer(minLength: 4)
|
|
Text(paddedFlightNumber)
|
|
.font(.system(size: 28, weight: .heavy).monospaced())
|
|
.foregroundStyle(.white)
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.7)
|
|
.kerning(-0.6)
|
|
Spacer(minLength: 6)
|
|
BarcodeStripe()
|
|
.frame(height: 12)
|
|
.opacity(0.95)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 14)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
|
.background(
|
|
LinearGradient(
|
|
colors: [HistoryStyle.runwayOrange, HistoryStyle.runwayOrangeDeep],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
}
|
|
|
|
/// "0007" — flight number zero-padded to 4 digits when it parses
|
|
/// as an int, else just the raw string.
|
|
private var paddedFlightNumber: String {
|
|
guard let num = flight.flightNumber, let i = Int(num) else {
|
|
return flight.flightNumber ?? "—"
|
|
}
|
|
return String(format: "%04d", i)
|
|
}
|
|
|
|
// MARK: - Body
|
|
|
|
private var body_: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
|
Text(flight.departureIATA.isEmpty ? "—" : flight.departureIATA)
|
|
.font(.system(size: 24, weight: .heavy).monospaced())
|
|
.kerning(-0.5)
|
|
.foregroundStyle(HistoryStyle.ink(scheme))
|
|
Text("▶")
|
|
.font(.system(size: 13, weight: .black))
|
|
.foregroundStyle(HistoryStyle.runwayOrange)
|
|
Text(flight.arrivalIATA.isEmpty ? "—" : flight.arrivalIATA)
|
|
.font(.system(size: 24, weight: .heavy).monospaced())
|
|
.kerning(-0.5)
|
|
.foregroundStyle(HistoryStyle.ink(scheme))
|
|
}
|
|
Text(stubDate)
|
|
.font(.system(size: 10, weight: .heavy).monospaced())
|
|
.tracking(1.8)
|
|
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
|
|
Spacer(minLength: 6)
|
|
metaRow
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 14)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
.background(HistoryStyle.card(scheme))
|
|
}
|
|
|
|
private var stubDate: String {
|
|
let f = DateFormatter()
|
|
f.dateFormat = "dd MMM yy"
|
|
return f.string(from: flight.flightDate).uppercased()
|
|
}
|
|
|
|
/// Bottom-of-card metadata line: `EQP B737 · TAIL N7747C · MI 239`
|
|
private var metaRow: some View {
|
|
HStack(spacing: 14) {
|
|
metaItem(label: "EQP", value: flight.aircraftType)
|
|
metaItem(label: "TAIL", value: flight.registration)
|
|
metaItem(label: "MI", value: distanceValue)
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
|
|
private var distanceValue: String? {
|
|
// We don't have the store passed in here, so we recompute the
|
|
// distance from the airport database directly.
|
|
guard let dep = database.airport(byIATA: flight.departureIATA),
|
|
let arr = database.airport(byIATA: flight.arrivalIATA)
|
|
else { return nil }
|
|
let dLat = (arr.coordinate.latitude - dep.coordinate.latitude) * .pi / 180
|
|
let dLon = (arr.coordinate.longitude - dep.coordinate.longitude) * .pi / 180
|
|
let lat1 = dep.coordinate.latitude * .pi / 180
|
|
let lat2 = arr.coordinate.latitude * .pi / 180
|
|
let a = sin(dLat / 2) * sin(dLat / 2)
|
|
+ cos(lat1) * cos(lat2) * sin(dLon / 2) * sin(dLon / 2)
|
|
let c = 2 * atan2(sqrt(a), sqrt(1 - a))
|
|
let km = 6371.0 * c
|
|
let mi = km / 1.609344
|
|
return mi >= 1 ? "\(Int(mi.rounded()))" : nil
|
|
}
|
|
|
|
private func metaItem(label: String, value: String?) -> some View {
|
|
HStack(spacing: 4) {
|
|
Text(label)
|
|
.font(.system(size: 9, weight: .bold).monospaced())
|
|
.tracking(1.0)
|
|
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
|
|
Text(value ?? "—")
|
|
.font(.system(size: 10, weight: .heavy).monospaced())
|
|
.foregroundStyle(HistoryStyle.ink(scheme))
|
|
}
|
|
}
|
|
|
|
// MARK: - Perforation
|
|
|
|
/// Vertical dashed line drawn between stub and body, with a small
|
|
/// inset top and bottom so it stops short of the punch cutouts.
|
|
private var perforationLine: some View {
|
|
GeometryReader { geo in
|
|
Path { path in
|
|
path.move(to: CGPoint(x: stubWidth, y: punchRadius + 2))
|
|
path.addLine(to: CGPoint(x: stubWidth, y: geo.size.height - punchRadius - 2))
|
|
}
|
|
.stroke(
|
|
HistoryStyle.inkTertiary(scheme),
|
|
style: StrokeStyle(lineWidth: 1, dash: [3, 3])
|
|
)
|
|
}
|
|
.allowsHitTesting(false)
|
|
}
|
|
}
|
|
|
|
// MARK: - Boarding pass shape
|
|
|
|
/// Rounded rectangle with two semicircular cutouts (top + bottom) at
|
|
/// the perforation column — the visual hallmark of a boarding pass.
|
|
/// Drawn clockwise starting from the top-left corner.
|
|
struct BoardingPassShape: Shape {
|
|
let cornerRadius: CGFloat
|
|
let perforationX: CGFloat
|
|
let punchRadius: CGFloat
|
|
|
|
func path(in rect: CGRect) -> Path {
|
|
var p = Path()
|
|
let r = cornerRadius
|
|
let pr = punchRadius
|
|
let pX = perforationX
|
|
let w = rect.width
|
|
let h = rect.height
|
|
|
|
// Start: top-left corner, after rounded corner
|
|
p.move(to: CGPoint(x: r, y: 0))
|
|
|
|
// Top edge to the perforation cutout
|
|
p.addLine(to: CGPoint(x: pX - pr, y: 0))
|
|
// Top semicircle cutout — sweeps DOWN into the row
|
|
p.addArc(
|
|
center: CGPoint(x: pX, y: 0),
|
|
radius: pr,
|
|
startAngle: .degrees(180),
|
|
endAngle: .degrees(0),
|
|
clockwise: false
|
|
)
|
|
// Continue along top edge
|
|
p.addLine(to: CGPoint(x: w - r, y: 0))
|
|
|
|
// Top-right corner
|
|
p.addArc(
|
|
center: CGPoint(x: w - r, y: r),
|
|
radius: r,
|
|
startAngle: .degrees(-90),
|
|
endAngle: .degrees(0),
|
|
clockwise: false
|
|
)
|
|
// Right edge
|
|
p.addLine(to: CGPoint(x: w, y: h - r))
|
|
// Bottom-right corner
|
|
p.addArc(
|
|
center: CGPoint(x: w - r, y: h - r),
|
|
radius: r,
|
|
startAngle: .degrees(0),
|
|
endAngle: .degrees(90),
|
|
clockwise: false
|
|
)
|
|
// Bottom edge to the perforation cutout
|
|
p.addLine(to: CGPoint(x: pX + pr, y: h))
|
|
// Bottom semicircle cutout — sweeps UP into the row
|
|
p.addArc(
|
|
center: CGPoint(x: pX, y: h),
|
|
radius: pr,
|
|
startAngle: .degrees(0),
|
|
endAngle: .degrees(180),
|
|
clockwise: false
|
|
)
|
|
// Continue along bottom edge
|
|
p.addLine(to: CGPoint(x: r, y: h))
|
|
|
|
// Bottom-left corner
|
|
p.addArc(
|
|
center: CGPoint(x: r, y: h - r),
|
|
radius: r,
|
|
startAngle: .degrees(90),
|
|
endAngle: .degrees(180),
|
|
clockwise: false
|
|
)
|
|
// Left edge
|
|
p.addLine(to: CGPoint(x: 0, y: r))
|
|
// Top-left corner
|
|
p.addArc(
|
|
center: CGPoint(x: r, y: r),
|
|
radius: r,
|
|
startAngle: .degrees(180),
|
|
endAngle: .degrees(270),
|
|
clockwise: false
|
|
)
|
|
p.closeSubpath()
|
|
return p
|
|
}
|
|
}
|
|
|
|
// MARK: - Faux barcode
|
|
|
|
/// Canvas-drawn faux barcode strip. Deliberately not a scannable
|
|
/// barcode — purely decorative. The bar widths cycle through a fixed
|
|
/// pattern that *looks* random enough at a glance.
|
|
struct BarcodeStripe: View {
|
|
/// Bar widths in points: [bar, gap, bar, gap, ...]. The pattern
|
|
/// repeats horizontally across the width of the canvas.
|
|
private static let widths: [CGFloat] = [1, 2, 1, 3, 2, 1, 1, 2, 3, 1, 2, 1, 1, 3, 2, 2]
|
|
|
|
var body: some View {
|
|
Canvas { context, size in
|
|
var x: CGFloat = 0
|
|
var i = 0
|
|
while x < size.width {
|
|
let w = Self.widths[i % Self.widths.count]
|
|
// Even indices are bars; odd are gaps.
|
|
if i.isMultiple(of: 2) {
|
|
let rect = CGRect(x: x, y: 0, width: w, height: size.height)
|
|
context.fill(Path(rect), with: .color(.white))
|
|
}
|
|
x += w
|
|
i += 1
|
|
}
|
|
}
|
|
}
|
|
}
|