diff --git a/Flights/Views/HistoryView.swift b/Flights/Views/HistoryView.swift index e14850b..b3edc1a 100644 --- a/Flights/Views/HistoryView.swift +++ b/Flights/Views/HistoryView.swift @@ -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 + } + } } } diff --git a/design/boarding-pass-variants-dark.png b/design/boarding-pass-variants-dark.png new file mode 100644 index 0000000..26395af Binary files /dev/null and b/design/boarding-pass-variants-dark.png differ diff --git a/design/boarding-pass-variants-light.png b/design/boarding-pass-variants-light.png new file mode 100644 index 0000000..8982fc2 Binary files /dev/null and b/design/boarding-pass-variants-light.png differ diff --git a/design/boarding-pass-variants.html b/design/boarding-pass-variants.html new file mode 100644 index 0000000..7dca213 --- /dev/null +++ b/design/boarding-pass-variants.html @@ -0,0 +1,1145 @@ + + + + + +Boarding Pass — Six Variations + + + + + + +
+ +
+
+
BOARDING PASS · 6 DIRECTIONS
+

Same anatomy, six personalities

+

All six are boarding-pass-shaped (stub + perforation + body), but each commits to a distinct visual personality. Pick one and we port it to SwiftUI.

+
+ +
+ +
+ + +
+
01

The Classic

+

Refined version of the original. Orange stub on the left, vertical perforation, faux barcode footer, condensed mono numerics.

+
+
+
+
+
WN
+
0007
+
+
+
+
+ DAL + + HOU +
+
27 MAY 26
+
+
EQP
B737
+
TAIL
N7747C
+
NM
239
+
+
+
+ +
+
+
+
WN
+
1942
+
+
+
+
+ LAS + + DAL +
+
27 JAN 24
+
+
EQP
B738
+
TAIL
N281WN
+
NM
1056
+
+
+
+ +
+
+
+
WN
+
5476
+
+
+
+
+ BNA + + BOS +
+
19 MAR 24
+
+
EQP
B38M
+
TAIL
N8642E
+
NM
943
+
+
+
+
+
+ + +
+
02

Reverse Stub

+

Stub flipped to the right (mirrors what you keep after the gate agent tears your pass). Photo thumb sits on the body's left. Flight number rotated vertical in the stub.

+
+
+
+
+
+
+
+
DALHOU
+
B737 · N7747C
+
+
27 May 26 · 239 nm
+
+
+
+
WN
+
0007
+
+
+
+ +
+
+
+
+
+
+
LASDAL
+
B738 · N281WN
+
+
27 Jan 24 · 1056 nm
+
+
+
+
WN
+
1942
+
+
+
+ +
+
+
+
+
+
+
BNABOS
+
B38M · N8642E
+
+
19 Mar 24 · 943 nm
+
+
+
+
WN
+
5476
+
+
+
+
+
+ + +
+
03

Heritage Navy

+

Mid-century airline aesthetic. Cream paper with a small navy stub. Cormorant italic for cities, Playfair small caps for the airline mark. Quietly dignified — feels like a 1968 TWA pass.

+
+
+
+
+
South
west
+
FLT0007
+
+
+

DallastoHouston

+
DAL → HOU · B737 · N7747C
+
Wednesday, May 27 · 239 mi
+
+
+ +
+
+
+
South
west
+
FLT1942
+
+
+

Las VegastoDallas

+
LAS → DAL · B738 · N281WN
+
Saturday, January 27 · 1,056 mi
+
+
+ +
+
+
+
South
west
+
FLT5476
+
+
+

NashvilletoBoston

+
BNA → BOS · B38M · N8642E
+
Tuesday, March 19 · 943 mi
+
+
+
+
+ + +
+
04

Foil Premium

+

Cream pass + a gold-foil stub with a shimmer streak. Allerta Stencil + Cormorant italic + JetBrains Mono. Reads like a Concorde-era first-class pass.

