Files
doormile_milderapp/lib/controllers/pickups_controller.dart
2026-08-28 18:16:28 +05:30

2679 lines
103 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/data/accepted_store.dart';
import 'package:geolocator/geolocator.dart';
import 'package:miler/providers/pickuplog/pickuplog_provider.dart';
import 'dart:io';
import 'package:minio/minio.dart';
import 'package:minio/io.dart';
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';
/// ── PROXIMITY ENFORCEMENT IS OFF ──
///
/// Off again on 2026-08-25, the same day it was turned on, at the founder's
/// call. Nothing was found wrong with it — this is a decision about when to
/// switch it on, not a retraction of the work.
///
/// It is off for **every** status — arrived, picked, picked up, delivery
/// arrived, delivered, cancelled — 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.
///
/// ── What it costs while it is off ──
///
/// This is the one control that decides whether the app is 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.
///
/// ── What is ready for the day it goes back on ──
///
/// Recorded here because the work is done and the flag is the only thing
/// holding it, so nobody has to rediscover any of it. The fence was off from
/// 2026-08-19 because it refused real work — riders pressing **Picked up** at a
/// counter were told "You're 4.2 km from this stop" — and that was a
/// measurement problem rather than a strictness one. Three causes, all fixed:
///
/// • **The fix was not worth measuring with.** Home handed the fence a
/// `LocationAccuracy.low` position — a ~1 km hint on Android — or a cached
/// one of any age. See [_freshFix] and [kGeofenceFixMaxAge].
/// • **The phone's own error was charged to the rider.** The comparison is
/// `distance - accuracy > radius`, so a rider 12 m out on a ±20 m fix is not
/// refused for a precision the hardware never provided.
/// • **There were three radii.** A configured `pickupradius` defaulting to
/// 100 here, a hardcoded 500 in Home's bulk gate, and no agreement between
/// them. There is one now: [kGeofenceRadiusMeters], set to 10.
///
/// All three are live in the code below and simply do not run while this is
/// false. The fourth cause is real and none of this fixes it: **the booking's
/// own coordinates are often wrong**, because they come from wherever the
/// customer dropped a pin. A stop carrying *no* coordinates is allowed through
/// — that is the hub's data, not something a rider can resolve from a doorstep
/// — but a stop carrying wrong ones will still refuse him.
///
/// ── Turning it on ──
///
/// flutter run --dart-define=ENFORCE_GEOFENCE=true
/// flutter build apk --dart-define=ENFORCE_GEOFENCE=true
///
/// Or flip the default. Ten metres is tight — at or inside consumer GPS
/// accuracy — so if the first reports are riders blocked at doors, raise
/// [kGeofenceRadiusMeters] before reaching for this switch again.
///
/// 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 none of the old mistakes come 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;
/// ─────────────────────────────────────────────────────────────────────────
/// HOW CLOSE IS "AT THE STOP" — 10 metres
///
/// A product decision, taken deliberately and tight: the rider must be *at the
/// door*, not on the street outside it, before he can mark a stop arrived,
/// picked or delivered.
///
/// ── Why this is a constant and no longer the server's `pickupradius` ──
///
/// The fence used to read `pickupradius` out of prefs, which login writes from
/// the profile and defaults to 100. That made the strictness of the app's one
/// honesty control a per-tenant configuration value nobody on this side could
/// see, and it silently disagreed with the second gate on Home, which was
/// hardcoded to 500. Three numbers for one rule. This is the rule.
///
/// ── What 10 metres actually demands, stated plainly ──
///
/// This is at or inside the accuracy of consumer GPS. A phone reports a fix
/// with an error radius, and 5–15 m in the open is normal while 30–50 m
/// between buildings is ordinary rather than exceptional. A fence smaller than
/// the error it is measured with will refuse a rider who is genuinely standing
/// at the door — which is the exact failure that got this whole control turned
/// off once before, and the reason two things below are not optional:
///
/// • **The fix must be worth 10 m.** [LocationAccuracy.best], not the `low`
/// the callers were passing — `low` is a ~1 km hint on Android and against
/// a 10 m fence it is not a measurement, it is a coin toss. See
/// [_freshFix].
///
/// • **The phone's own error is credited to the rider.** The check is
/// `distance - accuracy > radius`, not `distance > radius`: a rider 12 m
/// away on a fix that says ±20 m has not been shown to be outside the
/// fence, and refusing him is asserting a precision the hardware did not
/// provide. He is refused when the *phone* says he is outside, not when the
/// arithmetic does.
///
/// Together those keep 10 m meaning "at the door" without it meaning "when the
/// satellites are kind". If riders still report being blocked at a door, this
/// number is the knob — raise it here, in one place, rather than turning the
/// fence off again.
const double kGeofenceRadiusMeters = 10;
/// How stale a cached fix may be before the fence refuses to measure with it.
///
/// A last-known position is instant and free and can be an hour old. Against a
/// 100 m fence that was survivable; against 10 m it is how a rider marks a
/// delivery arrived from the previous street because that is where the phone
/// last looked.
const Duration kGeofenceFixMaxAge = Duration(seconds: 30);
class PickupsController extends GetxController
with ConnectivityControllerMixin {
MilerKalmanFilter? _kf;
DateTime? _lastUpdateTime;
final UpdatePickupProvider _updateProvider = UpdatePickupProvider();
bool _isSuccess(Map<String, dynamic>? resp) {
final status = resp?['status'];
if (status is bool) return status;
if (status is String) {
final v = status.toLowerCase();
return v == 'true' || v == 'success' || v == 'ok' || v == 'accepted';
}
return false;
}
// UI flags similar to xpressrider for parity
final RxBool arrivedShimmer = false.obs;
final RxBool pickedShimmer = false.obs;
final RxBool isPipEnabled = false.obs;
// Distance tracking for PiP mode
final RxDouble pickupableDistance = 0.0.obs; // Distance in meters
// Times captured in ISO-like format (yyyy-MM-dd HH:mm:ss)
String _arrivedTime = '';
String _pickedTime = '';
String _activeTime = '';
String _cancelledTime = '';
// Cached last known lat/lng from device
final RxString currentLat = '0'.obs;
final RxString currentLng = '0'.obs;
// 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
// arrive before the ETA deadline", and the distance is measured to bill
// rider charges — but they were only ever written to the payload and the log.
// The rider could not see whether he had made his ETA or how far off the
// planned route he had gone, and neither could the Activity tab.
//
// Published so the completion path can stamp them onto the finished stop.
// Null means "not knowable for this stop" (no ETA was set, no distance could
// 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++;
}
// ---------- UPLOAD IMAGE TO DO SPACES ----------
Future<String?> uploadProofImage(
File imageFile,
String folderName,
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 {
const String region = "sgp1";
const String bucketName = "doormile";
// folderName will be "picked" or "Picked up"
// File name
final now = DateTime.now();
final dateStr =
"${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}";
final timeStr =
"${now.hour.toString().padLeft(2, '0')}${now.minute.toString().padLeft(2, '0')}${now.second.toString().padLeft(2, '0')}";
final String fileName = '$folderName-$pickupId-$dateStr-$timeStr.jpg';
// Object path inside the bucket
// final String objectPath = "$folderName/$fileName";
final String objectPath = "support/$fileName";
// CDN URL you want
final String cdnUrl = "https://images.doormile.app/$objectPath";
// Initialize Minio
final minio = Minio(
endPoint: "$region.digitaloceanspaces.com",
accessKey: accessKey,
secretKey: secretKey,
region: region,
useSSL: true,
);
debugPrint("Uploading Proof: $objectPath");
// Upload to DO Spaces
await minio.fPutObject(
bucketName,
objectPath,
imageFile.path,
metadata: {"Content-Type": "image/jpeg", "x-amz-acl": "public-read"},
);
debugPrint("Proof Uploaded Successfully: $cdnUrl");
return cdnUrl;
} catch (e) {
debugPrint("Proof Upload error: $e");
AppFeedback.errorGlobal('Image upload failed. Please try again.');
return null;
}
}
// ---------------- Location helpers ----------------
/// The error radius, in metres, of the fix [_ensureLatLng] last obtained.
///
/// Read by [_checkGeofence], which credits it to the rider — see
/// [kGeofenceRadiusMeters]. Starts at zero so a fence measured before any fix
/// has been taken is strict rather than accidentally generous.
double _lastFixAccuracy = 0;
/// A position good enough to measure a [kGeofenceRadiusMeters] fence with.
///
/// ── What this replaced, and why it had to go ──
///
/// The old ladder was: last-known first, then `high` for 4s, then `low` for
/// 2s. Every rung of it is wrong against a 10 m fence.
///
/// • **Last-known first** returns instantly and can be an hour old. The
/// fence would then be measured from wherever the phone last happened to
/// look — reliably the previous stop, on a round.
/// • **`low` as a fallback** is a ~1 km hint on Android. Against 10 m that
/// is not a degraded measurement, it is noise being treated as evidence,
/// and it fails in both directions: it blocks a rider at the door and
/// waves through one two streets away.
///
/// So: ask for the best fix the hardware will give, wait long enough for the
/// GPS to actually settle, and fall back to a cached one **only** if it is
/// fresher than [kGeofenceFixMaxAge]. Whatever comes back carries its own
/// accuracy into [_lastFixAccuracy], so the fence knows how much to trust it.
Future<Position?> _freshFix() async {
Position? pos;
try {
pos = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.best,
// 8s, not 4. A cold GPS under a roofline needs the time, and the
// rider is standing still at a door — this is the one moment in his
// round where a few seconds buys something.
timeLimit: Duration(seconds: 8),
),
);
} catch (_) {
// No live fix. A recent cached one is a measurement; a stale one is not.
try {
final cached = await Geolocator.getLastKnownPosition();
final age = cached == null
? null
: DateTime.now().difference(cached.timestamp);
if (cached != null && age != null && age <= kGeofenceFixMaxAge) {
pos = cached;
} else if (cached != null) {
debugPrint(
'[GEOFENCE] cached fix is ${age?.inSeconds}s old — too stale to '
'measure a ${kGeofenceRadiusMeters.toStringAsFixed(0)}m fence',
);
}
} catch (_) {}
}
_lastFixAccuracy = pos?.accuracy ?? 0;
return pos;
}
Future<Map<String, String>> _ensureLatLng(String lat, String lng) async {
String outLat = lat;
String outLng = lng;
try {
final needsFetch =
(lat == '0' || lat.isEmpty || lng == '0' || lng.isEmpty);
// ── The two shortcuts below are disabled while the fence is on ──
//
// Every caller of this method feeds its answer to [_checkGeofence] and
// then puts the same pair on the payload. Both shortcuts hand back a
// position of unknown provenance: the first trusts whatever the screen
// passed in — Home passes a `LocationAccuracy.low` fix, which is a ~1 km
// hint — and the second reuses a cached value with no age on it at all.
// Neither carries an accuracy, so [_lastFixAccuracy] would be stale too
// and the fence would measure a 10 m rule with a number it cannot
// characterise.
//
// With the fence enforced this takes one real fix per status write. That
// is a few seconds, once, at a door the rider is standing still at — and
// it is the whole basis on which the app is about to refuse or allow his
// press. With the fence off the shortcuts stand: nothing is being decided
// on the answer, it is telemetry.
if (kBypassGeofenceForTesting) {
// Fast path: if we have valid coordinates, use them immediately
if (!needsFetch) return {'lat': outLat, 'lng': outLng};
// Reuse recently cached coordinates first if fresh (e.g. within 30s)
// For now just check if they exist to save time
if (currentLat.value.isNotEmpty &&
currentLat.value != '0' &&
currentLng.value.isNotEmpty &&
currentLng.value != '0') {
return {'lat': currentLat.value, 'lng': currentLng.value};
}
}
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) return {'lat': outLat, 'lng': outLng};
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
return {'lat': outLat, 'lng': outLng};
}
final pos = await _freshFix();
if (pos != null) {
final now = DateTime.now();
double outLatDouble = pos.latitude;
double outLngDouble = pos.longitude;
if (_kf == null) {
_kf = MilerKalmanFilter(lat: outLatDouble, lng: outLngDouble);
} else {
final double dt = _lastUpdateTime != null
? now.difference(_lastUpdateTime!).inMilliseconds / 1000.0
: 30.0;
_kf!.predict(dt);
_kf!.update(outLatDouble, outLngDouble);
outLatDouble = _kf!.x[0];
outLngDouble = _kf!.x[1];
}
_lastUpdateTime = now;
outLat = outLatDouble.toStringAsFixed(6);
outLng = outLngDouble.toStringAsFixed(6);
currentLat.value = outLat;
currentLng.value = outLng;
}
return {'lat': outLat, 'lng': outLng};
} catch (_) {
return {'lat': outLat, 'lng': outLng};
}
}
// 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',
String pickupLng = '0',
String address = '',
String city = '',
String state = '',
String suburb = '',
String postcode = '',
String pickupType = '',
String notes = '',
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);
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 pLat = double.tryParse(pickupLat) ?? 0.0;
final pLng = double.tryParse(pickupLng) ?? 0.0;
// Geofence Check: Picked -> Pickup Location
final inFence = await _checkGeofence(pLat, pLng, rLat, rLng, 'Picked');
if (!inFence) return false;
// Save pickup location ONLY if it hasn't been saved yet (first pickup only)
final existingPickup = await _getPickupLocation();
if (existingPickup['lat']!.isEmpty &&
pickupLat != '0' &&
pickupLng != '0') {
await _savePickupLocation(pickupLat, pickupLng);
} else {
debugPrint('[PICKED] Pickup location already exists, not overwriting');
}
// Calculate distance from rider location to pickup location if coordinates are available
double calculatedKms = actualKms;
if (actualKms == 0.0) {
try {
final riderLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0;
final riderLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0;
final pickLat = double.tryParse(pickupLat) ?? 0.0;
final pickLng = double.tryParse(pickupLng) ?? 0.0;
if (riderLat != 0 && riderLng != 0 && pickLat != 0 && pickLng != 0) {
final distanceMeters = Geolocator.distanceBetween(
riderLat,
riderLng,
pickLat,
pickLng,
);
calculatedKms = distanceMeters / 1000.0;
debugPrint(
'[PICKED] Calculated distance: ${calculatedKms.toStringAsFixed(2)} km from rider to pickup',
);
}
} catch (e) {
debugPrint('[PICKED] Error calculating distance: $e');
}
}
// ── Written down, not only sent ──
//
// This figure is the only measurement of the leg that exists: the backend
// does not reliably send a distance back on `GET /miler/bookings`, and
// until now the app posted this and kept nothing, so Activity's `km
// ridden` had no row to total and printed an em dash on a day of real
// riding. Recorded against the stop so the record that gets filed carries
// it — see [kStopKmKey] and `addCompletedBookings`.
//
// Best-effort: a preferences write must never cost the rider a pickup
// that has otherwise succeeded.
if (calculatedKms > 0) {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
kStopKmKey(pickupId),
calculatedKms.toStringAsFixed(2),
);
} catch (e) {
debugPrint('[PICKED] could not record the distance: $e');
}
}
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',
'pickuptime': _pickedTime,
'riderslat': ll['lat'],
'riderslon': ll['lng'],
'pickuplat': '',
'pickuplong': '',
'actualkms': calculatedKms.toStringAsFixed(2),
'pickupamt': 0.0,
'pickuptype': pickupType,
'address': address,
'city': city,
'state': state,
'suburb': suburb,
'postcode': postcode,
'notes': notes,
'pickupimage': proofImage ?? '', // Standard key
'proofimage': proofImage ?? '', // Fallback/Alternative key just in case
};
debugPrint('[PICKED] Payload: $payload');
final resp = await _updateProvider.updatePickup(payload);
final ok = _isSuccess(resp);
if (!ok) {
debugPrint('[UPDATE][PICKED][FAILED] resp=${jsonEncode(resp)}');
// ── The reason, kept, because the rider was told nothing ──
//
// Verified on production 27 Aug 2026, booking 207:
// `POST /miler/bookings/207/pickup-complete` → **500**
// `{"message":"failed to convert booking to consignment"}`.
//
// The app was right to refuse the move — `_advanceStop` leaves an
// unconfirmed pivot on Home rather than pushing it across the boundary
// — but it did that *silently*. The rider slid, the sheet closed, and
// the stop simply stayed where it was with nothing on screen. He slid
// again, and again, because nothing told him the hub had refused.
//
// The server's own sentence is the honest thing to show him, so it is
// kept here for the caller to read straight after its `await`, on the
// same terms as [lastBlockedReason].
final why = (resp?['message'] ?? '').toString().trim();
lastPickupRefusal = why.isEmpty
? 'Your office could not record this pickup. Try again in a moment.'
: why;
} else {
lastPickupRefusal = null;
// ── 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);
}
return ok;
} catch (e) {
debugPrint('[UPDATE][PICKED][ERROR] $e');
return false;
}
}
// Picked up
Future<bool> updatePickedupStatus({
required int pickupId,
required int orderHeaderId,
String ridersLat = '0',
String ridersLng = '0',
int pickupLocationId = 0,
int smsPickup = 0,
String pickupLat = '0',
String pickupLng = '0',
String notes = '',
double pickupAmount = 0.0,
double actualKms = 0.0,
double collectionAmount = 0.0,
double collectedAmount = 0.0,
int collectionStatus = 0,
bool wasSkipped = false,
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();
final now = DateTime.now();
_pickedTime = _formatDateTimeFull(now);
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(pickupLat) ?? 0.0;
final dLng = double.tryParse(pickupLng) ?? 0.0;
// Geofence Check: Picked up -> Pickup Location
final inFence = await _checkGeofence(dLat, dLng, rLat, rLng, 'Picked up');
if (!inFence) {
pickedShimmer.value = false;
return false;
}
debugPrint(
'[Picked up] Rider GPS location: lat=${ll['lat']}, lng=${ll['lng']}',
);
// Wait for prefs to be ready
final prefs = await prefsFuture;
// ---------------- RIDER TIME (minutes) ----------------
int riderTimeMinutes = 0;
try {
final key = 'ridertime_start_$pickupId';
final startStr = prefs.getString(key);
if (startStr != null && startStr.isNotEmpty) {
final start = DateTime.tryParse(startStr);
if (start != null) {
final diff = now.difference(start).inSeconds / 60.0;
if (diff > 0) {
// Round: 2.7 -> 3, 2.5 -> 3, 2.4 -> 2, etc.
riderTimeMinutes = diff.round();
}
}
}
debugPrint('[Picked up] riderTimeMinutes=$riderTimeMinutes');
} catch (e) {
debugPrint('[Picked up] Error computing ridertime: $e');
}
// CRITICAL: Always calculate riderkms - even for small distances (10 meters = 0.01 km)
// This ensures riderkms is ALWAYS passed correctly, never null
double calculatedRiderKms = 0.0;
final riderLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0;
final riderLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0;
// PRIORITY 1: Use cumulative distance tracked from active to Picked up (most accurate)
// This is the actual distance the rider traveled, calculated from GPS points every 30 seconds
try {
final cumulativeKmStr =
prefs.getString('pickup_tracking_${pickupId}_cumulativeKm') ?? '';
if (cumulativeKmStr.isNotEmpty) {
final cumulativeKm = double.tryParse(cumulativeKmStr) ?? 0.0;
if (cumulativeKm > 0) {
calculatedRiderKms = cumulativeKm;
debugPrint(
'[Picked up] ✅ Using CUMULATIVE tracked distance: ${calculatedRiderKms.toStringAsFixed(4)} km',
);
debugPrint(
'[Picked up] 📊 This is the actual distance traveled from active to Picked up',
);
}
}
} catch (e) {
debugPrint('[Picked up] Error getting cumulative distance: $e');
}
// PRIORITY 2: Only use actualKms if cumulative distance not available and it's greater than 0
if (calculatedRiderKms == 0.0 &&
actualKms > 0.0 &&
riderLat != 0 &&
riderLng != 0) {
calculatedRiderKms = actualKms;
debugPrint(
'[Picked up] Using provided actualKms: ${calculatedRiderKms.toStringAsFixed(2)} km',
);
}
// Always try to calculate distance if we have valid rider coordinates
// CRITICAL: Use Google Maps Directions API for accurate ROAD distance (not straight-line)
if (calculatedRiderKms == 0.0 && riderLat != 0 && riderLng != 0) {
try {
// Method 1: Try Start Location (Anti-Cheat) - most accurate
final startLoc = await _getPickupStartLocation(pickupId);
final startLatStr = startLoc['lat'] ?? '';
final startLngStr = startLoc['lng'] ?? '';
if (startLatStr.isNotEmpty && startLngStr.isNotEmpty) {
final startLat = double.tryParse(startLatStr) ?? 0.0;
final startLng = double.tryParse(startLngStr) ?? 0.0;
if (startLat != 0 && startLng != 0) {
// Try Google Maps route distance first (accurate road distance)
final routeKm = await _getRouteDistanceKm(
startLat,
startLng,
riderLat,
riderLng,
);
if (routeKm != null && routeKm > 0) {
calculatedRiderKms = routeKm;
debugPrint(
'[Picked up] ✅ Route distance (Start Location): ${calculatedRiderKms.toStringAsFixed(4)} km',
);
} else {
// Fallback to straight-line if API fails
final distanceMeters = Geolocator.distanceBetween(
startLat,
startLng,
riderLat,
riderLng,
);
calculatedRiderKms = distanceMeters / 1000.0;
debugPrint(
'[Picked up] ⚠️ Fallback straight-line (Start Location): ${distanceMeters.toStringAsFixed(2)} meters = ${calculatedRiderKms.toStringAsFixed(4)} km',
);
}
}
}
// Method 2: If start location failed, try provided pickup location
if (calculatedRiderKms == 0.0 &&
pickupLat != '0' &&
pickupLng != '0') {
final pickLat = double.tryParse(pickupLat) ?? 0.0;
final pickLng = double.tryParse(pickupLng) ?? 0.0;
if (pickLat != 0 && pickLng != 0) {
// Try Google Maps route distance first
final routeKm = await _getRouteDistanceKm(
pickLat,
pickLng,
riderLat,
riderLng,
);
if (routeKm != null && routeKm > 0) {
calculatedRiderKms = routeKm;
debugPrint(
'[Picked up] ✅ Route distance (Pickup Location): ${calculatedRiderKms.toStringAsFixed(4)} km',
);
} else {
// Fallback to straight-line
final distanceMeters = Geolocator.distanceBetween(
pickLat,
pickLng,
riderLat,
riderLng,
);
calculatedRiderKms = distanceMeters / 1000.0;
debugPrint(
'[Picked up] ⚠️ Fallback straight-line (Pickup Location): ${distanceMeters.toStringAsFixed(2)} meters = ${calculatedRiderKms.toStringAsFixed(4)} km',
);
}
}
}
// Method 3: Fallback to saved pickup location
if (calculatedRiderKms == 0.0) {
final pickupLoc = await _getPickupLocation();
final pickLat = double.tryParse(pickupLoc['lat'] ?? '') ?? 0.0;
final pickLng = double.tryParse(pickupLoc['lng'] ?? '') ?? 0.0;
if (pickLat != 0 && pickLng != 0) {
// Try Google Maps route distance first
final routeKm = await _getRouteDistanceKm(
pickLat,
pickLng,
riderLat,
riderLng,
);
if (routeKm != null && routeKm > 0) {
calculatedRiderKms = routeKm;
debugPrint(
'[Picked up] ✅ Route distance (Saved Pickup): ${calculatedRiderKms.toStringAsFixed(4)} km',
);
} else {
// Fallback to straight-line
final distanceMeters = Geolocator.distanceBetween(
pickLat,
pickLng,
riderLat,
riderLng,
);
calculatedRiderKms = distanceMeters / 1000.0;
debugPrint(
'[Picked up] ⚠️ Fallback straight-line (Saved Pickup): ${distanceMeters.toStringAsFixed(2)} meters = ${calculatedRiderKms.toStringAsFixed(4)} km',
);
}
}
}
// Method 4: Final fallback - calculate from pickup location to current location
if (calculatedRiderKms == 0.0 &&
pickupLat != '0' &&
pickupLng != '0') {
final dLat = double.tryParse(pickupLat) ?? 0.0;
final dLng = double.tryParse(pickupLng) ?? 0.0;
if (dLat != 0 && dLng != 0) {
// Try Google Maps route distance first
final routeKm = await _getRouteDistanceKm(
dLat,
dLng,
riderLat,
riderLng,
);
if (routeKm != null && routeKm > 0) {
calculatedRiderKms = routeKm;
debugPrint(
'[Picked up] ✅ Route distance (Pickup Location): ${calculatedRiderKms.toStringAsFixed(4)} km',
);
} else {
// Fallback to straight-line
final distanceMeters = Geolocator.distanceBetween(
dLat,
dLng,
riderLat,
riderLng,
);
calculatedRiderKms = distanceMeters / 1000.0;
debugPrint(
'[Picked up] ⚠️ Fallback straight-line (Pickup Location): ${distanceMeters.toStringAsFixed(2)} meters = ${calculatedRiderKms.toStringAsFixed(4)} km',
);
}
}
}
} catch (e) {
debugPrint('[Picked up] Error calculating distance: $e');
}
}
// CRITICAL: Ensure riderkms is never 0 or null - use minimum value if calculation failed
// Even 10 meters should be shown (0.01 km)
if (calculatedRiderKms == 0.0) {
debugPrint(
'[Picked up] ⚠️ WARNING: Could not calculate distance, using minimum 0.01 km (10 meters)',
);
calculatedRiderKms = 0.01; // Minimum 10 meters
}
debugPrint(
'[Picked up] Final riderkms: ${calculatedRiderKms.toStringAsFixed(4)} km',
);
// ---- BONUS POINTS LOGIC ----
int bonusPoints = 0;
// Reset per stop: these describe the stop being closed right now, and a
// 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 {
final endKey = 'eta_endtime_$orderId';
final endSeconds = prefs.getInt(endKey);
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
: Duration(seconds: currentSeconds - endSeconds);
// If Picked up before or at the end time, give bonus points
if (currentSeconds <= endSeconds) {
// Bonus points = riderkms rounded
bonusPoints = calculatedRiderKms.round();
debugPrint(
'[Picked up] 🏆 ON TIME! Bonus Points: $bonusPoints (RiderKms: $calculatedRiderKms)',
);
} else {
debugPrint(
'[Picked up] ⏳ LATE! No Bonus Points. (Deadline: $endSeconds, Now: $currentSeconds)',
);
}
} else {
debugPrint('[Picked up] No ETA timer found for bonus logic.');
}
} catch (e) {
debugPrint('[Picked up] Error calculating bonus points: $e');
}
}
final double riderChargeRate = await _getRiderChargeRate();
// ---- SKIP PENALTY CHECK ----
// If the rider has exceeded skip limits in the last 3 hours, forfeit bonus points
final skipStatus = await checkSkipStatus();
if (skipStatus['penalty'] == true) {
if (bonusPoints > 0) {
debugPrint(
'[Picked up] ⚠️ Bonus points ($bonusPoints) forfeited due to skip penalty.',
);
bonusPoints = 0;
}
}
final double riderChargesAmountRaw =
riderChargeRate > 0 && calculatedRiderKms > 0
? calculatedRiderKms * riderChargeRate
: 0.0;
// Ensure ridercharges is sent with 2 decimal places (Decimal(10,2) style)
final double riderChargesAmount = double.parse(
riderChargesAmountRaw.toStringAsFixed(2),
);
// CRITICAL: Always set pickuptime to current time right before creating payload
// This ensures pickuptime is ALWAYS passed when confirming pickup, no matter what
// Setting it here (right before payload) ensures it's the exact time of confirmation
final currentTime = DateTime.now();
final currentPickupTime = _formatDateTimeFull(currentTime);
_pickedTime = currentPickupTime; // Update the instance variable too
debugPrint('[Picked up] Setting pickuptime to: $currentPickupTime');
// CRITICAL: Ensure riderkms is always a valid string, never null or empty
final riderKmsString = calculatedRiderKms > 0
? calculatedRiderKms.toStringAsFixed(
4,
) // Use 4 decimals to show even 10 meters (0.0100 km)
: '0.0100'; // Fallback minimum (10 meters)
// CRITICAL: Validate coordinates before creating payload - NEVER send '0' or null
final ridersLatStr = ll['lat'] ?? '0';
final ridersLngStr = ll['lng'] ?? '0';
final ridersLatDouble = double.tryParse(ridersLatStr) ?? 0.0;
final ridersLngDouble = double.tryParse(ridersLngStr) ?? 0.0;
if (ridersLatDouble == 0 ||
ridersLngDouble == 0 ||
ridersLatDouble.abs() > 90 ||
ridersLngDouble.abs() > 180) {
debugPrint(
'[Picked up] ❌ ERROR: Invalid rider coordinates (lat=$ridersLatStr, lng=$ridersLngStr) - cannot proceed',
);
pickedShimmer.value = false;
return false; // Don't send invalid coordinates
}
// Validate pickup coordinates
final pickupLatDouble = double.tryParse(pickupLat) ?? 0.0;
final pickupLngDouble = double.tryParse(pickupLng) ?? 0.0;
if (pickupLatDouble == 0 ||
pickupLngDouble == 0 ||
pickupLatDouble.abs() > 90 ||
pickupLngDouble.abs() > 180) {
debugPrint(
'[Picked up] ❌ ERROR: Invalid pickup coordinates (lat=$pickupLat, lng=$pickupLng) - cannot proceed',
);
pickedShimmer.value = false;
return false; // Don't send invalid coordinates
}
debugPrint(
'[Picked up] ✅ Validated coordinates - Rider: ($ridersLatStr, $ridersLngStr), Pickup: ($pickupLat, $pickupLng)',
);
final payload = <String, dynamic>{
'pickupid': pickupId,
'orderheaderid': orderHeaderId,
'pickuplocationid': pickupLocationId,
'orderstatus': 'Picked up',
'pickupedtime': currentPickupTime,
'pickuptime':
currentPickupTime, // ALWAYS pass current time - required field, set right before payload
'smspickup': smsPickup,
'riderslat': ridersLatStr, // Guaranteed non-zero and valid
'riderslon': ridersLngStr, // Guaranteed non-zero and valid
'raw_latitude': ll['raw_lat'] ?? '0',
'raw_longitude': ll['raw_lng'] ?? '0',
'velocity_lat': ll['velocity_lat'] ?? '0',
'velocity_lng': ll['velocity_lng'] ?? '0',
'speed': ll['speed'] ?? '0',
'heading': ll['heading'] ?? '0',
'pickuplat': pickupLat, // Guaranteed non-zero and valid
'pickuplong': pickupLng, // Guaranteed non-zero and valid
'riderkms':
riderKmsString, // ALWAYS pass riderkms - never null, shows even 10 meters (0.0100 km)
'ridercharges': riderChargesAmount,
'pickupamt': pickupAmount,
'collectionamt': collectionAmount,
'collectedamt': collectedAmount,
'collectionstatus': collectionStatus,
'ridertime': riderTimeMinutes,
'notes': notes,
'wasskipped': wasSkipped,
'bonuspts': bonusPoints, // ✅ ADDED BONUS POINTS
'dropimage': proofImage ?? '', // Include in payload
};
// Update observable for UI
lastBonusPoints.value = bonusPoints;
debugPrint('[Picked up] Payload includes pickuptime: $currentPickupTime');
debugPrint('[Picked up] Payload includes riderkms: $riderKmsString km');
debugPrint('[Picked up] Payload includes bonuspts: $bonusPoints');
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';
final actualLng = ll['lng'] ?? '0';
if (actualLat != '0' && actualLng != '0') {
await _saveLastPickupLocation(actualLat, actualLng);
}
// CRITICAL: Clean up cumulative distance tracking after pickup is completed
try {
final cumulativeKm =
prefs.getString('pickup_tracking_${pickupId}_cumulativeKm') ?? '';
if (cumulativeKm.isNotEmpty) {
debugPrint(
'[Picked up] 📊 Final cumulative distance used: $cumulativeKm km',
);
}
// Clean up tracking data
await prefs.remove('pickup_tracking_${pickupId}_lastLat');
await prefs.remove('pickup_tracking_${pickupId}_lastLng');
await prefs.remove('pickup_tracking_${pickupId}_cumulativeKm');
await prefs.remove('ridertime_start_$pickupId');
final activeTracking =
prefs.getStringList('active_tracking_pickup_ids') ?? [];
if (activeTracking.contains(pickupId.toString())) {
activeTracking.remove(pickupId.toString());
await prefs.setStringList(
'active_tracking_pickup_ids',
activeTracking,
);
}
// ✅ Clean up ETA timer
if (orderId.isNotEmpty) {
await prefs.remove('eta_endtime_$orderId');
}
debugPrint(
'[Picked up] 🧹 Cleaned up distance tracking for pickupId: $pickupId',
);
} catch (e) {
debugPrint('[Picked up] Error cleaning up tracking: $e');
}
}
// Reset foreground pickup notification flag
await setNotificationSent(false);
pickedShimmer.value = false;
if (!ok) {
debugPrint('[UPDATE][Picked up][FAILED] resp=${jsonEncode(resp)}');
} else {
// --- MQTT LOGIC ---
MilerMqttService().publishLog('pickup_completed', payload);
}
return ok;
} catch (e) {
debugPrint('[UPDATE][Picked up][ERROR] $e');
pickedShimmer.value = false;
return false;
}
}
// ---------------- Date/Time helpers ----------------
String _two(int n) => n.toString().padLeft(2, '0');
String _formatDateTimeFull(DateTime dt) {
final y = dt.year.toString();
final m = _two(dt.month);
final d = _two(dt.day);
final hh = _two(dt.hour);
final mm = _two(dt.minute);
final ss = _two(dt.second);
return "$y-$m-$d $hh:$mm:$ss";
}
// ---------------- SharedPrefs helpers ----------------
Future<void> setNotificationSent(bool value) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('notificationSent', value);
} catch (_) {}
}
// ---------------- Geofencing helpers ----------------
//
// The radius is [kGeofenceRadiusMeters] and nothing else. It read the
// server's `pickupradius` out of prefs, which meant the app's one honesty
// control was a per-tenant number nobody here could see — and it disagreed
// with Home's own hardcoded 500. `pickupradius` is still stored at login;
// it simply no longer decides this.
double get _geofenceRadius => kGeofenceRadiusMeters;
// 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, {
Color? bgColor,
int seconds = 4,
int retryCount = 0,
}) {
// Prevent infinite recursion
if (retryCount > 3) {
debugPrint('[GEOFENCE] Max retries reached for snackbar. Title: $title');
return;
}
debugPrint('[GEOFENCE] $title: $message');
try {
AppFeedback.errorGlobal(message);
} catch (e) {
debugPrint(
'[GEOFENCE] Error showing snackbar (attempt ${retryCount + 1}): $e',
);
debugPrint(
'[GEOFENCE] GetX context available: ${Get.key.currentContext != null}',
);
Future.delayed(const Duration(milliseconds: 400), () {
try {
AppFeedback.errorGlobal(message);
} catch (e2) {
debugPrint('[GEOFENCE] Retry snackbar failed: $e2');
if (retryCount < 2) {
Future.delayed(const Duration(milliseconds: 300), () {
_showErrorSnackbar(
title,
message,
bgColor: bgColor,
seconds: seconds,
retryCount: retryCount + 1,
);
});
}
}
});
}
}
/// 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;
/// Why the last `pickup-complete` was refused, in the server's own words.
///
/// Null after a successful pivot. Set only when the call came back not-OK,
/// so a caller can say what happened instead of leaving the rider sliding at
/// a stop the hub keeps rejecting. See [updatePickedStatus].
String? lastPickupRefusal;
Future<bool> _checkGeofence(
double targetLat,
double targetLng,
double currentLat,
double currentLng,
String action,
) async {
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) {
debugPrint(
'[GEOFENCE] OFF for $action — enforcement is disabled in this build. '
'Stop completion is NOT proximity-verified. '
'See kGeofenceEnforced.',
);
return true;
}
// Validate coordinates - ensure they are valid GPS coordinates
final bool hasValidTarget =
targetLat != 0 &&
targetLng != 0 &&
targetLat.abs() <= 90 &&
targetLng.abs() <= 180;
final bool hasValidCurrent =
currentLat != 0 &&
currentLng != 0 &&
currentLat.abs() <= 90 &&
currentLng.abs() <= 180;
// ── Two ways to have no coordinates, and only one of them is the rider's ──
//
// This used to treat both the same and wave both through: "Missing
// coordinates. Proceeding with update." That is the bypass that makes a
// fence decorative — turn location off and every rung opens — and it was
// survivable only because the fence itself was off.
//
// **No target.** The booking carries no pin. That is the hub's data, the
// rider cannot fix it from a doorstep, and blocking him leaves the stop
// unworkable by anyone. Allowed, and logged, exactly as before.
if (!hasValidTarget) {
debugPrint(
'[GEOFENCE] $action allowed: the stop carries no coordinates '
'($targetLat, $targetLng), so proximity cannot be checked. This is a '
'data gap on the booking, not a rider who is somewhere else.',
);
return true;
}
// **No fix.** Location is off, permission is denied, or the GPS did not
// settle in time. This one the rider *can* fix, and it is the difference
// between a fence and a suggestion — so it is refused, and the message
// says which of the three to go and change.
if (!hasValidCurrent) {
debugPrint(
'[GEOFENCE] $action refused: no usable fix '
'($currentLat, $currentLng)',
);
lastBlockedReason =
'Your phone could not find your location, so this stop cannot be '
'marked ${action.toLowerCase()}. Turn location on, allow it for '
'Miler, and step outside if you can.';
_showErrorSnackbar('Location Error', lastBlockedReason!, seconds: 5);
return false;
}
try {
final radius = _geofenceRadius;
final distance = Geolocator.distanceBetween(
targetLat,
targetLng,
currentLat,
currentLng,
);
final distanceKm = distance / 1000.0;
final distanceMeters = distance;
// ── The phone's own error is credited to the rider ──
//
// A fix carries an accuracy in metres, and at a 10 m fence that number
// is the same size as the thing being measured. Comparing a raw distance
// against 10 m asserts a precision the hardware did not provide, and the
// rider standing at the door on a ±25 m fix is the one it refuses.
//
// So the fence is measured against the *nearest point the phone allows*:
// 12 m away on a ±20 m fix has not been shown to be outside it. He is
// blocked when the phone says he is outside, not when the arithmetic
// does. See [kGeofenceRadiusMeters].
final slack = _lastFixAccuracy;
final effective = (distance - slack).clamp(0.0, double.infinity);
debugPrint(
'[GEOFENCE] $action | target ($targetLat, $targetLng) '
'| rider ($currentLat, $currentLng) '
'| ${distanceMeters.toStringAsFixed(1)}m ±${slack.toStringAsFixed(0)}m '
'→ ${effective.toStringAsFixed(1)}m vs ${radius.toStringAsFixed(0)}m',
);
if (effective > radius) {
// ── 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.toStringAsFixed(0)} m to mark it '
'${action.toLowerCase()}';
_showErrorSnackbar('Location Error', lastBlockedReason!, seconds: 5);
return false;
}
return true;
} catch (e) {
if (kDebugMode) {
debugPrint('[GEOFENCE] Error calculating distance: $e');
}
// On error, show warning but allow (fail-safe)
_showErrorSnackbar(
'Location Warning',
'Unable to verify distance. Proceeding with caution.',
bgColor: ColorConstants.warning,
seconds: 3,
);
return true; // Allow on error (fail-safe)
}
}
// Get last pickup location (for calculating riderkms between pickup)
Future<Map<String, String>> _getLastPickupLocation() async {
try {
final prefs = await SharedPreferences.getInstance();
final lat = prefs.getString('lastPickupLat') ?? '';
final lng = prefs.getString('lastPickupLng') ?? '';
if (lat.isNotEmpty && lng.isNotEmpty) {
return {'lat': lat, 'lng': lng};
}
} catch (_) {}
return {'lat': '', 'lng': ''};
}
// Save last pickup location (rider's actual GPS location, not destination)
Future<void> _saveLastPickupLocation(String lat, String lng) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('lastPickupLat', lat);
await prefs.setString('lastPickupLng', lng);
debugPrint('[TRACKING] Saved last pickup location: lat=$lat, lng=$lng');
} catch (e) {
debugPrint('[TRACKING] Error saving last pickup location: $e');
}
}
// Save start location for a specific pickup (when "Start Navigation" is slid)
Future<void> _savePickupStartLocation(
int pickupId,
String lat,
String lng,
) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('pickup_start_${pickupId}_lat', lat);
await prefs.setString('pickup_start_${pickupId}_lng', lng);
debugPrint(
'[TRACKING] Saved start location for pickup $pickupId: lat=$lat, lng=$lng',
);
} catch (e) {
debugPrint('[TRACKING] Error saving pickup start location: $e');
}
}
// Get start location for a specific pickup
Future<Map<String, String>> _getPickupStartLocation(int pickupId) async {
try {
final prefs = await SharedPreferences.getInstance();
final lat = prefs.getString('pickup_start_${pickupId}_lat') ?? '';
final lng = prefs.getString('pickup_start_${pickupId}_lng') ?? '';
if (lat.isNotEmpty && lng.isNotEmpty) {
return {'lat': lat, 'lng': lng};
}
} catch (_) {}
return {'lat': '', 'lng': ''};
}
// Get pickup location (for first pickup riderkms calculation)
Future<Map<String, String>> _getPickupLocation() async {
try {
final prefs = await SharedPreferences.getInstance();
final lat = prefs.getString('pickupLat') ?? '';
final lng = prefs.getString('pickupLng') ?? '';
if (lat.isNotEmpty && lng.isNotEmpty) {
return {'lat': lat, 'lng': lng};
}
} catch (_) {}
return {'lat': '', 'lng': ''};
}
/// Road distance in kilometres between two points, or null when the router
/// cannot answer — the caller then falls back to straight-line.
///
/// ── Why this stopped being a Google call ──
///
/// It asked the Directions API directly, with the same key that is revoked
/// everywhere else in this app. Every request came back `REQUEST_DENIED`, the
/// method logged it and returned null, and the caller quietly used the
/// straight-line distance instead. Nothing looked broken — but road distance
/// is what riders are *paid* on, and a crow-flies figure is short on every
/// trip that is not a straight road.
///
/// OSRM answers the same question for free. See [MilerRouter].
Future<double?> _getRouteDistanceKm(
double originLat,
double originLng,
double destLat,
double destLng,
) async {
// Validate coordinates.
if (originLat == 0 ||
originLng == 0 ||
destLat == 0 ||
destLng == 0 ||
originLat.abs() > 90 ||
originLng.abs() > 180 ||
destLat.abs() > 90 ||
destLng.abs() > 180) {
debugPrint('[ROUTE] Invalid coordinates for route calculation');
return null;
}
final metres = await MilerRouter.roadDistanceMetres(
LatLng(originLat, originLng),
LatLng(destLat, destLng),
);
if (metres == null) {
debugPrint(
'[ROUTE] No road distance — caller falls back to a straight line.',
);
return null;
}
final km = metres / 1000.0;
debugPrint('[ROUTE] Road distance: ${km.toStringAsFixed(4)} km');
return km;
}
// Save pickup location
Future<void> _savePickupLocation(String lat, String lng) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('pickupLat', lat);
await prefs.setString('pickupLng', lng);
debugPrint('[TRACKING] Saved pickup location: lat=$lat, lng=$lng');
} catch (e) {
debugPrint('[TRACKING] Error saving pickup location: $e');
}
}
Future<double> _getRiderChargeRate() async {
try {
final prefs = await SharedPreferences.getInstance();
// Prefer new fuelcharge key; fall back to legacy firstmilecharge if needed
Object? raw = prefs.get('fuelcharge');
raw ??= prefs.get('firstmilecharge');
if (raw is double) return raw;
if (raw is int) return raw.toDouble();
if (raw is String) {
return double.tryParse(raw) ?? 0.0;
}
} catch (_) {}
return 0.0;
}
// ---------------- Status Updates ----------------
// Accepted
Future<bool> updateAcceptedStatus({
required int pickupId,
required int orderHeaderId,
String ridersLat = '0',
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());
final payload = <String, dynamic>{
'pickupid': pickupId,
'orderheaderid': orderHeaderId,
'orderstatus': 'accepted',
'acceptedtime': acceptedTime,
'riderslat': ll['lat'],
'riderslon': ll['lng'],
'raw_latitude': ll['raw_lat'] ?? '0',
'raw_longitude': ll['raw_lng'] ?? '0',
'velocity_lat': ll['velocity_lat'] ?? '0',
'velocity_lng': ll['velocity_lng'] ?? '0',
'speed': ll['speed'] ?? '0',
'heading': ll['heading'] ?? '0',
'pickuplat': '',
'pickuplong': '',
'actualkms': '',
'pickupamt': 0.0,
'notes': notes,
};
final resp = await _updateProvider.updatePickup(payload);
final ok = _isSuccess(resp);
if (!ok) {
// Log entire response for debugging
try {
debugPrint('[UPDATE][ACCEPTED][FAILED] resp=${jsonEncode(resp)}');
} catch (_) {}
}
return ok;
} catch (e) {
debugPrint('[UPDATE][ACCEPTED][ERROR] $e');
return false;
}
}
// Active (start navigation)
Future<bool> updateActiveStatus({
required int pickupId,
required int orderHeaderId,
String ridersLat = '0',
String ridersLng = '0',
String notes = '',
String orderId = '',
}) async {
final mock = _mockStatus(pickupId, 'active');
if (mock != null) return mock;
try {
final now = DateTime.now();
_activeTime = _formatDateTimeFull(now);
final prefs = await SharedPreferences.getInstance();
String startTime = prefs.getString('pickup_starttime_$pickupId') ?? '';
if (startTime.isEmpty) {
startTime = _formatDateTimeFull(now);
} else {
debugPrint('[UPDATE][ACTIVE] Using preserved starttime: $startTime');
}
// Ensure we never send 0/0 for rider location.
Map<String, String> ll = await _ensureLatLng(ridersLat, ridersLng);
if ((ll['lat'] ?? '0') == '0' || (ll['lng'] ?? '0') == '0') {
try {
final last = await Geolocator.getLastKnownPosition();
if (last != null) {
ll = {
'lat': last.latitude.toStringAsFixed(6),
'lng': last.longitude.toStringAsFixed(6),
};
}
} catch (_) {}
}
if ((ll['lat'] ?? '0') == '0' || (ll['lng'] ?? '0') == '0') {
try {
final pos = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.low,
timeLimit: const Duration(seconds: 3),
);
ll = {
'lat': pos.latitude.toStringAsFixed(6),
'lng': pos.longitude.toStringAsFixed(6),
};
} catch (_) {}
}
// If we still don't have a valid location, bail out to avoid sending 0/0.
if ((ll['lat'] ?? '0') == '0' || (ll['lng'] ?? '0') == '0') {
debugPrint('[UPDATE][ACTIVE] No valid rider coordinates; aborting');
return false;
}
final payload = <String, dynamic>{
'pickupid': pickupId,
'orderheaderid': orderHeaderId,
'orderstatus': 'active',
'activetime': _activeTime,
'starttime': startTime, // Add starttime when status changes to active
'activelat': ll['lat'],
'activelon': ll['lng'],
'riderslat': ll['lat'],
'riderslon': ll['lng'],
'raw_latitude': ll['raw_lat'] ?? '0',
'raw_longitude': ll['raw_lng'] ?? '0',
'velocity_lat': ll['velocity_lat'] ?? '0',
'velocity_lng': ll['velocity_lng'] ?? '0',
'speed': ll['speed'] ?? '0',
'heading': ll['heading'] ?? '0',
'pickuplat': '',
'pickuplong': '',
'actualkms': '',
'pickupamt': 0.0,
'notes': notes,
};
final resp = await _updateProvider.updatePickup(payload);
final ok = _isSuccess(resp);
if (ok) {
final lat = ll['lat'] ?? '0';
final lng = ll['lng'] ?? '0';
if (lat != '0' && lng != '0') {
await _savePickupStartLocation(pickupId, lat, lng);
}
// Save starttime to SharedPreferences for pickup logs (using pickupId as key)
// This will be retrieved when creating pickup log payloads
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('pickup_starttime_$pickupId', startTime);
if (orderId.isNotEmpty) {
await prefs.setString('current_riding_order_id', orderId);
}
await prefs.setString(
'current_riding_pickup_id',
pickupId.toString(),
);
debugPrint(
'[UPDATE][ACTIVE] Saved starttime: $startTime for pickupId: $pickupId',
);
// CRITICAL: Initialize cumulative distance tracking when pickup becomes active
// Reset any previous tracking data and set initial location
await prefs.setString('pickup_tracking_${pickupId}_lastLat', lat);
await prefs.setString('pickup_tracking_${pickupId}_lastLng', lng);
await prefs.setString(
'pickup_tracking_${pickupId}_cumulativeKm',
'0.0',
);
final activeTracking =
prefs.getStringList('active_tracking_pickup_ids') ?? [];
if (!activeTracking.contains(pickupId.toString())) {
activeTracking.add(pickupId.toString());
await prefs.setStringList(
'active_tracking_pickup_ids',
activeTracking,
);
}
debugPrint(
'[UPDATE][ACTIVE] 🚀 Initialized distance tracking for pickupId: $pickupId at ($lat, $lng)',
);
} catch (e) {
debugPrint('[UPDATE][ACTIVE] Error saving starttime: $e');
}
} else {
debugPrint('[UPDATE][ACTIVE][FAILED] resp=${jsonEncode(resp)}');
}
return ok;
} catch (e) {
debugPrint('[UPDATE][ACTIVE][ERROR] $e');
return false;
}
}
// 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;
/// Set when the server **refused** the arrival, as opposed to never hearing
/// it. Null on success and on a network failure.
///
/// ── The distinction the caller needs ──
///
/// `updateArrivedStatus` answers false for both a 4xx and a dead network, and
/// those want opposite handling: a rider with no signal at a kitchen door must
/// carry on, and a rider whose arrival the server rejected on a business rule
/// must not be shown as arrived. `ApiResult.status` is 0 when nothing came
/// back and the HTTP code when something did, which is the whole test.
String? lastArrivalRefusal;
Future<bool> updateArrivedStatus({
required int pickupId,
required int orderHeaderId,
String ridersLat = '0',
String ridersLng = '0',
String pickupLat = '0',
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 {
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 pLat = double.tryParse(pickupLat) ?? 0.0;
final pLng = double.tryParse(pickupLng) ?? 0.0;
// Geofence Check: Arrived -> Pickup Location
final inFence = await _checkGeofence(pLat, pLng, rLat, rLng, 'Arrived');
if (!inFence) {
arrivedShimmer.value = false;
return false;
}
final now = DateTime.now();
_arrivedTime = _formatDateTimeFull(now);
final payload = <String, dynamic>{
'pickupid': pickupId,
'orderheaderid': orderHeaderId,
'orderstatus': 'arrived',
'arrivaltime': _arrivedTime,
'riderslat': ll['lat'],
'riderslon': ll['lng'],
'raw_latitude': ll['raw_lat'] ?? '0',
'raw_longitude': ll['raw_lng'] ?? '0',
'velocity_lat': ll['velocity_lat'] ?? '0',
'velocity_lng': ll['velocity_lng'] ?? '0',
'speed': ll['speed'] ?? '0',
'heading': ll['heading'] ?? '0',
'pickuplat': '',
'pickuplong': '',
'actualkms': '',
'pickupamt': 0.0,
'notes': notes,
};
final resp = await _updateProvider.updatePickup(payload);
final ok = _isSuccess(resp);
// Stop shimmer immediately after response
arrivedShimmer.value = false;
if (!ok) {
// A code at or above 400 is the server answering. Anything else — 0
// most often — is the request never getting there.
final code = int.tryParse('${resp?['code'] ?? 0}') ?? 0;
lastArrivalRefusal = code >= 400
? (resp?['message']?.toString().trim().isNotEmpty == true
? resp!['message'].toString()
: 'Your office would not accept this arrival.')
: null;
debugPrint('[UPDATE][ARRIVED][FAILED] resp=${jsonEncode(resp)}');
return false;
}
lastArrivalRefusal = null;
// ── 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 office 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;
return false;
}
}
// ═══════════════════════════════════════════════════════════════════════
// 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,
required int orderHeaderId,
String ridersLat = '0',
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>{
'pickupid': pickupId,
'orderheaderid': orderHeaderId,
'orderstatus': 'rejected',
'riderslat': ll['lat'],
'riderslon': ll['lng'],
'raw_latitude': ll['raw_lat'] ?? '0',
'raw_longitude': ll['raw_lng'] ?? '0',
'velocity_lat': ll['velocity_lat'] ?? '0',
'velocity_lng': ll['velocity_lng'] ?? '0',
'speed': ll['speed'] ?? '0',
'heading': ll['heading'] ?? '0',
'pickuplat': '',
'pickuplong': '',
'actualkms': '',
'pickupamt': 0.0,
'notes': notes,
};
final resp = await _updateProvider.updatePickup(payload);
final ok = _isSuccess(resp);
if (!ok) {
debugPrint('[UPDATE][REJECTED][FAILED] resp=${jsonEncode(resp)}');
}
return ok;
} catch (e) {
debugPrint('[UPDATE][REJECTED][ERROR] $e');
return false;
}
}
// Cancelled (pickup cancelled by rider)
Future<bool> updateCancelledStatus({
required int pickupId,
required int orderHeaderId,
String ridersLat = '0',
String ridersLng = '0',
String pickupLat = '0',
String pickupLng = '0',
String notes = '',
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);
final ll = await _ensureLatLng(ridersLat, ridersLng);
final riderLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0;
final riderLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0;
final dLat = double.tryParse(pickupLat) ?? 0.0;
final dLng = double.tryParse(pickupLng) ?? 0.0;
// ✅ SPECIAL CASE: Skip geofence check for "Incorrect Location" cancellation
// When user cancels due to incorrect location, we don't verify range
final bool isIncorrectLocation = notes.toLowerCase().contains(
'incorrect location',
);
if (isIncorrectLocation) {
debugPrint(
'[CANCELLED] ⚠️ Skipping geofence check - Reason: Incorrect Location',
);
} else {
// Geofence Check: Cancelled -> Pickup Location (for all other reasons)
final inFence = await _checkGeofence(
dLat,
dLng,
riderLat,
riderLng,
'Cancelled',
);
if (!inFence) {
debugPrint(
'[CANCELLED] ❌ Geofence check failed - Rider not in range',
);
return false;
}
}
// PRIORITY 1: Use cumulative distance tracked from active to cancelled (most accurate)
// This is the actual distance the rider traveled, calculated from GPS points every 30 seconds
double calculatedKms = 0.0;
try {
final prefs = await SharedPreferences.getInstance();
final cumulativeKmStr =
prefs.getString('pickup_tracking_${pickupId}_cumulativeKm') ?? '';
if (cumulativeKmStr.isNotEmpty) {
final cumulativeKm = double.tryParse(cumulativeKmStr) ?? 0.0;
if (cumulativeKm > 0) {
calculatedKms = cumulativeKm;
debugPrint(
'[CANCELLED] ✅ Using CUMULATIVE tracked distance: ${calculatedKms.toStringAsFixed(4)} km',
);
debugPrint(
'[CANCELLED] 📊 This is the actual distance traveled from active to cancelled',
);
}
}
} catch (e) {
debugPrint('[CANCELLED] Error getting cumulative distance: $e');
}
// PRIORITY 2: Fallback to actualKms if cumulative not available
if (calculatedKms == 0.0) {
calculatedKms = actualKms;
}
if (wasSkipped && calculatedKms == 0.0) {
try {
final lastPickup = await _getLastPickupLocation();
final lastLat = double.tryParse(lastPickup['lat'] ?? '') ?? 0.0;
final lastLng = double.tryParse(lastPickup['lng'] ?? '') ?? 0.0;
if (lastLat != 0 && lastLng != 0 && riderLat != 0 && riderLng != 0) {
final distanceMeters = Geolocator.distanceBetween(
lastLat,
lastLng,
riderLat,
riderLng,
);
calculatedKms = distanceMeters / 1000.0;
debugPrint(
'[CANCELLED] Skipped pickup - rider travelled ${calculatedKms.toStringAsFixed(2)} km from last stop to cancellation point',
);
}
} catch (e) {
debugPrint('[CANCELLED] Error calculating skipped distance: $e');
}
}
if (calculatedKms == 0.0) {
try {
// ANTI-CHEAT: Try to get the locked "Start Location" for this pickup
final startLoc = await _getPickupStartLocation(pickupId);
final startLatStr = startLoc['lat'] ?? '';
final startLngStr = startLoc['lng'] ?? '';
if (startLatStr.isNotEmpty && startLngStr.isNotEmpty) {
final startLat = double.tryParse(startLatStr) ?? 0.0;
final startLng = double.tryParse(startLngStr) ?? 0.0;
if (startLat != 0 &&
startLng != 0 &&
riderLat != 0 &&
riderLng != 0) {
// Try Google Maps route distance first (accurate road distance)
final routeKm = await _getRouteDistanceKm(
startLat,
startLng,
riderLat,
riderLng,
);
if (routeKm != null && routeKm > 0) {
calculatedKms = routeKm;
debugPrint(
'[CANCELLED] ✅ Route distance (Anti-Cheat): ${calculatedKms.toStringAsFixed(4)} km from Start Location to cancellation point',
);
} else {
// Fallback to straight-line if API fails
final distanceMeters = Geolocator.distanceBetween(
startLat,
startLng,
riderLat,
riderLng,
);
calculatedKms = distanceMeters / 1000.0;
debugPrint(
'[CANCELLED] ⚠️ Fallback straight-line (Anti-Cheat): ${calculatedKms.toStringAsFixed(4)} km',
);
}
}
} else {
// Fallback to Pickup -> Current
final pickLat = double.tryParse(pickupLat) ?? 0.0;
final pickLng = double.tryParse(pickupLng) ?? 0.0;
if (pickLat != 0 &&
pickLng != 0 &&
riderLat != 0 &&
riderLng != 0) {
// Try Google Maps route distance first
final routeKm = await _getRouteDistanceKm(
pickLat,
pickLng,
riderLat,
riderLng,
);
if (routeKm != null && routeKm > 0) {
calculatedKms = routeKm;
debugPrint(
'[CANCELLED] ✅ Route distance: ${calculatedKms.toStringAsFixed(4)} km from pickup to cancellation point',
);
} else {
// Fallback to straight-line if API fails
final distanceMeters = Geolocator.distanceBetween(
pickLat,
pickLng,
riderLat,
riderLng,
);
calculatedKms = distanceMeters / 1000.0;
debugPrint(
'[CANCELLED] ⚠️ Fallback straight-line: ${calculatedKms.toStringAsFixed(4)} km',
);
}
}
}
} catch (e) {
debugPrint('[CANCELLED] Error calculating distance: $e');
}
}
final double riderChargeRate = await _getRiderChargeRate();
final double riderChargesAmountRaw =
riderChargeRate > 0 && calculatedKms > 0
? calculatedKms * riderChargeRate
: 0.0;
final double riderChargesAmount = double.parse(
riderChargesAmountRaw.toStringAsFixed(2),
);
if (riderChargeRate > 0) {
debugPrint(
'[CANCELLED] Applying rider charge rate $riderChargeRate => ridercharges ${riderChargesAmount.toStringAsFixed(2)}',
);
}
// CRITICAL: Clean up cumulative distance tracking after cancellation
try {
final prefs = await SharedPreferences.getInstance();
final cumulativeKm =
prefs.getString('pickup_tracking_${pickupId}_cumulativeKm') ?? '';
if (cumulativeKm.isNotEmpty) {
debugPrint(
'[CANCELLED] 📊 Final cumulative distance used: $cumulativeKm km',
);
}
// Clean up tracking data
await prefs.remove('pickup_tracking_${pickupId}_lastLat');
await prefs.remove('pickup_tracking_${pickupId}_lastLng');
await prefs.remove('pickup_tracking_${pickupId}_cumulativeKm');
final activeTracking =
prefs.getStringList('active_tracking_pickup_ids') ?? [];
if (activeTracking.contains(pickupId.toString())) {
activeTracking.remove(pickupId.toString());
await prefs.setStringList(
'active_tracking_pickup_ids',
activeTracking,
);
}
debugPrint(
'[CANCELLED] 🧹 Cleaned up distance tracking for pickupId: $pickupId',
);
} catch (e) {
debugPrint('[CANCELLED] Error cleaning up tracking: $e');
}
// CRITICAL: Validate coordinates before creating payload - NEVER send '0' or null
final ridersLatStr = ll['lat'] ?? '0';
final ridersLngStr = ll['lng'] ?? '0';
final ridersLatDouble = double.tryParse(ridersLatStr) ?? 0.0;
final ridersLngDouble = double.tryParse(ridersLngStr) ?? 0.0;
if (ridersLatDouble == 0 ||
ridersLngDouble == 0 ||
ridersLatDouble.abs() > 90 ||
ridersLngDouble.abs() > 180) {
debugPrint(
'[CANCELLED] ❌ ERROR: Invalid rider coordinates (lat=$ridersLatStr, lng=$ridersLngStr) - cannot proceed',
);
return false; // Don't send invalid coordinates
}
debugPrint(
'[CANCELLED] ✅ Validated coordinates - Rider: ($ridersLatStr, $ridersLngStr)',
);
final payload = <String, dynamic>{
'pickupid': pickupId,
'orderheaderid': orderHeaderId,
'orderstatus': 'cancelled',
'canceltime': _cancelledTime,
'riderslat': ridersLatStr, // Guaranteed non-zero and valid
'riderslon': ridersLngStr, // Guaranteed non-zero and valid
'raw_latitude': ll['raw_lat'] ?? '0',
'raw_longitude': ll['raw_lng'] ?? '0',
'velocity_lat': ll['velocity_lat'] ?? '0',
'velocity_lng': ll['velocity_lng'] ?? '0',
'speed': ll['speed'] ?? '0',
'heading': ll['heading'] ?? '0',
'pickuplat': '',
'pickuplong': '',
'riderkms': calculatedKms.toStringAsFixed(2),
'ridercharges': riderChargesAmount,
'pickupamt': 0.0,
'notes': notes,
};
final resp = await _updateProvider.updatePickup(payload);
final ok = _isSuccess(resp);
if (ok) {
// Update last pickup location to current location so next Booking starts here
final actualLat = ll['lat'] ?? '0';
final actualLng = ll['lng'] ?? '0';
if (actualLat != '0' && actualLng != '0') {
await _saveLastPickupLocation(actualLat, actualLng);
}
}
if (!ok) {
debugPrint('[UPDATE][CANCELLED][FAILED] resp=${jsonEncode(resp)}');
}
return ok;
} catch (e) {
debugPrint('[UPDATE][CANCELLED][ERROR] $e');
return false;
}
}
// Skipped (pickup skipped by rider)
Future<bool> updateSkippedStatus({
required int pickupId,
required int orderHeaderId,
String ridersLat = '0',
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());
final payload = <String, dynamic>{
'pickupid': pickupId,
'orderheaderid': orderHeaderId,
'orderstatus': 'skipped',
'skippedtime': skippedTime,
'riderslat': ll['lat'],
'riderslon': ll['lng'],
'pickuplat': '',
'pickuplong': '',
'actualkms': '',
'pickupamt': 0.0,
'notes': notes,
};
// Calculate distance for skipped Booking (Anti-Cheat)
double calculatedKms = 0.0;
try {
final riderLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0;
final riderLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0;
if (riderLat != 0 && riderLng != 0) {
// Try Start Location first
final startLoc = await _getPickupStartLocation(pickupId);
final startLatStr = startLoc['lat'] ?? '';
final startLngStr = startLoc['lng'] ?? '';
if (startLatStr.isNotEmpty && startLngStr.isNotEmpty) {
final startLat = double.tryParse(startLatStr) ?? 0.0;
final startLng = double.tryParse(startLngStr) ?? 0.0;
if (startLat != 0 && startLng != 0) {
// Try Google Maps route distance first (accurate road distance)
final routeKm = await _getRouteDistanceKm(
startLat,
startLng,
riderLat,
riderLng,
);
if (routeKm != null && routeKm > 0) {
calculatedKms = routeKm;
debugPrint(
'[SKIPPED] ✅ Route distance (Start Location): ${calculatedKms.toStringAsFixed(4)} km',
);
} else {
// Fallback to straight-line if API fails
final distanceMeters = Geolocator.distanceBetween(
startLat,
startLng,
riderLat,
riderLng,
);
calculatedKms = distanceMeters / 1000.0;
debugPrint(
'[SKIPPED] ⚠️ Fallback straight-line (Start Location): ${calculatedKms.toStringAsFixed(4)} km',
);
}
}
} else {
// Fallback to Last Pickup Location
final lastPickup = await _getLastPickupLocation();
final lastLat = double.tryParse(lastPickup['lat'] ?? '') ?? 0.0;
final lastLng = double.tryParse(lastPickup['lng'] ?? '') ?? 0.0;
if (lastLat != 0 && lastLng != 0) {
// Try Google Maps route distance first
final routeKm = await _getRouteDistanceKm(
lastLat,
lastLng,
riderLat,
riderLng,
);
if (routeKm != null && routeKm > 0) {
calculatedKms = routeKm;
debugPrint(
'[SKIPPED] ✅ Route distance (Last Pickup): ${calculatedKms.toStringAsFixed(4)} km',
);
} else {
// Fallback to straight-line if API fails
final distanceMeters = Geolocator.distanceBetween(
lastLat,
lastLng,
riderLat,
riderLng,
);
calculatedKms = distanceMeters / 1000.0;
debugPrint(
'[SKIPPED] ⚠️ Fallback straight-line (Last Pickup): ${calculatedKms.toStringAsFixed(4)} km',
);
}
}
}
}
} catch (e) {
debugPrint('[SKIPPED] Error calculating distance: $e');
}
if (calculatedKms > 0) {
payload['riderkms'] = calculatedKms.toStringAsFixed(2);
debugPrint('[SKIPPED] Calculated riderkms: ${payload['riderkms']}');
}
final resp = await _updateProvider.updatePickup(payload);
final ok = _isSuccess(resp);
if (ok) {
// Update last pickup location to current location so next Booking starts here
final actualLat = ll['lat'] ?? '0';
final actualLng = ll['lng'] ?? '0';
if (actualLat != '0' && actualLng != '0') {
await _saveLastPickupLocation(actualLat, actualLng);
}
}
if (!ok) {
debugPrint('[UPDATE][SKIPPED][FAILED] resp=${jsonEncode(resp)}');
}
return ok;
} catch (e) {
debugPrint('[UPDATE][SKIPPED][ERROR] $e');
return false;
}
}
/// Check if there are active pickup
/// Returns true ONLY if has_live_pickup is true (verified by API)
/// This ensures PiP is only enabled when there are actually active pickup
Future<bool> hasActivePickups() async {
try {
final prefs = await SharedPreferences.getInstance();
final hasLive = prefs.getBool('has_live_pickup') ?? false;
// ✅ CRITICAL: Only return true if has_live_pickup is true
// Don't rely on active_pickup_order_id alone - it may be stale
// The API sets has_live_pickup based on actual Booking status = "active"
if (!hasLive) {
// Clear stale active_pickup_order_id if has_live_pickup is false
final activeOrderId = prefs.getString('active_pickup_order_id');
if (activeOrderId != null && activeOrderId.isNotEmpty) {
debugPrint(
'[PICKUPS_CONTROLLER] Clearing stale active_pickup_order_id: $activeOrderId (no active pickup)',
);
await prefs.remove('active_pickup_order_id');
}
}
debugPrint(
'[PICKUPS_CONTROLLER] hasActivePickups: $hasLive (has_live_pickup from API)',
);
return hasLive;
} catch (e) {
debugPrint('[PICKUPS_CONTROLLER] Error checking active pickup: $e');
return false;
}
}
// ---------------- Skip Penalty Logic ----------------
/// Checks the current skip count and penalty status within the 3-hour window.
/// Resets the window if 3 hours have passed since the first skip.
Future<Map<String, dynamic>> checkSkipStatus() async {
try {
final prefs = await SharedPreferences.getInstance();
final now = DateTime.now();
final startStr = prefs.getString('skip_window_start');
int count = prefs.getInt('skip_count') ?? 0;
bool penalty = prefs.getBool('skip_penalty_active') ?? false;
if (startStr != null) {
final start = DateTime.tryParse(startStr);
if (start != null) {
final diff = now.difference(start);
if (diff.inHours >= 3) {
debugPrint(
'[SKIP_LOGIC] 3-hour window expired (elapsed: ${diff.inMinutes}m). Resetting skips.',
);
// Reset window
count = 0;
penalty = false;
await prefs.remove('skip_window_start');
await prefs.remove('skip_count');
await prefs.remove('skip_penalty_active');
}
} else {
// Invalid date string, reset
await prefs.remove('skip_window_start');
}
}
return {'count': count, 'penalty': penalty};
} catch (e) {
debugPrint('[SKIP_LOGIC] Error checking status: $e');
return {'count': 0, 'penalty': false};
}
}
/// Registers a skip action.
/// Increments skip count and sets penalty if specified.
/// Starts 3-hour window if not already active.
Future<void> registerSkip({bool applyPenalty = false}) async {
try {
final prefs = await SharedPreferences.getInstance();
final now = DateTime.now();
// Ensure we are working with fresh/current state
await checkSkipStatus();
// Re-fetch after potential reset
int count = prefs.getInt('skip_count') ?? 0;
String? startStr = prefs.getString('skip_window_start');
if (startStr == null) {
// Start new window
await prefs.setString('skip_window_start', now.toIso8601String());
debugPrint('[SKIP_LOGIC] Starting new 3-hour skip window.');
}
count++;
await prefs.setInt('skip_count', count);
if (applyPenalty) {
await prefs.setBool('skip_penalty_active', true);
debugPrint('[SKIP_LOGIC] Penalty activated for this session.');
}
debugPrint(
'[SKIP_LOGIC] Skip registered. Count: $count, Penalty: $applyPenalty',
);
} catch (e) {
debugPrint('[SKIP_LOGIC] Error registering skip: $e');
}
}
}