423 lines
18 KiB
Dart
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');
|
|
});
|
|
}
|