+
+
+
+
+
SOUTHWEST
+
WN0007
+
★ ★ ★
+
+
+
DallastoHouston
+
B737 · TAIL N7747C
+
+ 27 MAY 2026 + 239 NM +
+
+
+ +
+
+
+
SOUTHWEST
+
WN1942
+
★ ★ ★
+
+
+
Las VegastoDallas
+
B738 · TAIL N281WN
+
+ 27 JAN 2024 + 1056 NM +
+
+
+ +
+
+
+
SOUTHWEST
+
WN5476
+
★ ★ ★
+
+
+
NashvilletoBoston
+
B38M · TAIL N8642E
+
+ 19 MAR 2024 + 943 NM +
+
+
+
+
+ + +
+
05

Brutalist Terminal

+

Pure black canvas. Major Mono Display for the route. Faux scanlines. Orange spine on the stub. Reads like a UNIX boarding pass printed at 4am.

+
+
+
+
+
WN
+
0007
+
SWA
+
+
+
FLT WN0007 · ETD 0830 CDT
+
DALHOU
+
+ EQP B737 + TAIL N7747C + NM 239 +
+
+
+ +
+
+
+
WN
+
1942
+
SWA
+
+
+
FLT WN1942 · ETD 0900 PST
+
LASDAL
+
+ EQP B738 + TAIL N281WN + NM 1056 +
+
+
+ +
+
+
+
WN
+
5476
+
SWA
+
+
+
FLT WN5476 · ETD 2010 CDT
+
BNABOS
+
+ EQP B38M + TAIL N8642E + NM 943 +
+
+
+
+
+ + +
+
06

Photo Stub Hybrid

+

The stub IS the airframe photo. Flight number and tail overlay on top. Body holds the route in Fraunces serif. Most photogenic of the six.

+
+
+
+
+
+
WN
+
0007
+
N7747C
+
+
+
+
DAL HOU
+
27 MAY 2026 · 239 NM
+
B737SWA
+
+
+ +
+
+
+
+
WN
+
1942
+
N281WN
+
+
+
+
LAS DAL
+
27 JAN 2024 · 1056 NM
+
B738SWA
+
+
+ +
+
+
+
+
WN
+
5476
+
N8642E
+
+
+
+
BNA BOS
+
19 MAR 2024 · 943 NM
+
B38MSWA
+
+
+
+
+ +
+ + + +
+ + + + diff --git a/design/flight-row-variants-dark.png b/design/flight-row-variants-dark.png new file mode 100644 index 0000000..82c7e90 Binary files /dev/null and b/design/flight-row-variants-dark.png differ diff --git a/design/flight-row-variants-light.png b/design/flight-row-variants-light.png new file mode 100644 index 0000000..e1317f5 Binary files /dev/null and b/design/flight-row-variants-light.png differ diff --git a/design/flight-row-variants.html b/design/flight-row-variants.html new file mode 100644 index 0000000..5e2d539 --- /dev/null +++ b/design/flight-row-variants.html @@ -0,0 +1,1331 @@ + + + + + +Flight Row — Six Variants + + + + + + +
+ +
+
+
FLIGHT ROW · SIX DIRECTIONS
+

Make the row earn its rent

+

Six aesthetic directions for the History tab's flight row, each executed as 3 stacked rows so you can see how they sit in a feed. Pick one — or mix elements across — and we'll port it back to SwiftUI.

+
+ +
+ +
+ + +
+
+ 01 +

Boarding Pass Stub

+
+

Perforated tear-line, mono numerics, faux barcode strip. Reads like the stub you'd tear off at the gate. Most "I flew this" of the bunch.

+
+
+
+
+ + +
+
+
WN
+
0007
+
+
+
+
+ DAL + + HOU +
+
27 MAY 26
+
+
EQP
B737
+
TAIL
N7747C
+
NM
239
+
+
+
+ + +
+
+
WN
+
1942
+
+
+
+
+ LAS + + DAL +
+
27 JAN 24
+
+
EQP
B738
+
TAIL
N281WN
+
NM
1056
+
+
+
+ + +
+
+
WN
+
5476
+
+
+
+
+ BNA + + BOS +
+
19 MAR 24
+
+
EQP
B38M
+
TAIL
N8642E
+
NM
943
+
+
+
+ +
+
+
+
+ + +
+
+ 02 +

