T19/T21: map and export, completing Phase 4
flutter_map with the same OSM tiles and no-API-key reasoning that chose osmdroid. All four load-bearing behaviours ported and, unlike the native app's map, tested: one polyline per segment so a pause is a real gap, render-only decimation, the zoom clamp at OSM's max tile zoom with a short-ride fallback, and a real user agent. Speed colouring is bucketed per run rather than per-vertex, since neither osmdroid nor flutter_map makes per-vertex paint reasonable. Export via share_plus, which also handles the iPad popover anchor a naive port forgets. It passes the raw stored points, never the map's decimated path. 164 tests passing, analyze clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
213
lib/src/ui/components/ride_map.dart
Normal file
213
lib/src/ui/components/ride_map.dart
Normal file
@@ -0,0 +1,213 @@
|
||||
/// Ported from `com.rippr.ui.components.RideMap` (osmdroid → flutter_map).
|
||||
///
|
||||
/// Same tile source, same reasoning that chose osmdroid: OpenStreetMap raster tiles, no
|
||||
/// API key, no billing.
|
||||
///
|
||||
/// ## Four things here are load-bearing
|
||||
///
|
||||
/// 1. **One polyline per segment**, so a pause leaves a visible gap instead of a
|
||||
/// straight line drawn across town.
|
||||
/// 2. **Decimation is render-only.** `simplify` never touches storage or export.
|
||||
/// 3. **The zoom clamp.** Fitting a 50 m ride zooms past OpenStreetMap's maximum tile
|
||||
/// zoom of 19 and renders an empty grid — that shipped in v2.0 and is invisible to
|
||||
/// any fixture of the wrong size.
|
||||
/// 4. **A real user agent**, or OSM's tile servers return 403.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart' as ll;
|
||||
|
||||
import '../../domain/models.dart';
|
||||
import '../../geo/geo.dart' as geo;
|
||||
|
||||
/// Metres. Render-only: a 3-hour ride is ~21,600 points and would jank undecimated.
|
||||
const double simplifyEpsilonM = 5.0;
|
||||
|
||||
/// Speed colouring is bucketed rather than continuous — osmdroid's per-vertex paint was
|
||||
/// fiddly, and flutter_map has no equivalent either. Buckets also read better at a glance.
|
||||
const int speedBucketKmh = 10;
|
||||
|
||||
/// OpenStreetMap serves no raster tiles above this. Exceeding it renders a blank grid.
|
||||
const double maxTileZoom = 19.0;
|
||||
|
||||
/// What a very short ride falls back to, so streets stay visible.
|
||||
const double shortRideZoom = 17.0;
|
||||
|
||||
/// Identifies the app to OSM's servers. Anonymous bulk requests get 403.
|
||||
const String tileUserAgent = 'com.rippr.port';
|
||||
|
||||
class RideMap extends StatefulWidget {
|
||||
const RideMap({
|
||||
super.key,
|
||||
required this.points,
|
||||
required this.segments,
|
||||
this.height = 320,
|
||||
});
|
||||
|
||||
final List<TrackPoint> points;
|
||||
final List<Segment> segments;
|
||||
final double height;
|
||||
|
||||
@override
|
||||
State<RideMap> createState() => _RideMapState();
|
||||
}
|
||||
|
||||
class _RideMapState extends State<RideMap> {
|
||||
final _controller = MapController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Points grouped by segment, in recording order.
|
||||
List<List<TrackPoint>> get _bySegment {
|
||||
final grouped = <int, List<TrackPoint>>{};
|
||||
for (final p in widget.points) {
|
||||
(grouped[p.segmentId] ??= <TrackPoint>[]).add(p);
|
||||
}
|
||||
return grouped.values.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
if (widget.points.isEmpty) {
|
||||
return SizedBox(
|
||||
height: widget.height,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No path recorded',
|
||||
style: TextStyle(color: colors.outline),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final all = [
|
||||
for (final p in widget.points) geo.LatLon(p.latitude, p.longitude),
|
||||
];
|
||||
final bounds = geo.bounds(all)!;
|
||||
final polylines = _buildPolylines(colors);
|
||||
|
||||
return SizedBox(
|
||||
height: widget.height,
|
||||
// The map draws to the edge of its box; clipping keeps it from painting over
|
||||
// adjacent controls, which osmdroid did until it was explicitly bounded.
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: FlutterMap(
|
||||
mapController: _controller,
|
||||
options: MapOptions(
|
||||
initialCameraFit: bounds.isDegenerate
|
||||
// Every point at one spot — a parked "ride". Fitting this would zoom to
|
||||
// infinity, so centre and use a sane street-level zoom instead.
|
||||
? null
|
||||
: CameraFit.bounds(
|
||||
bounds: LatLngBounds(
|
||||
ll.LatLng(bounds.minLat, bounds.minLon),
|
||||
ll.LatLng(bounds.maxLat, bounds.maxLon),
|
||||
),
|
||||
padding: const EdgeInsets.all(24),
|
||||
// The clamp. Without it a short ride lands past OSM's max tile zoom
|
||||
// and renders an empty grid.
|
||||
maxZoom: maxTileZoom,
|
||||
),
|
||||
initialCenter: ll.LatLng(bounds.centerLat, bounds.centerLon),
|
||||
initialZoom: bounds.isDegenerate ? shortRideZoom : maxTileZoom,
|
||||
maxZoom: maxTileZoom,
|
||||
interactionOptions: const InteractionOptions(
|
||||
flags: InteractiveFlag.pinchZoom | InteractiveFlag.drag,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: tileUserAgent,
|
||||
maxNativeZoom: maxTileZoom.toInt(),
|
||||
// Respect OSM's usage policy: render what is looked at, never bulk prefetch.
|
||||
panBuffer: 0,
|
||||
),
|
||||
PolylineLayer(polylines: polylines),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// One polyline per speed run within each segment.
|
||||
///
|
||||
/// Splitting on bucket change gives speed colouring without per-vertex paint. Segments
|
||||
/// are never joined, so a pause is a genuine gap.
|
||||
List<Polyline> _buildPolylines(ColorScheme colors) {
|
||||
final maxSpeed = widget.points
|
||||
.map((p) => p.speedKmh)
|
||||
.fold<double>(0, (a, b) => a > b ? a : b);
|
||||
|
||||
final out = <Polyline>[];
|
||||
|
||||
for (final segment in _bySegment) {
|
||||
if (segment.isEmpty) continue;
|
||||
|
||||
// Decimate for rendering only. The stored and exported points are untouched.
|
||||
final simplified = geo.simplify([
|
||||
for (final p in segment) geo.LatLon(p.latitude, p.longitude),
|
||||
], simplifyEpsilonM);
|
||||
if (simplified.length < 2) continue;
|
||||
|
||||
// Walk the simplified path, splitting whenever the speed bucket changes.
|
||||
var runStart = 0;
|
||||
var runBucket = _bucketOf(segment.first.speedKmh);
|
||||
|
||||
for (var i = 1; i <= simplified.length; i++) {
|
||||
final bucket = i < simplified.length
|
||||
? _bucketOf(_speedNear(segment, simplified[i]))
|
||||
: -1;
|
||||
if (bucket != runBucket || i == simplified.length) {
|
||||
if (i - runStart >= 2) {
|
||||
out.add(
|
||||
Polyline(
|
||||
points: [
|
||||
for (final p in simplified.sublist(runStart, i))
|
||||
ll.LatLng(p.lat, p.lon),
|
||||
],
|
||||
strokeWidth: 4,
|
||||
color: _colorFor(runBucket, maxSpeed, colors),
|
||||
),
|
||||
);
|
||||
}
|
||||
runStart = i - 1;
|
||||
runBucket = bucket;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/// The speed of the original point nearest a simplified vertex.
|
||||
///
|
||||
/// Simplification drops points, so colouring has to come from the raw data rather than
|
||||
/// from the decimated path.
|
||||
double _speedNear(List<TrackPoint> segment, geo.LatLon vertex) {
|
||||
for (final p in segment) {
|
||||
if (p.latitude == vertex.lat && p.longitude == vertex.lon) {
|
||||
return p.speedKmh;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int _bucketOf(double speedKmh) => (speedKmh / speedBucketKmh).floor();
|
||||
|
||||
/// Cool for slow, accent for fast. A single hue ramp reads better through a visor than
|
||||
/// a rainbow.
|
||||
Color _colorFor(int bucket, double maxSpeed, ColorScheme colors) {
|
||||
if (bucket < 0 || maxSpeed <= 0) return colors.primary;
|
||||
final t = ((bucket * speedBucketKmh) / maxSpeed).clamp(0.0, 1.0);
|
||||
return Color.lerp(const Color(0xFF4FC3F7), colors.primary, t)!;
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,11 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../app/providers.dart';
|
||||
import '../../export/export_manager.dart';
|
||||
import '../../domain/models.dart';
|
||||
import '../../stats/ride_statistics.dart';
|
||||
import '../../telemetry/telemetry.dart';
|
||||
import '../components/ride_map.dart';
|
||||
import '../components/stats.dart';
|
||||
import '../format.dart';
|
||||
|
||||
@@ -23,6 +25,7 @@ class TripDetail {
|
||||
required this.segments,
|
||||
required this.histogram,
|
||||
required this.profile,
|
||||
required this.mapEnabled,
|
||||
});
|
||||
|
||||
final Trip trip;
|
||||
@@ -31,10 +34,16 @@ class TripDetail {
|
||||
final List<Segment> segments;
|
||||
final List<SpeedBucket> histogram;
|
||||
final List<ElevationSample> profile;
|
||||
|
||||
/// The map stays behind a toggle, as in v2: it is only ever live on a visible screen,
|
||||
/// and never while recording in the background.
|
||||
final bool mapEnabled;
|
||||
}
|
||||
|
||||
final tripDetailProvider =
|
||||
FutureProvider.autoDispose.family<TripDetail?, int>((ref, tripId) async {
|
||||
final tripDetailProvider = FutureProvider.autoDispose.family<TripDetail?, int>((
|
||||
ref,
|
||||
tripId,
|
||||
) async {
|
||||
final repo = ref.watch(tripRepositoryProvider);
|
||||
final trip = await repo.tripById(tripId);
|
||||
if (trip == null) return null;
|
||||
@@ -49,6 +58,7 @@ final tripDetailProvider =
|
||||
segments: segments,
|
||||
histogram: speedHistogram(points),
|
||||
profile: elevationProfile(points),
|
||||
mapEnabled: ref.watch(mapEnabledProvider),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -69,33 +79,73 @@ class TripDetailScreen extends ConsumerWidget {
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: onBack,
|
||||
),
|
||||
title: Text(async.valueOrNull == null
|
||||
? 'Ride'
|
||||
: tripLabel(async.value!.trip)),
|
||||
title: Text(
|
||||
async.valueOrNull == null ? 'Ride' : tripLabel(async.value!.trip),
|
||||
),
|
||||
actions: [
|
||||
if (async.valueOrNull != null)
|
||||
IconButton(
|
||||
key: const Key('rename'),
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
onPressed: () => _rename(context, ref, async.value!.trip),
|
||||
key: const Key('export'),
|
||||
icon: const Icon(Icons.ios_share),
|
||||
onPressed: () => _export(context, async.value!),
|
||||
),
|
||||
IconButton(
|
||||
key: const Key('rename'),
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
onPressed: () => _rename(context, ref, async.value!.trip),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: switch (async) {
|
||||
AsyncValue(hasValue: true, value: null) => const EmptyState(
|
||||
message: 'This ride no longer exists.',
|
||||
icon: Icons.help_outline,
|
||||
),
|
||||
message: 'This ride no longer exists.',
|
||||
icon: Icons.help_outline,
|
||||
),
|
||||
AsyncValue(hasValue: true, value: final d?) => _Body(detail: d),
|
||||
AsyncValue(hasError: true, :final error) =>
|
||||
EmptyState(message: 'Could not load this ride.\n$error'),
|
||||
AsyncValue(hasError: true, :final error) => EmptyState(
|
||||
message: 'Could not load this ride.\n$error',
|
||||
),
|
||||
_ => const Center(child: CircularProgressIndicator()),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _rename(
|
||||
BuildContext context, WidgetRef ref, Trip trip) async {
|
||||
Future<void> _export(BuildContext context, TripDetail d) async {
|
||||
final format = await showModalBottomSheet<ExportFormat>(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
key: const Key('export-gpx'),
|
||||
leading: const Icon(Icons.route),
|
||||
title: const Text('GPX'),
|
||||
subtitle: const Text('Strava, Garmin, Google Earth'),
|
||||
onTap: () => Navigator.of(context).pop(ExportFormat.gpx),
|
||||
),
|
||||
ListTile(
|
||||
key: const Key('export-geojson'),
|
||||
leading: const Icon(Icons.data_object),
|
||||
title: const Text('GeoJSON'),
|
||||
subtitle: const Text('Mapping and analysis tools'),
|
||||
onTap: () => Navigator.of(context).pop(ExportFormat.geojson),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (format == null) return;
|
||||
await shareRide(
|
||||
trip: d.trip,
|
||||
segments: d.segments,
|
||||
// Deliberately d.points, the raw stored points -- never the map's decimated path.
|
||||
points: d.points,
|
||||
format: format,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _rename(BuildContext context, WidgetRef ref, Trip trip) async {
|
||||
final controller = TextEditingController(text: trip.name ?? '');
|
||||
final name = await showDialog<String>(
|
||||
context: context,
|
||||
@@ -146,29 +196,39 @@ class _Body extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (detail.mapEnabled)
|
||||
RideMap(points: detail.points, segments: detail.segments),
|
||||
if (detail.mapEnabled) const SizedBox(height: 24),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
StatRow(
|
||||
label: 'Moving time',
|
||||
value: formatDuration(s.movingMillis)),
|
||||
label: 'Moving time',
|
||||
value: formatDuration(s.movingMillis),
|
||||
),
|
||||
StatRow(
|
||||
label: 'Elapsed', value: formatDuration(s.elapsedMillis)),
|
||||
label: 'Elapsed',
|
||||
value: formatDuration(s.elapsedMillis),
|
||||
),
|
||||
StatRow(
|
||||
label: 'Stopped', value: formatDuration(s.stoppedMillis)),
|
||||
label: 'Stopped',
|
||||
value: formatDuration(s.stoppedMillis),
|
||||
),
|
||||
StatRow(label: 'Max speed', value: formatSpeed(s.maxSpeedKmh)),
|
||||
StatRow(
|
||||
label: 'Max speed', value: formatSpeed(s.maxSpeedKmh)),
|
||||
label: 'Avg moving speed',
|
||||
value: formatSpeed(s.avgMovingSpeedKmh),
|
||||
),
|
||||
StatRow(
|
||||
label: 'Avg moving speed',
|
||||
value: formatSpeed(s.avgMovingSpeedKmh)),
|
||||
label: 'Elevation gain',
|
||||
value: formatElevation(s.elevationGainM),
|
||||
),
|
||||
StatRow(
|
||||
label: 'Elevation gain',
|
||||
value: formatElevation(s.elevationGainM)),
|
||||
StatRow(
|
||||
label: 'Elevation loss',
|
||||
value: formatElevation(s.elevationLossM)),
|
||||
label: 'Elevation loss',
|
||||
value: formatElevation(s.elevationLossM),
|
||||
),
|
||||
StatRow(label: 'Points', value: '${s.pointCount}'),
|
||||
StatRow(label: 'Segments', value: '${detail.segments.length}'),
|
||||
],
|
||||
@@ -193,16 +253,16 @@ class _SectionTitle extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
text.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
letterSpacing: 2,
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
),
|
||||
);
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
text.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
letterSpacing: 2,
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Time in each speed band.
|
||||
@@ -220,8 +280,9 @@ class _Histogram extends StatelessWidget {
|
||||
return const EmptyState(message: 'Not enough points to chart.');
|
||||
}
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final maxMillis =
|
||||
buckets.map((b) => b.millis).reduce((a, b) => a > b ? a : b);
|
||||
final maxMillis = buckets
|
||||
.map((b) => b.millis)
|
||||
.reduce((a, b) => a > b ? a : b);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
@@ -232,8 +293,10 @@ class _Histogram extends StatelessWidget {
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 64,
|
||||
child: Text(b.label,
|
||||
style: TextStyle(fontSize: 12, color: colors.outline)),
|
||||
child: Text(
|
||||
b.label,
|
||||
style: TextStyle(fontSize: 12, color: colors.outline),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
@@ -331,8 +394,7 @@ class _ElevationPainter extends CustomPainter {
|
||||
final path = Path();
|
||||
for (var i = 0; i < samples.length; i++) {
|
||||
final x = size.width * (samples[i].distanceM / xSpan);
|
||||
final y =
|
||||
size.height * (1 - (samples[i].altitudeM - minAlt) / span);
|
||||
final y = size.height * (1 - (samples[i].altitudeM - minAlt) / span);
|
||||
if (i == 0) {
|
||||
path.moveTo(x, y);
|
||||
} else {
|
||||
|
||||
@@ -21,50 +21,48 @@ abstract final class Routes {
|
||||
}
|
||||
|
||||
GoRouter buildRouter() => GoRouter(
|
||||
initialLocation: Routes.record,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: Routes.record,
|
||||
builder: (context, state) => RecordScreen(
|
||||
onOpenTrips: () => context.push(Routes.trips),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.trips,
|
||||
builder: (context, state) => TripsScreen(
|
||||
onOpenTrip: (id) => context.push(Routes.detailFor(id)),
|
||||
onBack: () => context.pop(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.tripDetail,
|
||||
builder: (context, state) {
|
||||
// Parsed explicitly. A malformed or missing id must land on a real screen
|
||||
// saying so, never on a blank one or a crash.
|
||||
final id = int.tryParse(state.pathParameters['tripId'] ?? '');
|
||||
if (id == null) {
|
||||
return const _NotFound();
|
||||
}
|
||||
return TripDetailScreen(tripId: id, onBack: () => context.pop());
|
||||
},
|
||||
),
|
||||
],
|
||||
errorBuilder: (context, state) => const _NotFound(),
|
||||
);
|
||||
initialLocation: Routes.record,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: Routes.record,
|
||||
builder: (context, state) =>
|
||||
RecordScreen(onOpenTrips: () => context.push(Routes.trips)),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.trips,
|
||||
builder: (context, state) => TripsScreen(
|
||||
onOpenTrip: (id) => context.push(Routes.detailFor(id)),
|
||||
onBack: () => context.pop(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.tripDetail,
|
||||
builder: (context, state) {
|
||||
// Parsed explicitly. A malformed or missing id must land on a real screen
|
||||
// saying so, never on a blank one or a crash.
|
||||
final id = int.tryParse(state.pathParameters['tripId'] ?? '');
|
||||
if (id == null) {
|
||||
return const _NotFound();
|
||||
}
|
||||
return TripDetailScreen(tripId: id, onBack: () => context.pop());
|
||||
},
|
||||
),
|
||||
],
|
||||
errorBuilder: (context, state) => const _NotFound(),
|
||||
);
|
||||
|
||||
class _NotFound extends StatelessWidget {
|
||||
const _NotFound();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.canPop()
|
||||
? context.pop()
|
||||
: context.go(Routes.record),
|
||||
),
|
||||
),
|
||||
body: const Center(child: Text('That ride could not be found.')),
|
||||
);
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () =>
|
||||
context.canPop() ? context.pop() : context.go(Routes.record),
|
||||
),
|
||||
),
|
||||
body: const Center(child: Text('That ride could not be found.')),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,8 +15,9 @@ import '../format.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>{});
|
||||
final _selectionProvider = StateProvider.autoDispose<Set<int>>(
|
||||
(ref) => <int>{},
|
||||
);
|
||||
|
||||
class TripsScreen extends ConsumerWidget {
|
||||
const TripsScreen({super.key, this.onOpenTrip, this.onBack});
|
||||
@@ -31,9 +32,9 @@ class TripsScreen extends ConsumerWidget {
|
||||
final trips = tripsAsync.valueOrNull ?? const <Trip>[];
|
||||
|
||||
// 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(),
|
||||
);
|
||||
final live = ref
|
||||
.watch(_selectionProvider)
|
||||
.intersection(trips.map((t) => t.id).toSet());
|
||||
final selecting = live.isNotEmpty;
|
||||
// Merge combines exactly two rides; anything else is ambiguous.
|
||||
final canMerge = live.length == 2;
|
||||
@@ -61,8 +62,7 @@ class TripsScreen extends ConsumerWidget {
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: Text(
|
||||
'${live.length} selected',
|
||||
style:
|
||||
TextStyle(fontSize: 14, color: colors.outline),
|
||||
style: TextStyle(fontSize: 14, color: colors.outline),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
@@ -96,8 +96,10 @@ class TripsScreen extends ConsumerWidget {
|
||||
TextButton(
|
||||
key: const Key('delete'),
|
||||
onPressed: () => _confirmDelete(context, ref, live),
|
||||
child: Text('Delete',
|
||||
style: TextStyle(color: colors.error)),
|
||||
child: Text(
|
||||
'Delete',
|
||||
style: TextStyle(color: colors.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -108,29 +110,28 @@ class TripsScreen extends ConsumerWidget {
|
||||
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.',
|
||||
message: 'No rides yet.\nPress Start on the record screen to capture one.',
|
||||
icon: Icons.two_wheeler,
|
||||
),
|
||||
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,
|
||||
onTap: () {
|
||||
if (selecting) {
|
||||
_toggle(ref, trip.id);
|
||||
} else {
|
||||
onOpenTrip?.call(trip.id);
|
||||
}
|
||||
},
|
||||
onLongPress: () => _toggle(ref, trip.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
itemCount: trips.length,
|
||||
itemBuilder: (context, i) {
|
||||
final trip = trips[i];
|
||||
return _TripTile(
|
||||
trip: trip,
|
||||
selected: live.contains(trip.id),
|
||||
selecting: selecting,
|
||||
onTap: () {
|
||||
if (selecting) {
|
||||
_toggle(ref, trip.id);
|
||||
} else {
|
||||
onOpenTrip?.call(trip.id);
|
||||
}
|
||||
},
|
||||
onLongPress: () => _toggle(ref, trip.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
_ => const Center(child: CircularProgressIndicator()),
|
||||
},
|
||||
),
|
||||
@@ -150,7 +151,10 @@ class TripsScreen extends ConsumerWidget {
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(
|
||||
BuildContext context, WidgetRef ref, Set<int> selection) async {
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
Set<int> selection,
|
||||
) async {
|
||||
final ok = await confirmDialog(
|
||||
context,
|
||||
title: 'Delete ${selection.length} ride(s)?',
|
||||
@@ -166,15 +170,20 @@ class TripsScreen extends ConsumerWidget {
|
||||
ref.read(_selectionProvider.notifier).state = <int>{};
|
||||
}
|
||||
|
||||
Future<void> _confirmMerge(BuildContext context, WidgetRef ref,
|
||||
List<Trip> trips, Set<int> selection) async {
|
||||
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)}. '
|
||||
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',
|
||||
@@ -218,8 +227,10 @@ class _TripTile extends StatelessWidget {
|
||||
color: selected ? colors.primary : colors.outline,
|
||||
)
|
||||
: null,
|
||||
title: Text(tripLabel(trip),
|
||||
style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
title: Text(
|
||||
tripLabel(trip),
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${formatDistance(trip.distanceM)} · '
|
||||
'${formatDuration(trip.movingMillis)} · '
|
||||
|
||||
Reference in New Issue
Block a user