/// Ported from `com.rippr.ui.theme.RipprTheme`. /// /// Dark-only by design for a pocketed ride -- read at a glance, in a pocket-glance, at /// night as much as in daylight. V3-05 adds [ripprMountedTheme], a deliberate exception /// for the one screen that is actually looked at continuously, in direct daylight, behind /// a visor -- where the dark ground loses to glare instead of helping. library; import 'package:flutter/material.dart'; // UI-08: "Modern Professional Dark", the design system actually backing every fetched // Stitch mockup (confirmed by hex-matching, not by trusting the project's declared // default theme — see docs/design/stitch-export/README.md). This supersedes V3-16's // safety-orange identity outright, not an extension of it. Full spec: // docs/design/stitch-export/design-system-modern-professional-dark.md. // // `surface-main` (#0a0a0c), not the spec's plain `background`/`surface` (#131313), is // used as the app ground: the spec's own prose calls out #0a0a0c as *the* background, // with #131313 one step up for a mid-level surface and #16161a another step up for cards // — a three-step stack, not two. const _ground = Color(0xFF0A0A0C); const _surfaceMid = Color(0xFF131313); const _ink = Color(0xFFE5E2E1); const _outline = Color(0xFF8B919F); const _danger = Color(0xFFFFB4AB); /// Professional Blue. The soul of the interface per the design system's own doc — /// primary buttons, the active nav indicator, the user's own location dot, and (via /// `RideMap`'s speed gradient) the accent end of the live-path colouring. Replaces /// safety-orange as *the* accent everywhere; the app's existing convention of leaving /// headline figures (`BigStat`) in plain ink and reserving colour for `StatRow`'s /// `reference` values is unchanged by this ticket. const _primary = Color(0xFF4090FE); /// UI-08 decision (the design system doesn't prescribe this split itself): reference /// readings (a max, an average) get the warm tertiary orange, never the live figure, /// which stays [_primary]. Inverted from V3-16 (blue was the reference colour there) /// because blue is now the primary/live colour — a same-hue reference tone would have /// been visually indistinguishable from the live one it exists to be told apart from. const _referenceOrange = Color(0xFFFFB68C); const ripprColors = ColorScheme.dark( primary: _primary, onPrimary: Color(0xFF002F64), secondary: Color(0xFFAEC7F6), tertiary: _referenceOrange, onTertiary: Color(0xFF532200), // UI-05: explicit rather than left to `ColorScheme.dark`'s own M3 tonal-palette // derivation -- the Map HUD's Pause/Stop control bar needs to match the design // system's literal `tertiary-container`/`error-container` tokens exactly, not // whatever a generated tonal palette happens to compute from `tertiary`/`error` alone. tertiaryContainer: Color(0xFFE3711F), onTertiaryContainer: Color(0xFF481D00), surface: _surfaceMid, onSurface: _ink, surfaceContainerHighest: Color(0xFF353534), onSurfaceVariant: Color(0xFFC1C6D5), outline: _outline, // UI-03: the design system's own `outline-variant` token -- a subtler line than // `outline` itself, for a `GlassPanel`'s 1px border and a `FloatingPill`'s dividers, // where a full-strength outline colour would read as a hard edge rather than a // barely-there seam between tonal layers. outlineVariant: Color(0xFF414753), error: _danger, onError: Color(0xFF690005), errorContainer: Color(0xFF93000A), onErrorContainer: Color(0xFFFFDAD6), ); /// The near-black ground the whole app sits on. const ripprBackground = _ground; /// Cards and modals — one step up from [ripprColors]'s `surface`, per the design /// system's three-level stack ("Surface Strategy": base darkest, cards a step lighter). /// Exposed separately because `ColorScheme` has no fourth surface slot for it that isn't /// already claimed by `surfaceContainerHighest` (reserved here for the selected/ /// highlighted state, not the resting card colour). const ripprCardColor = Color(0xFF16161A); /// Shape spec: "Rounded (Level 2)" — small components (buttons, inputs) get 8px, large /// containers (cards, modals) get 16px. Two constants, not a single "roundness" figure, /// because the spec explicitly gives them different values. const double ripprRadiusSmall = 8.0; const double ripprRadiusLarge = 16.0; ThemeData ripprTheme() { final base = ThemeData( colorScheme: ripprColors, useMaterial3: true, scaffoldBackgroundColor: _ground, ); return base.copyWith( // Compose's `Surface` was load-bearing, not decoration: it set `LocalContentColor`. // Without it, any Text that did not name a colour fell back to black — invisible on // this near-black ground, and **no test caught it; only a screenshot did**. // // Flutter's equivalent exposure is the default text colour. Setting it explicitly // here, rather than relying on a wrapping widget, means the failure cannot recur by // someone removing a container. // UI-08: the design system specifies Inter exclusively. Not switched to here -- // naming a fontFamily with no matching asset bundled would silently fall back to // the platform default (Roboto), the exact class of invisible failure the bodyColor // fix below exists to prevent. Bundling the actual font files (or a vetted offline // build of the google_fonts package) is left as a deliberate follow-up rather than // shipping a fontFamily string that doesn't resolve to anything. textTheme: base.textTheme.apply(bodyColor: _ink, displayColor: _ink), appBarTheme: const AppBarTheme( backgroundColor: _ground, foregroundColor: _ink, elevation: 0, ), cardTheme: const CardThemeData( color: ripprCardColor, shape: RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(ripprRadiusLarge)), ), ), dividerTheme: const DividerThemeData(color: Color(0xFF2D3037), space: 1), ); } // V3-05: mounted colours. Near-white ground, near-black ink -- the inverse tradeoff from // the pocketed theme above. Read against a visor in direct daylight, a dark ground loses // to glare in a way a dark theme never has to answer for at a pocket glance. const _mountedGround = Color(0xFFF5F3EF); const _mountedInk = Color(0xFF12100D); const _mountedAccent = Color(0xFFD84315); // V3-16: the same instrument/reference split as the pocketed theme, darkened enough to // hold WCAG AA against a near-white ground -- the mounted theme's own reason to exist // (sunlight, a visor) is not touched by this, only extended. const _mountedInstrumentBlue = Color(0xFF01579B); const ripprMountedColors = ColorScheme.light( primary: _mountedAccent, onPrimary: Color(0xFFFFFFFF), secondary: Color(0xFF4A4034), tertiary: _mountedInstrumentBlue, onTertiary: Color(0xFFFFFFFF), surface: Color(0xFFFFFFFF), onSurface: _mountedInk, surfaceContainerHighest: Color(0xFFE7E2D9), onSurfaceVariant: _mountedInk, outline: Color(0xFF706A5E), error: Color(0xFFB3261E), onError: Color(0xFFFFFFFF), ); const ripprMountedBackground = _mountedGround; /// Scales body/headline text up for legibility at a glance, at speed, behind a visor. /// Kept separate from V3-16's visual identity work -- this is a stopgap born of a real /// constraint, not a design pass. const double mountedTextScale = 1.25; /// V3-05's mounted mode: high contrast, larger text. Only applied to the record screen /// while mounted, not the whole app -- everywhere else is still read at a pocket glance. ThemeData ripprMountedTheme() { final base = ThemeData( colorScheme: ripprMountedColors, useMaterial3: true, scaffoldBackgroundColor: _mountedGround, ); return base.copyWith( textTheme: base.textTheme.apply( bodyColor: _mountedInk, displayColor: _mountedInk, ), appBarTheme: const AppBarTheme( backgroundColor: _mountedGround, foregroundColor: _mountedInk, elevation: 0, ), dividerTheme: const DividerThemeData(color: Color(0xFFDAD4C8), space: 1), ); } /// WCAG 2.x's contrast ratio: `(L1 + 0.05) / (L2 + 0.05)`, `L1` the lighter of the two /// relative luminances. AA requires 4.5:1 for normal text, 3:1 for large text (18pt, or /// 14pt bold) -- see the tests in `theme_test.dart` for V3-16's acceptance criterion, /// which asserts this directly rather than trusting a palette pick by eye. double contrastRatio(Color a, Color b) { final la = a.computeLuminance(); final lb = b.computeLuminance(); final lighter = la > lb ? la : lb; final darker = la > lb ? lb : la; return (lighter + 0.05) / (darker + 0.05); } /// Monospace for anything numeric. /// /// Digits that change every second must not make the layout jitter, which proportional /// figures do. Used for every speed, distance and clock in the app. const TextStyle monoDigits = TextStyle( fontFamily: 'monospace', fontFeatures: [FontFeature.tabularFigures()], );