History row: boarding-pass classic design

Replaces PassportFlightRow with the "Classic" boarding-pass design
selected from design/boarding-pass-variants.html.

Anatomy:
- BoardingPassShape: custom SwiftUI Shape — rounded rect with two
  semicircular cutouts at the perforation column (top + bottom).
  Hand-drawn clockwise from top-left so the path closes cleanly.
- Stub (88pt wide): orange linear-gradient background. "WN"
  monospaced eyebrow at 9pt/tracking 2.2 at top, padded flight
  number ("0007") at 28pt monospaced heavy in the middle,
  BarcodeStripe at the bottom.
- BarcodeStripe: Canvas-drawn faux barcode — 16-element width
  pattern cycles across the width, even indices fill, odd are gaps.
- Body (flex): card background. Route IATA pair at 24pt mono heavy
  with an orange ▶ between, date in 10pt tracked mono uppercase,
  meta row of EQP / TAIL / MI metadata with mono labels in tertiary
  ink and values in primary.
- Perforation: GeometryReader-driven dashed line drawn between stub
  and body, inset top/bottom to stop short of the cutouts.

Distance is recomputed inline via haversine from the AirportDatabase
since the row doesn't get the FlightHistoryStore passed in. Mile
display only — clean integer rounded value.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-05-29 18:08:55 -05:00
parent 86582cea4a
commit e1b7fd4b0d
7 changed files with 2740 additions and 66 deletions
+264 -66
View File
@@ -465,87 +465,285 @@ struct HistoryView: View {
}
// 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
@State private var photo: AircraftPhotoService.Photo?
@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: 12) {
thumbnail
.frame(width: 56, height: 44)
.clipShape(RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Text(flight.flightLabel)
.font(.system(size: 14, weight: .heavy).monospaced())
.foregroundStyle(HistoryStyle.ink(scheme))
if let type = flight.aircraftType {
Text(type)
.font(.system(size: 10, weight: .bold).monospaced())
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
.padding(.horizontal, 5)
.padding(.vertical, 2)
.background(HistoryStyle.cardSubtle(scheme), in: RoundedRectangle(cornerRadius: 4))
}
}
HStack(spacing: 6) {
Text(flight.departureIATA)
.font(.system(size: 13, weight: .heavy).monospaced())
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
Image(systemName: "arrow.right")
.font(.system(size: 9, weight: .bold))
.foregroundStyle(HistoryStyle.runwayOrange)
Text(flight.arrivalIATA)
.font(.system(size: 13, weight: .heavy).monospaced())
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
}
}
Spacer()
Text(shortDate(flight.flightDate))
.font(.system(size: 11, weight: .semibold).monospacedDigit())
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
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(.vertical, 10)
.padding(.horizontal, 12)
.background(HistoryStyle.card(scheme), in: RoundedRectangle(cornerRadius: 14))
.task(id: flight.registration ?? flight.id.uuidString) {
guard let reg = flight.registration, !reg.isEmpty else { return }
photo = await AircraftPhotoService.shared.photo(registration: reg, icao24: flight.icao24 ?? "")
}
.padding(.vertical, 14)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.background(
LinearGradient(
colors: [HistoryStyle.runwayOrange, HistoryStyle.runwayOrangeDeep],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
@ViewBuilder
private var thumbnail: some View {
if let url = photo?.thumbnailURL {
AsyncImage(url: url) { phase in
switch phase {
case .success(let img):
img.resizable().aspectRatio(contentMode: .fill)
default: placeholder
}
/// "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))
}
} else {
placeholder
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 placeholder: some View {
ZStack {
HistoryStyle.cardSubtle(scheme)
Image(systemName: "airplane")
.font(.system(size: 14, weight: .heavy))
.foregroundStyle(HistoryStyle.runwayOrange)
.rotationEffect(.degrees(-45))
}
}
private func shortDate(_ d: Date) -> String {
private var stubDate: String {
let f = DateFormatter()
f.dateFormat = "d MMM"
return f.string(from: d).uppercased()
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
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 489 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 616 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 614 KiB

File diff suppressed because it is too large Load Diff