import 'dart:convert'; import 'dart:io' show Platform; import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:get/get.dart'; import 'package:miler/views/helpers/widgets/app_widgets.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:miler/providers/auth/auth_provider.dart'; import 'package:miler/utils/device.dart'; import 'package:miler/controllers/profile_controller.dart'; import 'package:miler/Models/login/login.dart'; import 'package:miler/data/api_config.dart'; import 'package:miler/data/miler_api.dart'; import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart'; /// Which screen the phone number on the sign-in form has earned. /// /// ── `otp` is gone, and it was never real ── /// /// There is no OTP route on the miler side — `MilerApi` carries the whole auth /// surface and it is login / set-pin / verify-pin / device-token. The old `otp` /// branch fired when the directory said "no such account", sent the rider to a /// code screen that verified nothing (`verifyOtp` returned `true` without /// checking), and dead-ended at a Create-MPIN screen that could not write a /// PIN. A rider who took it could not come back. /// /// The server answers this question directly now — see [MilerApi.pinSetOf]. enum AuthNext { /// `pin_set: true` — he has a PIN. Enter-PIN, exactly as before. verifyPin, /// `pin_set: false` — a rider who has never signed in. Set-PIN. setPin, /// 404. No miler account on this number. notRegistered, /// 403. The row exists but is not an active miler. inactive, /// The directory could not be reached. Not evidence about the rider. error, } class AuthController extends GetxController { final RxBool sendingOtp = false.obs; String? currentPhone; final AuthProvider _api = AuthProvider(); AuthNext? lastDecision; // Optional callback used by MPIN screen to clear and refocus fields when user taps "Retry" VoidCallback? onPinRetry; /// Why the last [verifyPinWithServer] failed, in the rider's words. /// /// ── "Incorrect MPIN" was the answer to every question ── /// /// The MPIN screen painted that one line whenever the controller reported a /// failure — a wrong PIN, a dead network, a 500, and (for a long time) a /// device-id lookup that threw before the request was sent. So the one /// symptom a rider could report was the one cause that was often not true, /// and there was no way to tell a mistyped PIN from an app that was never /// going to reach the server. /// /// Set on every failure path, cleared on success. Read by `Mpin.dart`. String? lastPinFailure; static const String _prefsUserIdKey = 'userid'; static const String _prefsPendingPinUserIdKey = 'pending_pin_userid'; static const String _prefsUserNameKey = 'user_name'; static const String _prefsUserEmailKey = 'user_email'; static const String _prefsContactNoKey = 'contactno'; static const String _prefsAddressKey = 'user_address'; // ── The master-PIN constants are gone ── // // `_masterPinValue = '1234'`, `masterPinValue`, `forceMasterPinPrefKey` and // `_forceMasterPinFlow` were declared here and read by nothing — the feature // they belonged to was removed and its constants were not. A public constant // named `masterPinValue` holding a four-digit PIN is an invitation to the // next person looking for a shortcut, and it read as though the app still had // a back door. Removed with the set-PIN work rather than left to be // rediscovered. // // Riders set their own PIN now; `Creat_mpin.dart` refuses `1234` and `1111` // along with every other trivial sequence. Future _notifyProfileController() async { try { if (Get.isRegistered()) { final prefs = await SharedPreferences.getInstance(); final pc = Get.find(); await pc.loadFromPrefs(); pc.setProfile( name: prefs.getString(_prefsUserNameKey), email: prefs.getString(_prefsUserEmailKey), contact: prefs.getString(_prefsContactNoKey), address: prefs.getString(_prefsAddressKey), ); } } catch (_) {} } String _normalizePhone(String input) { final digitsOnly = input.replaceAll(RegExp(r'\D'), ''); if (digitsOnly.length >= 10) { return digitsOnly.substring(digitsOnly.length - 10); } return digitsOnly; } void _showBottomSheet({required String title, required String message}) { // `Get.bottomSheet` stays (this controller has no BuildContext for the // kit's presenter), but the surface inside it is the kit's — the same // glass, handle and insets as every sheet after sign-in, so the first // sheet a rider ever meets is not the one drawn differently. Get.bottomSheet( MilerSheetScaffold( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ MilerSheetHeader( title: title, subtitle: message, icon: LucideIcons.info, ), const SizedBox(height: 18), MilerButton( label: 'Retry', onPressed: () { Get.back(); // If MPIN screen has registered a retry callback, run it onPinRetry?.call(); }, ), ], ), ), isScrollControlled: true, backgroundColor: Colors.transparent, ); } /// Which screen this phone number has earned, asked of the server. /// /// ── One call, one boolean, no guessing ── /// /// This used to ask `milerAccountExists`, which read *only the status code* /// of `POST /miler/login` and threw the body away. From "an account exists" /// it inferred Enter-PIN, and from "it does not" it inferred an OTP branch /// that verified nothing and dead-ended at a screen which could not write a /// PIN. A `null` — the directory unreachable — was read as "he has an /// account", because the OTP direction was the worse place to be wrong. /// /// The server answers directly now. `pin_set` is the whole decision, and it /// is read as a boolean rather than off the message beside it, which is prose /// and will be reworded. /// /// The fallback when the field is absent — an older server, or a body that /// did not parse — is **Enter-PIN**, for the same reason the old `null` case /// chose it: a rider who does have a PIN can sign in, and one who does not /// gets a refusal he can report. Sending him to Set-PIN on a guess earns a /// 409 and a screen he cannot leave. Future precheckPhone(String phone) async { try { final normalized = _normalizePhone(phone); currentPhone = normalized; final prefs = await SharedPreferences.getInstance(); // The mocked "Demo Rider" (userid 9999) that used to be written here is // gone. It bypassed the server entirely and left a fake identity in prefs // that outlived the session it was created for. The real user is // established by verify-pin / set-pin and nowhere else. await prefs.setString(_prefsContactNoKey, normalized); final res = await MilerApi.login(normalized); debugPrint( '[AUTH][PRECHECK] $normalized -> ${res.status} ' 'pin_set=${MilerApi.pinSetOf(res)} raw=${res.raw}', ); if (res.status == 404) { lastDecision = AuthNext.notRegistered; return lastDecision!; } if (res.status == 403 || res.status == 401) { lastDecision = AuthNext.inactive; return lastDecision!; } if (!res.ok) { // 5xx, a timeout, a body that did not parse. Not a fact about the // rider, and not a reason to send him anywhere final. lastDecision = AuthNext.error; return lastDecision!; } lastDecision = MilerApi.pinSetOf(res) == false ? AuthNext.setPin : AuthNext.verifyPin; return lastDecision!; } catch (e) { debugPrint('Precheck phone error: $e'); lastDecision = AuthNext.error; return lastDecision!; } } /// Why the last [setPin] failed, in the rider's words. Null on success. String? lastSetPinFailure; /// True when [setPin] was refused because the account already has a PIN — /// the caller sends the rider to Enter-PIN rather than showing an error. bool lastSetPinWasAlreadySet = false; Future sendOtp([String? phoneArg]) async { if (sendingOtp.value) return false; if (phoneArg != null && phoneArg.isNotEmpty) { currentPhone = _normalizePhone(phoneArg); } sendingOtp.value = true; try { await Future.delayed(const Duration(milliseconds: 500)); return true; } finally { sendingOtp.value = false; } } /// ── There is no OTP route on the backend ── /// /// This returned true with the comment "automatically succeed for mocked /// login", which read as leftover demo scaffolding. It is not: `MilerApi` /// carries the whole auth surface and it is three routes — `login`, /// `verify-pin`, `device-token`. Nothing verifies a code, so there is nothing /// for this to call. /// /// It stays a pass-through for the same reason [AuthProvider.updatePin] does: /// failing instead would strand a new rider on a screen with no way forward, /// which is worse and no more honest. What changes is that the gap is now /// recorded rather than described as a mock, so it shows up in the same place /// as every other missing route. Future verifyOtp(String code) async { ApiConfig.logGap( 'verifyOtp', 'No OTP verification route exists; the code entered is not checked.', ); return true; } /// Creates this rider's PIN and signs him in. `POST /miler/set-pin`. /// /// ── What this replaces ── /// /// It called `AuthProvider.updatePin`, which had no route to call and /// returned a manufactured `403 "Your MPIN is issued by your office and /// cannot be changed from the app."` — correct while `reset-pin` was the only /// PIN write and it needed an admin token, and a dead end for the rider /// standing on the Create-MPIN screen. /// /// Riders set their own PIN on first sign-in now. The call returns a **full /// session**, so this lands the rider logged in — there is no verify-pin /// afterwards and no second screen. /// /// Returns true when the session is real. On a `409` — the account already /// has a PIN — [lastSetPinWasAlreadySet] is set and the caller sends him to /// Enter-PIN rather than showing him an error he cannot act on. Future setPin(String newPin) async { lastSetPinFailure = null; lastSetPinWasAlreadySet = false; final phone = currentPhone; if (phone == null || phone.isEmpty) { lastSetPinFailure = 'We lost your number. Go back and enter it again.'; return false; } if (newPin.length != 4 || int.tryParse(newPin) == null) { lastSetPinFailure = 'Enter a 4-digit PIN.'; return false; } try { final prefs = await SharedPreferences.getInstance(); String deviceId = ''; String fcmToken = ''; try { deviceId = await DeviceUtils.ensureDeviceId(prefs); } catch (e) { debugPrint('[AUTH] device id unavailable, continuing: $e'); } try { fcmToken = await DeviceUtils.ensureFcmToken(prefs); } catch (e) { debugPrint('[AUTH] fcm token unavailable, continuing: $e'); } // Same rule as verify-pin: only THIS attempt may grant a session, so a // stale token cannot make a refused set-pin look accepted. await ApiConfig.clearToken(); final Login res = await _api.loginParsed( contactNo: phone, deviceType: Platform.operatingSystem, configId: 6, deviceId: deviceId, fcmToken: fcmToken, pinRaw: newPin, firstTime: true, ); // `_loginNew` normalises the envelope as `code: ok ? 200 : httpStatus`, // so on a refusal this IS the server's status line. `httpstatus` is on // the envelope too but `Login` does not parse it. final int http = res.code ?? 0; // ── 409 is not a failure the rider can fix by trying again ── // // It means the account already has a PIN — he is not a first-time rider // after all, or he set one on another handset. The caller sends him to // Enter-PIN; telling him "could not save your PIN" would leave him // retyping a PIN the server will never accept. if (http == 409) { lastSetPinWasAlreadySet = true; lastSetPinFailure = 'You already have a PIN on this number. Enter it to sign in.'; return false; } if (http == 404) { lastSetPinFailure = 'That number is not registered as a Miler. Contact your manager.'; return false; } if (http == 403 || http == 401) { lastSetPinFailure = 'This account is not active. Contact your manager.'; return false; } final bool serverAccepted = res.status == true; final String? token = await ApiConfig.getToken(); final bool haveSession = token != null && token.isNotEmpty; if (serverAccepted && !haveSession) { // The PIN was created and there is nothing to sign in with. An // integration fault, and it must never be reported as the rider's // mistake — see the same branch in [verifyPinWithServer]. debugPrint('[AUTH] set-pin succeeded but returned no usable token'); lastSetPinFailure = 'Your PIN was saved, but the server did not return a session. ' 'Sign in with your new PIN.'; lastSetPinWasAlreadySet = true; return false; } if (serverAccepted && haveSession) { await prefs.setString('dbPin', newPin); await prefs.setBool('logged_out', false); await prefs.setString(_prefsContactNoKey, phone); await prefs.remove(_prefsPendingPinUserIdKey); await _notifyProfileController(); return true; } final String serverMsg = (res.message ?? '').trim(); lastSetPinFailure = serverMsg.isNotEmpty && serverMsg.length < 140 ? serverMsg : 'Could not set your PIN. Check your connection and try again.'; return false; } catch (e) { debugPrint('setPin error: $e'); lastSetPinFailure = 'Something went wrong while setting your PIN. Try again.'; return false; } } /// Refresh session on backend with latest deviceId/FCM for the current phone. Future refreshSession({String? phone}) async { try { final prefs = await SharedPreferences.getInstance(); final String? usePhone = phone ?? currentPhone; if (usePhone == null || usePhone.isEmpty) { return false; } final deviceId = await DeviceUtils.ensureDeviceId(prefs); final fcmToken = await DeviceUtils.ensureFcmToken(prefs); final Login loginRes = await _api.loginParsed( contactNo: usePhone, deviceType: Platform.operatingSystem, configId: 6, deviceId: deviceId, fcmToken: fcmToken, ); if (loginRes.userid != null) { await prefs.setInt(_prefsUserIdKey, loginRes.userid!); } try { final String? name = loginRes.fullname ?? loginRes.firstname; final String? email = loginRes.email; final String contact = (loginRes.contactno ?? usePhone).toString(); final String? address = loginRes.address; if (name != null && name.trim().isNotEmpty) { await prefs.setString(_prefsUserNameKey, name.trim()); } if (email != null && email.trim().isNotEmpty) { await prefs.setString(_prefsUserEmailKey, email.trim()); } if (contact.isNotEmpty) { final normalizedContact = _normalizePhone(contact); await prefs.setString(_prefsContactNoKey, normalizedContact); } if (address != null && address.trim().isNotEmpty) { await prefs.setString(_prefsAddressKey, address.trim()); } await _notifyProfileController(); } catch (_) {} currentPhone = _normalizePhone(usePhone); return loginRes.status == true; } catch (_) { return false; } } Future verifyPinWithServer(String inputPin) async { final prefs = await SharedPreferences.getInstance(); // The mocked-login bypass that used to sit here accepted ANY four digits // and logged the rider in without asking the server. It is gone: a PIN // check that cannot fail is not a PIN check. // Authenticate phone + PIN against POST /miler/verify-pin. Only a real // success (server ok + bearer token stored) logs the rider in. try { final String phone = currentPhone ?? prefs.getString(_prefsContactNoKey) ?? ''; final int? pinNum = int.tryParse(inputPin); if (phone.isEmpty || pinNum == null || inputPin.length != 4) { // Two different faults wearing one message. A missing phone is not the // rider mistyping — it means he reached this screen without the number // step, and telling him to re-enter his PIN sends him round a loop that // cannot end. lastPinFailure = phone.isEmpty ? 'We lost your phone number. Go back and enter it again.' : 'Enter all 4 digits of your MPIN.'; _showBottomSheet(title: 'Invalid PIN', message: lastPinFailure!); return false; } // ── Nothing gathered here may stop the sign-in ── // // These two lines used to sit bare inside this `try`, and // `ensureDeviceId` threw on iOS and on any Android that handed back an // empty id. The throw landed in the catch below, so the rider was told // "Could not reach the server" — before a single byte had been sent — // and the MPIN screen then called it an incorrect PIN. Every number, // every attempt. // // `ensureDeviceId` is total now (see [DeviceUtils]), and this second // guard says why it must stay that way: `deviceId` is not even part of // the verify-pin body, and a push token the rider declined is not a // reason to refuse him his shift. Best effort, then post regardless. String deviceId = ''; String fcmToken = ''; try { deviceId = await DeviceUtils.ensureDeviceId(prefs); } catch (e) { debugPrint('[AUTH] device id unavailable, continuing: $e'); } try { fcmToken = await DeviceUtils.ensureFcmToken(prefs); } catch (e) { debugPrint('[AUTH] fcm token unavailable, continuing: $e'); } // ── This attempt is the only thing that may grant a session ── // // The check below asks prefs whether a token exists. Without this line // that question is answered by *any previous session*, so the gate was // broken in both directions: a stale token made a rejected PIN look // accepted, and a fresh install with a token the app could not find made // an accepted PIN look rejected. await ApiConfig.clearToken(); final Login res = await _api.loginParsed( contactNo: phone, deviceType: Platform.operatingSystem, configId: 6, deviceId: deviceId, fcmToken: fcmToken, pin: pinNum, pinRaw: inputPin, ); // ── Three outcomes, not two ── // // `res.status` is the server's verdict on the credentials. The token is // whether this call handed back a session. They are different facts, and // collapsing them into one boolean is what produced **"Login failed"** on // a PIN the server had just accepted — the app could not find the token // in the response, so it reported the rider's PIN as wrong. final bool serverAccepted = res.status == true; final String? token = await ApiConfig.getToken(); final bool haveSession = token != null && token.isNotEmpty; final bool ok = serverAccepted && haveSession; if (serverAccepted && !haveSession) { // The credentials were right and there is nothing to sign in with. // This is an integration fault, not a rider fault, and it must never // again be reported as a bad PIN. The log line above it names the keys // the response actually carried. debugPrint( '[AUTH] verify-pin accepted the PIN but returned no usable token', ); lastPinFailure = 'Your MPIN was accepted, but the server did not return a session. ' 'Please report this to your office — it is not your PIN.'; _showBottomSheet( title: 'Could not start session', message: lastPinFailure!, ); return false; } if (ok) { lastPinFailure = null; await prefs.setString('dbPin', inputPin); await prefs.setBool('logged_out', false); currentPhone = _normalizePhone(phone); // Overwrite any stale demo profile with the REAL logged-in identity. // The UI reads the display name from 'user_name'; loginParsed only wrote // 'username'/'firstname', so mirror the real name/email/contact here. final String realName = (res.fullname != null && res.fullname!.trim().isNotEmpty) ? res.fullname!.trim() : (prefs.getString('username') ?? '${res.firstname ?? ''} ${res.lastname ?? ''}') .trim(); if (realName.isNotEmpty) { await prefs.setString(_prefsUserNameKey, realName); } final String realEmail = (res.email ?? '').trim(); if (realEmail.isNotEmpty) { await prefs.setString(_prefsUserEmailKey, realEmail); } await prefs.setString(_prefsContactNoKey, _normalizePhone(phone)); await _notifyProfileController(); return true; } // ── Only 401/403 is a statement about the PIN ── // // Everything else — a 502, a gateway timeout, a captive portal, a body // that is not JSON — is the sign-in failing to *complete*, which is a // different problem with a different fix. Reporting all of it as a login // failure is what made a network fault indistinguishable from a wrong // MPIN, on a screen whose whole job is to tell those apart. final int status = res.code ?? 0; final String serverSaid = (res.message ?? '').trim(); final bool aboutTheCredentials = status == 401 || status == 403; if (aboutTheCredentials) { lastPinFailure = serverSaid.isNotEmpty ? serverSaid : 'Incorrect MPIN for $phone. Try again.'; _showBottomSheet(title: 'Login failed', message: lastPinFailure!); } else { lastPinFailure = serverSaid.isNotEmpty ? 'Sign-in could not complete. $serverSaid' : 'Sign-in could not complete (HTTP $status). This is not your ' 'MPIN — check the connection and try again.'; _showBottomSheet( title: 'Sign-in did not complete', message: lastPinFailure!, ); } return false; } catch (e) { // Never reached the server, or could not read what came back. Whatever // this is, it is NOT the rider's PIN, and saying so is the whole point. debugPrint('verifyPinWithServer error: $e'); lastPinFailure = 'Could not reach the server. Check your connection and try again.'; _showBottomSheet(title: 'Connection error', message: lastPinFailure!); return false; } } }