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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
138 lines
8.3 KiB
Markdown
138 lines
8.3 KiB
Markdown
# UI-03 — Shared floating-glass component kit
|
|
|
|
**Depends on** UI-08 (theme tokens) · **Size** M · **Status** Done
|
|
|
|
## Goal
|
|
Build once, use everywhere: the small set of visual primitives every Stitch screen
|
|
reuses, so UI-04/05/06/07 compose them instead of each reinventing blur-panel styling.
|
|
|
|
## Context
|
|
All four Stitch exports lean on the same handful of visual patterns repeatedly:
|
|
glassmorphic panels (`bg-surface/80 backdrop-blur-xl border border-white/10`), a floating
|
|
centered pill for a compact stat summary, and a pulsing/radar-style location marker.
|
|
Building these once means every consuming screen ticket is "compose these," not
|
|
"reimplement blur and border styling a fourth time."
|
|
|
|
## Design
|
|
Three widgets, all pure presentation (no data-fetching, no business logic):
|
|
|
|
**`GlassPanel`** — the workhorse. `BackdropFilter` + `ImageFilter.blur` inside a
|
|
`ClipRRect`, a translucent surface-color fill, a 1px low-opacity border. Takes a `child`
|
|
and behaves like a styled `Container`. Every floating card, tooltip, and control in the
|
|
redesign is one of these underneath.
|
|
|
|
**`FloatingPill`** — a `GlassPanel` shaped as a horizontally-centered, rounded-full
|
|
capsule holding a row of labeled stat columns (see Route Planning's Distance/Est. Time/
|
|
Pins header). Takes a list of `(label, value)` pairs and lays them out with vertical
|
|
dividers between them, matching the Stitch export exactly.
|
|
|
|
**`PulsingLocationMarker`** — the expanding-ring-plus-glow-dot from the Plan/Route
|
|
Planning exports. A `CustomPainter` or layered `AnimatedContainer`s driving an
|
|
`AnimationController` in a loop (scale 0.8→1.0, opacity 0.8→0, repeating) — respect
|
|
`MediaQuery.disableAnimations`/`prefers-reduced-motion` equivalent by falling back to a
|
|
static dot when animations are disabled system-wide.
|
|
|
|
## Implementation
|
|
1. `lib/src/ui/components/glass_panel.dart` — `GlassPanel`, with blur radius, fill
|
|
opacity, and border opacity as named constants (not magic numbers scattered per call
|
|
site), sourced from UI-08's theme tokens.
|
|
2. `lib/src/ui/components/floating_pill.dart` — `FloatingPill`, built on `GlassPanel`.
|
|
3. `lib/src/ui/components/pulsing_location_marker.dart` — `PulsingLocationMarker`,
|
|
wrapping its `AnimationController` lifecycle correctly (dispose on unmount, same
|
|
discipline as `RideMap`'s existing lifecycle observer from V3-04).
|
|
4. Widget tests render each in isolation against both themes (if UI-08 keeps a light
|
|
variant) to catch a black-on-black-style contrast regression early, the same
|
|
discipline V3-16 established.
|
|
|
|
## Acceptance criteria
|
|
- [ ] `GlassPanel` renders a blurred, bordered, translucent container matching the
|
|
Stitch reference visually
|
|
- [ ] `FloatingPill` renders an arbitrary number of stat columns with dividers between
|
|
them, not hardcoded to exactly three
|
|
- [ ] `PulsingLocationMarker` animates continuously without leaking its
|
|
`AnimationController` across widget rebuilds or disposal
|
|
- [ ] Reduced-motion setting is respected by `PulsingLocationMarker`
|
|
|
|
## Tests
|
|
- Widget: each component renders with representative content and takes a screenshot-
|
|
comparable snapshot of its structure (no golden files per V3-16's precedent — assert
|
|
structure/color, not pixels)
|
|
- Widget: `PulsingLocationMarker`'s animation controller is disposed when the widget is
|
|
removed from the tree (a `flutter_test` pending-timer/ticker check, mirroring the
|
|
Drift stream-query keep-alive pattern already documented in `widget_test.dart`)
|
|
|
|
## Risks
|
|
- `BackdropFilter` is one of the more expensive Flutter widgets to composite; stacking
|
|
several `GlassPanel`s over a live, animating map (per UI-01/UI-02) could visibly cost
|
|
frame time on lower-end devices. Worth a real-device check once UI-05 assembles them
|
|
together, not just in isolation.
|
|
|
|
## 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`.
|