Miler rider app: surface system, visible design language, backend lifecycle

Design system
- MilerSurface ladder (canvas → working → raised → floating) with MilerPanel
  as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1.
- Visible vocabulary applied across Home, Deliveries, Activity, Account and
  the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x),
  canvas wells for anything that opens, small filled tags for shelf labels,
  demoted placeholders. Recorded in DESIGN_SYSTEM.md §6.
- One icon family: 222 Material glyphs migrated to Lucide; none left outside
  lib/xpress.
- Colour semantics corrected: amber only for what is genuinely owed, brand red
  reserved for the live stop, disabled primaries go neutral rather than pale.

Data and lifecycle
- lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart
  makes admin sequence the single ordering authority; service_day.dart, and
  stop_area.dart rewritten against live Coimbatore addresses (digit-token
  stripping, city stoplist, street suffixes, stammer collapse).
- countLabel states the load once, in bags.

Testing
- 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity,
  sheets and verify, with test/failures/ now gitignored (diff debris).
- New pins: home_gutter_test, stop_area_test, plus updated structural bounds.

Note: this commit also carries pre-existing working-tree deletions that were
present before this work (API_SPEC.md, README.md, demo test fixtures).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 05:40:35 +05:30
parent c8350563d9
commit d7348e253f
387 changed files with 80693 additions and 12272 deletions

View File

