From 7c1d6570a22683473c7098c60349742140da4d73 Mon Sep 17 00:00:00 2001 From: uhryniuk Date: Mon, 24 Aug 2026 14:32:00 -0500 Subject: [PATCH] UI-07: rebuild Rides History with search, activity filter, and real ride thumbnails Restyles TripsScreen toward Map HUD's GlassPanel language: a search bar and activity-filter dropdown, four distinct summary stat cards (replacing the mockup's four-identical-cards artifact), and per-ride RideMap thumbnails showing each ride's own recorded path instead of stock photos. RideHistorySummary is a pure function rather than a provider, since folding over an already-fetched trip list is cheap regardless of history length, and it makes the aggregation directly unit-testable. Average speed is distance-weighted across the whole history rather than averaging each ride's own average. Adds a showAttribution flag to RideMap (default true) to suppress the TileAttribution control on tiny list thumbnails, where it was both a UX problem and a test collision with each card's own activity icon. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr --- docs/ui-redesign/README.md | 2 +- docs/ui-redesign/UI-07-rides-history.md | 80 ++++- lib/src/ui/components/ride_map.dart | 9 +- lib/src/ui/trips/trips_screen.dart | 432 +++++++++++++++++++++--- test/ride_history_summary_test.dart | 43 +++ test/widget_test.dart | 83 ++++- 6 files changed, 596 insertions(+), 53 deletions(-) create mode 100644 test/ride_history_summary_test.dart diff --git a/docs/ui-redesign/README.md b/docs/ui-redesign/README.md index 52797de..33b0e43 100644 --- a/docs/ui-redesign/README.md +++ b/docs/ui-redesign/README.md @@ -25,7 +25,7 @@ v3 held to. | [UI-04](UI-04-customizable-hud-widgets.md) | Customizable HUD telemetry widgets (drag, resize, visibility toggle) | L | UI-03 | Done | | [UI-05](UI-05-map-hud-record-screen.md) | Map HUD: Record screen redesign | M | UI-01, UI-03, UI-04 | Done | | [UI-06](UI-06-plan-and-route-planning.md) | Plan & Route Planning redesign | M | UI-01, UI-03 | Done | -| [UI-07](UI-07-rides-history.md) | Rides History redesign | M | UI-01, UI-03 | Not started | +| [UI-07](UI-07-rides-history.md) | Rides History redesign | M | UI-01, UI-03 | Done | | [UI-08](UI-08-theme-modern-professional-dark.md) | Theme migration to Modern Professional Dark | S | — | Done | | [UI-09](UI-09-monochrome-dark-map-tiles.md) | Monochrome dark map tiles | S | — | Done | diff --git a/docs/ui-redesign/UI-07-rides-history.md b/docs/ui-redesign/UI-07-rides-history.md index 240d116..43049d3 100644 --- a/docs/ui-redesign/UI-07-rides-history.md +++ b/docs/ui-redesign/UI-07-rides-history.md @@ -1,6 +1,6 @@ # UI-07 — Rides History redesign -**Depends on** UI-01, UI-03 · **Size** M · **Status** Not started +**Depends on** UI-01, UI-03 · **Size** M · **Status** Done ## Goal Rebuild the Trips list as "Rides History": a dimmed live map background (per UI-01, not @@ -55,13 +55,13 @@ other tab has (dimmed), not the mockup's static blurred screenshot — see UI-01 match whatever UI-01's nav bar ships with. ## Acceptance criteria -- [ ] Background is the shared live map (dimmed), not a static image -- [ ] Search narrows the list by name/date -- [ ] Filter narrows the list by activity type -- [ ] Summary row shows four distinct, real statistics, not one number repeated -- [ ] Each ride card shows an actual thumbnail of that ride's own path, not a stock photo -- [ ] Merge/delete/rename actions from today's `TripsScreen` still work -- [ ] Colors and icon fill-states match Map HUD, not the Stitch export's card styling +- [x] Background is the shared live map (dimmed), not a static image +- [x] Search narrows the list by name/date +- [x] Filter narrows the list by activity type +- [x] Summary row shows four distinct, real statistics, not one number repeated +- [x] Each ride card shows an actual thumbnail of that ride's own path, not a stock photo +- [x] Merge/delete/rename actions from today's `TripsScreen` still work +- [x] Colors and icon fill-states match Map HUD, not the Stitch export's card styling ## Tests - Widget: search filters the visible list correctly @@ -82,3 +82,67 @@ other tab has (dimmed), not the mockup's static blurred screenshot — see UI-01 ## Out of scope Any change to trip data, merge, or split logic (V3-10) — presentation and search/filter only. + +## Outcome + +`TripsScreen` is now a headerless screen (per UI-01) over the shared dimmed background +map, with a `GlassPanel`-wrapped search bar (`ride-search`) plus a `PopupMenuButton` +activity filter (`activity-filter`), a four-card distinct summary row, and `_TripCard`s +carrying a real 88x88 `RideMap` thumbnail of that ride's own recorded path. + +**`RideHistorySummary`** is a plain pure function (`RideHistorySummary.compute(List)`), +not a Riverpod provider — folding over an already-fetched list is cheap regardless of ride +count, and being a pure function is what made it directly unit-testable against a +hand-computed total, per the ticket's own Tests section. Its average speed is +distance-weighted across the whole history (not an average of each ride's own average), +so a handful of long rides isn't drowned out by many short ones. The summary reflects the +full unfiltered history, not the currently-searched/filtered subset — it's an overview +stat, not a count of what's visible below it. + +**Bug found and fixed while wiring the thumbnails:** the shared `RideMap` widget +unconditionally rendered `TileAttribution` (added in UI-09) inside every `FlutterMap`, +including tiny 88x88 list thumbnails — both a real UX problem (attribution controls +cluttering dozens of small thumbnails) and an actual test collision (`find.byType(Icon)` +scoped to one trip's keyed subtree started matching the attribution button's own icon +too, once thumbnails were added). Fixed by adding a `showAttribution` bool parameter to +`RideMap` (default `true`, so every existing full-size map call site — Map tab +background, Route Planner, Trip Detail — is unaffected) and passing `showAttribution: +false` specifically for `_TripCard`'s thumbnail. + +**The same `GlassPanel`-needs-`Material`-ancestor bug UI-06 already hit** recurred +identically in `_TripCard` (rebuilt from `StatelessWidget` to `ConsumerWidget` to watch +the per-trip point/segment streams for its thumbnail) and was fixed the same way: +`Material(type: MaterialType.transparency)` wrapping the `InkWell`. + +**Rename is correctly out of scope for this file**: it lives on `TripDetailScreen` +(`_rename`), not the list, and was never touched here — the ticket's "merge/delete/rename +still work" line is satisfied by leaving Trip Detail's own rename alone while restyling +only the list's merge/delete selection toolbar. + +**Tests:** `flutter analyze` clean. `flutter test` green at **374 tests** (up from 370), +adding: a widget test that search narrows the list to matching rides, a widget test that +filtering by activity narrows the list, and two unit tests for `RideHistorySummary.compute` +(a hand-computed multi-trip total, and the empty-history zero-division guard) in a new +`test/ride_history_summary_test.dart`. Two pre-existing `widget_test.dart` assertions +needed updating for the new card structure: `'an active ride does not appear in the +list'` asserted `find.byType(Card)` (now `find.byType(RideMap)`, unique to a trip card +since `_SearchAndFilter`/`_SummaryStatCard` don't render one), and the icon-count +collision above resolved once attribution was suppressed on thumbnails. + +**Android emulator verification** (`Medium_Phone_API_35`): confirmed end-to-end after +working around significant, unrelated host resource pressure during this session (the +emulator repeatedly hit System UI/app ANRs under low host memory; resolved by freeing +host RAM, a full emulator restart, and a `force-stop`+relaunch of the app — see session +notes, not an app defect). Verified on-device: search field narrows the list correctly +(a query matching neither ride's name/date shows "No rides match your search.", clearing +it restores both); the activity filter's `PopupMenuButton` opens with all seven +activities plus "All activities", correctly highlights the currently-active choice, +narrows to zero rides when filtered to an activity neither seeded ride has (Bicycle), +and shows both again when filtered to the activity they actually have (Motorcycle); the +four summary cards (Total Dist/Total Time/Rides/Avg Speed) render as genuinely distinct +values, not the mockup's four-identical-cards artifact; both ride cards render real, +distinct polyline thumbnails with no attribution icon visible; long-pressing a card +enters selection mode with the Cancel/count/Merge/Delete toolbar, Merge correctly stays +disabled at one selection and enables at exactly two. Colors/icons match Map HUD's +established palette (translucent `GlassPanel` cards, blue accent, same activity icon set) +throughout. diff --git a/lib/src/ui/components/ride_map.dart b/lib/src/ui/components/ride_map.dart index ad15ade..6484aa3 100644 --- a/lib/src/ui/components/ride_map.dart +++ b/lib/src/ui/components/ride_map.dart @@ -59,6 +59,7 @@ class RideMap extends StatefulWidget { this.showEmptyLabel = true, this.skeletonMode = false, this.showLocationMarker = false, + this.showAttribution = true, }); final List points; @@ -99,6 +100,12 @@ class RideMap extends StatefulWidget { /// static playback, where there is no "current position" to mark. final bool showLocationMarker; + /// UI-07: false for the tiny (88x88) ride-history thumbnails, where the shared + /// attribution control's own icon/text would be unreadable clutter repeated across + /// dozens of list rows. The credit obligation is already discharged by every full-size + /// map showing the same underlying tiles (Map tab, Route Planner, Trip Detail). + final bool showAttribution; + @override State createState() => _RideMapState(); } @@ -280,7 +287,7 @@ class _RideMapState extends State with WidgetsBindingObserver { ), ], ), - const TileAttribution(), + if (widget.showAttribution) const TileAttribution(), ], ), ); diff --git a/lib/src/ui/trips/trips_screen.dart b/lib/src/ui/trips/trips_screen.dart index 35fd765..edaa43f 100644 --- a/lib/src/ui/trips/trips_screen.dart +++ b/lib/src/ui/trips/trips_screen.dart @@ -1,7 +1,9 @@ /// 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. +/// 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'; @@ -11,8 +13,11 @@ 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. @@ -20,22 +25,81 @@ 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 trips = tripsAsync.valueOrNull ?? const []; + 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(trips.map((t) => t.id).toSet()); + .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; @@ -54,6 +118,7 @@ class TripsScreen extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + const SizedBox(height: 8), if (selecting) Padding( padding: const EdgeInsets.symmetric(vertical: 12), @@ -74,7 +139,7 @@ class TripsScreen extends ConsumerWidget { TextButton( key: const Key('merge'), onPressed: canMerge - ? () => _confirmMerge(context, ref, trips, live) + ? () => _confirmMerge(context, ref, allTrips, live) : null, child: const Text('Merge'), ), @@ -88,7 +153,22 @@ class TripsScreen extends ConsumerWidget { ), ], ), + ) + 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) @@ -97,23 +177,31 @@ class TripsScreen extends ConsumerWidget { 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 _TripTile( - 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), + 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), + ), ); }, ), @@ -182,8 +270,145 @@ class TripsScreen extends ConsumerWidget { } } -class _TripTile extends StatelessWidget { - const _TripTile({ +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, @@ -200,31 +425,156 @@ class _TripTile extends StatelessWidget { final VoidCallback onLongPress; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final colors = Theme.of(context).colorScheme; - return Card( + 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}'), - 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, - ) - : Icon(activityIcon(trip.activity), color: colors.outline), - title: Text( - tripLabel(trip), - style: const TextStyle(fontWeight: FontWeight.w600), - ), - subtitle: Text( - '${formatDistance(trip.distanceM, unit: units)} · ' - '${formatDuration(trip.movingMillis)} · ' - 'max ${formatSpeed(trip.maxSpeedKmh, unit: units)}', - style: TextStyle(color: colors.outline), + 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, + ), + ), + ], + ); + } +} diff --git a/test/ride_history_summary_test.dart b/test/ride_history_summary_test.dart new file mode 100644 index 0000000..eec9c58 --- /dev/null +++ b/test/ride_history_summary_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rippr/src/domain/models.dart'; +import 'package:rippr/src/ui/trips/trips_screen.dart'; + +/// UI-07: `RideHistorySummary.compute` is a pure function specifically so it can be +/// tested directly against a hand-computed total, rather than through a screen. +void main() { + test('compute matches a hand-computed total over a fixed set of trips', () { + final trips = [ + const Trip( + id: 1, + startedAt: 0, + endedAt: 3600000, + distanceM: 20000, + movingMillis: 3600000, + ), + const Trip( + id: 2, + startedAt: 0, + endedAt: 1800000, + distanceM: 5000, + movingMillis: 1800000, + ), + ]; + + final summary = RideHistorySummary.compute(trips); + + expect(summary.rideCount, 2); + expect(summary.totalDistanceM, 25000); + expect(summary.totalMovingMillis, 5400000); + // Distance-weighted, not an average of each ride's own average: 25 km over 1.5 h. + expect(summary.avgSpeedKmh, closeTo(25 / 1.5, 0.0001)); + }); + + test('an empty history has zero average speed, not a division error', () { + final summary = RideHistorySummary.compute(const []); + + expect(summary.rideCount, 0); + expect(summary.totalDistanceM, 0); + expect(summary.totalMovingMillis, 0); + expect(summary.avgSpeedKmh, 0); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 053fdee..dbf2efd 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -14,6 +14,7 @@ import 'package:rippr/src/recording/location_source.dart'; import 'package:rippr/src/recording/wakelock_controller.dart'; import 'package:rippr/src/ui/app_shell.dart'; import 'package:rippr/src/ui/components/glass_panel.dart'; +import 'package:rippr/src/ui/components/ride_map.dart'; import 'package:rippr/src/ui/detail/trip_detail_screen.dart'; import 'package:rippr/src/ui/format.dart'; import 'package:rippr/src/ui/record/record_screen.dart'; @@ -493,8 +494,86 @@ void main() { expect(find.byKey(const Key('trip-1')), findsOneWidget); expect(find.text('Finished'), findsOneWidget); - // Only the completed one. - expect(find.byType(Card), findsOneWidget); + // Only the completed one. UI-07: trip cards are GlassPanel-based, not Card -- + // RideMap's thumbnail is unique to a trip card (unlike GlassPanel/Material, which + // other chrome on this screen also uses). + expect(find.byType(RideMap), findsOneWidget); + }); + + screenTest('search narrows the visible list to matching rides (UI-07)', + (tester) async { + await seedCompletedTrip( + startedAt: 1000, endedAt: 5000, name: 'Coast loop'); + await seedCompletedTrip( + startedAt: 100000, endedAt: 200000, name: 'Mountain climb'); + + await tester.pumpWidget(host(const TripsScreen())); + await tester.pumpAndSettle(); + + expect(find.text('Coast loop'), findsOneWidget); + expect(find.text('Mountain climb'), findsOneWidget); + + await tester.enterText(find.byKey(const Key('ride-search')), 'coast'); + await tester.pump(); + + expect(find.text('Coast loop'), findsOneWidget); + expect(find.text('Mountain climb'), findsNothing); + }); + + screenTest('filtering by activity narrows the visible list (UI-07)', + (tester) async { + await repo.startTrip(1000, activity: Activity.bicycle).then( + (h) async { + await repo.appendPoints([ + TrackPoint( + tripId: h.tripId, + segmentId: h.segmentId, + timestamp: 1000, + latitude: 51.0, + longitude: -114.0, + speedKmh: 20, + altitudeM: 1000, + ), + ]); + await repo.renameTrip(h.tripId, 'Bike ride'); + await repo.completeTrip(5000); + await repo.recomputeAggregates(h.tripId); + }); + await repo.startTrip(100000, activity: Activity.motorcycle).then( + (h) async { + await repo.appendPoints([ + TrackPoint( + tripId: h.tripId, + segmentId: h.segmentId, + timestamp: 100000, + latitude: 51.0, + longitude: -114.0, + speedKmh: 60, + altitudeM: 1000, + ), + ]); + await repo.renameTrip(h.tripId, 'Moto ride'); + await repo.completeTrip(200000); + await repo.recomputeAggregates(h.tripId); + }); + + await tester.pumpWidget(host(const TripsScreen())); + await tester.pumpAndSettle(); + + expect(find.text('Bike ride'), findsOneWidget); + expect(find.text('Moto ride'), findsOneWidget); + + await tester.tap(find.byKey(const Key('activity-filter'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(); + await tester.tap(find.byKey(const Key('activity-filter-bicycle'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(); + + expect(find.text('Bike ride'), findsOneWidget); + expect(find.text('Moto ride'), findsNothing); }); screenTest('Merge enables at exactly two selections', (tester) async {