/// 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/activity_profile.dart'; import '../../domain/models.dart'; import '../../export/export_manager.dart'; import '../../stats/ride_statistics.dart'; import '../../telemetry/telemetry.dart'; import '../activity_display.dart'; import '../components/ride_map.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, required this.mapEnabled, }); final Trip trip; final RideSummary summary; final List points; final List segments; final List histogram; final List profile; /// The map stays behind a toggle, as in v2: it is only ever live on a visible screen, /// and never while recording in the background. final bool mapEnabled; } 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); final activityProfile = ActivityProfile.forActivity(trip.activity); return TripDetail( trip: trip, summary: computeSummary(points, segments: segments, profile: activityProfile), points: points, segments: segments, histogram: speedHistogram( points, bucketKmh: activityProfile.histogramBucketKmh, ), profile: elevationProfile(points), mapEnabled: ref.watch(mapEnabledProvider), ); }); 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('export'), icon: const Icon(Icons.ios_share), onPressed: () => _export(context, async.value!), ), 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, onEditActivity: () => _editActivity(context, ref, d.trip), ), AsyncValue(hasError: true, :final error) => EmptyState( message: 'Could not load this ride.\n$error', ), _ => const Center(child: CircularProgressIndicator()), }, ); } Future _export(BuildContext context, TripDetail d) async { final format = await showModalBottomSheet( context: context, builder: (context) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( key: const Key('export-gpx'), leading: const Icon(Icons.route), title: const Text('GPX'), subtitle: const Text('Strava, Garmin, Google Earth'), onTap: () => Navigator.of(context).pop(ExportFormat.gpx), ), ListTile( key: const Key('export-geojson'), leading: const Icon(Icons.data_object), title: const Text('GeoJSON'), subtitle: const Text('Mapping and analysis tools'), onTap: () => Navigator.of(context).pop(ExportFormat.geojson), ), ], ), ), ); if (format == null) return; await shareRide( trip: d.trip, segments: d.segments, // Deliberately d.points, the raw stored points -- never the map's decimated path. points: d.points, format: format, ); } 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)); } /// No picker in front of Start — V3-01's constraint — but a ride's activity is fully /// editable here after the fact, the same pattern as rename. Future _editActivity(BuildContext context, WidgetRef ref, Trip trip) async { final chosen = await showModalBottomSheet( context: context, // Seven options, unlike the two-item export sheet -- a plain Column with // mainAxisSize.min overflowed on a short screen. Scrollable rather than fixed. isScrollControlled: true, builder: (context) => SafeArea( child: ListView( shrinkWrap: true, children: [ for (final activity in Activity.values) ListTile( key: Key('activity-${activity.name}'), leading: Icon(activityIcon(activity)), title: Text(activityLabel(activity)), trailing: activity == trip.activity ? const Icon(Icons.check) : null, onTap: () => Navigator.of(context).pop(activity), ), ], ), ), ); if (chosen == null || chosen == trip.activity) return; // Recomputes aggregates under the new profile -- a noise floor tuned for a // motorcycle should not silently keep governing a walk. await ref.read(tripRepositoryProvider).setActivity(trip.id, chosen); ref.invalidate(tripDetailProvider(trip.id)); } } class _Body extends StatelessWidget { const _Body({required this.detail, required this.onEditActivity}); final TripDetail detail; final VoidCallback onEditActivity; @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: 16), Center( child: _ActivityRow( activity: detail.trip.activity, onTap: onEditActivity, ), ), const SizedBox(height: 24), if (detail.mapEnabled) RideMap(points: detail.points, segments: detail.segments), if (detail.mapEnabled) 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), ], ); } } /// A tappable "what this ride was" row — icon, label, and a small edit affordance. class _ActivityRow extends StatelessWidget { const _ActivityRow({required this.activity, required this.onTap}); final Activity activity; final VoidCallback onTap; @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; return InkWell( key: const Key('activity-row'), onTap: onTap, borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(activityIcon(activity), size: 16, color: colors.outline), const SizedBox(width: 6), Text(activityLabel(activity), style: TextStyle(color: colors.outline)), const SizedBox(width: 4), Icon(Icons.edit_outlined, size: 13, color: colors.outline), ], ), ), ); } } 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; }