Files
doormile_milderapp/lib/controllers/auth.dart
Thiru-tenext d612916fe4 Session expiry, arrival geofence guard, multi-destination stops
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
2026-09-18 11:05:40 +05:30

587 lines
24 KiB
Dart

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<void> _notifyProfileController() async {
try {
if (Get.isRegistered<ProfileController>()) {
final prefs = await SharedPreferences.getInstance();
final pc = Get.find<ProfileController>();
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<AuthNext> 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<bool> 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<bool> 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<bool> 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<bool> 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<bool> 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;
}
}
}