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 _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 = [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 _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 _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 _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 _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?> _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?> _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?> _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?> createRiderLog( Map data, ) async { if (await _dutyEstablished()) return _heartbeat(data); final started = await _startDuty(data); if (await _dutyEstablished()) await _heartbeat(data); return started; } } class UpdateRiderLogProvider { Future?> updateRiderLog( Map 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?> 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?> getRiderCount() async { ApiConfig.logGap('getRiderCount', 'No count endpoint in v1.'); return {'status': true, 'count': 0}; } } class BreakRiderLogProvider { Future?> createBreakRiderLog( Map 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?> 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 future) { future.catchError((Object e) { debugPrint('[RIDERLOG] background call failed: $e'); }); }