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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
This commit is contained in:
@@ -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-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-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-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-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 |
|
| [UI-09](UI-09-monochrome-dark-map-tiles.md) | Monochrome dark map tiles | S | — | Done |
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# UI-07 — Rides History redesign
|
# 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
|
## Goal
|
||||||
Rebuild the Trips list as "Rides History": a dimmed live map background (per UI-01, not
|
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.
|
match whatever UI-01's nav bar ships with.
|
||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
- [ ] Background is the shared live map (dimmed), not a static image
|
- [x] Background is the shared live map (dimmed), not a static image
|
||||||
- [ ] Search narrows the list by name/date
|
- [x] Search narrows the list by name/date
|
||||||
- [ ] Filter narrows the list by activity type
|
- [x] Filter narrows the list by activity type
|
||||||
- [ ] Summary row shows four distinct, real statistics, not one number repeated
|
- [x] 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
|
- [x] 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
|
- [x] 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] Colors and icon fill-states match Map HUD, not the Stitch export's card styling
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
- Widget: search filters the visible list correctly
|
- 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
|
## Out of scope
|
||||||
Any change to trip data, merge, or split logic (V3-10) — presentation and search/filter
|
Any change to trip data, merge, or split logic (V3-10) — presentation and search/filter
|
||||||
only.
|
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<Trip>)`),
|
||||||
|
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.
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ class RideMap extends StatefulWidget {
|
|||||||
this.showEmptyLabel = true,
|
this.showEmptyLabel = true,
|
||||||
this.skeletonMode = false,
|
this.skeletonMode = false,
|
||||||
this.showLocationMarker = false,
|
this.showLocationMarker = false,
|
||||||
|
this.showAttribution = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<TrackPoint> points;
|
final List<TrackPoint> points;
|
||||||
@@ -99,6 +100,12 @@ class RideMap extends StatefulWidget {
|
|||||||
/// static playback, where there is no "current position" to mark.
|
/// static playback, where there is no "current position" to mark.
|
||||||
final bool showLocationMarker;
|
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
|
@override
|
||||||
State<RideMap> createState() => _RideMapState();
|
State<RideMap> createState() => _RideMapState();
|
||||||
}
|
}
|
||||||
@@ -280,7 +287,7 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const TileAttribution(),
|
if (widget.showAttribution) const TileAttribution(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
/// Ported from `com.rippr.ui.trips.TripsScreen` and `TripsViewModel`.
|
/// 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
|
/// UI-07: "Rides History" -- restyled to Map HUD's (UI-05) established colours/icons,
|
||||||
/// never touches the point table. That keeps it fast with hundreds of rides.
|
/// 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;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -11,8 +13,11 @@ import '../../app/providers.dart';
|
|||||||
import '../../domain/models.dart';
|
import '../../domain/models.dart';
|
||||||
import '../../telemetry/telemetry.dart';
|
import '../../telemetry/telemetry.dart';
|
||||||
import '../activity_display.dart';
|
import '../activity_display.dart';
|
||||||
|
import '../components/glass_panel.dart';
|
||||||
|
import '../components/ride_map.dart';
|
||||||
import '../components/stats.dart';
|
import '../components/stats.dart';
|
||||||
import '../format.dart';
|
import '../format.dart';
|
||||||
|
import '../theme.dart';
|
||||||
|
|
||||||
/// Selection is screen-local: it must not survive navigating away, and nothing else
|
/// Selection is screen-local: it must not survive navigating away, and nothing else
|
||||||
/// needs to observe it.
|
/// needs to observe it.
|
||||||
@@ -20,22 +25,81 @@ final _selectionProvider = StateProvider.autoDispose<Set<int>>(
|
|||||||
(ref) => <int>{},
|
(ref) => <int>{},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// 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<String>((ref) => '');
|
||||||
|
final _activityFilterProvider = StateProvider.autoDispose<Activity?>((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<Trip>` 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<Trip> trips) {
|
||||||
|
final totalDistanceM = trips.fold<double>(0, (a, t) => a + t.distanceM);
|
||||||
|
final totalMovingMillis = trips.fold<int>(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 {
|
class TripsScreen extends ConsumerWidget {
|
||||||
const TripsScreen({super.key, this.onOpenTrip});
|
const TripsScreen({super.key, this.onOpenTrip});
|
||||||
|
|
||||||
final void Function(int tripId)? 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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final colors = Theme.of(context).colorScheme;
|
final colors = Theme.of(context).colorScheme;
|
||||||
final tripsAsync = ref.watch(completedTripsProvider);
|
final tripsAsync = ref.watch(completedTripsProvider);
|
||||||
final trips = tripsAsync.valueOrNull ?? const <Trip>[];
|
final allTrips = tripsAsync.valueOrNull ?? const <Trip>[];
|
||||||
final units = ref.watch(unitSystemProvider);
|
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.
|
// Drop ids that no longer exist, so a deleted trip cannot linger in the selection.
|
||||||
final live = ref
|
final live = ref
|
||||||
.watch(_selectionProvider)
|
.watch(_selectionProvider)
|
||||||
.intersection(trips.map((t) => t.id).toSet());
|
.intersection(allTrips.map((t) => t.id).toSet());
|
||||||
final selecting = live.isNotEmpty;
|
final selecting = live.isNotEmpty;
|
||||||
// Merge combines exactly two rides; anything else is ambiguous.
|
// Merge combines exactly two rides; anything else is ambiguous.
|
||||||
final canMerge = live.length == 2;
|
final canMerge = live.length == 2;
|
||||||
@@ -54,6 +118,7 @@ class TripsScreen extends ConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
const SizedBox(height: 8),
|
||||||
if (selecting)
|
if (selecting)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
@@ -74,7 +139,7 @@ class TripsScreen extends ConsumerWidget {
|
|||||||
TextButton(
|
TextButton(
|
||||||
key: const Key('merge'),
|
key: const Key('merge'),
|
||||||
onPressed: canMerge
|
onPressed: canMerge
|
||||||
? () => _confirmMerge(context, ref, trips, live)
|
? () => _confirmMerge(context, ref, allTrips, live)
|
||||||
: null,
|
: null,
|
||||||
child: const Text('Merge'),
|
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(
|
Expanded(
|
||||||
child: switch (tripsAsync) {
|
child: switch (tripsAsync) {
|
||||||
AsyncValue(hasValue: true, value: final list)
|
AsyncValue(hasValue: true, value: final list)
|
||||||
@@ -97,11 +177,18 @@ class TripsScreen extends ConsumerWidget {
|
|||||||
message: 'No rides yet.\nPress Start on the record screen to capture one.',
|
message: 'No rides yet.\nPress Start on the record screen to capture one.',
|
||||||
icon: Icons.two_wheeler,
|
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(
|
AsyncValue(hasValue: true) => ListView.builder(
|
||||||
itemCount: trips.length,
|
itemCount: trips.length,
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final trip = trips[i];
|
final trip = trips[i];
|
||||||
return _TripTile(
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: _TripCard(
|
||||||
trip: trip,
|
trip: trip,
|
||||||
selected: live.contains(trip.id),
|
selected: live.contains(trip.id),
|
||||||
selecting: selecting,
|
selecting: selecting,
|
||||||
@@ -114,6 +201,7 @@ class TripsScreen extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onLongPress: () => _toggle(ref, trip.id),
|
onLongPress: () => _toggle(ref, trip.id),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -182,8 +270,145 @@ class TripsScreen extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TripTile extends StatelessWidget {
|
class _SearchAndFilter extends StatelessWidget {
|
||||||
const _TripTile({
|
const _SearchAndFilter({
|
||||||
|
required this.query,
|
||||||
|
required this.activityFilter,
|
||||||
|
required this.onQueryChanged,
|
||||||
|
required this.onFilterChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String query;
|
||||||
|
final Activity? activityFilter;
|
||||||
|
final ValueChanged<String> onQueryChanged;
|
||||||
|
final ValueChanged<Activity?> 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<Activity?>(
|
||||||
|
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.trip,
|
||||||
required this.selected,
|
required this.selected,
|
||||||
required this.selecting,
|
required this.selecting,
|
||||||
@@ -200,31 +425,156 @@ class _TripTile extends StatelessWidget {
|
|||||||
final VoidCallback onLongPress;
|
final VoidCallback onLongPress;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final colors = Theme.of(context).colorScheme;
|
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}'),
|
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,
|
color: selected ? colors.surfaceContainerHighest : null,
|
||||||
child: ListTile(
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
onLongPress: onLongPress,
|
onLongPress: onLongPress,
|
||||||
leading: selecting
|
borderRadius: const BorderRadius.all(Radius.circular(ripprRadiusLarge)),
|
||||||
? Icon(
|
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,
|
selected ? Icons.check_circle : Icons.circle_outlined,
|
||||||
color: selected ? colors.primary : colors.outline,
|
color: selected ? colors.primary : colors.outline,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: Icon(activityIcon(trip.activity), color: colors.outline),
|
else
|
||||||
title: Text(
|
// 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),
|
tripLabel(trip),
|
||||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
|
||||||
'${formatDistance(trip.distanceM, unit: units)} · '
|
|
||||||
'${formatDuration(trip.movingMillis)} · '
|
|
||||||
'max ${formatSpeed(trip.maxSpeedKmh, unit: units)}',
|
|
||||||
style: TextStyle(color: colors.outline),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
43
test/ride_history_summary_test.dart
Normal file
43
test/ride_history_summary_test.dart
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import 'package:rippr/src/recording/location_source.dart';
|
|||||||
import 'package:rippr/src/recording/wakelock_controller.dart';
|
import 'package:rippr/src/recording/wakelock_controller.dart';
|
||||||
import 'package:rippr/src/ui/app_shell.dart';
|
import 'package:rippr/src/ui/app_shell.dart';
|
||||||
import 'package:rippr/src/ui/components/glass_panel.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/detail/trip_detail_screen.dart';
|
||||||
import 'package:rippr/src/ui/format.dart';
|
import 'package:rippr/src/ui/format.dart';
|
||||||
import 'package:rippr/src/ui/record/record_screen.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.byKey(const Key('trip-1')), findsOneWidget);
|
||||||
expect(find.text('Finished'), findsOneWidget);
|
expect(find.text('Finished'), findsOneWidget);
|
||||||
// Only the completed one.
|
// Only the completed one. UI-07: trip cards are GlassPanel-based, not Card --
|
||||||
expect(find.byType(Card), findsOneWidget);
|
// 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 {
|
screenTest('Merge enables at exactly two selections', (tester) async {
|
||||||
|
|||||||
Reference in New Issue
Block a user