Refresh the Flutter snapshot: UI-01 through UI-09, plus a fresh installable APK

Full UI redesign pass complete: persistent tab shell with an always-visible
background map, Modern Professional Dark theme, monochrome dark map tiles,
offline skeleton map, a shared GlassPanel/FloatingPill component kit,
customizable HUD telemetry widgets, and the Map HUD / Plan & Route Planning /
Rides History screen rebuilds. 374 tests passing, up from 316.

The APK is a fresh release build (debug-signed, no release signing config
exists yet).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
This commit is contained in:
2026-08-24 14:41:35 -05:00
parent 4c634354bd
commit 587f75eab4
42 changed files with 4401 additions and 89 deletions

View File

@@ -1,6 +1,8 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/config/config.dart';
import 'package:rippr/src/domain/models.dart';
import 'package:rippr/src/hud/hud_metric.dart';
import 'package:rippr/src/hud/hud_widget_layout.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// `Config` had no dedicated tests before V3-03 — every existing widget test leaves
@@ -81,6 +83,57 @@ void main() {
});
});
group('hudLayout (UI-04)', () {
test('a fresh install gets a default layout for every metric', () async {
final config = await freshConfig();
final layout = config.hudLayout;
expect(layout.keys.toSet(), HudMetric.values.toSet());
});
test('round-trips an edited layout through set/get', () async {
final config = await freshConfig();
final edited = {
...config.hudLayout,
HudMetric.speed: const HudWidgetLayout(
metric: HudMetric.speed,
x: 0.33,
y: 0.44,
width: 0.25,
height: 0.1,
visible: true,
),
};
await config.setHudLayout(edited);
final reloaded = config.hudLayout;
expect(reloaded[HudMetric.speed]!.x, 0.33);
expect(reloaded[HudMetric.speed]!.y, 0.44);
});
test('a metric missing from a saved layout (e.g. one added in a later app '
'version) falls back to its default rather than being omitted', () async {
SharedPreferences.setMockInitialValues({
'hud_layout': '{"speed": {"x": 0.1, "y": 0.1, "width": 0.2, "height": 0.1, '
'"visible": true}}',
});
final config = await Config.load();
final layout = config.hudLayout;
expect(layout.keys.toSet(), HudMetric.values.toSet());
final expectedDefault = HudWidgetLayout.defaultFor(HudMetric.distance);
expect(layout[HudMetric.distance]!.x, expectedDefault.x);
expect(layout[HudMetric.distance]!.visible, expectedDefault.visible);
});
test('corrupted stored JSON falls back to defaults rather than crashing',
() async {
SharedPreferences.setMockInitialValues({'hud_layout': 'not json at all'});
final config = await Config.load();
expect(config.hudLayout.keys.toSet(), HudMetric.values.toSet());
});
});
group('deviceId', () {
test('is generated once and then stable across reads', () async {
final config = await freshConfig();

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);
});
});
}

View File

@@ -0,0 +1,113 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/app/providers.dart';
import 'package:rippr/src/config/config.dart';
import 'package:rippr/src/hud/hud_metric.dart';
import 'package:rippr/src/ui/components/hud_edit_overlay.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// UI-04: drag/toggle behaviour of the customizable HUD, simulated via `TestGesture`
/// the same way other drag interactions in this codebase are tested (see
/// `route_planner_screen_test.dart`'s waypoint-drag coverage).
void main() {
late Config config;
setUp(() async {
SharedPreferences.setMockInitialValues({});
config = await Config.load();
});
Widget host() => ProviderScope(
overrides: [configProvider.overrideWith((ref) => config)],
child: MaterialApp(
home: Scaffold(
body: SizedBox.expand(
child: HudEditOverlay(
metricBuilder: (context, metric) => Text(metric.label),
),
),
),
),
);
testWidgets('a tap outside edit mode does not move a widget', (tester) async {
await tester.pumpWidget(host());
await tester.pumpAndSettle();
expect(find.byKey(const Key('hud-edit-done')), findsNothing,
reason: 'not editing yet');
await tester.tap(find.text('Speed'));
await tester.pump();
expect(find.byKey(const Key('hud-edit-done')), findsNothing,
reason: 'a plain tap on a widget must never enter edit mode or move it');
});
testWidgets('a long-press on empty space enters edit mode, showing resize handles '
'and Done', (tester) async {
await tester.pumpWidget(host());
await tester.pumpAndSettle();
await tester.longPress(find.byKey(const Key('hud-edit-background')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('hud-edit-done')), findsOneWidget);
expect(find.byKey(const Key('hud-resize-handle')), findsWidgets);
});
testWidgets('a long-press-and-drag on a widget moves it, and Done persists the new '
'position', (tester) async {
await tester.pumpWidget(host());
await tester.pumpAndSettle();
await tester.longPress(find.byKey(const Key('hud-edit-background')));
await tester.pumpAndSettle();
final before = config.hudLayout[HudMetric.speed]!;
final speedFinder = find.text('Speed');
final gesture = await tester.startGesture(tester.getCenter(speedFinder));
await tester.pump(kLongPressTimeout + kPressTimeout);
await gesture.moveBy(const Offset(40, 60));
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('hud-edit-done')));
await tester.pumpAndSettle();
final after = config.hudLayout[HudMetric.speed]!;
expect(after.x, isNot(before.x));
expect(after.y, isNot(before.y));
});
testWidgets('a drag while not editing does not move the widget', (tester) async {
await tester.pumpWidget(host());
await tester.pumpAndSettle();
final before = config.hudLayout[HudMetric.speed]!;
final speedFinder = find.text('Speed');
final gesture = await tester.startGesture(tester.getCenter(speedFinder));
await tester.pump(kLongPressTimeout + kPressTimeout);
await gesture.moveBy(const Offset(40, 60));
await gesture.up();
await tester.pumpAndSettle();
expect(find.byKey(const Key('hud-edit-done')), findsNothing);
final after = config.hudLayout[HudMetric.speed]!;
expect(after.x, before.x);
expect(after.y, before.y);
});
testWidgets('only visible metrics render a widget', (tester) async {
await tester.pumpWidget(host());
await tester.pumpAndSettle();
expect(find.text('Speed'), findsOneWidget, reason: 'visible by default');
expect(find.text('Max speed'), findsNothing, reason: 'hidden by default');
});
}

