Files
doormile_milderapp/lib/controllers/pickups_controller.dart
Thiru-tenext d612916fe4 Session expiry, arrival geofence guard, multi-destination stops
Three fixes found by running the app on a real handset against production.

1. An expired token left the app looking signed in and unable to work.
   MilerApi.onUnauthorized was declared and called on every 401 but never
   assigned, so the token was dropped and nothing else happened: the profile
   stayed on disk, logged_out stayed false, and the rider saw his own name over
   a dashboard whose every call returned 401. He reads that as "no work today".
   The teardown now lives in endSession() and both ways out of a session — the
   Log out button and the 401 path — use it.

2. Arrived was written locally even when the rider was not there.
   updateArrivedStatus answers false for three different things and the caller
   treated all of them as "the write did not land", which is only true of one.
   A geofence refusal and a server refusal now stop the rung and hand back the
   reason; a dead network still advances, as it should.

3. A multi-destination customer pickup collapsed onto one stop.
   GET /miler/bookings returns a row per destination once collected, all with
   the same bookingid and reference. Every local store keys on that id, so the
   accepted store deduped two of three drops away and their consignment ids
   were unrecoverable. orderid is now the stop key; bookingreference stays the
   booking's name. Cards show "Stop 2 of 3" and the receiver's own name and
   number rather than the sender's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
2026-09-18 11:05:40 +05:30

