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
153 lines
6.1 KiB
Dart
153 lines
6.1 KiB
Dart
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);
|
|
});
|
|
});
|
|
}
|