View File

@@ -0,0 +1,71 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/config/config.dart';
import 'package:rippr/src/hud/hud_layout_controller.dart';
import 'package:rippr/src/hud/hud_metric.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
Future<Config> freshConfig() async {
SharedPreferences.setMockInitialValues({});
return Config.load();
}
group('HudLayoutController', () {
test('updatePosition updates only the given metric, clamped', () async {
final controller = HudLayoutController(await freshConfig());
final before = controller.state[HudMetric.distance]!;
controller.updatePosition(HudMetric.speed, 5.0, 5.0);
expect(controller.state[HudMetric.speed]!.x, lessThanOrEqualTo(1.0));
expect(controller.state[HudMetric.distance]!.x, before.x,
reason: 'moving one metric must not disturb another');
});
test('updateSize clamps to the legibility floor/ceiling', () async {
final controller = HudLayoutController(await freshConfig());
controller.updateSize(HudMetric.speed, 0.0, 0.0);
expect(controller.state[HudMetric.speed]!.width, greaterThan(0.0));
expect(controller.state[HudMetric.speed]!.height, greaterThan(0.0));
});
test('setVisible(false) then setVisible(true) restores the last saved position, '
'not a fresh default', () async {
final controller = HudLayoutController(await freshConfig());
controller.updatePosition(HudMetric.avgSpeed, 0.6, 0.6);
final movedX = controller.state[HudMetric.avgSpeed]!.x;
controller.setVisible(HudMetric.avgSpeed, false);
expect(controller.state[HudMetric.avgSpeed]!.visible, isFalse);
controller.setVisible(HudMetric.avgSpeed, true);
expect(controller.state[HudMetric.avgSpeed]!.visible, isTrue);
expect(controller.state[HudMetric.avgSpeed]!.x, movedX,
reason: 'a metric with a real saved position keeps it when re-enabled');
});
test('persist writes the current state to Config', () async {
final config = await freshConfig();
final controller = HudLayoutController(config);
controller.updatePosition(HudMetric.speed, 0.4, 0.4);
await controller.persist();
final reloaded = config.hudLayout;
expect(reloaded[HudMetric.speed]!.x, 0.4);
});
test('edits before persist are not visible to a fresh read of Config', () async {
final config = await freshConfig();
final controller = HudLayoutController(config);
controller.updatePosition(HudMetric.speed, 0.4, 0.4);
// No persist() call yet.
final reloaded = config.hudLayout;
expect(reloaded[HudMetric.speed]!.x, isNot(0.4),
reason: 'a drag in progress must only touch in-memory state -- '
'persisting every frame is the ticket\'s own named risk');
});
});
}

View File

