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:
@@ -1,4 +1,3 @@
|
||||
import 'dart:async' show unawaited;
|
||||
import 'dart:io';
|
||||
import 'package:miler/helpers/http_overrides.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -22,7 +21,10 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:miler/background/backgroundservice.dart';
|
||||
import 'package:miler/helpers/shift_end_alarm.dart';
|
||||
import 'package:miler/data/accepted_store.dart';
|
||||
import 'package:miler/data/miler_api.dart';
|
||||
import 'package:miler/data/service_profile.dart';
|
||||
import 'package:miler/data/session.dart';
|
||||
import 'package:miler/views/onboardscreens/Sign_in.dart';
|
||||
import 'package:miler/xpress/delivery_bootstrap.dart';
|
||||
import 'package:miler/views/helpers/constants/app_theme.dart';
|
||||
import 'package:miler/views/helpers/widgets/page_transitions.dart';
|
||||
@@ -77,9 +79,29 @@ Future<bool> recheckVersion() async {
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
// One-time drain of the pre-scoping global stores. See [migrateLegacyStores]
|
||||
// for why unattributable rows are dropped rather than adopted.
|
||||
unawaited(migrateLegacyStores());
|
||||
// ── Awaited, because the screens read what it writes ──
|
||||
//
|
||||
// This was `unawaited(...)`, which meant the one-time drain of the
|
||||
// pre-scoping stores raced the first frame. On the launch after an upgrade —
|
||||
// the only launch where it does anything — Home, Deliveries and Activity
|
||||
// could all read the scoped key *before* the migration had written it, and a
|
||||
// rider mid-shift opened the app to an empty day. The rows were not lost; he
|
||||
// simply could not see them until he killed and reopened the app, which is
|
||||
// not a thing he knows to do.
|
||||
//
|
||||
// It is cheap on every other launch: `containsKey` against an already-loaded
|
||||
// preferences map, for eight base keys, and it returns before the first
|
||||
// `await` when there is nothing to move.
|
||||
//
|
||||
// Guarded, and deliberately not fatal. A migration that throws — a corrupt
|
||||
// blob, a prefs backend that will not open — must not stop the rider signing
|
||||
// in and working. He loses local history he can no longer see anyway; the
|
||||
// server's record is untouched by any of this.
|
||||
try {
|
||||
await migrateLegacyStores();
|
||||
} catch (e, st) {
|
||||
debugPrint('[SCOPE] legacy store migration failed, continuing: $e\n$st');
|
||||
}
|
||||
HttpOverrides.global = MyHttpOverrides();
|
||||
|
||||
try {
|
||||
@@ -102,6 +124,25 @@ Future<void> main() async {
|
||||
// way to receive it at all. Refreshed from `GET /miler/profile` first so the
|
||||
// resolution below reads the current answer. Cheap and non-blocking for a
|
||||
// rider we already have a name for; see [refreshTenantFromProfile].
|
||||
// ── What happens when the server stops accepting this rider ──
|
||||
//
|
||||
// `MilerApi` drops the bearer token on any 401 and calls `onUnauthorized`.
|
||||
// Nothing ever assigned it, so the second half never happened: the credential
|
||||
// was deleted and the app went on believing it was signed in, because the
|
||||
// profile and `logged_out = false` were both still on disk. Every call after
|
||||
// that came back `401 authorization header is required`, forever, behind a
|
||||
// dashboard showing the rider's own name.
|
||||
//
|
||||
// Observed on a handset: rider 23, name and tenant drawn on Account, no
|
||||
// `authtoken` in SharedPreferences, Home / Deliveries / Activity and the
|
||||
// heartbeat all failing in a loop. What it costs is not a blank screen — it
|
||||
// is a rider who sees no jobs, concludes there is no work today, and is never
|
||||
// told to sign in again.
|
||||
//
|
||||
// Registered here, before the line branch below, because both lines make
|
||||
// authenticated calls and either can be the one to receive the 401.
|
||||
MilerApi.onUnauthorized = _handleSessionExpired;
|
||||
|
||||
await refreshTenantFromProfile();
|
||||
final profile = await TenantController.to.load();
|
||||
|
||||
@@ -140,6 +181,53 @@ Future<void> main() async {
|
||||
runApp(const _RootApp());
|
||||
}
|
||||
|
||||
/// True while a session-expiry teardown is already under way.
|
||||
///
|
||||
/// A 401 rarely arrives alone — the home poll, the deliveries queue and the
|
||||
/// background heartbeat can each get one within the same second, and every one
|
||||
/// of them calls this. Without the latch the rider would be torn down three
|
||||
/// times and pushed at the sign-in screen three times, which on GetX stacks
|
||||
/// three routes he then has to dismiss.
|
||||
bool _signingOut = false;
|
||||
|
||||
/// Ends the session the server has stopped accepting, and says so.
|
||||
///
|
||||
/// Deliberately not silent. The rider did not choose this, so he is told what
|
||||
/// happened in the words of the thing that happened — his session ended — and
|
||||
/// landed somewhere he can act on it. Dropping him on sign-in with no message
|
||||
/// reads as the app having crashed and lost his work.
|
||||
Future<void> _handleSessionExpired() async {
|
||||
if (_signingOut) return;
|
||||
_signingOut = true;
|
||||
try {
|
||||
await endSession();
|
||||
|
||||
// A 401 can reach a background isolate, where there is no navigator and
|
||||
// `Get.offAll` would throw. The teardown above has still happened, so the
|
||||
// next launch reads `logged_out` and opens sign-in — the rider is not left
|
||||
// holding a dead session either way.
|
||||
if (Get.context == null) {
|
||||
debugPrint('[AUTH] session expired with no UI attached — '
|
||||
'cleared, sign-in will open on next launch');
|
||||
return;
|
||||
}
|
||||
|
||||
Get.offAll(() => const SignIn());
|
||||
Get.snackbar(
|
||||
'Signed out',
|
||||
'Your session ended. Please sign in again to see your jobs.',
|
||||
snackPosition: SnackPosition.BOTTOM,
|
||||
);
|
||||
} catch (e, st) {
|
||||
// Never let the teardown itself throw into an API response handler: the
|
||||
// call that received the 401 has its own error path and this must not
|
||||
// replace it with a crash.
|
||||
debugPrint('[AUTH] session teardown failed: $e\n$st');
|
||||
} finally {
|
||||
_signingOut = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Paints the status and navigation bars to match the page behind them.
|
||||
void _applySystemChrome() {
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
|
||||
Reference in New Issue
Block a user