Files
doormile_milderapp/lib/data/mock_backend.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

293 lines
12 KiB
Dart

import 'package:flutter/foundation.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// THE APP WITH NO BACKEND BEHIND IT
///
/// One switch that takes the whole app off the network: sign-in, the day's
/// work, and every status the rider writes. It exists so the flow can be built,
/// demonstrated and judged without `api.doormile.com` answering — on a bench, on
/// a plane, or against a backend that does not have the meal contract yet.
///
/// ── This app has been burned by a mock layer before ──
///
/// One was ripped out for a good reason: it wrote rows into the same
/// SharedPreferences stores the real work uses, so demo stops surfaced on live
/// riders' tabs weeks later with nothing left in the repo to explain them.
/// `purgeDemoRecords()` still runs at every launch to clean up after it.
///
/// Four rules keep this one from becoming that:
///
/// 1. **It cannot ship.** [enabled] is false in a release build unless someone
/// types `--dart-define=MOCK_BACKEND=true` on the build command, which is
/// not something that happens by accident. Debug and profile builds — the
/// ones a developer actually runs — get it by default.
/// 2. **It intercepts the transport, not the stores.** Everything is served as
/// an *API response*; nothing writes to a store that real work shares. The
/// app cannot tell the difference and neither can its data layer.
/// 3. **Its ids are self-identifying.** Every record is `MOCK-…`, which is
/// exactly what `purgeDemoRecords()` looks for, so anything that does leak
/// into a store is removed at the next launch by code that already ships.
/// 4. **It says so, loudly.** Every intercepted call is logged with a `[MOCK]`
/// prefix, so a confusing session is one glance at the console away from
/// being explained.
///
/// ── What is given up while it is on ──
///
/// The hub is not told anything. A rider can accept, arrive, pick up and
/// deliver, and the console will show none of it — every write is answered with
/// a success that was manufactured on the phone. That is the correct trade for
/// design and demo work and the wrong one for a real shift, which is what rule 1
/// is for.
/// ─────────────────────────────────────────────────────────────────────────
/// The one switch.
///
/// Default: **on** in debug and profile, **off** in release. Override either
/// way from the build command:
///
/// flutter run # mock
/// flutter run --dart-define=MOCK_BACKEND=false # real API
/// flutter build apk --dart-define=MOCK_BACKEND=true # deliberate demo
/// ── Opt in, not opt out ──
///
/// This defaulted to **on** in debug and profile, so every `flutter run` served
/// a canned rider, a canned day and a canned login — and the backend was only
/// ever exercised by someone who remembered to turn the mock off. Three
/// consequences, all of which actually happened:
///
/// • A rider signing in with his real number became the fixture's rider.
/// • A whole class of contract bugs — a field renamed, a number sent where a
/// string was required — could not be seen, because nothing left the device.
/// • The default development experience diverged from production silently,
/// which is the one kind of divergence nobody notices until release.
///
/// The canned day is still one flag away, and is genuinely useful on a bench or
/// a plane. It is just no longer what you get by not deciding:
///
/// flutter run # the real API
/// flutter run --dart-define=MOCK_BACKEND=true # the canned day
const bool kMockBackend = bool.fromEnvironment('MOCK_BACKEND');
/// One customer pickup with three doors, for looking at.
///
/// **Off unless asked for**, and separate from [kMockBackend] on purpose. The
/// bookings route above is deliberately empty — "inventing one here would put
/// fictional consignments in front of a parcel rider" — and that stays exactly
/// true by default. This is the one shape that cannot be reached any other way:
/// `GET /miler/bookings` only returns a row per destination once a
/// multi-destination customer-app booking has been collected, so seeing the
/// three-door card on a bench needs either a seeded backend or this.
///
/// flutter run --dart-define=MOCK_BACKEND=true --dart-define=MOCK_MULTIDROP=true
const bool kMockMultiDrop = bool.fromEnvironment('MOCK_MULTIDROP');
/// Canned answers for the routes the rider's day passes through.
///
/// Everything else gets a generic success, on purpose: the alternative is a
/// mock that fails on an endpoint nobody thought about and reads to the person
/// using it as a broken app rather than an unmapped route.
class MockBackend {
MockBackend._();
/// Whether the canned answers are being served.
///
/// Settable so a test can exercise the transport itself — the interception
/// happens *above* the HTTP client, so with this on there is no request to
/// assert against. Nothing in the app assigns it; it follows [kMockBackend].
static bool enabled = kMockBackend;
/// The signed-in rider, when there is no server to ask.
///
/// `tenantname` is what puts the build on the meal line — see
/// [ServiceProfile] — so the mock day and the mock login agree about which
/// application the rider is in.
static const Map<String, dynamic> rider = {
'userid': 90001,
'id': 90001,
'name': 'Suresh Kumar',
'phone': '9876543210',
'mobile': '9876543210',
'email': 'suresh@doormile.test',
'roleid': 5,
'configid': 1001,
'tenantid': 916,
'tenantname': 'DailyGrubs',
'status': 'Active',
'vehicletype': 'Bike',
'vehicleno': 'TN 37 CV 4412',
};
/// A token that is obviously not a JWT, so nothing tries to read claims out
/// of it and no log line makes it look like a real session.
static const String token = 'MOCK-TOKEN-not-a-real-session';
/// One collected customer pickup, as three rows — the shape
/// `milerStopsForBooking` returns once `pickup-complete` has fanned a
/// multi-destination booking out into a consignment per door.
///
/// Written in the **raw wire shape**, not the app's stop shape, because that
/// is what the mock intercepts: these go through `ApiConfig.pickupFromBooking`
/// exactly as a real response would, so the adapter is being exercised rather
/// than bypassed. Same `bookingid` and `bookingreference` on all three, which
/// is the whole point — they are told apart only by `destinationseq`.
///
/// Ids are `MOCK-` per rule 3 so `purgeDemoRecords()` can find anything that
/// leaks into a store.
static const List<Map<String, dynamic>> multiDropVisit = [
{
'bookingid': 900482913,
'bookingreference': 'MOCK-482913',
'status': 'Converted_To_Consignment',
'consignmentid': 900500,
'consignmentstatus': 'Out_for_Delivery',
'trackingno': 'MOCK-TRK-8841020',
'destinationseq': 0,
'destinationcount': 3,
'next_action': 'deliver',
'step': 1,
'pickup_source_type': 'customer',
'pickup_source_name': 'Ravi Kumar',
'pickupaddress': '14, Cross Cut Road, Gandhipuram, Coimbatore 641012',
'pickuplatitude': 11.0183,
'pickuplongitude': 76.9725,
'deliveryaddress': '22, Anna Nagar West, Chennai, Tamil Nadu',
'deliverylatitude': 11.0210,
'deliverylongitude': 76.9810,
'recipientname': 'Anitha Raghavan',
'recipientphone': '9840012201',
'customername': 'Ravi Kumar',
'customerphone': '9843311220',
'parcels': [
{'bookingparcelid': 900001, 'itemcategory': 'General'},
{'bookingparcelid': 900002, 'itemcategory': 'General'},
],
},
{
'bookingid': 900482913,
'bookingreference': 'MOCK-482913',
'status': 'Converted_To_Consignment',
'consignmentid': 900501,
'consignmentstatus': 'Out_for_Delivery',
'trackingno': 'MOCK-TRK-8841021',
'destinationseq': 1,
'destinationcount': 3,
'next_action': 'deliver',
'step': 2,
'pickup_source_type': 'customer',
'pickup_source_name': 'Ravi Kumar',
'pickupaddress': '14, Cross Cut Road, Gandhipuram, Coimbatore 641012',
'pickuplatitude': 11.0183,
'pickuplongitude': 76.9725,
'deliveryaddress': '7, Panampilly Nagar, Ernakulam, Kerala',
'deliverylatitude': 11.0301,
'deliverylongitude': 76.9902,
'recipientname': 'Suresh Menon',
'recipientphone': '9847712345',
'customername': 'Ravi Kumar',
'customerphone': '9843311220',
'parcels': [
{'bookingparcelid': 900003, 'itemcategory': 'General'},
],
},
{
'bookingid': 900482913,
'bookingreference': 'MOCK-482913',
'status': 'Converted_To_Consignment',
'consignmentid': 900502,
'consignmentstatus': 'Out_for_Delivery',
'trackingno': 'MOCK-TRK-8841022',
'destinationseq': 2,
'destinationcount': 3,
'next_action': 'deliver',
'step': 3,
'pickup_source_type': 'customer',
'pickup_source_name': 'Ravi Kumar',
'pickupaddress': '14, Cross Cut Road, Gandhipuram, Coimbatore 641012',
'pickuplatitude': 11.0183,
'pickuplongitude': 76.9725,
'deliveryaddress': '90, Indiranagar 2nd Stage, Bengaluru, Karnataka',
'deliverylatitude': 11.0405,
'deliverylongitude': 77.0011,
'recipientname': 'Meena Prakash',
'recipientphone': '9880045512',
'customername': 'Ravi Kumar',
'customerphone': '9843311220',
'parcels': [
{'bookingparcelid': 900004, 'itemcategory': 'General'},
],
},
];
/// Answers [method] [path], or null when the caller should fall through to
/// the network. Null is never returned while [enabled] — see the class note.
static Map<String, dynamic>? respond(
String method,
String path, {
Object? body,
Map<String, String>? query,
}) {
if (!enabled) return null;
final route = path.split('?').first;
debugPrint('[MOCK] $method $route');
// ── Auth ──
if (route.endsWith('/login')) {
return _ok({'otpsent': true, 'phone': _field(body, 'phone')});
}
if (route.endsWith('/verify-pin')) {
// The shape the real handler returns, verified against a live 200: the
// profile lives under `user` / `user.profile`, and there is no `data`
// key. Getting this wrong cost a debugging session once already — see the
// note in [MilerApi].
//
// The phone is the one the rider actually typed. It used to be the
// fixture's, so signing in with your own number made you Suresh Kumar and
// the screen you landed on disagreed with the number on the screen you
// came from — which reads as a broken login rather than as a mock.
final phone = _field(body, 'phone');
final signedIn = <String, dynamic>{
...rider,
if (phone.isNotEmpty) ...{
'phone': phone,
'mobile': phone,
'contactno': phone,
},
};
return {
'success': true,
'message': 'ok',
'token': token,
'user': {...signedIn, 'profile': signedIn},
};
}
if (route.endsWith('/profile')) return _ok(rider);
// ── The day's work ──
//
// Empty rather than invented: the meal line's stops come from
// [MealRunMock], which the provider reads *before* it reaches the network
// at all. A parcel booking list has no fixture and inventing one here would
// put fictional consignments in front of a parcel rider.
if (route.endsWith('/bookings') || route.endsWith('/assignments')) {
return _ok(kMockMultiDrop ? multiDropVisit : const <dynamic>[]);
}
// ── Everything the rider writes ──
//
// Accept, reject, reached, parcels, payment, pickup-complete, deliver,
// skip, duty, breaks, telemetry, device tokens. All answered yes, and all
// going nowhere.
return _ok(const <String, dynamic>{});
}
static Map<String, dynamic> _ok(dynamic data) => {
'success': true,
'message': 'ok',
'data': data,
};
static String _field(Object? body, String key) =>
(body is Map && body[key] != null) ? body[key].toString() : '';
}