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:
2026-08-24 14:32:00 -05:00
parent dccccb68b6
commit 7c1d6570a2
6 changed files with 596 additions and 53 deletions

View File

@@ -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<Set<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 {
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 <Trip>[];
final allTrips = tripsAsync.valueOrNull ?? const <Trip>[];
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<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.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,
),
),
],
);
}
}