Three fixes found by running the app on a real handset against production. 1. An expired token left the app looking signed in and unable to work. MilerApi.onUnauthorized was declared and called on every 401 but never assigned, so the token was dropped and nothing else happened: the profile stayed on disk, logged_out stayed false, and the rider saw his own name over a dashboard whose every call returned 401. He reads that as "no work today". The teardown now lives in endSession() and both ways out of a session — the Log out button and the 401 path — use it. 2. Arrived was written locally even when the rider was not there. updateArrivedStatus answers false for three different things and the caller treated all of them as "the write did not land", which is only true of one. A geofence refusal and a server refusal now stop the rung and hand back the reason; a dead network still advances, as it should. 3. A multi-destination customer pickup collapsed onto one stop. GET /miler/bookings returns a row per destination once collected, all with the same bookingid and reference. Every local store keys on that id, so the accepted store deduped two of three drops away and their consignment ids were unrecoverable. orderid is now the stop key; bookingreference stays the booking's name. Cards show "Stop 2 of 3" and the receiver's own name and number rather than the sender's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
671 lines
29 KiB
Dart
671 lines
29 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/heartbeat.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,
|
|
|
|
/// True for a rider setting his PIN for the first time — routes to
|
|
/// `POST /miler/set-pin` and sends the digits as `new_pin`. The response,
|
|
/// the token handling and the persisted identity are identical either way.
|
|
bool firstTime = false,
|
|
}) async {
|
|
// The legacy `rider/login` path on the retired backend 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,
|
|
path: firstTime ? '/miler/set-pin' : '/miler/verify-pin',
|
|
pinField: firstTime ? 'new_pin' : 'pin',
|
|
);
|
|
}
|
|
|
|
/// 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.
|
|
/// ── One normaliser, two routes ──
|
|
///
|
|
/// `POST /miler/set-pin` answers with the **same envelope** as verify-pin —
|
|
/// `{success, token, tenantid, tenantname, user:{authname, contactno,
|
|
/// profile:{…}}}` — because it is also a sign-in: a rider who sets his PIN is
|
|
/// logged in by that one call and does not verify afterwards.
|
|
///
|
|
/// So the path and the PIN field name are arguments rather than a second copy
|
|
/// of this function. Everything below — finding the token in any of the nine
|
|
/// places this contract has worn it, splitting the display name, building the
|
|
/// legacy `{status, details:{…}}` envelope that [loginParsed] persists from —
|
|
/// is the part that must not drift between the two paths, and now cannot.
|
|
Future<http.Response> _loginNew({
|
|
required String contactNo,
|
|
int? pin,
|
|
String? pinRaw,
|
|
String? fcmToken,
|
|
String path = '/miler/verify-pin',
|
|
String pinField = 'pin',
|
|
}) async {
|
|
final uri = Uri.parse(ApiConfig.url(path));
|
|
// 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) pinField: 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', path, 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,
|
|
// ── Zero is not a safe default for this one ──
|
|
//
|
|
// Every other field here is an *identifier*, and an unknown identifier
|
|
// is honestly zero. `logseconds` is a **cadence**, and zero read as a
|
|
// cadence means "never beat" — which is exactly how both call sites in
|
|
// [RiderLogController] took it, so the rider-log loop never started on
|
|
// a v1 deployment and the console lost battery, connection, GPS and
|
|
// the rider's live position with it. See [kDefaultLogSeconds].
|
|
'logseconds': kDefaultLogSeconds,
|
|
// ── 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: 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,
|
|
bool firstTime = false,
|
|
}) async {
|
|
final res = await login(
|
|
contactNo: contactNo,
|
|
deviceType: deviceType,
|
|
configId: configId,
|
|
deviceId: deviceId,
|
|
fcmToken: fcmToken,
|
|
pin: pin,
|
|
pinRaw: pinRaw,
|
|
firstTime: firstTime,
|
|
);
|
|
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);
|
|
// Not `?? 0`. This is read back as the heartbeat's interval, and a zero
|
|
// there stops the loop starting at all — see [resolveLogSeconds]. A
|
|
// legacy backend that sends a real cadence still wins; anything else
|
|
// lands on the 30s floor rather than on silence.
|
|
await prefs.setInt(
|
|
'logseconds',
|
|
resolveLogSeconds(details['logseconds']),
|
|
);
|
|
|
|
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);
|
|
}
|
|
|
|
/// Deprecated. `POST /miler/reset-pin` is admin-only and this app must never
|
|
/// call it — it was once open, and reset-pin followed by verify-pin took over
|
|
/// any rider account given nothing but a phone number.
|
|
///
|
|
/// ── The 403 this used to manufacture is gone ──
|
|
///
|
|
/// There was no rider-facing PIN write, so this returned a synthetic
|
|
/// `403 "Your MPIN is issued by your office and cannot be changed from the
|
|
/// app."` — true when written, and a dead end for the Create-MPIN screen.
|
|
///
|
|
/// `POST /miler/set-pin` is that route, shipped 2026-09-16. It is
|
|
/// self-service, cannot overwrite an existing PIN (409), and returns a full
|
|
/// session. Riders set their own PIN on first sign-in and the console no
|
|
/// longer issues one, so nothing needs this method any more.
|
|
@Deprecated('Use AuthController.setPin, which calls POST /miler/set-pin.')
|
|
Future<http.Response> updatePin({
|
|
required int userId,
|
|
required int pin,
|
|
}) async {
|
|
ApiConfig.logGap(
|
|
'updatePin',
|
|
'reset-pin is admin-only; first-time PIN creation goes through '
|
|
'POST /miler/set-pin. This method should have no callers.',
|
|
);
|
|
return http.Response(
|
|
json.encode(<String, dynamic>{
|
|
'status': false,
|
|
'code': 410,
|
|
'message':
|
|
'This app no longer changes PINs through reset-pin. Set your PIN '
|
|
'on the sign-in screen.',
|
|
}),
|
|
410,
|
|
headers: {'content-type': 'application/json'},
|
|
);
|
|
}
|
|
}
|