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

@@ -215,7 +215,7 @@ class AuthController extends GetxController {
if (decoded is Map) reason = (decoded['message'] ?? '').toString();
} catch (_) {}
if (reason.trim().isEmpty) {
reason = 'Could not set your MPIN. Please contact your hub manager.';
reason = 'Could not set your MPIN. Please contact your manager.';
}
_showBottomSheet(title: 'MPIN not changed', message: reason);
return false;
@@ -368,7 +368,7 @@ class AuthController extends GetxController {
);
lastPinFailure =
'Your MPIN was accepted, but the server did not return a session. '
'Please report this to the hub — it is not your PIN.';
'Please report this to your office — it is not your PIN.';
_showBottomSheet(
title: 'Could not start session',
message: lastPinFailure!,

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 '

View File

@@ -1,31 +1,159 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:miler/Models/summary/riderweeklykms.dart';
import 'package:miler/data/miler_api.dart';
import 'package:miler/views/helpers/constants/apiconstants.dart';
class RiderWeeklyKmController {
final String baseUrl = ApiConstants.summaryriderkmLive;
/// `GET /miler/earnings?period=weekly` → `data:{ total_kms, breakdown }`.
///
/// [userId] is unused: the token identifies the rider, and asking for someone
/// else's kilometres is not a thing the endpoint offers. Kept on the
/// signature because the provider above passes it and the call sites read
/// better for naming whose distance they mean.
Future<Map<String, dynamic>> getRiderWeeklyKms(int userId) async {
final res = await MilerApi.earnings(period: 'weekly');
if (!res.ok) {
throw Exception('Failed to fetch (code: ${res.status})');
}
final breakdown = res.map['breakdown'];
final details = (breakdown is List ? breakdown : const [])
.whereType<Map>()
.map((e) => RiderWeeklyKms.fromJson(Map<String, dynamic>.from(e)))
.toList();
return {
'details': details,
'total_kms': (res.map['total_kms'] ?? 0).toDouble(),
};
}
}
import 'package:miler/Models/summary/riderweeklykms.dart';
import 'package:miler/data/miler_api.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// THE WEEK'S KILOMETRES
///
/// ── Why the chart was empty ──
///
/// This read `data.breakdown` off `GET /miler/earnings?period=weekly` and
/// mapped it into the seven bars. That field is not on the contract: the
/// earnings response carries `completed_stops`, `cancelled_stops`,
/// `total_stops`, `total_kms`, `total_earnings` and `total_bonus` — six
/// **totals for the period asked for**, and no per-day series at all.
///
/// So `breakdown` was always null, the list was always empty, and the chart
/// drew seven bars of nothing while the rider had ridden all week. It failed
/// silently because an absent key and an empty week look identical downstream.
///
/// ── What it does instead ──
///
/// The endpoint answers for *a* period, and it takes a `date`. A week is seven
/// days, so the week is seven daily calls — asked concurrently, so the page
/// waits for the slowest one rather than the sum of seven.
///
/// That is more requests than one, and it is the honest cost of a contract that
/// has no series on it. It is bounded (seven, on a screen opened occasionally
/// rather than polled), and it is built from the same figure the totals row
/// prints, so the bars and the total cannot disagree.
///
/// **The fast path stays.** If `breakdown` ever ships, it is used and the seven
/// calls are skipped — that check is two lines and it is what makes this
/// removable later without touching the page.
/// ─────────────────────────────────────────────────────────────────────────
class RiderWeeklyKmController {
/// `Mon` … `Sun`. Written out rather than taken from `intl` because these
/// strings are matched against the server's own day names elsewhere, and a
/// locale-aware formatter would introduce a mismatch that comparison cannot
/// survive.
static const List<String> _days = [
'Mon',
'Tue',
'Wed',
'Thu',
'Fri',
'Sat',
'Sun',
];
static String _iso(DateTime d) =>
'${d.year}-${d.month.toString().padLeft(2, '0')}-'
'${d.day.toString().padLeft(2, '0')}';
static double _num(dynamic v) {
if (v is num) return v.toDouble();
return double.tryParse('${v ?? ''}') ?? 0;
}
/// Reads a weekly `breakdown` into the chart's series, or returns null when
/// there is nothing usable in it.
///
/// ── Null is the whole contract here ──
///
/// Anything short of a series the chart can draw has to fall through to the
/// seven daily calls, because the alternative is what this page shipped for
/// months: an empty list drawn as seven empty bars, indistinguishable from a
/// week with no riding in it. Absent, null, not a list, empty, entries that
/// are not maps, entries with no day or no parseable distance — all of them
/// are "no series", and none of them is a chart.
///
/// ── The day label is normalised ──
///
/// The chart labels its bars with the first three characters of `day`, which
/// works for `Mon` and produces `202` for `2026-08-19`. The backend's example
/// uses the ISO form, so an ISO date is converted to the weekday it names and
/// anything else is passed through — a server that sends `Monday`, `Mon` or
/// `mon` already works, and one that sends something unrecognisable is
/// unusable rather than silently mislabelled.
static List<RiderWeeklyKms>? _readBreakdown(dynamic raw) {
if (raw is! List || raw.isEmpty) return null;
final rows = <RiderWeeklyKms>[];
for (final entry in raw) {
if (entry is! Map) return null;
final day = _dayLabel(entry['day']);
if (day.isEmpty) return null;
final kms = entry['kms'];
if (kms != null && kms is! num && double.tryParse('$kms') == null) {
return null;
}
rows.add(RiderWeeklyKms(day: day, kms: _num(kms)));
}
return rows.isEmpty ? null : rows;
}
/// `2026-08-19` → `Tue`. Any other non-empty string is returned as it came.
static String _dayLabel(dynamic raw) {
final s = raw?.toString().trim() ?? '';
if (s.isEmpty) return '';
final parsed = DateTime.tryParse(s);
if (parsed != null) return _days[parsed.weekday - 1];
return s;
}
/// The last seven days, oldest first, plus the week's total.
///
/// [userId] is unused: the token identifies the rider, and asking for someone
/// else's kilometres is not a thing the endpoint offers. Kept on the
/// signature because the call sites read better for naming whose distance
/// they mean.
Future<Map<String, dynamic>> getRiderWeeklyKms(int userId) async {
final weekly = await MilerApi.earnings(period: 'weekly');
if (!weekly.ok) {
throw Exception('Failed to fetch (code: ${weekly.status})');
}
final weekTotal = _num(weekly.map['total_kms']);
// ── The fast path ──
//
// If the server returns a per-day series, take it and spend no further
// requests. Nothing below runs.
final fast = _readBreakdown(weekly.map['breakdown']);
if (fast != null) {
return {'details': fast, 'total_kms': weekTotal};
}
// Seven days ending today, asked at once.
final today = DateTime.now();
final dates = <DateTime>[
for (var back = 6; back >= 0; back--)
DateTime(today.year, today.month, today.day - back),
];
final results = await Future.wait(
dates.map((d) => MilerApi.earnings(period: 'daily', date: _iso(d))),
);
final details = <RiderWeeklyKms>[];
var summed = 0.0;
for (final (i, res) in results.indexed) {
// A day that failed is a day with no figure, not a zero worth charting
// against the others — but the bar still has to exist or the week is six
// days long and the labels slide. Zero, and the total below is what
// corrects for it.
final km = res.ok ? _num(res.map['total_kms']) : 0.0;
summed += km;
details.add(RiderWeeklyKms(day: _days[dates[i].weekday - 1], kms: km));
}
return {
'details': details,
// The weekly total is the server's own where it has one — the seven daily
// figures are a reconstruction, and a reconstruction should not overrule
// the number the backend computed. It stands in only when the weekly call
// reported nothing.
'total_kms': weekTotal > 0 ? weekTotal : summed,
};
}
}