V3-10: trip splitting

TripRepository.splitTrip mirrors mergeTrips' transaction shape: splits at a segment boundary, moves the target segment and everything after it to a new trip, recomputes both trips' aggregates from scratch. startedAt/endedAt derive from each trip's actual remaining segments, not copied from the pre-split row. Trip detail gained a split action with a segment-boundary picker and a naming confirmation; a single-segment ride explains why it can't split instead of offering a dead control.
This commit is contained in:
2026-08-17 19:23:44 -05:00
parent a1e99684e0
commit 0e605ef174
7 changed files with 417 additions and 2 deletions

View File

@@ -337,6 +337,13 @@ class AppDatabase extends _$AppDatabase {
SegmentsCompanion(tripId: Value(newTripId)),
);
/// Used by split (V3-10): unlike [reparentSegments], moves exactly one segment rather
/// than every segment on a trip.
Future<void> reparentSegment(int segmentId, int newTripId) =>
(update(segments)..where((s) => s.id.equals(segmentId))).write(
SegmentsCompanion(tripId: Value(newTripId)),
);
Future<int> countSegmentsForTrip(int tripId) async => (await (select(
segments,
)..where((s) => s.tripId.equals(tripId))).get()).length;
@@ -499,6 +506,17 @@ class AppDatabase extends _$AppDatabase {
TrackPointsCompanion(tripId: Value(newTripId)),
);
/// Used by split (V3-10): moves only the points belonging to the given segments, not
/// every point on the trip -- the counterpart to [reparentSegment].
Future<void> reparentPointsForSegments(List<int> segmentIds, int newTripId) {
if (segmentIds.isEmpty) return Future.value();
return (update(
trackPoints,
)..where((p) => p.segmentId.isIn(segmentIds))).write(
TrackPointsCompanion(tripId: Value(newTripId)),
);
}
Future<List<domain.TrackPoint>> allPoints() async {
final rows = await (select(
trackPoints,

View File

@@ -248,6 +248,58 @@ class TripRepository {
return survivor.id;
});
/// Splits one recorded ride into two at a segment boundary. The natural counterpart to
/// [mergeTrips].
///
/// Splits at a **segment boundary**, never mid-segment (see V3-10) — segments already
/// mark where the rider paused, which is exactly where a forgotten stop shows up, and
/// it avoids inventing a new boundary type. [atSegmentId] and every segment after it
/// move to a brand-new trip; everything before it stays on [tripId].
///
/// Returns the new trip's id, or null if the split was rejected: an active trip, a
/// trip with fewer than two segments (nothing to split), or [atSegmentId] naming the
/// trip's first segment (there would be nothing left before the split).
Future<int?> splitTrip(int tripId, int atSegmentId) => _db.transaction(() async {
final trip = await _db.getTrip(tripId);
if (trip == null || trip.isActive) return null;
final segments = await _db.segmentsForTrip(tripId);
if (segments.length < 2) return null;
final atIndex = segments.indexWhere((s) => s.id == atSegmentId);
if (atIndex <= 0) return null;
final kept = segments.sublist(0, atIndex);
final moved = segments.sublist(atIndex);
final newTripId = await _db.insertTrip(
Trip(
startedAt: moved.first.startedAt,
endedAt: trip.endedAt,
state: TripState.completed,
activity: trip.activity,
),
);
for (final segment in moved) {
await _db.reparentSegment(segment.id, newTripId);
}
await _db.reparentPointsForSegments(
[for (final segment in moved) segment.id],
newTripId,
);
// Derived from the segment the original trip actually keeps, not copied from the
// pre-split trip row -- see the ticket's named risk.
final originalEndedAt = kept.last.endedAt ?? trip.endedAt ?? trip.startedAt;
await _db.closeTrip(tripId, originalEndedAt, state: TripState.completed);
await recomputeAggregates(tripId);
await recomputeAggregates(newTripId);
return newTripId;
});
/// Recomputes a trip's stored totals from the points it actually owns.
///
/// This is the authoritative pass. The recorder's live accumulation is an estimate, so

View File

@@ -100,6 +100,17 @@ class TripDetailScreen extends ConsumerWidget {
icon: const Icon(Icons.edit_outlined),
onPressed: () => _rename(context, ref, async.value!.trip),
),
if (async.valueOrNull != null)
IconButton(
key: const Key('split'),
icon: const Icon(Icons.content_cut),
tooltip: async.value!.segments.length < 2
? 'Nothing to split -- this ride has only one segment'
: 'Split this ride',
// V3-10: a dead control that explains itself via SnackBar on tap, rather
// than a disabled button with no explanation at all.
onPressed: () => _split(context, ref, async.value!),
),
],
),
body: switch (async) {
@@ -185,6 +196,64 @@ class TripDetailScreen extends ConsumerWidget {
ref.invalidate(tripDetailProvider(trip.id));
}
/// V3-10: splits at a segment boundary only -- see `TripRepository.splitTrip`. A
/// single-segment ride has no boundary to offer, so this explains why rather than
/// silently doing nothing.
Future<void> _split(BuildContext context, WidgetRef ref, TripDetail detail) async {
if (detail.segments.length < 2) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('This ride has only one segment -- there is nothing to split.'),
),
);
return;
}
final atSegmentId = await showModalBottomSheet<int>(
context: context,
isScrollControlled: true,
builder: (context) => SafeArea(
child: ListView(
shrinkWrap: true,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text('Split before...'),
),
// The first segment can never be a split point -- nothing would remain
// before it. See TripRepository.splitTrip.
for (final segment in detail.segments.skip(1))
ListTile(
key: Key('split-at-${segment.id}'),
leading: const Icon(Icons.content_cut),
title: Text(formatDateTime(segment.startedAt)),
onTap: () => Navigator.of(context).pop(segment.id),
),
],
),
),
);
if (atSegmentId == null || !context.mounted) return;
final segment = detail.segments.firstWhere((s) => s.id == atSegmentId);
final firstLabel = formatDateTime(detail.trip.startedAt);
final secondLabel = formatDateTime(segment.startedAt);
final confirmed = await confirmDialog(
context,
title: 'Split this ride?',
message: 'This ride will become two: "$firstLabel" and "$secondLabel". '
'Distance and other totals are recomputed for each.',
confirmLabel: 'Split',
);
if (!confirmed) return;
final newTripId = await ref
.read(tripRepositoryProvider)
.splitTrip(detail.trip.id, atSegmentId);
ref.invalidate(tripDetailProvider(detail.trip.id));
if (newTripId != null) ref.invalidate(tripDetailProvider(newTripId));
}
/// 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 {