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 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 _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 = [ 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 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 _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 decoded = {}; try { if (res.body.isNotEmpty) { decoded = json.decode(res.body) as Map; } } 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 : {}); final Map profile = (user['profile'] is Map) ? user['profile'] as Map : {}; // 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 = { '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': { '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 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 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 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 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 jsonMap = res.body.isNotEmpty ? json.decode(res.body) as Map : {}; 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 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({ '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'}, ); } }