diff --git a/lib/main.dart b/lib/main.dart index 03b5870..110bfee 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,125 +2,38 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'src/app/providers.dart'; -import 'src/recording/recording_engine.dart'; +import 'src/ui/router.dart'; +import 'src/ui/theme.dart'; -/// Phase 3 entry point. -/// -/// The UI proper arrives in Phase 4 (T15–T21). This is deliberately a thin harness: it -/// exists so the recording pipeline can be exercised on a real device before any screens -/// are built, because the pipeline is the part that cannot be validated by unit tests or -/// by an emulator. void main() { runApp(const ProviderScope(child: RipprApp())); } -class RipprApp extends StatelessWidget { +class RipprApp extends ConsumerStatefulWidget { const RipprApp({super.key}); @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'Rippr', - debugShowCheckedModeBanner: false, - theme: ThemeData( - colorScheme: ColorScheme.fromSeed( - seedColor: const Color(0xFFFF6B1A), // safety orange, matching the icon - brightness: Brightness.dark, - ), - useMaterial3: true, - ), - home: const _RecorderHarness(), - ); - } + ConsumerState createState() => _RipprAppState(); } -class _RecorderHarness extends ConsumerStatefulWidget { - const _RecorderHarness(); - - @override - ConsumerState<_RecorderHarness> createState() => _RecorderHarnessState(); -} - -class _RecorderHarnessState extends ConsumerState<_RecorderHarness> { - String? _error; +class _RipprAppState extends ConsumerState { + final _router = buildRouter(); @override void initState() { super.initState(); - // Re-attach to a ride that was in progress when the process died. Must happen before - // any user interaction, and the database is the only thing that knows. - WidgetsBinding.instance.addPostFrameCallback((_) async { - await ref.read(recordingEngineProvider).restoreAfterProcessDeath(); + // Re-attach to a ride that was in progress when the process died. Must run before any + // user interaction, and the database is the only thing that knows a ride was open. + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(recordingEngineProvider).restoreAfterProcessDeath(); }); } - Future _run(Future Function() action) async { - try { - await action(); - if (mounted) setState(() => _error = null); - } catch (e) { - if (mounted) setState(() => _error = '$e'); - } - } - @override - Widget build(BuildContext context) { - final engine = ref.watch(recordingEngineProvider); - final state = ref.watch(recorderStateProvider).valueOrNull ?? engine.state; - final trip = ref.watch(activeTripProvider).valueOrNull; - - return Scaffold( - appBar: AppBar(title: const Text('Rippr — recorder harness')), - body: Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text('State: ${state.name}', - style: Theme.of(context).textTheme.headlineSmall), - const SizedBox(height: 12), - if (trip != null) ...[ - Text('Trip ${trip.id} · ${trip.pointCount} points'), - Text('${trip.distanceM.toStringAsFixed(1)} m'), - Text('max ${trip.maxSpeedKmh.toStringAsFixed(1)} km/h'), - ] else - const Text('No active ride'), - if (_error != null) ...[ - const SizedBox(height: 12), - Text(_error!, style: const TextStyle(color: Colors.redAccent)), - ], - const Spacer(), - // 72dp targets, matching the native app's glove-sized controls. - SizedBox( - height: 72, - child: FilledButton( - onPressed: () => _run(engine.start), - child: Text(state == RecorderState.paused ? 'RESUME' : 'START'), - ), - ), - const SizedBox(height: 12), - SizedBox( - height: 72, - child: OutlinedButton( - onPressed: state == RecorderState.recording - ? () => _run(engine.pause) - : null, - child: const Text('PAUSE'), - ), - ), - const SizedBox(height: 12), - SizedBox( - height: 72, - child: OutlinedButton( - onPressed: state == RecorderState.idle - ? null - : () => _run(() async => engine.stop()), - child: const Text('STOP'), - ), - ), - ], - ), - ), - ); - } + Widget build(BuildContext context) => MaterialApp.router( + title: 'Rippr', + debugShowCheckedModeBanner: false, + theme: ripprTheme(), + routerConfig: _router, + ); } diff --git a/lib/src/app/providers.dart b/lib/src/app/providers.dart index ca5b4b9..17afb14 100644 --- a/lib/src/app/providers.dart +++ b/lib/src/app/providers.dart @@ -16,6 +16,7 @@ import '../domain/models.dart'; import '../recording/geolocator_location_source.dart'; import '../recording/location_source.dart'; import '../recording/recording_engine.dart'; +import '../telemetry/live_telemetry.dart'; /// The Drift database, opened against app-private storage. /// @@ -47,6 +48,10 @@ final recordingEngineProvider = Provider((ref) { return engine; }); +/// The live speedo feed, published from the fix callback at GPS rate. +final liveTelemetryProvider = + Provider((ref) => LiveTelemetry.instance); + // --- Reactive state --------------------------------------------------------- /// The in-progress ride, straight from the database. diff --git a/lib/src/ui/components/stats.dart b/lib/src/ui/components/stats.dart new file mode 100644 index 0000000..ce25bbb --- /dev/null +++ b/lib/src/ui/components/stats.dart @@ -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 confirmDialog( + BuildContext context, { + required String title, + required String message, + required String confirmLabel, +}) async { + final result = await showDialog( + 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), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/ui/detail/trip_detail_screen.dart b/lib/src/ui/detail/trip_detail_screen.dart new file mode 100644 index 0000000..63b7145 --- /dev/null +++ b/lib/src/ui/detail/trip_detail_screen.dart @@ -0,0 +1,360 @@ +/// Ported from `com.rippr.ui.detail.TripDetailScreen` and `TripDetailViewModel`. +/// +/// Unlike the trips list, this screen *does* read the point table — it has to, for the +/// authoritative summary, the charts and (in T19) the map. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../app/providers.dart'; +import '../../domain/models.dart'; +import '../../stats/ride_statistics.dart'; +import '../../telemetry/telemetry.dart'; +import '../components/stats.dart'; +import '../format.dart'; + +/// Everything the detail screen needs, computed once off the stored points. +class TripDetail { + const TripDetail({ + required this.trip, + required this.summary, + required this.points, + required this.segments, + required this.histogram, + required this.profile, + }); + + final Trip trip; + final RideSummary summary; + final List points; + final List segments; + final List histogram; + final List profile; +} + +final tripDetailProvider = + FutureProvider.autoDispose.family((ref, tripId) async { + final repo = ref.watch(tripRepositoryProvider); + final trip = await repo.tripById(tripId); + if (trip == null) return null; + + final points = await repo.pointsForTrip(tripId); + final segments = await repo.segmentsForTrip(tripId); + + return TripDetail( + trip: trip, + summary: computeSummary(points, segments: segments), + points: points, + segments: segments, + histogram: speedHistogram(points), + profile: elevationProfile(points), + ); +}); + +class TripDetailScreen extends ConsumerWidget { + const TripDetailScreen({super.key, required this.tripId, this.onBack}); + + final int tripId; + final VoidCallback? onBack; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final async = ref.watch(tripDetailProvider(tripId)); + + return Scaffold( + appBar: AppBar( + leading: IconButton( + key: const Key('back'), + icon: const Icon(Icons.arrow_back), + onPressed: onBack, + ), + title: Text(async.valueOrNull == null + ? 'Ride' + : tripLabel(async.value!.trip)), + actions: [ + if (async.valueOrNull != null) + IconButton( + key: const Key('rename'), + icon: const Icon(Icons.edit_outlined), + onPressed: () => _rename(context, ref, async.value!.trip), + ), + ], + ), + body: switch (async) { + AsyncValue(hasValue: true, value: null) => const EmptyState( + message: 'This ride no longer exists.', + icon: Icons.help_outline, + ), + AsyncValue(hasValue: true, value: final d?) => _Body(detail: d), + AsyncValue(hasError: true, :final error) => + EmptyState(message: 'Could not load this ride.\n$error'), + _ => const Center(child: CircularProgressIndicator()), + }, + ); + } + + Future _rename( + BuildContext context, WidgetRef ref, Trip trip) async { + final controller = TextEditingController(text: trip.name ?? ''); + final name = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Name this ride'), + content: TextField( + key: const Key('name-field'), + controller: controller, + autofocus: true, + decoration: const InputDecoration(hintText: 'Morning loop'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(controller.text), + child: const Text('Save'), + ), + ], + ), + ); + if (name == null) return; + // Empty collapses to null in the repository, so the date label comes back. + await ref.read(tripRepositoryProvider).renameTrip(trip.id, name); + ref.invalidate(tripDetailProvider(trip.id)); + } +} + +class _Body extends StatelessWidget { + const _Body({required this.detail}); + + final TripDetail detail; + + @override + Widget build(BuildContext context) { + final s = detail.summary; + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Center( + child: BigStat( + key: const Key('distance'), + label: 'DISTANCE', + value: (s.distanceM / 1000).toStringAsFixed(1), + unit: 'km', + ), + ), + const SizedBox(height: 24), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + StatRow( + label: 'Moving time', + value: formatDuration(s.movingMillis)), + StatRow( + label: 'Elapsed', value: formatDuration(s.elapsedMillis)), + StatRow( + label: 'Stopped', value: formatDuration(s.stoppedMillis)), + StatRow( + label: 'Max speed', value: formatSpeed(s.maxSpeedKmh)), + StatRow( + label: 'Avg moving speed', + value: formatSpeed(s.avgMovingSpeedKmh)), + StatRow( + label: 'Elevation gain', + value: formatElevation(s.elevationGainM)), + StatRow( + label: 'Elevation loss', + value: formatElevation(s.elevationLossM)), + StatRow(label: 'Points', value: '${s.pointCount}'), + StatRow(label: 'Segments', value: '${detail.segments.length}'), + ], + ), + ), + ), + const SizedBox(height: 24), + _SectionTitle('Speed distribution'), + _Histogram(buckets: detail.histogram), + const SizedBox(height: 24), + _SectionTitle('Elevation profile'), + _ElevationChart(samples: detail.profile), + ], + ); + } +} + +class _SectionTitle extends StatelessWidget { + const _SectionTitle(this.text); + + final String text; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + text.toUpperCase(), + style: TextStyle( + fontSize: 12, + letterSpacing: 2, + color: Theme.of(context).colorScheme.outline, + ), + ), + ); +} + +/// Time in each speed band. +/// +/// Degrades to a message rather than an empty box: a ride of one point has no intervals +/// to measure, and an unexplained blank looks like a bug. +class _Histogram extends StatelessWidget { + const _Histogram({required this.buckets}); + + final List buckets; + + @override + Widget build(BuildContext context) { + if (buckets.isEmpty) { + return const EmptyState(message: 'Not enough points to chart.'); + } + final colors = Theme.of(context).colorScheme; + final maxMillis = + buckets.map((b) => b.millis).reduce((a, b) => a > b ? a : b); + + return Column( + children: [ + for (final b in buckets) + Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + children: [ + SizedBox( + width: 64, + child: Text(b.label, + style: TextStyle(fontSize: 12, color: colors.outline)), + ), + Expanded( + child: LayoutBuilder( + builder: (context, c) => Stack( + children: [ + Container( + height: 14, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(3), + ), + ), + Container( + height: 14, + width: maxMillis == 0 + ? 0 + : c.maxWidth * (b.millis / maxMillis), + decoration: BoxDecoration( + color: colors.primary, + borderRadius: BorderRadius.circular(3), + ), + ), + ], + ), + ), + ), + SizedBox( + width: 72, + child: Text( + formatDuration(b.millis), + textAlign: TextAlign.right, + style: TextStyle(fontSize: 12, color: colors.outline), + ), + ), + ], + ), + ), + ], + ); + } +} + +/// Altitude against distance travelled. +/// +/// Needs at least two points to draw a line — below that it says so. +class _ElevationChart extends StatelessWidget { + const _ElevationChart({required this.samples}); + + final List samples; + + @override + Widget build(BuildContext context) { + if (samples.length < 2) { + return const EmptyState(message: 'Not enough points to chart.'); + } + return SizedBox( + height: 140, + child: CustomPaint( + painter: _ElevationPainter( + samples: samples, + line: Theme.of(context).colorScheme.primary, + fill: Theme.of(context).colorScheme.primary.withValues(alpha: 0.18), + ), + child: const SizedBox.expand(), + ), + ); + } +} + +class _ElevationPainter extends CustomPainter { + _ElevationPainter({ + required this.samples, + required this.line, + required this.fill, + }); + + final List samples; + final Color line; + final Color fill; + + @override + void paint(Canvas canvas, Size size) { + var minAlt = samples.first.altitudeM; + var maxAlt = samples.first.altitudeM; + for (final s in samples) { + if (s.altitudeM < minAlt) minAlt = s.altitudeM; + if (s.altitudeM > maxAlt) maxAlt = s.altitudeM; + } + // A dead-flat ride would divide by zero; give it a nominal band so the line sits + // centred rather than vanishing. + final span = (maxAlt - minAlt).abs() < 1e-9 ? 1.0 : maxAlt - minAlt; + final totalDistance = samples.last.distanceM; + final xSpan = totalDistance <= 0 ? 1.0 : totalDistance; + + final path = Path(); + for (var i = 0; i < samples.length; i++) { + final x = size.width * (samples[i].distanceM / xSpan); + final y = + size.height * (1 - (samples[i].altitudeM - minAlt) / span); + if (i == 0) { + path.moveTo(x, y); + } else { + path.lineTo(x, y); + } + } + + final area = Path.from(path) + ..lineTo(size.width, size.height) + ..lineTo(0, size.height) + ..close(); + + canvas.drawPath(area, Paint()..color = fill); + canvas.drawPath( + path, + Paint() + ..color = line + ..style = PaintingStyle.stroke + ..strokeWidth = 2, + ); + } + + @override + bool shouldRepaint(_ElevationPainter old) => old.samples != samples; +} diff --git a/lib/src/ui/record/record_screen.dart b/lib/src/ui/record/record_screen.dart new file mode 100644 index 0000000..8608c49 --- /dev/null +++ b/lib/src/ui/record/record_screen.dart @@ -0,0 +1,379 @@ +/// Ported from `com.rippr.ui.record.RecordScreen` and `RecordViewModel`. +library; + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../app/providers.dart'; +import '../../domain/models.dart'; +import '../../recording/location_source.dart'; +import '../../telemetry/telemetry.dart'; +import '../components/stats.dart'; +import '../format.dart'; + +/// Everything the recording screen draws, in one snapshot. +class RecordUiState { + const RecordUiState({ + this.trip, + this.uploadError, + this.currentSpeedKmh = 0, + this.elapsedMillis = 0, + }); + + final Trip? trip; + final String? uploadError; + final double currentSpeedKmh; + + /// Wall clock since the ride began, ticking every second. + final int elapsedMillis; + + bool get isIdle => trip == null; + bool get isRecording => trip?.state == TripState.recording; + bool get isPaused => trip?.state == TripState.paused; +} + +class RecordScreen extends ConsumerStatefulWidget { + const RecordScreen({super.key, this.onOpenTrips}); + + final VoidCallback? onOpenTrips; + + @override + ConsumerState createState() => _RecordScreenState(); +} + +class _RecordScreenState extends ConsumerState { + /// Drives the elapsed clock. + /// + /// The Trip row only advances `movingMillis`, and only on a ~2 s flush while actually + /// moving, so without this the screen looks frozen at a traffic light — which is + /// exactly how it read on the first real ride of v2.0. + /// + /// Runs **only while a ride is active**. An idle screen has no clock to advance, so + /// ticking then would be pure wasted rebuilds — and it makes `pumpAndSettle` in widget + /// tests spin forever, since a repeating timer that calls setState never settles. + Timer? _ticker; + int _now = DateTime.now().millisecondsSinceEpoch; + + double _liveSpeed = 0; + StreamSubscription? _speedSub; + String? _error; + + @override + void initState() { + super.initState(); + _speedSub = ref.read(liveTelemetryProvider).speedStream.listen((s) { + if (mounted) setState(() => _liveSpeed = s); + }); + // Start and stop the clock with the ride itself. + ref.listenManual>(activeTripProvider, (previous, next) { + next.whenData((trip) => _setTicking(trip != null)); + }, fireImmediately: true); + } + + @override + void dispose() { + _ticker?.cancel(); + _speedSub?.cancel(); + super.dispose(); + } + + void _setTicking(bool ticking) { + if (ticking && _ticker == null) { + _ticker = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) { + setState(() => _now = DateTime.now().millisecondsSinceEpoch); + } + }); + } else if (!ticking) { + _ticker?.cancel(); + _ticker = null; + } + } + + Future _guard(Future Function() action) async { + try { + await action(); + if (mounted) setState(() => _error = null); + } on LocationException catch (e) { + if (!mounted) return; + setState( + () => _error = switch (e.failure) { + LocationFailure.serviceDisabled => + 'Location services are switched off', + LocationFailure.permissionDenied => + 'Location permission is needed to record a ride', + LocationFailure.permissionDeniedForever => + 'Location permission was denied. Enable it in Settings.', + }, + ); + } + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final engine = ref.watch(recordingEngineProvider); + final trip = ref.watch(activeTripProvider).valueOrNull; + + final ui = RecordUiState( + trip: trip, + currentSpeedKmh: _liveSpeed, + uploadError: _error, + elapsedMillis: trip == null + ? 0 + : (_now - trip.startedAt).clamp(0, 1 << 62), + ); + + return Scaffold( + // Scrollable, but still centred when there is room. + // + // With a ride active the card grows to six stat rows, which overflows a short + // screen. Compose clipped this silently; Flutter reports it, and a widget test + // caught it immediately. Glove-sized 72dp controls make the content tall enough + // that this is a real risk on small phones, not a theoretical one. + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'RIPPR', + style: TextStyle( + fontSize: 34, + fontWeight: FontWeight.w900, + letterSpacing: 6, + color: colors.primary, + ), + ), + TextButton( + onPressed: widget.onOpenTrips, + child: const Text('Rides'), + ), + ], + ), + const SizedBox(height: 20), + + // Current speed, not max: a max figure only moves when you beat it, which + // reads as a frozen screen while riding steadily. This is the 2.0.1 fix and + // it must not regress. + BigStat( + key: const Key('speed'), + label: 'SPEED', + value: ui.currentSpeedKmh.toStringAsFixed(0), + unit: 'km/h', + ), + const SizedBox(height: 16), + + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + if (ui.isIdle) ...[ + // A resting state, not a zeroed ride — otherwise the screen looks + // like a recording that is going nowhere. + const StatRow(label: 'Status', value: 'Ready'), + const StatRow( + label: 'Last ride', + value: 'See Rides', + ), + ] else ...[ + StatRow( + label: 'Distance', + value: formatDistance(ui.trip!.distanceM), + ), + StatRow( + label: 'Elapsed', + value: formatDuration(ui.elapsedMillis), + ), + StatRow( + label: 'Moving time', + value: formatDuration(ui.trip!.movingMillis), + ), + StatRow( + label: 'Max speed', + value: formatSpeed(ui.trip!.maxSpeedKmh), + ), + StatRow( + label: 'Points captured', + value: '${ui.trip!.pointCount}', + ), + StatRow( + label: 'Elevation gain', + value: formatElevation(ui.trip!.elevationGainM), + ), + if (ui.isPaused) + const StatRow(label: 'Status', value: 'Paused'), + ], + if (ui.uploadError != null) + StatRow(label: 'Problem', value: ui.uploadError!), + ], + ), + ), + ), + + const SizedBox(height: 28), + _Controls( + ui: ui, + onStart: () => _guard(engine.start), + onPause: () => _guard(engine.pause), + onStop: () => _guard(() async => engine.stop()), + onDiscard: () async { + final confirmed = await confirmDialog( + context, + title: 'Discard this ride?', + message: + '${ui.trip?.pointCount ?? 0} points recorded over ' + '${formatDistance(ui.trip?.distanceM ?? 0)} will be ' + 'permanently deleted.', + confirmLabel: 'Discard', + ); + if (confirmed) await _guard(engine.discard); + }, + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _Controls extends StatelessWidget { + const _Controls({ + required this.ui, + required this.onStart, + required this.onPause, + required this.onStop, + required this.onDiscard, + }); + + final RecordUiState ui; + final VoidCallback onStart; + final VoidCallback onPause; + final VoidCallback onStop; + final VoidCallback onDiscard; + + @override + Widget build(BuildContext context) { + if (ui.isIdle) { + return _PrimaryButton( + label: 'START RECORDING', + onPressed: onStart, + keyName: 'start', + ); + } + + if (ui.isRecording) { + return Column( + children: [ + _PrimaryButton(label: 'PAUSE', onPressed: onPause, keyName: 'pause'), + const SizedBox(height: 12), + _SecondaryButton(label: 'STOP', onPressed: onStop, keyName: 'stop'), + ], + ); + } + + // Discard is offered only while paused. Putting a destructive action next to Pause + // during a live ride invites a gloved mis-tap at speed. + return Column( + children: [ + _PrimaryButton(label: 'RESUME', onPressed: onStart, keyName: 'resume'), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _SecondaryButton( + label: 'STOP', + onPressed: onStop, + keyName: 'stop', + ), + ), + const SizedBox(width: 12), + Expanded( + child: _SecondaryButton( + label: 'DISCARD', + onPressed: onDiscard, + keyName: 'discard', + destructive: true, + ), + ), + ], + ), + ], + ); + } +} + +/// 72dp tall throughout — these get pressed with gloves on. +class _PrimaryButton extends StatelessWidget { + const _PrimaryButton({ + required this.label, + required this.onPressed, + required this.keyName, + }); + + final String label; + final VoidCallback onPressed; + final String keyName; + + @override + Widget build(BuildContext context) => SizedBox( + width: double.infinity, + height: 72, + child: FilledButton( + key: Key(keyName), + onPressed: onPressed, + child: Text( + label, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + ), + ); +} + +class _SecondaryButton extends StatelessWidget { + const _SecondaryButton({ + required this.label, + required this.onPressed, + required this.keyName, + this.destructive = false, + }); + + final String label; + final VoidCallback onPressed; + final String keyName; + final bool destructive; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return SizedBox( + height: 56, + child: OutlinedButton( + key: Key(keyName), + onPressed: onPressed, + style: OutlinedButton.styleFrom( + foregroundColor: destructive ? colors.error : colors.onSurface, + ), + child: Text( + label, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ), + ); + } +} diff --git a/lib/src/ui/router.dart b/lib/src/ui/router.dart new file mode 100644 index 0000000..7821d8e --- /dev/null +++ b/lib/src/ui/router.dart @@ -0,0 +1,70 @@ +/// Ported from `com.rippr.ui.RipprNavHost`. +/// +/// Three destinations, same shape as the Compose original. `go_router` replaces +/// `navigation-compose`; the path parameter is parsed explicitly, mirroring the Kotlin +/// comment about ids arriving as the wrong type when the argument type is left implicit. +library; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import 'detail/trip_detail_screen.dart'; +import 'record/record_screen.dart'; +import 'trips/trips_screen.dart'; + +abstract final class Routes { + static const record = '/'; + static const trips = '/trips'; + static const tripDetail = '/trip/:tripId'; + + static String detailFor(int tripId) => '/trip/$tripId'; +} + +GoRouter buildRouter() => GoRouter( + initialLocation: Routes.record, + routes: [ + GoRoute( + path: Routes.record, + builder: (context, state) => RecordScreen( + onOpenTrips: () => context.push(Routes.trips), + ), + ), + GoRoute( + path: Routes.trips, + builder: (context, state) => TripsScreen( + onOpenTrip: (id) => context.push(Routes.detailFor(id)), + onBack: () => context.pop(), + ), + ), + GoRoute( + path: Routes.tripDetail, + builder: (context, state) { + // Parsed explicitly. A malformed or missing id must land on a real screen + // saying so, never on a blank one or a crash. + final id = int.tryParse(state.pathParameters['tripId'] ?? ''); + if (id == null) { + return const _NotFound(); + } + return TripDetailScreen(tripId: id, onBack: () => context.pop()); + }, + ), + ], + errorBuilder: (context, state) => const _NotFound(), + ); + +class _NotFound extends StatelessWidget { + const _NotFound(); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.canPop() + ? context.pop() + : context.go(Routes.record), + ), + ), + body: const Center(child: Text('That ride could not be found.')), + ); +} diff --git a/lib/src/ui/theme.dart b/lib/src/ui/theme.dart new file mode 100644 index 0000000..3d6328d --- /dev/null +++ b/lib/src/ui/theme.dart @@ -0,0 +1,65 @@ +/// Ported from `com.rippr.ui.theme.RipprTheme`. +/// +/// Dark-only by design. This is read at a glance in daylight with a visor down, and a +/// light theme would be actively worse for that. +library; + +import 'package:flutter/material.dart'; + +// Matches the launcher icon: near-black tarmac with a safety-orange accent. +const _accent = Color(0xFFFF5722); +const _ground = Color(0xFF101418); +const _surface = Color(0xFF181D23); +const _ink = Color(0xFFF2F5F8); +const _outline = Color(0xFF66727E); +const _danger = Color(0xFFE53935); + +const ripprColors = ColorScheme.dark( + primary: _accent, + onPrimary: Color(0xFF100A06), + secondary: _outline, + surface: _surface, + onSurface: _ink, + surfaceContainerHighest: Color(0xFF212832), + onSurfaceVariant: _ink, + outline: _outline, + error: _danger, + onError: _ink, +); + +/// The near-black ground the whole app sits on. +const ripprBackground = _ground; + +ThemeData ripprTheme() { + final base = ThemeData( + colorScheme: ripprColors, + useMaterial3: true, + scaffoldBackgroundColor: _ground, + ); + + return base.copyWith( + // Compose's `Surface` was load-bearing, not decoration: it set `LocalContentColor`. + // Without it, any Text that did not name a colour fell back to black — invisible on + // this near-black ground, and **no test caught it; only a screenshot did**. + // + // Flutter's equivalent exposure is the default text colour. Setting it explicitly + // here, rather than relying on a wrapping widget, means the failure cannot recur by + // someone removing a container. + textTheme: base.textTheme.apply(bodyColor: _ink, displayColor: _ink), + appBarTheme: const AppBarTheme( + backgroundColor: _ground, + foregroundColor: _ink, + elevation: 0, + ), + dividerTheme: const DividerThemeData(color: Color(0xFF2A323C), space: 1), + ); +} + +/// Monospace for anything numeric. +/// +/// Digits that change every second must not make the layout jitter, which proportional +/// figures do. Used for every speed, distance and clock in the app. +const TextStyle monoDigits = TextStyle( + fontFamily: 'monospace', + fontFeatures: [FontFeature.tabularFigures()], +); diff --git a/lib/src/ui/trips/trips_screen.dart b/lib/src/ui/trips/trips_screen.dart new file mode 100644 index 0000000..e1ea7da --- /dev/null +++ b/lib/src/ui/trips/trips_screen.dart @@ -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>((ref) => {}); + +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 []; + + // 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 = {}; + + 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 _confirmDelete( + BuildContext context, WidgetRef ref, Set 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 = {}; + } + + Future _confirmMerge(BuildContext context, WidgetRef ref, + List trips, Set selection) async { + final total = trips + .where((t) => selection.contains(t.id)) + .fold(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 = {}; + } +} + +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), + ), + ), + ); + } +} diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..d772d6a --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,341 @@ +import 'package:drift/drift.dart' show driftRuntimeOptions; +import 'package:drift/native.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/data/database.dart'; +import 'package:rippr/src/data/trip_repository.dart'; +import 'package:rippr/src/domain/models.dart'; +import 'package:rippr/src/recording/location_source.dart'; +import 'package:rippr/src/ui/detail/trip_detail_screen.dart'; +import 'package:rippr/src/ui/record/record_screen.dart'; +import 'package:rippr/src/ui/theme.dart'; +import 'package:rippr/src/ui/trips/trips_screen.dart'; + +/// **The first UI tests this project has ever had.** +/// +/// `docs/v3/BACKLOG.md` names "zero UI tests across six screens" as v2's largest coverage +/// gap: everything was verified by manual screenshot, which is how a 64 sp speed figure +/// once shipped rendering black-on-black. Flutter makes these cheap enough that carrying +/// that debt into the port would be indefensible. +void main() { + late AppDatabase db; + late TripRepository repo; + late FakeLocationSource source; + + setUp(() { + driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; + db = AppDatabase(NativeDatabase.memory()); + repo = TripRepository(db); + source = FakeLocationSource(); + }); + + tearDown(() async { + await source.dispose(); + await db.close(); + }); + + /// Runs a widget test and then tears the tree down cleanly. + /// + /// `flutter_test` asserts that no `Timer` is pending once the tree is disposed. Two + /// things here legitimately hold one: the elapsed clock on the record screen, and + /// Drift's stream-query cache, which keeps a query alive briefly after its last + /// listener leaves so re-subscribing is cheap. Removing the tree cancels the first; + /// pumping past the keep-alive window clears the second. + void screenTest(String description, Future Function(WidgetTester) body) { + testWidgets(description, (tester) async { + await body(tester); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(seconds: 2)); + }); + } + + /// Pumps a screen that has a live ride on it. + /// + /// `pumpAndSettle` cannot be used once a ride is active: the elapsed clock ticks every + /// second forever, so "settled" never arrives. Two explicit frames are enough to build + /// and then reflect the streamed trip. + Future pumpLive(WidgetTester tester, Widget widget) async { + await tester.pumpWidget(widget); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + } + + /// Wraps a screen with the real theme and an in-memory stack. + /// + /// The theme is deliberately included: the black-on-black bug was invisible to logic + /// tests and only a rendered widget can catch its equivalent. + Widget host(Widget child) => ProviderScope( + overrides: [ + databaseProvider.overrideWithValue(db), + locationSourceProvider.overrideWithValue(source), + ], + child: MaterialApp(theme: ripprTheme(), home: child), + ); + + Future seedCompletedTrip({ + required int startedAt, + required int endedAt, + String? name, + int points = 5, + double startLat = 51.0, + }) async { + final h = await repo.startTrip(startedAt); + await repo.appendPoints([ + for (var i = 0; i < points; i++) + TrackPoint( + tripId: h.tripId, + segmentId: h.segmentId, + timestamp: startedAt + i * 1000, + latitude: startLat + i * 0.0001, + longitude: -114.0, + speedKmh: 40.0 + i, + altitudeM: 1000.0 + i, + ), + ]); + if (name != null) await repo.renameTrip(h.tripId, name); + await repo.completeTrip(endedAt); + await repo.recomputeAggregates(h.tripId); + return h.tripId; + } + + group('record screen', () { + screenTest('idle shows Ready and only START', (tester) async { + await tester.pumpWidget(host(const RecordScreen())); + await tester.pumpAndSettle(); + + expect(find.text('Ready'), findsOneWidget); + expect(find.byKey(const Key('start')), findsOneWidget); + expect(find.byKey(const Key('pause')), findsNothing); + expect(find.byKey(const Key('stop')), findsNothing); + // A resting screen must not look like a ride going nowhere. + expect(find.text('Distance'), findsNothing); + }); + + screenTest('the headline is live speed, not max speed', (tester) async { + // v2.0 shipped max speed as the headline and it read as a frozen, broken screen on + // a real ride, because a max figure only moves when you beat it. This guards the + // 2.0.1 fix. + await tester.pumpWidget(host(const RecordScreen())); + await tester.pumpAndSettle(); + + expect(find.text('SPEED'), findsOneWidget); + expect(find.text('MAX SPEED'), findsNothing); + }); + + screenTest('recording swaps to PAUSE and STOP, with no DISCARD', + (tester) async { + await repo.startTrip(1000); + await pumpLive(tester, host(const RecordScreen())); + + expect(find.byKey(const Key('pause')), findsOneWidget); + expect(find.byKey(const Key('stop')), findsOneWidget); + // Destructive actions must not sit next to Pause during a live ride — a gloved + // mis-tap at speed would lose the whole recording. + expect(find.byKey(const Key('discard')), findsNothing); + expect(find.byKey(const Key('start')), findsNothing); + }); + + screenTest('paused offers RESUME, STOP and DISCARD', (tester) async { + await repo.startTrip(1000); + await repo.pauseTrip(2000); + await pumpLive(tester, host(const RecordScreen())); + + expect(find.byKey(const Key('resume')), findsOneWidget); + expect(find.byKey(const Key('stop')), findsOneWidget); + expect(find.byKey(const Key('discard')), findsOneWidget); + expect(find.text('Paused'), findsOneWidget); + }); + + screenTest('discard asks before destroying anything', (tester) async { + await repo.startTrip(1000); + await repo.pauseTrip(2000); + await pumpLive(tester, host(const RecordScreen())); + + await tester.tap(find.byKey(const Key('discard'))); + await tester.pumpAndSettle(); + + expect(find.text('Discard this ride?'), findsOneWidget); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(await repo.activeTrip(), isNotNull, + reason: 'cancelling must not delete the ride'); + }); + + 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 + // test could catch it. This asserts the rendered colour differs from the ground. + await tester.pumpWidget(host(const RecordScreen())); + await tester.pumpAndSettle(); + + final speed = tester.widget( + find.descendant( + of: find.byKey(const Key('speed')), + matching: find.text('0'), + ), + ); + final colour = speed.style?.color; + expect(colour, isNotNull, reason: 'the headline must name its colour'); + expect(colour, isNot(ripprBackground)); + expect(colour, isNot(Colors.black)); + }); + }); + + group('trips list', () { + screenTest('empty state explains what to do', (tester) async { + await tester.pumpWidget(host(const TripsScreen())); + await tester.pumpAndSettle(); + + expect(find.textContaining('No rides yet'), findsOneWidget); + }); + + screenTest('completed rides are listed newest first', (tester) async { + await seedCompletedTrip( + startedAt: 1000, endedAt: 5000, name: 'Older ride'); + await seedCompletedTrip( + startedAt: 100000, endedAt: 200000, name: 'Newer ride'); + + await tester.pumpWidget(host(const TripsScreen())); + await tester.pumpAndSettle(); + + final newer = tester.getTopLeft(find.text('Newer ride')).dy; + final older = tester.getTopLeft(find.text('Older ride')).dy; + expect(newer, lessThan(older)); + }); + + screenTest('an active ride does not appear in the list', (tester) async { + await seedCompletedTrip( + startedAt: 1000, endedAt: 5000, name: 'Finished'); + await repo.startTrip(100000); + + await tester.pumpWidget(host(const TripsScreen())); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.byKey(const Key('trip-1')), findsOneWidget); + expect(find.text('Finished'), findsOneWidget); + // Only the completed one. + expect(find.byType(Card), findsOneWidget); + }); + + screenTest('Merge enables at exactly two selections', (tester) async { + final a = await seedCompletedTrip(startedAt: 1000, endedAt: 5000); + final b = await seedCompletedTrip(startedAt: 10000, endedAt: 15000); + final c = await seedCompletedTrip(startedAt: 20000, endedAt: 25000); + + await tester.pumpWidget(host(const TripsScreen())); + await tester.pumpAndSettle(); + + // One selected: merge is present but disabled. + await tester.longPress(find.byKey(Key('trip-$a'))); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byKey(const Key('merge'))).onPressed, + isNull, + reason: 'merging one ride is meaningless', + ); + + // Two: enabled. + await tester.tap(find.byKey(Key('trip-$b'))); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byKey(const Key('merge'))).onPressed, + isNotNull, + ); + + // Three: ambiguous, so disabled again. + await tester.tap(find.byKey(Key('trip-$c'))); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byKey(const Key('merge'))).onPressed, + isNull, + reason: 'merge combines exactly two rides', + ); + }); + + screenTest('deleting asks first and then removes the ride', + (tester) async { + final a = await seedCompletedTrip(startedAt: 1000, endedAt: 5000); + + await tester.pumpWidget(host(const TripsScreen())); + await tester.pumpAndSettle(); + + await tester.longPress(find.byKey(Key('trip-$a'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('delete'))); + await tester.pumpAndSettle(); + + expect(find.textContaining('Delete 1 ride'), findsOneWidget); + // "Delete" appears both in the toolbar and in the dialog; target the dialog's. + await tester.tap(find.descendant( + of: find.byType(AlertDialog), + matching: find.text('Delete'), + )); + await tester.pumpAndSettle(); + + expect(await repo.tripById(a), isNull); + }); + }); + + group('trip detail', () { + screenTest('shows the authoritative summary', (tester) async { + final id = await seedCompletedTrip( + startedAt: 1000, endedAt: 5000, name: 'Sunday blast', points: 11); + + await tester.pumpWidget(host(TripDetailScreen(tripId: id))); + await tester.pumpAndSettle(); + + expect(find.text('Sunday blast'), findsOneWidget); + expect(find.text('DISTANCE'), findsOneWidget); + expect(find.text('Max speed'), findsOneWidget); + expect(find.text('Segments'), findsOneWidget); + }); + + screenTest('a missing ride says so instead of rendering blank', + (tester) async { + await tester.pumpWidget(host(const TripDetailScreen(tripId: 9999))); + await tester.pumpAndSettle(); + + expect(find.textContaining('no longer exists'), findsOneWidget); + }); + + screenTest('charts degrade gracefully below two points', (tester) async { + final id = await seedCompletedTrip( + startedAt: 1000, endedAt: 2000, points: 1); + + await tester.pumpWidget(host(TripDetailScreen(tripId: id))); + await tester.pumpAndSettle(); + + // Both charts need intervals; one point has none. An unexplained blank box would + // read as a bug. + // + // The detail body is a ListView, so the second chart is not built until it is + // scrolled into view -- asserting without scrolling would silently check only one. + expect(find.textContaining('Not enough points'), findsOneWidget); + await tester.drag(find.byType(ListView), const Offset(0, -600)); + await tester.pumpAndSettle(); + expect(find.textContaining('Not enough points'), findsWidgets); + expect(find.text('Elevation profile'.toUpperCase()), findsOneWidget); + }); + + screenTest('renaming updates the title', (tester) async { + final id = await seedCompletedTrip(startedAt: 1000, endedAt: 5000); + + await tester.pumpWidget(host(TripDetailScreen(tripId: id))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('rename'))); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(const Key('name-field')), 'Coast run'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(find.text('Coast run'), findsOneWidget); + expect((await repo.tripById(id))!.name, 'Coast run'); + }); + }); +}