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:
360
lib/src/ui/detail/trip_detail_screen.dart
Normal file
360
lib/src/ui/detail/trip_detail_screen.dart
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user