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 // 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 { struct PassportFlightRow: View {
let flight: LoggedFlight let flight: LoggedFlight
let database: AirportDatabase let database: AirportDatabase
@State private var photo: AircraftPhotoService.Photo?
@Environment(\.colorScheme) private var scheme @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 { var body: some View {
HStack(spacing: 12) { HStack(spacing: 0) {
thumbnail stub
.frame(width: 56, height: 44) .frame(width: stubWidth)
.clipShape(RoundedRectangle(cornerRadius: 8)) body_
}
VStack(alignment: .leading, spacing: 4) { .frame(maxWidth: .infinity, minHeight: rowHeight)
HStack(spacing: 8) { .clipShape(BoardingPassShape(
Text(flight.flightLabel) cornerRadius: cornerRadius,
.font(.system(size: 14, weight: .heavy).monospaced()) perforationX: stubWidth,
.foregroundStyle(HistoryStyle.ink(scheme)) punchRadius: punchRadius
if let type = flight.aircraftType { ))
Text(type) .overlay(perforationLine)
.font(.system(size: 10, weight: .bold).monospaced()) }
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
.padding(.horizontal, 5) // MARK: - Stub
.padding(.vertical, 2)
.background(HistoryStyle.cardSubtle(scheme), in: RoundedRectangle(cornerRadius: 4)) private var stub: some View {
} VStack(alignment: .leading, spacing: 0) {
} Text(flight.carrierIATA ?? flight.carrierICAO ?? "")
HStack(spacing: 6) { .font(.system(size: 9, weight: .heavy).monospaced())
Text(flight.departureIATA) .tracking(2.2)
.font(.system(size: 13, weight: .heavy).monospaced()) .foregroundStyle(.white.opacity(0.85))
.foregroundStyle(HistoryStyle.inkSecondary(scheme)) Spacer(minLength: 4)
Image(systemName: "arrow.right") Text(paddedFlightNumber)
.font(.system(size: 9, weight: .bold)) .font(.system(size: 28, weight: .heavy).monospaced())
.foregroundStyle(HistoryStyle.runwayOrange) .foregroundStyle(.white)
Text(flight.arrivalIATA) .lineLimit(1)
.font(.system(size: 13, weight: .heavy).monospaced()) .minimumScaleFactor(0.7)
.foregroundStyle(HistoryStyle.inkSecondary(scheme)) .kerning(-0.6)
} Spacer(minLength: 6)
} BarcodeStripe()
Spacer() .frame(height: 12)
Text(shortDate(flight.flightDate)) .opacity(0.95)
.font(.system(size: 11, weight: .semibold).monospacedDigit())
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
} }
.padding(.vertical, 10)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.background(HistoryStyle.card(scheme), in: RoundedRectangle(cornerRadius: 14)) .padding(.vertical, 14)
.task(id: flight.registration ?? flight.id.uuidString) { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
guard let reg = flight.registration, !reg.isEmpty else { return } .background(
photo = await AircraftPhotoService.shared.photo(registration: reg, icao24: flight.icao24 ?? "") LinearGradient(
} colors: [HistoryStyle.runwayOrange, HistoryStyle.runwayOrangeDeep],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
} }
@ViewBuilder /// "0007" flight number zero-padded to 4 digits when it parses
private var thumbnail: some View { /// as an int, else just the raw string.
if let url = photo?.thumbnailURL { private var paddedFlightNumber: String {
AsyncImage(url: url) { phase in guard let num = flight.flightNumber, let i = Int(num) else {
switch phase { return flight.flightNumber ?? ""
case .success(let img): }
img.resizable().aspectRatio(contentMode: .fill) return String(format: "%04d", i)
default: placeholder }
}
// 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 { Text(stubDate)
placeholder .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 { private var stubDate: String {
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 {
let f = DateFormatter() let f = DateFormatter()
f.dateFormat = "d MMM" f.dateFormat = "dd MMM yy"
return f.string(from: d).uppercased() 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