Files
doormile_milderapp/lib/providers/Riderlog/riderlog_provider.dart
2026-08-28 18:16:28 +05:30

423 lines
18 KiB
Dart

import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/data/api_config.dart';
import 'package:miler/data/miler_api.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// DUTY, BREAKS AND THE LOCATION HEARTBEAT
///
/// ── What was deleted here ──
///
/// A `_buildSslBypassClient()` that returned an `IOClient` whose
/// `badCertificateCallback` returned `true` for every certificate, pinned a
/// hardcoded IP for the retired backend's host, and hand-rolled its own
/// `SecureSocket` upgrade. It existed to work around a carrier's broken DNS on
/// a backend that is no longer deployed — and it meant every request it carried
/// could be read and rewritten by anything on the path, including a rider's GPS
/// track and his duty state.
///
/// It is gone along with the rest of the legacy path. `api.doormile.com` is
/// reached over ordinary, verified TLS.
///
/// ── The class names survive their implementations ──
///
/// `RiderLogController` drives duty through create/update/get "rider log"
/// calls, which is the legacy shape. Rather than rewrite that controller, these
/// four classes keep their names and map the intent onto the real endpoints:
/// the first log of a session starts duty, later ones are location heartbeats,
/// and an off-duty payload ends it.
/// ─────────────────────────────────────────────────────────────────────────
String _s(dynamic v) => v == null ? '' : v.toString();
double? _d(dynamic v) => double.tryParse(_s(v));
/// ─────────────────────────────────────────────────────────────────────────
/// WHY THE CONSOLE'S DEVICE PANEL WAS EMPTY
///
/// Not the telemetry. [DeviceTelemetry] reads the handset correctly, its
/// payload carries every field the console draws, and every caller spreads it
/// into the heartbeat. The reading was right and it never left the phone.
///
/// [_heartbeat] is the only thing that posts `POST /miler/logs`, and it is also
/// the only thing that writes `PUT /miler/location`. Both were unreachable,
/// because the two entry points below gate on one locally cached number:
///
/// ```
/// if ((prefs.getInt('dutylogid') ?? 0) <= 0) return _startDuty(data);
/// return _heartbeat(data);
/// ```
///
/// `dutylogid` is written in exactly one place — a successful
/// `POST /miler/duty/start` — and it is zeroed on going off duty. So there are
/// two ordinary ways to end up on duty with a zero, and both are permanent:
///
/// • **The server already has the rider on duty.** `duty/start` answers 400,
/// which [_startDuty] correctly treats as a reconciliation rather than a
/// failure and delegates to [_currentDuty] — which read `dutylogid` out of
/// the response and wrote it to `logid` and `logId` **and not to
/// `dutylogid`**. The gate stayed shut. Next tick: `duty/start` again, 400
/// again, reconcile again, still shut. Forever, and silently, because
/// nothing in that loop is an error. It happens after a reinstall, after
/// cleared storage, after an `endDuty` that never landed, and on any fresh
/// login for a rider the backend still has on shift.
///
/// • **The response spells the id differently.** [_startDuty] read one key on
/// one level. Anything else — `logid`, `id`, a top-level field rather than
/// one under `data` — parsed to zero and was *stored* as zero, which puts a
/// rider into the same permanent loop on his very first shift. This codebase
/// has been bitten by exactly this before; see `AuthProvider._tokenIn`,
/// which looks in nine places for the bearer for precisely this reason.
///
/// ── The design error underneath both ──
///
/// `POST /miler/logs` does not take a duty log id. Its contract is
/// `{logdate, latitude, longitude, battery, is_charging, connection,
/// location_service, accuracy, is_background}` — nothing about duty. Gating
/// telemetry on an identifier the endpoint does not want, that the backend is
/// not obliged to return, is what turned one unmapped field into total
/// silence on both writes.
///
/// So the gate is now *"has duty been established"* — a fact — rather than
/// *"do we happen to hold a non-zero id"*, which is an artefact. The id is
/// still stored when it arrives, because [_dutyEnvelope] and the legacy duty
/// readers want it; it simply no longer decides whether a rider reports.
/// ─────────────────────────────────────────────────────────────────────────
/// Set once duty is known to be on, and cleared when it goes off.
///
/// Deliberately separate from `dutylogid`: this answers "should the heartbeat
/// run", which is a question about duty, and the id answers "which duty log
/// row", which is a question about the backend's bookkeeping. Conflating them
/// is the bug above.
const String _kDutyEstablished = 'duty_established';
/// Every spelling and nesting the duty endpoints have used for the log id.
///
/// Ordered by how the current contract answers, then by what deployments have
/// been seen to send. A miss is no longer fatal — see [_dutyEstablished] — but
/// finding the id keeps the legacy `logid` readers correct.
const List<String> _dutyLogIdKeys = [
'dutylogid',
'dutyLogId',
'duty_log_id',
'logid',
'logId',
'log_id',
'id',
];
/// The duty log id anywhere in [res], or `0`.
///
/// Looks in the envelope's `data` and in the undecorated body, because a
/// handler that returns `{success, dutylogid}` and one that returns
/// `{success, data:{dutylogid}}` are both shapes this contract has worn.
int _dutyLogIdIn(ApiResult res) {
final sources = <Map>[res.map, if (res.raw is Map) res.raw as Map];
for (final source in sources) {
for (final key in _dutyLogIdKeys) {
final v = source[key];
if (v == null) continue;
final n = int.tryParse(v.toString().trim());
if (n != null && n > 0) return n;
}
}
return 0;
}
/// Records that the rider is on duty, and the id if the backend sent one.
///
/// The flag is set whether or not there is an id, which is the whole fix: a
/// rider whose duty the server has confirmed reports, even when the response
/// did not name the row.
Future<void> _rememberDuty(int dutyLogId) async {
final prefs = await SharedPreferences.getInstance();
if (dutyLogId > 0) {
await prefs.setInt('logid', dutyLogId);
await prefs.setInt('logId', dutyLogId);
await prefs.setInt('dutylogid', dutyLogId);
}
await prefs.setBool(_kDutyEstablished, true);
await prefs.setInt('onduty', 1);
}
/// Forgets duty, on the way off shift.
Future<void> _forgetDuty() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('dutylogid', 0);
await prefs.setBool(_kDutyEstablished, false);
}
/// Whether duty has been established, so the heartbeat should run.
Future<bool> _dutyEstablished() async {
final prefs = await SharedPreferences.getInstance();
if (prefs.getBool(_kDutyEstablished) == true) return true;
// An install that predates the flag: a stored id means duty was started by
// an earlier build, and that rider must not have to go off and on again to
// start reporting.
return (prefs.getInt('dutylogid') ?? 0) > 0;
}
/// Legacy `{status, details}` for the duty-state readers.
Map<String, dynamic> _dutyEnvelope(Map data) {
final bool on = data['onduty'] == true;
final dutylogid = data['dutylogid'] ?? 0;
return {
'status': true,
'code': 200,
'details': {
'logid': dutylogid,
'onduty': on ? 1 : 0,
'login': data['loginat'],
},
};
}
Future<Map<String, dynamic>?> _startDuty(Map data) async {
final res = await MilerApi.startDuty(
lat: _d(data['latitude']),
lon: _d(data['longitude']),
);
if (res.ok) {
// Looked at one key on one level and stored whatever it found, including
// a zero — which shut the heartbeat's gate permanently on a response that
// had merely spelled the id differently. See [_dutyLogIdIn].
final id = _dutyLogIdIn(res);
await _rememberDuty(id);
return {
'status': true,
'code': 200,
'details': {'logid': id, 'login': res.map['loginat'], 'onduty': 1},
};
}
// "Already on duty" is a 400 and is not a failure — it is the app and the
// server disagreeing about state, which the current duty answers.
if (res.status == 400) return _currentDuty();
return {'status': false, 'code': res.status};
}
Future<Map<String, dynamic>?> _currentDuty() async {
final res = await MilerApi.dutyCurrent();
if (!res.ok) return {'status': false, 'code': res.status};
final on = res.map['onduty'] == true;
if (on) {
// ── The line that was missing ──
//
// This wrote the id to `logid` and `logId` and stopped, leaving
// `dutylogid` — the only key the heartbeat's gate reads — at zero. So the
// reconciliation that exists to recover from "the server already has you
// on duty" recovered the duty state and not the ability to report it, and
// the next tick came straight back here. See the note at the top.
await _rememberDuty(_dutyLogIdIn(res));
} else {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('onduty', 0);
await _forgetDuty();
}
return _dutyEnvelope(res.map);
}
/// The GPS heartbeat, which is two writes with different lifetimes:
///
/// • `PUT /miler/location` — Redis, and the index dispatch searches. This is
/// the one that decides whether the rider is findable.
/// • `POST /miler/logs` — the telemetry trail, for reconstructing a shift
/// afterwards.
///
/// Both are best-effort and neither blocks the rider. The location write is
/// reported back because duty state depends on it; the log is fire-and-forget.
Future<Map<String, dynamic>?> _heartbeat(Map data) async {
final lat = _d(data['latitude']);
final lon = _d(data['longitude']);
if (lat == null || lon == null) return ApiConfig.okEnvelope('no fix');
final res = await MilerApi.pushLocation(
latitude: lat,
longitude: lon,
pincode: _s(data['pincode']).isEmpty ? null : _s(data['pincode']),
speed: _d(data['speed']),
heading: _d(data['heading']),
);
// ── Awaited, but its outcome is still discarded ──
//
// This was `unawaited(...)`, for a good reason that turned out to be the
// wrong mechanism: a telemetry failure must never read as a location failure,
// because the caller treats the latter as duty state going wrong. `unawaited`
// achieves that by never looking at the result — and also by not waiting for
// the request to finish.
//
// On Android this heartbeat runs inside `flutter_foreground_task`'s **own
// Flutter engine**, spun up per tick. When the callback returns, that engine
// can be suspended before an in-flight future completes. So the awaited
// `PUT /miler/location` above landed on every tick and this `POST /miler/logs`
// was killed mid-flight — which is exactly what the backend saw: a rider with
// a live position in Redis and no new telemetry row behind it, while the app
// logged a successful heartbeat.
//
// Awaiting it costs one round trip on a call that already had to happen and
// guarantees the request outlives the tick. The isolation the `unawaited` was
// protecting is kept by the `try`: the result is logged and thrown away, and
// nothing below branches on it.
try {
final logRes = await MilerApi.postLog(
latitude: lat,
longitude: lon,
speed: _d(data['speed']),
heading: _d(data['heading']),
accuracy: _d(data['accuracy']),
status: _s(data['status']).isEmpty ? null : _s(data['status']),
orderId: data['orderid'],
battery: int.tryParse(_s(data['battery'])),
isCharging: data['is_charging'] == true,
connection: _s(data['connection']).isEmpty
? null
: _s(data['connection']),
// ── This was being dropped on the floor ──
//
// Every other device field on the heartbeat was forwarded and this one
// was not, so `location_service` could never reach `/miler/logs` however
// faithfully the caller supplied it — and the console's Location Service
// column had no field to read. It is the single most useful thing on
// that panel: a rider who has turned location off explains his own
// silence, and nothing else on the row can.
locationService: _s(data['location_service']).isEmpty
? null
: _s(data['location_service']),
isBackground: data['is_background'] == true,
);
// The one line that says whether telemetry reached the console. It was
// silent on success *and* on failure, which is why a dropped post took
// several rounds of backend archaeology to find.
debugPrint(
'[RIDERLOG][LOGS] POST /miler/logs -> ${logRes.status} '
'ok=${logRes.ok} battery=${_s(data['battery'])} '
'connection=${_s(data['connection'])}',
);
} catch (e) {
debugPrint('[RIDERLOG][LOGS] POST /miler/logs failed: $e');
}
return res.ok
? ApiConfig.okEnvelope()
: {'status': false, 'code': res.status};
}
/// True when the payload means "go off duty".
bool _isOffDutyIntent(Map data) {
final status = _s(data['status']).toLowerCase();
final onduty = data['onduty'];
final bool ondutyZero = onduty == 0 || _s(onduty) == '0';
return status.contains('offline') ||
status.contains('logout') ||
data.containsKey('logout') ||
(data.containsKey('onduty') && ondutyZero);
}
class CreateRiderLogProvider {
/// The first call of a session starts duty; every later one is a heartbeat.
///
/// ── The start is no longer a tick the rider loses ──
///
/// This returned [_startDuty]'s envelope and stopped, so the tick that opened
/// the shift carried no telemetry and no position — the console had nothing
/// until the next one, thirty seconds later. Worse, when `duty/start`
/// reconciled a rider the server already had on duty, the old gate never
/// opened at all and *every* tick ended here. See the note at the top of this
/// file.
///
/// Duty is established first, then the same payload beats immediately. The
/// heartbeat is best-effort and its result is not merged into the duty
/// envelope, because the caller reads that envelope for duty state and a
/// telemetry failure is not a duty failure.
Future<Map<String, dynamic>?> createRiderLog(
Map<String, dynamic> data,
) async {
if (await _dutyEstablished()) return _heartbeat(data);
final started = await _startDuty(data);
if (await _dutyEstablished()) await _heartbeat(data);
return started;
}
}
class UpdateRiderLogProvider {
Future<Map<String, dynamic>?> updateRiderLog(
Map<String, dynamic> data,
) async {
if (_isOffDutyIntent(data)) {
await _forgetDuty();
final res = await MilerApi.endDuty();
if (res.ok) {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('onduty', 0);
}
return res.ok
? ApiConfig.toLegacyEnvelope(res.raw ?? {'success': true})
: {'status': false, 'code': res.status};
}
final onduty = data['onduty'];
if ((onduty == 1 || _s(onduty) == '1') && !await _dutyEstablished()) {
// Same shape as [CreateRiderLogProvider.createRiderLog]: establish duty,
// then beat on the same tick rather than returning and leaving the
// console with nothing for another interval.
final started = await _startDuty(data);
if (await _dutyEstablished()) await _heartbeat(data);
return started;
}
return _heartbeat(data);
}
}
class GetRiderLogProvider {
Future<Map<String, dynamic>?> getRiderLog() => _currentDuty();
/// There is no rider-count endpoint in the contract, and no screen that needs
/// one — the dashboard figures come from bookings and earnings. Kept as a
/// zero so the one legacy caller does not have to branch.
Future<Map<String, dynamic>?> getRiderCount() async {
ApiConfig.logGap('getRiderCount', 'No count endpoint in v1.');
return {'status': true, 'count': 0};
}
}
class BreakRiderLogProvider {
Future<Map<String, dynamic>?> createBreakRiderLog(
Map<String, dynamic> data,
) async {
final bt = _s(data['breaktype'] ?? data['reason'] ?? 'Personal');
final res = await MilerApi.startBreak(bt);
if (res.ok) {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(
'breaklogid',
int.tryParse('${res.map['breaklogid'] ?? 0}') ?? 0,
);
// The availability enum's break value is `Break`, not `On_Break` — the
// obvious guess is the wrong one and is silently rejected.
unawaited(MilerApi.setAvailability('Break'));
return ApiConfig.toLegacyEnvelope(res.raw ?? {'success': true});
}
return {'status': false, 'code': res.status};
}
Future<Map<String, dynamic>?> updateBreakRiderLog() async {
final res = await MilerApi.endBreak();
if (res.ok) unawaited(MilerApi.setAvailability('Available'));
return res.ok
? ApiConfig.toLegacyEnvelope(res.raw ?? {'success': true})
: {'status': false, 'code': res.status};
}
}
/// Local `unawaited`, so a fire-and-forget call reads as deliberate rather than
/// as a missing `await`.
void unawaited(Future<void> future) {
future.catchError((Object e) {
debugPrint('[RIDERLOG] background call failed: $e');
});
}