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

View File

@@ -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<TrackPoint> points;
final List<Segment> segments;
final List<SpeedBucket> histogram;
final List<ElevationSample> profile;
}
final tripDetailProvider =
FutureProvider.autoDispose.family<TripDetail?, int>((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<void> _rename(
BuildContext context, WidgetRef ref, Trip trip) async {
final controller = TextEditingController(text: trip.name ?? '');
final name = await showDialog<String>(
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<SpeedBucket> 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<ElevationSample> 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<ElevationSample> 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;
}

View File

@@ -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<RecordScreen> createState() => _RecordScreenState();
}
class _RecordScreenState extends ConsumerState<RecordScreen> {
/// 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<double>? _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<AsyncValue<Trip?>>(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<void> _guard(Future<void> 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),
),
),
);
}
}

70
lib/src/ui/router.dart Normal file
View File

@@ -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.')),
);
}

65
lib/src/ui/theme.dart Normal file
View File

@@ -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()],
);

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