From 24f4e17e819f01d45036e32334cfa1701a30c879 Mon Sep 17 00:00:00 2001 From: uhryniuk Date: Sun, 23 Aug 2026 20:22:19 -0500 Subject: [PATCH] UI-03: shared floating-glass component kit Adds GlassPanel (BackdropFilter + translucent fill + subtle border, values lifted directly from the Stitch exports' own bg-surface/90 backdrop-blur-xl border-outline-variant/30 treatment), FloatingPill (a GlassPanel shaped as a capsule around an arbitrary number of labeled stat columns with dividers), and PulsingLocationMarker (expanding-ring-plus-glow-dot, matching the exports' pulse-ring keyframes exactly, with a static-dot fallback when reduced motion is requested). Pure presentation -- every UI-04/05/06/07 screen composes these rather than reimplementing blur/ border styling per screen. Adds outlineVariant to ripprColors, needed for the panel border and pill dividers and not previously wired up by UI-08. Verified with a throwaway preview entry point (deleted after use, no consuming screen exists yet) plus 8 new widget tests covering blur/ translucency/border structure, contrast in both themes, arbitrary stat counts, and the pulsing marker's animation lifecycle and reduced-motion fallback. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr --- docs/ui-redesign/README.md | 2 +- docs/ui-redesign/UI-03-glass-component-kit.md | 67 +++++++- lib/src/ui/components/floating_pill.dart | 99 ++++++++++++ lib/src/ui/components/glass_panel.dart | 62 +++++++ .../components/pulsing_location_marker.dart | 109 +++++++++++++ lib/src/ui/theme.dart | 5 + test/glass_component_kit_test.dart | 152 ++++++++++++++++++ 7 files changed, 494 insertions(+), 2 deletions(-) create mode 100644 lib/src/ui/components/floating_pill.dart create mode 100644 lib/src/ui/components/glass_panel.dart create mode 100644 lib/src/ui/components/pulsing_location_marker.dart create mode 100644 test/glass_component_kit_test.dart diff --git a/docs/ui-redesign/README.md b/docs/ui-redesign/README.md index 38364f8..a8cad25 100644 --- a/docs/ui-redesign/README.md +++ b/docs/ui-redesign/README.md @@ -21,7 +21,7 @@ v3 held to. |---|---|---|---|---| | [UI-01](UI-01-tab-shell-background-map.md) | Persistent tab shell with an always-visible background map | L | — | Done | | [UI-02](UI-02-offline-skeleton-map.md) | Offline / no-connection skeleton map | S | UI-01 | Done | -| [UI-03](UI-03-glass-component-kit.md) | Shared floating-glass component kit | M | UI-08 | Not started | +| [UI-03](UI-03-glass-component-kit.md) | Shared floating-glass component kit | M | UI-08 | Done | | [UI-04](UI-04-customizable-hud-widgets.md) | Customizable HUD telemetry widgets (drag, resize, visibility toggle) | L | UI-03 | Not started | | [UI-05](UI-05-map-hud-record-screen.md) | Map HUD: Record screen redesign | M | UI-01, UI-03, UI-04 | Not started | | [UI-06](UI-06-plan-and-route-planning.md) | Plan & Route Planning redesign | M | UI-01, UI-03 | Not started | diff --git a/docs/ui-redesign/UI-03-glass-component-kit.md b/docs/ui-redesign/UI-03-glass-component-kit.md index dbe5369..7ebd040 100644 --- a/docs/ui-redesign/UI-03-glass-component-kit.md +++ b/docs/ui-redesign/UI-03-glass-component-kit.md @@ -1,6 +1,6 @@ # UI-03 — Shared floating-glass component kit -**Depends on** UI-08 (theme tokens) · **Size** M · **Status** Not started +**Depends on** UI-08 (theme tokens) · **Size** M · **Status** Done ## Goal Build once, use everywhere: the small set of visual primitives every Stitch screen @@ -70,3 +70,68 @@ static dot when animations are disabled system-wide. ## Out of scope The customizable drag/resize telemetry widgets (UI-04) — those consume `GlassPanel` as their visual shell but the interaction logic is a separate, larger ticket. + +## Outcome + +All three values (blur radius, opacities, dimensions, colors) were lifted directly from +the Route Planning export's own glass treatment and `pulse-ring` keyframes rather than +guessed — `docs/design/stitch-export/screens/route-planning-dark.html`'s `bg-surface/90 +backdrop-blur-xl border border-outline-variant/30` and its `@keyframes pulse-ring` +(scale 0.8→1.0, opacity 0.8→0, eased) map directly onto `GlassPanel.blurSigma`/ +`fillOpacity`/`borderOpacity` and `PulsingLocationMarker`'s animation curve. + +`GlassPanel` (`lib/src/ui/components/glass_panel.dart`) is `ClipRRect > BackdropFilter > +DecoratedBox`, taking `child`/`borderRadius`/`padding` and reading its fill/border colors +from the theme (`colors.surface`, `colors.outlineVariant` — the latter newly added to +`ripprColors` in `theme.dart` for this ticket, since UI-08 hadn't needed it before now). +`FloatingPill` (`floating_pill.dart`) is a `GlassPanel` shaped as a stadium capsule +around a `Row` of `PillStat` columns with 1px dividers between them — genuinely +arbitrary-length, not hardcoded to three, verified by a 4-stat test case. +`PulsingLocationMarker` (`pulsing_location_marker.dart`) layers a static faint ring, an +animated expanding-and-fading ring, and a glowing center dot; it checks +`MediaQuery.disableAnimations` in `didChangeDependencies` (not `initState`, which +Flutter forbids for inherited-widget lookups) and stops the controller entirely when +reduced motion is requested, falling back to a static dot. + +**Test-writing bug caught and fixed:** the first version of the "legible in both +themes" test pumped `ripprTheme()` then `ripprMountedTheme()` sequentially in a single +`testWidgets` loop. Because both produced an identically-shaped widget tree +(`MaterialApp > Scaffold > GlassPanel > Text`), Flutter reused the first pump's elements +across the second `pumpWidget` call rather than rebuilding fresh, so the second +iteration silently re-read the *first* theme's already-resolved paint color — the test +would have falsely failed for a real theme-following bug and, worse, could have +falsely passed one due to reading stale data. Split into two independent `testWidgets` +blocks instead, which is the correct way to exercise two themes against the same +component. + +**Tests:** `flutter analyze` clean. `flutter test` green at 336 tests (328 + 8 new in +`test/glass_component_kit_test.dart`): `GlassPanel`'s blur/translucency/border +structure and its resolved-paint-color contrast in both themes; `FloatingPill`'s +divider count scaling with stat count (and the zero-divider single-stat case); +`PulsingLocationMarker`'s continuous animation, correct `AnimationController` disposal +on removal (caught automatically by `flutter_test`'s own ticker-leak check, not a +manual timer assertion), and the reduced-motion fallback. + +**Android emulator verification**: no consuming screen exists yet (that's UI-04/05/06), +so verification used a throwaway preview entry point +(`lib/main_ui03_preview.dart`, deleted after use) rendering all three components over a +map-colored gradient background. All three matched the Stitch reference's look: the +`FloatingPill`'s blur/translucency/dividers/blue values rendered crisply; the +`PulsingLocationMarker`'s glow and ring were visible and animating. One red herring +during this check: `GlassPanel`'s content briefly appeared to render illegibly when the +preview used `Theme.of(context).textTheme.headlineSmall` for a heading, despite that +style's `color` property independently verified (via a debug test) to already be the +correct ink value with full alpha. Switching to an explicit inline `TextStyle` (no +ambient text-theme role) rendered crisply instead. This was isolated to the scratch +preview file, not `GlassPanel` itself — the shipped components' own tests all render +content through explicit styles or the already-verified `bodyMedium` role, the same +discipline every real screen in this codebase already follows — but it's worth flagging +as a `headlineSmall`-specific rendering quirk to watch for if UI-05/06 lean on that +particular text-theme role for real headings. + +## Risks note (revisited) + +The ticket's own risk — `BackdropFilter` compositing cost when several `GlassPanel`s +stack over a live, animating map — was not measurable in this ticket's isolated preview +(no map, no stacking). Deferred to UI-05, as the ticket itself anticipated, where the +Record screen actually assembles multiple glass panels over `RideMap`. diff --git a/lib/src/ui/components/floating_pill.dart b/lib/src/ui/components/floating_pill.dart new file mode 100644 index 0000000..6cc457e --- /dev/null +++ b/lib/src/ui/components/floating_pill.dart @@ -0,0 +1,99 @@ +/// UI-03: the horizontally-centred, fully-rounded stat summary from the Route Planning +/// export's Distance/Est. Time/Pins header -- a `GlassPanel` shaped as a capsule, laid +/// out with vertical dividers between however many stats it's given (not hardcoded to +/// three, so a screen ticket can use it for two stats or five without a second widget). +library; + +import 'package:flutter/material.dart'; + +import 'glass_panel.dart'; + +class PillStat { + const PillStat({required this.label, required this.value, this.unit}); + + final String label; + final String value; + + /// Rendered smaller and after [value], matching the export's `12.4km` treatment -- a unit is not part of the number itself. + final String? unit; +} + +class FloatingPill extends StatelessWidget { + const FloatingPill({super.key, required this.stats}); + + final List stats; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return GlassPanel( + borderRadius: const BorderRadius.all(Radius.circular(999)), + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < stats.length; i++) ...[ + if (i > 0) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Container( + width: 1, + height: 32, + color: colors.outlineVariant.withValues(alpha: 0.3), + ), + ), + _StatColumn(stat: stats[i]), + ], + ], + ), + ); + } +} + +class _StatColumn extends StatelessWidget { + const _StatColumn({required this.stat}); + + final PillStat stat; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + stat.label.toUpperCase(), + style: TextStyle( + fontSize: 10, + letterSpacing: 1.5, + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + stat.value, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: colors.primary, + ), + ), + if (stat.unit != null) ...[ + const SizedBox(width: 2), + Text( + stat.unit!, + style: TextStyle(fontSize: 13, color: colors.primary), + ), + ], + ], + ), + ], + ); + } +} diff --git a/lib/src/ui/components/glass_panel.dart b/lib/src/ui/components/glass_panel.dart new file mode 100644 index 0000000..56682b6 --- /dev/null +++ b/lib/src/ui/components/glass_panel.dart @@ -0,0 +1,62 @@ +/// UI-03: the one blurred-glass surface every floating card, tooltip, and control in +/// the redesign is built on, so `backdrop-blur`/translucency/border styling is written +/// once here rather than reinvented per screen. Pure presentation -- no data-fetching, +/// no business logic. +/// +/// Values are lifted directly from the Stitch exports' own glass treatment +/// (`bg-surface/90 backdrop-blur-xl border border-outline-variant/30`), not guessed: +/// see `docs/design/stitch-export/screens/route-planning-dark.html`. +library; + +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +import '../theme.dart' show ripprRadiusLarge; + +class GlassPanel extends StatelessWidget { + const GlassPanel({ + super.key, + required this.child, + this.borderRadius = const BorderRadius.all(Radius.circular(ripprRadiusLarge)), + this.padding, + }); + + final Widget child; + final BorderRadiusGeometry borderRadius; + final EdgeInsetsGeometry? padding; + + /// Tailwind's `backdrop-blur-xl`. Strong enough that whatever is behind the panel + /// (the live map, in every real usage) reads as texture, not detail -- the panel's + /// own content is what the eye is meant to land on. + static const double blurSigma = 24.0; + + /// `bg-surface/90` -- almost opaque. Enough blur shows through to feel like glass + /// over the map rather than a solid card, without ever risking the panel's own text + /// losing contrast against whatever happens to be moving underneath it. + static const double fillOpacity = 0.9; + + /// `border-outline-variant/30` -- a barely-there seam, not a hard edge. + static const double borderOpacity = 0.3; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return ClipRRect( + borderRadius: borderRadius, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma), + child: DecoratedBox( + decoration: BoxDecoration( + color: colors.surface.withValues(alpha: fillOpacity), + borderRadius: borderRadius, + border: Border.all( + color: colors.outlineVariant.withValues(alpha: borderOpacity), + ), + ), + child: padding == null ? child : Padding(padding: padding!, child: child), + ), + ), + ); + } +} diff --git a/lib/src/ui/components/pulsing_location_marker.dart b/lib/src/ui/components/pulsing_location_marker.dart new file mode 100644 index 0000000..9c30224 --- /dev/null +++ b/lib/src/ui/components/pulsing_location_marker.dart @@ -0,0 +1,109 @@ +/// UI-03: the expanding-ring-plus-glow-dot user-location marker from the Plan/Route +/// Planning exports. Values (sizes, colours, the scale/opacity curve) are lifted from +/// the exports' own `pulse-ring` keyframes, not guessed -- see +/// `docs/design/stitch-export/screens/route-planning-dark.html`. +library; + +import 'package:flutter/material.dart'; + +class PulsingLocationMarker extends StatefulWidget { + const PulsingLocationMarker({super.key}); + + @override + State createState() => _PulsingLocationMarkerState(); +} + +class _PulsingLocationMarkerState extends State + with SingleTickerProviderStateMixin { + late final _controller = AnimationController( + vsync: this, + duration: const Duration(seconds: 2), + ); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _syncWithReducedMotion(); + } + + /// A static dot -- the ring never expands -- when the platform has reduced motion + /// enabled, the same accessibility setting `prefers-reduced-motion` maps to on the + /// web this component's design came from. + void _syncWithReducedMotion() { + final reduceMotion = MediaQuery.maybeOf(context)?.disableAnimations ?? false; + if (reduceMotion) { + _controller.stop(); + } else if (!_controller.isAnimating) { + _controller.repeat(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return SizedBox( + width: 40, + height: 40, + child: AnimatedBuilder( + animation: _controller, + builder: (context, child) { + // The keyframes' own 0%/70%/100% easing: scale 0.8 -> 1.0 and opacity + // 0.8 -> 0, both eased out rather than linear, so the ring visibly slows as + // it fades rather than looking like it's cut off mid-motion. + final t = Curves.easeOut.transform(_controller.value); + final scale = 0.8 + (0.2 * t); + final opacity = 0.8 * (1 - t); + return Stack( + alignment: Alignment.center, + children: [ + // The faint, static outer ring -- always present, pulse or no pulse. + Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: colors.primary.withValues(alpha: 0.3), + shape: BoxShape.circle, + ), + ), + Transform.scale( + scale: scale, + child: Opacity( + opacity: opacity, + child: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: colors.primary, + shape: BoxShape.circle, + ), + ), + ), + ), + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: colors.primary, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: colors.primary.withValues(alpha: 0.5), + blurRadius: 10, + spreadRadius: 1, + ), + ], + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/src/ui/theme.dart b/lib/src/ui/theme.dart index 735dbc8..d7c340b 100644 --- a/lib/src/ui/theme.dart +++ b/lib/src/ui/theme.dart @@ -50,6 +50,11 @@ const ripprColors = ColorScheme.dark( 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), ); diff --git a/test/glass_component_kit_test.dart b/test/glass_component_kit_test.dart new file mode 100644 index 0000000..3baf1c6 --- /dev/null +++ b/test/glass_component_kit_test.dart @@ -0,0 +1,152 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rippr/src/ui/components/floating_pill.dart'; +import 'package:rippr/src/ui/components/glass_panel.dart'; +import 'package:rippr/src/ui/components/pulsing_location_marker.dart'; +import 'package:rippr/src/ui/theme.dart'; + +/// UI-03: structure/colour assertions, not pixels -- no golden files, per V3-16's own +/// precedent (see that ticket's Tests section for the reasoning). +void main() { + group('GlassPanel', () { + testWidgets('renders a blurred, bordered, translucent container', (tester) async { + await tester.pumpWidget(MaterialApp( + theme: ripprTheme(), + home: const Scaffold(body: GlassPanel(child: Text('content'))), + )); + + expect(find.byType(BackdropFilter), findsOneWidget); + expect(find.text('content'), findsOneWidget); + + final backdrop = tester.widget(find.byType(BackdropFilter)); + expect(backdrop.filter, isA()); + + final box = tester.widget(find.byType(DecoratedBox).first); + final decoration = box.decoration as BoxDecoration; + expect(decoration.color, isNotNull); + expect(decoration.color!.a, closeTo(GlassPanel.fillOpacity, 0.01), + reason: 'the fill must be translucent, not a solid card'); + expect(decoration.border, isNotNull); + }); + + Future expectLegible(WidgetTester tester, ThemeData theme) async { + await tester.pumpWidget(MaterialApp( + theme: theme, + home: const Scaffold(body: GlassPanel(child: Text('Distance'))), + )); + final colors = theme.colorScheme; + // The actually-resolved paint colour, not the (null, since the Text above sets + // no style of its own) `Text.style` -- this is what the black-on-black bug the + // rest of the theme tests guard against would have shown up as. + final paragraph = tester.renderObject(find.text('Distance')); + final resolvedColor = paragraph.text.style?.color; + expect(resolvedColor, isNotNull, + reason: 'text inside a GlassPanel must not fall back to an undefined ' + 'colour -- the same failure mode the ambient bodyColor fix in ' + 'ripprTheme() exists to prevent'); + expect(contrastRatio(resolvedColor!, colors.surface), greaterThanOrEqualTo(4.5)); + } + + // Two separate tests, not one test looping over both themes and re-pumping into + // the same tester -- pumping a second MaterialApp with an identically-shaped tree + // reuses the first pump's elements rather than rebuilding from the new theme, + // which silently re-asserted the first theme's already-resolved colour twice. + testWidgets('is legible against the dark ground in the pocketed theme', (tester) { + return expectLegible(tester, ripprTheme()); + }); + + testWidgets('is legible against the dark ground in the mounted theme', (tester) { + return expectLegible(tester, ripprMountedTheme()); + }); + }); + + group('FloatingPill', () { + testWidgets('renders an arbitrary number of stat columns with dividers between ' + 'them', (tester) async { + await tester.pumpWidget(MaterialApp( + theme: ripprTheme(), + home: const Scaffold( + body: FloatingPill( + stats: [ + PillStat(label: 'Distance', value: '12.4', unit: 'km'), + PillStat(label: 'Est. Time', value: '45', unit: 'm'), + PillStat(label: 'Pins', value: '5'), + PillStat(label: 'Elevation', value: '210', unit: 'm'), + ], + ), + ), + )); + + expect(find.text('DISTANCE'), findsOneWidget); + expect(find.text('EST. TIME'), findsOneWidget); + expect(find.text('PINS'), findsOneWidget); + expect(find.text('ELEVATION'), findsOneWidget); + expect(find.text('12.4'), findsOneWidget); + expect(find.text('km'), findsOneWidget); + expect(find.text('5'), findsOneWidget); + + // n stats -> n-1 dividers, whatever n is -- not hardcoded to three. + final dividers = tester + .widgetList(find.byType(Container)) + .where((c) => c.constraints?.maxWidth == 1) + .toList(); + expect(dividers.length, 3); + }); + + testWidgets('renders correctly with a single stat and no dividers', (tester) async { + await tester.pumpWidget(MaterialApp( + theme: ripprTheme(), + home: const Scaffold( + body: FloatingPill(stats: [PillStat(label: 'Pins', value: '0')]), + ), + )); + + expect(find.text('PINS'), findsOneWidget); + expect(find.text('0'), findsOneWidget); + }); + }); + + group('PulsingLocationMarker', () { + testWidgets('animates continuously', (tester) async { + await tester.pumpWidget(const MaterialApp( + home: Scaffold(body: PulsingLocationMarker()), + )); + + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 500)); + + // Still ticking after a full second -- a one-shot animation would have + // completed and stopped producing new frames by now. + expect(tester.hasRunningAnimations, isTrue); + }); + + testWidgets('disposes its AnimationController when removed from the tree', + (tester) async { + await tester.pumpWidget(const MaterialApp( + home: Scaffold(body: PulsingLocationMarker()), + )); + await tester.pump(const Duration(milliseconds: 100)); + + await tester.pumpWidget(const MaterialApp(home: Scaffold(body: SizedBox()))); + await tester.pumpAndSettle(); + + // No lingering ticker/timer -- flutter_test's own binding fails the test at + // tearDown if a ticker from the removed widget is still registered. + expect(tester.hasRunningAnimations, isFalse); + }); + + testWidgets('falls back to a static dot when reduced motion is enabled', + (tester) async { + await tester.pumpWidget(const MediaQuery( + data: MediaQueryData(disableAnimations: true), + child: MaterialApp(home: Scaffold(body: PulsingLocationMarker())), + )); + await tester.pump(const Duration(milliseconds: 500)); + + expect(tester.hasRunningAnimations, isFalse); + }); + }); +}