updated the sheet
This commit is contained in:
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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
89
lib/data/heartbeat.dart
Normal 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';
|
||||
@@ -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)}';
|
||||
|
||||
Reference in New Issue
Block a user