V3-01: activity type per ride
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>
This commit is contained in:
29
lib/src/ui/activity_display.dart
Normal file
29
lib/src/ui/activity_display.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
/// Icon and label for each [Activity], shared between the trips list and trip detail.
|
||||
///
|
||||
/// Kept Flutter-dependent and separate from `ui/format.dart`, which stays pure Dart with
|
||||
/// no widget imports.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../domain/models.dart';
|
||||
|
||||
String activityLabel(Activity activity) => switch (activity) {
|
||||
Activity.motorcycle => 'Motorcycle',
|
||||
Activity.bicycle => 'Bicycle',
|
||||
Activity.scooter => 'Scooter',
|
||||
Activity.skateboard => 'Skateboard',
|
||||
Activity.running => 'Running',
|
||||
Activity.walking => 'Walking',
|
||||
Activity.other => 'Other',
|
||||
};
|
||||
|
||||
IconData activityIcon(Activity activity) => switch (activity) {
|
||||
Activity.motorcycle => Icons.two_wheeler,
|
||||
Activity.bicycle => Icons.pedal_bike,
|
||||
Activity.scooter => Icons.electric_scooter,
|
||||
Activity.skateboard => Icons.skateboarding,
|
||||
Activity.running => Icons.directions_run,
|
||||
Activity.walking => Icons.directions_walk,
|
||||
Activity.other => Icons.route,
|
||||
};
|
||||
@@ -8,10 +8,12 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../app/providers.dart';
|
||||
import '../../export/export_manager.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';
|
||||
@@ -50,13 +52,17 @@ final tripDetailProvider = FutureProvider.autoDispose.family<TripDetail?, int>((
|
||||
|
||||
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),
|
||||
summary: computeSummary(points, segments: segments, profile: activityProfile),
|
||||
points: points,
|
||||
segments: segments,
|
||||
histogram: speedHistogram(points),
|
||||
histogram: speedHistogram(
|
||||
points,
|
||||
bucketKmh: activityProfile.histogramBucketKmh,
|
||||
),
|
||||
profile: elevationProfile(points),
|
||||
mapEnabled: ref.watch(mapEnabledProvider),
|
||||
);
|
||||
@@ -101,7 +107,10 @@ class TripDetailScreen extends ConsumerWidget {
|
||||
message: 'This ride no longer exists.',
|
||||
icon: Icons.help_outline,
|
||||
),
|
||||
AsyncValue(hasValue: true, value: final d?) => _Body(detail: d),
|
||||
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',
|
||||
),
|
||||
@@ -174,12 +183,46 @@ class TripDetailScreen extends ConsumerWidget {
|
||||
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});
|
||||
const _Body({required this.detail, required this.onEditActivity});
|
||||
|
||||
final TripDetail detail;
|
||||
final VoidCallback onEditActivity;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -195,6 +238,13 @@ class _Body extends StatelessWidget {
|
||||
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),
|
||||
@@ -246,6 +296,37 @@ class _Body extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ 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/stats.dart';
|
||||
import '../format.dart';
|
||||
|
||||
@@ -226,7 +227,7 @@ class _TripTile extends StatelessWidget {
|
||||
selected ? Icons.check_circle : Icons.circle_outlined,
|
||||
color: selected ? colors.primary : colors.outline,
|
||||
)
|
||||
: null,
|
||||
: Icon(activityIcon(trip.activity), color: colors.outline),
|
||||
title: Text(
|
||||
tripLabel(trip),
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
|
||||
Reference in New Issue
Block a user