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 @@ + + +
+ + +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.
+Refined version of the original. Orange stub on the left, vertical perforation, faux barcode footer, condensed mono numerics.
+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.
+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.
+Cream pass + a gold-foil stub with a shimmer streak. Allerta Stencil + Cormorant italic + JetBrains Mono. Reads like a Concorde-era first-class pass.
+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.
+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.
+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.
+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.
+Full-width airframe photo. Bottom gradient. Anton display sets the route in capital letters. The most magazine-like.
+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.
+Solari mechanical aesthetic. Pure black, amber pixel-font, faux scanlines, individual character chiclets with a split line. Lives outside light/dark mode by design.
+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.
+No photo. No chrome. Type does all the work. Reads like a Swiss timetable or a Jasper Morrison product card. Pure information density.
+