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:
232
lib/src/ui/trips/trips_screen.dart
Normal file
232
lib/src/ui/trips/trips_screen.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user