Adds Activity (motorcycle/bicycle/scooter/skateboard/running/walking/other) as a column on Trip, and threads it through as real behaviour rather than a label: ActivityProfile (domain/activity_profile.dart) carries a noise floor, accuracy gate, histogram bucket width, and elevation smoothing window/threshold per activity, consumed by sanitizeSpeedKmh, isUsableFix, computeSummary, Accumulator and speedHistogram. The motorcycle profile reproduces the exact constants the app shipped with before this existed, and a test asserts they never drift apart. This is the port's first real migration: schemaVersion 1 -> 2, m.addColumn(trips, trips.activity) with a motorcycle default so every existing row survives unmodified. Proven with a hand-built v1 SQLite file (raw sqlite3, not drift_dev's schema tooling) that a real database with real rides upgrades and keeps every trip, segment and point. No picker in front of Start: a new trip defaults to whichever activity was most recently used, derived live from the trips table rather than duplicated into a Config field. Editable afterwards on trip detail, which recomputes aggregates under the new profile immediately -- a walking pace that reads as noise under a motorcycle's floor reads as real movement once the activity is corrected, and there's a test proving exactly that transition. The widget-test pass for the activity-picker sheet caught a real overflow bug: seven options overflowed a Column-based bottom sheet the same way the record screen once did. Fixed with a scrollable ListView + isScrollControlled, same shape as that earlier fix. 188 tests (171 -> 188): 2 migration, 5 ActivityProfile, 3 profile-threading proofs in computeSummary, 5 repository, 2 widget. Analyze clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
504 lines
16 KiB
Dart
504 lines
16 KiB
Dart
/// Ported from `com.rippr.ui.detail.TripDetailScreen` and `TripDetailViewModel`.
|
|
///
|
|
/// Unlike the trips list, this screen *does* read the point table — it has to, for the
|
|
/// authoritative summary, the charts and (in T19) the map.
|
|
library;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../app/providers.dart';
|
|
import '../../domain/activity_profile.dart';
|
|
import '../../domain/models.dart';
|
|
import '../../export/export_manager.dart';
|
|
import '../../stats/ride_statistics.dart';
|
|
import '../../telemetry/telemetry.dart';
|
|
import '../activity_display.dart';
|
|
import '../components/ride_map.dart';
|
|
import '../components/stats.dart';
|
|
import '../format.dart';
|
|
|
|
/// Everything the detail screen needs, computed once off the stored points.
|
|
class TripDetail {
|
|
const TripDetail({
|
|
required this.trip,
|
|
required this.summary,
|
|
required this.points,
|
|
required this.segments,
|
|
required this.histogram,
|
|
required this.profile,
|
|
required this.mapEnabled,
|
|
});
|
|
|
|
final Trip trip;
|
|
final RideSummary summary;
|
|
final List<TrackPoint> points;
|
|
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 repo = ref.watch(tripRepositoryProvider);
|
|
final trip = await repo.tripById(tripId);
|
|
if (trip == null) return null;
|
|
|
|
final points = await repo.pointsForTrip(tripId);
|
|
final segments = await repo.segmentsForTrip(tripId);
|
|
final activityProfile = ActivityProfile.forActivity(trip.activity);
|
|
|
|
return TripDetail(
|
|
trip: trip,
|
|
summary: computeSummary(points, segments: segments, profile: activityProfile),
|
|
points: points,
|
|
segments: segments,
|
|
histogram: speedHistogram(
|
|
points,
|
|
bucketKmh: activityProfile.histogramBucketKmh,
|
|
),
|
|
profile: elevationProfile(points),
|
|
mapEnabled: ref.watch(mapEnabledProvider),
|
|
);
|
|
});
|
|
|
|
class TripDetailScreen extends ConsumerWidget {
|
|
const TripDetailScreen({super.key, required this.tripId, this.onBack});
|
|
|
|
final int tripId;
|
|
final VoidCallback? onBack;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final async = ref.watch(tripDetailProvider(tripId));
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
leading: IconButton(
|
|
key: const Key('back'),
|
|
icon: const Icon(Icons.arrow_back),
|
|
onPressed: onBack,
|
|
),
|
|
title: Text(
|
|
async.valueOrNull == null ? 'Ride' : tripLabel(async.value!.trip),
|
|
),
|
|
actions: [
|
|
if (async.valueOrNull != null)
|
|
IconButton(
|
|
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,
|
|
),
|
|
AsyncValue(hasValue: true, value: final d?) => _Body(
|
|
detail: d,
|
|
onEditActivity: () => _editActivity(context, ref, d.trip),
|
|
),
|
|
AsyncValue(hasError: true, :final error) => EmptyState(
|
|
message: 'Could not load this ride.\n$error',
|
|
),
|
|
_ => const Center(child: CircularProgressIndicator()),
|
|
},
|
|
);
|
|
}
|
|
|
|
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,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Name this ride'),
|
|
content: TextField(
|
|
key: const Key('name-field'),
|
|
controller: controller,
|
|
autofocus: true,
|
|
decoration: const InputDecoration(hintText: 'Morning loop'),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: const Text('Cancel'),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(controller.text),
|
|
child: const Text('Save'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (name == null) return;
|
|
// Empty collapses to null in the repository, so the date label comes back.
|
|
await ref.read(tripRepositoryProvider).renameTrip(trip.id, name);
|
|
ref.invalidate(tripDetailProvider(trip.id));
|
|
}
|
|
|
|
/// No picker in front of Start — V3-01's constraint — but a ride's activity is fully
|
|
/// editable here after the fact, the same pattern as rename.
|
|
Future<void> _editActivity(BuildContext context, WidgetRef ref, Trip trip) async {
|
|
final chosen = await showModalBottomSheet<Activity>(
|
|
context: context,
|
|
// Seven options, unlike the two-item export sheet -- a plain Column with
|
|
// mainAxisSize.min overflowed on a short screen. Scrollable rather than fixed.
|
|
isScrollControlled: true,
|
|
builder: (context) => SafeArea(
|
|
child: ListView(
|
|
shrinkWrap: true,
|
|
children: [
|
|
for (final activity in Activity.values)
|
|
ListTile(
|
|
key: Key('activity-${activity.name}'),
|
|
leading: Icon(activityIcon(activity)),
|
|
title: Text(activityLabel(activity)),
|
|
trailing: activity == trip.activity
|
|
? const Icon(Icons.check)
|
|
: null,
|
|
onTap: () => Navigator.of(context).pop(activity),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
if (chosen == null || chosen == trip.activity) return;
|
|
// Recomputes aggregates under the new profile -- a noise floor tuned for a
|
|
// motorcycle should not silently keep governing a walk.
|
|
await ref.read(tripRepositoryProvider).setActivity(trip.id, chosen);
|
|
ref.invalidate(tripDetailProvider(trip.id));
|
|
}
|
|
}
|
|
|
|
class _Body extends StatelessWidget {
|
|
const _Body({required this.detail, required this.onEditActivity});
|
|
|
|
final TripDetail detail;
|
|
final VoidCallback onEditActivity;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final s = detail.summary;
|
|
return ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
Center(
|
|
child: BigStat(
|
|
key: const Key('distance'),
|
|
label: 'DISTANCE',
|
|
value: (s.distanceM / 1000).toStringAsFixed(1),
|
|
unit: 'km',
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Center(
|
|
child: _ActivityRow(
|
|
activity: detail.trip.activity,
|
|
onTap: onEditActivity,
|
|
),
|
|
),
|
|
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),
|
|
),
|
|
StatRow(
|
|
label: 'Elapsed',
|
|
value: formatDuration(s.elapsedMillis),
|
|
),
|
|
StatRow(
|
|
label: 'Stopped',
|
|
value: formatDuration(s.stoppedMillis),
|
|
),
|
|
StatRow(label: 'Max speed', value: formatSpeed(s.maxSpeedKmh)),
|
|
StatRow(
|
|
label: 'Avg moving speed',
|
|
value: formatSpeed(s.avgMovingSpeedKmh),
|
|
),
|
|
StatRow(
|
|
label: 'Elevation gain',
|
|
value: formatElevation(s.elevationGainM),
|
|
),
|
|
StatRow(
|
|
label: 'Elevation loss',
|
|
value: formatElevation(s.elevationLossM),
|
|
),
|
|
StatRow(label: 'Points', value: '${s.pointCount}'),
|
|
StatRow(label: 'Segments', value: '${detail.segments.length}'),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
_SectionTitle('Speed distribution'),
|
|
_Histogram(buckets: detail.histogram),
|
|
const SizedBox(height: 24),
|
|
_SectionTitle('Elevation profile'),
|
|
_ElevationChart(samples: detail.profile),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A tappable "what this ride was" row — icon, label, and a small edit affordance.
|
|
class _ActivityRow extends StatelessWidget {
|
|
const _ActivityRow({required this.activity, required this.onTap});
|
|
|
|
final Activity activity;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colors = Theme.of(context).colorScheme;
|
|
return InkWell(
|
|
key: const Key('activity-row'),
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(activityIcon(activity), size: 16, color: colors.outline),
|
|
const SizedBox(width: 6),
|
|
Text(activityLabel(activity), style: TextStyle(color: colors.outline)),
|
|
const SizedBox(width: 4),
|
|
Icon(Icons.edit_outlined, size: 13, color: colors.outline),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SectionTitle extends StatelessWidget {
|
|
const _SectionTitle(this.text);
|
|
|
|
final String text;
|
|
|
|
@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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Time in each speed band.
|
|
///
|
|
/// Degrades to a message rather than an empty box: a ride of one point has no intervals
|
|
/// to measure, and an unexplained blank looks like a bug.
|
|
class _Histogram extends StatelessWidget {
|
|
const _Histogram({required this.buckets});
|
|
|
|
final List<SpeedBucket> buckets;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (buckets.isEmpty) {
|
|
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);
|
|
|
|
return Column(
|
|
children: [
|
|
for (final b in buckets)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 3),
|
|
child: Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 64,
|
|
child: Text(
|
|
b.label,
|
|
style: TextStyle(fontSize: 12, color: colors.outline),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: LayoutBuilder(
|
|
builder: (context, c) => Stack(
|
|
children: [
|
|
Container(
|
|
height: 14,
|
|
decoration: BoxDecoration(
|
|
color: colors.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(3),
|
|
),
|
|
),
|
|
Container(
|
|
height: 14,
|
|
width: maxMillis == 0
|
|
? 0
|
|
: c.maxWidth * (b.millis / maxMillis),
|
|
decoration: BoxDecoration(
|
|
color: colors.primary,
|
|
borderRadius: BorderRadius.circular(3),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 72,
|
|
child: Text(
|
|
formatDuration(b.millis),
|
|
textAlign: TextAlign.right,
|
|
style: TextStyle(fontSize: 12, color: colors.outline),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Altitude against distance travelled.
|
|
///
|
|
/// Needs at least two points to draw a line — below that it says so.
|
|
class _ElevationChart extends StatelessWidget {
|
|
const _ElevationChart({required this.samples});
|
|
|
|
final List<ElevationSample> samples;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (samples.length < 2) {
|
|
return const EmptyState(message: 'Not enough points to chart.');
|
|
}
|
|
return SizedBox(
|
|
height: 140,
|
|
child: CustomPaint(
|
|
painter: _ElevationPainter(
|
|
samples: samples,
|
|
line: Theme.of(context).colorScheme.primary,
|
|
fill: Theme.of(context).colorScheme.primary.withValues(alpha: 0.18),
|
|
),
|
|
child: const SizedBox.expand(),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ElevationPainter extends CustomPainter {
|
|
_ElevationPainter({
|
|
required this.samples,
|
|
required this.line,
|
|
required this.fill,
|
|
});
|
|
|
|
final List<ElevationSample> samples;
|
|
final Color line;
|
|
final Color fill;
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
var minAlt = samples.first.altitudeM;
|
|
var maxAlt = samples.first.altitudeM;
|
|
for (final s in samples) {
|
|
if (s.altitudeM < minAlt) minAlt = s.altitudeM;
|
|
if (s.altitudeM > maxAlt) maxAlt = s.altitudeM;
|
|
}
|
|
// A dead-flat ride would divide by zero; give it a nominal band so the line sits
|
|
// centred rather than vanishing.
|
|
final span = (maxAlt - minAlt).abs() < 1e-9 ? 1.0 : maxAlt - minAlt;
|
|
final totalDistance = samples.last.distanceM;
|
|
final xSpan = totalDistance <= 0 ? 1.0 : totalDistance;
|
|
|
|
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);
|
|
if (i == 0) {
|
|
path.moveTo(x, y);
|
|
} else {
|
|
path.lineTo(x, y);
|
|
}
|
|
}
|
|
|
|
final area = Path.from(path)
|
|
..lineTo(size.width, size.height)
|
|
..lineTo(0, size.height)
|
|
..close();
|
|
|
|
canvas.drawPath(area, Paint()..color = fill);
|
|
canvas.drawPath(
|
|
path,
|
|
Paint()
|
|
..color = line
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = 2,
|
|
);
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(_ElevationPainter old) => old.samples != samples;
|
|
}
|