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,
|
||||
|
||||
@@ -136,7 +136,6 @@ class PickupCard extends StatelessWidget {
|
||||
], 'Address not available');
|
||||
final String orderId = _val(['orderid']);
|
||||
final String notes = _val(['notes', 'Notes']);
|
||||
final String phone = _val(['pickupcontactno']);
|
||||
|
||||
final int deliverQty = deliveryParcelCount(item);
|
||||
final int collectQty = pickupParcelCount(item);
|
||||
@@ -157,15 +156,54 @@ class PickupCard extends StatelessWidget {
|
||||
// The receiver names the far end whenever the rider is carrying it there —
|
||||
// whether that is a milk round (line-level) or a hyperlocal parcel the
|
||||
// backend routed direct (per-order).
|
||||
// Asked of the ROW first. On a meal tenant this static answered true for
|
||||
// every card on the screen, so a CX customer pickup sitting next to a
|
||||
// kitchen load was drawn as though the rider were already carrying it to a
|
||||
// receiver. [WorkPolicy] reads the row's own `next_action` /
|
||||
// `pickup_source_type` and only falls back to the line where the row is
|
||||
// silent — which keeps every existing payload rendering exactly as it does
|
||||
// today.
|
||||
final bool carryingToCustomer =
|
||||
ServiceProfile.active.deliversToCustomer || leg.isCustomer;
|
||||
WorkPolicy.deliversToCustomer(item) || leg.isCustomer;
|
||||
final bool pickupIsSource = carryingToCustomer && source.isNotEmpty;
|
||||
final String pickupName = pickupIsSource ? source : customer;
|
||||
final String dropAddress = _val(['dropaddress', 'DropAddress']);
|
||||
final String dropName = pickupIsSource
|
||||
// ── Who is actually at the far end ──
|
||||
//
|
||||
// `pickupcustomer` is the booking's customer, which on a parcel booking is
|
||||
// the **sender** — the person the rider collected FROM. Using it to name the
|
||||
// drop put the sender's name over the receiver's door, and on a
|
||||
// multi-destination pickup it put the same name over all three of them.
|
||||
// The receiver is carried per destination and is the honest answer whenever
|
||||
// the payload has one.
|
||||
final String recipient = _val(['recipientname']);
|
||||
final String dropName = recipient.isNotEmpty
|
||||
? recipient
|
||||
: pickupIsSource
|
||||
? customer
|
||||
: (dropAddress.isEmpty ? '' : 'Collection centre');
|
||||
|
||||
// ── The Call button has to dial the door he is standing at ──
|
||||
//
|
||||
// `pickupcontactno` is the booking's customer — the SENDER — and it was
|
||||
// what this button dialled on every card, delivery legs included. So a
|
||||
// rider at the receiver's gate pressed Call and rang the man he had
|
||||
// collected from that morning, in another city; on a three-drop pickup he
|
||||
// rang him at all three gates. The receiver's own number is carried per
|
||||
// destination and is the right one to ring whenever the rider is on his way
|
||||
// to that receiver — the same test the name above uses, so the card cannot
|
||||
// show one person and phone another.
|
||||
final String recipientPhone = _val(['recipientphone']);
|
||||
final String phone = (recipient.isNotEmpty && recipientPhone.isNotEmpty)
|
||||
? recipientPhone
|
||||
: _val(['pickupcontactno']);
|
||||
|
||||
/// `Stop 2 of 3` on a customer pickup with several doors, `''` otherwise.
|
||||
/// Sits with the leg eyebrow because it answers the same question — what
|
||||
/// kind of stop is this — and because three cards that differ only in their
|
||||
/// address are three chances to deliver the wrong bag.
|
||||
final String stopLabel = MilkRun.stopLabel(item);
|
||||
|
||||
/// True once the rider is carrying it: the drop becomes the destination and
|
||||
/// takes the top of the card.
|
||||
// ── The immediate destination leads; never a hub leg's receiver ──
|
||||
@@ -179,6 +217,46 @@ class PickupCard extends StatelessWidget {
|
||||
(pickupIsSource || leg.isCustomer) &&
|
||||
(dropName.isNotEmpty || dropAddress.isNotEmpty);
|
||||
|
||||
// ── On a base leg the BASE leads ──
|
||||
//
|
||||
// The card was right to refuse the receiver — he is in another district and
|
||||
// leading with him is the navigation mistake the rule above exists to
|
||||
// prevent. But refusing the receiver only left `pickupName`, so a handover
|
||||
// card read:
|
||||
//
|
||||
// BASE HANDOVER
|
||||
// Anitha R
|
||||
// Gandhipuram
|
||||
//
|
||||
// …the customer he collected from an hour ago, under a heading telling him
|
||||
// to go to a base. The place he is actually going appeared nowhere on the
|
||||
// card and only on the sheet's slider, after he had already set off. On a
|
||||
// round with two bases that is a parcel left in the wrong building.
|
||||
//
|
||||
// `next_hub` rides on the row (the backend sends all six fields), so the
|
||||
// destination is known here. A base with no coordinates still leads with
|
||||
// its name — it is a caption rather than a destination, and the fence
|
||||
// refuses the handover anyway with a sentence pointing at the office.
|
||||
final HandoverHub? handoverBase = leg.isHub
|
||||
? (HandoverHub.from(item['next_hub'] ?? item['nexthub']) ??
|
||||
PickupLocations.baseFor(
|
||||
item['next_hub_id'] ?? item['nexthubid'] ?? item['hubid'],
|
||||
))
|
||||
: null;
|
||||
final bool baseLeads = handoverBase != null;
|
||||
|
||||
/// What the card is headed with, in one place so the name and the address
|
||||
/// below it can never describe two different places.
|
||||
final String headlineName = baseLeads
|
||||
? handoverBase.name
|
||||
: (dropLeads ? dropName : pickupName);
|
||||
final String headlineAddress = baseLeads
|
||||
? [
|
||||
handoverBase.address,
|
||||
handoverBase.pincode,
|
||||
].where((v) => v.trim().isNotEmpty).join(', ')
|
||||
: '';
|
||||
|
||||
// ── No accent stripe any more ──
|
||||
//
|
||||
// The card carried a 4px coloured bar down its left edge, on the argument
|
||||
@@ -261,9 +339,15 @@ class PickupCard extends StatelessWidget {
|
||||
// has to say which before the rider reads the place.
|
||||
// Drawn only once the parcel is actually his; an
|
||||
// uncollected stop has no leg yet.
|
||||
if (leg.leg.eyebrow.isNotEmpty) ...[
|
||||
if (leg.leg.eyebrow.isNotEmpty ||
|
||||
stopLabel.isNotEmpty) ...[
|
||||
Text(
|
||||
leg.leg.eyebrow,
|
||||
[
|
||||
if (leg.leg.eyebrow.isNotEmpty)
|
||||
leg.leg.eyebrow,
|
||||
if (stopLabel.isNotEmpty)
|
||||
stopLabel.toUpperCase(),
|
||||
].join(' · '),
|
||||
style: TextStyle(
|
||||
fontSize: 10.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
@@ -279,9 +363,7 @@ class PickupCard extends StatelessWidget {
|
||||
SizedBox(height: 3.h),
|
||||
],
|
||||
Text(
|
||||
(dropLeads ? dropName : pickupName).isEmpty
|
||||
? 'Not named'
|
||||
: (dropLeads ? dropName : pickupName),
|
||||
headlineName.isEmpty ? 'Not named' : headlineName,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
@@ -312,6 +394,17 @@ class PickupCard extends StatelessWidget {
|
||||
// lost — see [areaOf].
|
||||
Text(
|
||||
() {
|
||||
// A base is read at a gate, so it gets its street
|
||||
// and pincode rather than the locality summary the
|
||||
// other legs use — "Peelamedu" is not enough to
|
||||
// find a loading bay by.
|
||||
if (baseLeads) {
|
||||
// Empty rather than repeating the name: a base
|
||||
// with no address on the row has nothing more
|
||||
// to say here, and printing "Salem Base" twice
|
||||
// reads as a rendering fault.
|
||||
return headlineAddress;
|
||||
}
|
||||
final full = dropLeads
|
||||
? (dropAddress.isEmpty
|
||||
? address
|
||||
@@ -1560,7 +1653,7 @@ String _destinationName(Map<String, dynamic> item) {
|
||||
String _destinationAddress(Map<String, dynamic> item) {
|
||||
final area = areaOf(
|
||||
item,
|
||||
preferDrop: ServiceProfile.active.deliversToCustomer,
|
||||
preferDrop: WorkPolicy.deliversToCustomer(item),
|
||||
);
|
||||
if (area.isNotEmpty) return area;
|
||||
|
||||
@@ -1569,6 +1662,6 @@ String _destinationAddress(Map<String, dynamic> item) {
|
||||
final drop = (item['dropaddress'] ?? item['DropAddress'] ?? '')
|
||||
.toString()
|
||||
.trim();
|
||||
if (ServiceProfile.active.deliversToCustomer && drop.isNotEmpty) return drop;
|
||||
if (WorkPolicy.deliversToCustomer(item) && drop.isNotEmpty) return drop;
|
||||
return (item['pickupaddress'] ?? '').toString().trim();
|
||||
}
|
||||
|
||||
@@ -36,6 +36,15 @@ enum DeliveryOutcome {
|
||||
|
||||
/// It is not going to happen. `POST /miler/bookings/:id/cancel`.
|
||||
cancelled,
|
||||
|
||||
/// Handed in at a base, ending this rider's custody.
|
||||
/// `POST /miler/consignments/:id/inward-at-hub` — see [handOverAtHub].
|
||||
///
|
||||
/// Not a kind of [delivered]. A base handover has no receiver, no proof
|
||||
/// photo, no OTP and no COD, and it must never reach `deliver` — that route
|
||||
/// moves the consignment to `Out_for_Delivery` against a receiver in another
|
||||
/// district, which is a state the handset cannot undo.
|
||||
handedOver,
|
||||
}
|
||||
|
||||
extension DeliveryOutcomeX on DeliveryOutcome {
|
||||
@@ -43,6 +52,7 @@ extension DeliveryOutcomeX on DeliveryOutcome {
|
||||
DeliveryOutcome.delivered => 'Delivered',
|
||||
DeliveryOutcome.skipped => 'Skip',
|
||||
DeliveryOutcome.cancelled => 'Cancelled',
|
||||
DeliveryOutcome.handedOver => 'Handed over',
|
||||
};
|
||||
|
||||
IconData get icon => switch (this) {
|
||||
@@ -54,12 +64,15 @@ extension DeliveryOutcomeX on DeliveryOutcome {
|
||||
// skip-reason sheet's "Delivery paused" row already wears.
|
||||
DeliveryOutcome.skipped => LucideIcons.clock,
|
||||
DeliveryOutcome.cancelled => LucideIcons.circleX,
|
||||
// A building, not a tick: the parcel is not finished, it has changed hands.
|
||||
DeliveryOutcome.handedOver => LucideIcons.building2,
|
||||
};
|
||||
|
||||
Color get colour => switch (this) {
|
||||
DeliveryOutcome.delivered => ColorConstants.acceptGreen,
|
||||
DeliveryOutcome.skipped => ColorConstants.warning,
|
||||
DeliveryOutcome.cancelled => ColorConstants.errorRed,
|
||||
DeliveryOutcome.handedOver => ColorConstants.acceptGreen,
|
||||
};
|
||||
|
||||
/// What Activity will show once the round is over.
|
||||
@@ -67,6 +80,7 @@ extension DeliveryOutcomeX on DeliveryOutcome {
|
||||
DeliveryOutcome.delivered => 'Delivered',
|
||||
DeliveryOutcome.skipped => 'Skipped',
|
||||
DeliveryOutcome.cancelled => 'Cancelled',
|
||||
DeliveryOutcome.handedOver => 'Handed over',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -583,6 +597,14 @@ Future<Map<String, dynamic>?> _closeDelivery(
|
||||
// reportable from `Collected_By_Miler` as well as `Out_for_Delivery` — a
|
||||
// customer who is not home is not home whether or not the rider remembered
|
||||
// to press Start round. See [ConsignmentStateX.canSkip].
|
||||
// ── A handover is deliberately NOT gated here ──
|
||||
//
|
||||
// [DeliverGate] answers the question "may this be *delivered*", and a
|
||||
// base-routed parcel is `Created` — which it reads as `awaitingInward` and
|
||||
// refuses with "this parcel hasn't been released for delivery yet". Correct
|
||||
// for a delivery, exactly wrong for the rung that performs the inward.
|
||||
// `inward-at-hub` is the server's own judge of its preconditions and answers
|
||||
// `INVALID_STATE` when they are not met.
|
||||
final gated =
|
||||
outcome == DeliveryOutcome.delivered ||
|
||||
outcome == DeliveryOutcome.skipped;
|
||||
@@ -820,6 +842,15 @@ Future<Map<String, dynamic>?> _closeDelivery(
|
||||
notes: notes,
|
||||
);
|
||||
|
||||
case DeliveryOutcome.handedOver:
|
||||
// The fence, the consignment lookup and the `inward-at-hub` write all
|
||||
// live in [handOverAtHub] — this is the one close path, so the record
|
||||
// that gets filed is the same shape whichever rung produced it.
|
||||
ok = await handOverAtHub(stop);
|
||||
if (!ok && lastHandoverFailure != null && context.mounted) {
|
||||
AppFeedback.error(context, lastHandoverFailure!);
|
||||
}
|
||||
|
||||
case DeliveryOutcome.skipped:
|
||||
// ── Straight to the consignment route ──
|
||||
//
|
||||
@@ -1050,3 +1081,142 @@ Future<Map<String, dynamic>?> _closeDelivery(
|
||||
'notes': notes,
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// THE BASE HANDOVER — the leg the rider could not finish
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Why the last [handOverAtHub] refused, in the rider's words. Null on success.
|
||||
String? lastHandoverFailure;
|
||||
|
||||
/// The base this stop must be handed in at, or null when it is not a base leg.
|
||||
///
|
||||
/// Two sources, in order. `next_hub` rides on the row itself (request 25) and
|
||||
/// is the authoritative one. [PickupLocations.baseFor] is the fallback for a
|
||||
/// row that names a hub id without expanding it — a shape older payloads use.
|
||||
HandoverHub? handoverBaseFor(Map<String, dynamic> stop) {
|
||||
final fromRow = HandoverHub.from(stop['next_hub'] ?? stop['nexthub']);
|
||||
if (fromRow != null) return fromRow;
|
||||
return PickupLocations.baseFor(
|
||||
stop['next_hub_id'] ?? stop['nexthubid'] ?? stop['hubid'],
|
||||
);
|
||||
}
|
||||
|
||||
/// Hands a base-routed consignment in, ending this rider's custody of it.
|
||||
///
|
||||
/// ── The leg that existed everywhere except the app ──
|
||||
///
|
||||
/// `MilerApi.inwardAtHub` and `MilerLifecycle.inwardAtHub` were both written,
|
||||
/// documented and covered by `hub_handover_contract_test.dart`. Neither had a
|
||||
/// single call site. So with `MILER_HUB_HANDOVER_ENABLED` on server-side, a
|
||||
/// Gandhipuram → Chennai parcel arrived on Deliveries as [NextLeg.hub],
|
||||
/// [releaseForDelivery] correctly refused to start a customer round for it —
|
||||
/// telling the rider "hand it over at base" — and there was no control in the
|
||||
/// app that could record him doing so. The parcel sat in his queue until hub
|
||||
/// staff inwarded it from the console, and the assignment closed against
|
||||
/// nobody, so the job reported zero distance and zero value on his earnings.
|
||||
///
|
||||
/// ── Correct in both positions of the server flag ──
|
||||
///
|
||||
/// This never asks which way the flag is set, because the app must not mirror
|
||||
/// it. With the flag **off**, `pickup-complete` inwards the parcel itself and
|
||||
/// the row comes back `Inwarded_at_Hub` / `handed_to_hub` — [NextLeg.closed] —
|
||||
/// so this function is never reached and no control is drawn. With it **on**,
|
||||
/// the row is `Created` / `inward_at_hub` — [NextLeg.hub] — and this is the
|
||||
/// rung that finishes it. The leg is read from the row, every time.
|
||||
///
|
||||
/// ── Fenced at the base, like every other presence claim ──
|
||||
///
|
||||
/// 100 m, against the base's own coordinates. A base with no coordinates is
|
||||
/// refused rather than waved through, for the same reason a customer stop with
|
||||
/// no pin is: nobody can say afterwards where the rider was standing.
|
||||
///
|
||||
/// Idempotent twice over — the shared `Idempotency-Key` covers a retry after a
|
||||
/// dropped response, and a parcel already inwarded answers 200 with
|
||||
/// `already_inwarded: true`, which [MilerLifecycle.inwardAtHub] reads as the
|
||||
/// success it is. A rider pressing again on bad signal at a loading bay is
|
||||
/// confirmed, not refused.
|
||||
Future<bool> handOverAtHub(Map<String, dynamic> stop) async {
|
||||
lastHandoverFailure = null;
|
||||
|
||||
final orderId = MilkRun.idOf(stop);
|
||||
final leg = NextLegResolver.resolve(
|
||||
stop,
|
||||
pivotAction: (await getPivotNextActions())[orderId] ?? '',
|
||||
);
|
||||
if (!leg.isHub) {
|
||||
// Not a refusal the rider caused — the row says this parcel is not going to
|
||||
// a base. Saying so beats posting a handover the server will reject.
|
||||
lastHandoverFailure =
|
||||
'This parcel is not going to a base. Check the stop and try again.';
|
||||
debugPrint('[HANDOVER] $orderId is not a base leg — $leg');
|
||||
return false;
|
||||
}
|
||||
|
||||
final base = handoverBaseFor(stop);
|
||||
|
||||
// ── The fence, before anything else is spent ──
|
||||
//
|
||||
// A base with no coordinates lands on [GeofenceOutcome.noTarget] and is
|
||||
// refused with a sentence pointing at the office, which is the only party who
|
||||
// can add the missing pin.
|
||||
final decision = await Geofence.check(
|
||||
targetLat: base?.latitude,
|
||||
targetLng: base?.longitude,
|
||||
action: 'Handed over',
|
||||
);
|
||||
if (!decision.allowed) {
|
||||
lastHandoverFailure = decision.reason;
|
||||
debugPrint('[HANDOVER] $orderId refused by the fence — $decision');
|
||||
return false;
|
||||
}
|
||||
|
||||
final consignmentId = await resolveConsignmentId(stop);
|
||||
if (consignmentId.isEmpty) {
|
||||
lastHandoverFailure =
|
||||
'This stop has no shipment reference yet, so it cannot be handed over. '
|
||||
'Ask your office to check it — pressing again will not help.';
|
||||
debugPrint('[HANDOVER] no consignment id for $orderId');
|
||||
return false;
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'[TRACE][HANDOVER] consignment=$consignmentId base=${base?.id} '
|
||||
'POST /miler/consignments/$consignmentId/inward-at-hub — calling',
|
||||
);
|
||||
final res = await MilerApi.inwardAtHub(
|
||||
consignmentId,
|
||||
hubId: (base?.id.isNotEmpty ?? false) ? base!.id : null,
|
||||
// The position the fence just judged, not a fresh one: two fixes seconds
|
||||
// apart are two different answers and the hub's history row should carry
|
||||
// the one the app actually allowed the handover on.
|
||||
lat: decision.riderLat,
|
||||
lon: decision.riderLng,
|
||||
);
|
||||
final t = MilerLifecycle.inwardAtHub(res);
|
||||
MilerLifecycle.report('inward-at-hub', t);
|
||||
debugPrint(
|
||||
'[TRACE][HANDOVER] consignment=$consignmentId -> ${res.status} '
|
||||
'${res.code} confirmed=${t.isConfirmed} raw=${res.raw}',
|
||||
);
|
||||
|
||||
if (t.isConfirmed) return true;
|
||||
|
||||
// ── A 200 that names no state is not proof ──
|
||||
//
|
||||
// The rule request 15 exists for, and the one this app has been bitten by on
|
||||
// `reached`: a bare success is not a transition. The rider is not shown a
|
||||
// handover the hub may not have recorded.
|
||||
if (t.isUnconfirmed) {
|
||||
lastHandoverFailure =
|
||||
'Your office did not confirm the handover. Check with the base before '
|
||||
'you leave the parcel.';
|
||||
return false;
|
||||
}
|
||||
|
||||
final serverMsg = (res.message).trim();
|
||||
lastHandoverFailure = serverMsg.isNotEmpty && serverMsg.length < 140
|
||||
? 'The base would not accept this parcel: $serverMsg'
|
||||
: 'The base would not accept this parcel. Ask your office to check it.';
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -193,6 +193,11 @@ class _PickupMapScreenState extends State<_PickupMapScreen>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Which leg this stop is on. Async because it reads the pivot store, so the
|
||||
// sheet opens in its delivery shape and corrects itself a frame later —
|
||||
// the base CTA is behind a slide, so there is no window in which the rider
|
||||
// can commit the wrong rung.
|
||||
unawaited(_resolveHubLeg());
|
||||
_camera = MilerMapCamera(controller: _mapController, vsync: this);
|
||||
_routeAnim =
|
||||
AnimationController(
|
||||
@@ -1360,6 +1365,73 @@ class _PickupMapScreenState extends State<_PickupMapScreen>
|
||||
collectedIds: widget.parentState?._collectedIds ?? const <String>{},
|
||||
).isDelivery;
|
||||
|
||||
/// True when this stop ends at a base rather than at a receiver's door.
|
||||
///
|
||||
/// Read from the row's own `next_action`, never from the rider's line — the
|
||||
/// same resolver [handOverAtHub] and the Deliveries queue use, so the card,
|
||||
/// the sheet and the write cannot disagree about where a parcel is going.
|
||||
///
|
||||
/// Held in state rather than computed in `build` because resolving it reads
|
||||
/// the pivot store, which is async.
|
||||
bool _isHubLeg = false;
|
||||
|
||||
/// The base this stop is handed in at, once [_resolveHubLeg] has looked.
|
||||
HandoverHub? _handoverBase;
|
||||
|
||||
/// Resolves [_isHubLeg] once, on open.
|
||||
///
|
||||
/// Silent on failure: a stop whose leg cannot be read falls back to the
|
||||
/// delivery shape it had before this existed, which is the behaviour every
|
||||
/// other screen already has.
|
||||
Future<void> _resolveHubLeg() async {
|
||||
try {
|
||||
final leg = NextLegResolver.resolve(
|
||||
widget.pickup,
|
||||
pivotAction:
|
||||
(await getPivotNextActions())[MilkRun.idOf(widget.pickup)] ?? '',
|
||||
);
|
||||
if (!mounted) return;
|
||||
final base = leg.isHub ? handoverBaseFor(widget.pickup) : null;
|
||||
setState(() {
|
||||
_isHubLeg = leg.isHub;
|
||||
_handoverBase = base;
|
||||
// ── The destination is the base, not the door he collected at ──
|
||||
//
|
||||
// `MilkRun.navigatesToCustomer` is false for a base leg — correctly, a
|
||||
// base-routed parcel must never point at its receiver 500 km away — so
|
||||
// `_pickupLocation` fell back to the **pickup** coordinates and the map
|
||||
// sent the rider back to the customer he had just collected from.
|
||||
//
|
||||
// A base with no coordinates is left alone: navigating to `0, 0` lands
|
||||
// in the Gulf of Guinea. The handover is still refused by the fence in
|
||||
// that case, with a sentence pointing at the office.
|
||||
if (base != null && base.isNavigable) {
|
||||
_pickupLocation = LatLng(base.latitude, base.longitude);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('[HANDOVER] could not resolve the leg: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Hands the parcel in at the base and closes the stop.
|
||||
///
|
||||
/// The base-routed twin of [_markDelivered]: same shape, different rung. It
|
||||
/// does not open [DeliveryProofPage] — there is no receiver to photograph and
|
||||
/// no OTP to take; the evidence the contract asks for is the position, which
|
||||
/// [handOverAtHub] stamps from the fix the fence judged.
|
||||
Future<void> _handOverAtBase() async {
|
||||
if (_isNavigating || !mounted) return;
|
||||
HapticFeedback.mediumImpact();
|
||||
// Straight through the one close path. [closeDelivery] routes
|
||||
// `handedOver` to [handOverAtHub] — which takes the fence, resolves the
|
||||
// consignment and posts `inward-at-hub` — and then files the same finished
|
||||
// record every other outcome files, so Activity, the carried set and the
|
||||
// queue all see one kind of closed stop. Calling [handOverAtHub] here as
|
||||
// well would post the handover twice.
|
||||
await _closeDelivery(DeliveryOutcome.handedOver);
|
||||
}
|
||||
|
||||
/// True once the rider has set off from **this screen** — slid Start
|
||||
/// delivery, or opened navigation on the collection leg.
|
||||
///
|
||||
@@ -1447,6 +1519,30 @@ class _PickupMapScreenState extends State<_PickupMapScreen>
|
||||
// [MilkRun.deliveryArrivalIsLocalOnly]), so a button for it would write
|
||||
// nothing. Once he sets off the stop is simply **active**, and the only
|
||||
// question left is whether it was handed over.
|
||||
// ── A base handover is its own rung ──
|
||||
//
|
||||
// It is not a delivery: there is no receiver, no proof photo, no OTP and no
|
||||
// "customer was out" outcome. It is also not nothing, which is what the app
|
||||
// offered before — [releaseForDelivery] refuses a base leg, correctly, and
|
||||
// the rider was left holding a parcel with a message telling him to hand it
|
||||
// over and no control that would record it.
|
||||
if (_isHubLeg) {
|
||||
final onTheRoad = _setOff;
|
||||
// Named where it is known. "Handed over at Coimbatore Base" is a rider
|
||||
// confirming a place; "Handed over at base" is him confirming a category,
|
||||
// and on a round with two bases that is the difference between a parcel
|
||||
// arriving and a parcel being looked for.
|
||||
final base = _handoverBase?.name ?? '';
|
||||
return MilerSlideAction(
|
||||
label: onTheRoad
|
||||
? (base.isEmpty ? 'Handed over at base' : 'Handed over at $base')
|
||||
: 'Slide to start ride',
|
||||
icon: onTheRoad ? LucideIcons.check : LucideIcons.chevronRight,
|
||||
color: ColorConstants.acceptGreen,
|
||||
onCommit: onTheRoad ? _handOverAtBase : _startRideToBase,
|
||||
);
|
||||
}
|
||||
|
||||
if (_isDeliveryLeg) {
|
||||
final onTheRoad = _setOff;
|
||||
final slide = MilerSlideAction(
|
||||
@@ -1572,6 +1668,16 @@ class _PickupMapScreenState extends State<_PickupMapScreen>
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Not refused, but not recorded either ──
|
||||
//
|
||||
// The rung advances locally below, which is right — a dead network must
|
||||
// not strand a rider at a door. What was wrong is that it advanced
|
||||
// *silently*: the rider saw ARRIVED and could not tell the difference
|
||||
// between a stop the hub knows about and one it does not.
|
||||
if (!ok && dc.lastArrivalNotice != null && mounted) {
|
||||
AppFeedback.warn(context, dc.lastArrivalNotice!);
|
||||
}
|
||||
|
||||
// The rung the rider sees, on the row he is looking at and in the store
|
||||
// that survives the refresh this flow triggers. `reached` does not
|
||||
// persist on every deployment yet — see `getArrivedOrderIds` — and the
|
||||
@@ -1607,8 +1713,100 @@ class _PickupMapScreenState extends State<_PickupMapScreen>
|
||||
/// here — no photo, a dead signature, a refused PUT — resolves to an empty
|
||||
/// string, which is what this call site was sending unconditionally until
|
||||
/// now. The local copy stays on the device either way.
|
||||
/// The parcel photograph's storage references, once uploaded. Null until it
|
||||
/// has been, and after a failed attempt — a failure is not a photograph.
|
||||
UploadRef? _parcelProof;
|
||||
|
||||
/// Uploads the door photograph once per stop.
|
||||
///
|
||||
/// Best-effort by design: a dead object store must not strand a rider at a
|
||||
/// customer's door holding parcels he cannot record. When it fails the parcel
|
||||
/// update goes without `photos` — which is honest, and better than an empty
|
||||
/// array claiming no photograph was taken.
|
||||
Future<void> _ensureParcelProofUploaded(int bookingId) async {
|
||||
if (_parcelProof != null || bookingId <= 0) return;
|
||||
final path = (_verificationProofPath ?? '').trim();
|
||||
if (path.isEmpty) return;
|
||||
|
||||
final file = File(path);
|
||||
if (!file.existsSync()) return;
|
||||
|
||||
// Kept on the device first, whatever the network does — see ProofStore.
|
||||
try {
|
||||
final orderId = (widget.pickup['orderid'] ?? '').toString();
|
||||
if (orderId.isNotEmpty) await ProofStore.save(orderId, path);
|
||||
} catch (e) {
|
||||
debugPrint('[PICKUP] could not keep a local copy of the proof: $e');
|
||||
}
|
||||
|
||||
try {
|
||||
_parcelProof = await MilerApi.uploadProofRef(
|
||||
file,
|
||||
purpose: MilerApi.proofPickup,
|
||||
// The BOOKING id, in the booking field. There is no consignment yet —
|
||||
// `pickup-complete` has not run — and this used to pass the booking id
|
||||
// in the `consignmentid` slot, filing every pickup proof under a
|
||||
// consignment number that did not exist.
|
||||
bookingId: bookingId,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[PICKUP] parcel proof upload failed: $e');
|
||||
}
|
||||
|
||||
if (_parcelProof == null || !_parcelProof!.hasKey) {
|
||||
ApiConfig.logGap(
|
||||
'uploads/sign',
|
||||
'the parcel photo for booking $bookingId could not be uploaded (or the '
|
||||
'server returned no key); the parcels are being recorded without '
|
||||
'one and the copy stays on the device.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The local path of the photograph the verification screen took, if any.
|
||||
String? _verificationProofPath;
|
||||
|
||||
/// Copies the uploaded key onto the verification map the provider reads.
|
||||
Map<String, dynamic> _withParcelPhotos(Map<String, dynamic> verification) {
|
||||
final key = _parcelProof?.key ?? '';
|
||||
if (key.isEmpty) return verification;
|
||||
final pickup = verification['pickup'];
|
||||
if (pickup is! Map) return verification;
|
||||
return {
|
||||
...verification,
|
||||
'pickup': {...pickup, 'photos': <String>[key]},
|
||||
};
|
||||
}
|
||||
|
||||
Future<String> _uploadParcelProof(String path, int bookingId) async {
|
||||
if (path.isEmpty || bookingId <= 0) return '';
|
||||
|
||||
// Already sent, on the way to `POST /parcel`. Pushing the same bytes twice
|
||||
// costs a rider at a doorstep several seconds on a mobile connection and
|
||||
// leaves two copies of one photograph in the bucket.
|
||||
await _ensureParcelProofUploaded(bookingId);
|
||||
if (_parcelProof != null) return _parcelProof!.url;
|
||||
|
||||
// ── Kept on the device first, whatever the network does ──
|
||||
//
|
||||
// The uploaded URL goes nowhere: `pickup-complete` takes latitude and
|
||||
// longitude and nothing else, so even a successful upload leaves the hub
|
||||
// with no reference to the photograph (see BE-2, and the gap logged in
|
||||
// `UpdatePickupProvider`). The rider was being made to photograph the
|
||||
// parcels — the flow will not let him confirm without it — for a record
|
||||
// that existed only in a bucket nobody could search.
|
||||
//
|
||||
// A local copy against the order id is the one form of this evidence that
|
||||
// is actually retrievable today: the office rings the rider and he has it.
|
||||
// Best-effort, and deliberately before the upload, because the case where
|
||||
// it matters most is the one where the network is the problem.
|
||||
try {
|
||||
final orderId = (widget.pickup['orderid'] ?? '').toString();
|
||||
if (orderId.isNotEmpty) await ProofStore.save(orderId, path);
|
||||
} catch (e) {
|
||||
debugPrint('[PICKUP] could not keep a local copy of the proof: $e');
|
||||
}
|
||||
|
||||
try {
|
||||
final url = await MilerApi.uploadProof(
|
||||
File(path),
|
||||
@@ -1686,6 +1884,22 @@ class _PickupMapScreenState extends State<_PickupMapScreen>
|
||||
await _openGoogleMapsNavigation();
|
||||
}
|
||||
|
||||
/// Sets off for the base.
|
||||
///
|
||||
/// Deliberately **not** [_startDelivery]. `start-delivery` moves the
|
||||
/// consignment to `Out_for_Delivery` against a receiver in another district;
|
||||
/// the hub then sees a rider actively delivering a parcel that is going onto
|
||||
/// a line-haul truck, and there is no way back from that state on the
|
||||
/// handset. A base leg has no release rung at all — the rider simply rides
|
||||
/// there, and the next thing the server hears is the handover.
|
||||
Future<void> _startRideToBase() async {
|
||||
if (_isNavigating || !mounted) return;
|
||||
setState(() => _setOff = true);
|
||||
HapticFeedback.mediumImpact();
|
||||
_hasOpenedNavigation = false;
|
||||
await _openGoogleMapsNavigation();
|
||||
}
|
||||
|
||||
/// Records a stop that could not be handed over.
|
||||
///
|
||||
/// The same chooser the proof page shows, reached without having to slide
|
||||
@@ -1845,7 +2059,15 @@ class _PickupMapScreenState extends State<_PickupMapScreen>
|
||||
|
||||
Map<String, dynamic> verifyResult = <String, dynamic>{'verified': true};
|
||||
|
||||
if (ServiceProfile.active.needsVerification) {
|
||||
// ── Asked of the stop, not of the rider ──
|
||||
//
|
||||
// `ServiceProfile.active.needsVerification` gives the same answer for
|
||||
// every row on the screen. On a meal tenant that walked a CX customer
|
||||
// pickup straight past the door flow — no parcel count, no destination,
|
||||
// no weight — and the booking pivoted on whatever the customer typed into
|
||||
// the app days earlier. [WorkPolicy] asks the row, and falls back to the
|
||||
// line only where the row says nothing.
|
||||
if (WorkPolicy.capturesShipmentDetails(widget.pickup)) {
|
||||
final verified = await openScreen<Map<String, dynamic>>(
|
||||
context,
|
||||
StopVerificationPage(pickup: widget.pickup),
|
||||
@@ -1870,7 +2092,7 @@ class _PickupMapScreenState extends State<_PickupMapScreen>
|
||||
// customer. Its answers ride along in the same map, and
|
||||
// `_startPickupNavigation` sends them — addresses first, because
|
||||
// `pickup-complete` routes the consignment from them.
|
||||
if (ServiceProfile.active.capturesShipmentAddresses) {
|
||||
if (WorkPolicy.capturesShipmentAddresses(widget.pickup)) {
|
||||
final seeded = Map<String, dynamic>.from(widget.pickup);
|
||||
// Don't ask for the weight twice: the verification page has just taken
|
||||
// it, so the desk opens with it filled in.
|
||||
@@ -2038,6 +2260,12 @@ class _PickupMapScreenState extends State<_PickupMapScreen>
|
||||
if (_isNavigating || !mounted) return;
|
||||
setState(() => _isNavigating = true);
|
||||
|
||||
// The photograph the verification screen took, remembered so the parcel
|
||||
// update and the pickup-complete proof both use the one upload.
|
||||
_verificationProofPath = (verificationData['parcelImage'] ?? '')
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
try {
|
||||
final dc = Get.put(PickupsController(), permanent: true);
|
||||
final d = widget.pickup;
|
||||
@@ -2184,9 +2412,23 @@ class _PickupMapScreenState extends State<_PickupMapScreen>
|
||||
}
|
||||
|
||||
if (pickupId > 0) {
|
||||
// ── The photograph is uploaded HERE, before the parcels are sent ──
|
||||
//
|
||||
// It used to be uploaded much later, inside `_payAndCreateOrder`, and
|
||||
// only its URL was kept — so `POST /parcel` went out with no `photos`
|
||||
// and the evidence ended up in a bucket nobody could search. `photos`
|
||||
// wants the storage **key**, so the upload has to happen before this
|
||||
// call, not after it.
|
||||
//
|
||||
// Uploaded once and remembered: `_payAndCreateOrder` reuses the same
|
||||
// ref for `proofImage` rather than pushing the same bytes twice.
|
||||
await _ensureParcelProofUploaded(pickupId);
|
||||
|
||||
final parcelRes = await UpdatePickupProvider().submitParcels(
|
||||
pickupId,
|
||||
verificationData,
|
||||
// The key rides in on the verification map the provider already
|
||||
// reads, so no third source of truth about this door.
|
||||
_withParcelPhotos(verificationData),
|
||||
);
|
||||
if (parcelRes?['status'] != true) {
|
||||
debugPrint('[PARCEL] booking $pickupId: not recorded — $parcelRes');
|
||||
@@ -2220,7 +2462,7 @@ class _PickupMapScreenState extends State<_PickupMapScreen>
|
||||
// the compliance stamp, the completed record, the next-stop list and the
|
||||
// hand-off screen — runs once and unchanged for either.
|
||||
final dynamic result =
|
||||
(ServiceProfile.active.initiatesShipment && shipmentCapture != null)
|
||||
(WorkPolicy.initiatesShipment(widget.pickup) && shipmentCapture != null)
|
||||
? await openScreen<Map<String, dynamic>>(
|
||||
context,
|
||||
ShipmentReviewPage(
|
||||
|
||||
@@ -15,7 +15,7 @@ library;
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart' show kDebugMode, mapEquals, setEquals;
|
||||
import 'package:flutter/foundation.dart' show mapEquals, setEquals;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:miler/views/helpers/constants/design_constants.dart';
|
||||
@@ -82,6 +82,10 @@ import 'package:miler/data/service_day.dart';
|
||||
import 'package:miler/data/work_domain.dart';
|
||||
import 'package:miler/data/work_repository.dart';
|
||||
import 'package:miler/data/miler_api.dart';
|
||||
import 'package:miler/data/lifecycle.dart';
|
||||
import 'package:miler/data/geofence.dart';
|
||||
import 'package:miler/data/task_profile.dart';
|
||||
import 'package:miler/data/pickup_locations.dart';
|
||||
import 'package:miler/data/assignment_lookup.dart';
|
||||
import 'package:miler/utils/external_navigation.dart';
|
||||
|
||||
@@ -943,33 +947,10 @@ class _MyPickupsState extends State<MyPickups>
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
_fetchDiagLine(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// The last fetch's stage counts, shown under an empty state in debug only.
|
||||
/// Never compiled into release — [kDebugMode] is a const, so the whole widget
|
||||
/// folds away.
|
||||
Widget _fetchDiagLine() {
|
||||
if (!kDebugMode || _fetchDiag.isEmpty) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: 18.h),
|
||||
child: Text(
|
||||
'debug · $_fetchDiag\n'
|
||||
'${_bookingTrips.length} trip(s) · showing ${_visibleStops.length}',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 10.sp,
|
||||
height: 1.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTripRail() {
|
||||
// The whole run, finished stops included — see [_railStops]. Drawn as long
|
||||
// as *anything* happened today, so a rider who has just closed his last
|
||||
@@ -2182,8 +2163,10 @@ class _MyPickupsState extends State<MyPickups>
|
||||
// separate causes have made this tab look empty — a gate on the raw API
|
||||
// count, a missing empty state on an unfilled trip tab, and stops
|
||||
// filtered out as still-pending — and every one of them looked identical
|
||||
// on screen. The counts are now shown on the empty state itself, so the
|
||||
// failing stage is visible instead of guessed at.
|
||||
// on screen. The counts go to the log so the failing stage can be read
|
||||
// off a `flutter logs` rather than guessed at. They are deliberately NOT
|
||||
// drawn on the empty state any more: that put developer diagnostics in
|
||||
// front of the rider on every debug and demo build.
|
||||
_fetchDiag =
|
||||
// Which line the app resolved the rider onto. Three of the reports
|
||||
// that landed here as "the tab is wrong" were really "the app thinks
|
||||
@@ -3118,9 +3101,16 @@ class _MyPickupsState extends State<MyPickups>
|
||||
// is the picture now, at a size worth looking at, and
|
||||
// it keeps its own copy.
|
||||
//
|
||||
// [_fetchDiagLine] stays: it is `kDebugMode`-gated and
|
||||
// folds away entirely in release, so nothing ships
|
||||
// underneath the art.
|
||||
// Nothing is drawn under the art. A stage-count
|
||||
// readout used to sit here — `debug · line=milkMan
|
||||
// api=0 accepted=0 …` — on the argument that it was
|
||||
// `kDebugMode`-gated and folded away in release.
|
||||
// True, and it still meant every internal build,
|
||||
// every demo and every screenshot showed a rider a
|
||||
// line of diagnostics under a picture telling him he
|
||||
// was all caught up. The counts are still gathered
|
||||
// and still logged — see `[MYPICKUPS][DIAG]` — where
|
||||
// the person who needs them is looking.
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -3140,7 +3130,6 @@ class _MyPickupsState extends State<MyPickups>
|
||||
),
|
||||
),
|
||||
),
|
||||
_fetchDiagLine(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -193,7 +193,7 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
|
||||
// stays "Confirm pickup".
|
||||
final String confirmLabel = isDelivery
|
||||
? 'Confirm delivery'
|
||||
: (ServiceProfile.active.initiatesShipment
|
||||
: (WorkPolicy.initiatesShipment(widget.pickup)
|
||||
? 'Initiate order'
|
||||
: 'Confirm pickup');
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import 'package:miler/controllers/profile_controller.dart';
|
||||
import 'package:miler/data/service_profile.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:miler/data/accepted_store.dart';
|
||||
import 'package:miler/data/session.dart';
|
||||
import 'package:miler/data/proof_store.dart';
|
||||
import 'package:miler/controllers/rewards_controller.dart';
|
||||
import 'package:miler/controllers/summary_controller.dart';
|
||||
@@ -866,8 +867,32 @@ void _showLogoutDialog(BuildContext context) {
|
||||
// Doorstep photos are exactly the kind of record that must
|
||||
// not outlive the session that took them.
|
||||
await ProofStore.clearScope();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('logged_out', true);
|
||||
|
||||
// ── The session ends here, not at the next sign-in ──
|
||||
//
|
||||
// This did not clear the bearer token. The rider was sent
|
||||
// to the sign-in screen and `logged_out` kept him there, so
|
||||
// it *looked* finished — but the token stayed in
|
||||
// SharedPreferences under `authtoken`, still valid, until
|
||||
// the next `verifyPinWithServer` happened to overwrite it.
|
||||
//
|
||||
// Two things followed. Anything that reads the token
|
||||
// without checking the flag — a background isolate, the
|
||||
// notification handler, a heartbeat that outlives the
|
||||
// route change — could go on making authenticated calls as
|
||||
// the rider who just left. And a handset handed to the next
|
||||
// rider carried the previous one's credential on disk.
|
||||
//
|
||||
// Logging out is the one moment the app is certain the
|
||||
// session is over. The credential goes then.
|
||||
// Everything above plus the token, the in-flight guard, the
|
||||
// cached fix and the `logged_out` flag now live in one
|
||||
// place — see [endSession]. The other caller is the 401
|
||||
// handler in `main.dart`: a session the server has stopped
|
||||
// accepting has to end exactly as thoroughly as one the
|
||||
// rider chose to end, and when the two were written out
|
||||
// separately only this one existed.
|
||||
await endSession();
|
||||
Get.offAll(() => const SignIn());
|
||||
},
|
||||
height: ButtonSizes.secondary,
|
||||
|
||||
Reference in New Issue
Block a user