production

This commit is contained in:
2026-08-28 11:13:15 +05:30
parent d7348e253f
commit 5723d373b2
162 changed files with 17924 additions and 7026 deletions

View File

@@ -25,42 +25,62 @@ import 'package:miler/views/helpers/widgets/app_widgets.dart';
/// ── PROXIMITY ENFORCEMENT IS OFF ──
///
/// Turned off deliberately on 2026-08-19, at the founder's call, because it was
/// refusing real work: riders pressing **Picked up** at a counter were told
/// "You're 4.2 km from this stop", and a rider who cannot record what he has
/// physically done has no way round it. Correctness of the fence matters less
/// than a rider being able to work.
/// 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 — accepted, arrived, picked up, out for
/// delivery, delivered — and on both gates, this one and the bulk check on
/// Home. Half a fence is worse than none: it would block one route into a rung
/// and wave through another, with two different messages and no explanation of
/// why one worked.
///
/// ── Turning it back on ──
///
/// flutter run --dart-define=ENFORCE_GEOFENCE=true
/// flutter build apk --dart-define=ENFORCE_GEOFENCE=true
///
/// Or flip [kGeofenceEnforced]'s default back to `true` when the underlying
/// problem is fixed. That problem is worth naming, because it is the reason
/// this is off rather than merely loosened: the fence compares the rider's GPS
/// against `pickuplat`/`pickuplon` **on the booking**, and those come from
/// wherever the customer dropped a pin — so the distance it measures is as
/// often the data being wrong as the rider being absent.
/// 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 decided whether the app was telling the truth
/// 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 neither old mistake comes back ──
/// ── 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
@@ -76,6 +96,56 @@ const bool kGeofenceEnforced = bool.fromEnvironment(
/// 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;
@@ -260,6 +330,66 @@ class PickupsController extends GetxController
}
// ---------------- Location helpers ----------------
/// The error radius, in metres, of the fix [_ensureLatLng] last obtained.
///
/// Read by [_checkGeofence], which credits it to the rider — see
/// [kGeofenceRadiusMeters]. Starts at zero so a fence measured before any fix
/// has been taken is strict rather than accidentally generous.
double _lastFixAccuracy = 0;
/// A position good enough to measure a [kGeofenceRadiusMeters] fence with.
///
/// ── What this replaced, and why it had to go ──
///
/// The old ladder was: last-known first, then `high` for 4s, then `low` for
/// 2s. Every rung of it is wrong against a 10 m fence.
///
/// • **Last-known first** returns instantly and can be an hour old. The
/// fence would then be measured from wherever the phone last happened to
/// look — reliably the previous stop, on a round.
/// • **`low` as a fallback** is a ~1 km hint on Android. Against 10 m that
/// is not a degraded measurement, it is noise being treated as evidence,
/// and it fails in both directions: it blocks a rider at the door and
/// waves through one two streets away.
///
/// So: ask for the best fix the hardware will give, wait long enough for the
/// GPS to actually settle, and fall back to a cached one **only** if it is
/// fresher than [kGeofenceFixMaxAge]. Whatever comes back carries its own
/// accuracy into [_lastFixAccuracy], so the fence knows how much to trust it.
Future<Position?> _freshFix() async {
Position? pos;
try {
pos = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.best,
// 8s, not 4. A cold GPS under a roofline needs the time, and the
// rider is standing still at a door — this is the one moment in his
// round where a few seconds buys something.
timeLimit: Duration(seconds: 8),
),
);
} catch (_) {
// No live fix. A recent cached one is a measurement; a stale one is not.
try {
final cached = await Geolocator.getLastKnownPosition();
final age = cached == null
? null
: DateTime.now().difference(cached.timestamp);
if (cached != null && age != null && age <= kGeofenceFixMaxAge) {
pos = cached;
} else if (cached != null) {
debugPrint(
'[GEOFENCE] cached fix is ${age?.inSeconds}s old — too stale to '
'measure a ${kGeofenceRadiusMeters.toStringAsFixed(0)}m fence',
);
}
} catch (_) {}
}
_lastFixAccuracy = pos?.accuracy ?? 0;
return pos;
}
Future<Map<String, String>> _ensureLatLng(String lat, String lng) async {
String outLat = lat;
String outLng = lng;
@@ -268,16 +398,34 @@ class PickupsController extends GetxController
final needsFetch =
(lat == '0' || lat.isEmpty || lng == '0' || lng.isEmpty);
// Fast path: if we have valid coordinates, use them immediately
if (!needsFetch) return {'lat': outLat, 'lng': outLng};
// ── 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};
// 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();
@@ -292,32 +440,7 @@ class PickupsController extends GetxController
return {'lat': outLat, 'lng': outLng};
}
Position? pos;
// 1. Try Last Known Position (Instant)
try {
pos = await Geolocator.getLastKnownPosition();
} catch (_) {}
// 2. If no last known, try current with a single balanced timeout
// Reduced complicated retry logic to one solid attempt
if (pos == null) {
try {
pos = await Geolocator.getCurrentPosition(
desiredAccuracy:
LocationAccuracy.high, // Better accuracy for pickup
timeLimit: const Duration(seconds: 4),
);
} catch (_) {
// Fallback to low accuracy if high fails quickly
try {
pos = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.low,
timeLimit: const Duration(seconds: 2),
);
} catch (_) {}
}
}
final pos = await _freshFix();
if (pos != null) {
final now = DateTime.now();
@@ -484,7 +607,27 @@ class PickupsController extends GetxController
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
@@ -1051,14 +1194,13 @@ class PickupsController extends GetxController
}
// ---------------- Geofencing helpers ----------------
Future<int> _getPickupRadius() async {
try {
final prefs = await SharedPreferences.getInstance();
return prefs.getInt('pickupradius') ?? 100;
} catch (_) {
return 100;
}
}
//
// 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
//
@@ -1138,6 +1280,13 @@ class PickupsController extends GetxController
/// a caller can read it straight after its `await`.
String? lastBlockedReason;
/// Why the last `pickup-complete` was refused, in the server's own words.
///
/// Null after a successful pivot. Set only when the call came back not-OK,
/// so a caller can say what happened instead of leaving the rider sliding at
/// a stop the hub keeps rejecting. See [updatePickedStatus].
String? lastPickupRefusal;
Future<bool> _checkGeofence(
double targetLat,
double targetLng,
@@ -1175,26 +1324,44 @@ class PickupsController extends GetxController
currentLat.abs() <= 90 &&
currentLng.abs() <= 180;
if (!hasValidTarget || !hasValidCurrent) {
// If coordinates are missing or invalid, show error and block
if (kDebugMode) {
debugPrint(
'[GEOFENCE] Missing or invalid coordinates for $action. Target: ($targetLat, $targetLng), Current: ($currentLat, $currentLng)',
);
}
// Show warning snackbar using helper method
_showErrorSnackbar(
'Location Warning',
'Missing coordinates. Proceeding with update.',
bgColor: ColorConstants.warning,
seconds: 3,
// ── 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; // Allow update to proceed despite missing coords
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 = await _getPickupRadius();
final radius = _geofenceRadius;
final distance = Geolocator.distanceBetween(
targetLat,
targetLng,
@@ -1202,16 +1369,30 @@ class PickupsController extends GetxController
currentLng,
);
final distanceKm = distance / 1000.0;
final radiusKm = radius / 1000.0;
final distanceMeters = distance;
if (kDebugMode) {
debugPrint(
'[GEOFENCE] Action: $action | Target: ($targetLat, $targetLng) | Current: ($currentLat, $currentLng) | Distance: ${distanceMeters.toStringAsFixed(1)}m (${distanceKm.toStringAsFixed(3)}km) | Radius: ${radius}m (${radiusKm.toStringAsFixed(3)}km)',
);
}
// ── 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);
if (distance > radius) {
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
@@ -1221,7 +1402,8 @@ class PickupsController extends GetxController
? '${distanceKm.toStringAsFixed(1)} km'
: '${distanceMeters.toStringAsFixed(0)} m';
lastBlockedReason =
"You're $away from this stop — get within $radius m to mark it "
"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;
@@ -1601,6 +1783,18 @@ class PickupsController extends GetxController
/// `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,
@@ -1659,9 +1853,18 @@ class PickupsController extends GetxController
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? ──
//
@@ -1678,7 +1881,7 @@ class PickupsController extends GetxController
arrivalConfirmedByServer.value = resp?['confirmed'] == true;
if (!arrivalConfirmedByServer.value) {
lastArrivalNotice =
'Marked arrived on your phone. Your hub has not recorded it — '
'Marked arrived on your phone. Your office has not recorded it — '
'their system is not accepting arrivals yet.';
debugPrint(
'[UPDATE][ARRIVED][UNCONFIRMED] server said '