Skip to main content

ferro_wallet/
subject.rs

1//! `WalletSubject` — the content contract every downstream domain object implements
2//! to be issued as either an Apple `.pkpass` or a Google Wallet save-JWT.
3//!
4//! See the design spec at `docs/superpowers/specs/2026-05-11-ferro-wallet-crate.md`
5//! §3.1 for the authoritative public surface. Value types stay deliberately small:
6//! they are the input shape both `ApplePassBuilder::build` and
7//! `GoogleWalletBuilder::save_jwt` accept.
8
9use crate::WalletError;
10use chrono::{DateTime, Utc};
11
12/// Top-level pass category. `EventTicket` renders a rounded card with an optional
13/// strip banner. `BoardingPass` renders the "tear-off ticket stub" shape with a
14/// perforation line above the barcode and rounded inner notches on each side —
15/// the Trenitalia / airline look. `Generic` and `Coupon` use flatter card chrome.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum PassKind {
18    EventTicket,
19    BoardingPass(TransitType),
20    Generic,
21    Coupon,
22}
23
24/// Transit class shown by Apple Wallet on a [`PassKind::BoardingPass`]. Selects
25/// the small transit-mode icon rendered next to the primary fields. Use
26/// [`TransitType::Generic`] for non-transit "ticket-stub style" passes
27/// (admissions, restaurant reservations, etc.).
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum TransitType {
30    Air,
31    Boat,
32    Bus,
33    Generic,
34    Train,
35}
36
37impl TransitType {
38    pub(crate) fn as_apple_str(&self) -> &'static str {
39        match self {
40            TransitType::Air => "PKTransitTypeAir",
41            TransitType::Boat => "PKTransitTypeBoat",
42            TransitType::Bus => "PKTransitTypeBus",
43            TransitType::Generic => "PKTransitTypeGeneric",
44            TransitType::Train => "PKTransitTypeTrain",
45        }
46    }
47}
48
49/// Field alignment hint surfaced to the wallet renderer. Maps directly to Apple's
50/// `PKTextAlignment*` values; Google ignores it.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum FieldAlignment {
53    Left,
54    Center,
55    Right,
56    Natural,
57}
58
59/// Foreground colour derivation mode. [`TextColorMode::Auto`] uses BT.601 luminance
60/// (see [`auto_foreground`]). [`TextColorMode::Light`] / [`TextColorMode::Dark`]
61/// force white / black respectively.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum TextColorMode {
64    Auto,
65    Light,
66    Dark,
67}
68
69/// A single labelled value on a pass (primary, secondary, auxiliary, or back row).
70#[derive(Debug, Clone)]
71pub struct Field {
72    pub key: String,
73    pub label: String,
74    pub value: String,
75    pub alignment: FieldAlignment,
76}
77
78/// Visual + identity branding applied to the pass. All image fields are raw PNG bytes —
79/// the `images` module produces them in the resolutions Apple / Google require.
80#[derive(Debug, Clone)]
81pub struct Branding {
82    pub organization_name: Option<String>,
83    pub logo_text: Option<String>,
84    pub background_color: RgbColor,
85    pub text_color_mode: TextColorMode,
86    pub logo_png_bytes: Option<Vec<u8>>,
87    pub icon_png_bytes: Option<Vec<u8>>,
88    pub hero_png_bytes: Option<Vec<u8>>,
89}
90
91/// Optional geographic relevance hint — Apple surfaces the pass on the lock screen when
92/// the device is near the coordinate.
93#[derive(Debug, Clone)]
94pub struct GeoPoint {
95    pub latitude: f64,
96    pub longitude: f64,
97    pub relevant_text: Option<String>,
98}
99
100/// 24-bit RGB colour. Constructed from a `#RRGGBB` hex string via [`RgbColor::from_hex`].
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub struct RgbColor {
103    pub r: u8,
104    pub g: u8,
105    pub b: u8,
106}
107
108impl RgbColor {
109    /// Parse a 6-digit hex colour. Accepts `"#RRGGBB"` or `"RRGGBB"`. Case-insensitive.
110    /// Short forms (`#fff`) and 8-digit forms (`#RRGGBBAA`) are rejected.
111    pub fn from_hex(s: &str) -> Result<Self, WalletError> {
112        let hex = s.strip_prefix('#').unwrap_or(s);
113        if hex.len() != 6 {
114            return Err(WalletError::InvalidInput(format!(
115                "rgb hex must be 6 chars (with optional leading '#'): got {s:?}"
116            )));
117        }
118        let parse = |range: std::ops::Range<usize>| -> Result<u8, WalletError> {
119            u8::from_str_radix(&hex[range], 16)
120                .map_err(|e| WalletError::InvalidInput(format!("rgb hex parse: {e}")))
121        };
122        Ok(RgbColor {
123            r: parse(0..2)?,
124            g: parse(2..4)?,
125            b: parse(4..6)?,
126        })
127    }
128
129    /// CSS-style `rgb(r,g,b)` literal — used by Apple `pass.json` colour fields.
130    pub fn css_rgb(&self) -> String {
131        format!("rgb({},{},{})", self.r, self.g, self.b)
132    }
133}
134
135/// Derives a readable foreground colour from a background using ITU-R BT.601 luminance
136/// (D-06). Normalised luminance `Y = 0.299*R + 0.587*G + 0.114*B`, all channels in `0..1`.
137///
138/// - `Y < 0.5` ⇒ white `rgb(255,255,255)`
139/// - `Y >= 0.5` ⇒ dark slate `rgb(17,24,39)`
140///
141/// The mid-grey case `rgb(128,128,128)` resolves to dark slate (luminance ≈ 0.502).
142pub fn auto_foreground(bg: RgbColor) -> RgbColor {
143    let r = bg.r as f64 / 255.0;
144    let g = bg.g as f64 / 255.0;
145    let b = bg.b as f64 / 255.0;
146    let lum = 0.299 * r + 0.587 * g + 0.114 * b;
147    if lum < 0.5 {
148        RgbColor {
149            r: 255,
150            g: 255,
151            b: 255,
152        }
153    } else {
154        RgbColor {
155            r: 17,
156            g: 24,
157            b: 39,
158        }
159    }
160}
161
162/// The content contract every domain object implements to be issued as a wallet pass.
163///
164/// Implementors describe what the pass _means_ (identity, fields, branding, barcode,
165/// timing). The builders ([`crate::apple::ApplePassBuilder`],
166/// [`crate::google::GoogleWalletBuilder`]) translate this into the appropriate wire
167/// format.
168pub trait WalletSubject {
169    /// Top-level pass category.
170    fn pass_kind(&self) -> PassKind;
171
172    /// Unique-per-pass identifier (becomes Apple `serialNumber` and the suffix of the
173    /// Google `object.id`).
174    fn serial(&self) -> String;
175
176    /// The primary field row. `eventTicket` shows the first entry as the
177    /// large headline; `boardingPass` expects two entries (origin/destination)
178    /// with Apple's transit arrow rendered between them. Return at most two
179    /// fields — extra entries are silently ignored by Apple's renderer.
180    fn primary(&self) -> Vec<Field>;
181
182    /// Secondary row — usually 1–4 fields directly under the primary.
183    fn secondary(&self) -> Vec<Field>;
184
185    /// Auxiliary row — additional context fields below secondary.
186    fn auxiliary(&self) -> Vec<Field>;
187
188    /// Back-of-pass fields — long-form details exposed when the user flips the pass.
189    fn back(&self) -> Vec<Field>;
190
191    /// Opaque token encoded into the pass's QR / barcode.
192    fn barcode_token(&self) -> String;
193
194    /// Optional timestamp at which the pass is most relevant (Apple surfaces it on the
195    /// lock screen near this time).
196    fn relevant_at(&self) -> Option<DateTime<Utc>>;
197
198    /// Optional expiry timestamp.
199    fn expires_at(&self) -> Option<DateTime<Utc>>;
200
201    /// Optional geographic relevance hints.
202    fn locations(&self) -> Vec<GeoPoint>;
203
204    /// Visual + identity branding.
205    fn branding(&self) -> Branding;
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn rgb_from_hex() {
214        assert_eq!(
215            RgbColor::from_hex("#ffffff").unwrap(),
216            RgbColor {
217                r: 255,
218                g: 255,
219                b: 255
220            }
221        );
222        assert_eq!(
223            RgbColor::from_hex("000000").unwrap(),
224            RgbColor { r: 0, g: 0, b: 0 }
225        );
226        assert_eq!(
227            RgbColor::from_hex("#FF8000").unwrap(),
228            RgbColor {
229                r: 255,
230                g: 128,
231                b: 0
232            }
233        );
234        // Mixed case accepted.
235        assert_eq!(
236            RgbColor::from_hex("#aAbBcC").unwrap(),
237            RgbColor {
238                r: 0xaa,
239                g: 0xbb,
240                b: 0xcc
241            }
242        );
243    }
244
245    #[test]
246    fn rgb_from_hex_rejects_malformed() {
247        // Free-form non-hex string.
248        let err = RgbColor::from_hex("not-a-color").unwrap_err();
249        assert!(matches!(err, WalletError::InvalidInput(_)));
250
251        // 3-digit short form not supported.
252        let err = RgbColor::from_hex("#fff").unwrap_err();
253        assert!(matches!(err, WalletError::InvalidInput(_)));
254
255        // 7 chars total (one too many).
256        let err = RgbColor::from_hex("#fffffff").unwrap_err();
257        assert!(matches!(err, WalletError::InvalidInput(_)));
258
259        // 6 chars but non-hex.
260        let err = RgbColor::from_hex("#zzzzzz").unwrap_err();
261        assert!(matches!(err, WalletError::InvalidInput(_)));
262
263        // Empty.
264        let err = RgbColor::from_hex("").unwrap_err();
265        assert!(matches!(err, WalletError::InvalidInput(_)));
266    }
267
268    #[test]
269    fn auto_foreground_dark_bg_is_white() {
270        assert_eq!(
271            auto_foreground(RgbColor { r: 0, g: 0, b: 0 }),
272            RgbColor {
273                r: 255,
274                g: 255,
275                b: 255
276            }
277        );
278    }
279
280    #[test]
281    fn auto_foreground_light_bg_is_dark_slate() {
282        assert_eq!(
283            auto_foreground(RgbColor {
284                r: 255,
285                g: 255,
286                b: 255
287            }),
288            RgbColor {
289                r: 17,
290                g: 24,
291                b: 39
292            }
293        );
294    }
295
296    #[test]
297    fn rgb_css_rgb_format() {
298        assert_eq!(
299            RgbColor {
300                r: 17,
301                g: 24,
302                b: 39
303            }
304            .css_rgb(),
305            "rgb(17,24,39)"
306        );
307    }
308}