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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
This commit is contained in:
2026-08-23 20:22:19 -05:00
parent 10040e6985
commit 24f4e17e81
7 changed files with 494 additions and 2 deletions

View File

@@ -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<BackdropFilter>(find.byType(BackdropFilter));
expect(backdrop.filter, isA<ImageFilter>());
final box = tester.widget<DecoratedBox>(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<void> 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<RenderParagraph>(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<Container>(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);
});
});
}