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
581 lines
21 KiB
Dart
581 lines
21 KiB
Dart
/// 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<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 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(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 = <int>{};
|
|
|
|
// 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<void> _confirmDelete(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
Set<int> 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 = <int>{};
|
|
}
|
|
|
|
Future<void> _confirmMerge(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
List<Trip> trips,
|
|
Set<int> selection,
|
|
) async {
|
|
final total = trips
|
|
.where((t) => selection.contains(t.id))
|
|
.fold<double>(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 = <int>{};
|
|
}
|
|
}
|
|
|
|
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,
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|