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:
+264
-66
@@ -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
Reference in New Issue
Block a user