Files
doormile_milderapp/lib/views/offline/offline_banner.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

126 lines
5.1 KiB
Dart

import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
import 'package:miler/views/helpers/constants/miler_type.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// OFFLINE, WITHOUT TAKING THE JOB AWAY
///
/// ── What this replaces ──
///
/// Losing signal used to lay an opaque full-screen page over the entire app:
/// `Stack(children: [baseChild, const OfflinePage()])`. Not a warning — a
/// replacement.
///
/// A rider standing at a doorstep in a basement, a lift, a stairwell or a rural
/// lane — which is where Indian first-mile delivery actually happens — lost the
/// address, the customer's name, their phone number, the parcel count and the
/// OTP field. He was holding a parcel and could not see who it was for. The app
/// then told him it was "retrying automatically every 5 seconds", which is not
/// something he can act on while a customer waits.
///
/// ── Why a strip is the right shape ──
///
/// Everything the rider needs at the door was already fetched. Connectivity is
/// needed to *report* the stop, not to work it. So the right shape is a line of
/// text, not a screen.
///
/// ── What it must not say ──
///
/// It said "Changes will sync when you're back online". **There is no mutation
/// queue in this app** — no outbox, no replay, no retry loop. A status the
/// rider sets with no signal is attempted once and fails, and nothing ever
/// sends it again. The banner was promising a mechanism that does not exist,
/// to the one person who would be blamed when the hub had no record of the
/// stop.
///
/// The copy now says what is actually true: he can keep working the stop, and
/// the *status* needs signal. Building the queue is a real piece of work and a
/// separate one (see the release report); until it exists this line must not
/// imply it.
///
/// The full-screen page survives for the one case where it is truthful: a cold
/// start with no cached session, where there genuinely is nothing to show.
///
/// ── Sizing ──
///
/// Deliberately thin, and it does **not** overlay the content — [OfflineScope]
/// gives it its own row above the app, so it can never cover a field the rider
/// is typing into. The one thing worse than a blocker is a floating bar sitting
/// on top of the OTP box.
/// ─────────────────────────────────────────────────────────────────────────
class OfflineBanner extends StatelessWidget {
const OfflineBanner({super.key});
@override
Widget build(BuildContext context) {
return Semantics(
liveRegion: true,
label: 'Offline. Status updates need a connection. Stop details are still available.',
excludeSemantics: true,
child: Material(
color: ColorConstants.warning,
child: SafeArea(
bottom: false,
child: Padding(
padding: EdgeInsets.fromLTRB(16.w, 7.h, 16.w, 7.h),
child: Row(
children: [
Icon(
LucideIcons.cloudOff,
size: 15.sp,
color: ColorConstants.onAccent,
),
SizedBox(width: 9.w),
Expanded(
child: Text(
// States what is true and what he can still do. It must
// not promise a sync — nothing in this app replays a failed
// mutation. See the class note.
'Offline · Status updates need a connection',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: MilerType.micro.on(ColorConstants.onAccent).semibold,
),
),
],
),
),
),
),
);
}
}
/// Puts [OfflineBanner] above the app rather than over it.
///
/// A `Column` rather than a `Stack` on purpose: the strip takes its own height
/// out of the layout, so nothing underneath is ever covered. The app simply
/// starts a few points lower while the connection is down.
class OfflineScope extends StatelessWidget {
final Widget child;
final bool offline;
const OfflineScope({super.key, required this.child, required this.offline});
@override
Widget build(BuildContext context) {
return Column(
children: [
AnimatedSize(
duration: DesignConstants.motionState,
curve: Curves.easeOutCubic,
alignment: Alignment.bottomCenter,
child: offline
? const OfflineBanner()
: const SizedBox(width: double.infinity),
),
Expanded(child: child),
],
);
}
}