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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user