@@ -0,0 +1,128 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/hud/hud_metric.dart';
import 'package:rippr/src/hud/hud_widget_layout.dart';
/// UI-04: the JSON round-trip and clamp math are exercised directly with fixed inputs
/// -- no real gestures needed to test either, per the ticket's own Tests section.
void main() {
group('JSON round trip', () {
test('encodes and decodes exactly', () {
const original = HudWidgetLayout(
metric: HudMetric.speed,
x: 0.1,
y: 0.2,
width: 0.3,
height: 0.15,
visible: true,
);
final decoded = HudWidgetLayout.fromJson(HudMetric.speed, original.toJson());
expect(decoded.x, original.x);
expect(decoded.y, original.y);
expect(decoded.width, original.width);
expect(decoded.height, original.height);
expect(decoded.visible, original.visible);
});
test('a malformed entry falls back to the default rather than crashing', () {
final decoded = HudWidgetLayout.fromJson(HudMetric.distance, {'x': 'not a number'});
final default_ = HudWidgetLayout.defaultFor(HudMetric.distance);
expect(decoded.x, default_.x);
expect(decoded.width, default_.width);
});
test('a missing field falls back to the default rather than crashing', () {
final decoded = HudWidgetLayout.fromJson(HudMetric.maxSpeed, {'x': 0.1, 'y': 0.1});
final default_ = HudWidgetLayout.defaultFor(HudMetric.maxSpeed);
expect(decoded.x, default_.x);
});
});
group('defaultFor', () {
test('every metric gets a distinct default position', () {
final positions = HudMetric.values
.map(HudWidgetLayout.defaultFor)
.map((l) => '${l.x},${l.y}')
.toSet();
expect(positions.length, HudMetric.values.length,
reason: 'no two metrics should default to overlapping positions');
});
test('the first four metrics (the Map HUD mockup\'s fixed row: Speed/Avg Speed/'
'Dist/Time) start visible', () {
expect(HudWidgetLayout.defaultFor(HudMetric.speed).visible, isTrue);
expect(HudWidgetLayout.defaultFor(HudMetric.avgSpeed).visible, isTrue);
expect(HudWidgetLayout.defaultFor(HudMetric.distance).visible, isTrue);
expect(HudWidgetLayout.defaultFor(HudMetric.elapsedTime).visible, isTrue);
});
test('metrics beyond the fixed row start hidden', () {
expect(HudWidgetLayout.defaultFor(HudMetric.maxSpeed).visible, isFalse);
expect(HudWidgetLayout.defaultFor(HudMetric.elevationGain).visible, isFalse);
});
test('every default is already within the valid clamp bounds', () {
for (final metric in HudMetric.values) {
final layout = HudWidgetLayout.defaultFor(metric);
expect(layout.clamped().x, layout.x);
expect(layout.clamped().y, layout.y);
expect(layout.clamped().width, layout.width);
expect(layout.clamped().height, layout.height);
}
});
});
group('clamped', () {
const base = HudWidgetLayout(
metric: HudMetric.speed,
x: 0.5,
y: 0.5,
width: 0.3,
height: 0.15,
visible: true,
);
test('a position dragged past the right/bottom edge is pulled back on-screen', () {
final result = base.copyWith(x: 1.5, y: 1.5).clamped();
expect(result.x, 1.0 - base.width);
expect(result.y, 1.0 - base.height);
});
test('a position dragged past the left/top edge is pulled back on-screen', () {
final result = base.copyWith(x: -0.5, y: -0.5).clamped();
expect(result.x, 0.0);
expect(result.y, 0.0);
});
test('a resize below the legibility floor is corrected up to the minimum', () {
final result = base.copyWith(width: 0.01, height: 0.01).clamped();
expect(result.width, hudMinWidthFraction);
expect(result.height, hudMinHeightFraction);
});
test('a resize above the sane ceiling is corrected down to the maximum', () {
final result = base.copyWith(width: 5.0, height: 5.0).clamped();
expect(result.width, hudMaxWidthFraction);
expect(result.height, hudMaxHeightFraction);
});
test('shrinking to fit happens before repositioning, so a widget resized past '
'the edge shrinks rather than silently relocates', () {
final result = base.copyWith(x: 0.9, width: 0.7).clamped();
expect(result.width, hudMaxWidthFraction);
// x itself is still clamped against the (now-bounded) width so the widget
// never sits partially off-screen either.
expect(result.x + result.width, lessThanOrEqualTo(1.0));
});
test('an already-valid layout is unchanged', () {
expect(base.clamped().x, base.x);
expect(base.clamped().y, base.y);
expect(base.clamped().width, base.width);
expect(base.clamped().height, base.height);
});
});
}

View File

@@ -0,0 +1,116 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/tiles/map_connectivity.dart';
/// UI-02: exercises the failure-count/threshold/probe logic with a fake probe -- no
/// real network -- mirroring V3-11's `tile_downloader_test.dart` pattern of injecting
/// fake fetch outcomes rather than hitting a real tile host from a test.
void main() {
late bool probeResult;
late int probeCalls;
late MapConnectivityState state;
setUp(() {
probeResult = false;
probeCalls = 0;
state = MapConnectivityState(
probe: () async {
probeCalls++;
return probeResult;
},
probeInterval: const Duration(milliseconds: 20),
);
});
tearDown(() {
state.dispose();
});
test('starts in live mode', () {
expect(state.skeletonMode, isFalse);
});
test('fewer than the threshold of failures stays live', () {
state.reportFailure();
state.reportFailure();
expect(state.skeletonMode, isFalse);
});
test('reaching the threshold of consecutive failures switches to skeleton mode', () {
for (var i = 0; i < skeletonFailureThreshold; i++) {
state.reportFailure();
}
expect(state.skeletonMode, isTrue);
});
test('a success before the threshold resets the failure count', () {
state.reportFailure();
state.reportFailure();
state.reportSuccess();
state.reportFailure();
state.reportFailure();
expect(state.skeletonMode, isFalse,
reason: 'the success should have reset the run -- two more failures is not '
'the same as four in a row');
});
test('notifies listeners exactly when skeletonMode actually changes', () {
var notifications = 0;
state.addListener(() => notifications++);
state.reportFailure();
state.reportFailure();
expect(notifications, 0, reason: 'still below threshold -- no state change yet');
state.reportFailure();
expect(notifications, 1);
expect(state.skeletonMode, isTrue);
});
test('a single success while already in skeleton mode recovers immediately', () {
for (var i = 0; i < skeletonFailureThreshold; i++) {
state.reportFailure();
}
expect(state.skeletonMode, isTrue);
state.reportSuccess();
expect(state.skeletonMode, isFalse);
});
test('further failures are ignored once already in skeleton mode', () {
for (var i = 0; i < skeletonFailureThreshold; i++) {
state.reportFailure();
}
var notifications = 0;
state.addListener(() => notifications++);
// Recovery is the probe loop's job from here -- more failure reports (e.g. from a
// TileLayer that hadn't yet unmounted) must not do anything further.
state.reportFailure();
state.reportFailure();
expect(notifications, 0);
expect(state.skeletonMode, isTrue);
});
test('the probe loop recovers automatically once it starts succeeding', () async {
for (var i = 0; i < skeletonFailureThreshold; i++) {
state.reportFailure();
}
expect(state.skeletonMode, isTrue);
probeResult = true;
// Give the periodic probe timer a couple of intervals to fire.
await Future<void>.delayed(const Duration(milliseconds: 60));
expect(state.skeletonMode, isFalse);
expect(probeCalls, greaterThan(0));
});
test('the probe loop does not run while already live', () async {
await Future<void>.delayed(const Duration(milliseconds: 60));
expect(probeCalls, 0,
reason: 'a TileLayer that is live and working generates its own fetch '
'reports -- a periodic probe on top of that would be exactly the '
'excess-request behaviour this ticket exists to avoid');
});
}

