Files
Flights/Flights/Views/HistoryView.swift
T
Trey T e5333ff965 Enrich existing flights with aircraft types
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>
2026-05-29 18:48:59 -05:00

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
}
}
}
}