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

@@ -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 {