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

@@ -6,6 +6,8 @@ import 'package:miler/views/helpers/widgets/page_transitions.dart';
import 'package:miler/controllers/auth.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/widget/Bottom_page.dart';
import 'package:miler/views/helpers/widgets/app_widgets.dart';
import 'package:miler/views/onboardscreens/Mpin.dart';
import 'package:miler/views/onboardscreens/auth_scaffold.dart';
@@ -178,11 +180,35 @@ class _CreateMpinBodyState extends State<_CreateMpinBody> {
final ok = await _auth.setPin(_pin(_newMpinControllers));
if (!mounted) return;
setState(() => isLoading = false);
// ── Setting the PIN IS the sign-in ──
//
// `POST /miler/set-pin` returns a full session — token and user — so there
// is no verify-pin afterwards and no second PIN to type. This used to push
// the rider to the unlock screen to enter the PIN he had just chosen, which
// was the only thing it could do while the write had no route behind it.
if (ok) {
openScreen(context, Mpin());
} else {
setState(() => _error = 'Could not save your PIN. Please try again.');
Get.offAll(() => const BottomPage());
return;
}
// A 409 means he is not a first-time rider after all — he already has a
// PIN, here or on another handset. Sending him to Enter-PIN is the useful
// answer; "could not save your PIN" would leave him retyping one the
// server will never accept.
if (_auth.lastSetPinWasAlreadySet) {
if (_auth.lastSetPinFailure != null) {
AppFeedback.infoGlobal(_auth.lastSetPinFailure!);
}
openScreen(context, Mpin());
return;
}
setState(
() => _error =
_auth.lastSetPinFailure ??
'Could not save your PIN. Please try again.',
);
}
@override

View File

@@ -4,7 +4,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:miler/views/helpers/widgets/page_transitions.dart';
import 'package:miler/views/onboardscreens/otp_page.dart';
import 'package:miler/views/onboardscreens/Creat_mpin.dart';
import 'package:miler/views/onboardscreens/Mpin.dart';
import 'package:miler/views/onboardscreens/auth_scaffold.dart';
import 'package:miler/controllers/auth.dart';
@@ -138,21 +138,43 @@ class _SignInState extends State<SignIn> {
if (!mounted) return;
setState(() => isLoading = false);
if (decision == AuthNext.otp) {
await controller.auth.sendOtp(phoneController.text);
if (!mounted) return;
openScreen(context, OtpPage());
} else if (decision == AuthNext.verifyPin) {
openScreen(context, Mpin());
} else {
// notRegistered / error. Reported on the screen rather than in a snackbar
// under the keyboard, where the old version put it.
setState(() {
_showError = true;
_errorText =
'We could not sign you in with that number. Check it and try '
'again, or contact your manager.';
});
// ── The server decides which screen, on one boolean ──
//
// `POST /miler/login` answers `pin_set`. False means a rider who has never
// signed in — the console no longer issues PINs, so he sets his own. True
// is the path every existing rider has always taken.
//
// This used to branch to an OTP screen when the directory said "no such
// account": the code was never verified (`verifyOtp` returned true without
// checking) and it dead-ended at a Create-MPIN screen with no route to
// call. A rider who took it could not get back.
switch (decision) {
case AuthNext.setPin:
openScreen(context, CreateMpin());
case AuthNext.verifyPin:
openScreen(context, Mpin());
case AuthNext.notRegistered:
setState(() {
_showError = true;
_errorText =
'That number is not registered as a Miler. Check it, or ask '
'your manager to add you.';
});
case AuthNext.inactive:
setState(() {
_showError = true;
_errorText =
'This account is not active. Contact your manager.';
});
case AuthNext.error:
// The directory could not be reached. Not a fact about the rider, and
// deliberately worded as the temporary thing it is.
setState(() {
_showError = true;
_errorText =
'We could not reach the server. Check your connection and try '
'again.';
});
}
}