Hero Photo Dominant

+
+

Full-width airframe photo. Bottom gradient. Anton display sets the route in capital letters. The most magazine-like.

+
+
+
+
+ +
+
+
+
+
WN 0007
+
MAY 27 · 2026
+
+
+
DAL HOU
+
239 NM · B737
+
+
+ +
+
+
+
+
WN 1942
+
JAN 27 · 2024
+
+
+
LAS DAL
+
1056 NM · B738
+
+
+ +
+ +
+
+
+
WN 5476
+
MAR 19 · 2024
+
+
+
BNA BOS
+
943 NM · B38M
+
+
+ +
+
+
+
+ + +
+
+ 03 +

Luggage Tag

+
+

Kraft-paper colorway, eyelet rivet, Fraunces italic for the destination. Reads like a souvenir glued in a scrapbook. Cleanly distinct from anything else in the app.

+
+
+
+
+ +
+
SOUTHWEST · WN 7
+
DAL to HOU
+
+ B737 · N7747C · 239 mi + May 27, 2026 +
+
+ +
+
SOUTHWEST · WN 1942
+
LAS to DAL
+
+ B738 · N281WN · 1056 mi + Jan 27, 2024 +
+
+ +
+
SOUTHWEST · WN 1136
+
SJU to MCO
+
+ B738 · N280WN · 1187 mi + Apr 11, 2022 +
+
+ +
+
+
+
+ + +
+
+ 04 +

Split-Flap Departures

+
+

Solari mechanical aesthetic. Pure black, amber pixel-font, faux scanlines, individual character chiclets with a split line. Lives outside light/dark mode by design.

+
+
+
+
+ +
+
+ WN 0007 + 27 MAY 26 +
+
+
+
D
A
L
+
+ ▶▶▶ +
+
H
O
U
+
+
+
+ B737 + N7747C + 239 NM +
+
+ +
+
+ WN 1942 + 27 JAN 24 +
+
+
+
L
A
S
+
+ ▶▶▶ +
+
D
A
L
+
+
+
+ B738 + N281WN + 1056 NM +
+
+ +
+
+ WN 5476 + 19 MAR 24 +
+
+
+
B
N
A
+
+ ▶▶▶ +
+
B
O
S
+
+
+
+ B38M + N8642E + 943 NM +
+
+ +
+
+
+
+ + +
+
+ 05 +

Postcard + Cancellation

+
+

Photo as postcard front. Postage stamp overlays it. Red cancellation circle on the body shouts the route. Most "collected stamp in a passport" of all six.

+
+
+
+
+ +
+
+
+ WN7 + ★ ★ ★ +
+
+
+
DAL · HOU
MAY 27 26
+

Dallas to Houston

+

239 mi · 50 m airborne

+
+ B737N7747C +
+
+
+ +
+
+
+ WN1942 + ★ ★ ★ +
+
+
+
LAS · DAL
JAN 27 24
+

Las Vegas to Dallas

+

1,056 mi · 2h 14m airborne

+
+ B738N281WN +
+
+
+ +
+
+
+
BNA · BOS
MAR 19 24
+

Nashville to Boston

+

943 mi · 2h 08m airborne

+
+ B38MN8642E +
+
+
+ +
+
+
+
+ + +
+
+ 06 +

Brutalist Mono

+
+

No photo. No chrome. Type does all the work. Reads like a Swiss timetable or a Jasper Morrison product card. Pure information density.

+
+
+
+
+ +
+
DAL/HOU
+
WN0007
+
27.05.26
+
+ B737· + N7747C· + 239 nm· + 2nd time on this airframe +
+
+ +
+
LAS/DAL
+
WN1942
+
27.01.24
+
+ B738· + N281WN· + 1056 nm +
+
+ +
+
BNA/BOS
+
WN5476
+
19.03.24
+
+ B38M· + N8642E· + 943 nm +
+
+ +
+
+
+
+ +
+ + + +
+ + + +