Replaces the scrolling stats-card layout with the full-bleed Map HUD: UI-04's draggable/resizable HUD widgets float over UI-01's shared background map (full opacity on this tab), and the old Pause/Stop/Resume/ Discard buttons become full-bleed, icon-only segments matching the mockup's tertiary-container/error-container colors exactly. The idle state keeps its prior compact layout deliberately -- HUD widgets only appear once a ride exists, preserving the existing "a resting screen must not look like a ride going nowhere" guarantee rather than reinterpreting it. State-machine logic (ticker, wakelock, speed subscription, error handling) is untouched; every pre-existing record-screen test passed unchanged against the rebuilt screen. Added tests that actually tap Start/Pause/Stop and verify engine state changes, confirm HUD widgets render over the map rather than replacing it, and verify the mounted-mode speed digit's real contrast ratio against GlassPanel's translucent surface specifically (the ticket's own named risk). Corrects a UI-04 mistake found while implementing this ticket: the HUD's default four metrics were ordered Speed/Distance/Elapsed/Max Speed, a guess made before reading the actual Map HUD mockup HTML closely. The real fixed row is Speed/Avg Speed/Dist/Time -- reordered HudMetric to match and updated every test that asserted the old order. Adds PulsingLocationMarker (UI-03) to RideMap's live usage via a new showLocationMarker flag, and explicit tertiaryContainer/errorContainer tokens to ripprColors so the control bar matches the design system's literal values rather than an auto-derived tonal palette. Verified end-to-end on a real emulator: Start, Pause, Resume, and Stop all correctly drive the trip state machine with the full live map behind everything. A lengthy false alarm during this verification (taps appearing to do nothing) turned out to be a screenshot-scale mis-measurement on the verification side, not an app defect -- resolved by sampling pixel colors directly from the raw screenshot to find the control bar's true on-screen position. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
200 lines
8.8 KiB
Dart
200 lines
8.8 KiB
Dart
/// 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()],
|
|
);
|