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:
+261
-63
@@ -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_
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, minHeight: rowHeight)
|
||||||
|
.clipShape(BoardingPassShape(
|
||||||
|
cornerRadius: cornerRadius,
|
||||||
|
perforationX: stubWidth,
|
||||||
|
punchRadius: punchRadius
|
||||||
|
))
|
||||||
|
.overlay(perforationLine)
|
||||||
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
// MARK: - Stub
|
||||||
HStack(spacing: 8) {
|
|
||||||
Text(flight.flightLabel)
|
private var stub: some View {
|
||||||
.font(.system(size: 14, weight: .heavy).monospaced())
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
.foregroundStyle(HistoryStyle.ink(scheme))
|
Text(flight.carrierIATA ?? flight.carrierICAO ?? "—")
|
||||||
if let type = flight.aircraftType {
|
.font(.system(size: 9, weight: .heavy).monospaced())
|
||||||
Text(type)
|
.tracking(2.2)
|
||||||
.font(.system(size: 10, weight: .bold).monospaced())
|
.foregroundStyle(.white.opacity(0.85))
|
||||||
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
|
Spacer(minLength: 4)
|
||||||
.padding(.horizontal, 5)
|
Text(paddedFlightNumber)
|
||||||
.padding(.vertical, 2)
|
.font(.system(size: 28, weight: .heavy).monospaced())
|
||||||
.background(HistoryStyle.cardSubtle(scheme), in: RoundedRectangle(cornerRadius: 4))
|
.foregroundStyle(.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.7)
|
||||||
|
.kerning(-0.6)
|
||||||
|
Spacer(minLength: 6)
|
||||||
|
BarcodeStripe()
|
||||||
|
.frame(height: 12)
|
||||||
|
.opacity(0.95)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
.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)
|
|
||||||
default: placeholder
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
placeholder
|
|
||||||
}
|
}
|
||||||
|
return String(format: "%04d", i)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var placeholder: some View {
|
// MARK: - Body
|
||||||
ZStack {
|
|
||||||
HistoryStyle.cardSubtle(scheme)
|
private var body_: some View {
|
||||||
Image(systemName: "airplane")
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
.font(.system(size: 14, weight: .heavy))
|
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)
|
.foregroundStyle(HistoryStyle.runwayOrange)
|
||||||
.rotationEffect(.degrees(-45))
|
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 func shortDate(_ d: Date) -> String {
|
private var distanceValue: String? {
|
||||||
let f = DateFormatter()
|
// We don't have the store passed in here, so we recompute the
|
||||||
f.dateFormat = "d MMM"
|
// distance from the airport database directly.
|
||||||
return f.string(from: d).uppercased()
|
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
Reference in New Issue
Block a user