Session expiry, arrival geofence guard, multi-destination stops
Three fixes found by running the app on a real handset against production. 1. An expired token left the app looking signed in and unable to work. MilerApi.onUnauthorized was declared and called on every 401 but never assigned, so the token was dropped and nothing else happened: the profile stayed on disk, logged_out stayed false, and the rider saw his own name over a dashboard whose every call returned 401. He reads that as "no work today". The teardown now lives in endSession() and both ways out of a session — the Log out button and the 401 path — use it. 2. Arrived was written locally even when the rider was not there. updateArrivedStatus answers false for three different things and the caller treated all of them as "the write did not land", which is only true of one. A geofence refusal and a server refusal now stop the rung and hand back the reason; a dead network still advances, as it should. 3. A multi-destination customer pickup collapsed onto one stop. GET /miler/bookings returns a row per destination once collected, all with the same bookingid and reference. Every local store keys on that id, so the accepted store deduped two of three drops away and their consignment ids were unrecoverable. orderid is now the stop key; bookingreference stays the booking's name. Cards show "Stop 2 of 3" and the receiver's own name and number rather than the sender's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
This commit is contained in:
@@ -67,6 +67,7 @@ import 'package:miler/data/order_manifest.dart';
|
||||
import 'package:miler/data/milk_run.dart';
|
||||
import 'package:miler/data/order_events.dart';
|
||||
import 'package:miler/data/work_domain.dart';
|
||||
import 'package:miler/data/geofence.dart';
|
||||
import 'package:miler/data/work_repository.dart';
|
||||
import 'package:miler/data/pickup_locations.dart';
|
||||
import 'package:miler/data/service_day.dart';
|
||||
@@ -600,55 +601,67 @@ class _HomepageState extends State<Homepage>
|
||||
}
|
||||
|
||||
// ==================== PROXIMITY METHODS ====================
|
||||
Future<bool> _isNearPickupLocation(Map<String, dynamic> Booking) async {
|
||||
// ── The second proximity gate ──
|
||||
//
|
||||
// `PickupsController._checkGeofence` guards a single stop. This one guards
|
||||
// the *bulk* "mark selected as arrived" action on Home, and it runs before
|
||||
// the controller is ever called.
|
||||
//
|
||||
// It honours the same switch and, since this change, the same **radius**.
|
||||
// It was hardcoded to 500 m while the controller read a configured 100 —
|
||||
// so the app had two fences of different sizes on two routes into the same
|
||||
// rung, and which one a rider met depended on whether he had ticked boxes
|
||||
// or slid a sheet. One number now: [kGeofenceRadiusMeters].
|
||||
if (kBypassGeofenceForTesting) {
|
||||
debugPrint(
|
||||
'[GEOFENCE] OFF for bulk arrival — enforcement is disabled in this '
|
||||
'build. Stop completion is NOT proximity-verified. '
|
||||
'See kGeofenceEnforced.',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
final pickupLat = _parseDouble(
|
||||
Booking['pickuplat'] ?? Booking['PickupLat'],
|
||||
);
|
||||
final pickupLng = _parseDouble(
|
||||
Booking['pickuplon'] ?? Booking['PickupLon'],
|
||||
);
|
||||
if (pickupLat == 0 || pickupLng == 0) return true;
|
||||
final riderLoc = await _getValidCoordinates();
|
||||
if (riderLoc == null) return true;
|
||||
final double riderLat = double.tryParse(riderLoc.$1) ?? 0;
|
||||
final double riderLng = double.tryParse(riderLoc.$2) ?? 0;
|
||||
if (riderLat == 0 || riderLng == 0) return true;
|
||||
final double distanceInMeters = Geolocator.distanceBetween(
|
||||
riderLat,
|
||||
riderLng,
|
||||
pickupLat,
|
||||
pickupLng,
|
||||
);
|
||||
// The phone's own error is credited to the rider here too — see
|
||||
// [kGeofenceRadiusMeters] for why a 10 m fence measured with a ±20 m fix
|
||||
// has to, and `_getValidCoordinates` for the fix it is measured with.
|
||||
final double slack = _lastFixAccuracy;
|
||||
return (distanceInMeters - slack) <= kGeofenceRadiusMeters;
|
||||
} catch (e) {
|
||||
debugPrint('[PROXIMITY] Error checking proximity: $e');
|
||||
return true;
|
||||
}
|
||||
/// The fence's answer for the last stop [_isNearPickupLocation] measured.
|
||||
///
|
||||
/// Kept so the bulk-arrival sheet can name a distance instead of repeating a
|
||||
/// generic "you are too far" for every ticked box.
|
||||
GeofenceDecision? _lastBulkGeofence;
|
||||
|
||||
/// The bulk gate on Home: the *selected stops* route into the arrived rung.
|
||||
///
|
||||
/// ── One fence, reached two ways ──
|
||||
///
|
||||
/// `PickupsController._checkGeofence` guards a single stop; this guards the
|
||||
/// "mark selected as arrived" action, and it runs before the controller is
|
||||
/// ever called. The two used to be separate implementations with separate
|
||||
/// radii — 500 m hardcoded here against the controller's configured 100 — so
|
||||
/// which fence a rider met depended on whether he ticked boxes or slid a
|
||||
/// sheet. Both now call [Geofence] and there is no arithmetic left in this
|
||||
/// file to drift.
|
||||
///
|
||||
/// ── It used to fail open in four places ──
|
||||
///
|
||||
/// A booking with no pin, a rider fix that could not be read, a zero
|
||||
/// coordinate pair and any thrown exception each ended in `return true`. All
|
||||
/// four are refusals now; [Geofence] decides which sentence the rider sees.
|
||||
Future<bool> _isNearPickupLocation(Map<String, dynamic> booking) async {
|
||||
final decision = await Geofence.check(
|
||||
targetLat: _parseDouble(booking['pickuplat'] ?? booking['PickupLat']),
|
||||
targetLng: _parseDouble(booking['pickuplon'] ?? booking['PickupLon']),
|
||||
action: 'Arrived',
|
||||
);
|
||||
_lastBulkGeofence = decision;
|
||||
return decision.allowed;
|
||||
}
|
||||
|
||||
/// How many bulk status writes may be in flight at once.
|
||||
///
|
||||
/// Not unbounded. Twenty simultaneous requests from one handset queue at the
|
||||
/// radio on a rider's 3G connection and finish *slower* than a handful, and a
|
||||
/// thundering herd from one rider is a poor shape to hand the backend. Six is
|
||||
/// enough to hide the per-request latency — the thing that made the old
|
||||
/// sequential loop take a minute — without any of that.
|
||||
static const int _bulkLanes = 6;
|
||||
|
||||
/// Straight-line kilometres from the rider to a stop, measured on the device.
|
||||
///
|
||||
/// Handed to `updatePickedStatus` as `actualKms` purely so its priority-3
|
||||
/// branch — an OSRM road-distance request per order, six-second timeout —
|
||||
/// never runs during a bulk action. It is the same straight line that branch
|
||||
/// already falls back to when the routing service fails.
|
||||
double _bulkLegKm(
|
||||
String riderLat,
|
||||
String riderLng,
|
||||
String stopLat,
|
||||
String stopLng,
|
||||
) {
|
||||
final rLat = double.tryParse(riderLat) ?? 0;
|
||||
final rLng = double.tryParse(riderLng) ?? 0;
|
||||
final sLat = double.tryParse(stopLat) ?? 0;
|
||||
final sLng = double.tryParse(stopLng) ?? 0;
|
||||
if (rLat == 0 || rLng == 0 || sLat == 0 || sLng == 0) return 0;
|
||||
return Geolocator.distanceBetween(rLat, rLng, sLat, sLng) / 1000.0;
|
||||
}
|
||||
|
||||
Future<Map<String, bool>> _checkProximityForOrders(
|
||||
@@ -695,7 +708,7 @@ class _HomepageState extends State<Homepage>
|
||||
content: Text(
|
||||
specificMessage ??
|
||||
'You must be within '
|
||||
'${kGeofenceRadiusMeters.toStringAsFixed(0)} metres of the '
|
||||
'${kGeofenceRadiusMetres.toStringAsFixed(0)} metres of the '
|
||||
'pickup location to mark this booking as arrived.',
|
||||
style: TextStyle(
|
||||
fontSize: FontConstants.regular(context),
|
||||
@@ -2054,6 +2067,34 @@ class _HomepageState extends State<Homepage>
|
||||
// is slide again at a stop that will keep refusing. See
|
||||
// [PickupsController.lastPickupRefusal].
|
||||
if (nextStatus == 'PICKED') return dc.lastPickupRefusal ?? 'refused';
|
||||
|
||||
// ── Optimistic covers a write that did not LAND, never a rider who is
|
||||
// not THERE ──
|
||||
//
|
||||
// The paragraph above is right about the case it describes: a rider at
|
||||
// a counter with no signal must not have his own report of where he is
|
||||
// snapped back by a late poll.
|
||||
//
|
||||
// But `updateArrivedStatus` answers false for three different things,
|
||||
// and only one of them is that case. It is also false when the geofence
|
||||
// refused — the rider is 1.4km from the door and no request was ever
|
||||
// made — and when the server itself rejected the arrival on a business
|
||||
// rule. Advancing on those wrote ARRIVED onto a stop the rider had not
|
||||
// reached, and the admin console, which only ever hears what the server
|
||||
// was told, went on showing the stop as pending. That is the divergence
|
||||
// reported from the field: arrived on the phone, not arrived in the
|
||||
// office, and no way for either side to tell who was wrong.
|
||||
//
|
||||
// So the two "he is not there / the office said no" cases stop here and
|
||||
// are handed back to be shown. A dead network still falls through and
|
||||
// still advances — with [PickupsController.lastArrivalNotice] telling
|
||||
// him the office has not been told.
|
||||
//
|
||||
// `lastBlockedReason` is read first and is always fresh: `_checkGeofence`
|
||||
// clears it on entry, so a non-null value can only have come from the
|
||||
// check that just ran.
|
||||
final notThere = dc.lastBlockedReason ?? dc.lastArrivalRefusal;
|
||||
if (notThere != null) return notThere;
|
||||
}
|
||||
|
||||
// ── The rung the SERVER produced, not the one the button is named after ──
|
||||
@@ -2930,13 +2971,22 @@ class _HomepageState extends State<Homepage>
|
||||
}
|
||||
|
||||
if (targetStatus == 'ARRIVED') {
|
||||
final proximityResults = await _checkProximityForOrders(selectedOrderIds)
|
||||
.timeout(
|
||||
const Duration(seconds: 3),
|
||||
onTimeout: () => Map<String, bool>.fromEntries(
|
||||
selectedOrderIds.map((id) => MapEntry(id, true)),
|
||||
),
|
||||
);
|
||||
// ── The 3-second fail-OPEN is gone ──
|
||||
//
|
||||
// This was wrapped in `.timeout(3s, onTimeout: () => everything inside
|
||||
// the fence)`. Each check took its own 8-second GPS fix, so twenty of
|
||||
// them could never finish in three seconds — the timeout fired on
|
||||
// essentially every bulk arrival and waved every order through whatever
|
||||
// the distance was. A hole straight through the 100 m rule, and one that
|
||||
// looked like a safety net.
|
||||
//
|
||||
// It is not needed now: [Geofence] reuses one live fix for
|
||||
// [kGeofenceFixReuse], so twenty checks cost one GPS settle and complete
|
||||
// in milliseconds. If the fix itself cannot be had, each check refuses on
|
||||
// its own terms and says which of location-off / permission / no-signal
|
||||
// it was — which is the answer the rider can act on, and the one a
|
||||
// blanket "allow everything" was hiding.
|
||||
final proximityResults = await _checkProximityForOrders(selectedOrderIds);
|
||||
|
||||
final farOrders = proximityResults.entries
|
||||
.where((e) => !e.value)
|
||||
@@ -2948,7 +2998,8 @@ class _HomepageState extends State<Homepage>
|
||||
await _showProximityWarning(
|
||||
context,
|
||||
specificMessage: farOrders.length == selectedOrderIds.length
|
||||
? 'You must be within 500 meters of the pickup location(s) to mark bookings as arrived.'
|
||||
? 'You must be within ${kGeofenceRadiusMetres.round()} m of the pickup '
|
||||
'location to mark it arrived.'
|
||||
: 'Some selected bookings are too far from their pickup locations.',
|
||||
);
|
||||
}
|
||||
@@ -3041,11 +3092,41 @@ class _HomepageState extends State<Homepage>
|
||||
/// than at the next poll.
|
||||
final Set<String> bulkCollected = <String>{};
|
||||
|
||||
for (final orderId in selectedOrderIds) {
|
||||
// ── Phase 1: the network, overlapped ──
|
||||
//
|
||||
// This was a plain sequential `for`, so twenty orders meant twenty full
|
||||
// round trips end to end — status call, GPS fix, routing call and four
|
||||
// preferences writes each, nothing overlapping anything. Measured at
|
||||
// 3–5 s per order, which is the 1–2 minutes riders were reporting for a
|
||||
// twenty-bag counter pick.
|
||||
//
|
||||
// The writes are independent — different bookings, different
|
||||
// [MutationGuard] keys (`picked:1042`), and the backend carries an
|
||||
// `Idempotency-Key` per action — so they can be in flight together. The
|
||||
// *local* bookkeeping is NOT done here: it is collected and applied once,
|
||||
// below, so the order of local state changes stays deterministic and a
|
||||
// growing JSON blob is not rewritten twenty times.
|
||||
//
|
||||
// Bounded to [_bulkLanes] rather than `Future.wait` over everything:
|
||||
// twenty simultaneous requests on a rider's 3G connection queue at the
|
||||
// radio and finish slower than six, and it is a kinder shape for the
|
||||
// backend than a thundering herd from one handset.
|
||||
final List<String> bulkIds = [];
|
||||
final Map<String, Map<String, dynamic>> bulkStops = {};
|
||||
for (final id in selectedOrderIds) {
|
||||
// Not `_ordersMap` — a stop past ACCEPT is no longer in it, and every
|
||||
// rung after the first was silently skipped here. See [_bulkStopFor].
|
||||
final Booking = _bulkStopFor(orderId);
|
||||
if (Booking == null) continue;
|
||||
final stop = _bulkStopFor(id);
|
||||
if (stop == null) continue;
|
||||
bulkIds.add(id);
|
||||
bulkStops[id] = stop;
|
||||
}
|
||||
|
||||
/// Which orders the backend confirmed.
|
||||
final Set<String> bulkOk = <String>{};
|
||||
|
||||
Future<void> runOne(String orderId) async {
|
||||
final Booking = bulkStops[orderId]!;
|
||||
|
||||
final pickupId = int.tryParse('${Booking['pickupid'] ?? 0}') ?? 0;
|
||||
final orderHeaderId =
|
||||
@@ -3085,10 +3166,7 @@ class _HomepageState extends State<Homepage>
|
||||
// Home, still selectable, and pressing again costs a tap. So it
|
||||
// records what the backend confirmed and says what it did not —
|
||||
// the same rule `_advanceStop` already applies to PICKED.
|
||||
if (success) {
|
||||
_hiddenOrderIds.add(orderId);
|
||||
acceptedBookings.add(Booking);
|
||||
} else {
|
||||
if (!success) {
|
||||
debugPrint(
|
||||
'[BULK] accept REFUSED for $orderId — leaving it on Home',
|
||||
);
|
||||
@@ -3110,10 +3188,7 @@ class _HomepageState extends State<Homepage>
|
||||
// successful bulk arrival was a network call, a success count and
|
||||
// a row that came straight back on ACCEPTED — which is what "mark
|
||||
// as arrived does nothing" looked like from the outside.
|
||||
if (success) {
|
||||
Booking['orderstatus'] = 'arrived';
|
||||
await addArrivedOrderIds([orderId]);
|
||||
}
|
||||
if (success) Booking['orderstatus'] = 'arrived';
|
||||
} else if (targetStatus == 'PICKED') {
|
||||
debugPrint('[BULK] Picking Booking $orderId');
|
||||
success = await dc.updatePickedStatus(
|
||||
@@ -3125,11 +3200,31 @@ class _HomepageState extends State<Homepage>
|
||||
ridersLng: bulkRidersLng,
|
||||
pickupLat: pickupLat,
|
||||
pickupLng: pickupLng,
|
||||
// ── Pre-computed, so the routing call never fires ──
|
||||
//
|
||||
// Left at 0 this falls through to priority 3, which is an OSRM
|
||||
// road-distance request per order with a 6-second timeout. On a
|
||||
// bulk pick the rider never "started" each stop individually, so
|
||||
// the cumulative-tracking key is absent for most of them and that
|
||||
// request ran for nearly all twenty — to compute a number that
|
||||
// `UpdatePickupProvider` never reads and `pickup-complete` never
|
||||
// sends. The backend derives earnings from the coordinates by its
|
||||
// own haversine; this figure only feeds Activity's local
|
||||
// "km ridden".
|
||||
//
|
||||
// So it is measured here, on the device, for free — the same
|
||||
// straight line the priority-3 branch already falls back to when
|
||||
// OSRM fails.
|
||||
actualKms: _bulkLegKm(
|
||||
bulkRidersLat,
|
||||
bulkRidersLng,
|
||||
pickupLat,
|
||||
pickupLng,
|
||||
),
|
||||
proofImage: bulkProofImageUrl,
|
||||
);
|
||||
if (success) {
|
||||
_startPickupPosting(Booking);
|
||||
_hiddenOrderIds.add(orderId);
|
||||
|
||||
// ── The write that says the load is in his hands ──
|
||||
//
|
||||
@@ -3146,11 +3241,8 @@ class _HomepageState extends State<Homepage>
|
||||
// time are wrong" half of it.
|
||||
//
|
||||
// Recorded only after the backend confirmed, same rule as accept.
|
||||
await addCollectedOrderIds([orderId]);
|
||||
// Applied in phase 2 — see [bulkCollected].
|
||||
bulkCollected.add(orderId);
|
||||
// One copy of the day, and it is now stale — Deliveries must not
|
||||
// render this order from a response that predates the hand-over.
|
||||
unawaited(WorkRepository.instance.invalidate());
|
||||
|
||||
// ── Picked is a hand-over on one line and a finish on the other ──
|
||||
//
|
||||
@@ -3163,14 +3255,8 @@ class _HomepageState extends State<Homepage>
|
||||
// drops he makes himself — so filing it as completed sent it to
|
||||
// Activity instead of to Deliveries, and the delivery leg had
|
||||
// nothing to work. See [ServiceProfile.handsOffAtCollection].
|
||||
if (ServiceProfile.active.handsOffAtCollection) {
|
||||
await addAcceptedBookings([Booking]);
|
||||
} else {
|
||||
await removeAcceptedBookings([orderId]);
|
||||
await addCompletedBookings([Booking]);
|
||||
}
|
||||
// Off the arrived rung — it has been collected.
|
||||
await removeArrivedOrderIds([orderId]);
|
||||
// The handsOffAtCollection split is applied in phase 2, once,
|
||||
// against the whole batch.
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -3179,12 +3265,78 @@ class _HomepageState extends State<Homepage>
|
||||
}
|
||||
|
||||
if (success) {
|
||||
successCount++;
|
||||
bulkOk.add(orderId);
|
||||
} else {
|
||||
debugPrint('[BULK] Failed to update Booking $orderId');
|
||||
}
|
||||
}
|
||||
|
||||
// Bounded-concurrency drain. `next++` is safe: there is no `await`
|
||||
// between reading and incrementing it, and Dart does not preempt inside a
|
||||
// synchronous run of statements.
|
||||
var next = 0;
|
||||
final int lanes = _bulkLanes < bulkIds.length
|
||||
? _bulkLanes
|
||||
: (bulkIds.isEmpty ? 1 : bulkIds.length);
|
||||
await Future.wait(
|
||||
List.generate(lanes, (_) async {
|
||||
while (true) {
|
||||
final i = next++;
|
||||
if (i >= bulkIds.length) break;
|
||||
await runOne(bulkIds[i]);
|
||||
}
|
||||
}),
|
||||
);
|
||||
successCount += bulkOk.length;
|
||||
|
||||
// ── Phase 2: every local store written once ──
|
||||
//
|
||||
// Each of these helpers decodes a JSON blob, mutates it and writes it
|
||||
// back. Called per order inside the loop that was O(n²) writes against a
|
||||
// growing list, and it left a window where a rider backgrounding the app
|
||||
// mid-batch had half a batch filed.
|
||||
final List<String> okIds = bulkIds.where(bulkOk.contains).toList();
|
||||
final List<Map<String, dynamic>> okStops = [
|
||||
for (final id in okIds) bulkStops[id]!,
|
||||
];
|
||||
|
||||
if (okIds.isNotEmpty) {
|
||||
// ── ARRIVED is deliberately NOT hidden ──
|
||||
//
|
||||
// Accept and Picked take the row off Home; arriving does not. An
|
||||
// arrived stop is still work in front of the rider and stays on the
|
||||
// list — see `arrived_stays_on_home_test.dart`. Hiding all three here
|
||||
// would have made a bulk arrival look like the stops had vanished.
|
||||
if (targetStatus == 'ACCEPTED') {
|
||||
_hiddenOrderIds.addAll(okIds);
|
||||
acceptedBookings.addAll(okStops);
|
||||
} else if (targetStatus == 'ARRIVED') {
|
||||
await addArrivedOrderIds(okIds);
|
||||
} else if (targetStatus == 'PICKED') {
|
||||
_hiddenOrderIds.addAll(okIds);
|
||||
await addCollectedOrderIds(okIds);
|
||||
// ── Picked is a hand-over on one line and a finish on the other ──
|
||||
//
|
||||
// Right for **logistics**, where what he collects goes to the base
|
||||
// and the booking's own story ends at the counter. On a **milk run**
|
||||
// collection is the *middle* of the day — every bag is followed by a
|
||||
// round of drops he makes himself — so filing it as completed would
|
||||
// send it to Activity instead of Deliveries and the delivery leg
|
||||
// would have nothing to work. See [ServiceProfile.handsOffAtCollection].
|
||||
if (ServiceProfile.active.handsOffAtCollection) {
|
||||
await addAcceptedBookings(okStops);
|
||||
} else {
|
||||
await removeAcceptedBookings(okIds);
|
||||
await addCompletedBookings(okStops);
|
||||
}
|
||||
// Off the arrived rung — it has been collected.
|
||||
await removeArrivedOrderIds(okIds);
|
||||
// One copy of the day, and it is now stale — Deliveries must not
|
||||
// render these orders from a response that predates the hand-over.
|
||||
unawaited(WorkRepository.instance.invalidate());
|
||||
}
|
||||
}
|
||||
|
||||
// The leg every card reads is derived from this set, so it has to land
|
||||
// before the next build — not at the next poll three seconds later.
|
||||
if (bulkCollected.isNotEmpty && mounted) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
import 'package:miler/data/service_profile.dart';
|
||||
import 'package:miler/data/task_profile.dart';
|
||||
import 'package:miler/views/Dashboard/home/trip.dart';
|
||||
import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
|
||||
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
|
||||
@@ -164,7 +165,8 @@ class StopCard extends StatelessWidget {
|
||||
// he is riding to. After it, the drop leads — and that card lives on the
|
||||
// work tab, where [PickupCard] does exactly the same thing in the other
|
||||
// direction.
|
||||
final twoLeg = ServiceProfile.active.deliversToCustomer && drop.isNotEmpty;
|
||||
// Per row: a CX pickup on a meal tenant is not a two-leg meal stop.
|
||||
final twoLeg = WorkPolicy.deliversToCustomer(stop) && drop.isNotEmpty;
|
||||
final grouped = groupedUnderSource && source.isNotEmpty;
|
||||
final headline = (twoLeg && !grouped && source.isNotEmpty)
|
||||
? source
|
||||
@@ -278,7 +280,7 @@ class StopCard extends StatelessWidget {
|
||||
final chip = _live
|
||||
? LiveMark(label: state == StopState.arrived ? 'Arrived' : 'Active')
|
||||
: (state == StopState.pending &&
|
||||
!ServiceProfile.active.handsOffAtCollection)
|
||||
!WorkPolicy.staysOnHomeUntilCollected(stop))
|
||||
? null
|
||||
: StopStateChip(state: state, filled: true);
|
||||
|
||||
@@ -410,7 +412,7 @@ class StopCard extends StatelessWidget {
|
||||
// On a meal run the parcel counts are the one-label rule restated
|
||||
// as an item count, which is exactly the second number this app
|
||||
// does not allow. Only the money survives.
|
||||
countsHidden: ServiceProfile.active.deliversToCustomer,
|
||||
countsHidden: WorkPolicy.deliversToCustomer(stop),
|
||||
);
|
||||
final showLabel = !labelShownAbove && printed.isNotEmpty;
|
||||
if (!showLabel && meta.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
@@ -21,6 +21,7 @@ import 'package:miler/data/stop_area.dart';
|
||||
import 'package:miler/data/milk_run.dart';
|
||||
import 'package:miler/data/pickup_locations.dart';
|
||||
import 'package:miler/data/service_profile.dart';
|
||||
import 'package:miler/data/task_profile.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// TRIP CARD — one slot's whole route, start to finish.
|
||||
@@ -694,7 +695,28 @@ class TripCard extends StatelessWidget {
|
||||
// give. `trip_brief_layout_test.dart` sweeps for exactly this.
|
||||
Flexible(
|
||||
child: Text(
|
||||
ServiceProfile.active.endsAtHub ? 'RETURN · BASE' : 'END · HOME',
|
||||
// ── Line OR row, and the OR is the point ──
|
||||
//
|
||||
// This was migrated to `TripShape.endsAtHub(trip.stops)` alone and
|
||||
// reverted within the hour, for the reason `task_profile.dart`
|
||||
// already documents about `sourceIsKitchen`: **the rows cannot
|
||||
// answer this one.** A base leg is only knowable from
|
||||
// `next_action: inward_at_hub`, which a parcel does not carry until
|
||||
// it has been collected — so a logistics trip of five *pre-pickup*
|
||||
// stops folds to `false` and a rider who has always ended his day
|
||||
// at a base was told "END · HOME". `two_lines_test` and
|
||||
// `card_density_test` both caught it.
|
||||
//
|
||||
// The line knows where the day ends; it is a fact about the rider's
|
||||
// round, not about any one parcel. The fold is kept beside it
|
||||
// because it can only ever *add* — a meal rider carrying one
|
||||
// hub-routed parcel now correctly reads RETURN · BASE, which
|
||||
// neither source could say on its own. `any`, not `every`: one
|
||||
// parcel the network needs is enough to send him to a base.
|
||||
(ServiceProfile.active.endsAtHub ||
|
||||
TripShape.endsAtHub(trip.stops))
|
||||
? 'RETURN · BASE'
|
||||
: 'END · HOME',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: MilerType.eyebrow,
|
||||
@@ -1073,7 +1095,8 @@ class TripCard extends StatelessWidget {
|
||||
// logistics rider genuinely does return collected
|
||||
// shipments to the depot, so the label follows the
|
||||
// capability rather than the screen position.
|
||||
: (ServiceProfile.active.endsAtHub
|
||||
: ((ServiceProfile.active.endsAtHub ||
|
||||
TripShape.endsAtHub(trip.stops))
|
||||
? 'RETURN · BASE'
|
||||
: 'END · HOME'),
|
||||
maxLines: 1,
|
||||
|
||||
Reference in New Issue
Block a user