2520 lines
96 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:miler/data/geofence.dart';
import 'package:miler/data/work_repository.dart';
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart' show LatLng;
import 'package:miler/helpers/miler_router.dart';
import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/data/accepted_store.dart';
import 'package:geolocator/geolocator.dart';
import 'package:miler/providers/pickuplog/pickuplog_provider.dart';
import 'dart:io';
import 'package:minio/minio.dart';
import 'package:minio/io.dart';
import 'package:miler/utils/kalman_filter.dart';
import 'package:miler/utils/mqtt_service.dart';
import 'package:miler/controllers/connectivity_mixin.dart';
import 'package:miler/data/meal_run_mock.dart';
import 'package:miler/views/helpers/widgets/app_widgets.dart';
/// ── PROXIMITY ENFORCEMENT IS ON ──
///
/// The fence itself moved to `lib/data/geofence.dart` on 2026-09-16. What used
/// to live here — four constants, a `_freshFix` ladder and a 130-line
/// `_checkGeofence` — could only be reached from a `GetxController`, so Home's
/// bulk gate grew a second copy of the arithmetic with a different radius, and
/// none of it could be tested without a real handset.
///
/// [Geofence] is that logic, extracted and corrected. Four things changed with
/// it, and each one was a way a rider could mark a stop he was not standing at:
///
/// * **It is on.** `kGeofenceEnforced` defaulted to `false`, so
/// `_checkGeofence` returned `true` on its third line in every shipped
/// build.
/// * **The radius is 100 m, not 10.** Ten metres is inside consumer-GPS error,
/// which is what got the fence switched off twice before.
/// * **It fails closed.** The old `catch` returned `true` with the comment
/// "allow on error (fail-safe)". Any throw from the location stack opened
/// every rung in the app.
/// * **The phone's error is no longer credited to the rider.** The comparison
/// was `distance - accuracy > radius`, so slack grew with inaccuracy and a
/// ±250 m fix bought 250 m of it. A fix too vague to resolve the fence is
/// refused instead — see `kGeofenceMaxAccuracyMetres`.
///
/// What is left in this file is the wiring: [_checkGeofence] adapts [Geofence]
/// to the eight call sites below, and every one of them returns **before**
/// building a payload when it answers false. That is asserted end-to-end in
/// `geofence_test.dart` with an HTTP client that fails the test if any request
/// is sent.
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.
///
/// Telemetry only. The fence no longer reads this — [Geofence] takes its own
/// fix and rejects one it cannot trust rather than crediting its error to the
/// rider. Kept because the payload builders log it.
double _lastFixAccuracy = 0;
/// A position good enough to put on a status payload.
///
/// ── 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 ${kGeofenceRadiusMetres.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);
// ── These shortcuts are safe again, because nothing is decided on them ──
//
// They used to be gated on the fence being off, and rightly: the fence
// measured with whatever this returned, and both shortcuts hand back a
// position of unknown provenance — the first trusts whatever the screen
// passed in (Home passes a `LocationAccuracy.low` fix, a ~1 km hint on
// Android), the second reuses a cached value with no age on it at all.
//
// [Geofence] takes its own `best` fix now and characterises it before
// deciding, so this method is back to what its name says: the coordinates
// that go **on the payload**. Telemetry, not evidence. Taking a second
// 8-second fix for it would cost the rider time at every door to improve
// a number nobody adjudicates.
// 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)
if (currentLat.value.isNotEmpty &&
currentLat.value != '0' &&
currentLng.value.isNotEmpty &&
currentLng.value != '0') {
return {'lat': currentLat.value, 'lng': currentLng.value};
}
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) return {'lat': outLat, 'lng': outLng};
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
return {'lat': outLat, 'lng': outLng};
}
final pos = await _freshFix();
if (pos != null) {
final now = DateTime.now();
double outLatDouble = pos.latitude;
double outLngDouble = pos.longitude;
if (_kf == null) {
_kf = MilerKalmanFilter(lat: outLatDouble, lng: outLngDouble);
} else {
final double dt = _lastUpdateTime != null
? now.difference(_lastUpdateTime!).inMilliseconds / 1000.0
: 30.0;
_kf!.predict(dt);
_kf!.update(outLatDouble, outLngDouble);
outLatDouble = _kf!.x[0];
outLngDouble = _kf!.x[1];
}
_lastUpdateTime = now;
outLat = outLatDouble.toStringAsFixed(6);
outLng = outLngDouble.toStringAsFixed(6);
currentLat.value = outLat;
currentLng.value = outLng;
}
return {'lat': outLat, 'lng': outLng};
} catch (_) {
return {'lat': outLat, 'lng': outLng};
}
}
// Picked
/// ── The meal line has nothing to post to ──
///
/// There are no meal endpoints. Every status method below would otherwise
/// build a payload and fire it at a parcel URL that has never heard of a
/// crate — and, worse, run the proximity check against a mock address in
/// Coimbatore, so a rider walking the flow anywhere else is blocked at the
/// first door by a fence guarding fictional coordinates.
///
/// So on that line the status is recorded in [MealRunMock] and reported as
/// sent. The flow is identical; only the wire is absent. Returns null when
/// this is a real parcel call, so the caller carries on.
///
/// See [MealRunMock] for why this cannot reach a parcel rider.
bool? _mockStatus(int pickupId, String status) {
if (!MealRunMock.active) return null;
lastBlockedReason = null;
return MealRunMock.setStatusByPickupId(pickupId, status);
}
Future<bool> updatePickedStatus({
required int pickupId,
required int orderHeaderId,
required int pickupLocationId,
/// ── The key the delivery leg will look this stop up by ──
///
/// `pickup-complete` is what creates the consignment, and the id it returns
/// is filed against this order so the door can find it later. The payload
/// carried no `orderid`, so the provider fell back to the *booking* id —
/// and `closeDelivery` reads the map by **order** id. Every collected stop
/// was therefore filed under a key nothing would ever ask for, and pressing
/// **Delivered** answered "this order was never picked up on the system"
/// for a bag the rider was holding. See [rememberConsignmentId].
String orderId = '',
String ridersLat = '0',
String ridersLng = '0',
String pickupLat = '0',
String pickupLng = '0',
String address = '',
String city = '',
String state = '',
String suburb = '',
String postcode = '',
String pickupType = '',
String notes = '',
double actualKms = 0.0,
String? proofImage, // New parameter
}) async {
final mock = _mockStatus(pickupId, 'picked');
if (mock != null) return mock;
try {
final now = DateTime.now();
_pickedTime = _formatDateTimeFull(now);
final ll = await _ensureLatLng(ridersLat, ridersLng);
final rLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0;
final rLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0;
final pLat = double.tryParse(pickupLat) ?? 0.0;
final pLng = double.tryParse(pickupLng) ?? 0.0;
// Geofence Check: Picked -> Pickup Location
final inFence = await _checkGeofence(pLat, pLng, rLat, rLng, 'Picked');
if (!inFence) return false;
// Save pickup location ONLY if it hasn't been saved yet (first pickup only)
final existingPickup = await _getPickupLocation();
if (existingPickup['lat']!.isEmpty &&
pickupLat != '0' &&
pickupLng != '0') {
await _savePickupLocation(pickupLat, pickupLng);
} else {
debugPrint('[PICKED] Pickup location already exists, not overwriting');
}
// Calculate distance from rider location to pickup location if coordinates are available
double calculatedKms = actualKms;
if (actualKms == 0.0) {
try {
final riderLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0;
final riderLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0;
final pickLat = double.tryParse(pickupLat) ?? 0.0;
final pickLng = double.tryParse(pickupLng) ?? 0.0;
if (riderLat != 0 && riderLng != 0 && pickLat != 0 && pickLng != 0) {
final distanceMeters = Geolocator.distanceBetween(
riderLat,
riderLng,
pickLat,
pickLng,
);
calculatedKms = distanceMeters / 1000.0;
debugPrint(
'[PICKED] Calculated distance: ${calculatedKms.toStringAsFixed(2)} km from rider to pickup',
);
}
} catch (e) {
debugPrint('[PICKED] Error calculating distance: $e');
}
}
// ── Written down, not only sent ──
//
// This figure is the only measurement of the leg that exists: the backend
// does not reliably send a distance back on `GET /miler/bookings`, and
// until now the app posted this and kept nothing, so Activity's `km
// ridden` had no row to total and printed an em dash on a day of real
// riding. Recorded against the stop so the record that gets filed carries
// it — see [kStopKmKey] and `addCompletedBookings`.
//
// Best-effort: a preferences write must never cost the rider a pickup
// that has otherwise succeeded.
if (calculatedKms > 0) {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
kStopKmKey(pickupId),
calculatedKms.toStringAsFixed(2),
);
} catch (e) {
debugPrint('[PICKED] could not record the distance: $e');
}
}
final payload = <String, dynamic>{
'pickupid': pickupId,
// Not sent to the backend — the legacy shape carries it and the mapper
// reads it to file the consignment under the id the door will use.
if (orderId.isNotEmpty) 'orderid': orderId,
'orderheaderid': orderHeaderId,
'pickuplocationid': pickupLocationId,
'orderstatus': 'picked',
'pickuptime': _pickedTime,
'riderslat': ll['lat'],
'riderslon': ll['lng'],
'pickuplat': '',
'pickuplong': '',
'actualkms': calculatedKms.toStringAsFixed(2),
'pickupamt': 0.0,
'pickuptype': pickupType,
'address': address,
'city': city,
'state': state,
'suburb': suburb,
'postcode': postcode,
'notes': notes,
'pickupimage': proofImage ?? '', // Standard key
'proofimage': proofImage ?? '', // Fallback/Alternative key just in case
};
debugPrint('[PICKED] Payload: $payload');
final resp = await _updateProvider.updatePickup(payload);
final ok = _isSuccess(resp);
if (!ok) {
debugPrint('[UPDATE][PICKED][FAILED] resp=${jsonEncode(resp)}');
// ── The reason, kept, because the rider was told nothing ──
//
// Verified on production 27 Aug 2026, booking 207:
// `POST /miler/bookings/207/pickup-complete` → **500**
// `{"message":"failed to convert booking to consignment"}`.
//
// The app was right to refuse the move — `_advanceStop` leaves an
// unconfirmed pivot on Home rather than pushing it across the boundary
// — but it did that *silently*. The rider slid, the sheet closed, and
// the stop simply stayed where it was with nothing on screen. He slid
// again, and again, because nothing told him the hub had refused.
//
// The server's own sentence is the honest thing to show him, so it is
// kept here for the caller to read straight after its `await`, on the
// same terms as [lastBlockedReason].
final why = (resp?['message'] ?? '').toString().trim();
lastPickupRefusal = why.isEmpty
? 'Your office could not record this pickup. Try again in a moment.'
: why;
} else {
lastPickupRefusal = null;
// ── What the stop actually became, in the server's words ──
//
// The pivot has two legitimate outcomes and the caller must stamp the
// one that happened, not the one this build was written against:
//
// `collectedByMiler` the rider holds it — PICKED, and Start
// delivery is what makes it active.
// `outForDelivery` compatibility mode — the pivot released it,
// so it is ALREADY out for delivery and the
// console is right to say Active.
//
// Stamping 'picked' in the second case is the mismatch this whole
// exercise is about: the rider would read PICKED off a consignment his
// office reads as Active, and neither of them would be wrong about
// what their own screen said.
lastPivotConsignmentStatus = (resp?['consignmentstatus'] ?? '')
.toString();
lastPivotCompatibilityMode = resp?['compatibilitymode'] == true;
lastPivotNextAction = (resp?['nextaction'] ?? '').toString();
debugPrint(
'[UPDATE][PICKED] server state="$lastPivotConsignmentStatus" '
'next="$lastPivotNextAction" '
'compatibility=$lastPivotCompatibilityMode',
);
// --- MQTT LOGIC ---
MilerMqttService().publishLog('pickup_picked', payload);
}
return ok;
} catch (e) {
debugPrint('[UPDATE][PICKED][ERROR] $e');
return false;
}
}
// Picked up
Future<bool> updatePickedupStatus({
required int pickupId,
required int orderHeaderId,
String ridersLat = '0',
String ridersLng = '0',
int pickupLocationId = 0,
int smsPickup = 0,
String pickupLat = '0',
String pickupLng = '0',
String notes = '',
double pickupAmount = 0.0,
double actualKms = 0.0,
double collectionAmount = 0.0,
double collectedAmount = 0.0,
int collectionStatus = 0,
bool wasSkipped = false,
String orderId = '', // New parameter for timer/bonus logic
String? proofImage, // New parameter
}) async {
final mock = _mockStatus(pickupId, 'picked');
if (mock != null) return mock;
try {
lastBlockedReason = null;
pickedShimmer.value = true;
// ✅ PARALLEL OPTIMIZATION: Start fetching Prefs immediately
final prefsFuture = SharedPreferences.getInstance();
final now = DateTime.now();
_pickedTime = _formatDateTimeFull(now);
final ll = await _ensureLatLng(ridersLat, ridersLng);
final rLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0;
final rLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0;
final dLat = double.tryParse(pickupLat) ?? 0.0;
final dLng = double.tryParse(pickupLng) ?? 0.0;
// Geofence Check: Picked up -> Pickup Location
final inFence = await _checkGeofence(dLat, dLng, rLat, rLng, 'Picked up');
if (!inFence) {
pickedShimmer.value = false;
return false;
}
debugPrint(
'[Picked up] Rider GPS location: lat=${ll['lat']}, lng=${ll['lng']}',
);
// Wait for prefs to be ready
final prefs = await prefsFuture;
// ---------------- RIDER TIME (minutes) ----------------
int riderTimeMinutes = 0;
try {
final key = 'ridertime_start_$pickupId';
final startStr = prefs.getString(key);
if (startStr != null && startStr.isNotEmpty) {
final start = DateTime.tryParse(startStr);
if (start != null) {
final diff = now.difference(start).inSeconds / 60.0;
if (diff > 0) {
// Round: 2.7 -> 3, 2.5 -> 3, 2.4 -> 2, etc.
riderTimeMinutes = diff.round();
}
}
}
debugPrint('[Picked up] riderTimeMinutes=$riderTimeMinutes');
} catch (e) {
debugPrint('[Picked up] Error computing ridertime: $e');
}
// CRITICAL: Always calculate riderkms - even for small distances (10 meters = 0.01 km)
// This ensures riderkms is ALWAYS passed correctly, never null
double calculatedRiderKms = 0.0;
final riderLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0;
final riderLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0;
// PRIORITY 1: Use cumulative distance tracked from active to Picked up (most accurate)
// This is the actual distance the rider traveled, calculated from GPS points every 30 seconds
try {
final cumulativeKmStr =
prefs.getString('pickup_tracking_${pickupId}_cumulativeKm') ?? '';
if (cumulativeKmStr.isNotEmpty) {
final cumulativeKm = double.tryParse(cumulativeKmStr) ?? 0.0;
if (cumulativeKm > 0) {
calculatedRiderKms = cumulativeKm;
debugPrint(
'[Picked up] ✅ Using CUMULATIVE tracked distance: ${calculatedRiderKms.toStringAsFixed(4)} km',
);
debugPrint(
'[Picked up] 📊 This is the actual distance traveled from active to Picked up',
);
}
}
} catch (e) {
debugPrint('[Picked up] Error getting cumulative distance: $e');
}
// PRIORITY 2: Only use actualKms if cumulative distance not available and it's greater than 0
if (calculatedRiderKms == 0.0 &&
actualKms > 0.0 &&
riderLat != 0 &&
riderLng != 0) {
calculatedRiderKms = actualKms;
debugPrint(
'[Picked up] Using provided actualKms: ${calculatedRiderKms.toStringAsFixed(2)} km',
);
}
// Always try to calculate distance if we have valid rider coordinates
// CRITICAL: Use Google Maps Directions API for accurate ROAD distance (not straight-line)
if (calculatedRiderKms == 0.0 && riderLat != 0 && riderLng != 0) {
try {
// Method 1: Try Start Location (Anti-Cheat) - most accurate
final startLoc = await _getPickupStartLocation(pickupId);
final startLatStr = startLoc['lat'] ?? '';
final startLngStr = startLoc['lng'] ?? '';
if (startLatStr.isNotEmpty && startLngStr.isNotEmpty) {
final startLat = double.tryParse(startLatStr) ?? 0.0;
final startLng = double.tryParse(startLngStr) ?? 0.0;
if (startLat != 0 && startLng != 0) {
// Try Google Maps route distance first (accurate road distance)
final routeKm = await _getRouteDistanceKm(
startLat,
startLng,
riderLat,
riderLng,
);
if (routeKm != null && routeKm > 0) {
calculatedRiderKms = routeKm;
debugPrint(
'[Picked up] ✅ Route distance (Start Location): ${calculatedRiderKms.toStringAsFixed(4)} km',
);
} else {
// Fallback to straight-line if API fails
final distanceMeters = Geolocator.distanceBetween(
startLat,
startLng,
riderLat,
riderLng,
);
calculatedRiderKms = distanceMeters / 1000.0;
debugPrint(
'[Picked up] ⚠️ Fallback straight-line (Start Location): ${distanceMeters.toStringAsFixed(2)} meters = ${calculatedRiderKms.toStringAsFixed(4)} km',
);
}
}
}
// Method 2: If start location failed, try provided pickup location
if (calculatedRiderKms == 0.0 &&
pickupLat != '0' &&
pickupLng != '0') {
final pickLat = double.tryParse(pickupLat) ?? 0.0;
final pickLng = double.tryParse(pickupLng) ?? 0.0;
if (pickLat != 0 && pickLng != 0) {
// Try Google Maps route distance first
final routeKm = await _getRouteDistanceKm(
pickLat,
pickLng,
riderLat,
riderLng,
);
if (routeKm != null && routeKm > 0) {
calculatedRiderKms = routeKm;
debugPrint(
'[Picked up] ✅ Route distance (Pickup Location): ${calculatedRiderKms.toStringAsFixed(4)} km',
);
} else {
// Fallback to straight-line
final distanceMeters = Geolocator.distanceBetween(
pickLat,
pickLng,
riderLat,
riderLng,
);
calculatedRiderKms = distanceMeters / 1000.0;
debugPrint(
'[Picked up] ⚠️ Fallback straight-line (Pickup Location): ${distanceMeters.toStringAsFixed(2)} meters = ${calculatedRiderKms.toStringAsFixed(4)} km',
);
}
}
}
// Method 3: Fallback to saved pickup location
if (calculatedRiderKms == 0.0) {
final pickupLoc = await _getPickupLocation();
final pickLat = double.tryParse(pickupLoc['lat'] ?? '') ?? 0.0;
final pickLng = double.tryParse(pickupLoc['lng'] ?? '') ?? 0.0;
if (pickLat != 0 && pickLng != 0) {
// Try Google Maps route distance first
final routeKm = await _getRouteDistanceKm(
pickLat,
pickLng,
riderLat,
riderLng,
);
if (routeKm != null && routeKm > 0) {
calculatedRiderKms = routeKm;
debugPrint(
'[Picked up] ✅ Route distance (Saved Pickup): ${calculatedRiderKms.toStringAsFixed(4)} km',
);
} else {
// Fallback to straight-line
final distanceMeters = Geolocator.distanceBetween(
pickLat,
pickLng,
riderLat,
riderLng,
);
calculatedRiderKms = distanceMeters / 1000.0;
debugPrint(
'[Picked up] ⚠️ Fallback straight-line (Saved Pickup): ${distanceMeters.toStringAsFixed(2)} meters = ${calculatedRiderKms.toStringAsFixed(4)} km',
);
}
}
}
// Method 4: Final fallback - calculate from pickup location to current location
if (calculatedRiderKms == 0.0 &&
pickupLat != '0' &&
pickupLng != '0') {
final dLat = double.tryParse(pickupLat) ?? 0.0;
final dLng = double.tryParse(pickupLng) ?? 0.0;
if (dLat != 0 && dLng != 0) {
// Try Google Maps route distance first
final routeKm = await _getRouteDistanceKm(
dLat,
dLng,
riderLat,
riderLng,
);
if (routeKm != null && routeKm > 0) {
calculatedRiderKms = routeKm;
debugPrint(
'[Picked up] ✅ Route distance (Pickup Location): ${calculatedRiderKms.toStringAsFixed(4)} km',
);
} else {
// Fallback to straight-line
final distanceMeters = Geolocator.distanceBetween(
dLat,
dLng,
riderLat,
riderLng,
);
calculatedRiderKms = distanceMeters / 1000.0;
debugPrint(
'[Picked up] ⚠️ Fallback straight-line (Pickup Location): ${distanceMeters.toStringAsFixed(2)} meters = ${calculatedRiderKms.toStringAsFixed(4)} km',
);
}
}
}
} catch (e) {
debugPrint('[Picked up] Error calculating distance: $e');
}
}
// CRITICAL: Ensure riderkms is never 0 or null - use minimum value if calculation failed
// Even 10 meters should be shown (0.01 km)
if (calculatedRiderKms == 0.0) {
debugPrint(
'[Picked up] ⚠️ WARNING: Could not calculate distance, using minimum 0.01 km (10 meters)',
);
calculatedRiderKms = 0.01; // Minimum 10 meters
}
debugPrint(
'[Picked up] Final riderkms: ${calculatedRiderKms.toStringAsFixed(4)} km',
);
// ---- BONUS POINTS LOGIC ----
int bonusPoints = 0;
// Reset per stop: these describe the stop being closed right now, and a
// stale value from the previous one would be stamped onto this record.
lastOnTime.value = null;
lastLateBy.value = null;
lastEtaDeadline.value = null;
lastRiderKms.value = calculatedRiderKms;
if (orderId.isNotEmpty) {
try {
final endKey = 'eta_endtime_$orderId';
final endSeconds = prefs.getInt(endKey);
if (endSeconds != null) {
final currentSeconds =
DateTime.now().millisecondsSinceEpoch ~/ 1000;
lastEtaDeadline.value = DateTime.fromMillisecondsSinceEpoch(
endSeconds * 1000,
);
lastOnTime.value = currentSeconds <= endSeconds;
lastLateBy.value = currentSeconds <= endSeconds
? Duration.zero
: Duration(seconds: currentSeconds - endSeconds);
// If Picked up before or at the end time, give bonus points
if (currentSeconds <= endSeconds) {
// Bonus points = riderkms rounded
bonusPoints = calculatedRiderKms.round();
debugPrint(
'[Picked up] 🏆 ON TIME! Bonus Points: $bonusPoints (RiderKms: $calculatedRiderKms)',
);
} else {
debugPrint(
'[Picked up] ⏳ LATE! No Bonus Points. (Deadline: $endSeconds, Now: $currentSeconds)',
);
}
} else {
debugPrint('[Picked up] No ETA timer found for bonus logic.');
}
} catch (e) {
debugPrint('[Picked up] Error calculating bonus points: $e');
}
}
final double riderChargeRate = await _getRiderChargeRate();
// ---- SKIP PENALTY CHECK ----
// If the rider has exceeded skip limits in the last 3 hours, forfeit bonus points
final skipStatus = await checkSkipStatus();
if (skipStatus['penalty'] == true) {
if (bonusPoints > 0) {
debugPrint(
'[Picked up] ⚠️ Bonus points ($bonusPoints) forfeited due to skip penalty.',
);
bonusPoints = 0;
}
}
final double riderChargesAmountRaw =
riderChargeRate > 0 && calculatedRiderKms > 0
? calculatedRiderKms * riderChargeRate
: 0.0;
// Ensure ridercharges is sent with 2 decimal places (Decimal(10,2) style)
final double riderChargesAmount = double.parse(
riderChargesAmountRaw.toStringAsFixed(2),
);
// CRITICAL: Always set pickuptime to current time right before creating payload
// This ensures pickuptime is ALWAYS passed when confirming pickup, no matter what
// Setting it here (right before payload) ensures it's the exact time of confirmation
final currentTime = DateTime.now();
final currentPickupTime = _formatDateTimeFull(currentTime);
_pickedTime = currentPickupTime; // Update the instance variable too
debugPrint('[Picked up] Setting pickuptime to: $currentPickupTime');
// CRITICAL: Ensure riderkms is always a valid string, never null or empty
final riderKmsString = calculatedRiderKms > 0
? calculatedRiderKms.toStringAsFixed(
4,
) // Use 4 decimals to show even 10 meters (0.0100 km)
: '0.0100'; // Fallback minimum (10 meters)
// CRITICAL: Validate coordinates before creating payload - NEVER send '0' or null
final ridersLatStr = ll['lat'] ?? '0';
final ridersLngStr = ll['lng'] ?? '0';
final ridersLatDouble = double.tryParse(ridersLatStr) ?? 0.0;
final ridersLngDouble = double.tryParse(ridersLngStr) ?? 0.0;
if (ridersLatDouble == 0 ||
ridersLngDouble == 0 ||
ridersLatDouble.abs() > 90 ||
ridersLngDouble.abs() > 180) {
debugPrint(
'[Picked up] ❌ ERROR: Invalid rider coordinates (lat=$ridersLatStr, lng=$ridersLngStr) - cannot proceed',
);
pickedShimmer.value = false;
return false; // Don't send invalid coordinates
}
// Validate pickup coordinates
final pickupLatDouble = double.tryParse(pickupLat) ?? 0.0;
final pickupLngDouble = double.tryParse(pickupLng) ?? 0.0;
if (pickupLatDouble == 0 ||
pickupLngDouble == 0 ||
pickupLatDouble.abs() > 90 ||
pickupLngDouble.abs() > 180) {
debugPrint(
'[Picked up] ❌ ERROR: Invalid pickup coordinates (lat=$pickupLat, lng=$pickupLng) - cannot proceed',
);
pickedShimmer.value = false;
return false; // Don't send invalid coordinates
}
debugPrint(
'[Picked up] ✅ Validated coordinates - Rider: ($ridersLatStr, $ridersLngStr), Pickup: ($pickupLat, $pickupLng)',
);
final payload = <String, dynamic>{
'pickupid': pickupId,
'orderheaderid': orderHeaderId,
'pickuplocationid': pickupLocationId,
'orderstatus': 'Picked up',
'pickupedtime': currentPickupTime,
'pickuptime':
currentPickupTime, // ALWAYS pass current time - required field, set right before payload
'smspickup': smsPickup,
'riderslat': ridersLatStr, // Guaranteed non-zero and valid
'riderslon': ridersLngStr, // Guaranteed non-zero and valid
'raw_latitude': ll['raw_lat'] ?? '0',
'raw_longitude': ll['raw_lng'] ?? '0',
'velocity_lat': ll['velocity_lat'] ?? '0',
'velocity_lng': ll['velocity_lng'] ?? '0',
'speed': ll['speed'] ?? '0',
'heading': ll['heading'] ?? '0',
'pickuplat': pickupLat, // Guaranteed non-zero and valid
'pickuplong': pickupLng, // Guaranteed non-zero and valid
'riderkms':
riderKmsString, // ALWAYS pass riderkms - never null, shows even 10 meters (0.0100 km)
'ridercharges': riderChargesAmount,
'pickupamt': pickupAmount,
'collectionamt': collectionAmount,
'collectedamt': collectedAmount,
'collectionstatus': collectionStatus,
'ridertime': riderTimeMinutes,
'notes': notes,
'wasskipped': wasSkipped,
'bonuspts': bonusPoints, // ✅ ADDED BONUS POINTS
'dropimage': proofImage ?? '', // Include in payload
};
// Update observable for UI
lastBonusPoints.value = bonusPoints;
debugPrint('[Picked up] Payload includes pickuptime: $currentPickupTime');
debugPrint('[Picked up] Payload includes riderkms: $riderKmsString km');
debugPrint('[Picked up] Payload includes bonuspts: $bonusPoints');
final resp = await _updateProvider.updatePickup(payload);
final ok = _isSuccess(resp);
// Reset first: a stale number from the previous stop shown on this one's
// success screen would be worse than none.
lastTrackingNo.value = '';
if (ok && resp?['details'] is Map) {
final details = resp!['details'] as Map;
lastTrackingNo.value =
(details['tracking_no'] ?? details['trackingno'] ?? '').toString();
}
// Save current RIDER GPS location as last pickup location for next pickup
if (ok) {
final actualLat = ll['lat'] ?? '0';
final actualLng = ll['lng'] ?? '0';
if (actualLat != '0' && actualLng != '0') {
await _saveLastPickupLocation(actualLat, actualLng);
}
// CRITICAL: Clean up cumulative distance tracking after pickup is completed
try {
final cumulativeKm =
prefs.getString('pickup_tracking_${pickupId}_cumulativeKm') ?? '';
if (cumulativeKm.isNotEmpty) {
debugPrint(
'[Picked up] 📊 Final cumulative distance used: $cumulativeKm km',
);
}
// Clean up tracking data
await prefs.remove('pickup_tracking_${pickupId}_lastLat');
await prefs.remove('pickup_tracking_${pickupId}_lastLng');
await prefs.remove('pickup_tracking_${pickupId}_cumulativeKm');
await prefs.remove('ridertime_start_$pickupId');
final activeTracking =
prefs.getStringList('active_tracking_pickup_ids') ?? [];
if (activeTracking.contains(pickupId.toString())) {
activeTracking.remove(pickupId.toString());
await prefs.setStringList(
'active_tracking_pickup_ids',
activeTracking,
);
}
// ✅ Clean up ETA timer
if (orderId.isNotEmpty) {
await prefs.remove('eta_endtime_$orderId');
}
debugPrint(
'[Picked up] 🧹 Cleaned up distance tracking for pickupId: $pickupId',
);
} catch (e) {
debugPrint('[Picked up] Error cleaning up tracking: $e');
}
}
// Reset foreground pickup notification flag
await setNotificationSent(false);
pickedShimmer.value = false;
if (!ok) {
debugPrint('[UPDATE][Picked up][FAILED] resp=${jsonEncode(resp)}');
} else {
// --- MQTT LOGIC ---
MilerMqttService().publishLog('pickup_completed', payload);
}
return ok;
} catch (e) {
debugPrint('[UPDATE][Picked up][ERROR] $e');
pickedShimmer.value = false;
return false;
}
}
// ---------------- Date/Time helpers ----------------
String _two(int n) => n.toString().padLeft(2, '0');
String _formatDateTimeFull(DateTime dt) {
final y = dt.year.toString();
final m = _two(dt.month);
final d = _two(dt.day);
final hh = _two(dt.hour);
final mm = _two(dt.minute);
final ss = _two(dt.second);
return "$y-$m-$d $hh:$mm:$ss";
}
// ---------------- SharedPrefs helpers ----------------
Future<void> setNotificationSent(bool value) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('notificationSent', value);
} catch (_) {}
}
// ---------------- Geofencing helpers ----------------
//
// The radius is [kGeofenceRadiusMetres], in `lib/data/geofence.dart`, and
// nothing else. It read the server's `pickupradius` out of prefs, which made
// the app's one honesty control 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.
/// The fence's answer to the last rung that asked, for screens that want to
/// show the live distance rather than only the refusal.
GeofenceDecision? lastGeofence;
// 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;
/// The proximity gate for one rung. **False means do not call the API.**
///
/// Adapts [Geofence] to the call sites below. The rider's own coordinates are
/// no longer taken as arguments: the fence takes its *own* fix, at
/// [LocationAccuracy.best], because the pair the screens pass in comes from a
/// `low`-accuracy Home poll or an unaged cache and carries no accuracy at all
/// — so the fence could not tell how much to trust the number it was
/// measuring a 100 m rule with. The parameters stay for the eight call sites
/// that build a payload from the same values; they are ignored here.
///
/// Sets [lastBlockedReason] to the rider-facing sentence on every refusal, so
/// a caller can say what happened instead of reporting a network failure.
Future<bool> _checkGeofence(
double targetLat,
double targetLng,
double currentLat,
double currentLng,
String action,
) async {
lastBlockedReason = null;
final decision = await Geofence.check(
targetLat: targetLat,
targetLng: targetLng,
action: action,
);
lastGeofence = decision;
if (decision.allowed) return true;
lastBlockedReason = decision.reason;
if (decision.reason != null) {
_showErrorSnackbar('Location Error', decision.reason!, seconds: 5);
}
return false;
}
// 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;
// Cleared on entry, not only on the paths that set them. The caller now
// reads these to decide whether a failed arrival may still advance the rung
// locally, and a value left over from an earlier stop would answer for this
// one — refusing an arrival because a different door refused twenty minutes
// ago. `lastBlockedReason` gets the same treatment inside `_checkGeofence`.
lastArrivalRefusal = null;
lastArrivalNotice = null;
// 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;
// ── A write that never landed must not look like one that did ──
//
// `lastArrivalRefusal` stays null on a transport failure, deliberately:
// the caller lets a rider with no signal carry on rather than stranding
// him at a door. But it then advanced the rung *silently*, so the rider
// saw ARRIVED and had no way to know the hub had not been told — and
// the first person to find out was an operator wondering why he had
// been at a kitchen for forty minutes.
//
// He still carries on. He is simply told.
lastArrivalNotice = lastArrivalRefusal == null
? 'Marked arrived on your phone. Your office has not been told — '
'you had no connection. Tell them if it matters.'
: 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;
// ── Unconditional ──
//
// This was wrapped in `if (dLat != 0 && dLng != 0)`, so a consignment
// whose drop pin was missing skipped the fence entirely — silently, with
// no log and no record. That is the one case where the fence matters
// most: nobody can say afterwards where the rider was standing. A missing
// pin is now [GeofenceOutcome.noTarget] and is refused with a sentence
// that sends the rider to his office.
//
// Fenced against the DROP, not the pickup. Using the pickup coordinates
// here would fence the rider to the counter he left an hour ago.
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;
// ── Unconditional ──
//
// This was wrapped in `if (dLat != 0 && dLng != 0)`, so a consignment
// whose drop pin was missing skipped the fence entirely — silently, with
// no log and no record. That is the one case where the fence matters
// most: nobody can say afterwards where the rider was standing. A missing
// pin is now [GeofenceOutcome.noTarget] and is refused with a sentence
// that sends the rider to his office.
//
// Fenced against the DROP, not the pickup. Using the pickup coordinates
// here would fence the rider to the counter he left an hour ago.
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');
}
}
}