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>
661 lines
28 KiB
Dart
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'},
|
|
);
|
|
}
|
|
}
|