import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:miler/data/work_repository.dart'; import 'package:flutter/material.dart'; import 'package:latlong2/latlong.dart' show LatLng; import 'package:miler/helpers/miler_router.dart'; import 'package:get/get.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:miler/data/accepted_store.dart'; import 'package:geolocator/geolocator.dart'; import 'package:miler/providers/pickuplog/pickuplog_provider.dart'; import 'dart:io'; import 'package:minio/minio.dart'; import 'package:minio/io.dart'; import 'package:miler/utils/kalman_filter.dart'; import 'package:miler/utils/mqtt_service.dart'; import 'package:miler/controllers/connectivity_mixin.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/data/meal_run_mock.dart'; import 'package:miler/views/helpers/widgets/app_widgets.dart'; /// ── PROXIMITY ENFORCEMENT IS OFF ── /// /// Off again on 2026-08-25, the same day it was turned on, at the founder's /// call. Nothing was found wrong with it — this is a decision about when to /// switch it on, not a retraction of the work. /// /// It is off for **every** status — arrived, picked, picked up, delivery /// arrived, delivered, cancelled — and on both gates, this one and the bulk /// check on Home. Half a fence is worse than none: it would block one route /// into a rung and wave through another, with two different messages and no /// explanation of why one worked. /// /// ── What it costs while it is off ── /// /// This is the one control that decides whether the app is telling the truth /// about where a rider was standing when he said a stop was done. With it off, /// "Picked up" means he pressed a button, not that he was there — the /// timestamps and the GPS still ride along on every status write, so the office /// can audit after the fact, but nothing is refused at the moment of the press. /// /// ── What is ready for the day it goes back on ── /// /// Recorded here because the work is done and the flag is the only thing /// holding it, so nobody has to rediscover any of it. The fence was off from /// 2026-08-19 because it refused real work — riders pressing **Picked up** at a /// counter were told "You're 4.2 km from this stop" — and that was a /// measurement problem rather than a strictness one. Three causes, all fixed: /// /// • **The fix was not worth measuring with.** Home handed the fence a /// `LocationAccuracy.low` position — a ~1 km hint on Android — or a cached /// one of any age. See [_freshFix] and [kGeofenceFixMaxAge]. /// • **The phone's own error was charged to the rider.** The comparison is /// `distance - accuracy > radius`, so a rider 12 m out on a ±20 m fix is not /// refused for a precision the hardware never provided. /// • **There were three radii.** A configured `pickupradius` defaulting to /// 100 here, a hardcoded 500 in Home's bulk gate, and no agreement between /// them. There is one now: [kGeofenceRadiusMeters], set to 10. /// /// All three are live in the code below and simply do not run while this is /// false. The fourth cause is real and none of this fixes it: **the booking's /// own coordinates are often wrong**, because they come from wherever the /// customer dropped a pin. A stop carrying *no* coordinates is allowed through /// — that is the hub's data, not something a rider can resolve from a doorstep /// — but a stop carrying wrong ones will still refuse him. /// /// ── Turning it on ── /// /// flutter run --dart-define=ENFORCE_GEOFENCE=true /// flutter build apk --dart-define=ENFORCE_GEOFENCE=true /// /// Or flip the default. Ten metres is tight — at or inside consumer GPS /// accuracy — so if the first reports are riders blocked at doors, raise /// [kGeofenceRadiusMeters] before reaching for this switch again. /// /// Every bypass is logged in every build mode (see `_checkGeofence`), so a /// build's own log says which way it was compiled. /// /// ── The history, so none of the old mistakes come back ── /// /// This was once `kDebugMode`, which meant the fence was off in exactly the /// builds anyone tested with — so it was never really tested. It was then /// pinned to a hard `false`, which left no way to walk the flow at a desk. The /// shape below survives both: one named constant, one define, one default, and /// the default is a product decision rather than a side effect of how the app /// was built. const bool kGeofenceEnforced = bool.fromEnvironment( 'ENFORCE_GEOFENCE', defaultValue: false, ); /// The name every call site reads. Derived, so there is one switch and not two. const bool kBypassGeofenceForTesting = !kGeofenceEnforced; /// ───────────────────────────────────────────────────────────────────────── /// HOW CLOSE IS "AT THE STOP" — 10 metres /// /// A product decision, taken deliberately and tight: the rider must be *at the /// door*, not on the street outside it, before he can mark a stop arrived, /// picked or delivered. /// /// ── Why this is a constant and no longer the server's `pickupradius` ── /// /// The fence used to read `pickupradius` out of prefs, which login writes from /// the profile and defaults to 100. That made the strictness of the app's one /// honesty control a per-tenant configuration value nobody on this side could /// see, and it silently disagreed with the second gate on Home, which was /// hardcoded to 500. Three numbers for one rule. This is the rule. /// /// ── What 10 metres actually demands, stated plainly ── /// /// This is at or inside the accuracy of consumer GPS. A phone reports a fix /// with an error radius, and 5–15 m in the open is normal while 30–50 m /// between buildings is ordinary rather than exceptional. A fence smaller than /// the error it is measured with will refuse a rider who is genuinely standing /// at the door — which is the exact failure that got this whole control turned /// off once before, and the reason two things below are not optional: /// /// • **The fix must be worth 10 m.** [LocationAccuracy.best], not the `low` /// the callers were passing — `low` is a ~1 km hint on Android and against /// a 10 m fence it is not a measurement, it is a coin toss. See /// [_freshFix]. /// /// • **The phone's own error is credited to the rider.** The check is /// `distance - accuracy > radius`, not `distance > radius`: a rider 12 m /// away on a fix that says ±20 m has not been shown to be outside the /// fence, and refusing him is asserting a precision the hardware did not /// provide. He is refused when the *phone* says he is outside, not when the /// arithmetic does. /// /// Together those keep 10 m meaning "at the door" without it meaning "when the /// satellites are kind". If riders still report being blocked at a door, this /// number is the knob — raise it here, in one place, rather than turning the /// fence off again. const double kGeofenceRadiusMeters = 10; /// How stale a cached fix may be before the fence refuses to measure with it. /// /// A last-known position is instant and free and can be an hour old. Against a /// 100 m fence that was survivable; against 10 m it is how a rider marks a /// delivery arrived from the previous street because that is where the phone /// last looked. const Duration kGeofenceFixMaxAge = Duration(seconds: 30); class PickupsController extends GetxController with ConnectivityControllerMixin { MilerKalmanFilter? _kf; DateTime? _lastUpdateTime; final UpdatePickupProvider _updateProvider = UpdatePickupProvider(); bool _isSuccess(Map? 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 lastOnTime = Rxn(); final Rxn lastLateBy = Rxn(); /// The clock the stop was promised by, as read from `eta_endtime_` /// 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 lastEtaDeadline = Rxn(); 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 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 _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> _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 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 = { '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 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 = { '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 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 _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> _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 _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 _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> _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> _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 _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 _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 _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 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 = { '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 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 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 = { '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 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 = { '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 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 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 = { '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 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 = { '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 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 = { '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 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 = { '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 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> 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 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'); } } }