@@ -1,6 +1,9 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:miler/data/work_repository.dart';
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart' show LatLng;
import 'package:miler/helpers/miler_router.dart';
@@ -17,16 +20,61 @@ import 'package:miler/utils/kalman_filter.dart';
import 'package:miler/utils/mqtt_service.dart';
import 'package:miler/controllers/connectivity_mixin.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/data/meal_run_mock.dart';
import 'package:miler/views/helpers/widgets/app_widgets.dart';
/// Testing/demo flag. When `true`, the proximity ("you are too far from the
/// location") geofence is skipped so mock stops far from the rider can still be
/// completed while testing the design.
/// ── PROXIMITY ENFORCEMENT IS OFF ──
///
/// Tied to [kDebugMode] so it is ALWAYS `false` in release builds — the
/// geofence can never be accidentally shipped disabled, but debug/testing keeps
/// working with mock stops.
const bool kBypassGeofenceForTesting = kDebugMode;
/// Turned off deliberately on 2026-08-19, at the founder's call, because it was
/// refusing real work: riders pressing **Picked up** at a counter were told
/// "You're 4.2 km from this stop", and a rider who cannot record what he has
/// physically done has no way round it. Correctness of the fence matters less
/// than a rider being able to work.
///
/// It is off for **every** status — accepted, arrived, picked up, out for
/// delivery, delivered — and on both gates, this one and the bulk check on
/// Home. Half a fence is worse than none: it would block one route into a rung
/// and wave through another, with two different messages and no explanation of
/// why one worked.
///
/// ── Turning it back on ──
///
/// flutter run --dart-define=ENFORCE_GEOFENCE=true
/// flutter build apk --dart-define=ENFORCE_GEOFENCE=true
///
/// Or flip [kGeofenceEnforced]'s default back to `true` when the underlying
/// problem is fixed. That problem is worth naming, because it is the reason
/// this is off rather than merely loosened: the fence compares the rider's GPS
/// against `pickuplat`/`pickuplon` **on the booking**, and those come from
/// wherever the customer dropped a pin — so the distance it measures is as
/// often the data being wrong as the rider being absent.
///
/// ── What it costs while it is off ──
///
/// This is the one control that decided whether the app was telling the truth
/// about where a rider was standing when he said a stop was done. With it off,
/// "Picked up" means he pressed a button, not that he was there — the
/// timestamps and the GPS still ride along on every status write, so the office
/// can audit after the fact, but nothing is refused at the moment of the press.
///
/// Every bypass is logged in every build mode (see `_checkGeofence`), so a
/// build's own log says which way it was compiled.
///
/// ── The history, so neither old mistake comes back ──
///
/// This was once `kDebugMode`, which meant the fence was off in exactly the
/// builds anyone tested with — so it was never really tested. It was then
/// pinned to a hard `false`, which left no way to walk the flow at a desk. The
/// shape below survives both: one named constant, one define, one default, and
/// the default is a product decision rather than a side effect of how the app
/// was built.
const bool kGeofenceEnforced = bool.fromEnvironment(
'ENFORCE_GEOFENCE',
defaultValue: false,
);
/// The name every call site reads. Derived, so there is one switch and not two.
const bool kBypassGeofenceForTesting = !kGeofenceEnforced;
class PickupsController extends GetxController
with ConnectivityControllerMixin {
@@ -66,6 +114,17 @@ class PickupsController extends GetxController
// Bonus Points Tracking
final RxInt lastBonusPoints = 0.obs;
/// The tracking number the last `pickup-complete` minted, or '' when the call
/// has not run or did not return one.
///
/// `pickup-complete` is the moment a booking becomes a real consignment, and
/// it answers with `{tracking_no, consignment_id, booking_no}` — the shipment
/// the rider has just brought into existence. That payload was being
/// discarded, so the success screen could only say "done" in the abstract.
/// Showing the tracking number makes it a receipt: the rider can read it back
/// to a customer who asks, on the spot, without phoning the hub.
final RxString lastTrackingNo = ''.obs;
// ── Compliance of the stop just completed ──
//
// All three are computed here already — the bonus rule is literally "did he
@@ -79,12 +138,35 @@ class PickupsController extends GetxController
// be measured) — which the UI states as unknown rather than as a failure.
final Rxn<bool> lastOnTime = Rxn<bool>();
final Rxn<Duration> lastLateBy = Rxn<Duration>();
/// The clock the stop was promised by, as read from `eta_endtime_<orderid>`
/// at the moment it closed.
///
/// The verdict — on time or late by how much — was the only thing kept, and
/// it answers "did he make it" without ever answering "by when". A rider
/// asked about a stop wants both, and the deadline itself lives in a prefs
/// key the next stop overwrites. Held here so the completion path can write
/// it onto the record, the same argument [StopCompliance] makes for the two
/// figures beside it.
final Rxn<DateTime> lastEtaDeadline = Rxn<DateTime>();
final RxDouble lastRiderKms = 0.0.obs;
// Trigger to refresh pickup list across screens
final RxInt refreshTrigger = 0.obs;
/// ── After a mutation, the server is asked, not assumed ──
///
/// This bumped a counter and every screen listening re-read *its own* copy of
/// the day — so accepting a stop on Home updated Home, and Bookings and
/// Activity carried on showing the state from before the tap until their own
/// poll came round.
///
/// The shared copy is invalidated first, so the re-read that the counter
/// triggers goes to the API rather than to a cached answer that predates the
/// mutation. One request, and all three screens land on the state the server
/// actually confirmed rather than on the one the button implied.
void triggerRefresh() {
unawaited(WorkRepository.instance.invalidate());
refreshTrigger.value++;
}
@@ -95,11 +177,42 @@ class PickupsController extends GetxController
int userId,
int pickupId,
) async {
// Nothing to store proof against on the meal line — the crate photo has no
// booking behind it yet, so it stays on the phone rather than filling a
// bucket with pictures of a day that only exists in memory.
if (MealRunMock.active) {
debugPrint('[MEAL_MOCK] crate photo kept local: ${imageFile.path}');
return null;
}
// ── These were literals in the source ──
//
// A DigitalOcean Spaces access key and secret key, compiled into every APK
// the app has ever shipped. Anyone with the binary can extract them with
// `strings`, and they are read-write on the whole `doormile` bucket — every
// rider's proof photo, for every tenant, readable and deletable by anybody
// who has installed the app once.
//
// Moving them to `--dart-define` stops the next build embedding them. It
// does **not** undo the exposure: the pair below has been distributed and
// is in this repository's history, so it has to be **rotated**, and the
// upload belongs behind a server-issued signed URL rather than in the
// client at all. Both of those are ops decisions, so this change makes the
// dependency explicit and loud rather than pretending to fix it.
const String accessKey = String.fromEnvironment('SPACES_KEY');
const String secretKey = String.fromEnvironment('SPACES_SECRET');
if (accessKey.isEmpty || secretKey.isEmpty) {
debugPrint(
'[UPLOAD] refused: SPACES_KEY/SPACES_SECRET are not set on this build. '
'Pass them with --dart-define, and rotate the pair that used to be '
'hard-coded here.',
);
AppFeedback.errorGlobal('Photo upload is not configured on this build.');
return null;
}
try {
// final rng = Random(); // Unused
const String region = "sgp1";
const String accessKey = "DO00NQER7N2FRYZAB2HR";
const String secretKey = "nMDewX25IBEu1FM5dakK+v28/WbW3TzBAwq913+dxP0";
const String bucketName = "doormile";
// folderName will be "picked" or "Picked up"
@@ -237,10 +350,40 @@ class PickupsController extends GetxController
}
// Picked
/// ── The meal line has nothing to post to ──
///
/// There are no meal endpoints. Every status method below would otherwise
/// build a payload and fire it at a parcel URL that has never heard of a
/// crate — and, worse, run the proximity check against a mock address in
/// Coimbatore, so a rider walking the flow anywhere else is blocked at the
/// first door by a fence guarding fictional coordinates.
///
/// So on that line the status is recorded in [MealRunMock] and reported as
/// sent. The flow is identical; only the wire is absent. Returns null when
/// this is a real parcel call, so the caller carries on.
///
/// See [MealRunMock] for why this cannot reach a parcel rider.
bool? _mockStatus(int pickupId, String status) {
if (!MealRunMock.active) return null;
lastBlockedReason = null;
return MealRunMock.setStatusByPickupId(pickupId, status);
}
Future<bool> updatePickedStatus({
required int pickupId,
required int orderHeaderId,
required int pickupLocationId,
/// ── The key the delivery leg will look this stop up by ──
///
/// `pickup-complete` is what creates the consignment, and the id it returns
/// is filed against this order so the door can find it later. The payload
/// carried no `orderid`, so the provider fell back to the *booking* id —
/// and `closeDelivery` reads the map by **order** id. Every collected stop
/// was therefore filed under a key nothing would ever ask for, and pressing
/// **Delivered** answered "this order was never picked up on the system"
/// for a bag the rider was holding. See [rememberConsignmentId].
String orderId = '',
String ridersLat = '0',
String ridersLng = '0',
String pickupLat = '0',
@@ -255,6 +398,9 @@ class PickupsController extends GetxController
double actualKms = 0.0,
String? proofImage, // New parameter
}) async {
final mock = _mockStatus(pickupId, 'picked');
if (mock != null) return mock;
try {
final now = DateTime.now();
_pickedTime = _formatDateTimeFull(now);
@@ -307,6 +453,9 @@ class PickupsController extends GetxController
final payload = <String, dynamic>{
'pickupid': pickupId,
// Not sent to the backend — the legacy shape carries it and the mapper
// reads it to file the consignment under the id the door will use.
if (orderId.isNotEmpty) 'orderid': orderId,
'orderheaderid': orderHeaderId,
'pickuplocationid': pickupLocationId,
'orderstatus': 'picked',
@@ -336,6 +485,30 @@ class PickupsController extends GetxController
if (!ok) {
debugPrint('[UPDATE][PICKED][FAILED] resp=${jsonEncode(resp)}');
} else {
// ── What the stop actually became, in the server's words ──
//
// The pivot has two legitimate outcomes and the caller must stamp the
// one that happened, not the one this build was written against:
//
// `collectedByMiler` the rider holds it — PICKED, and Start
// delivery is what makes it active.
// `outForDelivery` compatibility mode — the pivot released it,
// so it is ALREADY out for delivery and the
// console is right to say Active.
//
// Stamping 'picked' in the second case is the mismatch this whole
// exercise is about: the rider would read PICKED off a consignment his
// office reads as Active, and neither of them would be wrong about
// what their own screen said.
lastPivotConsignmentStatus = (resp?['consignmentstatus'] ?? '')
.toString();
lastPivotCompatibilityMode = resp?['compatibilitymode'] == true;
lastPivotNextAction = (resp?['nextaction'] ?? '').toString();
debugPrint(
'[UPDATE][PICKED] server state="$lastPivotConsignmentStatus" '
'next="$lastPivotNextAction" '
'compatibility=$lastPivotCompatibilityMode',
);
// --- MQTT LOGIC ---
MilerMqttService().publishLog('pickup_picked', payload);
}
@@ -366,7 +539,11 @@ class PickupsController extends GetxController
String orderId = '', // New parameter for timer/bonus logic
String? proofImage, // New parameter
}) async {
final mock = _mockStatus(pickupId, 'picked');
if (mock != null) return mock;
try {
lastBlockedReason = null;
pickedShimmer.value = true;
// ✅ PARALLEL OPTIMIZATION: Start fetching Prefs immediately
final prefsFuture = SharedPreferences.getInstance();
@@ -620,6 +797,7 @@ class PickupsController extends GetxController
// stale value from the previous one would be stamped onto this record.
lastOnTime.value = null;
lastLateBy.value = null;
lastEtaDeadline.value = null;
lastRiderKms.value = calculatedRiderKms;
if (orderId.isNotEmpty) {
try {
@@ -629,6 +807,9 @@ class PickupsController extends GetxController
if (endSeconds != null) {
final currentSeconds =
DateTime.now().millisecondsSinceEpoch ~/ 1000;
lastEtaDeadline.value = DateTime.fromMillisecondsSinceEpoch(
endSeconds * 1000,
);
lastOnTime.value = currentSeconds <= endSeconds;
lastLateBy.value = currentSeconds <= endSeconds
? Duration.zero
@@ -770,6 +951,15 @@ class PickupsController extends GetxController
final resp = await _updateProvider.updatePickup(payload);
final ok = _isSuccess(resp);
// Reset first: a stale number from the previous stop shown on this one's
// success screen would be worse than none.
lastTrackingNo.value = '';
if (ok && resp?['details'] is Map) {
final details = resp!['details'] as Map;
lastTrackingNo.value =
(details['tracking_no'] ?? details['trackingno'] ?? '').toString();
}
// Save current RIDER GPS location as last pickup location for next pickup
if (ok) {
final actualLat = ll['lat'] ?? '0';
@@ -871,6 +1061,18 @@ class PickupsController extends GetxController
}
// Helper method to show snackbar reliably in both debug and release builds
//
// ── It has to say what actually happened ──
//
// This took a title and a message, built neither into anything, and printed
// the string "Something went wrong" instead — every time, for every reason.
// The one case that matters most is the proximity block: the caller composes
// "You are 4,200 m from the stop, you need to be within 100 m", and the rider
// was shown three words that told him nothing and implied a bug in the app
// rather than a thing he could fix by riding to the address.
//
// The message is now the message. The title is still only for the log — the
// snackbar is one line and a heading above it would push the fact off it.
void _showErrorSnackbar(
String title,
String message, {
@@ -884,15 +1086,10 @@ class PickupsController extends GetxController
return;
}
debugPrint('[GEOFENCE] $title: $message');
try {
final context = Get.key.currentContext;
// Get bottom safe area padding
final double bottomSafePadding = context != null
? MediaQuery.of(context).padding.bottom
: 0;
AppFeedback.errorGlobal('Something went wrong');
AppFeedback.errorGlobal(message);
} catch (e) {
debugPrint(
'[GEOFENCE] Error showing snackbar (attempt ${retryCount + 1}): $e',
@@ -903,7 +1100,7 @@ class PickupsController extends GetxController
Future.delayed(const Duration(milliseconds: 400), () {
try {
AppFeedback.errorGlobal('Something went wrong');
AppFeedback.errorGlobal(message);
} catch (e2) {
debugPrint('[GEOFENCE] Retry snackbar failed: $e2');
@@ -923,6 +1120,24 @@ class PickupsController extends GetxController
}
}
/// Why the last status update refused to run, when it refused for a reason
/// the rider can act on — today that is only the proximity check. Null when
/// nothing blocked it.
///
/// ── Refused is not failed ──
///
/// [updatePickedupStatus] and friends return `false` for two very different
/// events: the server said no / the request never left the phone, and *the
/// app itself declined to send it* because the rider is not at the address.
/// Callers treat a `false` of the first kind optimistically — the stop is
/// finished locally and the rider moves on, because a dead network must not
/// strand him at a door. Doing that for the second kind is how a stop the hub
/// was never told about ends up on the success screen.
///
/// Set immediately before the refusal, cleared at the top of every update, so
/// a caller can read it straight after its `await`.
String? lastBlockedReason;
Future<bool> _checkGeofence(
double targetLat,
double targetLng,
@@ -930,12 +1145,21 @@ class PickupsController extends GetxController
double currentLng,
String action,
) async {
// Testing/demo bypass: skip proximity enforcement so mock stops far from
// the rider can still be completed. Remove/flip for production.
lastBlockedReason = null;
// Opt-in via `--dart-define=BYPASS_GEOFENCE=true`; enforced otherwise. See
// the declaration for why it is a define rather than a constant.
//
// Logged with `debugPrint` in *every* build mode, deliberately — not behind
// `kDebugMode` like the diagnostics below. The whole risk of this flag is a
// build going out with proximity silently off, so the one thing it must
// never be is quiet.
if (kBypassGeofenceForTesting) {
if (kDebugMode) {
debugPrint('[GEOFENCE] Bypassed for $action (testing flag on)');
}
debugPrint(
'[GEOFENCE] OFF for $action — enforcement is disabled in this build. '
'Stop completion is NOT proximity-verified. '
'See kGeofenceEnforced.',
);
return true;
}
@@ -988,12 +1212,18 @@ class PickupsController extends GetxController
}
if (distance > radius) {
// Show error snackbar with clear message using helper method
_showErrorSnackbar(
'Location Error',
'You are too far from the location to mark as $action.\nDistance: ${distanceMeters.toStringAsFixed(0)} m (${distanceKm.toStringAsFixed(2)} km)\nRequired: Within $radius m',
seconds: 5,
);
// ── One sentence, in metres he can act on ──
//
// Was three lines of "Distance: 4213 m (4.21 km) / Required: Within
// 100 m" — a readout, in a snackbar, on a phone in a jacket pocket.
// What the rider needs is how far he still has to go.
final String away = distanceMeters >= 1000
? '${distanceKm.toStringAsFixed(1)} km'
: '${distanceMeters.toStringAsFixed(0)} m';
lastBlockedReason =
"You're $away from this stop — get within $radius m to mark it "
'${action.toLowerCase()}';
_showErrorSnackbar('Location Error', lastBlockedReason!, seconds: 5);
return false;
}
return true;
@@ -1166,6 +1396,9 @@ class PickupsController extends GetxController
String ridersLng = '0',
String notes = '',
}) async {
final mock = _mockStatus(pickupId, 'accepted');
if (mock != null) return mock;
try {
final ll = await _ensureLatLng(ridersLat, ridersLng);
final acceptedTime = _formatDateTimeFull(DateTime.now());
@@ -1213,6 +1446,9 @@ class PickupsController extends GetxController
String notes = '',
String orderId = '',
}) async {
final mock = _mockStatus(pickupId, 'active');
if (mock != null) return mock;
try {
final now = DateTime.now();
_activeTime = _formatDateTimeFull(now);
@@ -1342,6 +1578,29 @@ class PickupsController extends GetxController
}
// Arrived
/// The consignment state the last `pickup-complete` reported, as a
/// [ConsignmentState] name. Empty when the response named none.
///
/// Read by Home to stamp the rung the **server** produced rather than the
/// one this build expects. See [updatePickedStatus].
String lastPivotConsignmentStatus = '';
/// True when that pivot released the consignment itself — the backend's
/// compatibility mode, and the reason Admin shows Active for a fresh pickup.
bool lastPivotCompatibilityMode = false;
/// The server's own instruction for what comes next, e.g. `start_delivery`.
String lastPivotNextAction = '';
/// Whether the **server** confirmed the last arrival, not whether the call
/// returned 200. See [updateArrivedStatus].
final RxBool arrivalConfirmedByServer = true.obs;
/// What to tell the rider when it did not. Null when there is nothing to
/// say, which is the state this should be in once the backend ships a
/// `reached` that writes `Arrived_At_Pickup`.
String? lastArrivalNotice;
Future<bool> updateArrivedStatus({
required int pickupId,
required int orderHeaderId,
@@ -1351,6 +1610,9 @@ class PickupsController extends GetxController
String pickupLng = '0',
String notes = '',
}) async {
final mock = _mockStatus(pickupId, 'arrived');
if (mock != null) return mock;
// START LOADING IMMEDIATELY for better UX
arrivedShimmer.value = true;
try {
@@ -1398,8 +1660,34 @@ class PickupsController extends GetxController
if (!ok) {
debugPrint('[UPDATE][ARRIVED][FAILED] resp=${jsonEncode(resp)}');
return false;
}
return ok;
// ── Succeeded, but did the hub record it? ──
//
// Two different answers, and this used to return the first as if it were
// the second. `reached` answers 200 `success: true` and leaves the
// booking status untouched (verified in production 21 Aug 2026), so the
// rider saw ARRIVED and the office saw nothing — for as long as that
// endpoint stays a no-op, which is not something the app can fix.
//
// What the app can do is stop asserting the agreement. The rung still
// advances, because a rider standing at a kitchen cannot be blocked by
// somebody else's deployment; but it advances as *his* record, and the
// discrepancy is named rather than hidden behind a tick.
arrivalConfirmedByServer.value = resp?['confirmed'] == true;
if (!arrivalConfirmedByServer.value) {
lastArrivalNotice =
'Marked arrived on your phone. Your hub has not recorded it — '
'their system is not accepting arrivals yet.';
debugPrint(
'[UPDATE][ARRIVED][UNCONFIRMED] server said '
'"${resp?['serverstatus']}" — ${resp?['evidence']}',
);
} else {
lastArrivalNotice = null;
}
return true;
} catch (e) {
debugPrint('[UPDATE][ARRIVED][ERROR] $e');
arrivedShimmer.value = false;
@@ -1407,6 +1695,185 @@ class PickupsController extends GetxController
}
}
// ═══════════════════════════════════════════════════════════════════════
// THE MILK RUN'S DELIVERY LEG
//
// Deliberately separate methods rather than a flag on the pickup ones. The
// pickup path measures rider kilometres, awards a punctuality bonus, saves a
// "last pickup location" for the next leg's distance and recomputes
// chargeable weight — none of which describes handing somebody a lunch. A
// boolean threading through all of that would make both jobs harder to read
// and put the collection path, which every logistics rider depends on, one
// typo away from a milk-run change.
//
// What they DO share is the geofence, the location fix and the provider, so
// those are reused as-is.
// ═══════════════════════════════════════════════════════════════════════
/// The rider has reached a **customer's** door on his round.
///
/// **BACKEND DEPENDENCY — this writes nothing.** There is no route that
/// records arrival at a delivery address: `/bookings/:id/reached` belongs to
/// the pickup phase and the consignment routes have no arrival event. So this
/// verifies he is actually there and returns — the rung exists because the
/// rider needs it (it is what turns his Deliver button on at the right door),
/// not because the hub can see it.
///
/// Returning true from a method that sent nothing would normally be a lie;
/// it is not one here because the method does not claim to have written.
/// See [MilkRun.deliveryArrivalIsLocalOnly].
Future<bool> updateDeliveryArrived({
required int pickupId,
String ridersLat = '0',
String ridersLng = '0',
String dropLat = '0',
String dropLng = '0',
}) async {
arrivedShimmer.value = true;
try {
final ll = await _ensureLatLng(ridersLat, ridersLng);
final rLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0;
final rLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0;
final dLat = double.tryParse(dropLat) ?? 0.0;
final dLng = double.tryParse(dropLng) ?? 0.0;
// Fenced against the DROP, not the pickup. Using the pickup coordinates
// here would fence the rider to the kitchen he left an hour ago.
if (dLat != 0 && dLng != 0) {
final inFence = await _checkGeofence(
dLat,
dLng,
rLat,
rLng,
'Delivery arrived',
);
if (!inFence) return false;
}
// The clock the completion record reads, same key the pickup leg uses.
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
'pickup_start_$pickupId',
DateTime.now().toIso8601String(),
);
return true;
} catch (e) {
debugPrint('[DELIVERY][ARRIVED][ERROR] $e');
return false;
} finally {
arrivedShimmer.value = false;
}
}
/// Handed over. `POST /miler/consignments/:consignmentid/deliver`.
///
/// Keyed on the **consignment**, which is why [consignmentId] is required and
/// not derived: it and the booking id come from different sequences, and the
/// route answers 404 for the wrong one while the rider is shown success —
/// the same trap `bookingassignmentid` sets on the accept side.
///
/// No OTP: nothing generates one for a milk-run drop and the handler cannot
/// verify a code against anything, so sending a placeholder would be
/// fabricating proof. See [MilerApi.deliver].
Future<bool> updateDeliveredStatus({
required int pickupId,
required String consignmentId,
required String deliveredToName,
String ridersLat = '0',
String ridersLng = '0',
String dropLat = '0',
String dropLng = '0',
String notes = '',
String? proofImage,
}) async {
lastBlockedReason = null;
if (consignmentId.trim().isEmpty) {
debugPrint(
'[DELIVERED] no consignmentid for pickup $pickupId — refusing',
);
// Not a wire failure: the app itself refused, and the caller's message
// should say what to do rather than suggest a retry.
lastBlockedReason =
'This order was never picked up on the system — mark it picked '
'up first, then deliver.';
return false;
}
pickedShimmer.value = true;
try {
final ll = await _ensureLatLng(ridersLat, ridersLng);
final rLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0;
final rLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0;
final dLat = double.tryParse(dropLat) ?? 0.0;
final dLng = double.tryParse(dropLng) ?? 0.0;
if (dLat != 0 && dLng != 0) {
final inFence = await _checkGeofence(
dLat,
dLng,
rLat,
rLng,
'Delivered',
);
if (!inFence) return false;
}
_pickedTime = _formatDateTimeFull(DateTime.now());
// ── The coordinates must be the DOOR's ──
//
// The server computes `riderkms` for this leg by haversine from the
// pickup coordinates to whatever is sent here, and writes it onto the
// earnings record. Sending the pickup's own position would silently zero
// the rider's distance — and therefore his pay — for the whole leg.
final payload = <String, dynamic>{
'pickupid': pickupId,
'consignmentid': consignmentId.trim(),
'orderstatus': 'delivered',
'pickupcustomer': deliveredToName,
'pickupedtime': _pickedTime,
'riderslat': ll['lat'],
'riderslon': ll['lng'],
'pickuplat': dropLat,
'pickuplong': dropLng,
'notes': notes,
'dropimage': proofImage ?? '',
};
final resp = await _updateProvider.updatePickup(payload);
final ok = _isSuccess(resp);
if (!ok) {
debugPrint('[DELIVERED][FAILED] resp=${jsonEncode(resp)}');
// ── The server's refusal is the rider's message ──
//
// This swallowed the response and the UI fell back to "Could not
// record delivered — try again", which reads as a network blip and
// invites a retry that will refuse identically. The backend's message
// names the actual condition ("not out for delivery", "not found"),
// which is the difference between a rider retrying forever and a
// rider knowing what to tell the hub.
final serverMsg = (resp?['message'] ?? '').toString().trim();
// ── `not out for delivery` is NOT a hub-release message ──
//
// It was mapped to "the hub hasn't released this yet", which is one of
// the two opposite situations the backend spells that way — the other
// is **already delivered**, which is what a rider actually meets. The
// question is now answered before the call, from the consignment's own
// history, so this branch no longer guesses: whatever the server says
// is passed through as the server's own words.
if (serverMsg.isNotEmpty && serverMsg.length < 140) {
lastBlockedReason = 'The office refused this delivery: $serverMsg';
}
}
return ok;
} catch (e) {
debugPrint('[DELIVERED][ERROR] $e');
return false;
} finally {
pickedShimmer.value = false;
}
}
// Rejected (remove from queue)
Future<bool> updateRejectedStatus({
required int pickupId,
@@ -1415,6 +1882,9 @@ class PickupsController extends GetxController
String ridersLng = '0',
String notes = '',
}) async {
final mock = _mockStatus(pickupId, 'rejected');
if (mock != null) return mock;
try {
final ll = await _ensureLatLng(ridersLat, ridersLng);
final payload = <String, dynamic>{
@@ -1461,7 +1931,11 @@ class PickupsController extends GetxController
double actualKms = 0.0,
bool wasSkipped = false,
}) async {
final mock = _mockStatus(pickupId, 'cancelled');
if (mock != null) return mock;
try {
lastBlockedReason = null;
final now = DateTime.now();
_cancelledTime = _formatDateTimeFull(now);
@@ -1746,6 +2220,9 @@ class PickupsController extends GetxController
String ridersLng = '0',
String notes = '',
}) async {
final mock = _mockStatus(pickupId, 'skipped');
if (mock != null) return mock;
try {
final ll = await _ensureLatLng(ridersLat, ridersLng);
final skippedTime = _formatDateTimeFull(DateTime.now());