2686 lines
104 KiB
Dart
2686 lines
104 KiB
Dart
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/views/helpers/constants/apiconstants.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";
|
||
}
|
||
|
||
String _resolveUpdateUrl() {
|
||
return ApiConstants.mainRoute == 'live'
|
||
? ApiConstants.updatePickupLive
|
||
: ApiConstants.updatePickupDev;
|
||
}
|
||
|
||
// ---------------- 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');
|
||
}
|
||
}
|
||
}
|