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
This commit is contained in:
2026-09-18 11:05:40 +05:30
parent 127fa062ed
commit d612916fe4
53 changed files with 6346 additions and 748 deletions

View File

@@ -10,9 +10,37 @@ 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';
enum AuthNext { verifyPin, otp, notRegistered, error }
/// 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;
@@ -41,11 +69,18 @@ class AuthController extends GetxController {
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;
// ── 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>()) {
@@ -103,6 +138,26 @@ class AuthController extends GetxController {
);
}
/// 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);
@@ -111,31 +166,34 @@ class AuthController extends GetxController {
// 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.
// 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);
// 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;
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!;
}
// The directory answered, and said there is no such account.
lastDecision = AuthNext.otp;
lastDecision = MilerApi.pinSetOf(res) == false
? AuthNext.setPin
: AuthNext.verifyPin;
return lastDecision!;
} catch (e) {
debugPrint('Precheck phone error: $e');
@@ -144,6 +202,13 @@ class AuthController extends GetxController {
}
}
/// 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) {
@@ -179,52 +244,129 @@ class AuthController extends GetxController {
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();
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.',
);
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 (userId == null) {
_showBottomSheet(
title: 'Error',
message: 'User ID not found. Please try again.',
);
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 int pinNum = int.parse(newPin);
final res = await _api.updatePin(userId: userId, pin: pinNum);
if (res.statusCode >= 200 && res.statusCode < 300) {
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;
}
// 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 manager.';
}
_showBottomSheet(title: 'MPIN not changed', message: reason);
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');
_showBottomSheet(
title: 'Error',
message: 'Something went wrong while setting the PIN.',
);
lastSetPinFailure =
'Something went wrong while setting your PIN. Try again.';
return false;
}
}