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/views/helpers/widgets/miler_sheet_kit.dart'; enum AuthNext { verifyPin, otp, notRegistered, 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'; static const String _prefsForceMasterPinKey = 'force_master_pin'; static const String _masterPinValue = '1234'; static const String forceMasterPinPrefKey = _prefsForceMasterPinKey; static const String masterPinValue = _masterPinValue; bool _forceMasterPinFlow = false; 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, ); } 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 — every screen reading // 'userid' got 9999 until the app was reinstalled. The real user is // established by verify-pin and nowhere else. await prefs.setString(_prefsContactNoKey, normalized); // On the live backend, ask whether this phone already belongs to an // active miler account with a PIN on file. If it does, go straight to the // MPIN screen: OTP delivery isn't live yet, and the OTP path ends at // Create-MPIN, which would overwrite the PIN the account was issued. // Seeded development accounts take exactly this branch — enter the phone, // enter the seeded MPIN, done. // Null means the directory could not be reached — see // [AuthProvider.milerAccountExists]. Treat it as "he has an account", // because that is true of every rider who gets this far and because the // MPIN screen is the only one that can tell him what went wrong. The OTP // branch is the dead end: it ends at Create-MPIN, which cannot write a // PIN, so guessing wrong in that direction locks a rider out. final exists = await _api.milerAccountExists(normalized); if (exists ?? true) { lastDecision = AuthNext.verifyPin; return lastDecision!; } // The directory answered, and said there is no such account. lastDecision = AuthNext.otp; return lastDecision!; } catch (e) { debugPrint('Precheck phone error: $e'); lastDecision = AuthNext.error; return lastDecision!; } } 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; } Future setPin(String newPin) async { try { final prefs = await SharedPreferences.getInstance(); int? userId = prefs.getInt(_prefsPendingPinUserIdKey) ?? prefs.getInt(_prefsUserIdKey); if (newPin.length != 4 || int.tryParse(newPin) == null) { _showBottomSheet( title: 'Invalid PIN', message: 'Please enter a valid 4-digit PIN.', ); return false; } if (userId == null) { _showBottomSheet( title: 'Error', message: 'User ID not found. Please try again.', ); return false; } final int pinNum = int.parse(newPin); final res = await _api.updatePin(userId: userId, pin: pinNum); if (res.statusCode >= 200 && res.statusCode < 300) { await prefs.setString('dbPin', newPin); await prefs.remove(_prefsPendingPinUserIdKey); return true; } // The server's own sentence, not a slice of its JSON. A rider reading // `{"status":false,"code":403,...}` learns nothing he can act on. String reason = ''; try { final decoded = json.decode(res.body); if (decoded is Map) reason = (decoded['message'] ?? '').toString(); } catch (_) {} if (reason.trim().isEmpty) { reason = 'Could not set your MPIN. Please contact your hub manager.'; } _showBottomSheet(title: 'MPIN not changed', message: reason); return false; } catch (e) { debugPrint('setPin error: $e'); _showBottomSheet( title: 'Error', message: 'Something went wrong while setting the PIN.', ); 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 the hub — 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; } } }