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,155 @@
/// Shared display components.
///
/// Ported from `com.rippr.ui.components.Stats`.
library;
import 'package:flutter/material.dart';
import '../theme.dart';
/// The one number that matters, rendered large.
///
/// Keyed so widget tests can find a specific readout by name rather than by matching
/// text that may legitimately appear twice on screen.
class BigStat extends StatelessWidget {
const BigStat({
super.key,
required this.label,
required this.value,
required this.unit,
});
final String label;
final String value;
final String unit;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: TextStyle(
fontSize: 13,
letterSpacing: 2,
color: colors.outline,
),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
value,
style: monoDigits.copyWith(
fontSize: 64,
fontWeight: FontWeight.bold,
// Named explicitly. The Compose version rendered black-on-black when the
// ambient content colour went missing.
color: colors.onSurface,
),
),
Text(
' $unit',
style: TextStyle(fontSize: 18, color: colors.outline),
),
],
),
],
);
}
}
class StatRow extends StatelessWidget {
const StatRow({super.key, required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: TextStyle(color: colors.outline)),
Text(
value,
style: monoDigits.copyWith(
fontWeight: FontWeight.w500,
color: colors.onSurface,
),
),
],
),
);
}
}
/// Confirmation for anything destructive. Returns true only if confirmed.
Future<bool> confirmDialog(
BuildContext context, {
required String title,
required String message,
required String confirmLabel,
}) async {
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(
confirmLabel,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
],
),
);
return result ?? false;
}
/// A ride's worth of nothing. Used by the trips list and the detail charts.
class EmptyState extends StatelessWidget {
const EmptyState({super.key, required this.message, this.icon});
final String message;
final IconData? icon;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 48, color: colors.outline),
const SizedBox(height: 16),
],
Text(
message,
textAlign: TextAlign.center,
style: TextStyle(color: colors.outline),
),
],
),
),
);
}
}