/// Ported from `com.rippr.ui.trips.TripsScreen` and `TripsViewModel`. /// /// UI-07: "Rides History" -- restyled to Map HUD's (UI-05) established colours/icons, /// not the Stitch export's own card styling (see the ticket's Design section for the /// generation artifacts deliberately not copied: four identical summary cards, and a /// grayscale "older ride" treatment on the third card with no defined rule behind it). 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 '../activity_display.dart'; import '../components/glass_panel.dart'; import '../components/ride_map.dart'; import '../components/stats.dart'; import '../format.dart'; import '../theme.dart'; /// Selection is screen-local: it must not survive navigating away, and nothing else /// needs to observe it. final _selectionProvider = StateProvider.autoDispose>( (ref) => {}, ); /// UI-07: also screen-local -- a search/filter session has no reason to survive /// leaving the tab, same reasoning as the selection above. final _searchQueryProvider = StateProvider.autoDispose((ref) => ''); final _activityFilterProvider = StateProvider.autoDispose((ref) => null); /// Distinct, real statistics across every completed ride -- the ticket's own named fix /// for the mockup's "four identical cards" generation artifact. A pure function, not a /// provider: folding over an already-fetched `List` is cheap regardless of ride /// count (simple arithmetic, no I/O), so there is no cost this would actually be /// optimising away -- and being a pure function is what makes it directly unit-testable /// against a hand-computed total, per the ticket's own Tests section. class RideHistorySummary { const RideHistorySummary({ required this.totalDistanceM, required this.totalMovingMillis, required this.rideCount, required this.avgSpeedKmh, }); final double totalDistanceM; final int totalMovingMillis; final int rideCount; final double avgSpeedKmh; factory RideHistorySummary.compute(List trips) { final totalDistanceM = trips.fold(0, (a, t) => a + t.distanceM); final totalMovingMillis = trips.fold(0, (a, t) => a + t.movingMillis); return RideHistorySummary( totalDistanceM: totalDistanceM, totalMovingMillis: totalMovingMillis, rideCount: trips.length, // Average speed across the whole history, not an average of each ride's own // average -- distance-weighted, so a handful of long rides isn't drowned out by // many short ones the way a naive per-ride average would be. avgSpeedKmh: totalMovingMillis <= 0 ? 0 : (totalDistanceM / 1000) / (totalMovingMillis / 3600000), ); } } class TripsScreen extends ConsumerWidget { const TripsScreen({super.key, this.onOpenTrip}); final void Function(int tripId)? onOpenTrip; bool _matchesQuery(Trip trip, String query) { if (query.isEmpty) return true; final q = query.toLowerCase(); return tripLabel(trip).toLowerCase().contains(q) || formatDateTime(trip.startedAt).toLowerCase().contains(q); } @override Widget build(BuildContext context, WidgetRef ref) { final colors = Theme.of(context).colorScheme; final tripsAsync = ref.watch(completedTripsProvider); final allTrips = tripsAsync.valueOrNull ?? const []; final units = ref.watch(unitSystemProvider); final query = ref.watch(_searchQueryProvider); final activityFilter = ref.watch(_activityFilterProvider); final trips = allTrips .where((t) => _matchesQuery(t, query)) .where((t) => activityFilter == null || t.activity == activityFilter) .toList(); // The summary row reflects the whole history, not the filtered/searched subset -- // it's an overview stat, not a count of what's currently visible below it. final summary = RideHistorySummary.compute(allTrips); // Drop ids that no longer exist, so a deleted trip cannot linger in the selection. final live = ref .watch(_selectionProvider) .intersection(allTrips.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 = {}; // UI-01: no back/title header -- Rides is a tab now. The selection toolbar (Cancel/ // count/Merge/Delete) is functional, not chrome, so it stays -- it only appears // while `selecting` is true. return Scaffold( backgroundColor: Colors.transparent, body: SafeArea( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const SizedBox(height: 8), if (selecting) Padding( padding: const EdgeInsets.symmetric(vertical: 12), child: Row( children: [ 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), ), ), const Spacer(), TextButton( key: const Key('merge'), onPressed: canMerge ? () => _confirmMerge(context, ref, allTrips, live) : null, child: const Text('Merge'), ), TextButton( key: const Key('delete'), onPressed: () => _confirmDelete(context, ref, live), child: Text( 'Delete', style: TextStyle(color: colors.error), ), ), ], ), ) else ...[ _SearchAndFilter( query: query, activityFilter: activityFilter, onQueryChanged: (q) => ref.read(_searchQueryProvider.notifier).state = q, onFilterChanged: (a) => ref.read(_activityFilterProvider.notifier).state = a, ), const SizedBox(height: 12), if (allTrips.isNotEmpty) ...[ _SummaryRow(summary: summary, units: units), const SizedBox(height: 12), ], ], 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) when trips.isEmpty => const EmptyState( message: 'No rides match your search.', icon: Icons.search_off, ), AsyncValue(hasValue: true) => ListView.builder( itemCount: trips.length, itemBuilder: (context, i) { final trip = trips[i]; return Padding( padding: const EdgeInsets.only(bottom: 12), child: _TripCard( trip: trip, selected: live.contains(trip.id), selecting: selecting, units: units, 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 _SearchAndFilter extends StatelessWidget { const _SearchAndFilter({ required this.query, required this.activityFilter, required this.onQueryChanged, required this.onFilterChanged, }); final String query; final Activity? activityFilter; final ValueChanged onQueryChanged; final ValueChanged onFilterChanged; @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; return GlassPanel( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), child: Row( children: [ Icon(Icons.search, color: colors.onSurfaceVariant), const SizedBox(width: 8), Expanded( child: TextField( key: const Key('ride-search'), onChanged: onQueryChanged, controller: TextEditingController.fromValue( TextEditingValue( text: query, selection: TextSelection.collapsed(offset: query.length), ), ), decoration: const InputDecoration( hintText: 'Search rides...', border: InputBorder.none, isDense: true, ), ), ), PopupMenuButton( key: const Key('activity-filter'), icon: Icon( Icons.tune, color: activityFilter == null ? colors.onSurfaceVariant : colors.primary, ), tooltip: 'Filter by activity', initialValue: activityFilter, onSelected: onFilterChanged, itemBuilder: (context) => [ const PopupMenuItem( key: Key('activity-filter-all'), value: null, child: Text('All activities'), ), for (final activity in Activity.values) PopupMenuItem( key: Key('activity-filter-${activity.name}'), value: activity, child: Row( children: [ Icon(activityIcon(activity), size: 20), const SizedBox(width: 12), Text(activityLabel(activity)), ], ), ), ], ), ], ), ); } } class _SummaryRow extends StatelessWidget { const _SummaryRow({required this.summary, required this.units}); final RideHistorySummary summary; final UnitSystem units; @override Widget build(BuildContext context) { final stats = [ ('TOTAL DIST', formatDistance(summary.totalDistanceM, unit: units)), ('TOTAL TIME', formatDuration(summary.totalMovingMillis)), ('RIDES', '${summary.rideCount}'), ('AVG SPEED', formatSpeed(summary.avgSpeedKmh, unit: units)), ]; return Row( children: [ for (var i = 0; i < stats.length; i++) ...[ if (i > 0) const SizedBox(width: 8), Expanded(child: _SummaryStatCard(label: stats[i].$1, value: stats[i].$2)), ], ], ); } } class _SummaryStatCard extends StatelessWidget { const _SummaryStatCard({required this.label, required this.value}); final String label; final String value; @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; return GlassPanel( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( label, style: TextStyle(fontSize: 9, letterSpacing: 1, color: colors.onSurfaceVariant), maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 4), Text( value, style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: colors.primary, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ); } } class _TripCard extends ConsumerWidget { const _TripCard({ required this.trip, required this.selected, required this.selecting, required this.units, required this.onTap, required this.onLongPress, }); final Trip trip; final bool selected; final bool selecting; final UnitSystem units; final VoidCallback onTap; final VoidCallback onLongPress; @override Widget build(BuildContext context, WidgetRef ref) { final colors = Theme.of(context).colorScheme; final points = ref.watch(livePointsProvider(trip.id)).valueOrNull ?? const []; final segments = ref.watch(liveSegmentsProvider(trip.id)).valueOrNull ?? const []; final avgSpeedKmh = trip.movingMillis <= 0 ? 0.0 : (trip.distanceM / 1000) / (trip.movingMillis / 3600000); return GlassPanel( key: Key('trip-${trip.id}'), padding: EdgeInsets.zero, // A `ListTile`/`InkWell` paints its ink splash on the nearest `Material` // ancestor -- `GlassPanel`'s own `DecoratedBox` sits between it and the // `Scaffold`'s Material otherwise (the same fix UI-06 needed for its own // GlassPanel-wrapped list rows). child: Material( type: MaterialType.transparency, color: selected ? colors.surfaceContainerHighest : null, child: InkWell( onTap: onTap, onLongPress: onLongPress, borderRadius: const BorderRadius.all(Radius.circular(ripprRadiusLarge)), child: Padding( padding: const EdgeInsets.all(8), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (selecting) Padding( padding: const EdgeInsets.only(right: 8, top: 28), child: Icon( selected ? Icons.check_circle : Icons.circle_outlined, color: selected ? colors.primary : colors.outline, ), ) else // UI-07: a real thumbnail of this ride's own path -- the ticket's // own fix for the mockup's stock placeholder photos -- rather than // a second, separate map-rendering path, this reuses `RideMap` // directly, non-interactive (wrapped in `IgnorePointer`) at a small // fixed size. If a long ride history ever makes many simultaneous // small `RideMap`s a real scroll-performance cost, the ticket's own // documented fallback is a lightweight static polyline-on-canvas // painter instead -- not attempted here without a realistic ride // history to actually measure against. Padding( padding: const EdgeInsets.only(right: 12), child: ClipRRect( borderRadius: BorderRadius.circular(ripprRadiusSmall), child: SizedBox( width: 88, height: 88, child: IgnorePointer( child: RideMap( points: points, segments: segments, fill: true, tileProvider: ref.watch(cachedTileProviderProvider), skeletonMode: ref.watch(mapConnectivityProvider).skeletonMode, showAttribution: false, ), ), ), ), ), Expanded( child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(activityIcon(trip.activity), size: 16, color: colors.outline), const SizedBox(width: 6), Expanded( child: Text( tripLabel(trip), style: const TextStyle(fontWeight: FontWeight.w600), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ], ), if (trip.name != null) ...[ const SizedBox(height: 2), Text( formatDateTime(trip.startedAt), style: TextStyle(fontSize: 12, color: colors.outline), ), ], const SizedBox(height: 8), Row( children: [ _StatColumn( label: 'Distance', value: formatDistance(trip.distanceM, unit: units), ), const SizedBox(width: 16), _StatColumn( label: 'Time', value: formatDuration(trip.movingMillis), ), const SizedBox(width: 16), _StatColumn( label: 'Avg spd', value: formatSpeed(avgSpeedKmh, unit: units), ), ], ), ], ), ), ), ], ), ), ), ), ); } } class _StatColumn extends StatelessWidget { const _StatColumn({required this.label, required this.value}); final String label; final String value; @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( label.toUpperCase(), style: TextStyle(fontSize: 9, letterSpacing: 1, color: colors.onSurfaceVariant), ), Text( value, style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: colors.primary, ), ), ], ); } }