Files
doormile_milderapp/lib/providers/auth/auth_provider.dart
Thiru-tenext d7348e253f Miler rider app: surface system, visible design language, backend lifecycle
Design system
- MilerSurface ladder (canvas → working → raised → floating) with MilerPanel
  as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1.
- Visible vocabulary applied across Home, Deliveries, Activity, Account and
  the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x),
  canvas wells for anything that opens, small filled tags for shelf labels,
  demoted placeholders. Recorded in DESIGN_SYSTEM.md §6.
- One icon family: 222 Material glyphs migrated to Lucide; none left outside
  lib/xpress.
- Colour semantics corrected: amber only for what is genuinely owed, brand red
  reserved for the live stop, disabled primaries go neutral rather than pale.

Data and lifecycle
- lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart
  makes admin sequence the single ordering authority; service_day.dart, and
  stop_area.dart rewritten against live Coimbatore addresses (digit-token
  stripping, city stoplist, street suffixes, stammer collapse).
- countLabel states the load once, in bags.

Testing
- 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity,
  sheets and verify, with test/failures/ now gitignored (diff debris).
- New pins: home_gutter_test, stop_area_test, plus updated structural bounds.

Note: this commit also carries pre-existing working-tree deletions that were
present before this work (API_SPEC.md, README.md, demo test fixtures).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 05:40:35 +05:30

661 lines
28 KiB
Dart