View File

@@ -0,0 +1,43 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/domain/models.dart';
import 'package:rippr/src/ui/trips/trips_screen.dart';
/// UI-07: `RideHistorySummary.compute` is a pure function specifically so it can be
/// tested directly against a hand-computed total, rather than through a screen.
void main() {
test('compute matches a hand-computed total over a fixed set of trips', () {
final trips = [
const Trip(
id: 1,
startedAt: 0,
endedAt: 3600000,
distanceM: 20000,
movingMillis: 3600000,
),
const Trip(
id: 2,
startedAt: 0,
endedAt: 1800000,
distanceM: 5000,
movingMillis: 1800000,
),
];
final summary = RideHistorySummary.compute(trips);
expect(summary.rideCount, 2);
expect(summary.totalDistanceM, 25000);
expect(summary.totalMovingMillis, 5400000);
// Distance-weighted, not an average of each ride's own average: 25 km over 1.5 h.
expect(summary.avgSpeedKmh, closeTo(25 / 1.5, 0.0001));
});
test('an empty history has zero average speed, not a division error', () {
final summary = RideHistorySummary.compute(const []);
expect(summary.rideCount, 0);
expect(summary.totalDistanceM, 0);
expect(summary.totalMovingMillis, 0);
expect(summary.avgSpeedKmh, 0);
});
}

View File

@@ -3,6 +3,7 @@ import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/domain/models.dart';
import 'package:rippr/src/ui/components/ride_map.dart';
import 'package:rippr/src/ui/components/skeleton_map_layer.dart';
import 'package:rippr/src/ui/theme.dart';
/// Tiles are never fetched here — a widget test cannot serve them — but the polyline
@@ -117,4 +118,67 @@ void main() {
expect(map.options.maxZoom, maxTileZoom,
reason: 'exceeding OSM max tile zoom renders an empty grid');
});
group('skeleton mode (UI-02)', () {
testWidgets('shows the skeleton and no TileLayer when skeletonMode is true',
(tester) async {
await tester.pumpWidget(MaterialApp(
theme: ripprTheme(),
home: const Scaffold(
body: RideMap(points: [], segments: [], showEmptyLabel: false, skeletonMode: true),
),
));
await tester.pump();
expect(find.byType(SkeletonMapLayer), findsOneWidget);
expect(find.byType(TileLayer), findsNothing);
});
testWidgets('shows the TileLayer and no skeleton when skeletonMode is false',
(tester) async {
await tester.pumpWidget(MaterialApp(
theme: ripprTheme(),
home: const Scaffold(
body: RideMap(points: [], segments: [], showEmptyLabel: false),
),
));
await tester.pump();
expect(find.byType(TileLayer), findsOneWidget);
expect(find.byType(SkeletonMapLayer), findsNothing);
});
testWidgets('the recorded path still renders on top of the skeleton',
(tester) async {
final points = [
for (var i = 0; i < 4; i++)
TrackPoint(
id: i + 1,
tripId: 1,
segmentId: 1,
timestamp: 1000 + i * 1000,
latitude: 51.0 + i * 0.0005,
longitude: -114.0,
speedKmh: 40,
altitudeM: 1000,
),
];
const segments = [Segment(id: 1, tripId: 1, startedAt: 0, endedAt: 1)];
await tester.pumpWidget(MaterialApp(
theme: ripprTheme(),
home: Scaffold(
body: RideMap(points: points, segments: segments, skeletonMode: true),
),
));
await tester.pump();
expect(find.byType(SkeletonMapLayer), findsOneWidget);
expect(find.byType(TileLayer), findsNothing);
final layer = tester.widget<PolylineLayer>(find.byType(PolylineLayer));
expect(layer.polylines, isNotEmpty,
reason: 'the path comes from local data, not tiles -- it must not vanish '
'just because the tile fetch is failing');
});
});
}

View File

