updated the sheet

This commit is contained in:
2026-08-28 18:16:28 +05:30
parent 69f4f3e909
commit 074cc0eccf
43 changed files with 2617 additions and 1201 deletions

View File

@@ -13,7 +13,7 @@ import 'package:shared_preferences/shared_preferences.dart';
/// ── The flag is gone ──
///
/// This class used to carry `useNewApi`, a `--dart-define` switch between the
/// v1 backend and a legacy one (`jupiter.doormile.app` / `queue.workolik.com`).
/// v1 backend and a legacy one, on two now-retired hosts.
/// Every provider branched on it, so the app shipped two implementations of
/// every call and only one of them was ever exercised.
///

View File

@@ -46,10 +46,11 @@ class DeviceTelemetry {
/// problem as a rider at 12% and riding.
final bool? isCharging;
/// `wifi`, `mobile`, `none` … as the platform names it.
/// `WiFi`, `4G` or `none` — the contract's vocabulary, not the plugin's.
/// See [_connectionName].
final String? connection;
/// `enabled`, `disabled`, `denied`, `denied_forever`, `unknown`.
/// `enabled` or `disabled`. See [_locationServiceName].
///
/// The one field here that is about a *choice the rider made*, which is why
/// it is worth a column of its own on the console: a disabled location
@@ -86,9 +87,7 @@ class DeviceTelemetry {
String? connection;
try {
final result = await Connectivity().checkConnectivity();
connection = result.isNotEmpty
? result.first.toString().split('.').last
: 'none';
connection = _connectionName(result);
} catch (e) {
debugPrint('[TELEMETRY] connectivity unavailable: $e');
}
@@ -104,6 +103,7 @@ class DeviceTelemetry {
service = null;
}
}
service = _locationServiceName(service);
return DeviceTelemetry(
battery: level,
@@ -113,6 +113,48 @@ class DeviceTelemetry {
);
}
/// `WiFi` / `4G` / `none`, which is the vocabulary `/miler/logs` documents.
///
/// ── The plugin's own spelling is not the contract's ──
///
/// This was `result.first.toString().split('.').last`, which is the *enum
/// constant* — `wifi`, `mobile`, `ethernet`, `vpn`, `bluetooth`, `other`.
/// Two of those happen to look right in lower case and the rest do not
/// appear in the contract at all, so the console's Connection column was
/// rendering whatever `connectivity_plus` happened to call the transport
/// this release.
///
/// `first` was also the wrong pick: the list is every active transport, so a
/// phone on Wi-Fi with mobile data up could report either depending on the
/// order the platform returned them. Wi-Fi wins where both are present,
/// because it is the one that explains a rider whose data has run out.
static String _connectionName(List<ConnectivityResult> results) {
if (results.isEmpty) return 'none';
if (results.contains(ConnectivityResult.wifi)) return 'WiFi';
if (results.contains(ConnectivityResult.mobile)) return '4G';
if (results.contains(ConnectivityResult.ethernet)) return 'ethernet';
if (results.every((r) => r == ConnectivityResult.none)) return 'none';
return 'other';
}
/// `enabled` or `disabled` — the two values the contract names.
///
/// The richer answers the fix lookup produces — `denied`, `denied_forever`,
/// `unknown` — are more useful to a dispatcher, and they are not what this
/// field accepts. On a backend that validates its enums (and this one does
/// silently: see the `Break` / `On_Break` note in [MilerApi]) an unrecognised
/// value risks the whole row, which costs the battery and the connection
/// alongside it.
///
/// So anything that is not `enabled` is `disabled` here, which is the fact
/// the column exists to report — this rider's location is not reaching us.
/// The distinction between *off* and *denied* is not lost: it still rides on
/// the MQTT `location_turned_off` alert as `error_type`.
static String? _locationServiceName(String? raw) {
if (raw == null || raw.isEmpty) return null;
return raw == 'enabled' ? 'enabled' : 'disabled';
}
/// The heartbeat payload's own field names, ready to spread into it.
///
/// Only what was actually read: an absent key is a field the console draws as

89
lib/data/heartbeat.dart Normal file
View File

@@ -0,0 +1,89 @@
/// ─────────────────────────────────────────────────────────────────────────
/// HOW OFTEN THE RIDER LOG BEATS
///
/// ── Why this constant exists ──
///
/// The heartbeat's cadence came from one place: `logseconds`, a field the
/// **legacy** login returned and the app persisted at sign-in. The v1 contract
/// at `api.doormile.com` does not send it — `POST /miler/verify-pin` answers
/// with `{success, token, user:{…, profile:{…}}}` and nothing about logging
/// cadence — so the adapter in `AuthProvider` had no value to map and wrote the
/// `?? 0` fallback into prefs.
///
/// Zero was then read as an instruction rather than as an absence. Both call
/// sites treated it as "do not beat":
///
/// • `RiderLogController.setOnDuty` started the loop only `if (interval > 0)`.
/// • `startAutoCreateLoginLoop` computed `baseInterval = 0` and returned at
/// `if (interval <= 0)`.
///
/// So on every v1 deployment the periodic loop never started. What went with
/// it was not just telemetry: `_heartbeat()` in the rider-log provider is the
/// one place that writes **both** `POST /miler/logs` (the trail the console's
/// Battery, Charging, Connection, GPS Accuracy and Location Service columns are
/// read from) and `PUT /miler/location` (the Redis geo-index dispatch searches
/// to find a rider at all). A rider on duty was reporting neither, and the
/// console drew an em dash against a phone that was measuring all of it
/// correctly — see [DeviceTelemetry], which was never the problem.
///
/// The only reason it was not total silence is that a live pickup forces the
/// interval to 30 by a separate path, so a rider mid-collection beat and a
/// rider between stops did not.
///
/// ── Why 30 seconds ──
///
/// It is the cadence the rest of the app already assumes when nobody has told
/// it otherwise: the pickup log's own `_getLogInterval` falls back to 30, and
/// both controllers hard-code 30 for the live-pickup case. Matching it means a
/// rider's location trail has one shape rather than two, and a hub that later
/// starts sending `logseconds` still wins — this is a floor under a missing
/// answer, not a replacement for a real one.
/// ─────────────────────────────────────────────────────────────────────────
library;
/// Seconds between rider-log heartbeats when the backend has not said.
const int kDefaultLogSeconds = 30;
/// The heartbeat interval to actually use, given whatever the backend said.
///
/// A positive value is honoured exactly. `null`, a zero, a negative, and a
/// string that is none of those all mean *the backend did not answer*, and the
/// answer to that is [kDefaultLogSeconds] — never zero, because zero is what
/// stopped the heartbeat starting in the first place.
///
/// Accepts an [Object] rather than an `int?` because the value arrives from two
/// different shapes: `prefs.getInt('logseconds')` gives an `int?`, while the
/// login envelope's `details['logseconds']` is untyped JSON and has been seen
/// as both a number and a string.
int resolveLogSeconds(Object? configured) {
final int? parsed = switch (configured) {
final int n => n,
final num n => n.toInt(),
final String s => int.tryParse(s.trim()),
_ => null,
};
return (parsed != null && parsed > 0) ? parsed : kDefaultLogSeconds;
}
/// The `status` a heartbeat reports, given whether the rider has live work.
///
/// ── `active` and `idle` are not words this contract knows ──
///
/// Four call sites built this string inline as `hasActivePickups ? 'active' :
/// 'idle'`, and neither value appears anywhere in the API. `status` on
/// `POST /miler/logs` takes an **availability** value — the set
/// [MilerApi.availabilityStatuses] lists, and the same set
/// `PUT /miler/availability` validates:
///
/// Offline · Available · Assigned · On_Pickup · At_Customer ·
/// Picked_Up · On_Delivery · Break · Blocked
///
/// This backend rejects an unrecognised enum **silently** — the `Break` /
/// `On_Break` note in [MilerApi] records the last time the obvious guess was
/// quietly dropped — so a bad `status` risks the whole row, and takes the
/// battery, the connection and the location reading down with it.
///
/// A rider working a counter is `On_Pickup`; a rider between stops is
/// `Available`. Both are values the console already renders.
String heartbeatStatus({required bool hasActiveWork}) =>
hasActiveWork ? 'On_Pickup' : 'Available';

View File

@@ -135,9 +135,32 @@ class MilerApi {
/// Telemetry wants strings for values that are numbers everywhere else.
static String _str(dynamic v) => v == null ? '' : v.toString();
/// `YYYY-MM-DD HH:MM:SS`, IST wall-clock, for telemetry `logdate`.
/// `YYYY-MM-DD HH:MM:SS`, the phone's **local wall clock**, for `logdate`.
///
/// ── This was briefly changed to UTC, and that was wrong ──
///
/// The reasoning looked sound: the field carries no zone suffix, so one side
/// has to name the convention, and UTC is the usual answer. It is not the
/// answer here. The Doormile backend's read range — `DBNow` / `DBToday` — and
/// the database itself run on **IST wall-clock**, so a UTC stamp from an
/// Indian handset lands 5h30m *behind* the window the console asks for, and
/// after 18:30 local it files the evening's work under the previous day.
///
/// Confirmed against the live backend on 28 Aug 2026, after the UTC change
/// had already shipped to a test build. It went unnoticed there because that
/// handset's own clock was running on UTC, so local and UTC agreed and the
/// rows landed in the window anyway.
///
/// So: local, and the phone's timezone is assumed to be the fleet's. That is
/// true for every Doormile Miler today. The durable fix is an offset on the
/// wire — an ISO-8601 stamp the backend parses with its zone — and until the
/// contract carries one, this is the convention both sides have agreed.
static String logStamp([DateTime? at]) {
final t = at ?? DateTime.now();
// `.toLocal()` because a caller can hand this a UTC `DateTime` — the
// consignment breadcrumb does — and reading `.hour` off one of those
// renders the UTC wall clock, which is the bug this method just came back
// from. A local `DateTime` passes through unchanged.
final t = (at ?? DateTime.now()).toLocal();
String p(int n) => n.toString().padLeft(2, '0');
return '${t.year}-${p(t.month)}-${p(t.day)} '
'${p(t.hour)}:${p(t.minute)}:${p(t.second)}';