import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'package:miler/Models/login/login.dart';
import 'package:miler/data/api_config.dart';
import 'package:miler/data/mock_backend.dart';
import 'package:miler/data/miler_api.dart';
import 'package:miler/data/service_profile.dart';
import 'package:shared_preferences/shared_preferences.dart';
class AuthProvider {
/// Null rather than 0 for anything unparseable, so a missing tenant falls
/// through to the build's value instead of masquerading as tenant zero.
static int? _toInt(dynamic v) {
if (v == null) return null;
if (v is int) return v;
if (v is num) return v.toInt();
return int.tryParse(v.toString().trim());
}
/// Null for zero, so an absent tenant falls through the `??` chain instead of
/// stopping it as tenant zero — which is not a tenant, but is truthy enough
/// to look like one.
static int? _nonZero(int v) => v == 0 ? null : v;
/// What to tell the rider when `verify-pin` did not succeed.
///
/// The server's own sentence when it sent one — `incorrect PIN` is precise
/// and actionable. Otherwise the status code and a short slice of the body,
/// because "something between this phone and the server said no" is a real
/// answer and "your PIN is wrong" is a false one.
static String _failureText(http.Response res, Map<String, dynamic> decoded) {
final said = (decoded['message'] ?? decoded['error'] ?? '')
.toString()
.trim();
if (said.isNotEmpty) return said;
final body = res.body.trim();
final preview = body.length > 120 ? '${body.substring(0, 120)}…' : body;
if (preview.isEmpty) {
return 'The server returned HTTP ${res.statusCode} with no message.';
}
return 'The server returned HTTP ${res.statusCode}: $preview';
}
/// Every spelling and nesting this contract has used for the bearer token.
///
/// Order is deliberate: the envelope first, because that is where the handler
/// puts it today, then the two payload maps for deployments that nest it.
static const List<String> _tokenKeys = [
'token',
'access_token',
'accessToken',
'authtoken',
'authToken',
'jwt',
'idToken',
'id_token',
'bearer',
];
/// First non-empty token found across the response's envelope, its `data`
/// child, and the two payload maps — or `''`. Never throws on a shape it does
/// not recognise.
@visibleForTesting
static String tokenFromResponse(Map? envelope, Map? user, Map? profile) =>
_tokenIn(envelope, user, profile);
static String _tokenIn(Map? envelope, Map? user, Map? profile) {
final candidates = <Map>[
if (envelope != null) envelope,
if (envelope != null && envelope['data'] is Map) envelope['data'] as Map,
if (user != null) user,
if (profile != null) profile,
];
for (final map in candidates) {
for (final key in _tokenKeys) {
final v = map[key];
if (v == null) continue;
final s = v.toString().trim();
if (s.isNotEmpty && s.toLowerCase() != 'null') return s;
}
}
return '';
}
Future<http.Response> login({
required String contactNo,
required String deviceType,
required int configId,
required String deviceId,
required String fcmToken,
int? pin,
String? pinRaw,
}) async {
// The legacy `jupiter.doormile.app/.../rider/login` path that used to sit
// behind a flag here is gone with the rest of the old backend.
return _loginNew(
contactNo: contactNo,
pin: pin,
pinRaw: pinRaw,
fcmToken: fcmToken,
);
}
/// NEW API: POST /miler/verify-pin { phone, pin, device_token }
/// -> { success, token, data:{ userid, displayname, phone, hubid,
/// availabilitystatus, rating } }
///
/// We store the bearer token, then return a synthetic http.Response whose body
/// is the LEGACY `{status, details:{...}}` shape so [loginParsed] persists the
/// same SharedPreferences keys it always has — no change to the auth UI flow.
///
/// `device_token` is REQUIRED for the rider to receive push at all: the backend
/// stores it on the miler profile at verify-pin time and
/// AssignMilerToBooking pushes "New Pickup Assigned" to exactly that token.
/// Omitting it leaves the profile's token empty and silently makes the app
/// poll-only.
Future<http.Response> _loginNew({
required String contactNo,
int? pin,
String? pinRaw,
String? fcmToken,
}) async {
final uri = Uri.parse(ApiConfig.url('/miler/verify-pin'));
// The backend bcrypt-compares the PIN as a STRING, so a leading zero is
// significant. Prefer the raw text the rider typed — round-tripping through
// int drops it ("0512" -> 512 -> "512") and fails a valid PIN.
final String? pinValue = (pinRaw != null && pinRaw.isNotEmpty)
? pinRaw
: pin?.toString();
final body = {
'phone': contactNo,
if (pinValue != null) 'pin': pinValue,
// Riders live in partition 1001. It defaults server-side, so omitting it
// appeared to work — but a miler row created without it can never log in,
// and sending it explicitly is the only way the app and the console agree
// about which partition a rider belongs to.
'configid': MilerApi.configId,
// Which operating company this build signs riders in for. Omitted
// entirely when the build declares none — see [MilerApi.tenantId].
if (MilerApi.hasTenantId) 'tenantid': MilerApi.tenantId,
if (fcmToken != null && fcmToken.isNotEmpty) 'device_token': fcmToken,
};
debugPrint('[AUTH][LOGIN][NEW] URL: $uri');
debugPrint('[AUTH][LOGIN][NEW] Body: ${json.encode(body)}');
// ── The mock is a *response*, not a shortcut ──
//
// This used to `return` the mocked payload directly, and everything that
// makes a payload usable happens BELOW: the bearer token is stored there,
// and the response is mapped into the `{status, details:{…}}` envelope that
// `Login.fromJson` reads. Returning early skipped both.
//
// So on the mock path `status` was never set — and an absent `status`
// parses as **false** — and no token was ever stored. The sign-in saw a
// rejected login with no HTTP status behind it and told the rider
// "Login failed", for every phone number and every MPIN. `MOCK_BACKEND`
// defaults to on in debug, which is how the app is run, so that was the
// state of sign-in for anyone not building release.
//
// The whole point of rule 2 in [MockBackend] is that it intercepts the
// *transport* and nothing downstream can tell the difference. An early
// return is not an intercepted transport; it is a second implementation of
// this function that nobody was testing. One `res`, one path.
//
// Signing in with nothing to sign in to. This is the one call that cannot
// be intercepted in [MilerApi] — auth posts its own request, because it
// runs before there is a token for the shared transport to attach.
final mocked = MockBackend.respond('POST', '/miler/verify-pin', body: body);
final http.Response res = mocked != null
? http.Response(
json.encode(mocked),
200,
headers: const {'content-type': 'application/json'},
)
: await http.post(
uri,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: json.encode(body),
);
debugPrint('[AUTH][LOGIN][NEW] Status: ${res.statusCode}');
debugPrint('[AUTH][LOGIN][NEW] Response: ${res.body}');
Map<String, dynamic> decoded = <String, dynamic>{};
try {
if (res.body.isNotEmpty) {
decoded = json.decode(res.body) as Map<String, dynamic>;
}
} catch (_) {}
final bool ok =
decoded['success'] == true &&
res.statusCode >= 200 &&
res.statusCode < 300;
// REAL shape: { success, token, user:{ authname, contactno, email, userid,
// profile:{ userid, displayname, phone, hubid, applocationid,
// availabilitystatus, rating, ... } } } (the pasted doc was wrong).
final Map user = (decoded['user'] is Map)
? decoded['user'] as Map
: (decoded['data'] is Map
? decoded['data'] as Map
: <String, dynamic>{});
final Map profile = (user['profile'] is Map)
? user['profile'] as Map
: <String, dynamic>{};
// Prefer profile field, then top-level user field, then the envelope.
//
// The envelope is in the chain for the tenant pair: `verify-pin` returns
// `tenantid`/`tenantname` on `user`, but a handler that later moves them
// to the top level should not silently drop the rider back to the default
// line. Three places to look costs nothing and removes a whole class of
// "why is this rider on the wrong flow" report.
dynamic pick(String k) => profile[k] ?? user[k] ?? decoded[k];
// ── Find the bearer token wherever this deployment puts it ──
//
// This read `decoded['token']` and nothing else, and the sign-in then used
// "is there a token in prefs?" as its test for *whether the PIN was right*.
// So a backend that nests the token one level down — `data.token`,
// `user.token`, `access_token`, any of the spellings this contract has
// worn — produced a verify-pin that **succeeded** and a sign-in that
// reported **"Login failed"**. For every rider, on every attempt, with the
// server agreeing the PIN was correct.
//
// Looking in nine places costs nothing and removes the whole class of
// failure. [_tokenIn] returns '' when there is genuinely no token, which is
// a different fact from a rejected PIN and is now reported as one.
final String token = _tokenIn(decoded, user, profile);
if (token.isNotEmpty) {
await ApiConfig.setToken(token);
} else if (ok) {
debugPrint(
'[AUTH][LOGIN][NEW] server said success but carried no token; '
'top-level keys: ${decoded.keys.toList()}',
);
}
// displayname -> first/last name split (best effort).
final String displayName = (pick('displayname') ?? user['authname'] ?? '')
.toString()
.trim();
final int spaceIdx = displayName.indexOf(' ');
final String firstName = spaceIdx > 0
? displayName.substring(0, spaceIdx)
: displayName;
final String lastName = spaceIdx > 0
? displayName.substring(spaceIdx + 1).trim()
: '';
final String availability = (pick('availabilitystatus') ?? 'Offline')
.toString();
final int onduty =
(availability.toLowerCase() == 'offline' || availability.isEmpty)
? 0
: 1;
final userId = user['userid'] ?? profile['userid'] ?? 0;
final phone = user['contactno'] ?? profile['phone'] ?? contactNo;
// Map the new payload into the legacy `details` shape loginParsed reads.
final legacy = <String, dynamic>{
'status': ok,
// Whether THIS call produced a session, as distinct from whether the
// credentials were accepted. The sign-in needs to tell those apart to
// say anything useful.
'tokenstored': token.isNotEmpty,
// ── The transport failure used to vanish here ──
//
// This was `decoded['code'] ?? 400`. A proxy 502, a captive portal's
// redirect page, a gateway timeout — anything whose body is not the JSON
// this handler speaks — decoded to `{}`, so the code became a hard-coded
// 400 and the message became `''`. The sign-in then reported all of it
// as **"Login failed"**, which the rider reads as "my PIN is wrong".
//
// The real status now survives, and so does a slice of whatever came
// back, so a failing handset can say what it is actually being told
// instead of only that it is unhappy.
'code': ok ? 200 : res.statusCode,
'httpstatus': res.statusCode,
'message': ok ? '' : _failureText(res, decoded),
'details': <String, dynamic>{
'userid': userId,
'riderid': userId,
'displayname': displayName,
'username': displayName,
'firstname': firstName,
'lastname': lastName,
'contactno': phone,
'email': user['email'] ?? '',
// hub/app-location scoping.
'hubid': profile['hubid'] ?? 0,
'locationid': profile['applocationid'] ?? profile['hubid'] ?? 0,
'applocationid': profile['applocationid'] ?? 0,
'rating': pick('rating') ?? 0,
'availabilitystatus': availability,
'onduty': onduty,
// Fields the new contract does not provide yet — safe defaults.
'shiftid': 0,
'logid': 0,
'partnerid': 0,
'configid': 0,
// ── Which line of work this rider is on ──
//
// The server's answer first, the build's declared tenant only when
// there is no answer to have. `verify-pin` returns both halves now, so
// this is a live path rather than a seam: a rostered rider's account
// decides his flow, and a build flag can no longer override it.
//
// This is the switch that decides whether the rider gets the Logistics
// flow or the Milk Man flow — see [ServiceProfile]. "Which work am I
// doing today?" must be answered by the hub that rostered him, not by
// whoever compiled the APK.
// The token's claim sits between the body and the build flag: it is the
// same fact from the same source, signed by the server, and it is
// present on every deployment — including ones whose `verify-pin` does
// not put the tenant in the body. That was the live case: a login
// carrying tenant 13 in its token, a body with no tenant at all, and
// every rider falling through to the default line.
'tenantid':
_toInt(pick('tenantid')) ??
_nonZero(ApiConfig.tenantIdFromToken(token)) ??
MilerApi.tenantId,
'tenantname': (pick('tenantname') ?? pick('tenantcode') ?? '')
.toString(),
'pickupradius': 100,
'starttime': '',
'endtime': '',
},
};
// Also persist contactno directly (rider logs read it from prefs).
final prefs = await SharedPreferences.getInstance();
await prefs.setString('contactno', phone.toString());
return http.Response(
json.encode(legacy),
ok ? 200 : res.statusCode,
headers: {'content-type': 'application/json'},
);
}
/// NEW API: POST /miler/login { phone }
///
/// Account-existence precheck. 200 means the phone belongs to an active miler
/// account that already has a PIN on file, so the rider should go straight to
/// the MPIN screen — no OTP, no Create-MPIN (which would overwrite the PIN
/// they were given). 404 means the number isn't registered.
///
/// Returns true only for an existing, active miler account.
/// Whether [contactNo] is an active miler account: `true`, `false`, or
/// **null when the question could not be asked**.
///
/// ── "No" and "I don't know" are not the same answer ──
///
/// This returned a plain `bool` and answered `false` for a timeout, a DNS
/// failure, a 502, and a body it could not parse. `precheckPhone` reads a
/// `false` as *this number has no account* and routes the rider into the
/// OTP → Create-MPIN flow — which cannot set a PIN (see [updatePin]) but
/// tells him it did. So one flaky request took a rider with a perfectly good
/// account and walked him into a dead end that locks him out and looks like
/// his fault.
///
/// Null now means unknown, and the caller sends unknown to the MPIN screen —
/// the destination that is right for every rider who already has an account,
/// and the one screen that can report what actually went wrong.
Future<bool?> milerAccountExists(String contactNo) async {
try {
final uri = Uri.parse(ApiConfig.url('/miler/login'));
// Every phone number has an account when there is no directory to ask.
if (MockBackend.enabled) {
debugPrint('[MOCK] POST /miler/login -> account exists');
return true;
}
final res = await http
.post(
uri,
headers: const {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: json.encode({
'phone': contactNo,
'configid': MilerApi.configId,
if (MilerApi.hasTenantId) 'tenantid': MilerApi.tenantId,
}),
)
.timeout(const Duration(seconds: 15));
debugPrint(
'[AUTH][PRECHECK] $contactNo -> ${res.statusCode} ${res.body}',
);
// 404 is a real "no such account"; 5xx and anything else is the server
// failing to answer, which is not evidence about the rider.
if (res.statusCode == 404) return false;
if (res.statusCode < 200 || res.statusCode >= 300) {
if (res.statusCode == 401 || res.statusCode == 403) return false;
return null;
}
final decoded = json.decode(res.body);
if (decoded is! Map) return null;
return decoded['success'] == true;
} catch (e) {
debugPrint('[AUTH][PRECHECK] could not reach the directory: $e');
return null;
}
}
/// NEW API: PUT /miler/device-token { device_token }
///
/// verify-pin only registers the token at login time, but FCM rotates tokens
/// independently of the session. Without re-registering, the backend keeps
/// pushing to a dead token and the rider stops seeing new-assignment alerts
/// with no visible symptom. Call this whenever the token changes.
Future<bool> saveDeviceToken(String fcmToken) async {
if (fcmToken.isEmpty) return false;
try {
final uri = Uri.parse(ApiConfig.url('/miler/device-token'));
final res = await http
.put(
uri,
headers: await ApiConfig.authHeaders(),
body: json.encode({'device_token': fcmToken}),
)
.timeout(const Duration(seconds: 15));
debugPrint('[AUTH][DEVICE_TOKEN] status=${res.statusCode}');
return res.statusCode >= 200 && res.statusCode < 300;
} catch (e) {
debugPrint('[AUTH][DEVICE_TOKEN] error: $e');
return false;
}
}
// Convenience: send using a Login model body
Future<http.Response> loginWith(Login request) async {
final uri = Uri.parse(
'https://jupiter.doormile.app/live/api/v2/users/rider/login',
);
final body = request.toJson();
debugPrint('[AUTH][LOGIN] URL: ${uri.toString()}');
debugPrint('[AUTH][LOGIN] Body: ${json.encode(body)}');
final res = await http.post(
uri,
headers: {'Content-Type': 'application/json'},
body: json.encode(body),
);
debugPrint('[AUTH][LOGIN] Status: ${res.statusCode}');
debugPrint('[AUTH][LOGIN] Response: ${res.body}');
return res;
}
// Convenience: parsed response as Login model
Future<Login> loginParsed({
required String contactNo,
required String deviceType,
required int configId,
required String deviceId,
required String fcmToken,
int? pin,
String? pinRaw,
}) async {
final res = await login(
contactNo: contactNo,
deviceType: deviceType,
configId: configId,
deviceId: deviceId,
fcmToken: fcmToken,
pin: pin,
pinRaw: pinRaw,
);
final Map<String, dynamic> jsonMap = res.body.isNotEmpty
? json.decode(res.body) as Map<String, dynamic>
: <String, dynamic>{};
debugPrint('[AUTH] Raw Login JSON: $jsonMap');
// ── Only a successful login may rewrite who the rider is ──
//
// This ran on `containsKey('details')` alone, and the failure envelope
// carries a `details` map too — full of the zeros and empty strings used as
// safe defaults. So a rejected PIN, a 502, or a `refreshSession` that fired
// without one wrote `userid=0`, `tenantid=0` and a blank name straight over
// a perfectly good signed-in session. The rider was then on the fallback
// line with no identity, from a call that had failed.
final bool succeeded = jsonMap['status'] == true;
if (succeeded && jsonMap.containsKey('details')) {
final details = jsonMap['details'];
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('userid', details['userid'] ?? 0);
await prefs.setInt('userId', details['userid'] ?? 0);
await prefs.setInt('shiftid', details['shiftid'] ?? 0);
await prefs.setInt('shiftId', details['shiftid'] ?? 0);
await prefs.setInt('logid', details['logid'] ?? 0);
await prefs.setInt('logId', details['logid'] ?? 0);
await prefs.setInt('riderid', details['riderid'] ?? 0);
await prefs.setInt('partnerid', details['partnerid'] ?? 0);
await prefs.setInt('partnerId', details['partnerid'] ?? 0);
await prefs.setInt('configid', details['configid'] ?? 0);
await prefs.setInt('logseconds', details['logseconds'] ?? 0);
await prefs.setInt('locationid', details['locationid'] ?? 0);
await prefs.setInt('tenantid', details['tenantid'] ?? 0);
await prefs.setInt('applocationid', details['applocationid'] ?? 0);
// ── Which business this rider works for ──
//
// `tenantid` was already persisted here and sent back on rider logs; it
// is now also what picks the rider's whole flow — parcel logistics or a
// subscription meal run. See [TenantController].
//
// The name is stored alongside it when the backend sends one, because a
// name can be matched without shipping a release for every new tenant id
// and it is the field a human can check against the admin console.
final String tenantName =
(details['tenantname'] ??
details['tenantcode'] ??
details['tenant'] ??
'')
.toString()
.trim();
if (tenantName.isNotEmpty) {
await prefs.setString(TenantController.kTenantName, tenantName);
} else {
// A rider signing in to a different account must not inherit the last
// one's tenant name.
await prefs.remove(TenantController.kTenantName);
}
await TenantController.to.load();
final String fcm = (details['userfcmtoken'] ?? '').toString();
if (fcm.isNotEmpty) {
await prefs.setString('userfcmtoken', fcm);
}
// Persist rider name variants for downstream usage (e.g. rider logs)
final String firstName = (details['firstname'] ?? '').toString();
final String lastName = (details['lastname'] ?? '').toString();
final String apiUsername = (details['username'] ?? '').toString();
final String combinedName = ('$firstName $lastName').trim();
if (apiUsername.isNotEmpty) {
await prefs.setString('username', apiUsername);
} else if (combinedName.isNotEmpty) {
await prefs.setString('username', combinedName);
}
if (firstName.isNotEmpty) {
await prefs.setString('firstname', firstName);
}
if (lastName.isNotEmpty) {
await prefs.setString('lastname', lastName);
}
if (details['onduty'] != null) {
final int od = (details['onduty'] is num)
? (details['onduty'] as num).toInt()
: int.tryParse('${details['onduty']}') ?? 0;
await prefs.setInt('onduty', od);
}
// Persist rider payout config (per-kilometer fuel/rider charge) if provided
if (details.containsKey('fuelcharge')) {
final double fuelCharge =
double.tryParse('${details['fuelcharge']}') ?? 0.0;
await prefs.setDouble('fuelcharge', fuelCharge);
}
// Backward compatibility with older field names
if (details.containsKey('firstmilecharge')) {
final double firstMileCharge =
double.tryParse('${details['firstmilecharge']}') ?? 0.0;
await prefs.setDouble('firstmilecharge', firstMileCharge);
} else if (details.containsKey('firstmilecharges')) {
final double firstMileCharge =
double.tryParse('${details['firstmilecharges']}') ?? 0.0;
await prefs.setDouble('firstmilecharge', firstMileCharge);
}
// Save shift window for header display
if (details['starttime'] != null) {
await prefs.setString('starttime', details['starttime'].toString());
}
await prefs.setString('endtime', details['endtime'].toString());
// Save pickup radius for geofencing (default 100m if not provided)
if (details['pickupradius'] != null) {
final int radius = (details['pickupradius'] is num)
? (details['pickupradius'] as num).toInt()
: int.tryParse('${details['pickupradius']}') ?? 100;
await prefs.setInt('pickupradius', radius);
debugPrint('[AUTH] Saved pickupradius: $radius meters');
} else {
await prefs.setInt('pickupradius', 100); // Default
debugPrint('[AUTH] Saved default pickupradius: 100 meters');
}
debugPrint(
'[AUTH] SharedPrefs Saved: '
'userid=${details['userid']}, shiftid=${details['shiftid']}, '
'logid=${details['logid']}, riderid=${details['riderid']},'
'partnerid=${details['partnerid']}, configid=${details['configid']}',
);
// Rider log creation is deferred until the rider goes ON duty.
//
// NOTE: We intentionally do NOT auto-navigate from here anymore.
// Navigation after login / PIN verification is handled in the UI flows
// (e.g. MPIN screen) so that riders cannot reach the homepage before
// successfully entering a valid PIN.
}
return Login.fromJson(jsonMap);
}
Future<http.Response> updatePin({
required int userId,
required int pin,
}) async {
// ── There is no rider-facing set-PIN endpoint, and that is deliberate ──
//
// The only PIN-write route on the backend is `POST /miler/reset-pin`, and
// it requires an ADMIN token. It was once open, and reset-pin followed by
// verify-pin took over any rider account given nothing but a phone number.
// The app must not call it; rider PIN resets go through ops.
//
// So this stays a no-op success: the Create-MPIN screen's flow completes
// and the PIN the account was issued with remains the one that works.
// Making it fail instead would strand a rider on a screen with no way
// forward, which is worse and no more honest.
// ── It used to answer 200 ──
//
// "Making it fail instead would strand a rider on a screen with no way
// forward, which is worse and no more honest." Half of that was right and
// the conclusion was wrong. What the manufactured 200 actually did:
//
// 1. Create-MPIN told the rider his new MPIN was saved.
// 2. The app wrote it to `dbPin` locally.
// 3. The server never heard about it.
// 4. Every login from then on returned `incorrect PIN`, forever, with no
// way for the rider to tell that the PIN he was typing had never
// existed anywhere but his own handset.
//
// Being stranded on a screen that tells you who can help is not worse than
// that. It is the only version of this that a rider can act on.
ApiConfig.logGap(
'updatePin',
'No rider-facing set-PIN route; reset-pin is admin-only by design. '
'Refusing rather than reporting a write that did not happen.',
);
return http.Response(
json.encode(<String, dynamic>{
'status': false,
'code': 403,
'message':
'Your MPIN is issued by your hub and cannot be changed from the '
'app. Ask your supervisor to reset it, then sign in with the MPIN '
'they give you.',
}),
403,
headers: {'content-type': 'application/json'},
);
}
}