@@ -7,6 +7,9 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/app/providers.dart';
import 'package:rippr/src/data/database.dart';
import 'package:rippr/src/data/route_plan_repository.dart';
import 'package:rippr/src/ui/app_shell.dart';
import 'package:rippr/src/ui/components/floating_pill.dart';
import 'package:rippr/src/ui/format.dart';
import 'package:rippr/src/ui/routes/route_planner_screen.dart';
import 'package:rippr/src/ui/routes/routes_list_screen.dart';
import 'package:rippr/src/ui/theme.dart';
@@ -107,8 +110,10 @@ void main() {
final id = await repo.createRoutePlan(1000);
await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
expect(find.text('0 m'), findsOneWidget);
expect(find.textContaining('0 pins'), findsOneWidget);
// UI-06: no pins yet -- the "tap to drop a pin" tooltip shows, not a stat pill
// (there is nothing to summarise yet).
expect(find.textContaining('TAP TO DROP A PIN'), findsOneWidget);
expect(find.byType(FloatingPill), findsNothing);
await tester.tapAt(tester.getCenter(find.byType(FlutterMap)));
await tester.pump(const Duration(milliseconds: 50));
@@ -119,35 +124,54 @@ void main() {
final waypoints = await repo.waypointsFor(id);
expect(waypoints, hasLength(2));
expect(find.textContaining('2 pins'), findsOneWidget);
final label = tester.widget<Text>(find.byKey(const Key('route-distance')));
expect(label.data, isNot('0 m'));
// The tooltip is gone and the stat pill has taken its place, showing the real
// pin count and a non-zero distance. Read the pill's own `stats` directly
// rather than `find.text('2')` -- the second waypoint pin's own marker label
// is also "2", so that text is no longer unique on screen.
expect(find.textContaining('TAP TO DROP A PIN'), findsNothing);
final pill = tester.widget<FloatingPill>(find.byType(FloatingPill));
final pinsStat = pill.stats.firstWhere((s) => s.label == 'Pins');
final distanceStat = pill.stats.firstWhere((s) => s.label == 'Distance');
expect(pinsStat.value, '2');
expect(distanceStat.value, isNot(formatDistance(0)),
reason: 'the distance stat must reflect the two real pins, not stay zeroed');
});
screenTest('tapping a pin deletes it', (tester) async {
// UI-06: a single waypoint (rather than two) -- with only one pin, the camera
// centres exactly on it, keeping it clear of the floating "Start Route" button
// now anchored at the bottom of the screen; two close pins previously landed
// one of them directly underneath it in this test's fixed viewport.
final id = await repo.createRoutePlan(1000);
await repo.addWaypoint(id, 51.0, -114.0);
await repo.addWaypoint(id, 51.01, -114.0);
await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
// flutter_map's `Marker` is a plain data class, not a Widget -- it never appears
// in the tree itself. `CircleAvatar` is what `_WaypointPin` actually renders.
expect(find.byType(CircleAvatar), findsNWidgets(2));
expect(find.byType(CircleAvatar), findsOneWidget);
await tester.tap(find.text('1')); // the first pin's label
// Not `find.text('1')`: the FloatingPill's "Pins" stat also reads "1" with a
// single waypoint, so the pin's own label text is no longer a unique match.
await tester.tap(find.byType(CircleAvatar));
await tester.pump();
expect(await repo.waypointsFor(id), hasLength(1));
expect(await repo.waypointsFor(id), isEmpty);
});
screenTest('renaming updates the app bar title', (tester) async {
screenTest('renaming writes through to the repository (UI-06: no app bar title '
'to display it on any more)', (tester) async {
final id = await repo.createRoutePlan(1000, name: 'Old name');
await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
await tester.tap(find.byKey(const Key('rename-route')));
// Rename now lives behind the floating overflow menu -- open it first.
await tester.tap(find.byKey(const Key('route-overflow-menu')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.pump();
await tester.tap(find.byKey(const Key('rename-route')));
await tester.pump(const Duration(milliseconds: 300));
await tester.enterText(
find.byKey(const Key('route-name-field')),
'Sunday coast run',
@@ -155,7 +179,8 @@ void main() {
await tester.tap(find.text('Save'));
await tester.pump();
expect(find.text('Sunday coast run'), findsOneWidget);
final renamed = await repo.routePlanById(id);
expect(renamed?.name, 'Sunday coast run');
});
screenTest('a missing route says so instead of a blank map', (tester) async {
@@ -165,16 +190,21 @@ void main() {
expect(find.textContaining('no longer exists'), findsOneWidget);
});
screenTest('the offline-tiles download button is disabled with no pins '
'(V3-11)', (tester) async {
screenTest('the offline-tiles download menu item is disabled with no pins '
'(V3-11, UI-06: now a PopupMenuItem behind the overflow menu)',
(tester) async {
final id = await repo.createRoutePlan(1000);
await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
final button = tester.widget<IconButton>(
await tester.tap(find.byKey(const Key('route-overflow-menu')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.pump();
final item = tester.widget<PopupMenuItem<String>>(
find.byKey(const Key('download-tiles')),
);
expect(button.onPressed, isNull);
expect(button.tooltip, contains('Add pins'));
expect(item.enabled, isFalse);
});
screenTest('downloading shows a tile count and size estimate before any '
@@ -184,10 +214,15 @@ void main() {
await repo.addWaypoint(id, 51.01, -114.0);
await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
final button = tester.widget<IconButton>(
await tester.tap(find.byKey(const Key('route-overflow-menu')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.pump();
final item = tester.widget<PopupMenuItem<String>>(
find.byKey(const Key('download-tiles')),
);
expect(button.onPressed, isNotNull);
expect(item.enabled, isTrue);
await tester.tap(find.byKey(const Key('download-tiles')));
// Bounded, not pumpAndSettle: the confirmation dialog is safe to settle (no
@@ -201,5 +236,25 @@ void main() {
expect(find.textContaining('tiles?'), findsOneWidget);
expect(find.textContaining('MB'), findsOneWidget);
});
screenTest('the shell nav bar correctly shows Plan active on this screen '
'(UI-06)', (tester) async {
final id = await repo.createRoutePlan(1000);
await pumpMap(
tester,
host(
ShellScaffold(
currentIndex: 2, // Map, Rides, Plan, Settings -- Plan is index 2.
onDestinationSelected: (_) {},
child: RoutePlannerScreen(routeId: id),
),
),
);
final navBar = tester.widget<NavigationBar>(find.byKey(const Key('shell-nav-bar')));
expect(navBar.selectedIndex, 2,
reason: 'not the mockup\'s own generation error (it showed Rides active '
'while viewing Plan) -- the shipped nav bar must reflect the real tab');
});
});
}

View File

@@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/app/providers.dart';
import 'package:rippr/src/config/config.dart';
import 'package:rippr/src/domain/models.dart';
import 'package:rippr/src/hud/hud_metric.dart';
import 'package:rippr/src/ui/settings/settings_screen.dart';
import 'package:rippr/src/ui/theme.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -196,6 +197,46 @@ void main() {
expect(tester.takeException(), isNull);
});
group('Live HUD stats (UI-04)', () {
// The section sits at the bottom of a long ListView -- these rows are not built
// until scrolled into view (a plain `ListView(children:)` still lazily materialises
// via a sliver, same as `.builder`), so every test here scrolls first.
Future<void> scrollTo(WidgetTester tester, Key key) => tester.scrollUntilVisible(
find.byKey(key),
200,
scrollable: find.byType(Scrollable).first,
);
testWidgets('a metric switch reflects and writes through to Config', (tester) async {
await tester.pumpWidget(host());
await tester.pumpAndSettle();
await scrollTo(tester, const Key('hud-visible-maxSpeed'));
final initial = tester
.widget<SwitchListTile>(find.byKey(const Key('hud-visible-maxSpeed')))
.value;
expect(initial, isFalse, reason: 'metrics beyond the fixed row start hidden');
await tester.tap(find.byKey(const Key('hud-visible-maxSpeed')));
await tester.pumpAndSettle();
expect(config.hudLayout[HudMetric.maxSpeed]!.visible, isTrue,
reason: 'the switch must write through to Config immediately, not wait '
'for a HUD edit-mode session to end');
});
testWidgets('a metric already visible by default shows as on', (tester) async {
await tester.pumpWidget(host());
await tester.pumpAndSettle();
await scrollTo(tester, const Key('hud-visible-speed'));
final speedSwitch = tester
.widget<SwitchListTile>(find.byKey(const Key('hud-visible-speed')))
.value;
expect(speedSwitch, isTrue);
});
});
testWidgets('shows a spinner rather than crashing while Config is still loading',
(tester) async {
await tester.pumpWidget(

View File

@@ -89,4 +89,26 @@ void main() {
expect(await reopened.get(const TileKey(5, 10, 10)), isNotNull);
expect(await reopened.sizeBytes(), 64);
});
test('a tile cached under one provider directory is not served from another '
'(UI-09)', () async {
// `TileKey` carries no provider identity -- (z, x, y) alone can't tell an OSM tan
// tile from a CARTO dark one at the same coordinates. `tileCacheProvider` (see
// app/providers.dart) relies entirely on giving each tile source its own
// subdirectory to prevent a switch from silently serving stale, visually-mismatched
// tiles. This proves that isolation actually holds at the `FileTileCache` level.
final osm = FileTileCache(
directory: Directory('${tempDir.path}/osm'),
maxBytes: 1024 * 1024,
);
final cartoDark = FileTileCache(
directory: Directory('${tempDir.path}/carto_dark_v1'),
maxBytes: 1024 * 1024,
);
await osm.put(const TileKey(5, 10, 10), bytesOfSize(64));
expect(await cartoDark.get(const TileKey(5, 10, 10)), isNull,
reason: 'a tile cached under the old provider must not leak into the new one');
});
}

View File

@@ -12,6 +12,9 @@ import 'package:rippr/src/domain/models.dart';
import 'package:rippr/src/ui/activity_display.dart';
import 'package:rippr/src/recording/location_source.dart';
import 'package:rippr/src/recording/wakelock_controller.dart';
import 'package:rippr/src/ui/app_shell.dart';
import 'package:rippr/src/ui/components/glass_panel.dart';
import 'package:rippr/src/ui/components/ride_map.dart';
import 'package:rippr/src/ui/detail/trip_detail_screen.dart';
import 'package:rippr/src/ui/format.dart';
import 'package:rippr/src/ui/record/record_screen.dart';
@@ -184,6 +187,51 @@ void main() {
reason: 'cancelling must not delete the ride');
});
screenTest('tapping START actually starts a trip (UI-05)', (tester) async {
await tester.pumpWidget(host(const RecordScreen(), map: false));
await tester.pumpAndSettle();
expect(await repo.activeTrip(), isNull);
await tester.tap(find.byKey(const Key('start')));
await tester.pump();
expect(await repo.activeTrip(), isNotNull,
reason: 'the new icon-only control bar must still drive the same engine '
'call the old text button did');
});
screenTest('tapping PAUSE and then STOP actually pauses and completes the trip '
'(UI-05)', (tester) async {
await repo.startTrip(1000);
await pumpLive(tester, host(const RecordScreen(), map: false));
await tester.tap(find.byKey(const Key('pause')));
await tester.pump();
final paused = await repo.activeTrip();
expect(paused?.state, TripState.paused);
await tester.tap(find.byKey(const Key('stop')));
await tester.pump();
expect(await repo.activeTrip(), isNull,
reason: 'stopping completes the trip -- it is no longer the active one');
});
screenTest('HUD widgets render over the map, not replacing it (UI-05)',
(tester) async {
await repo.startTrip(1000);
await pumpLive(
tester,
host(
ShellScaffold(currentIndex: 0, onDestinationSelected: (_) {}, child: const RecordScreen()),
),
);
expect(find.byKey(const Key('shell-background-map')), findsOneWidget,
reason: 'the map is still there, underneath the HUD');
expect(find.text('SPEED'), findsOneWidget);
expect(find.text('DISTANCE'), findsOneWidget);
});
screenTest('text is legible against the dark ground', (tester) async {
// The regression this exists for: removing Compose's Surface left LocalContentColor
// black, and a 64sp figure rendered invisibly on a near-black background. No logic
@@ -203,58 +251,37 @@ void main() {
expect(colour, isNot(Colors.black));
});
screenTest('a settings entry point exists and is wired (V3-02)', (tester) async {
var opened = false;
await tester.pumpWidget(
host(RecordScreen(onOpenSettings: () => opened = true)),
);
await tester.pumpAndSettle();
// UI-01: Settings/Routes/Rides entry points are no longer per-screen callback
// buttons on RecordScreen -- they're destinations on the shell's persistent bottom
// nav bar. See the `AppShell`/`ShellScaffold` group below for their coverage.
expect(find.byKey(const Key('open-settings')), findsOneWidget);
await tester.tap(find.byKey(const Key('open-settings')));
await tester.pumpAndSettle();
expect(opened, isTrue,
reason: 'the button must actually invoke the callback that navigates');
});
screenTest('a routes entry point exists and is wired (V3-07)', (tester) async {
var opened = false;
await tester.pumpWidget(
host(RecordScreen(onOpenRoutes: () => opened = true)),
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('open-routes')), findsOneWidget);
await tester.tap(find.byKey(const Key('open-routes')));
await tester.pumpAndSettle();
expect(opened, isTrue);
});
screenTest('the live map appears only while recording and the toggle is on '
'(V3-04)', (tester) async {
// Idle, toggle on: no trip to draw, so no map at all.
await tester.pumpWidget(host(const RecordScreen()));
await tester.pumpAndSettle();
expect(find.byKey(const Key('live-map')), findsNothing);
// Recording, toggle off: RecordingEngine has produced a trip, but the map must
// not be constructed at all -- not just hidden.
screenTest('the background map appears only while the map toggle is on '
'(V3-04, moved to the shell by UI-01)', (tester) async {
// Toggle off: the shell must not construct the map at all -- not just hidden.
await repo.startTrip(1000);
await tester.pumpWidget(const SizedBox.shrink());
await pumpLive(tester, host(const RecordScreen(), map: false));
expect(find.byKey(const Key('live-map')), findsNothing);
await pumpLive(
tester,
host(
ShellScaffold(currentIndex: 0, onDestinationSelected: (_) {}, child: const RecordScreen()),
map: false,
),
);
expect(find.byKey(const Key('shell-background-map')), findsNothing);
expect(find.byType(FlutterMap), findsNothing);
// Recording, toggle on: the map is drawn.
// Toggle on: the map is drawn.
await tester.pumpWidget(const SizedBox.shrink());
await pumpLive(tester, host(const RecordScreen()));
expect(find.byKey(const Key('live-map')), findsOneWidget);
await pumpLive(
tester,
host(
ShellScaffold(currentIndex: 0, onDestinationSelected: (_) {}, child: const RecordScreen()),
),
);
expect(find.byKey(const Key('shell-background-map')), findsOneWidget);
});
screenTest('backgrounding the app drops the tile layer (V3-04)',
(tester) async {
screenTest('backgrounding the app drops the tile layer (V3-04, moved to the '
'shell by UI-01)', (tester) async {
final h = await repo.startTrip(1000);
await repo.appendPoints([
TrackPoint(
@@ -267,7 +294,12 @@ void main() {
altitudeM: 1000.0,
),
]);
await pumpLive(tester, host(const RecordScreen()));
await pumpLive(
tester,
host(
ShellScaffold(currentIndex: 0, onDestinationSelected: (_) {}, child: const RecordScreen()),
),
);
expect(find.byType(TileLayer), findsOneWidget,
reason: 'foregrounded: tiles render normally');
@@ -278,6 +310,7 @@ void main() {
await tester.binding.defaultBinaryMessenger
.handlePlatformMessage('flutter/lifecycle', message, (_) {});
await tester.pump();
await tester.pump();
expect(find.byType(TileLayer), findsNothing,
reason: 'backgrounded: no tile layer means no tile request can fire');
@@ -288,6 +321,7 @@ void main() {
await tester.binding.defaultBinaryMessenger
.handlePlatformMessage('flutter/lifecycle', resumed, (_) {});
await tester.pump();
await tester.pump();
expect(find.byType(TileLayer), findsOneWidget,
reason: 'foregrounding again must resume tiles');
});
@@ -362,6 +396,69 @@ void main() {
expect(start.height, 96,
reason: 'V3-05: 72dp is not enough at speed, with gloves');
});
screenTest('the mounted-mode speed digit is legible against GlassPanel\'s own '
'translucent surface, not just different from the dark ground (UI-05)',
(tester) async {
// UI-05's own named risk: mounted mode was built and tested against an opaque
// Card, not a blurred, translucent GlassPanel -- a light theme through blurred
// content behaves differently than the dark-on-dark case V3-05 originally
// guarded against. This asserts the real contrast ratio, not just "differs from
// the wrong ground" the way the test above does.
await tester.pumpWidget(host(const RecordScreen(), mountedMode: true));
await tester.pumpAndSettle();
final speed = tester.widget<Text>(
find.descendant(of: find.byKey(const Key('speed')), matching: find.text('0')),
);
final panel = tester.widget<GlassPanel>(find.byType(GlassPanel).first);
final mountedColors = ripprMountedTheme().colorScheme;
// GlassPanel fills with `colors.surface` at `GlassPanel.fillOpacity` -- since it
// is a solid, near-opaque fill (not a transparency composited over unknown
// content), the panel's own surface colour is what the text is actually read
// against in practice.
expect(panel.child, isNotNull);
expect(
contrastRatio(speed.style!.color!, mountedColors.surface),
greaterThanOrEqualTo(4.5),
reason: 'AA normal text against GlassPanel\'s mounted-theme surface',
);
});
});
group('shell nav bar', () {
// UI-01: Settings/Routes/Rides are no longer per-screen callback buttons on
// RecordScreen -- they're destinations on the shell's persistent bottom nav bar.
// This replaces the old "a settings/routes entry point exists and is wired"
// per-screen tests.
screenTest('all four destinations are present and switching tabs calls back '
'with the tapped index', (tester) async {
var lastIndex = -1;
await tester.pumpWidget(host(
ShellScaffold(
currentIndex: 0,
onDestinationSelected: (i) => lastIndex = i,
child: const RecordScreen(),
),
map: false,
));
await tester.pumpAndSettle();
expect(find.byKey(const Key('shell-nav-bar')), findsOneWidget);
expect(find.byKey(const Key('nav-map')), findsOneWidget);
expect(find.byKey(const Key('nav-rides')), findsOneWidget);
expect(find.byKey(const Key('nav-plan')), findsOneWidget);
expect(find.byKey(const Key('nav-settings')), findsOneWidget);
await tester.tap(find.byKey(const Key('nav-settings')));
expect(lastIndex, 3);
await tester.tap(find.byKey(const Key('nav-rides')));
expect(lastIndex, 1);
await tester.tap(find.byKey(const Key('nav-plan')));
expect(lastIndex, 2);
});
});
group('trips list', () {
@@ -397,8 +494,86 @@ void main() {
expect(find.byKey(const Key('trip-1')), findsOneWidget);
expect(find.text('Finished'), findsOneWidget);
// Only the completed one.
expect(find.byType(Card), findsOneWidget);
// Only the completed one. UI-07: trip cards are GlassPanel-based, not Card --
// RideMap's thumbnail is unique to a trip card (unlike GlassPanel/Material, which
// other chrome on this screen also uses).
expect(find.byType(RideMap), findsOneWidget);
});
screenTest('search narrows the visible list to matching rides (UI-07)',
(tester) async {
await seedCompletedTrip(
startedAt: 1000, endedAt: 5000, name: 'Coast loop');
await seedCompletedTrip(
startedAt: 100000, endedAt: 200000, name: 'Mountain climb');
await tester.pumpWidget(host(const TripsScreen()));
await tester.pumpAndSettle();
expect(find.text('Coast loop'), findsOneWidget);
expect(find.text('Mountain climb'), findsOneWidget);
await tester.enterText(find.byKey(const Key('ride-search')), 'coast');
await tester.pump();
expect(find.text('Coast loop'), findsOneWidget);
expect(find.text('Mountain climb'), findsNothing);
});
screenTest('filtering by activity narrows the visible list (UI-07)',
(tester) async {
await repo.startTrip(1000, activity: Activity.bicycle).then(
(h) async {
await repo.appendPoints([
TrackPoint(
tripId: h.tripId,
segmentId: h.segmentId,
timestamp: 1000,
latitude: 51.0,
longitude: -114.0,
speedKmh: 20,
altitudeM: 1000,
),
]);
await repo.renameTrip(h.tripId, 'Bike ride');
await repo.completeTrip(5000);
await repo.recomputeAggregates(h.tripId);
});
await repo.startTrip(100000, activity: Activity.motorcycle).then(
(h) async {
await repo.appendPoints([
TrackPoint(
tripId: h.tripId,
segmentId: h.segmentId,
timestamp: 100000,
latitude: 51.0,
longitude: -114.0,
speedKmh: 60,
altitudeM: 1000,
),
]);
await repo.renameTrip(h.tripId, 'Moto ride');
await repo.completeTrip(200000);
await repo.recomputeAggregates(h.tripId);
});
await tester.pumpWidget(host(const TripsScreen()));
await tester.pumpAndSettle();
expect(find.text('Bike ride'), findsOneWidget);
expect(find.text('Moto ride'), findsOneWidget);
await tester.tap(find.byKey(const Key('activity-filter')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.pump();
await tester.tap(find.byKey(const Key('activity-filter-bicycle')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.pump();
expect(find.text('Bike ride'), findsOneWidget);
expect(find.text('Moto ride'), findsNothing);
});
screenTest('Merge enables at exactly two selections', (tester) async {