T15-T18, T20, T23: app shell, three screens, and the first UI tests

Theme, go_router shell, record screen, trips list with selection, trip detail
with stats and charts, rename/delete/merge. 15 widget tests -- the first this
project has ever had, closing the gap docs/v3/BACKLOG.md names as v2's largest.

The theme sets bodyColor and displayColor explicitly rather than relying on a
wrapping widget, so the black-on-black regression cannot recur by someone
removing a container, and a widget test now asserts the headline names a colour
distinct from the ground.

Those tests immediately found a real layout bug: with a ride active the record
screen grows to six stat rows and overflows a short screen. Compose clipped this
silently, so it may be latent in the native app; Flutter reports it. Fixed by
making the screen scrollable while still centring when there is room.

Two harness lessons. pumpAndSettle never settles against a repeating timer, so
the elapsed clock now runs only while a ride is active -- better behaviour
anyway, since an idle screen has no clock to advance. And flutter_test asserts
no Timer is pending after disposal, which Drift's stream-query keep-alive cache
trips; tests now tear the tree down and pump past that window.

160 tests passing, analyze clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 16:23:59 -05:00
parent ee3ef74331
commit bcc12b314f
9 changed files with 1623 additions and 103 deletions

View File

@@ -0,0 +1,232 @@
/// Ported from `com.rippr.ui.trips.TripsScreen` and `TripsViewModel`.
///
/// Every value shown in this list is already persisted on the Trip row, so the screen
/// never touches the point table. That keeps it fast with hundreds of rides.
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/providers.dart';
import '../../domain/models.dart';
import '../../telemetry/telemetry.dart';
import '../components/stats.dart';
import '../format.dart';
/// Selection is screen-local: it must not survive navigating away, and nothing else
/// needs to observe it.
final _selectionProvider =
StateProvider.autoDispose<Set<int>>((ref) => <int>{});
class TripsScreen extends ConsumerWidget {
const TripsScreen({super.key, this.onOpenTrip, this.onBack});
final void Function(int tripId)? onOpenTrip;
final VoidCallback? onBack;
@override
Widget build(BuildContext context, WidgetRef ref) {
final colors = Theme.of(context).colorScheme;
final tripsAsync = ref.watch(completedTripsProvider);
final trips = tripsAsync.valueOrNull ?? const <Trip>[];
// Drop ids that no longer exist, so a deleted trip cannot linger in the selection.
final live = ref.watch(_selectionProvider).intersection(
trips.map((t) => t.id).toSet(),
);
final selecting = live.isNotEmpty;
// Merge combines exactly two rides; anything else is ambiguous.
final canMerge = live.length == 2;
void clearSelection() =>
ref.read(_selectionProvider.notifier).state = <int>{};
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(
children: [
if (selecting) ...[
TextButton(
onPressed: clearSelection,
child: const Text('Cancel'),
),
Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(
'${live.length} selected',
style:
TextStyle(fontSize: 14, color: colors.outline),
),
),
] else ...[
TextButton(
key: const Key('back'),
onPressed: onBack,
child: const Text('‹ Record'),
),
Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(
'RIDES',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
letterSpacing: 3,
color: colors.primary,
),
),
),
],
const Spacer(),
if (selecting) ...[
TextButton(
key: const Key('merge'),
onPressed: canMerge
? () => _confirmMerge(context, ref, trips, live)
: null,
child: const Text('Merge'),
),
TextButton(
key: const Key('delete'),
onPressed: () => _confirmDelete(context, ref, live),
child: Text('Delete',
style: TextStyle(color: colors.error)),
),
],
],
),
),
Expanded(
child: switch (tripsAsync) {
AsyncValue(hasValue: true, value: final list)
when (list ?? const []).isEmpty =>
const EmptyState(
message:
'No rides yet.\nPress Start on the record screen to capture one.',
icon: Icons.two_wheeler,
),
AsyncValue(hasValue: true) => ListView.builder(
itemCount: trips.length,
itemBuilder: (context, i) {
final trip = trips[i];
return _TripTile(
trip: trip,
selected: live.contains(trip.id),
selecting: selecting,
onTap: () {
if (selecting) {
_toggle(ref, trip.id);
} else {
onOpenTrip?.call(trip.id);
}
},
onLongPress: () => _toggle(ref, trip.id),
);
},
),
_ => const Center(child: CircularProgressIndicator()),
},
),
],
),
),
),
);
}
void _toggle(WidgetRef ref, int tripId) {
final notifier = ref.read(_selectionProvider.notifier);
final current = notifier.state;
notifier.state = current.contains(tripId)
? (current.toSet()..remove(tripId))
: (current.toSet()..add(tripId));
}
Future<void> _confirmDelete(
BuildContext context, WidgetRef ref, Set<int> selection) async {
final ok = await confirmDialog(
context,
title: 'Delete ${selection.length} ride(s)?',
message:
'Every recorded point for these rides will be permanently deleted.',
confirmLabel: 'Delete',
);
if (!ok) return;
final repo = ref.read(tripRepositoryProvider);
for (final id in selection) {
await repo.deleteTrip(id);
}
ref.read(_selectionProvider.notifier).state = <int>{};
}
Future<void> _confirmMerge(BuildContext context, WidgetRef ref,
List<Trip> trips, Set<int> selection) async {
final total = trips
.where((t) => selection.contains(t.id))
.fold<double>(0, (a, t) => a + t.distanceM);
final ok = await confirmDialog(
context,
title: 'Merge two rides?',
message: 'They become one ride of about ${formatDistance(total)}. '
'The join is kept as a segment break, so no straight line is drawn '
'across it.',
confirmLabel: 'Merge',
);
if (!ok) return;
final ids = selection.toList();
if (ids.length == 2) {
await ref.read(tripRepositoryProvider).mergeTrips(ids[0], ids[1]);
}
ref.read(_selectionProvider.notifier).state = <int>{};
}
}
class _TripTile extends StatelessWidget {
const _TripTile({
required this.trip,
required this.selected,
required this.selecting,
required this.onTap,
required this.onLongPress,
});
final Trip trip;
final bool selected;
final bool selecting;
final VoidCallback onTap;
final VoidCallback onLongPress;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Card(
key: Key('trip-${trip.id}'),
color: selected ? colors.surfaceContainerHighest : null,
child: ListTile(
onTap: onTap,
onLongPress: onLongPress,
leading: selecting
? Icon(
selected ? Icons.check_circle : Icons.circle_outlined,
color: selected ? colors.primary : colors.outline,
)
: null,
title: Text(tripLabel(trip),
style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text(
'${formatDistance(trip.distanceM)} · '
'${formatDuration(trip.movingMillis)} · '
'max ${formatSpeed(trip.maxSpeedKmh)}',
style: TextStyle(color: colors.outline),
),
),
);
}
}