Miler rider app: surface system, visible design language, backend lifecycle

Design system
- MilerSurface ladder (canvas → working → raised → floating) with MilerPanel
  as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1.
- Visible vocabulary applied across Home, Deliveries, Activity, Account and
  the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x),
  canvas wells for anything that opens, small filled tags for shelf labels,
  demoted placeholders. Recorded in DESIGN_SYSTEM.md §6.
- One icon family: 222 Material glyphs migrated to Lucide; none left outside
  lib/xpress.
- Colour semantics corrected: amber only for what is genuinely owed, brand red
  reserved for the live stop, disabled primaries go neutral rather than pale.

Data and lifecycle
- lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart
  makes admin sequence the single ordering authority; service_day.dart, and
  stop_area.dart rewritten against live Coimbatore addresses (digit-token
  stripping, city stoplist, street suffixes, stammer collapse).
- countLabel states the load once, in bags.

Testing
- 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity,
  sheets and verify, with test/failures/ now gitignored (diff debris).
- New pins: home_gutter_test, stop_area_test, plus updated structural bounds.

Note: this commit also carries pre-existing working-tree deletions that were
present before this work (API_SPEC.md, README.md, demo test fixtures).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 05:40:35 +05:30
parent c8350563d9
commit d7348e253f
387 changed files with 80693 additions and 12272 deletions

View File

@@ -1,12 +1,134 @@
import 'package:flutter/foundation.dart';
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/data/service_day.dart';
import 'package:miler/data/service_profile.dart';
import 'package:miler/data/work_scope.dart';
/// ── Every key in this file belongs to a session, not to the handset ──
///
/// These stores held finished work, skipped work, carried bags and released
/// orders under *global* keys — `completed_bookings` and friends — so one
/// phone had one drawer and whoever logged in last opened it. Logging out and
/// back in as another rider, or a tenant switch that moves the app between the
/// milk-man round and the logistics day, showed the previous scope's records
/// as if they were yours.
///
/// The key now carries the identity the session can prove: rider, tenant and
/// line. See [WorkScope]. Nothing else in this file changed shape — the
/// scoping happens here, at the data boundary, once.
Future<String> _scopedKey(String base) async =>
(await WorkScope.current()).scoped(base);
/// Legacy global keys, drained on first scoped access.
///
/// ── Why draining and not migrating ──
///
/// A legacy row cannot say whose it is: the global keys predate the identity
/// stamp, so a `completed_bookings` blob is *some* rider's, on *some* line,
/// and attributing it to whoever happens to log in first would be inventing
/// ownership — the exact leak this change exists to close. Rows that prove
/// their own ownership ([WorkScope.owns]) are carried across; the rest are
/// dropped. The cost is bounded and small: [getCompletedBookings] already
/// prunes to today, so at worst one day of local history is lost once, on one
/// upgrade, for records nobody can attribute anyway. The server-side history
/// is untouched by any of this.
Future<void> _drainLegacy(String base, WorkScope scope) async {
final prefs = await SharedPreferences.getInstance();
if (!prefs.containsKey(base)) return;
final scopedKey = scope.scoped(base);
final Object? legacy = prefs.get(base);
if (legacy is String && !prefs.containsKey(scopedKey)) {
// A JSON blob of records: keep only the rows that prove they are ours.
final rows = _decode(legacy);
final mine = [
for (final r in rows)
if (scope.owns(r)) scope.stamp(r),
];
if (mine.isNotEmpty) {
await prefs.setString(scopedKey, jsonEncode(mine));
}
}
// Id lists carry no identity at all, so there is nothing to carry across.
await prefs.remove(base);
debugPrint('[SCOPE] drained legacy "$base" into ${scope.key}');
}
/// Runs the one-time drain for every legacy key. Cheap after the first call —
/// `containsKey` on a loaded prefs map.
Future<void> migrateLegacyStores() async {
final scope = await WorkScope.current();
for (final base in const [
_kAcceptedBookingsKeyBase,
_kRejectedOrderIdsKeyBase,
_kCompletedBookingsKeyBase,
_kSkippedBookingsKeyBase,
_kCollectedOrderIdsKeyBase,
_kConsignmentIdsKeyBase,
_kOutForDeliveryKeyBase,
_kNotLoadedKeyBase,
_kBagLabelsKeyBase,
]) {
await _drainLegacy(base, scope);
}
}
/// Seeds one of this scope's stores directly. **Tests only.**
///
/// Fixtures used to write the bare global key (`completed_bookings`) because
/// that is what the store read. Now that a store belongs to a rider, a tenant
/// and a line, a fixture that writes the bare key is seeding a drawer nothing
/// opens — so it goes through the same resolver the app does, and the tests
/// exercise the shipped key scheme rather than a retired one.
/// [value] is the JSON blob for a record store, or the id list for one of the
/// set-shaped stores (`collected_order_ids`, `out_for_delivery_order_ids`,
/// `mock_rejected_order_ids`) — the same two shapes the stores themselves use.
/// The key [debugSeedStore] writes to, for assertions that read prefs back.
@visibleForTesting
Future<String> debugStoreKey(String base) => _scopedKey(base);
@visibleForTesting
Future<void> debugSeedStore(String base, Object value) async {
final prefs = await SharedPreferences.getInstance();
final key = await _scopedKey(base);
if (value is List) {
await prefs.setStringList(key, [for (final v in value) v.toString()]);
} else {
await prefs.setString(key, value.toString());
}
}
/// Wipes **this scope's** stores. Called on logout so the next rider on this
/// handset starts empty — see `AuthController`.
Future<void> clearScopedStores() async {
final prefs = await SharedPreferences.getInstance();
final scope = await WorkScope.current();
for (final base in const [
_kAcceptedBookingsKeyBase,
_kRejectedOrderIdsKeyBase,
_kCompletedBookingsKeyBase,
_kSkippedBookingsKeyBase,
_kCollectedOrderIdsKeyBase,
_kConsignmentIdsKeyBase,
_kOutForDeliveryKeyBase,
_kNotLoadedKeyBase,
_kBagLabelsKeyBase,
]) {
await prefs.remove(scope.scoped(base));
await prefs.remove(base);
}
debugPrint('[SCOPE] cleared ${scope.key}');
}
/// Persistent store of bookings the rider has accepted while the app runs on
/// mock data (offline / demo mode). This lets the accept flow be functional
/// without a server: the Home queue drops accepted bookings (and keeps them
/// dropped across refetches/navigation), and the Bookings tab picks them up.
const String _kAcceptedBookingsKey = 'mock_accepted_bookings';
const String _kRejectedOrderIdsKey = 'mock_rejected_order_ids';
const String _kAcceptedBookingsKeyBase = 'mock_accepted_bookings';
const String _kRejectedOrderIdsKeyBase = 'mock_rejected_order_ids';
/// Stops finished today, for the Activity tab.
///
@@ -26,7 +148,7 @@ const String _kRejectedOrderIdsKey = 'mock_rejected_order_ids';
///
/// This is the record that survives that. It is the same trade the accepted
/// store already makes, for the same reason.
const String _kCompletedBookingsKey = 'completed_bookings';
const String _kCompletedBookingsKeyBase = 'completed_bookings';
/// Stops the rider parked mid-shift for a return visit.
///
@@ -43,8 +165,8 @@ const String _kCompletedBookingsKey = 'completed_bookings';
///
/// So a skip is recorded here the moment it is taken, with the reason the rider
/// gave, and it is removed when he resumes the stop. Same trade as
/// [_kCompletedBookingsKey], for the same reason.
const String _kSkippedBookingsKey = 'skipped_bookings';
/// [await _scopedKey(_kCompletedBookingsKeyBase)], for the same reason.
const String _kSkippedBookingsKeyBase = 'skipped_bookings';
List<Map<String, dynamic>> _decode(String? raw) {
if (raw == null || raw.isEmpty) return [];
@@ -64,7 +186,7 @@ List<Map<String, dynamic>> _decode(String? raw) {
/// `orderstatus: 'accepted'`).
Future<List<Map<String, dynamic>>> getAcceptedBookings() async {
final prefs = await SharedPreferences.getInstance();
return _decode(prefs.getString(_kAcceptedBookingsKey));
return _decode(prefs.getString(await _scopedKey(_kAcceptedBookingsKeyBase)));
}
/// The set of order ids that have been locally accepted.
@@ -80,7 +202,9 @@ Future<Set<String>> getAcceptedOrderIds() async {
/// rejected booking leaves the pending list.
Future<Set<String>> getRejectedOrderIds() async {
final prefs = await SharedPreferences.getInstance();
return (prefs.getStringList(_kRejectedOrderIdsKey) ?? []).toSet();
return (prefs.getStringList(await _scopedKey(_kRejectedOrderIdsKeyBase)) ??
[])
.toSet();
}
/// Remember the given order ids as rejected.
@@ -88,9 +212,14 @@ Future<void> addRejectedOrderIds(List<String> ids) async {
final clean = ids.where((s) => s.isNotEmpty).toSet();
if (clean.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final existing = (prefs.getStringList(_kRejectedOrderIdsKey) ?? []).toSet();
final existing =
(prefs.getStringList(await _scopedKey(_kRejectedOrderIdsKeyBase)) ?? [])
.toSet();
existing.addAll(clean);
await prefs.setStringList(_kRejectedOrderIdsKey, existing.toList());
await prefs.setStringList(
await _scopedKey(_kRejectedOrderIdsKeyBase),
existing.toList(),
);
}
/// Undo a rejection.
@@ -102,10 +231,14 @@ Future<void> removeRejectedOrderIds(List<String> ids) async {
final drop = ids.where((s) => s.isNotEmpty).toSet();
if (drop.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final remaining = (prefs.getStringList(_kRejectedOrderIdsKey) ?? [])
.where((id) => !drop.contains(id))
.toList();
await prefs.setStringList(_kRejectedOrderIdsKey, remaining);
final remaining =
(prefs.getStringList(await _scopedKey(_kRejectedOrderIdsKeyBase)) ?? [])
.where((id) => !drop.contains(id))
.toList();
await prefs.setStringList(
await _scopedKey(_kRejectedOrderIdsKeyBase),
remaining,
);
}
/// Remove the given order ids from the accepted store. Called when a pickup is
@@ -116,9 +249,12 @@ Future<void> removeAcceptedBookings(List<String> ids) async {
if (drop.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final remaining = _decode(
prefs.getString(_kAcceptedBookingsKey),
prefs.getString(await _scopedKey(_kAcceptedBookingsKeyBase)),
).where((b) => !drop.contains((b['orderid'] ?? '').toString())).toList();
await prefs.setString(_kAcceptedBookingsKey, jsonEncode(remaining));
await prefs.setString(
await _scopedKey(_kAcceptedBookingsKeyBase),
jsonEncode(remaining),
);
}
/// Stops the rider finished **today**, newest first.
@@ -128,7 +264,9 @@ Future<void> removeAcceptedBookings(List<String> ids) async {
/// tab — and the store cannot grow without bound.
Future<List<Map<String, dynamic>>> getCompletedBookings() async {
final prefs = await SharedPreferences.getInstance();
final all = _decode(prefs.getString(_kCompletedBookingsKey));
final all = _decode(
prefs.getString(await _scopedKey(_kCompletedBookingsKeyBase)),
);
final today = _dayStamp(DateTime.now());
final mine = all.where((b) => (b['completedday'] ?? '') == today).toList()
@@ -140,7 +278,10 @@ Future<List<Map<String, dynamic>>> getCompletedBookings() async {
// Prune in the background if yesterday's rows are still in there.
if (mine.length != all.length) {
await prefs.setString(_kCompletedBookingsKey, jsonEncode(mine));
await prefs.setString(
await _scopedKey(_kCompletedBookingsKeyBase),
jsonEncode(mine),
);
}
return mine;
}
@@ -169,19 +310,35 @@ String kStopArrivedKey(Object pickupId) => 'pickup_start_$pickupId';
/// This is the last moment they are all true at once, so this is where they are
/// written down and the keys are cleared. Same argument as [StopCompliance],
/// which is stamped one call earlier for the same reason.
/// [terminalStatus] names what "finished" means on this line, and defaults to
/// the active profile's answer.
///
/// A logistics stop ends at `picked` — collecting the parcel *is* the job. A
/// milk-run stop ends at `delivered`, and stamping `picked` on it would file
/// the crate as the finished work and report fifteen lunches complete at the
/// moment the rider left the kitchen. One store, two honest endings; see
/// [StopStatus.isTerminal].
Future<void> addCompletedBookings(
List<Map<String, dynamic>> bookings, {
bool cancelled = false,
String? terminalStatus,
}) async {
if (bookings.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final now = DateTime.now();
final String done = ServiceProfile.active.deliversToCustomer
? 'delivered'
: 'picked';
final Map<String, Map<String, dynamic>> byId = {
for (final b in _decode(prefs.getString(_kCompletedBookingsKey)))
for (final b in _decode(
prefs.getString(await _scopedKey(_kCompletedBookingsKeyBase)),
))
(b['orderid'] ?? '').toString(): b,
};
final scope = await WorkScope.current();
for (final b in bookings) {
final id = (b['orderid'] ?? '').toString();
if (id.isEmpty) continue;
@@ -189,7 +346,11 @@ Future<void> addCompletedBookings(
// Stamped so the same `stopStatusOf` test that drops a stop from Bookings
// is the one that picks it up here — the two can never disagree about what
// "done" means.
copy['orderstatus'] = cancelled ? 'cancelled' : 'picked';
// `terminalStatus` is the caller naming the outcome exactly; `cancelled`
// is the older shorthand for "anything that was not a completion". The
// precise word wins, so a skipped delivery is filed as a skip rather than
// being flattened into a cancellation it was not.
copy['orderstatus'] = terminalStatus ?? (cancelled ? 'cancelled' : done);
copy['completedat'] = now.toIso8601String();
copy['completedday'] = _dayStamp(now);
@@ -205,11 +366,13 @@ Future<void> addCompletedBookings(
await prefs.remove(kStopArrivedKey(pickupId));
}
byId[id] = copy;
// Stamped with the session that produced it, so a row read back later
// can prove its own ownership even if the key scheme changes again.
byId[id] = scope.stamp(copy);
}
await prefs.setString(
_kCompletedBookingsKey,
await _scopedKey(_kCompletedBookingsKeyBase),
jsonEncode(byId.values.toList()),
);
}
@@ -221,7 +384,9 @@ Future<void> addCompletedBookings(
/// no longer act on.
Future<List<Map<String, dynamic>>> getSkippedBookings() async {
final prefs = await SharedPreferences.getInstance();
final all = _decode(prefs.getString(_kSkippedBookingsKey));
final all = _decode(
prefs.getString(await _scopedKey(_kSkippedBookingsKeyBase)),
);
final today = _dayStamp(DateTime.now());
final mine = all.where((b) => (b['skippedday'] ?? '') == today).toList()
@@ -232,11 +397,30 @@ Future<List<Map<String, dynamic>>> getSkippedBookings() async {
);
if (mine.length != all.length) {
await prefs.setString(_kSkippedBookingsKey, jsonEncode(mine));
await prefs.setString(
await _scopedKey(_kSkippedBookingsKeyBase),
jsonEncode(mine),
);
}
return mine;
}
/// The set of order ids finished today — picked up or written off.
///
/// The queue endpoints are a poll or more behind the rider, so this is the only
/// thing that knows a stop is done the moment he says so. Home stamps it over
/// whatever status the queue is still reporting — see `_fetchQueues` — the same
/// way it already does for skips, and for the same reason: a card that keeps
/// saying LIVE after the rider has closed the stop reads as the app not having
/// heard him.
Future<Set<String>> getCompletedOrderIds() async {
final list = await getCompletedBookings();
return list
.map((b) => (b['orderid'] ?? '').toString())
.where((s) => s.isNotEmpty)
.toSet();
}
/// The set of order ids currently parked as skipped.
Future<Set<String>> getSkippedOrderIds() async {
final list = await getSkippedBookings();
@@ -258,7 +442,9 @@ Future<void> addSkippedBooking(
final now = DateTime.now();
final Map<String, Map<String, dynamic>> byId = {
for (final b in _decode(prefs.getString(_kSkippedBookingsKey)))
for (final b in _decode(
prefs.getString(await _scopedKey(_kSkippedBookingsKeyBase)),
))
(b['orderid'] ?? '').toString(): b,
};
@@ -271,7 +457,10 @@ Future<void> addSkippedBooking(
copy['skippedday'] = _dayStamp(now);
byId[id] = copy;
await prefs.setString(_kSkippedBookingsKey, jsonEncode(byId.values.toList()));
await prefs.setString(
await _scopedKey(_kSkippedBookingsKeyBase),
jsonEncode(byId.values.toList()),
);
}
/// Forget a skip — the rider resumed the stop, so it is live work again.
@@ -280,21 +469,29 @@ Future<void> removeSkippedBookings(List<String> ids) async {
if (drop.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final remaining = _decode(
prefs.getString(_kSkippedBookingsKey),
prefs.getString(await _scopedKey(_kSkippedBookingsKeyBase)),
).where((b) => !drop.contains((b['orderid'] ?? '').toString())).toList();
await prefs.setString(_kSkippedBookingsKey, jsonEncode(remaining));
await prefs.setString(
await _scopedKey(_kSkippedBookingsKeyBase),
jsonEncode(remaining),
);
}
String _dayStamp(DateTime t) =>
'${t.year}-${t.month.toString().padLeft(2, '0')}-'
'${t.day.toString().padLeft(2, '0')}';
/// The local calendar date a record belongs to.
///
/// Delegates to [ServiceDay] so the stamp written here and the day Activity
/// asks for are produced by the same line of code — two implementations of a
/// date format is how a shift ends up half in one day and half in the next.
String _dayStamp(DateTime t) => ServiceDay.stamp(t);
/// Persist the given bookings as accepted, deduped by `orderid`.
Future<void> addAcceptedBookings(List<Map<String, dynamic>> bookings) async {
if (bookings.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final Map<String, Map<String, dynamic>> byId = {
for (final b in _decode(prefs.getString(_kAcceptedBookingsKey)))
for (final b in _decode(
prefs.getString(await _scopedKey(_kAcceptedBookingsKeyBase)),
))
(b['orderid'] ?? '').toString(): b,
};
for (final b in bookings) {
@@ -305,7 +502,384 @@ Future<void> addAcceptedBookings(List<Map<String, dynamic>> bookings) async {
byId[id] = copy;
}
await prefs.setString(
_kAcceptedBookingsKey,
await _scopedKey(_kAcceptedBookingsKeyBase),
jsonEncode(byId.values.toList()),
);
}
// ─────────────────────────────────────────────────────────────────────────
// COLLECTED — parcels that are physically in the rider's hands
//
// A meal run has a state the parcel backend cannot express. Its ladder is
// `accepted → arrived → picked`, and `picked` is terminal: it clears the order
// out of the accepted store and files it on Activity as finished. That is the
// right shape for a parcel, where collecting it from the customer IS the job.
//
// It is the wrong shape for a service route, where collecting is the *middle*.
// A rider loads ten lunches at a kitchen at 11:50 and delivers the last one at
// 13:20; writing `picked` at the kitchen would report all ten orders complete
// ninety minutes before anybody ate, and DailyGrubs is billed on that record.
//
// So `picked` stays where it belongs — the hand-over at the customer's door —
// and the intermediate state lives here, on the device, as the set of orders
// the rider is carrying. It is what moves a card off Home and onto Bookings.
//
// BACKEND DEPENDENCY: this is a local stand-in. The hub cannot see that a
// rider has loaded a kitchen until the API grows a `collected` status (and a
// `delivered` one, so the terminal event can be named for what it is). Until
// then a crash between the kitchen and the first door loses only the ordering,
// not the work: every order is still accepted server-side and still appears.
const String _kCollectedOrderIdsKeyBase = 'collected_order_ids';
/// Order ids the rider has loaded and is carrying.
Future<Set<String>> getCollectedOrderIds() async {
final prefs = await SharedPreferences.getInstance();
return (prefs.getStringList(await _scopedKey(_kCollectedOrderIdsKeyBase)) ??
[])
.toSet();
}
/// Records a load. Called once per kitchen, with everything taken from it.
Future<void> addCollectedOrderIds(List<String> ids) async {
final clean = ids.where((s) => s.isNotEmpty).toSet();
if (clean.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final existing =
(prefs.getStringList(await _scopedKey(_kCollectedOrderIdsKeyBase)) ?? [])
.toSet();
existing.addAll(clean);
await prefs.setStringList(
await _scopedKey(_kCollectedOrderIdsKeyBase),
existing.toList(),
);
}
/// ── The consignment id, captured at the pivot ──
///
/// `pickup-complete` is the call that *creates* the consignment, and its id is
/// the key every delivery action needs: `deliver` and `skip` are consignment
/// routes, not booking ones, and passing a booking id gets a 404 while the
/// rider is shown success.
///
/// That id was being thrown away. The response was wrapped into a legacy
/// envelope and discarded, and the row the rider then worked from on Deliveries
/// is a snapshot taken *before* the conversion — so it had no consignment id
/// either. The result: he collected an order, drove it to the door, pressed
/// **Delivered**, and the app refused because it did not know what to deliver.
///
/// The queue does carry the id once the backend catches up, so this is a
/// bridge, not a second source of truth: [consignmentIdFor] prefers the row and
/// falls back to what was recorded here.
const String _kConsignmentIdsKeyBase = 'consignment_ids_by_order';
/// Records the consignment `pickup-complete` just created for [orderId].
Future<void> rememberConsignmentId(String orderId, String consignmentId) async {
if (orderId.isEmpty || consignmentId.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final map = await getConsignmentIds();
map[orderId] = consignmentId;
await prefs.setString(
await _scopedKey(_kConsignmentIdsKeyBase),
jsonEncode(map),
);
}
/// Every consignment id this device recorded at a pivot, by order id.
Future<Map<String, String>> getConsignmentIds() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(await _scopedKey(_kConsignmentIdsKeyBase));
if (raw == null || raw.isEmpty) return <String, String>{};
try {
final decoded = jsonDecode(raw);
if (decoded is! Map) return <String, String>{};
return {
for (final e in decoded.entries)
e.key.toString(): e.value?.toString() ?? '',
}..removeWhere((_, v) => v.isEmpty);
} catch (_) {
return <String, String>{};
}
}
/// Drops ids for orders that are finished, so the map does not grow for the
/// life of the install.
Future<void> forgetConsignmentIds(List<String> orderIds) async {
if (orderIds.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final map = await getConsignmentIds();
var changed = false;
for (final id in orderIds) {
if (map.remove(id) != null) changed = true;
}
if (changed)
await prefs.setString(
await _scopedKey(_kConsignmentIdsKeyBase),
jsonEncode(map),
);
}
/// Clears ids once they are delivered — or once a short pick says they were
/// never in the box to begin with. Without this the set grows for the life of
/// the install and yesterday's run keeps today's cards off Home.
Future<void> removeCollectedOrderIds(List<String> ids) async {
final drop = ids.where((s) => s.isNotEmpty).toSet();
if (drop.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final remaining =
(prefs.getStringList(await _scopedKey(_kCollectedOrderIdsKeyBase)) ?? [])
.where((id) => !drop.contains(id))
.toList();
await prefs.setStringList(
await _scopedKey(_kCollectedOrderIdsKeyBase),
remaining,
);
}
// ─────────────────────────────────────────────────────────────────────────
// OUT FOR DELIVERY — the load has been released and the round has begun
//
// ── Why this is separate from `collected` ──
//
// Collected means "in my hands". It is written the moment a source hands over
// a crate, and on a two-kitchen morning the rider is collected-but-not-driving
// for the better part of an hour.
//
// If collection alone opened the delivery list, his round would build itself
// underneath him: he finishes kitchen one, five drops appear, he sets off, and
// kitchen two's five arrive behind him — a route he has already half-driven
// past. So the round is held until the whole load is aboard and then released
// in one gesture, which is what the rider means when he presses START DELIVERY.
//
// That press is also a real server event — `POST /miler/deliveries/start` moves
// the consignments to `Out_for_Delivery`, which is the state the delivery route
// requires — so this set is a mirror of a server fact, not a substitute for
// one. It exists because the queue endpoints are a poll behind the rider and he
// must not watch his round appear late.
const String _kOutForDeliveryKeyBase = 'out_for_delivery_order_ids';
/// Order ids the rider is actively delivering.
Future<Set<String>> getOutForDeliveryOrderIds() async {
final prefs = await SharedPreferences.getInstance();
return (prefs.getStringList(await _scopedKey(_kOutForDeliveryKeyBase)) ?? [])
.toSet();
}
/// Releases the round. Called once, with the whole load, after the server has
/// confirmed the transition.
Future<void> addOutForDeliveryOrderIds(List<String> ids) async {
final clean = ids.where((s) => s.isNotEmpty).toSet();
if (clean.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final existing =
(prefs.getStringList(await _scopedKey(_kOutForDeliveryKeyBase)) ?? [])
.toSet();
existing.addAll(clean);
await prefs.setStringList(
await _scopedKey(_kOutForDeliveryKeyBase),
existing.toList(),
);
}
/// Clears ids once they are delivered. Without this the set grows for the life
/// of the install and yesterday's round keeps today's cards on the delivery
/// list — the same trap [removeCollectedOrderIds] exists to avoid.
Future<void> removeOutForDeliveryOrderIds(List<String> ids) async {
final drop = ids.where((s) => s.isNotEmpty).toSet();
if (drop.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final remaining =
(prefs.getStringList(await _scopedKey(_kOutForDeliveryKeyBase)) ?? [])
.where((id) => !drop.contains(id))
.toList();
await prefs.setStringList(
await _scopedKey(_kOutForDeliveryKeyBase),
remaining,
);
}
// ─────────────────────────────────────────────────────────────────────────
// NOT LOADED — orders the kitchen could not supply
//
// Nine bags where the manifest said ten. The tenth is not skipped (nobody was
// visited), not cancelled (the customer did nothing wrong) and not delivered —
// it never entered the rider's box, and the only useful thing the app can do is
// take it off his route immediately and say why, rather than let him drive to a
// door at 12:50 for a meal that does not exist.
const String _kNotLoadedKeyBase = 'not_loaded_orders';
Future<Set<String>> getNotLoadedOrderIds() async {
final prefs = await SharedPreferences.getInstance();
return (prefs.getStringList(await _scopedKey(_kNotLoadedKeyBase)) ?? [])
.toSet();
}
Future<void> addNotLoadedOrderIds(List<String> ids) async {
final clean = ids.where((s) => s.isNotEmpty).toSet();
if (clean.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final existing =
(prefs.getStringList(await _scopedKey(_kNotLoadedKeyBase)) ?? []).toSet();
existing.addAll(clean);
await prefs.setStringList(
await _scopedKey(_kNotLoadedKeyBase),
existing.toList(),
);
}
// ─────────────────────────────────────────────────────────────────────────
// BAG IDENTITY — which bag an order travels in, for as long as it travels
//
// ── Why this is stored rather than recomputed ──
//
// A bag number is the order's position in the load it came off — Joe is Bag 1
// of the five that left Vidhya Kitchen. Recomputing that later from whatever
// list happens to be on screen is how identity breaks: deliver Joe, and a
// position-derived Arun silently becomes Bag 1. The rider is then looking for a
// bag labelled 1 that is in a customer's hallway.
//
// So the pairing is fixed once, at the counter, at the moment the manifest is
// confirmed — and read back unchanged through delivery, skip, completion and a
// day of intermittent signal. See [BagManifest], which derives it; this only
// remembers what it derived.
// ─────────────────────────────────────────────────────────────────────────
const String _kBagLabelsKeyBase = 'order_bag_labels';
/// Remembers which bag each order travels in. Merges, never replaces: a rider
/// works two kitchens and the second load must not erase the first.
Future<void> saveBagLabels(Map<String, String> byOrderId) async {
final clean = {
for (final e in byOrderId.entries)
if (e.key.trim().isNotEmpty && e.value.trim().isNotEmpty)
e.key.trim(): e.value.trim(),
};
if (clean.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final merged = {...await getBagLabels(), ...clean};
// Stored as `id\u0000label` rows: SharedPreferences has no map type, and a
// NUL separator cannot collide with an order id or a bag label.
await prefs.setStringList(await _scopedKey(_kBagLabelsKeyBase), [
for (final e in merged.entries) '${e.key}\u0000${e.value}',
]);
}
/// The bag each order is in, as recorded at pickup. Empty before any load.
Future<Map<String, String>> getBagLabels() async {
final prefs = await SharedPreferences.getInstance();
final rows =
prefs.getStringList(await _scopedKey(_kBagLabelsKeyBase)) ??
const <String>[];
return {
for (final row in rows)
if (row.contains('\u0000'))
row.split('\u0000').first: row.split('\u0000').last,
};
}
/// Clears every trace of a day's service run.
///
/// Both sets above are keyed by order id with no date on them, so without this
/// a rider who never finished yesterday's last drop would find today's Home
/// quietly hiding an unrelated order that happened to reuse the id.
Future<void> clearServiceRunState() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(await _scopedKey(_kCollectedOrderIdsKeyBase));
await prefs.remove(await _scopedKey(_kOutForDeliveryKeyBase));
await prefs.remove(await _scopedKey(_kNotLoadedKeyBase));
await prefs.remove(await _scopedKey(_kBagLabelsKeyBase));
}
// ─────────────────────────────────────────────────────────────────────────
// PURGING THE OLD DEMO LAYER OFF A DEVICE
//
// The mock data is gone from the source, and that is not enough. Every store
// above is SharedPreferences, and a phone that ran a build with the demo layer
// still has those rows on it — the accepted-store key is literally
// `mock_accepted_bookings`. Bookings merges the accepted store into its list
// and Activity reads the completed and skipped ones, so the demo stops keep
// appearing on exactly those two tabs with nothing in the repo producing them.
// Deleting the generator cannot reach data it already wrote.
//
// So this runs once at startup and drops anything the old layer left behind.
// It is keyed on the ids that layer used — `MOCK-Q-1001`, `del-mock-d-2003` —
// which is safe because a real order id comes from the backend and has never
// looked like that. A blanket wipe would take the rider's genuine accepted
// bookings with it.
const List<String> _demoIdMarkers = <String>['mock-', 'demo-'];
bool _looksLikeDemoRecord(Map<String, dynamic> booking) {
for (final key in const ['orderid', 'pickupid', 'orderheaderid']) {
final v = (booking[key] ?? '').toString().toLowerCase();
if (v.isEmpty) continue;
for (final marker in _demoIdMarkers) {
if (v.startsWith(marker) || v.contains('-$marker')) return true;
}
}
return false;
}
bool _looksLikeDemoId(String id) {
final v = id.toLowerCase();
return _demoIdMarkers.any((m) => v.startsWith(m) || v.contains('-$m'));
}
/// Removes every record the retired demo layer wrote, from all six stores.
///
/// Returns how many were dropped, so a one-off cleanup is visible in the log
/// rather than being a silent mutation of the rider's data.
Future<int> purgeDemoRecords() async {
final prefs = await SharedPreferences.getInstance();
var dropped = 0;
for (final key in [
await _scopedKey(_kAcceptedBookingsKeyBase),
await _scopedKey(_kCompletedBookingsKeyBase),
await _scopedKey(_kSkippedBookingsKeyBase),
]) {
final existing = _decode(prefs.getString(key));
if (existing.isEmpty) continue;
final kept = existing.where((b) => !_looksLikeDemoRecord(b)).toList();
if (kept.length != existing.length) {
dropped += existing.length - kept.length;
await prefs.setString(key, jsonEncode(kept));
}
}
for (final key in [
await _scopedKey(_kRejectedOrderIdsKeyBase),
await _scopedKey(_kCollectedOrderIdsKeyBase),
await _scopedKey(_kOutForDeliveryKeyBase),
await _scopedKey(_kNotLoadedKeyBase),
]) {
final existing = prefs.getStringList(key) ?? const <String>[];
if (existing.isEmpty) continue;
final kept = existing.where((id) => !_looksLikeDemoId(id)).toList();
if (kept.length != existing.length) {
dropped += existing.length - kept.length;
await prefs.setStringList(key, kept);
}
}
// The bag map is keyed by order id, so it needs the same sweep — otherwise a
// demo day leaves `MOCK-M-1001 → Bag 1` behind for the life of the install.
// Harmless on its own, but this store exists to be the one place that knows
// which bag an order is in, and a stale entry is exactly the kind of thing
// that is trusted later precisely because it is stored.
final bags =
prefs.getStringList(await _scopedKey(_kBagLabelsKeyBase)) ??
const <String>[];
if (bags.isNotEmpty) {
final kept = bags
.where((row) => !_looksLikeDemoId(row.split('\u0000').first))
.toList();
if (kept.length != bags.length) {
dropped += bags.length - kept.length;
await prefs.setStringList(await _scopedKey(_kBagLabelsKeyBase), kept);
}
}
if (dropped > 0) {
debugPrint('[STORE] purged $dropped demo record(s) left by an old build');
}
return dropped;
}

View File

@@ -1,4 +1,10 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:miler/data/api_status.dart';
import 'package:miler/data/consignment_state.dart';
import 'package:miler/data/route_order.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Base URL, bearer token, and the adapter that turns a v1 booking into the
@@ -59,6 +65,62 @@ class ApiConfig {
return headers;
}
// ---------------------------------------------------------------------------
// WHAT THE TOKEN ALREADY SAYS ABOUT THE RIDER
// ---------------------------------------------------------------------------
/// The `tenantid` claim carried in the bearer token, or 0 when there is none.
///
/// ── Why the app reads its own token ──
///
/// The rider's tenant decides his entire operational mode, and the server has
/// always known it — `GenerateToken` signs `tenantid` into every miler JWT.
/// What it did *not* do was put it in the `verify-pin` response body, so the
/// app was told the answer and could not hear it: the login carried tenant 13
/// in its token and a body with no tenant at all, and every rider resolved to
/// the fallback line.
///
/// The handler now returns it too, but a deployed backend is not the same
/// thing as a merged one, and the app should not need a release to be
/// redeployed alongside. The claim is the same fact from the same source —
/// signed by the server, not asserted by the client — so reading it closes
/// the gap without waiting on anything.
///
/// ── What this is not ──
///
/// It is **not** a security decision and must never become one. The signature
/// is not verified here — the app has no key and does not need one, because
/// every request is still authorised server-side by the same token. A rider
/// who edited this claim would change which screens his own phone draws and
/// nothing else; the API would keep answering for the tenant it verified.
///
/// Returns 0 for a missing, malformed or unparseable token rather than
/// throwing: an unreadable token must fall through to the other signals, not
/// take the app down at launch.
static int tenantIdFromToken(String? token) {
if (token == null || token.isEmpty) return 0;
try {
final parts = token.split('.');
if (parts.length != 3) return 0;
// JWT uses base64url without padding; `base64Url.decode` demands it.
String payload = parts[1];
payload += '=' * ((4 - payload.length % 4) % 4);
final decoded = json.decode(utf8.decode(base64Url.decode(payload)));
if (decoded is! Map) return 0;
final raw = decoded['tenantid'];
if (raw is int) return raw;
if (raw is num) return raw.toInt();
return int.tryParse(raw?.toString() ?? '') ?? 0;
} catch (e) {
debugPrint('[AUTH] could not read tenant from token: $e');
return 0;
}
}
/// The stored session's tenant claim. See [tenantIdFromToken].
static Future<int> storedTenantId() async =>
tenantIdFromToken(await getToken());
// ---------------------------------------------------------------------------
// Response envelope adapter: {success,data,message} -> {status,details}
// ---------------------------------------------------------------------------
@@ -94,26 +156,95 @@ class ApiConfig {
// / picked / skipped / cancelled / rejected
static String legacyStatusFromNew(String? newStatus) {
switch ((newStatus ?? '').trim()) {
case 'Miler_Assigned':
// Admin-assigned but NOT yet accepted by the rider. This must land on
// the Home tab as a pending booking to accept/reject — it becomes
// 'accepted' (a client-side marker in accepted_store) only once the
// rider accepts it, which is what moves it to the Bookings tab.
return 'assigned';
case 'Pickup_Scheduled':
return 'active';
case 'At_Customer':
return 'arrived';
case 'Picked_Up':
return 'Picked up';
case 'Converted_To_Consignment':
return 'picked';
case 'Cancelled':
return 'cancelled';
default:
return newStatus ?? '';
}
// ── Parsed, not string-matched ──
//
// This was a `switch` on raw strings whose `default` returned the value
// unchanged, so two things went wrong quietly. `Pending_Pickup` and
// `Created` were not listed at all and fell through as themselves, and a
// status added by the backend tomorrow would do the same — arriving in the
// UI as an unrecognised string that the row logic then had to guess at.
//
// [BookingStatus] covers the contract exhaustively and folds everything
// else into `unknown`, which maps to the empty string here: a stop the app
// cannot classify renders as undecided, never as picked, cancelled or
// otherwise finished. Wrong-but-safe beats wrong-and-settled.
return switch (BookingStatus.parse(newStatus)) {
// Admin-assigned but NOT yet accepted by the rider. These land on Home as
// pending bookings to accept or reject; the client-side accepted marker
// is what moves one to the work tab.
BookingStatus.pendingPickup ||
BookingStatus.created ||
BookingStatus.milerAssigned => 'assigned',
// ── `Pickup_Scheduled` is the ACCEPTED rung, not the arrived one ──
//
// It is the status the backend writes when the rider accepts an
// assignment — the flow doc calls it "the pickup is on their route", and
// the console maps it to *accepted* for exactly that reason.
//
// This mapped it to `active`, which every row in this app reads as *the
// rider is physically on the stop* (see `stopStateOf`, where a raw
// `active` outranks the local accepted record). The effect was that
// accepting skipped a whole rung: the moment the queue came back, the
// stop reported as arrived, and selecting it offered **Mark as Picked**
// for a kitchen the rider had not reached yet. Arrival — the one rung
// that has a real endpoint behind it, `reached` — could not be recorded
// at all, so the hub never saw it.
//
// The two applications were reading one status two different ways. This
// is the app's half of that; the console's half already said accepted.
BookingStatus.pickupScheduled => 'accepted',
// The rung `reached` writes. It was only ever reachable through the
// undocumented `At_Customer` spelling, handled here as a special case
// ahead of the parse; `Arrived_At_Pickup` is the contract name and both
// now come through [BookingStatus].
BookingStatus.arrivedAtPickup => 'arrived',
BookingStatus.pickedUp => 'Picked up',
BookingStatus.convertedToConsignment => 'picked',
// ── Past the boundary, and it has to say so ──
//
// A hyperlocal booking is released for delivery by `pickup-complete`
// itself, so this is the status most collected DailyGrubs orders carry.
// It fell through as `unknown` → '' → *undecided*, which put a bag
// already in the rider's box back on Home as work to accept. See
// [BookingStatus.outForDelivery].
BookingStatus.outForDelivery => 'outfordelivery',
BookingStatus.delivered => 'delivered',
BookingStatus.cancelled => 'cancelled',
BookingStatus.unknown => '',
};
}
/// The delivery half of the same translation.
///
/// ── Why a second mapper exists ──
///
/// A booking's story ends at `Converted_To_Consignment`. From there the work
/// belongs to a different object with a different vocabulary, and the app
/// had no way to see it on a list: every collected stop — in the box, on the
/// road, handed over an hour ago — reported the same terminal booking word.
/// That is why a delivered stop kept sitting on the Deliveries tab until
/// something asked its consignment directly, one round trip per stop.
///
/// Since 21 Aug 2026 `GET /miler/bookings` carries `consignmentstatus` on
/// every row, so the list itself answers it.
///
/// Returns `''` for the hub-side states (`Created`, `Inwarded_at_Hub`,
/// `Tripsheet_Loaded`, `In_Transit`) and for anything unrecognised — the
/// caller then keeps the booking's own word. A hub-side consignment is not
/// this rider's to act on and has no rung on his card; inventing one would
/// put a parcel in somebody else's warehouse on his screen.
static String legacyStatusFromConsignment(Object? raw) {
return switch (consignmentStateFromRaw(raw)) {
// Collected and in the rider's hands. Same rung the booking's
// `Converted_To_Consignment` produces — but now it is the consignment
// itself saying so.
ConsignmentState.collectedByMiler => 'picked',
ConsignmentState.outForDelivery => 'outfordelivery',
ConsignmentState.delivered => 'delivered',
ConsignmentState.cancelled ||
ConsignmentState.returnedToSender => 'cancelled',
_ => '',
};
}
// ---------------------------------------------------------------------------
@@ -124,6 +255,9 @@ class ApiConfig {
// pickuplat/pickuplong, dropaddress/droplat/droplon, pickupcustomer,
// pickupcontactno, collectionamt, step, type ...). Translate a new `booking`
// object into that shape so the existing cards/flows render unchanged.
/// The sequence spellings this adapter looks for, in [RouteOrder]'s order.
static const List<String> sequenceFieldNames = RouteOrder.sequenceKeys;
static Map<String, dynamic> pickupFromBooking(Map booking) {
String s(dynamic v) => v == null ? '' : v.toString();
// First non-null value among several candidate keys — makes the adapter
@@ -168,6 +302,21 @@ class ApiConfig {
'orderstatus',
'state',
]);
// ── The consignment outranks the booking, when there is one ──
//
// Not a preference — a correction. `Converted_To_Consignment` is where the
// booking stops being informative, so if the row also reports where the
// consignment has got to, that is the newer fact and the one the card must
// draw. Falls back to the booking's word whenever the consignment says
// nothing this rider can act on. See [legacyStatusFromConsignment].
final consignmentStatus = pick([
'consignmentstatus',
'consignmentStatus',
'consignment_status',
]);
final fromConsignment = legacyStatusFromConsignment(consignmentStatus);
return <String, dynamic>{
// identity
'pickupid': id,
@@ -177,7 +326,12 @@ class ApiConfig {
'bookingreference': s(ref),
// status
'orderstatus': legacyStatusFromNew(s(status)),
'orderstatus': fromConsignment.isNotEmpty
? fromConsignment
: legacyStatusFromNew(s(status)),
// Kept raw alongside, so anything that needs the consignment's own word
// reads it rather than inferring it back out of the legacy one.
'consignmentstatus': s(consignmentStatus),
// pickup side
'pickupcustomer': s(
@@ -201,6 +355,9 @@ class ApiConfig {
'pickupaddress': s(
pick(['pickupaddress', 'pickupAddress', 'pickup_address']),
),
'pickuppincode': s(
pick(['pickuppincode', 'pickupPincode', 'pickup_pincode']),
),
'pickuplat': s(
pick([
'pickuplatitude',
@@ -232,6 +389,9 @@ class ApiConfig {
'dropaddress': s(
pick(['deliveryaddress', 'deliveryAddress', 'delivery_address']),
),
'droppincode': s(
pick(['deliverypincode', 'deliveryPincode', 'delivery_pincode']),
),
'droplat': s(
pick(['deliverylatitude', 'deliveryLatitude', 'delivery_latitude']),
),
@@ -239,9 +399,80 @@ class ApiConfig {
pick(['deliverylongitude', 'deliveryLongitude', 'delivery_longitude']),
),
// stop type — new bookings are first-mile PICKUPS; delivery legs come
// through the consignment flow. Backend has no per-stop `type` yet.
'type': 'pickup',
// ── Where this stop is collected FROM ──
//
// On a milk run the rider works two or three sources in a morning and
// Home groups his stops under one heading per source, with the bulk
// collect belonging to that group. `stopSourceId` / `stopSourceName` read
// exactly these keys — and this adapter builds a fixed map, so a field it
// does not name is a field the UI can never see, however faithfully the
// backend sends it. That was the bug: every milk-run stop grouped into
// one nameless pile.
//
// Empty on a logistics booking, which is collected from a customer's door
// rather than from a source. Nothing is invented when the keys are
// absent: an empty string groups as "no source", which is the truth.
'sourceid': s(
pick([
'sourceid',
'sourceId',
'source_id',
'kitchenid',
'kitchenId',
'pickuplocationid',
'pickupLocationId',
]),
),
'sourcename': s(
pick([
'sourcename',
'sourceName',
'source_name',
'kitchenname',
'kitchenName',
'providerlocation',
'providercompany',
]),
),
'pickuplocationid': s(
pick(['pickuplocationid', 'pickupLocationId', 'pickup_location_id']),
),
// Present once the booking has been converted. It is what the delivery
// route keys on, so a milk-run drop cannot be closed without it.
'consignmentid': s(
pick(['consignmentid', 'consignmentId', 'consignment_id']),
),
// ── The hub's solved position in the route ──
//
// Verified live 21 Aug 2026: `GET /miler/bookings` carries `step` on
// every row. This adapter builds a **fixed map**, so a field it does not
// name is a field the UI can never see however faithfully the backend
// sends it — and `step` was not named. The delivery leg therefore had no
// sequence at all and fell back to ordering by distance, which is the
// app re-planning a route the hub had already solved.
//
// `0` means *not sequenced* and is passed through as such; see
// [RouteOrder.sequenceOf], which treats it as "no answer", never as
// position zero.
'step': pick(sequenceFieldNames) ?? 0,
// ── Which leg this stop is ──
//
// Also live, also previously hardcoded: every row came through as
// `'pickup'` because "backend has no per-stop type yet". It does now —
// 23 of this rider's 29 rows say `delivery`.
'type': s(pick(['stoptype', 'stopType', 'stop_type'])).isEmpty
? 'pickup'
: s(pick(['stoptype', 'stopType', 'stop_type'])).toLowerCase(),
// Route estimates, straight from the assignment. Zero until the hub's
// optimizer has run — the app shows its own estimate in that case and
// says so rather than drawing a confident 0.
'etaminutes': pick(['etaminutes', 'etaMinutes']) ?? 0,
'cumulativekms': pick(['cumulativekms', 'cumulativeKms']) ?? 0,
'cumulativeeta': pick(['cumulativeeta', 'cumulativeEta']) ?? 0,
// money — NOT provided by the new booking object yet (see gaps doc)
'collectionamt': booking['collectionamt'] ?? 0,

270
lib/data/api_status.dart Normal file
View File

@@ -0,0 +1,270 @@
/// ─────────────────────────────────────────────────────────────────────────
/// THE CONTRACT'S OWN VOCABULARY
///
/// Assignment, booking and consignment statuses arrive as free-form strings and
/// were compared as string literals wherever a screen needed one. That is the
/// same mistake [StopStatus] was written to fix on the read side of the parcel
/// flow, one layer further out: case-sensitivity landmines, variant spellings,
/// and — the expensive one — **an unrecognised value silently taking the
/// success branch** because the check was `!= 'Cancelled'`.
///
/// ── Unknown is a value, not a crash and not a success ──
///
/// The backend will add statuses this build has never heard of. Every enum here
/// therefore carries an [unknown] member and parses by *exact match on a
/// normalised string*, so a new server value lands on `unknown` and the screens
/// treat it as "not something I can act on" rather than as delivered, accepted
/// or complete. `values.byName`-style lookups and `firstWhere` without an
/// `orElse` both throw; neither is used.
///
/// The raw string is kept alongside, because a status this build cannot model
/// is still something the rider and the hub can read.
/// ─────────────────────────────────────────────────────────────────────────
library;
/// `Assigned_To_Miler` / `assigned to miler` / `ASSIGNED-TO-MILER` all reduce
/// to one key, so a spelling drift on the wire is not a behaviour change here.
String _key(Object? raw) => (raw?.toString() ?? '')
.trim()
.toLowerCase()
.replaceAll(RegExp(r'[\s\-]+'), '_');
/// What the hub has done with an offer of work.
enum AssignmentStatus {
assigned,
accepted,
rejected,
reassigned,
completed,
cancelled,
unknown;
static const _byKey = <String, AssignmentStatus>{
'assigned': AssignmentStatus.assigned,
'accepted': AssignmentStatus.accepted,
'rejected': AssignmentStatus.rejected,
'reassigned': AssignmentStatus.reassigned,
'completed': AssignmentStatus.completed,
'cancelled': AssignmentStatus.cancelled,
};
static AssignmentStatus parse(Object? raw) =>
_byKey[_key(raw)] ?? AssignmentStatus.unknown;
/// The rider still owes this one a decision.
bool get needsDecision => this == AssignmentStatus.assigned;
/// He has taken it on and it is not finished.
bool get isLive => this == AssignmentStatus.accepted;
/// Nothing further will happen here. **`unknown` is deliberately not
/// settled** — a status this build cannot read must not be filed as done.
bool get isSettled =>
this == AssignmentStatus.rejected ||
this == AssignmentStatus.reassigned ||
this == AssignmentStatus.completed ||
this == AssignmentStatus.cancelled;
}
/// A pickup, from the moment it exists to the moment it becomes a consignment.
enum BookingStatus {
pendingPickup,
created,
milerAssigned,
pickupScheduled,
/// The rider is standing at the pickup address.
///
/// `POST /miler/bookings/:id/reached` writes this — but until 21 Aug 2026 it
/// wrote nothing the app could observe, so **I've arrived** appeared to do
/// nothing and the console never showed the rung. The backend now persists
/// it under this name. `At_Customer` is the older spelling seen in the wild
/// and means the same thing; both parse here.
arrivedAtPickup,
pickedUp,
convertedToConsignment,
/// ── The hyperlocal short-circuit ──
///
/// `pickup-complete` decides routing from the two pincodes: matching 3-digit
/// prefixes are hyperlocal and the parcel goes **straight to
/// `Out_for_Delivery`** instead of routing via a hub. Every DailyGrubs run is
/// hyperlocal, so this is not an edge case on that line — it is the status a
/// collected meal actually carries.
///
/// It was missing from this enum, and the cost was precise: [parse] answered
/// [unknown], which the legacy translation maps to the empty string, which
/// reads as *undecided* — so a bag already in the rider's box came back from
/// the queue looking like work he had not accepted yet. The local collected
/// record hid it on the device that did the pickup and nowhere else: a
/// restart, a reinstall or a second device showed collected orders sitting on
/// Home as pending.
outForDelivery,
/// Handed over. Terminal.
delivered,
cancelled,
unknown;
static const _byKey = <String, BookingStatus>{
'pending_pickup': BookingStatus.pendingPickup,
'created': BookingStatus.created,
'miler_assigned': BookingStatus.milerAssigned,
'pickup_scheduled': BookingStatus.pickupScheduled,
'arrived_at_pickup': BookingStatus.arrivedAtPickup,
'at_customer': BookingStatus.arrivedAtPickup,
'picked_up': BookingStatus.pickedUp,
'converted_to_consignment': BookingStatus.convertedToConsignment,
// Spelled `Out_for_Delivery` on bookings — lower-case `f`, unlike the
// consignment enum's `Out_For_Delivery`. `_key` lower-cases before lookup
// so both land here, which is deliberate: the difference is a backend
// inconsistency, not a distinction, and no caller should have to know it.
'out_for_delivery': BookingStatus.outForDelivery,
'delivered': BookingStatus.delivered,
'cancelled': BookingStatus.cancelled,
};
static BookingStatus parse(Object? raw) =>
_byKey[_key(raw)] ?? BookingStatus.unknown;
/// Collection has happened — the pickup-to-delivery boundary has been
/// crossed, server-side.
///
/// `pickup-complete` is the pivot: it converts the booking into a consignment
/// and, on a hyperlocal run, releases it for delivery in the same call. Every
/// rung from there on counts, including [delivered] — a delivered order was
/// certainly collected, and a predicate that said otherwise would put a
/// finished stop back in the pickup domain.
///
/// This is what [WorkBoundary] reads. It must never include a rung before the
/// hand-over: an acceptance is a decision about work still to be done.
bool get isCollected =>
this == BookingStatus.pickedUp ||
this == BookingStatus.convertedToConsignment ||
this == BookingStatus.outForDelivery ||
this == BookingStatus.delivered;
/// The rider still has work to do at this address.
bool get isOpen =>
this == BookingStatus.pendingPickup ||
this == BookingStatus.created ||
this == BookingStatus.milerAssigned ||
this == BookingStatus.pickupScheduled ||
this == BookingStatus.arrivedAtPickup;
/// ── Cancellation is refused once picked up ──
///
/// The server enforces it; this is the client half, so the control is not
/// offered in a state where pressing it can only fail.
bool get canCancel => isOpen;
}
/// A consignment, from the hub's point of view.
enum ConsignmentStatus {
created,
inwardedAtHub,
tripsheetLoaded,
inTransit,
outForDelivery,
delivered,
rtoInitiated,
returnedToSender,
missing,
damaged,
unknown;
static const _byKey = <String, ConsignmentStatus>{
'created': ConsignmentStatus.created,
'inwarded_at_hub': ConsignmentStatus.inwardedAtHub,
'tripsheet_loaded': ConsignmentStatus.tripsheetLoaded,
'in_transit': ConsignmentStatus.inTransit,
'out_for_delivery': ConsignmentStatus.outForDelivery,
'delivered': ConsignmentStatus.delivered,
'rto_initiated': ConsignmentStatus.rtoInitiated,
'returned_to_sender': ConsignmentStatus.returnedToSender,
'missing': ConsignmentStatus.missing,
'damaged': ConsignmentStatus.damaged,
};
static ConsignmentStatus parse(Object? raw) =>
_byKey[_key(raw)] ?? ConsignmentStatus.unknown;
/// The one state `deliver` and `skip` are legal from — anything else is a
/// 400. Offering the control elsewhere is offering a guaranteed failure.
bool get isDeliverable => this == ConsignmentStatus.outForDelivery;
/// Handed over. Only this one.
bool get isDelivered => this == ConsignmentStatus.delivered;
/// Going back, or gone. Not failures the rider caused, and not states he can
/// work out of on this screen.
bool get isReturning =>
this == ConsignmentStatus.rtoInitiated ||
this == ConsignmentStatus.returnedToSender;
/// Something is wrong with the parcel itself and the hub owns it now.
bool get isException =>
this == ConsignmentStatus.missing || this == ConsignmentStatus.damaged;
/// Nothing further happens on the rider's phone. **`unknown` is excluded** —
/// see the note at the top of this file.
bool get isClosed => isDelivered || isReturning || isException;
}
/// What the rider is doing, as the availability endpoint understands it.
///
/// `Break`, not `On_Break`: the obvious guess is the wrong one, and it is the
/// value the server validates against.
enum RiderAvailability {
offline,
available,
assigned,
onPickup,
atCustomer,
pickedUp,
onDelivery,
onBreak,
blocked,
unknown;
static const _byKey = <String, RiderAvailability>{
'offline': RiderAvailability.offline,
'available': RiderAvailability.available,
'assigned': RiderAvailability.assigned,
'on_pickup': RiderAvailability.onPickup,
'at_customer': RiderAvailability.atCustomer,
'picked_up': RiderAvailability.pickedUp,
'on_delivery': RiderAvailability.onDelivery,
'break': RiderAvailability.onBreak,
'blocked': RiderAvailability.blocked,
};
static RiderAvailability parse(Object? raw) =>
_byKey[_key(raw)] ?? RiderAvailability.unknown;
/// The exact string this value is sent back as. Named separately from the
/// Dart member so `onBreak` can carry the wire's `Break` without the enum
/// having a member called `break`, which is a keyword.
String get wire => switch (this) {
RiderAvailability.offline => 'Offline',
RiderAvailability.available => 'Available',
RiderAvailability.assigned => 'Assigned',
RiderAvailability.onPickup => 'On_Pickup',
RiderAvailability.atCustomer => 'At_Customer',
RiderAvailability.pickedUp => 'Picked_Up',
RiderAvailability.onDelivery => 'On_Delivery',
RiderAvailability.onBreak => 'Break',
RiderAvailability.blocked => 'Blocked',
// Never sent. A value this build cannot model must not be echoed back to
// the server as though it were understood.
RiderAvailability.unknown => 'Offline',
};
/// On duty in any sense — anything but offline, blocked, or unreadable.
bool get isWorking =>
this != RiderAvailability.offline &&
this != RiderAvailability.blocked &&
this != RiderAvailability.unknown;
}

View File

@@ -1,8 +1,6 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:miler/data/api_config.dart';
import 'package:miler/data/miler_api.dart';
/// Resolves a BOOKING id into the BOOKING ASSIGNMENT id that the accept/reject
/// endpoints key on.
@@ -21,6 +19,27 @@ class AssignmentLookup {
/// bookingid (as string) -> bookingassignmentid
static final Map<String, int> _cache = <String, int>{};
/// bookingid (as string) -> the hub's solved stop number.
///
/// ── Why the sequence lives here and not on the booking ──
///
/// `step` is written onto **`bookingassignments`**, not onto the booking: the
/// hub's batch-assign endpoint sends each affected rider's whole active set to
/// the route optimizer and writes the returned road-network order back onto
/// the assignment rows. So `GET /miler/bookings` — the app's one read of the
/// day — cannot carry it, and this endpoint is the only place it exists.
///
/// It was being thrown away. This class fetched the assignment rows for their
/// ids and dropped everything else, so `Trip.sortStops` — which documents
/// `step` as authoritative and says the app must never second-guess the
/// admin's order — never saw one and fell back to booked time on every run.
/// The rider was choosing his own order while the hub believed it had solved
/// one for him.
///
/// `step: 0` means *not sequenced*, never *first*, and is not stored.
static final Map<String, int> _steps = <String, int>{};
static DateTime? _fetchedAt;
/// Assignments change whenever the hub assigns work, so the map goes stale
@@ -41,9 +60,22 @@ class AssignmentLookup {
static void invalidate() {
_cache.clear();
_steps.clear();
_fetchedAt = null;
}
/// The hub's stop order, by booking id, refreshed on the same TTL as the ids.
///
/// Best-effort by contract: sequencing is a separate service and the flow doc
/// is explicit that it being down must leave bookings *assigned but
/// unordered* rather than undo anything. An empty map therefore means "no
/// solved order available", which the sort reads as "fall back to booked
/// time" — not as "every stop is step 0".
static Future<Map<String, int>> steps() async {
if (_isStale) await _refresh();
return Map<String, int>.unmodifiable(_steps);
}
/// The assignment id for [bookingId], or null when the backend has no
/// assignment row for it (or the call fails).
///
@@ -67,22 +99,21 @@ class AssignmentLookup {
static Future<void> _refresh() async {
try {
final uri = Uri.parse(ApiConfig.url('/miler/assignments'));
final res = await http
.get(uri, headers: await ApiConfig.authHeaders())
.timeout(const Duration(seconds: 15));
if (res.statusCode < 200 || res.statusCode >= 300) {
debugPrint('[ASSIGNMENTS] fetch failed: HTTP ${res.statusCode}');
// Through [MilerApi], not a hand-rolled `http.get`. It was the latter,
// which meant this one call carried its own header building, its own
// envelope unwrapping and its own idea of what a 2xx is — and, because it
// bypassed `MilerApi.client`, it was the only request in the app that a
// test could not stub, so the suite made real network calls to the
// production API while checking a repository.
final res = await MilerApi.assignments();
if (!res.ok) {
debugPrint('[ASSIGNMENTS] fetch failed: HTTP ${res.status}');
return;
}
final decoded = json.decode(res.body);
dynamic data = decoded;
if (decoded is Map) {
data = decoded['data'] ?? decoded['details'] ?? decoded['assignments'];
}
if (data is! List) {
debugPrint('[ASSIGNMENTS] unexpected payload: ${data.runtimeType}');
final List<dynamic> data = res.list;
if (data.isEmpty && res.data is! List) {
debugPrint('[ASSIGNMENTS] no assignment rows in the response');
return;
}
@@ -90,9 +121,14 @@ class AssignmentLookup {
_cache
..clear()
..addAll(next);
final nextSteps = buildStepIndex(data);
_steps
..clear()
..addAll(nextSteps);
_fetchedAt = DateTime.now();
debugPrint(
'[ASSIGNMENTS] cached ${_cache.length} booking->assignment ids',
'[ASSIGNMENTS] cached ${_cache.length} booking->assignment ids, '
'${_steps.length} sequenced',
);
} catch (e) {
debugPrint('[ASSIGNMENTS] fetch error: $e');
@@ -131,6 +167,44 @@ class AssignmentLookup {
return index;
}
/// Collapses the assignment rows into one bookingid -> step entry.
///
/// Same newest-first, actionable-wins rule as [buildIndex] — a booking that
/// was assigned, rejected and reassigned must take the live assignment's
/// sequence, not the rejected corpse's — so the two indexes cannot describe
/// different assignment rows for the same booking.
@visibleForTesting
static Map<String, int> buildStepIndex(List<dynamic> rows) {
final index = <String, int>{};
final tookActionable = <String>{};
for (final row in rows.whereType<Map>()) {
final bookingKey = row['bookingid']?.toString().trim() ?? '';
if (bookingKey.isEmpty) continue;
final step = _asInt(row['step']) ?? 0;
final status = (row['assignmentstatus'] ?? '').toString().trim();
final isActionable = _actionable.contains(status);
final seen =
index.containsKey(bookingKey) || tookActionable.contains(bookingKey);
if (seen && !(isActionable && !tookActionable.contains(bookingKey))) {
continue;
}
if (isActionable) tookActionable.add(bookingKey);
// 0 is "not sequenced". Storing it would make an unsequenced stop look
// like it had been solved into position zero.
if (step > 0) {
index[bookingKey] = step;
} else {
index.remove(bookingKey);
}
}
return index;
}
static int? _asInt(dynamic v) {
if (v is int) return v;
if (v is num) return v.toInt();

124
lib/data/bag_manifest.dart Normal file
View File

@@ -0,0 +1,124 @@
import 'package:miler/data/milk_run.dart';
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// ONE PICKUP ORDER = ONE BAG
///
/// The rule the whole rider workflow rests on, in one file so it cannot be
/// stated two different ways on two screens.
///
/// If a kitchen has five pickup orders, the rider is handed **five bags** — one
/// per order — and ends up with **five deliveries**, each carrying the bag it
/// arrived in:
///
/// ```
/// Order 1 → Bag 1 → Joe
/// Order 2 → Bag 2 → Arun
/// Order 3 → Bag 3 → Priya
/// ```
///
/// ── Why this is a file and not a `+ 1` at each call site ──
///
/// Every screen in the pickup half of the app has to answer "how many bags?"
/// and "which bag is this?", and every screen that answered it independently
/// answered it differently: one showed a backend `Quantity` (an order's item
/// count, not a bag count), one showed a bare `baglabel` when the payload
/// happened to carry one and nothing at all when it did not, and the kitchen
/// heading counted "meals". A rider standing at a counter comparing "5 meals"
/// on his phone with four bags on the shelf has no way to tell which of the two
/// numbers is wrong.
///
/// So the count is **derived from the orders**, always, and there is no second
/// quantity anywhere that can disagree with it.
///
/// ── What is derived, and what is never invented ──
///
/// • **The count** is `orders.length`. It cannot drift, because it is not
/// stored — a bag is what an order arrives in.
/// • **The identity** prefers the label the backend printed on the physical
/// bag ([stopBagLabel]) and falls back to the order's **position in its own
/// pickup group** — `Bag 1`, `Bag 2` — which is what a rider counting a
/// shelf actually uses.
///
/// Nothing here fabricates a crate, a tote or a quantity the backend has not
/// sent. If a future contract ever puts more than one bag on an order, this is
/// the one file that changes.
/// ─────────────────────────────────────────────────────────────────────────
class BagManifest {
BagManifest._();
/// One line of a pickup manifest: the order, the customer it is for, and the
/// bag it travels in.
///
/// Deliberately carries the stop itself: every caller that renders a manifest
/// also needs to act on the orders behind it, and pairing them here is what
/// stops a screen from rendering five lines and posting four ids.
static List<BagLine> forGroup(List<Map<String, dynamic>> stops) => [
for (var i = 0; i < stops.length; i++)
BagLine(
stop: stops[i],
orderId: MilkRun.idOf(stops[i]),
customer: customerOf(stops[i]),
bag: _bagFor(stops[i], i),
),
];
/// The bag one order travels in, given the group it was collected with.
///
/// [stops] must be the whole pickup group in route order — the position in it
/// *is* the bag number when the backend has not printed one.
static String bagFor(
Map<String, dynamic> stop,
List<Map<String, dynamic>> group,
) {
final id = MilkRun.idOf(stop);
final index = group.indexWhere((s) => MilkRun.idOf(s) == id);
return _bagFor(stop, index < 0 ? 0 : index);
}
static String _bagFor(Map<String, dynamic> stop, int index) {
final printed = stopBagLabel(stop);
return printed.isNotEmpty ? printed : 'Bag ${index + 1}';
}
/// `5 bags` — the load, in the unit the rider actually carries.
///
/// It used to print `5 orders · 5 bags`: the two halves of the one-bag-per-
/// order rule side by side, as a check the rider could eyeball. On a device
/// that check cost the header the fact it exists for — a real kitchen name
/// plus `13 orders · 13 bags` pushed `11 to accept` off the row, and the
/// clause that truncated was the one that changes what he does next.
///
/// One number survives, and it is the physical one: the rows underneath are
/// named `Bag 1 … Bag n` and a settled group says `n bags collected`, so
/// "bags" is the word this column already speaks. No information is lost —
/// the rule makes the counts identical — and the verifiable statement lives
/// where the verifying happens: the manifest list itself, one line per bag.
static String countLabel(int orders) {
return orders == 1 ? '1 bag' : '$orders bags';
}
/// The name the bag is going to, for a manifest line.
static String customerOf(Map<String, dynamic> stop) =>
(stop['pickupcustomer'] ??
stop['customername'] ??
stop['tenantname'] ??
'')
.toString()
.trim();
}
/// One row of a pickup manifest. See [BagManifest.forGroup].
class BagLine {
final Map<String, dynamic> stop;
final String orderId;
final String customer;
final String bag;
const BagLine({
required this.stop,
required this.orderId,
required this.customer,
required this.bag,
});
}

View File

@@ -0,0 +1,316 @@
import 'package:flutter/foundation.dart';
import 'package:miler/data/miler_api.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// THE CONSIGNMENT'S OWN VOCABULARY
///
/// A booking and a consignment are two different objects with two different
/// state machines, and the app has been paying for treating them as one.
///
/// booking Pending_Pickup → Miler_Assigned → Pickup_Scheduled
/// → Picked_Up → **Converted_To_Consignment** | Cancelled
///
/// consignment Created → Inwarded_at_Hub → Tripsheet_Loaded → In_Transit
/// → **Out_for_Delivery** → **Delivered** | RTO | …
///
/// `Converted_To_Consignment` is where the booking's story ENDS. There is no
/// booking `Delivered`, and `GET /miler/bookings` therefore reports the same
/// terminal word for a parcel sitting in a hub, a parcel on a rider's bike and
/// a parcel handed over an hour ago. Any code that decides "is this stop still
/// mine to deliver?" from a booking status is asking the wrong object — which
/// is exactly the defect this file exists to close.
///
/// ── What this is not ──
///
/// Not a local mirror and not a cache to write into. The consignment's state
/// belongs to the server; the only honest way to know it is to ask. Nothing
/// here ever *sets* a state — see [ConsignmentGate].
/// ─────────────────────────────────────────────────────────────────────────
enum ConsignmentState {
created,
inwardedAtHub,
tripsheetLoaded,
inTransit,
/// **In the rider's hands, not yet on the road.**
///
/// Added by the backend on 21 Aug 2026 and it closes the gap this app had
/// been modelling locally: `pickup-complete` used to push hyperlocal work
/// straight to [outForDelivery], so the hub saw "actively delivering" for
/// food still on the kitchen counter and there was no server state meaning
/// *collected, holding*. Now the pivot lands here and
/// `POST /consignments/:id/start-delivery` makes the release.
collectedByMiler,
/// Released to a rider. **The only state `deliver` accepts.**
outForDelivery,
/// Handed over. Terminal.
delivered,
rtoInitiated,
returnedToSender,
missing,
damaged,
cancelled,
/// The server said something this build does not know. Deliberately not
/// deliverable and deliberately not "finished" — an unrecognised state is a
/// reason to ask, never a reason to act.
unknown,
}
/// Normalises the backend's `eventstatus` / `status` spelling.
ConsignmentState consignmentStateFromRaw(dynamic raw) {
final s = (raw?.toString() ?? '').trim().toLowerCase().replaceAll(' ', '_');
switch (s) {
case 'created':
return ConsignmentState.created;
case 'inwarded_at_hub':
return ConsignmentState.inwardedAtHub;
case 'tripsheet_loaded':
return ConsignmentState.tripsheetLoaded;
case 'in_transit':
return ConsignmentState.inTransit;
case 'collected_by_miler':
case 'collectedbymiler':
return ConsignmentState.collectedByMiler;
case 'out_for_delivery':
case 'outfordelivery':
return ConsignmentState.outForDelivery;
case 'delivered':
return ConsignmentState.delivered;
case 'rto_initiated':
return ConsignmentState.rtoInitiated;
case 'returned_to_sender':
return ConsignmentState.returnedToSender;
case 'missing':
return ConsignmentState.missing;
case 'damaged':
return ConsignmentState.damaged;
case 'cancelled':
case 'canceled':
return ConsignmentState.cancelled;
default:
return ConsignmentState.unknown;
}
}
extension ConsignmentStateX on ConsignmentState {
/// `POST /miler/consignments/:id/deliver` is refused unless the consignment
/// is `Out_for_Delivery`. One state, no inference, no other spelling.
bool get isDeliverable => this == ConsignmentState.outForDelivery;
/// Already handed over. The work is done server-side; the rider's device is
/// the thing that is behind.
bool get isDelivered => this == ConsignmentState.delivered;
/// Closed for good, one way or another — nothing left for a rider to do.
bool get isClosed =>
this == ConsignmentState.delivered ||
this == ConsignmentState.cancelled ||
this == ConsignmentState.returnedToSender;
/// Collected and waiting on the rider's own **Start round**, not on anyone
/// else. Deliberately *not* [awaitsHub]: telling a rider the hub has his
/// parcel while it is in his own box is the error this state exists to
/// prevent.
bool get needsRelease => this == ConsignmentState.collectedByMiler;
/// Still inside the hub's half of the network. **This is the case the
/// "not released yet" guard is for** — a logistics consignment sitting at a
/// hub genuinely cannot be delivered by this rider, and must stay blocked.
bool get awaitsHub =>
this == ConsignmentState.created ||
this == ConsignmentState.inwardedAtHub ||
this == ConsignmentState.tripsheetLoaded ||
this == ConsignmentState.inTransit;
/// After a successful `skip`, whether the stop is **still the rider's
/// problem**.
///
/// A skip is a failed attempt, not a closed consignment, and what the server
/// does with one is the server's business: it may move the consignment to a
/// failure state, or leave it `Out_for_Delivery` for a second attempt or an
/// RTO decision taken elsewhere. The app cannot tell from the skip's own
/// 200, so it reads the consignment afterwards and asks this.
///
/// **Unknown counts as open.** A read that failed is not permission to
/// declare a stop finished — writing a terminal local record over a
/// consignment the hub still calls open leaves two systems disagreeing about
/// whether a parcel is anyone's problem, with the rider's screen the only
/// one saying it is not.
bool get isOpenAfterSkip =>
isDeliverable || needsRelease || this == ConsignmentState.unknown;
/// `skip` is accepted from both halves of the rider's custody — the backend
/// widened it on 21 Aug 2026 so a failed attempt is reportable the moment
/// the parcel is collected, not only once the round has started.
bool get canSkip => needsRelease || isDeliverable;
}
/// What the app is allowed to do with a consignment, decided from the
/// authoritative server state rather than from a booking row or a local flag.
enum DeliverGate {
/// `Out_for_Delivery` — post the delivery.
deliverable,
/// `Delivered` — the server already has it. Reconcile locally; do not post
/// again and do not show the rider an error for work he completed.
alreadyDelivered,
/// Collected but the round has not been started. The rider unblocks this
/// himself — **Start round** on the Deliveries tab.
needsRelease,
/// A real hub-side hold. Block, and say so.
awaitingHub,
/// Closed some other way (cancelled, returned). Not deliverable, not an
/// error the rider caused.
closed,
/// The state could not be read — no id, no network, an unparseable answer.
/// **Not a block.** A read failure is not evidence of anything, so the
/// delivery is attempted and the server remains the judge. Blocking here
/// would strand a rider at a door because a GET timed out.
unknown,
}
/// Reads a consignment's authoritative state and answers what may be done.
///
/// ── Why this needs a network call at all ──
///
/// Nothing the rider's device already holds can answer it. `GET /miler/bookings`
/// carries the *booking* status (terminal at `Converted_To_Consignment`) and no
/// consignment status at all — verified against the live API. The local
/// collected/out-for-delivery sets record what the *rider* did on *this*
/// handset, which is exactly what a reinstall, a second device or a
/// hub-side change makes wrong.
///
/// `GET /miler/consignments/:consignmentid` reports the current state directly
/// — shipped 21 Aug 2026 at this app's request. Before it, the only route that
/// carried consignment state was `…/logs/:id`, and reading a state machine
/// meant pulling its entire history and sorting it. That still works and
/// remains the fallback here, because a rider mid-round on a build that meets
/// an older deployment must not be blocked by a 404.
class ConsignmentGate {
ConsignmentGate._();
/// Reads the current state of [consignmentId].
///
/// Returns [ConsignmentState.unknown] on any failure — see [DeliverGate].
static Future<ConsignmentState> stateOf(Object consignmentId) async {
final id = consignmentId.toString().trim();
if (id.isEmpty || id == '0') return ConsignmentState.unknown;
try {
final res = await MilerApi.consignment(id);
if (res.ok) {
final state = _stateFromDetail(res.data);
if (state != ConsignmentState.unknown) {
debugPrint('[CONSIGNMENT] $id is ${state.name}');
return state;
}
} else {
debugPrint('[CONSIGNMENT] get $id -> ${res.status} ${res.message}');
}
// Either the route is not deployed yet, or it answered something this
// build cannot read. The history still holds the answer.
return _stateFromLogs(id);
} catch (e) {
debugPrint('[CONSIGNMENT] could not read $id: $e');
return ConsignmentState.unknown;
}
}
/// Reads `GET /miler/consignments/:id`.
///
/// The response carries both a `status` string and the derived booleans
/// (`collected`, `out_for_delivery`, `delivered`, `can_deliver`…). The
/// string is preferred: it is the state itself, whereas the flags are the
/// server's opinion *about* the state and can be extended independently.
/// The flags are only consulted when the string is a word this build has
/// never heard of — and there, `delivered` first, because mistaking a
/// completed delivery for an open one is the failure that makes a rider
/// re-post work he has already done.
static ConsignmentState _stateFromDetail(Map<String, dynamic> data) {
if (data.isEmpty) return ConsignmentState.unknown;
final raw = data['consignmentstatus'] ?? data['status'] ?? data['state'];
final named = consignmentStateFromRaw(raw);
if (named != ConsignmentState.unknown) return named;
bool flag(String key) => data[key] == true || '${data[key]}' == 'true';
// State flags first — they describe where the consignment *is*.
if (flag('delivered')) return ConsignmentState.delivered;
if (flag('out_for_delivery')) return ConsignmentState.outForDelivery;
if (flag('collected')) return ConsignmentState.collectedByMiler;
// Then the permission flags, which describe what may be *done*. A weaker
// signal — `can_deliver` is the server having already decided the answer
// this app derives from the state — but a far better one than giving up:
// `unknown` blocks the Start delivery bar and makes the door guess.
if (flag('can_deliver')) return ConsignmentState.outForDelivery;
if (flag('can_start_delivery')) return ConsignmentState.collectedByMiler;
return ConsignmentState.unknown;
}
/// The pre-21-Aug-2026 read: the whole history, newest row wins.
static Future<ConsignmentState> _stateFromLogs(String id) async {
final res = await MilerApi.consignmentLogs(id);
if (!res.ok) {
debugPrint('[CONSIGNMENT] logs $id -> ${res.status} ${res.message}');
return ConsignmentState.unknown;
}
// Rows arrive oldest-first; the state is whatever happened last. Sorted
// on `historyid` rather than trusting arrival order, because a state
// machine read out of order is worse than not read.
final rows = <Map<String, dynamic>>[
for (final r in res.list)
if (r is Map) r.map((k, v) => MapEntry(k.toString(), v)),
];
if (rows.isEmpty) return ConsignmentState.unknown;
rows.sort((a, b) {
final ai = int.tryParse('${a['historyid'] ?? 0}') ?? 0;
final bi = int.tryParse('${b['historyid'] ?? 0}') ?? 0;
if (ai != bi) return ai.compareTo(bi);
return (a['createdat'] ?? '').toString().compareTo(
(b['createdat'] ?? '').toString(),
);
});
final state = consignmentStateFromRaw(
rows.last['eventstatus'] ?? rows.last['status'],
);
debugPrint('[CONSIGNMENT] $id is ${state.name} (from logs)');
return state;
}
/// [_stateFromDetail], reachable from a test.
///
/// The flag-reading path is the one that runs when the backend adds a state
/// this build has never heard of — the case that cannot be produced by
/// naming a status, and is exactly the case worth pinning.
@visibleForTesting
static ConsignmentState stateFromDetailForTest(Map<String, dynamic> data) =>
_stateFromDetail(data);
/// Maps a state to what the delivery flow may do about it.
static DeliverGate gateFor(ConsignmentState state) {
if (state.isDeliverable) return DeliverGate.deliverable;
if (state.isDelivered) return DeliverGate.alreadyDelivered;
if (state.needsRelease) return DeliverGate.needsRelease;
if (state.awaitsHub) return DeliverGate.awaitingHub;
if (state.isClosed) return DeliverGate.closed;
return DeliverGate.unknown;
}
/// Convenience: read and classify in one call.
static Future<DeliverGate> gateOf(Object consignmentId) async =>
gateFor(await stateOf(consignmentId));
}

276
lib/data/lifecycle.dart Normal file
View File

@@ -0,0 +1,276 @@
import 'package:flutter/foundation.dart';
import 'package:miler/data/api_status.dart';
import 'package:miler/data/consignment_state.dart';
import 'package:miler/data/miler_api.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// WHAT THE SERVER ACTUALLY CONFIRMED
///
/// Every rung this app draws — Accepted, Arrived, Picked, Active, Delivered —
/// is a claim about what the **hub** believes. The app had no way to check
/// that claim: a mutation returned `ok`, the screen advanced, and whether the
/// office could see the same thing was never asked.
///
/// ── The bug that made this a file ──
///
/// Verified against production on 21 Aug 2026, booking 78:
///
/// ```
/// BEFORE GET /miler/bookings status = Miler_Assigned
/// CALL POST /miler/bookings/78/reached
/// → 200 {"success":true,"data":{"bookingid":78,
/// "status":"Miler_Assigned"}}
/// AFTER GET /miler/bookings status = Miler_Assigned
/// ```
///
/// `reached` **returns success and writes nothing.** It echoes the booking's
/// current status. So the rider pressed *I've arrived*, the app got `ok: true`,
/// drew ARRIVED, and the hub never heard about it — not because the app failed
/// to call, but because a 200 was taken as proof of a transition that never
/// happened.
///
/// ── The rule ──
///
/// A 200 is proof the call was accepted. It is **not** proof of a state
/// change. The only proof of a state change is the state coming back in the
/// response. So every lifecycle mutation is read through this file, which
/// answers three separate questions the caller used to conflate:
///
/// • did the call succeed? → [ApiResult.ok]
/// • did the state actually move? → [TransitionOutcome.confirmed]
/// • what is the state now? → [bookingStatus] / [consignmentState]
///
/// Nothing here blocks a rider. A backend that does not record his arrival is
/// the backend's fault, and stranding him at a kitchen over it would turn one
/// broken endpoint into a stopped operation. What it does is stop the app
/// *claiming* the hub agrees when it demonstrably does not — the claim is
/// downgraded, logged with the evidence, and surfaced.
/// ─────────────────────────────────────────────────────────────────────────
enum TransitionOutcome {
/// The response carries the state the call was supposed to produce. The rung
/// the rider sees is the rung the office sees.
confirmed,
/// The call succeeded and the state did **not** move — or moved somewhere
/// the response does not name. The rider may carry on; the app must not
/// pretend the hub knows. See [MilerLifecycle.reached].
unconfirmed,
/// The server refused. A real failure with a reason.
refused,
}
/// One lifecycle mutation, read for what it proves.
@immutable
class StateTransition {
const StateTransition({
required this.outcome,
required this.bookingStatus,
required this.consignmentState,
this.consignmentId = '',
this.nextAction = '',
this.code = '',
this.message = '',
this.evidence = '',
});
final TransitionOutcome outcome;
/// The booking status the response reported, parsed. [BookingStatus.unknown]
/// when the response named none.
final BookingStatus bookingStatus;
/// The consignment state the response reported, parsed.
final ConsignmentState consignmentState;
/// The id minted at the pivot, when this transition was one.
final String consignmentId;
/// The server's own instruction for what happens next — `start_delivery`,
/// `inward_at_hub`. Read, never assumed.
final String nextAction;
/// The stable failure code, for callers that must branch on *why*.
final String code;
final String message;
/// What the response actually said, for a log line that can be pasted into a
/// backend ticket without re-running anything.
final String evidence;
bool get isConfirmed => outcome == TransitionOutcome.confirmed;
bool get isUnconfirmed => outcome == TransitionOutcome.unconfirmed;
bool get isRefused => outcome == TransitionOutcome.refused;
/// True when the pivot left the consignment in the rider's hands awaiting a
/// **Start delivery** press — the post-flag lifecycle.
bool get awaitsStartDelivery =>
consignmentState.needsRelease || nextAction == 'start_delivery';
/// True when the pivot released the consignment itself, which is the
/// pre-flag lifecycle the backend calls compatibility mode.
///
/// **This is the reason Admin shows Active for a stop the rider just
/// picked.** Not a client bug and not something the app may paper over: the
/// consignment really is out for delivery, and a rider told otherwise would
/// be looking at a different truth from his office.
bool get isCompatibilityMode =>
consignmentState.isDeliverable && nextAction != 'start_delivery';
}
/// Reads lifecycle mutations. Pure — no I/O of its own.
abstract final class MilerLifecycle {
/// First non-empty value among [keys], searched shallow then one level in.
static String _str(Map<String, dynamic> data, List<String> keys) {
for (final k in keys) {
final v = data[k];
if (v == null || v is Map || v is List) continue;
final s = v.toString().trim();
if (s.isNotEmpty && s != 'null' && s != '0') return s;
}
for (final v in data.values) {
if (v is Map) {
final nested = v.map((k, x) => MapEntry(k.toString(), x));
final found = _str(nested, keys);
if (found.isNotEmpty) return found;
}
}
return '';
}
/// `POST /miler/bookings/:id/reached`.
///
/// Confirmed **only** when the response reports
/// [BookingStatus.arrivedAtPickup]. Anything else — including the 200 that
/// echoes the unchanged status, which is what production returns today — is
/// [TransitionOutcome.unconfirmed], and the evidence says exactly what came
/// back so the report writes itself.
static StateTransition reached(ApiResult res) {
if (!res.ok) {
return StateTransition(
outcome: TransitionOutcome.refused,
bookingStatus: BookingStatus.unknown,
consignmentState: ConsignmentState.unknown,
code: res.code,
message: res.message,
evidence: 'HTTP ${res.status} ${res.code} ${res.message}',
);
}
final raw = _str(res.data, const [
'status',
'bookingstatus',
'booking_status',
]);
final parsed = BookingStatus.parse(raw);
final confirmed = parsed == BookingStatus.arrivedAtPickup;
return StateTransition(
outcome: confirmed
? TransitionOutcome.confirmed
: TransitionOutcome.unconfirmed,
bookingStatus: parsed,
consignmentState: ConsignmentState.unknown,
evidence: raw.isEmpty
? 'the response named no status at all'
: 'the response reported status="$raw"',
);
}
/// `POST /miler/bookings/:id/pickup-complete`.
///
/// The pivot has two legitimate outcomes and the app must not choose between
/// them from a build-time assumption:
///
/// `Collected_By_Miler` + `next_action: start_delivery`
/// the rider holds it; **Start delivery** releases it.
/// `Out_for_Delivery`
/// compatibility mode — the pivot released it in the same call.
///
/// Both are confirmed transitions. Which one happened is [isCompatibilityMode],
/// read from the response and never from a flag mirrored into this app.
static StateTransition pickupComplete(ApiResult res) {
if (!res.ok) {
return StateTransition(
outcome: TransitionOutcome.refused,
bookingStatus: BookingStatus.unknown,
consignmentState: ConsignmentState.unknown,
code: res.code,
message: res.message,
evidence: 'HTTP ${res.status} ${res.code} ${res.message}',
);
}
final bookingRaw = _str(res.data, const [
'booking_status',
'bookingstatus',
'status',
]);
final consignmentRaw = _str(res.data, const [
'consignmentstatus',
'consignment_status',
'consignmentstate',
]);
final id = _str(res.data, const [
'consignment_id',
'consignmentid',
'consignmentId',
'consignmentno',
]);
final next = _str(res.data, const [
'next_action',
'nextaction',
]).toLowerCase();
final booking = BookingStatus.parse(bookingRaw);
var consignment = consignmentStateFromRaw(consignmentRaw);
// A response that names only the booking still answers the question when
// the booking word is one of the two that carries the delivery half.
if (consignment == ConsignmentState.unknown) {
if (booking == BookingStatus.outForDelivery) {
consignment = ConsignmentState.outForDelivery;
} else if (next == 'start_delivery') {
consignment = ConsignmentState.collectedByMiler;
}
}
// The pivot is confirmed by the id it minted. Without one there is nothing
// to deliver against, whatever the words say.
final confirmed =
id.isNotEmpty ||
booking == BookingStatus.convertedToConsignment ||
consignment != ConsignmentState.unknown;
return StateTransition(
outcome: confirmed
? TransitionOutcome.confirmed
: TransitionOutcome.unconfirmed,
bookingStatus: booking,
consignmentState: consignment,
consignmentId: id,
nextAction: next,
evidence:
'booking="$bookingRaw" consignment="$consignmentRaw" '
'id="$id" next="$next"',
);
}
/// One line, in the shape a backend ticket wants.
static void report(String verb, StateTransition t) {
switch (t.outcome) {
case TransitionOutcome.confirmed:
debugPrint('[LIFECYCLE][$verb] confirmed — ${t.evidence}');
case TransitionOutcome.unconfirmed:
debugPrint(
'[LIFECYCLE][$verb] NOT CONFIRMED BY THE SERVER — ${t.evidence}. '
'The call succeeded and the state did not move. This is a backend '
'deployment mismatch, not a client failure.',
);
case TransitionOutcome.refused:
debugPrint('[LIFECYCLE][$verb] refused — ${t.evidence}');
}
}
}

100
lib/data/load_state.dart Normal file
View File

@@ -0,0 +1,100 @@
/// ─────────────────────────────────────────────────────────────────────────
/// WHAT A SCREEN IS SHOWING, AS ONE VALUE
///
/// Screens carried three or four independent booleans — `_firstLoadDone`,
/// `_fetching`, `_failed`, plus a list that might be empty — and every screen
/// combined them slightly differently. That is how a page ends up showing an
/// empty state during a refresh, or a spinner over stale data, or nothing at
/// all when a request fails.
///
/// One value, and the combinations that cannot happen are unrepresentable.
///
/// ── Empty is not a failure, and unavailable is neither ──
///
/// Three answers a rider acts on differently, which were all `[]` before:
///
/// * **[LoadEmpty]** — the hub has given him nothing today. Wait.
/// * **[LoadFailure]** — the request did not complete. Retry.
/// * **[LoadUnavailable]** — his line of work has no endpoint behind it. Neither
/// waiting nor retrying will help, and telling him to do either is a lie.
/// See `ServiceProfile.hasBookingsEndpoint`.
/// ─────────────────────────────────────────────────────────────────────────
library;
/// Why a load did not produce data. Kept separate from the HTTP status because
/// the rider's next action is what differs, not the number.
enum LoadFailureKind {
/// No usable connection, a timeout, or a socket that died mid-flight.
offline,
/// The session is over. The shell signs the rider out; the screen should not
/// offer a retry that cannot succeed.
unauthorized,
/// Too many requests. Retrying immediately makes it worse.
rateLimited,
/// The server answered and refused, or answered with something unreadable.
server;
/// Whether offering "Try again" is honest.
bool get isRetryable => this != LoadFailureKind.unauthorized;
}
/// The state of one screen's data.
sealed class LoadState<T> {
const LoadState();
/// The data, when there is any. Null in every other state — so a screen that
/// forgets to handle a case renders nothing rather than stale content.
T? get valueOrNull => switch (this) {
LoadData<T>(:final value) => value,
_ => null,
};
bool get isLoading => this is LoadLoading<T>;
}
/// First load, or a refresh with nothing to show yet.
class LoadLoading<T> extends LoadState<T> {
const LoadLoading();
}
/// Data arrived and there is something in it.
class LoadData<T> extends LoadState<T> {
final T value;
/// True while a refresh is running behind data that is already on screen.
///
/// The distinction the old booleans lost: a pull-to-refresh must not blank
/// the list it is refreshing.
final bool refreshing;
const LoadData(this.value, {this.refreshing = false});
}
/// The request succeeded and the answer was "nothing today".
class LoadEmpty<T> extends LoadState<T> {
const LoadEmpty();
}
/// The request did not complete.
class LoadFailure<T> extends LoadState<T> {
final LoadFailureKind kind;
/// The server's own sentence where it gave one — it is more useful than
/// anything this app can invent about a failure it did not cause.
final String message;
const LoadFailure(this.kind, {this.message = ''});
}
/// There is no backend for this rider's line of work.
///
/// Not an error and not an empty day: a capability the deployment does not have
/// yet. Retrying cannot fix it and neither can waiting.
class LoadUnavailable<T> extends LoadState<T> {
final String reason;
const LoadUnavailable(this.reason);
}

358
lib/data/meal_run_mock.dart Normal file
View File

@@ -0,0 +1,358 @@
import 'package:flutter/foundation.dart';
import 'package:miler/data/mock_backend.dart';
import 'package:miler/data/service_profile.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// A MEAL DAY, WITH NO BACKEND BEHIND IT
///
/// The meal line has no endpoints. The Doormile backend serves parcel bookings
/// and consignments and knows nothing about kitchens, crates or subscribers, and
/// building against invented URLs would produce a flow that compiles, demos, and
/// is wrong the day a real contract arrives.
///
/// So the meal line runs on this: a complete day, held in memory, walked with
/// the real screens. Every status change the rider makes is recorded here
/// instead of being sent, which means the whole process — accept, arrive, load
/// the crate, deliver, skip, fail — can be designed, used and judged before a
/// single endpoint exists.
///
/// ── Why this is not the demo layer that was deleted ──
///
/// A mock layer was ripped out of this app for a good reason: it wrote rows into
/// the same SharedPreferences stores the real work uses, so demo stops surfaced
/// on a live rider's tabs weeks later, with nothing left in the repo to explain
/// them. Three rules keep this one from becoming that:
///
/// 1. **It cannot reach a parcel rider.** [active] is false unless the signed-in
/// profile is the meal line, and an unrecognised tenant resolves to parcel.
/// There is no path from a normal login to this data.
/// 2. **Its ids are self-identifying.** Every id is `MOCK-…`, which is exactly
/// what `purgeDemoRecords()` already looks for — so anything that does leak
/// into a store is removed at the next launch, by code that already ships.
/// 3. **It has one switch.** [kMealMockEnabled] is the whole feature. When the
/// meal endpoints land, that constant goes false and the provider falls
/// through to the real call; nothing else in the app knows this file exists.
/// ─────────────────────────────────────────────────────────────────────────
/// Whether the day below is served instead of calling the API.
///
/// ── One switch for the whole app, not two ──
///
/// This used to be its own constant, and it was turned off on 2026-08-12 for a
/// real reason: while it was on, a meal rider tapped **Arrived** and **Picked
/// up**, watched the card advance, and the hub console never changed. Every
/// status write on this line was recorded in a `Map` on the phone and reported
/// as a success.
///
/// That failure mode has not gone away — it is simply what running without a
/// backend *means*, and it now applies to the whole app rather than to this
/// file alone. So the two switches became one: [kMockBackend] takes sign-in,
/// the day and every status write off the network together, and this follows
/// it. There is no state where the meal day is fictional but the statuses are
/// real, which is the confusing half of what used to be possible.
///
/// Turn the app back on to the real backend with:
///
/// flutter run --dart-define=MOCK_BACKEND=false
///
/// The meal line then falls through to the three booking routes it actually
/// depends on, all of which exist and are proven by the parcel line:
///
/// list `GET /miler/bookings`
/// arrived `POST /miler/bookings/:id/reached`
/// picked up `POST /miler/bookings/:id/pickup-complete`
/// Follows the one mock switch at runtime rather than at compile time, so a
/// test (or a bench session) that turns the canned backend off turns this off
/// with it. See [MockBackend.enabled].
bool get kMealMockEnabled => MockBackend.enabled;
/// Wall-clock helper so the mock day always looks like *today's* shift rather
/// than a fixed date that reads as stale the moment anybody opens it.
String _slot(int hour, int minute) {
final now = DateTime.now();
final t = DateTime(now.year, now.month, now.day, hour, minute);
return t.toIso8601String();
}
/// A meal run held in memory: two kitchens, seven subscribers, one rider.
///
/// Coordinates are real Coimbatore addresses in route order, because a route
/// that doubles back looks like a bug in the sequencing rather than what it is.
class MealRunMock {
MealRunMock._();
/// True when the app should serve this instead of calling the API.
static bool get active =>
kMealMockEnabled && ServiceProfile.active.sourceIsKitchen;
/// Status overrides the rider has caused this session, keyed by order id.
///
/// The mock's own rows are `assigned`; everything after that is something he
/// did. Held separately rather than mutated into the rows so a reset is one
/// `clear()` and the day itself stays declarative.
static final Map<String, String> _status = <String, String>{};
/// Records what a status call *would* have sent. Always succeeds — there is
/// nothing to fail.
static bool setStatus(String orderId, String status) {
if (orderId.isEmpty) return true;
_status[orderId] = status;
debugPrint('[MEAL_MOCK] $orderId → $status');
return true;
}
/// Same, keyed by the numeric booking id the controllers carry.
static bool setStatusByPickupId(int pickupId, String status) {
if (pickupId <= 0) return true;
final row = _day.firstWhere(
(r) => r['pickupid'] == pickupId,
orElse: () => const <String, dynamic>{},
);
final id = (row['orderid'] ?? '').toString();
return setStatus(id, status);
}
/// Puts the day back to the top. Wired to nothing yet — it exists so a
/// demo can be run twice without reinstalling the app.
static void reset() => _status.clear();
/// The day as the cards read it, with whatever the rider has done applied.
///
/// A fresh copy every call: the screens mutate stop maps in place (compliance
/// stamps, proof blocks), and handing out the master rows would let one run's
/// leftovers show up in the next.
static List<Map<String, dynamic>> stops() => [
for (final row in _day)
{...row, 'orderstatus': _status[row['orderid']] ?? row['orderstatus']},
];
// ── The day itself ──────────────────────────────────────────────────────
//
// Two kitchens is deliberate rather than decorative: a single-kitchen day
// hides every question the grouping exists to answer — which crate is this,
// which bags belong to it, what happens when the second load is still at a
// counter he has not reached.
//
// Five orders on the first counter and four on the second, because the whole
// pickup flow is a claim about a *set*: `5 orders · 5 bags`, five names on the
// confirmation, five deliveries afterwards. A day of ones would let every one
// of those read correctly while being wrong.
static final List<Map<String, dynamic>> _day = [
// ── Vidhya Kitchen · Gandhi Nagar, Peelamedu ──
_drop(
id: 1001,
step: 1,
name: 'Joe Mathew',
phone: '9840012001',
address: '12, SNS Colony, Peelamedu',
lat: 11.0271,
lng: 76.9962,
kitchen: _vidhya,
bag: 'DG-1001',
meals: 1,
),
_drop(
id: 1002,
step: 2,
name: 'Arun Prakash',
phone: '9840012002',
address: '21, New Street, Peelamedu',
lat: 11.0248,
lng: 76.9941,
kitchen: _vidhya,
bag: 'DG-1002',
meals: 1,
),
_drop(
id: 1003,
step: 3,
name: 'Priya Venkatesh',
phone: '9840012003',
address: '45, West Avenue, RS Puram',
lat: 11.0043,
lng: 76.9518,
kitchen: _vidhya,
bag: 'DG-1003',
meals: 1,
),
_drop(
id: 1004,
step: 4,
name: 'Kumar Selvam',
phone: '9840012004',
address: 'Flat 3C, Lakshmi Towers, 100 Feet Road, Gandhipuram',
lat: 11.0168,
lng: 76.9558,
kitchen: _vidhya,
bag: 'DG-1004',
meals: 1,
),
_drop(
id: 1005,
step: 5,
name: 'Ravi Shankar',
phone: '9840012005',
address: '8, Krishna Colony, 2nd Street, Singanallur',
lat: 10.9925,
lng: 77.0289,
kitchen: _vidhya,
bag: 'DG-1005',
meals: 1,
),
// ── Annapoorna Mess · Thadagam Road ──
_drop(
id: 2001,
step: 6,
name: 'Meena Krishnan',
phone: '9840012006',
address: '31, Sarojini Street, Gandhipuram',
lat: 11.0181,
lng: 76.9603,
kitchen: _annapoorna,
bag: 'AM-2001',
meals: 1,
),
_drop(
id: 2002,
step: 7,
name: 'Sai Ganesh',
phone: '9840012007',
address: '14, Trichy Road, above the pharmacy, Ramanathapuram',
lat: 10.9871,
lng: 76.9931,
kitchen: _annapoorna,
bag: 'AM-2002',
meals: 1,
),
_drop(
id: 2003,
step: 8,
name: 'Devi Lakshmi',
phone: '9840012008',
address: '22, Kamarajar Road, near the school gate, Uppilipalayam',
lat: 10.9903,
lng: 76.9977,
kitchen: _annapoorna,
bag: 'AM-2003',
meals: 1,
),
_drop(
id: 2004,
step: 9,
name: 'Karthik Raja',
phone: '9840012009',
address: '5B, Thadagam Road, opposite the water tank',
lat: 11.0221,
lng: 76.9391,
kitchen: _annapoorna,
bag: 'AM-2004',
meals: 1,
),
];
/// A counter the rider collects from: what it is called, where it is, and the
/// id everything groups on.
static const _vidhya = (
id: 'K1',
name: 'Vidhya Kitchen',
address: 'Gandhi Nagar, Peelamedu, Coimbatore - 641004',
lat: 11.0261,
lng: 76.9931,
locationId: 901,
);
static const _annapoorna = (
id: 'K2',
name: 'Annapoorna Mess',
address: '19, Thadagam Road, RS Puram, Coimbatore - 641002',
lat: 11.0195,
lng: 76.9412,
locationId: 902,
);
/// One subscriber drop, in the legacy stop shape every card in the app reads.
///
/// ── Both ends, in the right fields ──
///
/// The `pickup*` fields are the **kitchen** and the `drop*` fields are the
/// **customer**, because that is what the app navigates on: before collection
/// `navigationTarget` reads `pickuplat/pickuplon`, and after it reads
/// `droplat/droplon` — see [MilkRun]. This fixture used to put the customer in
/// both, which sent a rider to a subscriber's flat to collect a lunch that was
/// still at a counter three kilometres away.
///
/// `type: delivery` is stated rather than inferred: the app would reach the
/// same answer from the profile, but a fixture that relies on a fallback is
/// testing the fallback rather than the screen.
static Map<String, dynamic> _drop({
required int id,
required int step,
required String name,
required String phone,
required String address,
required double lat,
required double lng,
required ({
String id,
String name,
String address,
double lat,
double lng,
int locationId,
})
kitchen,
required String bag,
required int meals,
}) => <String, dynamic>{
// `MOCK-` so `purgeDemoRecords()` recognises anything that leaks into a
// store. See the note at the top of this file.
'orderid': 'MOCK-M-$id',
'pickupid': id,
'orderheaderid': id,
'pickuplocationid': kitchen.locationId,
'orderstatus': 'assigned',
'step': step,
// Who is being handed food.
'pickupcustomer': name,
'pickupcontactno': phone,
// Where he collects: the counter.
'pickupaddress': kitchen.address,
'pickuplat': kitchen.lat,
'pickuplon': kitchen.lng,
'pickuplong': kitchen.lng,
// Where it goes: the door.
'dropaddress': '$address, Coimbatore, Tamil Nadu 641004',
'droplat': lat,
'droplon': lng,
// The counter itself. The grouping, the headings, the manifest and the
// bag identity all key off these.
'kitchenid': kitchen.id,
'kitchenname': kitchen.name,
'baglabel': bag,
// A drop, and how many boxes are in it. One order is one bag — the count
// here is the *meal* count inside that bag, and nothing reads it as a bag
// count. See [BagManifest].
'type': 'delivery',
'deliveryqty': meals,
'quantity': meals,
// Nothing is owed at any door — a subscriber paid the client by the month.
// Stated as zero rather than omitted so a payload reader cannot mistake a
// missing field for an unknown amount.
'collectionamt': 0,
'pickupamt': 0,
// The lunch slot the whole run belongs to.
'starttime': _slot(11, 30),
'endtime': _slot(13, 30),
'eta': '8',
'kms': '2.4',
};
}

File diff suppressed because it is too large Load Diff

441
lib/data/milk_run.dart Normal file
View File

@@ -0,0 +1,441 @@
import 'package:miler/Models/stop_status.dart';
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
import 'package:miler/data/service_profile.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// THE MILK RUN — accept per order, collect per kitchen, deliver per order.
///
/// ```
/// HOME DELIVERIES ACTIVITY
/// ──── ────────── ────────
/// 6 orders ─ accept ─┬─ Vidhya ×3 ─ arrived ─ picked ─▶ deliver ─▶ done
/// ├─ Priyanka×2 ─ arrived ─ picked ─▶ deliver ─▶ done
/// └─ ABC ×1 ─ arrived ─ picked ─▶ deliver ─▶ done
/// ```
///
/// ── The three units, and why they differ ──
///
/// **Acceptance is per order.** The rider takes on the morning's work in one
/// press, whichever counters it comes from.
///
/// **Pickup is per kitchen.** Three orders from Vidhya Kitchen are one ride,
/// one counter and one handover. He arrives once and is then given bags one at
/// a time, so arrival is grouped and collection is per bag — and a selection
/// must never span two kitchens, or he posts "arrived" for a shop he is
/// nowhere near. See [sameSourceAs].
///
/// **Delivery is per order.** Each bag goes to its own door.
///
/// Accepted orders therefore stay individually visible on Home; the kitchen is
/// what bounds a pickup operation, not what replaces the cards.
///
/// ── Collected work goes out immediately ──
///
/// There is no "start delivery" gate. A kitchen's orders become live
/// deliveries the moment they are in the rider's hands, so he can drop the
/// first three while a second kitchen is still cooking. Holding them until
/// every counter was done made the first customers wait for food already on
/// the bike.
///
/// ── How client stages map to the real backend ──
///
/// The UI has more stages than the API has statuses, which is fine as long as
/// every one either writes something real or is honestly local. None invents a
/// server state:
///
/// | Rider does | UI stage | Server call |
/// |-------------------|--------------------|--------------------------------------|
/// | Accepts work | `accepted` | `POST /assignments/:aid/accept` |
/// | Reaches a kitchen | `arrived` | `POST /bookings/:id/reached` |
/// | Takes a bag | `picked` | `POST /bookings/:id/pickup-complete` |
/// | (rides on) | `outForDelivery` | `POST /deliveries/start` ¹ |
/// | Reaches a door | `deliveryArrived` | *(local — no route exists)* ² |
/// | Hands it over | `delivered` | `POST /consignments/:cid/deliver` |
///
/// ¹ **not deployed.** Fired best-effort as part of pickup; until it ships the
/// consignments stay `Inwarded_at_Hub` and `deliver` refuses them.
/// ² **no endpoint exists.** See [deliveryArrivalIsLocalOnly].
///
/// Everything here is a pure function over the stop maps the rest of the app
/// already passes around, so it can be tested without a widget tree, a GetX
/// container or a network. Same reasoning as `AssignmentLookup.buildIndex`.
/// ─────────────────────────────────────────────────────────────────────────
class MilkRun {
MilkRun._();
/// **BACKEND DEPENDENCY.** There is no route that records a rider arriving at
/// a *delivery* address — `/bookings/:id/reached` keys on a booking in its
/// pickup phase, and the consignment routes have no arrival event. So the
/// `deliveryArrived` rung is held on the device and the hub sees the round
/// jump from out-for-delivery straight to delivered.
///
/// Nothing is faked to cover this: no call is made and no success is
/// invented. The rung exists because the *rider* needs the distinction — it
/// is what turns his Deliver button on at the right door — and the flag is
/// here so the gap is greppable rather than folklore.
static const bool deliveryArrivalIsLocalOnly = true;
/// Order id of a stop, however the payload spells it.
static String idOf(Map<String, dynamic> stop) =>
(stop['orderid'] ?? stop['orderId'] ?? '').toString();
/// The consignment this stop became once it was collected, or '' before that.
///
/// The delivery route keys on this and not on the booking id — they come from
/// different sequences, the same trap [AssignmentLookup] exists for on the
/// accept side. A stop without one cannot be delivered yet.
static String consignmentIdOf(Map<String, dynamic> stop) =>
(stop['consignmentid'] ?? stop['consignmentId'] ?? '').toString().trim();
// ── Which counter a stop is collected from ──
//
// Pickup is grouped by kitchen even though acceptance and delivery are per
// order. Three orders from Vidhya Kitchen are one ride, one counter and one
// handover; the rider works them together and must not be able to sweep an
// order from a different kitchen into that operation.
//
// The key is the **stable id** where the payload carries one, because two
// kitchens can share a display name across areas and the id is what the hub
// controls. A name-only payload still groups rather than collapsing into one
// nameless pile, and a stop with neither gets its own bucket rather than a
// fabricated kitchen.
/// The stable key a stop groups under for pickup.
static String sourceKeyOf(Map<String, dynamic> stop) {
final id =
(stop['sourceid'] ??
stop['kitchenid'] ??
stop['pickuplocationid'] ??
'')
.toString()
.trim();
if (id.isNotEmpty && id != '0') return 'id:$id';
final name = sourceNameOf(stop).toLowerCase();
if (name.isNotEmpty) return 'name:$name';
return 'none';
}
/// The kitchen's name as the rider reads it, or '' when the payload has none.
/// **The** reader for a place's name. `stopSourceName` delegates here.
///
/// ── Two readers, one question, different answers ──
///
/// This read `sourcename` / `kitchenname`. `stopSourceName` read those *and*
/// the CamelCase `KitchenName` / `SourceName` the payload sometimes carries.
/// A booking with only the capitalised key therefore had a source according
/// to one function and none according to the other, and the route card used
/// both in the same expression:
///
/// • `stopSourceName` said "there is a counter", so the group was **not**
/// flat — the header became a foldable place with its orders under it.
/// • `sourceNameOf` said "there is no counter", so `navigationLabel` fell
/// through to its leg description and the header was titled **"pickup"**.
///
/// A group headed by the word *pickup* that folds open onto one order named
/// after the same stop — which is the shape the `flat` flag exists to
/// prevent, produced by the two halves of the decision disagreeing.
///
/// One key list, one answer.
static String sourceNameOf(Map<String, dynamic> stop) =>
(stop['sourcename'] ??
stop['SourceName'] ??
stop['kitchenname'] ??
stop['KitchenName'] ??
'')
.toString()
.trim();
/// True when two stops are collected from the same counter.
static bool sameSource(Map<String, dynamic> a, Map<String, dynamic> b) =>
sourceKeyOf(a) == sourceKeyOf(b);
/// Every stop collected from the same counter as [stop], out of [stops].
///
/// What the rider ticks at a kitchen, and the bound on what one pickup
/// operation may touch.
static List<Map<String, dynamic>> sameSourceAs(
Map<String, dynamic> stop,
List<Map<String, dynamic>> stops,
) {
final key = sourceKeyOf(stop);
return [
for (final s in stops)
if (sourceKeyOf(s) == key) s,
];
}
// ══════════════════════════════════════════════════════════════════════
// WHERE "NAVIGATE" GOES
//
// The same button on the same order means two different places depending on
// where the order is in its day, and getting it wrong is expensive in both
// directions: sending a rider to a customer for an order still sitting in a
// kitchen wastes the trip *and* the customer's slot, and sending him back to
// a kitchen for a bag already in his box is a ride to collect nothing.
//
// So the destination follows the stop's stage, in one place, rather than
// being decided by whichever screen happens to own the button.
// ══════════════════════════════════════════════════════════════════════
/// Whether this stop's next journey is to the customer rather than the
/// kitchen.
///
/// True once it is collected. On a logistics booking this is always false:
/// that line's collected parcel goes to a hub and is delivered by somebody
/// else, so the rider never drives to its customer.
static bool navigatesToCustomer(
Map<String, dynamic> stop, {
Set<String> collectedIds = const {},
}) {
if (!ServiceProfile.active.deliversToCustomer) return false;
if (collectedIds.contains(idOf(stop))) return true;
final status = stopStatusOf(stop);
return status.isPicked || status.isDeliveryLeg;
}
/// The kind of work this stop is **for the leg the rider is on**.
///
/// ── Why [stopKindOf] is not enough on its own ──
///
/// `stopKindOf` answers "what kind of stop is this?" from the payload, and the
/// payload is wrong about it on the one line where it matters most: the
/// booking adapter stamps `type: pickup` on every row it builds, because a v1
/// booking *is* a first-mile pickup and the backend has no per-stop type to
/// send. See `ApiConfig.pickupFromBooking`.
///
/// So a milk-run order the rider has already collected — a bag in his box, on
/// its way to a subscriber's door — still described itself as a pickup. The
/// card offered **Start Pickup**, the confirmation sheet asked him to
/// **Confirm pickup**, and the write path behind that button took the pickup
/// branch. He was being asked to collect something he was carrying.
///
/// The leg is the honest question, and this app already knows how to answer
/// it: [navigatesToCustomer] is true exactly when the load is in his hands.
/// Everything the rider reads and everything the button posts should follow
/// that, not the stamped type.
///
/// Returns [stopKindOf]'s answer unchanged on every other line and every
/// other stage, so a parcel booking is untouched.
static StopKind workingKind(
Map<String, dynamic> stop, {
Set<String> collectedIds = const {},
}) => navigatesToCustomer(stop, collectedIds: collectedIds)
? StopKind.delivery
: stopKindOf(stop);
/// Whether this stop's next rung is worked on **its own screen** — the map,
/// then I'VE ARRIVED, then the confirmation sheet — or in bulk on Home.
///
/// ── The rule ──
///
/// On **logistics**, every stop is worked on its own screen. The rider drives
/// to one customer, weighs one parcel, raises one shipment and takes one
/// payment; there is nothing to batch.
///
/// On a **kitchen line** the pickup half is not a per-stop journey at all. He
/// makes one trip to one counter and is handed a stack of bags, so the rungs
/// up to *picked up* are a bulk gesture on Home — select the orders, slide
/// once — and the single-stop map/arrive/confirm screen is only ever the
/// **delivery** leg, one subscriber's door at a time.
///
/// ── What it prevents ──
///
/// A stop still to be collected could be opened on that screen from the
/// Deliveries tab's live strip. It took the rider through a map, an I'VE
/// ARRIVED and a confirmation sheet for a collection he was supposed to make
/// at the kitchen with everything else — a second, contradictory way to work
/// the same rung, on the tab that is supposed to be his load. The screens ask
/// this before they offer that route in.
static bool worksOnOwnScreen(
Map<String, dynamic> stop, {
Set<String> collectedIds = const {},
}) =>
!ServiceProfile.active.handsOffAtCollection ||
navigatesToCustomer(stop, collectedIds: collectedIds);
/// The coordinates Navigate should open, or null when the stop carries none
/// for the leg it is on.
///
/// Returns null rather than falling back to the other end: a Navigate button
/// that quietly opens the wrong destination is worse than one that is
/// disabled, because the rider only finds out when he arrives.
static ({double lat, double lng})? navigationTarget(
Map<String, dynamic> stop, {
Set<String> collectedIds = const {},
}) {
double? read(List<String> keys) {
for (final k in keys) {
final v = double.tryParse('${stop[k] ?? ''}');
if (v != null && v != 0) return v;
}
return null;
}
if (navigatesToCustomer(stop, collectedIds: collectedIds)) {
final lat = read(['droplat', 'DropLat', 'deliverylatitude']);
final lng = read(['droplon', 'DropLon', 'deliverylongitude']);
if (lat == null || lng == null) return null;
return (lat: lat, lng: lng);
}
final lat = read(['pickuplat', 'PickupLat', 'pickuplatitude']);
final lng = read([
'pickuplon',
'pickuplong',
'PickupLon',
'pickuplongitude',
]);
if (lat == null || lng == null) return null;
return (lat: lat, lng: lng);
}
/// What that destination is called, for the button and the sheet.
static String navigationLabel(
Map<String, dynamic> stop, {
Set<String> collectedIds = const {},
}) {
if (navigatesToCustomer(stop, collectedIds: collectedIds)) {
final name = (stop['pickupcustomer'] ?? stop['PickupCustomer'] ?? '')
.toString()
.trim();
return name.isEmpty ? 'customer' : name;
}
final kitchen = sourceNameOf(stop);
if (kitchen.isNotEmpty) return kitchen;
// ── The word "pickup" is never a place ──
//
// This returned the literal `'pickup'`, and on a payload with no source
// name — which is what the live backend sends today — that word became
// the 24sp headline of the rider's first group. The project's own rule
// ("never display 'pickup' when a real name exists") was being met to the
// letter and lost in spirit: no name existed, so a leg description was
// promoted to a title.
//
// A rider thinks in PLACES, and the payload still knows one: the pickup
// address. Its first non-numeric component is the neighbourhood — the
// same reading the timeline's area line uses — and "RS Puram" is
// something he can ride to in a way "pickup" is not. Only when the
// payload has no address either does the label fall back to a word, and
// then it is at least a capitalised noun.
for (final key in const ['pickupaddress', 'PickupAddress']) {
final raw = (stop[key] ?? '').toString().trim();
if (raw.isEmpty) continue;
for (final part in raw.split(',')) {
final p = part.trim();
if (p.isEmpty) continue;
// Skip a leading door/plot number — the rider navigates by the
// neighbourhood, not by "12/4".
if (RegExp(r'^[0-9/\-]+$').hasMatch(p)) continue;
// And when the component carries its own house number — "124
// Gandhipuram Main Road" — the number is still not the place. Strip
// the leading digit token and title the street.
return p.replaceFirst(RegExp(r'^[0-9][0-9/\-]*\s+'), '');
}
}
return 'Pickup';
}
/// Stops still owing the rider a collection at a given counter.
///
/// A stop counts as outstanding when it has been accepted and is not yet in
/// his hands. Anything he has been told he will not get — not loaded by the
/// source, cancelled, rejected — is not outstanding: it is settled, and
/// holding the round open for it would strand him at a counter waiting for a
/// bag that is not coming.
static List<Map<String, dynamic>> outstandingPickups(
List<Map<String, dynamic>> stops, {
required Set<String> acceptedIds,
required Set<String> collectedIds,
Set<String> notLoadedIds = const {},
Set<String> rejectedIds = const {},
}) {
final out = <Map<String, dynamic>>[];
for (final stop in stops) {
final id = idOf(stop);
if (id.isEmpty) continue;
if (collectedIds.contains(id)) continue;
if (notLoadedIds.contains(id)) continue;
if (rejectedIds.contains(id)) continue;
final status = stopStatusOf(stop);
if (status.isCancelled || status.isRejected) continue;
// Already down the delivery leg — collected on a previous session whose
// local set was cleared. Not outstanding.
if (status.isDeliveryLeg || status.isPicked) continue;
// Only work he has taken on holds the round open. An un-accepted stop is
// an offer, and a rider must not be blocked from starting his round by
// work he never agreed to.
if (!acceptedIds.contains(id) && !status.isFinishedPickup) {
if (status.isPending) continue;
}
out.add(stop);
}
return out;
}
/// Where a stop is on the milk-run ladder, from the app's own records.
///
/// The local sets lead the server here, deliberately: the queue endpoints are
/// a poll or more behind the rider and a card that ignores what he just did
/// reads as a button that did nothing. The backend status is the tie-breaker
/// underneath, not the first word.
static StopStatus stageOf(
Map<String, dynamic> stop, {
required Set<String> acceptedIds,
required Set<String> collectedIds,
Set<String> outForDeliveryIds = const {},
Set<String> deliveredIds = const {},
}) {
final id = idOf(stop);
final reported = stopStatusOf(stop);
if (deliveredIds.contains(id) || reported.isDelivered) {
return StopStatus.delivered;
}
if (reported == StopStatus.deliveryArrived) {
return StopStatus.deliveryArrived;
}
// ── `Out_for_Delivery` is the consignment's state, not the rider's ──
//
// `pickup-complete` releases hyperlocal work itself: pickup and delivery
// pincodes sharing a 3-digit prefix means the parcel never sees a hub, so
// the consignment is stamped `Out_for_Delivery` in the same call that
// records the collection. Every DailyGrubs order is hyperlocal, so that is
// *always* what the next poll reports after a successful **Picked**.
//
// Reading it as this rung skipped `picked` entirely: the rider slid to
// confirm a hand-over at the counter and the ladder jumped straight to the
// delivery-active rung — before he had left the kitchen, let alone started
// the round. He never saw the state he had just created.
//
// So the released consignment resolves to [StopStatus.picked], and the
// delivery-active rung is the rider's own: [outForDeliveryIds] is written
// when he opens the stop and sets off. Nothing is faked and no status is
// invented — the two facts were simply being read as one. `deliveryArrived`
// and `delivered` are checked above and still win, so a stop the backend
// genuinely reports further along is never dragged back to picked.
if (outForDeliveryIds.contains(id)) return StopStatus.outForDelivery;
if (collectedIds.contains(id) ||
reported.isPicked ||
reported == StopStatus.outForDelivery) {
return StopStatus.picked;
}
if (reported == StopStatus.arrived) return StopStatus.arrived;
if (acceptedIds.contains(id) || reported == StopStatus.accepted) {
return StopStatus.accepted;
}
return reported;
}
/// The button a stop's current stage offers, in the rider's words, or null
/// when the stop is waiting on something other than him.
static String? nextActionLabel(StopStatus stage) => switch (stage) {
StopStatus.accepted => 'Arrived',
StopStatus.arrived => 'Picked up',
StopStatus.outForDelivery => 'Arrived',
StopStatus.deliveryArrived => 'Delivered',
_ => null,
};
}

182
lib/data/mock_backend.dart Normal file
View File

@@ -0,0 +1,182 @@
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');
/// 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';
/// 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(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() : '';
}

View File

@@ -0,0 +1,70 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// ONE PRESS, ONE REQUEST
///
/// Every business-critical action in this app is a POST that the backend is not
/// idempotent about: `accept`, `reject`, `reached`, `parcel`, `payment`,
/// `pickup-complete`, `deliver`, `skip`, `duty/start`, `duty/end`,
/// `breaks/start`, `breaks/end`, `availability`.
///
/// A rider presses those on a moving bike, with gloves, on a screen he cannot
/// look at for long — so a double tap is not an edge case, it is Tuesday. And
/// the failure is not cosmetic: two `duty/start` calls are an error the server
/// rejects, two `payment` calls are two payments, and two `accept` calls race
/// each other to decide what the list shows next.
///
/// The screens each solved this, or did not, with their own `_busy` flag —
/// which works until the widget rebuilds, the callback is captured twice, or the
/// action is reachable from two places (a card and a sheet) that do not share a
/// flag. This is one place that solves it once, keyed by the thing being
/// mutated rather than by the widget that happens to be showing it.
///
/// ── What this is not ──
///
/// Not a queue and not a retry. A second press while the first is in flight is
/// **dropped**, not deferred: the rider meant one thing, and running it twice a
/// second later is the bug, not the fix. Not a cache either — once the call
/// completes the key is free, so a genuine second accept of the same stop after
/// a failure still goes through.
/// ─────────────────────────────────────────────────────────────────────────
class MutationGuard {
MutationGuard._();
/// Keys with a request currently in flight.
static final Set<String> _inFlight = <String>{};
/// True while [key] has a mutation running.
///
/// Read by buttons so they can show the disabled/loading state that makes the
/// dropped second press *visible* rather than merely harmless.
static bool isBusy(String key) => _inFlight.contains(key);
/// Runs [action] unless [key] is already running, in which case it returns
/// null and does nothing.
///
/// The key names the **resource and the verb**, not the screen:
/// `accept:1042`, `deliver:77`, `duty`. Two widgets showing the same stop
/// therefore share one guard, which is the whole point.
static Future<T?> run<T>(String key, Future<T> Function() action) async {
if (_inFlight.contains(key)) {
debugPrint('[GUARD] dropped duplicate "$key" — one is already running');
return null;
}
_inFlight.add(key);
try {
return await action();
} finally {
// `finally`, so a thrown request frees its key. A guard that leaks on
// failure is worse than no guard: the rider's retry would be silently
// dropped for the rest of the session.
_inFlight.remove(key);
}
}
/// Clears every key. For tests, and for sign-out — a new session must not
/// inherit the last one's in-flight set.
static void reset() => _inFlight.clear();
}

200
lib/data/order_events.dart Normal file
View File

@@ -0,0 +1,200 @@
/// ─────────────────────────────────────────────────────────────────────────
/// WHEN EACH THING HAPPENED TO AN ORDER
///
/// ── Why this exists ──
///
/// Activity could show three moments — set off, arrived, finished — because
/// those were the only three anything wrote down. Everything else the rider did
/// to an order happened, changed a screen, went to the backend and left no
/// local trace: he accepted it at 9:10, reached the kitchen at 9:28, took the
/// bag at 9:35, and by the time he opened his own history all three were gone.
///
/// The backend knows some of it and returns none of it: `GET /miler/bookings`
/// carries a booking's *current* status and its `createdat`, and there is no
/// per-order event feed on the contract. So a rider asking "when did I accept
/// that?" had nothing to read, and the honest timeline was three rungs long.
///
/// This is the ledger those moments go into. Each stamp is written **at the
/// moment it is true**, by the code that already knows — the same argument
/// `addCompletedBookings` makes for the two clocks it rescues out of
/// SharedPreferences.
///
/// ── What it is not ──
///
/// Not a source of truth about the order, and not a substitute for one. The
/// backend owns the lifecycle; this owns *when the rider did his half of it*,
/// for the one screen that asks. Nothing reads it to decide anything — no gate,
/// no filter, no status. If the file were deleted the app would behave
/// identically and Activity would draw fewer rungs, which is exactly the
/// degradation it is built for: **a moment with no stamp is not drawn.**
///
/// ── Written once ──
///
/// [stampOrderEvent] never overwrites. A rider who re-accepts a stop he
/// un-rejected, or re-opens a map screen, is on the same errand; moving the
/// clock forward would quietly erase the first time he did it. The one
/// exception is a resumed skip, which is a genuinely new attempt — see
/// [clearOrderEvents].
/// ─────────────────────────────────────────────────────────────────────────
library;
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// The moments an order passes through, in the order they happen.
///
/// String values, not an index: they are written into JSON that outlives the
/// build that wrote it, and renumbering an enum would silently re-label every
/// stamp already on the device.
class OrderEvent {
OrderEvent._();
/// The booking first appeared in this rider's queue.
///
/// Not the hub's assignment clock — the contract has none, and the booking's
/// `updatedat` moves every time anything touches the row. This is when the
/// work *reached him*, stamped by [WorkRepository] on the first fetch that
/// carries it, which is the moment it became his as far as his phone is
/// concerned.
static const String assigned = 'assigned';
/// The rider took the stop on. `POST /assignments/:id/accept`.
static const String accepted = 'accepted';
/// He reached the counter. `POST /bookings/:id/reached`.
static const String arrivedAtPickup = 'arrived_at_pickup';
/// The bag is in his hands. `POST /bookings/:id/pickup-complete`.
static const String pickedUp = 'picked_up';
/// He set off on the round. `POST /miler/deliveries/start`.
static const String outForDelivery = 'out_for_delivery';
/// Every event, in the order a timeline should read them.
static const List<String> inOrder = [
assigned,
accepted,
arrivedAtPickup,
pickedUp,
outForDelivery,
];
}
const String _kKey = 'order_events';
/// Every order's stamps: `{orderid: {event: iso8601}}`.
Future<Map<String, Map<String, String>>> _read(SharedPreferences p) async {
try {
final raw = p.getString(_kKey);
if (raw == null || raw.isEmpty) return {};
final decoded = jsonDecode(raw);
if (decoded is! Map) return {};
return {
for (final entry in decoded.entries)
entry.key.toString(): {
if (entry.value is Map)
for (final e in (entry.value as Map).entries)
e.key.toString(): e.value.toString(),
},
};
} catch (e) {
// A corrupt ledger must never take the app down: this is the one store
// nothing depends on, so an unreadable one is an empty one.
debugPrint('[EVENTS] unreadable, starting empty: $e');
return {};
}
}
/// Records [event] against [orderId], if it has not been recorded already.
Future<void> stampOrderEvent(
Object orderId,
String event, {
DateTime? at,
}) async {
final id = orderId.toString().trim();
if (id.isEmpty) return;
try {
final prefs = await SharedPreferences.getInstance();
final all = await _read(prefs);
final mine = all[id] ?? <String, String>{};
// Written once — see the class doc.
if (mine.containsKey(event)) return;
mine[event] = (at ?? DateTime.now()).toIso8601String();
all[id] = mine;
await prefs.setString(_kKey, jsonEncode(all));
} catch (e) {
debugPrint('[EVENTS] could not stamp $event on $id: $e');
}
}
/// Stamps one event against several orders at once — a kitchen handover, an
/// accept-all, a released round.
Future<void> stampOrderEvents(Iterable<Object> orderIds, String event) async {
final at = DateTime.now();
for (final id in orderIds) {
await stampOrderEvent(id, event, at: at);
}
}
/// What is known about one order, as `{event: iso8601}`.
Future<Map<String, String>> getOrderEvents(Object orderId) async {
final id = orderId.toString().trim();
if (id.isEmpty) return const {};
final prefs = await SharedPreferences.getInstance();
return (await _read(prefs))[id] ?? const {};
}
/// Forgets an order's stamps.
///
/// Called when a skipped stop is resumed: the rider is making a second attempt
/// at the same door, and the first attempt's clocks describe a visit that has
/// been superseded. Same argument `addCompletedBookings` makes when it clears
/// the two prefs keys it has just read.
Future<void> clearOrderEvents(Object orderId) async {
final id = orderId.toString().trim();
if (id.isEmpty) return;
try {
final prefs = await SharedPreferences.getInstance();
final all = await _read(prefs);
if (all.remove(id) != null) {
await prefs.setString(_kKey, jsonEncode(all));
}
} catch (e) {
debugPrint('[EVENTS] could not clear $id: $e');
}
}
/// Drops orders whose last stamp is older than [keepDays].
///
/// The ledger is per-order and nothing prunes it on read, so without this it
/// grows for the life of the install — the same trap `removeCollectedOrderIds`
/// exists to avoid. Two days rather than one, because a shift that crosses
/// midnight must not lose its own morning.
Future<void> pruneOrderEvents({int keepDays = 2}) async {
try {
final prefs = await SharedPreferences.getInstance();
final all = await _read(prefs);
final cutoff = DateTime.now().subtract(Duration(days: keepDays));
final kept = <String, Map<String, String>>{};
for (final entry in all.entries) {
DateTime? newest;
for (final iso in entry.value.values) {
final t = DateTime.tryParse(iso);
if (t != null && (newest == null || t.isAfter(newest))) newest = t;
}
// An entry with no parseable stamp in it is kept: it is either from a
// build that wrote a shape this one does not know, or corrupt, and
// deleting somebody's history to tidy up is the worse failure.
if (newest == null || newest.isAfter(cutoff))
kept[entry.key] = entry.value;
}
if (kept.length != all.length) {
await prefs.setString(_kKey, jsonEncode(kept));
}
} catch (e) {
debugPrint('[EVENTS] prune failed: $e');
}
}

160
lib/data/proof_store.dart Normal file
View File

@@ -0,0 +1,160 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/data/work_scope.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// PROOF OF DELIVERY, KEPT WHERE IT SURVIVES
///
/// A photo taken at a door is evidence, and evidence that disappears is worse
/// than none — it makes the rider believe he has cover he does not have. Two
/// things were therefore decided deliberately:
///
/// **It is copied, not referenced.** `ImagePicker` hands back a file in the
/// OS *cache* directory, which Android reclaims whenever it likes and clears
/// outright on a storage sweep. Pointing the Activity record at that path
/// gives a record whose picture is gone by the end of the week. The file is
/// copied into the app's documents directory, which is the app's to keep.
///
/// **It belongs to a scope.** Filed under the same rider/tenant/line identity
/// as every other record ([WorkScope]) so signing out or switching line does
/// not leave one rider's doorstep photos where the next one can open them.
///
/// ── The limitation this cannot solve ──
///
/// **There is no upload route on the Miler API.** `POST
/// /miler/consignments/:id/deliver` takes a `photourl` *string*, and nothing
/// in the contract accepts a file — no multipart route, no signed-URL
/// endpoint, no attachment on any other call. So the proof is real, durable
/// and auditable **on the handset**, and the hub cannot see it until the
/// backend grows somewhere to put it. The app deliberately does NOT send the
/// local path in `photourl`: a filesystem path from somebody's phone is not a
/// URL, and writing one into the delivery record would put a string in the
/// hub's database that looks like evidence and resolves to nothing.
/// [remoteUrlFor] is where a real URL will come from on the day that route
/// exists; until then it is honestly empty.
/// ─────────────────────────────────────────────────────────────────────────
class ProofStore {
ProofStore._();
static const String _indexKey = 'delivery_proof_paths';
/// Where the copies live, created on demand.
static Future<Directory> _dir() async {
final base = await getApplicationDocumentsDirectory();
final dir = Directory('${base.path}/delivery_proof');
if (!await dir.exists()) await dir.create(recursive: true);
return dir;
}
static Future<String> _key() async =>
(await WorkScope.current()).scoped(_indexKey);
/// Copies [sourcePath] into the app's own storage and records it against
/// [orderId]. Returns the durable path, or null when the copy fails.
///
/// Failure is returned rather than thrown: a rider standing at a door with a
/// full disk still has to be able to complete the stop, and the delivery is
/// the thing that matters. The caller decides whether to proceed without it.
static Future<String?> save(String orderId, String sourcePath) async {
if (orderId.trim().isEmpty || sourcePath.trim().isEmpty) return null;
try {
final src = File(sourcePath);
if (!await src.exists()) return null;
final dir = await _dir();
// The order id is the natural name — one proof per stop, and a retake
// overwrites rather than accumulating. Sanitised because an id can carry
// characters a filename cannot.
final safe = orderId.replaceAll(RegExp(r'[^A-Za-z0-9_-]'), '_');
final dest = '${dir.path}/$safe.jpg';
await src.copy(dest);
final prefs = await SharedPreferences.getInstance();
final key = await _key();
final index = <String>[...(prefs.getStringList(key) ?? const [])]
..removeWhere((e) => e.startsWith('$orderId::'))
..add('$orderId::$dest');
await prefs.setStringList(key, index);
debugPrint('[PROOF] $orderId saved to $dest');
return dest;
} catch (e) {
debugPrint('[PROOF] could not save proof for $orderId: $e');
return null;
}
}
/// The stored proof for [orderId], or null when there is none **or the file
/// has gone**. A path that no longer resolves is not proof, and returning it
/// would draw a broken image in place of evidence.
static Future<String?> pathFor(String orderId) async {
if (orderId.trim().isEmpty) return null;
try {
final prefs = await SharedPreferences.getInstance();
final index = prefs.getStringList(await _key()) ?? const <String>[];
for (final entry in index) {
final i = entry.indexOf('::');
if (i <= 0) continue;
if (entry.substring(0, i) != orderId) continue;
final path = entry.substring(i + 2);
return await File(path).exists() ? path : null;
}
} catch (e) {
debugPrint('[PROOF] could not read proof for $orderId: $e');
}
return null;
}
/// The **remote** reference for [orderId], for `deliver`'s `photourl`.
///
/// Always empty today: the Miler API has no route that turns a file into a
/// URL. Kept as the single seam so that when one exists, the delivery call
/// starts carrying a real link without any other code changing — and so
/// that nobody is tempted to pass a device path in the meantime.
static Future<String> remoteUrlFor(String orderId) async => '';
/// Forgets proofs whose orders are long finished, so the directory cannot
/// grow for the life of the install. Best-effort.
static Future<void> prune({int keep = 200}) async {
try {
final prefs = await SharedPreferences.getInstance();
final key = await _key();
final index = prefs.getStringList(key) ?? const <String>[];
if (index.length <= keep) return;
final drop = index.take(index.length - keep).toList();
for (final entry in drop) {
final i = entry.indexOf('::');
if (i <= 0) continue;
final f = File(entry.substring(i + 2));
if (await f.exists()) await f.delete();
}
await prefs.setStringList(key, index.sublist(index.length - keep));
} catch (e) {
debugPrint('[PROOF] prune failed: $e');
}
}
/// Drops every proof in this scope. Called from logout alongside the other
/// scoped stores — a doorstep photo is exactly the kind of record that must
/// not outlive the session that took it.
static Future<void> clearScope() async {
try {
final prefs = await SharedPreferences.getInstance();
final key = await _key();
for (final entry in prefs.getStringList(key) ?? const <String>[]) {
final i = entry.indexOf('::');
if (i <= 0) continue;
final f = File(entry.substring(i + 2));
if (await f.exists()) await f.delete();
}
await prefs.remove(key);
} catch (e) {
debugPrint('[PROOF] could not clear scope: $e');
}
}
}

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
@@ -66,10 +67,10 @@ extension RiderTierUi on RiderTier {
int get threshold => kTierThresholds[this] ?? 0;
IconData get icon => switch (this) {
RiderTier.blue => Icons.pedal_bike_rounded,
RiderTier.silver => Icons.military_tech_rounded,
RiderTier.gold => Icons.workspace_premium_rounded,
RiderTier.platinum => Icons.diamond_rounded,
RiderTier.blue => LucideIcons.bike,
RiderTier.silver => LucideIcons.medal,
RiderTier.gold => LucideIcons.award,
RiderTier.platinum => LucideIcons.gem,
};
/// Colour of the badge, not of the page. The card behind it stays brand

218
lib/data/route_order.dart Normal file
View File

@@ -0,0 +1,218 @@
import 'package:flutter/foundation.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// THE ADMIN'S ROUTE IS THE ROUTE
///
/// One rule, in one place, for the only question that decides what order a
/// rider works his stops in: **has the hub solved this route?**
///
/// If yes — that order is used, exactly, on both legs. The app does not
/// improve it, shorten it, or reorder it by where the rider
/// happens to be standing.
/// If no — the app falls back, and *says* it is falling back.
///
/// ── Why this needed taking out of the screens ──
///
/// It was answered in two places that could not see each other.
/// `Trip.sortStops` — the pickup leg — reads `step` and documents it as
/// authoritative. The Deliveries tab had its own sort: stops with a step
/// first, then everything else **by straight-line distance from the rider's
/// current GPS fix**. Since `step` reached the app only through
/// `GET /miler/assignments`, and that endpoint is deliberately the *active*
/// queue (`Assigned`/`Accepted` only), a booking lost its step at the moment
/// it was collected — which is exactly when the delivery leg starts.
///
/// So every delivery was ordered nearest-first, the hub believed its solved
/// sequence was being followed, and nothing on either side said otherwise.
/// A rider re-optimising a route the hub has planned is not a small
/// difference: it changes promised arrival windows the customer was given.
///
/// ── The fallback is a fallback ──
///
/// Proximity ordering is not wrong in itself — with no assigned sequence the
/// app has to pick something, and nearest-first is the best guess available.
/// What is wrong is presenting a guess as the hub's plan. [RouteOrder.sort]
/// therefore returns *which rule it applied*, so a screen can label the queue
/// honestly and a test can assert the rule rather than the resulting order.
///
/// ── One sequenced stop is enough ──
///
/// If **any** stop in the set carries a sequence, the whole set is treated as
/// admin-ordered: the sequenced stops lead in their solved order, and the rest
/// follow. Mixing the two — sorting the sequenced ones by step and the others
/// by distance — is what the Deliveries tab used to do, and it produces a list
/// that is neither the hub's route nor a sane one.
/// ─────────────────────────────────────────────────────────────────────────
enum RouteOrderSource {
/// The hub solved it. `step` (or an equivalent) came down populated.
adminSequence,
/// No sequence, but the stops carry booked times — work them in the order
/// they were promised for.
bookedTime,
/// No sequence and no times. Whatever order the backend listed them in,
/// preserved rather than replaced.
backendOrder,
/// No sequence, and the app chose nearest-first from the rider's position.
/// **A guess.** Must be labelled as one wherever it reaches a screen.
proximity,
}
extension RouteOrderSourceX on RouteOrderSource {
/// True when the order came from the hub and must not be second-guessed.
bool get isAdmin => this == RouteOrderSource.adminSequence;
/// What the rider is told the list is ordered by. Short, because it sits
/// under a heading and not in a paragraph.
String get label => switch (this) {
RouteOrderSource.adminSequence => 'Hub route',
RouteOrderSource.bookedTime => 'By booked time',
RouteOrderSource.backendOrder => 'As assigned',
RouteOrderSource.proximity => 'Nearest first',
};
/// The longer form, for a place with room to explain — and specifically to
/// keep the app from implying the hub planned an order it did not.
String get explanation => switch (this) {
RouteOrderSource.adminSequence => 'Ordered by the route your hub assigned.',
RouteOrderSource.bookedTime =>
'No route assigned — ordered by booked time.',
RouteOrderSource.backendOrder =>
'No route assigned — shown in the order they came through.',
RouteOrderSource.proximity =>
'No route assigned — ordered by what is closest to you.',
};
}
/// A stop's place in the hub's solved route, and the sort that honours it.
abstract final class RouteOrder {
/// Field names that have carried the solved sequence.
///
/// `step` is the one the backend writes. The rest are spellings seen in the
/// wild or named in the contract for the delivery leg; reading all of them
/// costs nothing and means a rename does not silently drop the whole rule
/// back to nearest-first — the failure mode that started this.
static const List<String> sequenceKeys = [
'step',
'Step',
'deliverystep',
'deliveryStep',
'routestep',
'routeStep',
'sequence',
'routesequence',
'routeSequence',
'stopsequence',
];
/// This stop's place in the route, or `0` for *not sequenced*.
///
/// **Zero is not "first".** The hub numbers from 1, so a 0 means the
/// optimizer has not run for this stop, and treating it as position zero
/// would put unsequenced work at the head of a solved route.
static int sequenceOf(Map<String, dynamic> stop) {
for (final key in sequenceKeys) {
final raw = stop[key];
if (raw == null) continue;
final n = raw is num ? raw.toInt() : int.tryParse(raw.toString()) ?? 0;
if (n > 0) return n;
}
return 0;
}
/// True when the hub has solved an order for at least one of these stops.
static bool hasAdminSequence(Iterable<Map<String, dynamic>> stops) =>
stops.any((s) => sequenceOf(s) > 0);
/// Puts [stops] in the order they are to be worked, and says which rule it
/// used.
///
/// [distanceTo] is the escape hatch for the proximity fallback: the caller
/// supplies metres from the rider to a stop, because this file is pure and
/// has no business knowing about GPS. Omit it and proximity is simply never
/// used — which is the correct behaviour with no fix available, not a
/// reason to leave the list unsorted.
static (List<Map<String, dynamic>>, RouteOrderSource) sort(
List<Map<String, dynamic>> stops, {
double Function(Map<String, dynamic> stop)? distanceTo,
DateTime? Function(Map<String, dynamic> stop)? bookedTimeOf,
}) {
if (stops.length <= 1) {
return (
List<Map<String, dynamic>>.from(stops),
hasAdminSequence(stops)
? RouteOrderSource.adminSequence
: RouteOrderSource.backendOrder,
);
}
// Indexed so every comparison can fall back to the order the backend sent,
// which makes the sort stable and keeps "unordered" meaning *unchanged*.
final indexed = <(int, Map<String, dynamic>)>[
for (var i = 0; i < stops.length; i++) (i, stops[i]),
];
if (hasAdminSequence(stops)) {
indexed.sort((a, b) {
final sa = sequenceOf(a.$2);
final sb = sequenceOf(b.$2);
if (sa > 0 && sb > 0 && sa != sb) return sa - sb;
// Sequenced work leads. An unsequenced stop appended to a solved route
// is a stop the hub did not plan for, and it goes at the end of it.
if (sa > 0 && sb == 0) return -1;
if (sb > 0 && sa == 0) return 1;
return a.$1 - b.$1;
});
return ([for (final e in indexed) e.$2], RouteOrderSource.adminSequence);
}
// ── No admin route. Only now may the app choose. ──
final times = <int, DateTime>{};
if (bookedTimeOf != null) {
for (final e in indexed) {
final t = bookedTimeOf(e.$2);
if (t != null) times[e.$1] = t;
}
}
if (times.length > 1) {
indexed.sort((a, b) {
final ta = times[a.$1];
final tb = times[b.$1];
if (ta != null && tb != null && ta != tb) return ta.compareTo(tb);
if (ta != null && tb == null) return -1;
if (tb != null && ta == null) return 1;
return a.$1 - b.$1;
});
return ([for (final e in indexed) e.$2], RouteOrderSource.bookedTime);
}
if (distanceTo != null) {
final metres = {for (final e in indexed) e.$1: distanceTo(e.$2)};
indexed.sort((a, b) {
final da = metres[a.$1] ?? double.infinity;
final db = metres[b.$1] ?? double.infinity;
if (da != db) return da.compareTo(db);
return a.$1 - b.$1;
});
return ([for (final e in indexed) e.$2], RouteOrderSource.proximity);
}
return ([for (final e in indexed) e.$2], RouteOrderSource.backendOrder);
}
/// Records that a set of stops reached a screen with no assigned order.
///
/// Not a warning about a bug in this app — it is the hub's optimizer not
/// having run. Logged so the gap is visible in a rider's log rather than
/// inferred later from a complaint about stop order.
static void logUnsequenced(String where, int count) {
if (count <= 0) return;
debugPrint(
'[ROUTE][$where] $count stop(s) with no assigned sequence — '
'ordering is the app\'s own fallback, not the hub\'s route',
);
}
}

95
lib/data/service_day.dart Normal file
View File

@@ -0,0 +1,95 @@
/// ─────────────────────────────────────────────────────────────────────────
/// THE OPERATING DAY
///
/// Activity is a **shift log**, not an archive. What the rider needs from it is
/// "what have I done today" — and today ends when his day ends, not when a
/// timer somewhere fires.
///
/// ── Why this is a filter and not a cleanup job ──
///
/// The obvious build is a scheduled purge: at midnight, delete yesterday. It
/// is also the one that cannot work on a phone. The app is closed at midnight
/// far more often than it is open; a timer that must fire at 23:59 to keep the
/// screen correct is a timer that will not fire, and the rider opens Activity
/// at six the next morning to yesterday's finished work presented as today's.
/// Worse, a purge is destructive on a schedule nothing observes — one bad
/// timezone assumption and it takes the current shift with it.
///
/// So nothing is deleted on a clock. Every record carries the service day it
/// belongs to, and the screen asks for **today's**. When the date rolls over,
/// yesterday stops matching; it does not need to be removed, and it is still
/// there for anything that legitimately wants history. Closing the app, force
/// quitting it, flying through a timezone or leaving it open across midnight
/// all produce the same answer, because the answer is computed at read time.
///
/// ── Local, deliberately ──
///
/// A rider's day is the day where he is standing. `DateTime.now()` is already
/// local, and both stores stamp their records with the local calendar date at
/// the moment of writing — so a record written at 23:50 belongs to that day
/// and a record written ten minutes later belongs to the next, which is what
/// the rider would say too. Nothing here converts to UTC: doing so would move
/// the boundary to 05:30 local in IST and split every evening shift in half.
/// ─────────────────────────────────────────────────────────────────────────
abstract final class ServiceDay {
/// `2026-08-21` for [at], in the local calendar. The same shape both stores
/// write, so a stamp and a query can be compared as strings.
static String stamp(DateTime at) =>
'${at.year.toString().padLeft(4, '0')}-'
'${at.month.toString().padLeft(2, '0')}-'
'${at.day.toString().padLeft(2, '0')}';
/// The service day the rider is in right now.
static String get today => stamp(DateTime.now());
/// The stamps a record carries, newest-meaning first.
static const List<String> dayKeys = [
'completedday',
'skippedday',
'serviceday',
];
/// The timestamps to fall back on when a row carries no day stamp — an API
/// row, say, which has never been through either local store.
static const List<String> timeKeys = [
'completedat',
'skippedat',
'deliveredat',
'updatedat',
'createdat',
];
/// The service day [row] belongs to, or `''` when it carries nothing usable.
///
/// Empty is a real answer and callers must decide what it means for them —
/// see [belongsToToday], which keeps such a row rather than dropping it.
static String of(Map<String, dynamic> row) {
for (final k in dayKeys) {
final v = (row[k] ?? '').toString().trim();
if (v.length >= 10) return v.substring(0, 10);
}
for (final k in timeKeys) {
final raw = (row[k] ?? '').toString().trim();
if (raw.isEmpty) continue;
final t = DateTime.tryParse(raw);
if (t != null) return stamp(t.isUtc ? t.toLocal() : t);
}
return '';
}
/// Whether [row] belongs to the service day in progress.
///
/// ── A row with no date is kept, and kept quietly ──
///
/// It is on this screen because this session produced it or the API returned
/// it for this rider; the missing field is a data-quality problem, not
/// evidence that the work happened yesterday. Dropping it would silently
/// lose a rider's completed stop, and labelling it — the old `Undated` badge
/// — puts an internal defect on a screen he is supposed to read at a glance
/// and can do nothing about. So it stays, in today, unmarked.
static bool belongsToToday(Map<String, dynamic> row, {String? now}) {
final day = of(row);
if (day.isEmpty) return true;
return day == (now ?? today);
}
}

View File

@@ -0,0 +1,700 @@
import 'package:flutter/foundation.dart';
import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/data/api_config.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// WHICH LINE OF WORK THIS RIDER IS ON
///
/// One company — **Doormile** — running two lines of work out of one rider app.
///
/// • **Parcel.** First-mile logistics: collect a consignment from a customer,
/// carry it to the hub, cash on delivery, proof at every door. The app's
/// original business.
///
/// • **Meals.** Subscription food runs for clients with cloud kitchens: load a
/// crate of lunches at the kitchen, drop them to subscribers who have already
/// paid by the month, go home. No money at any door, no chain of custody, a
/// different shape of day entirely.
///
/// The two used to be two separate rider apps. They are one app now, and which
/// line a rider is on is decided by the **tenant id on his login** — the hub
/// assigns it, the app reads it once and never asks again.
///
/// ── Why the tenant and not the trip ──
///
/// The earlier design note for mixed routes argued for keying behaviour off the
/// *stop*, because one rider can hold a pickup and a delivery in the same hour.
/// That argument does not apply here: the two lines have separate riders,
/// separate hubs and separate clients. A rider belongs to one of them for the
/// life of his account, and `tenantid` is already on the login response — so it
/// resolves once, at login, and nothing downstream has to ask again.
///
/// ── The one rule for using this ──
///
/// Screens read a **capability**, never a line. `if (profile.collectsCash)`,
/// not `if (line == ServiceLine.meals)`. Capabilities are why signing a third
/// kind of client later is a new [ServiceProfile] rather than a third UI: the
/// day a food client wants cash on delivery, or a parcel client wants crate
/// loading, the screens are already asking the right question.
/// ─────────────────────────────────────────────────────────────────────────
enum ServiceLine {
/// Parcel logistics. The app's original and largest business.
parcel,
/// **Milk Man delivery** — load a crate at a source (a kitchen), then drop
/// to the customers who ordered from it.
///
/// Named `milkMan` after the operation the hub runs; the code called this
/// `meals` while it had one food client, and the shape of the work — collect
/// in bulk from one or more sources, then a round of prepaid drops — is the
/// same whatever is in the crate.
milkMan,
/// Last-mile delivery: carry consignments out of the hub and hand them to
/// customers. The Xpress-rider app's business.
///
/// ── Why this one is different from the other two ──
///
/// [parcel] and [meals] are the *same screens* with different capabilities
/// switched on. This one is not: it has its own screens, ported verbatim from
/// the Xpress-rider app and living under `lib/xpress`. A delivery rider gets
/// that app — its home, its deliveries list, its summary, its four-tab bottom
/// bar — wearing Doormile's colours.
///
/// So the capabilities on [ServiceProfile.delivery] are close to decorative:
/// almost nothing reads them, because the delivery screens do not ask the
/// capability questions the parcel screens ask. What this line is *for* is the
/// routing decision in `helpers/rider_shell.dart`, which is the only place
/// that has to know.
delivery,
}
/// The moment a stop leaves Home for the work tab. See [ServiceProfile.handoffAt].
enum HandoffPoint {
/// As soon as the rider accepts it.
accepted,
/// Only once he is physically carrying it.
collected,
}
/// What this line of work requires of the rider at the door.
@immutable
class ServiceProfile {
final ServiceLine line;
/// Shown wherever the rider needs to know which work this is.
///
/// Both lines are Doormile — the rider's employer does not change with the
/// crate he is carrying — so this names the *work*, not a company.
final String label;
/// ── The one noun the whole app uses for a job ──
///
/// The two rider apps this one replaces disagreed about what a job is called:
/// the parcel app said **booking**, the meal app said **delivery**. Merging
/// them meant picking one, and there is no single honest answer — a rider who
/// collects parcels for the hub does not "deliver" them, and a rider handing
/// somebody lunch has not taken a "booking".
///
/// So the noun follows the line, and every string that names a job reads it
/// from here: the tab in the nav bar, the accepted-count pill, the sentence on
/// a taken stop, the empty states. A rider only ever belongs to one line, so
/// he only ever sees one word — and the code has one path, not two.
///
/// Capitalised where a label needs it; see [workTabLabel].
final String jobNoun;
/// Plural of [jobNoun] — English is not regular enough to derive it.
final String jobNounPlural;
/// What the nav bar calls the tab holding accepted work.
///
/// "Bookings" on a parcel route, "Deliveries" on a meal run. Title case
/// because it is a proper destination in the app, not a word in a sentence.
String get workTabLabel =>
jobNounPlural[0].toUpperCase() + jobNounPlural.substring(1);
/// The verb for finishing a stop, in the rider's voice: "Picked up" when he
/// is taking something away, "Delivered" when he is handing it over.
///
/// A meal run's stops are all drops, so its rider never sees "Picked up" at a
/// customer's door — that word belongs to the kitchen, where he loads.
final String completionVerb;
/// Money changes hands at the stop. False for a subscription service — the
/// client is billed monthly, so the rider never sees an amount.
///
/// Read through [stopCollectionAmount], which returns 0 when this is false,
/// so every existing "is anything owed?" branch already does the right thing.
final bool collectsCash;
/// The full proof-of-work page — parcel ticks, weight, condition, OTP,
/// photo, review. Right for a parcel with a chain of custody; wrong for a
/// lunch box, where it is a two-minute form standing between a rider and a
/// doorbell.
///
/// **Nothing reads this today.** The page is off on both tenants: arriving
/// goes straight to the confirmation sheet, which asks the one question a
/// stop actually has an answer to — picked up, not picked, or skipped. The
/// capability and [StopVerificationPage] both survive because the form is
/// what a chain-of-custody client would need on the day one asks for it, and
/// this is the switch that would turn it back on for that tenant alone.
final bool needsVerification;
/// A camera step on the final status change.
final bool needsProofPhoto;
/// The rider decides stop by stop. False for a service run: he cannot
/// decline one subscriber's lunch, so the whole assignment is accepted at
/// once and the per-row Accept/Reject controls come off the card.
final bool acceptsPerStop;
/// Collection is grouped by source (a kitchen), not done per customer.
///
/// Also what makes Home group its stops under a heading per source and offer
/// one bulk collect per group: a rider standing at a counter with a crate is
/// doing one thing, not five.
final bool sourceIsKitchen;
/// When a stop stops being Home's problem and becomes the work tab's.
///
/// A parcel route hands off at **accept**: taking a booking is the decision,
/// and everything after it happens at the customer's door.
///
/// A meal run hands off at **collect**, because accepting changes nothing
/// physical — the rider still has to go to a kitchen and be given the food.
/// Moving the card at accept would empty Home the moment he arrived for his
/// shift and leave him working a list of meals he is not carrying. So the rule
/// is: **Home holds an order until it is in his hands.**
final HandoffPoint handoffAt;
/// One name for every stop's work type, or null to let each stop name its own
/// (`PICKUP` / `DELIVERY` / `P&D`).
///
/// A meal run is one job repeated — collect a crate, drop the boxes — and
/// labelling half of a rider's day PICKUP and half DELIVERY invites him to
/// look for a difference in handling that does not exist. The stop's real
/// kind is still tracked underneath, because the collection gate in P3 needs
/// it; this is only what the chip says.
final String? workTypeLabel;
/// Loading is done crate-at-a-counter, so it takes one photo for the whole
/// load rather than one per order.
///
/// A rider standing at a kitchen hatch with fifteen boxes cannot photograph
/// each one, and the thing worth photographing is the *crate* — what he was
/// handed, in one frame, at one time. False on a parcel route, where proof is
/// per consignment because each one has its own chain of custody.
final bool bulkLoadProof;
/// The rider asks the customer where this shipment is coming from and going
/// to, and the answers are written back to the booking.
///
/// ── Why this is a rider's job at all ──
///
/// A logistics booking often arrives half-addressed: the customer raised it
/// from a map pin and described the destination as a phone number and a
/// landmark. The rider is the first person standing in front of the sender
/// who can ask. Those answers decide the consignment's routing and its
/// pricing zone, so they have to be captured before the order is initiated.
///
/// False on a milk run, where both ends were fixed when the customer
/// subscribed and there is nothing for the rider to establish.
final bool capturesShipmentAddresses;
/// The fare is computed at the door from the real shipment, and shown to the
/// customer before he pays.
///
/// Follows [capturesShipmentAddresses] — a price needs a from, a to and a
/// weight, and a line that does not collect the first two cannot quote.
final bool pricesShipment;
/// Collection ends by converting the booking into a live shipment with a
/// tracking number — the moment the order really exists.
///
/// On a milk run collection is the *middle* of the day, not the end of a
/// transaction, so there is nothing to initiate.
final bool initiatesShipment;
/// The rider carries the load on to the customers himself.
///
/// True for a milk run: collection is followed by a round of drops he makes
/// personally. False for logistics, whose collected shipment goes to the hub
/// and is delivered by somebody else on another day — which is why that line
/// ends at a warehouse and this one ends at the last door.
final bool deliversToCustomer;
/// Delivery does not begin until every assigned pickup is in the rider's
/// hands, and it begins on an explicit press.
///
/// ── Why the whole load, and why a press ──
///
/// A milk-run rider works two or three sources before he starts driving to
/// customers. Letting each collected order drift into the delivery list on
/// its own gives him a half-built round: he sets off after kitchen one, and
/// kitchen two's five orders appear behind him. So the round is held until
/// the load is complete and then released in one gesture, which is also the
/// moment the shipments legitimately become "out for delivery" server-side.
final bool startsDeliveryAfterFullLoad;
const ServiceProfile({
required this.line,
required this.label,
required this.jobNoun,
required this.jobNounPlural,
required this.completionVerb,
required this.collectsCash,
required this.needsVerification,
required this.needsProofPhoto,
required this.acceptsPerStop,
required this.sourceIsKitchen,
required this.handoffAt,
this.bulkLoadProof = false,
this.capturesShipmentAddresses = false,
this.pricesShipment = false,
this.initiatesShipment = false,
this.deliversToCustomer = false,
this.startsDeliveryAfterFullLoad = false,
this.workTypeLabel,
});
/// First-mile parcel logistics — the app as it has always been. Also the
/// fallback for an unrecognised tenant, see [TenantController.load].
static const ServiceProfile parcel = ServiceProfile(
line: ServiceLine.parcel,
label: 'Doormile',
jobNoun: 'booking',
jobNounPlural: 'bookings',
completionVerb: 'Picked up',
collectsCash: true,
needsVerification: true,
needsProofPhoto: true,
acceptsPerStop: true,
sourceIsKitchen: false,
handoffAt: HandoffPoint.accepted,
// The full shipment desk at the customer's door: where is it from, where
// is it going, what does it weigh, what does that cost, and take the money
// — then turn it into a real consignment bound for the hub.
capturesShipmentAddresses: true,
pricesShipment: true,
initiatesShipment: true,
// The collected shipment goes to the hub; somebody else delivers it.
deliversToCustomer: false,
// Each stop names its own kind: a parcel route genuinely mixes them.
workTypeLabel: null,
);
/// **Milk Man delivery.** Load a crate at one or more sources, then a round
/// of prepaid drops. No money, no verification form, no per-stop decision.
static const ServiceProfile milkMan = ServiceProfile(
line: ServiceLine.milkMan,
label: 'Doormile Delivery',
jobNoun: 'delivery',
jobNounPlural: 'deliveries',
completionVerb: 'Delivered',
collectsCash: false,
needsVerification: false,
needsProofPhoto: false,
acceptsPerStop: false,
sourceIsKitchen: true,
handoffAt: HandoffPoint.collected,
bulkLoadProof: true,
// None of the shipment desk: these orders were booked and paid for before
// the rider's shift started.
capturesShipmentAddresses: false,
pricesShipment: false,
initiatesShipment: false,
// He collects the load and delivers it himself, once it is all aboard.
deliversToCustomer: true,
startsDeliveryAfterFullLoad: true,
// One word, deliberately. The chip is a fixed slot beside the state tag and
// a two-word label is the one that gives way — "MILK RU…" on every card in
// the list. `test/service_card_test.dart` holds this.
// ── Never the internal name of the line ──
//
// This was `MILK`, and it was printed on the rider's cards and on his
// Activity record as `Type: MILK`. That is the app's own vocabulary for a
// *service profile* — an implementation detail he did not choose, cannot
// change and gains nothing from. What he is actually doing at every stop
// on this line is a delivery, so that is what the mark says.
//
// The profile itself stays exactly as it was: it is what separates one
// rider's records from another's and decides which capabilities exist.
// That separation is a data rule, and it belongs in the data, not on a
// badge.
workTypeLabel: 'DELIVERY',
);
/// The old name for [milkMan], from when the line had one food client.
///
/// Kept so the existing call sites and tests that name it this way keep
/// compiling; there is one profile, under two names.
static const ServiceProfile meals = milkMan;
/// Last-mile delivery — the ported Xpress-rider flow.
///
/// The capability values here describe the work honestly (a delivery rider
/// does collect cash on COD, does photograph a doorstep, does take jobs one at
/// a time), but almost nothing reads them: this line renders its own screens,
/// so it does not go through the capability branches the parcel screens use.
/// They matter only if a delivery rider is ever routed into a shared screen.
static const ServiceProfile delivery = ServiceProfile(
line: ServiceLine.delivery,
label: 'Doormile',
jobNoun: 'delivery',
jobNounPlural: 'deliveries',
completionVerb: 'Delivered',
collectsCash: true,
needsVerification: false,
needsProofPhoto: true,
acceptsPerStop: true,
sourceIsKitchen: false,
handoffAt: HandoffPoint.accepted,
workTypeLabel: 'DELIVERY',
);
/// True when an accepted stop stays on Home until the rider is carrying it.
bool get handsOffAtCollection => handoffAt == HandoffPoint.collected;
/// True when every stop wears the same work-type mark — see [workTypeLabel].
bool get usesSingleWorkType => workTypeLabel != null;
/// True on the Milk Man line — collect at a source, then deliver.
bool get isMilkMan => line == ServiceLine.milkMan;
/// The old name for [isMilkMan].
bool get isMeals => isMilkMan;
/// ── Whether the backend can serve this line's work at all ──
///
/// True on every line, and that is a correction, not a shortcut. This read
/// `!sourceIsKitchen`, on the reasoning that the `/miler/*` contract serves
/// bookings and consignments while kitchens, crates and subscribers are not
/// concepts it has — so a milk-man rider could have no endpoint to ask.
///
/// **The hub does not dispatch that way.** The console's dispatch board reads
/// the milk round from `GET /admin/bookings` and puts a rider on a stop with
/// `POST /hub/bookings/:id/auto-assign`; the rider-facing read of those very
/// rows is `GET /miler/bookings`. A milk round *is* bookings today. The
/// kitchen vocabulary describes how this app **renders** the work — load at a
/// source, hand off at collection, one MILK chip per card — not where the
/// work comes from.
///
/// While the two were conflated, a rider on the milk-man line could not be
/// given work at all: [WorkRepository] returns `LoadUnavailable` on a false
/// answer here and never issues the request, so Home, Bookings and Activity
/// were all blank however many bookings the hub had assigned him. That is how
/// it was found — Rajan A (userid 38, tenant 13) was assigned a booking in the
/// console and the app showed him nothing, with no failure anywhere to
/// explain it.
///
/// The flag stays because the state it feeds is worth keeping: a line the
/// backend genuinely cannot answer for should render "your hub has not
/// enabled this yet" rather than an empty list that reads as a quiet day. It
/// is now something a future line sets deliberately, not something a rider
/// inherits from how his round is shaped.
///
/// It must never be satisfied by [MealRunMock]. That fixture is an opt-in
/// development tool behind `--dart-define=MOCK_BACKEND=true`; wiring it in
/// here would put fabricated business data on a production path.
bool get hasBookingsEndpoint => true;
/// True when the rider's day ends back at the depot.
///
/// A logistics rider collects shipments at customers' doors and carries them
/// to the hub, so the last leg of his route is a building. A **milk-man**
/// round is the other way up: he loads at the kitchen at the start and his
/// last address is a customer's door, so there is nothing to return and
/// nowhere to return it to — his day ends when the round does.
///
/// Derived from [deliversToCustomer] rather than stored, because they are the
/// same fact: a line that hands goods to the customer has already delivered
/// its load by the time it finishes.
bool get endsAtHub => !deliversToCustomer;
/// True on the Logistics line — the shipment desk at the customer's door.
bool get isLogistics => line == ServiceLine.parcel;
/// The old name for [isLogistics].
bool get isParcel => isLogistics;
/// True when this rider gets the ported Xpress-rider screens rather than the
/// parcel ones. Read by `helpers/rider_shell.dart` and by nothing else — see
/// the note on [ServiceLine.delivery].
bool get isDelivery => line == ServiceLine.delivery;
// ── Reading the active profile ──
//
// A plain static, deliberately, and not a GetX lookup.
//
// [stopCollectionAmount] is a pure map helper called from model code and from
// tests with no widget tree at all. Routing it through `Get.put` made it
// initialise a real `WidgetsFlutterBinding` as a side effect of asking "is
// any money owed here?" — which is enough to stop an unrelated widget test in
// another file from starting at all. Data code must not be able to boot the
// framework.
//
// [TenantController] still owns loading and still publishes an `Rx` for
// screens that want to rebuild; this is the synchronous read everything else
// uses.
static ServiceProfile _active = parcel;
/// The signed-in rider's profile.
///
/// Parcel until [TenantController.load] says otherwise — see that method for
/// why the fallback runs in that direction.
static ServiceProfile get active => _active;
/// Sets the active profile. Called by [TenantController.load]; also the seam
/// tests use to put the app on one line or the other.
static void setActive(ServiceProfile profile) => _active = profile;
}
/// Resolves the rider's line of work from what login persisted.
///
/// Order: **the rider's own tenant** (name, then id) → build override →
/// logistics.
///
/// ── Why the server now wins over the build flag ──
///
/// This used to read the `TENANT` dart-define first, because `verify-pin`
/// returned no tenant at all and the flag was the only signal there was. That
/// is no longer true: the login response carries `tenantid` and `tenantname`,
/// so the rostered answer exists and the app can simply ask.
///
/// Which way round these two go is a real decision, not a detail. "Which work
/// am I doing today?" must be answered by the hub that rostered the rider, not
/// by whoever compiled the APK — one build serves every rider, and a flag that
/// outranks the account means one wrong build puts every rider on the wrong
/// flow. So the flag is now what it should always have been: a fallback for
/// builds signed in against a backend that cannot answer, and a way to demo a
/// line without a matching account.
///
/// A free function rather than a controller method so it can be tested without
/// a GetX container — see the note on [ServiceProfile.active].
Future<ServiceProfile> resolveServiceProfile() async {
final prefs = await SharedPreferences.getInstance();
// ── What the rider's account says ──
//
// Name first: it is the field a human can check against the admin console,
// and it means a new tenant does not need an app release. An unrecognised
// name is not an answer, so it falls through to the id.
final name = (prefs.getString(TenantController.kTenantName) ?? '')
.trim()
.toLowerCase();
if (name.isNotEmpty) {
final byName = TenantController.profileForName(name);
if (byName != null) return byName;
}
final id = prefs.getInt(TenantController.kTenantId) ?? 0;
if (id != 0) {
final byId = TenantController.profileForId(id);
if (byId != null) return byId;
}
// ── The tenant the session's own token claims ──
//
// Read after the persisted pair and before the build flag, and it exists for
// two cases the pair does not cover.
//
// The first is a backend whose `verify-pin` does not return the tenant in its
// body. It signs one into every token regardless, so the answer is in the
// app's hands either way — this is the same fact from the same source.
//
// The second is the rider who is **already signed in**. His prefs were
// written by an older build that stored tenant 0, and nothing would correct
// that until he happened to log out; reading his live session instead means
// the next launch resolves him properly with no action from him at all.
//
// Not a security decision — see [ApiConfig.tenantIdFromToken].
final claimed = ApiConfig.tenantIdFromToken(prefs.getString('authtoken'));
if (claimed != 0) {
final byClaim = TenantController.profileForId(claimed);
if (byClaim != null) return byClaim;
}
// ── The build's declared line, as a fallback ──
//
// Reached only when the account said nothing this app recognises. Unset
// means logistics, so an ordinary build is unchanged.
if (TenantController.buildOverride.isNotEmpty) {
final override = TenantController.buildOverride.trim().toLowerCase();
final byOverride = TenantController.profileForName(override);
if (byOverride != null) return byOverride;
// The ported Xpress line is reachable ONLY from the build flag — never
// from a tenant name or id off the wire. It is a reference implementation
// that lives beside this app, not one of the two operations the hub runs,
// and a tenant that happened to be called "delivery" must not tip a live
// rider into a different application. See `helpers/rider_shell.dart`.
if (TenantController.deliveryTenantNames.contains(override)) {
return ServiceProfile.delivery;
}
}
return ServiceProfile.parcel;
}
/// Resolves the signed-in rider's line of work, once, and hands out its profile.
///
/// Follows [DutyController]: a permanent GetX singleton with a `.to` accessor
/// and a `load()` that reads persisted prefs, so any screen can read the
/// profile synchronously without an await in `build`.
class TenantController extends GetxController {
/// Parcel until proven otherwise — see [load] for why that direction.
final Rx<ServiceProfile> profile = ServiceProfile.parcel.obs;
/// Persisted by the login response. See `auth_provider.dart`.
static const String kTenantId = 'tenantid';
static const String kTenantName = 'tenantname';
/// Tenant ids whose riders work the **Milk Man** line.
///
/// **This is the switch the hub actually operates.** A rider is put on the
/// milk-run flow by being given one of these tenant ids at login and nothing
/// else — no separate app, no separate build, no flag on his phone.
///
/// **13** is the tenant the Milk Man riders are on — the account Rajan A
/// (userid 38) signs in with, confirmed from the `tenantid` claim on a live
/// login against `api.doormile.com`.
///
/// ── Why an id is pinned here at all ──
///
/// The name is the better switch and stays the first thing checked. But the
/// deployed backend does not return `tenantname` yet (the handler that does
/// is written and not released), so today the id is the only signal a real
/// rider carries. When the deploy lands, the name will match first and this
/// becomes a belt-and-braces second answer rather than the load-bearing one.
///
/// **If tenant 13 is not the milk-run client**, this line is the whole fix:
/// remove the 13 and a rider on it goes back to logistics.
static const Set<int> milkManTenantIds = <int>{13};
/// The old name for [milkManTenantIds].
static const Set<int> mealTenantIds = milkManTenantIds;
/// Name/code match, case-insensitive, against `tenantname` on the login.
///
/// Cheaper than a release for every new tenant id, and it is the field a
/// human can actually verify against the admin console.
static const Set<String> milkManTenantNames = <String>{
'milkman',
'milk man',
'milk-man',
'milkrun',
'milk run',
'meals',
'dailygrubs',
};
/// The old name for [milkManTenantNames].
static const Set<String> mealTenantNames = milkManTenantNames;
/// Tenant ids whose riders work the **Logistics** line.
///
/// Logistics is also the fallback, so this list exists to make a tenant
/// *explicitly* logistics rather than merely unrecognised — which matters
/// when a name would otherwise be ambiguous.
static const Set<int> logisticsTenantIds = <int>{};
/// The names that mean the Logistics line.
static const Set<String> logisticsTenantNames = <String>{
'doormile',
'logistics',
'parcel',
};
/// The old name for [logisticsTenantNames].
static const Set<String> parcelTenantNames = logisticsTenantNames;
/// The profile a tenant *name* means, or null when this app does not know
/// the name. Case-insensitive; callers pass an already-lowercased string.
///
/// One lookup used by both the account path and the build override, so the
/// two can never disagree about what a name means.
static ServiceProfile? profileForName(String name) {
if (milkManTenantNames.contains(name)) return ServiceProfile.milkMan;
if (logisticsTenantNames.contains(name)) return ServiceProfile.parcel;
return null;
}
/// The profile a tenant *id* means, or null when this app does not know it.
static ServiceProfile? profileForId(int id) {
if (milkManTenantIds.contains(id)) return ServiceProfile.milkMan;
if (logisticsTenantIds.contains(id)) return ServiceProfile.parcel;
return null;
}
/// Tenant ids that would mean the ported Xpress-rider screens under
/// `lib/xpress`.
///
/// **Permanently empty, deliberately.** That subtree is a *reference*
/// implementation kept beside this app — it is not one of the two operations
/// the hub runs, and the Milk Man flow is now implemented natively in Miler
/// rather than by routing riders into it.
///
/// Nothing off the wire may reach it: a tenant that happened to be named
/// "delivery" must not tip a live rider into a different application with a
/// different bottom bar and a different set of endpoints. The only way in is
/// the [buildOverride] flag, for looking at it. See `resolveServiceProfile`.
static const Set<int> deliveryTenantIds = <int>{};
/// Build-override names that open the ported Xpress screens. Never matched
/// against a tenant off the login — see [deliveryTenantIds].
static const Set<String> deliveryTenantNames = <String>{
'delivery',
'xpress',
'express',
'doormile xpress',
'doormilexpress',
};
/// Account partitions that mean the ported delivery line. Retained for the
/// build-override path only; nothing off the wire is matched against it.
static const Set<int> deliveryConfigIds = <int>{6};
/// Which line this build declares, **when the rider's account does not say**.
///
/// flutter run --dart-define=TENANT=milkman
/// flutter run --dart-define=TENANT=logistics
/// flutter run --dart-define=TENANT=delivery # the Xpress reference
///
/// ── This is a testing aid, not the production switch ──
///
/// A production rider's line comes from his own tenant, which `verify-pin`
/// now returns. This flag is read only when the account carries a tenant this
/// app does not recognise, so it cannot override a rostered rider — see the
/// ordering note on [resolveServiceProfile].
///
/// Two flags, and they answer different questions:
///
/// • `TENANT` picks the **profile**, i.e. which screens the rider gets.
/// • `TENANT_ID` is the **number sent to the API** — see [MilerApi.tenantId].
///
/// Unset means logistics, so an ordinary build is unchanged.
static const String buildOverride = String.fromEnvironment('TENANT');
static TenantController get to => Get.isRegistered<TenantController>()
? Get.find<TenantController>()
: Get.put(TenantController(), permanent: true);
/// Resolves the tenant and publishes it, both to [ServiceProfile.active] for
/// synchronous reads and to [profile] for screens that rebuild on it.
///
/// The fallback direction is the point: an unconfigured or unrecognised
/// tenant lands on **logistics**. The worst case that way is a milk-run rider
/// seeing a payment prompt he can dismiss; the other direction takes the
/// cash-collection screen away from a live logistics rider at a door.
Future<ServiceProfile> load() async {
final resolved = await resolveServiceProfile();
ServiceProfile.setActive(resolved);
profile.value = resolved;
debugPrint('[TENANT] resolved ${resolved.label}');
return resolved;
}
}

203
lib/data/stop_area.dart Normal file
View File

@@ -0,0 +1,203 @@
/// ─────────────────────────────────────────────────────────────────────────
/// WHERE A STOP IS, IN ONE WORD
///
/// A rider scanning twenty stops is deciding between *areas* — is Joe on the
/// way to Priya, or the opposite side of the city. He is not reading twenty
/// street strings, and he certainly is not reading twenty street strings cut
/// off mid-word: `SEQTEST Gandhipur…` answers nothing that a blank line would
/// not have answered, and costs a line to do it.
///
/// So the collapsed queue shows the locality and the full address lives where
/// the rare question is asked — the detail sheet, and the map at the door.
///
/// ── Why this is one function and not two ──
///
/// Home and Deliveries both needed it and both had their own. The rule is
/// subtle enough to get wrong in exactly the same way twice: the first version
/// skipped a component only when it was *entirely* digits, so
/// `12 SNS Colony, Peelamedu, Coimbatore 641004` returned `12 SNS Colony` — a
/// street with a door number on it, presented as an area. Two copies of that
/// bug is two places to find it.
/// ─────────────────────────────────────────────────────────────────────────
library;
/// The locality component of a stop's address, or `''` when it has none.
///
/// Prefers the drop, because on a delivery leg the area that matters is where
/// the bag is going. Falls back to the pickup for a stop that carries no drop,
/// and returns nothing rather than inventing a placeholder.
String areaOf(Map<String, dynamic> stop, {bool preferDrop = true}) {
final keys = preferDrop
? const [
'dropaddress',
'DropAddress',
'deliveryaddress',
'pickupaddress',
'PickupAddress',
]
: const [
'pickupaddress',
'PickupAddress',
'dropaddress',
'DropAddress',
'deliveryaddress',
];
for (final key in keys) {
final raw = (stop[key] ?? '').toString().trim();
if (raw.isEmpty) continue;
final parts = raw
.split(',')
.map((p) => p.trim())
.where((p) => p.isNotEmpty)
.toList();
// ── An area is a NAME, not a description ──
//
// Three versions of this rule, each corrected by a real address:
//
// 1. "Skip a part only when it is entirely digits" printed `12 SNS
// Colony` — a door number presented as an area.
// 2. "Skip a part containing any digit" fixed that, but then
// `12 Peelamedu, Coimbatore` lost `12 Peelamedu` wholesale and answered
// with the *city* — and every stop on a Coimbatore route is in
// Coimbatore, so the queue said nothing at all. Seen on a device.
// 3. Now the digits are stripped from *inside* the part instead of taking
// the part with them: `12 Peelamedu` yields the name `Peelamedu`.
//
// What separates a locality from a landmark is length in words — names
// are short (Gandhipuram, Peelamedu), landmarks and streets are phrases
// (`Coimbatore Gandhipuram Mofussil Bus Stand`, verified live). And the
// parts are scanned in order because an Indian address runs specific →
// general: the earliest name is the most local one, which is exactly what
// beats `Coimbatore` when both are one-word names.
//
// The word left when a number is removed is sometimes administrative
// noise, not a place — `Ward 54` is not the ward's name — so those
// residues are discarded by a small stop list.
// `Ward 54` is not a place called Ward, and `4 Cross` — Indian street
// numbering — is not a place called Cross. Both from live addresses.
const noise = {
'ward', 'zone', 'no', 'door', 'flat', 'floor', 'plot', 'block', //
'cross', 'main', 'street', 'road',
};
String? nameOf(String part) {
final words = [
for (final w in part.split(RegExp(r'\s+')))
if (!RegExp(r'[0-9]').hasMatch(w)) w,
];
if (words.isEmpty) return null;
// `RS Puram RS Puram` — the geocoder stammer [compactAddress] already
// collapses — reaches here too, seen live as `Gandhipuram Gandhipuram`
// on the queue. A name whose two halves are the same name is one name.
if (words.length.isEven && words.length >= 2) {
final half = words.length ~/ 2;
final a = words.sublist(0, half).join(' ');
final b = words.sublist(half).join(' ');
if (a.toLowerCase() == b.toLowerCase()) {
words.removeRange(half, words.length);
}
}
final name = words.join(' ');
if (words.length == 1 &&
(name.length < 3 || noise.contains(name.toLowerCase()))) {
return null;
}
return name;
}
// ── The city rung is named, because position cannot find it ──
//
// "Drop the last part" was tried and is structurally undecidable: an
// address that ends at the city (`2 Saibaba Colony, Coimbatore`) and one
// that ends at the locality (`12, Cross Cut Road, Gandhipuram`) look
// identical from the end. Both are live addresses, and each broke the
// heuristic the other needed.
//
// So the rung is knowledge, like the noise words above: the region this
// tenant operates in, plus the state/country tails geocoders append. A
// city name distinguishes nothing on a route that is entirely inside it,
// so it ranks last — but it still stands when it is all the address has,
// because an honest rung beats a blank.
bool isCity(String n) => const {
'coimbatore', 'coimbatore north', 'coimbatore south', //
'tamil nadu', 'india', 'chennai', 'tiruppur', 'erode', 'salem',
'madurai', 'pollachi', 'mettupalayam',
}.contains(n.toLowerCase());
// A two-word name ending in a thoroughfare word is a street, not a
// locality — `Mettupalayam Road` must lose to the `RS Puram` behind it,
// exactly as the longer landmark phrases already do.
bool isStreet(String n) => const {
'road', 'street', 'salai', 'lane', 'veedhi', 'highway', //
}.contains(n.split(' ').last.toLowerCase());
final names = [
for (final p in parts)
if (nameOf(p) case final n?) n,
];
final local = [
for (final n in names)
if (!isCity(n)) n,
];
// In rank order: the first one-word name — a locality is a name and
// names are short; then the first short non-street name (RS Puram,
// Saibaba Colony); then any phrase left; and only then the city.
for (final n in local) {
if (!n.contains(' ')) return n;
}
for (final n in local) {
if (n.split(' ').length <= 2 && !isStreet(n)) return n;
}
if (local.isNotEmpty) return local.first;
if (names.isNotEmpty) return names.first;
}
return '';
}
/// A full address, minus the boilerplate a geocoder appends.
///
/// The record page prints the address in full — it is the one screen whose
/// job is the specifics — and on live data that full address arrived as
/// `RS Puram RS Puram, Ward 24, West Zone, Perur, Coimbatore South,
/// Coimbatore, Tamil Nadu, 641002, India`, wrapped to two lines and cut with
/// an ellipsis. The trimmed tail was the useful half; the kept head began by
/// stammering.
///
/// This strips only what carries no information at a door in this region:
/// the state and country rungs, a part that is nothing but a pincode, an
/// immediate word-for-word repeat inside a part, and a part that repeats the
/// one before it. Everything that names a place stays, in order.
String compactAddress(String raw) {
final parts = raw
.split(',')
.map((p) => p.trim())
.where((p) => p.isNotEmpty)
.toList();
const tail = {'tamil nadu', 'india'};
String dedupWords(String part) {
final w = part.split(RegExp(r'\s+'));
if (w.length.isEven && w.length >= 2) {
final half = w.length ~/ 2;
final a = w.sublist(0, half).join(' ');
final b = w.sublist(half).join(' ');
if (a.toLowerCase() == b.toLowerCase()) return a;
}
return part;
}
final kept = <String>[];
for (final p in parts) {
final cleaned = dedupWords(p);
final lower = cleaned.toLowerCase();
if (tail.contains(lower)) continue;
if (RegExp(r'^[0-9]{6}$').hasMatch(cleaned)) continue;
if (kept.isNotEmpty && kept.last.toLowerCase() == lower) continue;
kept.add(cleaned);
}
return kept.join(', ');
}

View File

@@ -0,0 +1,89 @@
/// ─────────────────────────────────────────────────────────────────────────
/// WHO THE RIDER IS RINGING
///
/// One number on a screen is not one fact. `Call` beside a kitchen's name and
/// `Call` beside a customer's name are different promises, and a rider at a
/// door who reaches the kitchen instead has lost the two minutes the control
/// existed to save him.
///
/// ── What the payload actually carries ──
///
/// Verified against production 21 Aug 2026: `GET /miler/bookings` returns
/// exactly **one** phone field, `customerphone`, which the adapter stores as
/// `pickupcontactno` — a key named after the leg it was first read on rather
/// than after whose number it is. There is no separate drop or receiver
/// contact anywhere in the contract.
///
/// So on today's data there is only one number to dial and it belongs to the
/// booking's customer: the sender on a logistics collection, the subscriber on
/// a meal delivery. Dialling it is correct on both legs. What was **not**
/// correct is the app announcing it as "Call customer" while the rider is
/// standing at a kitchen counter.
///
/// This file therefore does two things: it prefers a genuine drop contact the
/// moment the backend ships one — the precedence is written now so that day
/// needs no archaeology — and it names who is being called, per leg, so the
/// label can never drift from the number again.
/// ─────────────────────────────────────────────────────────────────────────
library;
/// A number to dial, and who answers it.
class StopContact {
const StopContact(this.number, this.who);
/// Empty when the stop carries no usable number. Callers omit the control
/// rather than showing one that cannot dial.
final String number;
/// `the kitchen` / `the customer` — used to build the accessible label and
/// any visible caption, so both are produced from one decision.
final String who;
bool get isEmpty => number.isEmpty;
bool get isNotEmpty => number.isNotEmpty;
/// `Call the customer`.
String get action => 'Call $who';
/// Resolves the contact for the leg being worked.
///
/// [delivery] is the leg, not the line: a milk run's collection at a kitchen
/// is a pickup leg even though the line delivers to customers. Ask
/// `MilkRun.navigatesToCustomer` for it rather than deriving it here — one
/// answer to "which leg is this", used by the map, the sheet and this.
static StopContact forLeg(
Map<String, dynamic> stop, {
required bool delivery,
}) {
String read(List<String> keys) {
for (final k in keys) {
final v = (stop[k] ?? '').toString().trim();
if (v.isNotEmpty && v != 'null' && v != '0') return v;
}
return '';
}
// Written for the contract that exists *and* the one that is coming: a
// drop contact wins on a delivery leg the moment one is present, and until
// then the single number the payload carries is used on both.
const dropKeys = [
'dropcontactno',
'DropContactNo',
'deliverycontactno',
'receiverphone',
'dropphone',
];
const pickupKeys = [
'pickupcontactno',
'PickupContactNo',
'contactno',
'customerphone',
];
final number = delivery
? read([...dropKeys, ...pickupKeys])
: read([...pickupKeys, ...dropKeys]);
return StopContact(number, delivery ? 'the customer' : 'the pickup');
}
}

168
lib/data/work_domain.dart Normal file
View File

@@ -0,0 +1,168 @@
import 'package:miler/Models/stop_status.dart';
import 'package:miler/data/milk_run.dart';
import 'package:miler/data/service_profile.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// WHICH SCREEN OWNS AN ORDER
///
/// One rule, in one place, for the question both work screens have to answer
/// about every row of the shared day: **is this Home's or is it Deliveries'?**
///
/// ── Why it is here and not on the screens ──
///
/// It used to be answered twice. Home decided what to draw from
/// [stopStateOf] plus its own local stores; the Deliveries tab decided what to
/// list from a `where` clause inside its fetch, with a second `where` for the
/// statuses that clause was supposed to re-admit. Two expressions of one fact,
/// edited on different days, and they disagreed exactly where it hurts most:
/// **an accepted booking could be claimed by both.** It stayed on Home to be
/// collected, and it appeared on Deliveries as live work with a strip offering
/// to continue it — which opened a map, an I'VE ARRIVED and a confirmation
/// sheet for a collection the rider had not made yet.
///
/// The fix is not a third filter. It is that the classification is one function
/// on the shared data layer, above both screens, so they cannot hold different
/// opinions about where an order belongs.
///
/// ── The boundary ──
///
/// PENDING ─ accept ─▶ ACCEPTED ─ navigate ─▶ ARRIVED ─ pick up ─▶ PICKED
/// └──────────────── HOME owns all of this ──────────────┘ │
/// ▼
/// DELIVERIES owns from here on
///
/// **Picked up is the boundary**, and only the backend can move it. Accepting
/// an assignment is a decision about work still to be done; it is not evidence
/// that anything has been collected. Nothing in this file infers a completed
/// pickup from an acceptance.
///
/// ── The one knob ──
///
/// Where the boundary sits is a property of the rider's line, declared once as
/// [ServiceProfile.handoffAt], and read here rather than re-derived:
///
/// • **A kitchen line** ([HandoffPoint.collected]) hands over at collection.
/// The rider makes one trip to one counter for a stack of bags, so every
/// rung up to picked-up is Home's, in bulk, and Deliveries holds only what
/// is in his hands.
/// • **Logistics** ([HandoffPoint.accepted]) hands over at acceptance. There is
/// no counter and nothing to batch: he drives to one customer, raises the
/// shipment at the door and the collection *is* the job — so an accepted
/// booking is already the work tab's, which is where that flow lives.
///
/// Same rule, one declared knob. Not a line-name check, and not a per-screen
/// conditional. See [ServiceProfile.handoffAt].
/// ─────────────────────────────────────────────────────────────────────────
enum WorkDomain {
/// Home's. Pending, accepted, on the way, arrived — everything before the
/// backend has confirmed the load is aboard.
pickup,
/// The Deliveries tab's. Past the hand-over point for this line.
delivery,
/// Neither work screen's. Finished or withdrawn — Activity's record.
closed,
}
/// The hand-over point between [WorkDomain.pickup] and [WorkDomain.delivery].
abstract final class WorkBoundary {
/// True once the **backend** says the collection is done.
///
/// Two sources, both authoritative, neither of them "the rider accepted it":
///
/// • The status on the row — `Picked_Up` / `Converted_To_Consignment`, or
/// any of the delivery rungs past them.
/// • The collected record, written by Home only after the pickup-complete
/// call has come back successful. It exists because the queue is up to one
/// poll behind: without it a stop the rider has just handed over jumps back
/// to Home for a few seconds, which reads as the confirm having failed.
///
/// It deliberately does **not** consult the accepted store. That set says a
/// decision was made, not that goods changed hands.
static bool pickupComplete(
Map<String, dynamic> stop, {
Set<String> collectedIds = const <String>{},
}) {
final status = stopStatusOf(stop);
if (status.isPicked || status.isDeliveryLeg) return true;
return collectedIds.contains(MilkRun.idOf(stop));
}
/// True when this order is finished or withdrawn, whatever screen it was on.
///
/// [StopStatus.picked] is terminal on a line that ends at the hub and is the
/// middle of the morning on one that does not — so it is asked of the line,
/// through the same knob everything else here reads. See
/// [ServiceProfile.endsAtHub].
static bool isClosed(Map<String, dynamic> stop) {
final status = stopStatusOf(stop);
if (status == StopStatus.delivered || status.isCancelled) return true;
return status.isPicked && ServiceProfile.active.endsAtHub;
}
/// Which screen owns this order.
///
/// [rejectedIds] and a server-side rejection do **not** close an order here:
/// a declined stop stays in the pickup domain because Home is where the rider
/// can change his mind about it. What it must never be is delivery work.
static WorkDomain domainOf(
Map<String, dynamic> stop, {
Set<String> collectedIds = const <String>{},
Set<String> acceptedIds = const <String>{},
}) {
if (isClosed(stop)) return WorkDomain.closed;
final handedOver = switch (ServiceProfile.active.handoffAt) {
HandoffPoint.collected => pickupComplete(
stop,
collectedIds: collectedIds,
),
// On logistics the work tab takes it at acceptance — from either source,
// because the local store leads the queue by a poll.
HandoffPoint.accepted =>
acceptedIds.contains(MilkRun.idOf(stop)) ||
stopStatusOf(stop) == StopStatus.accepted ||
stopStatusOf(stop).isActive ||
stopStatusOf(stop) == StopStatus.arrived,
};
return handedOver ? WorkDomain.delivery : WorkDomain.pickup;
}
/// Everything the Deliveries tab is allowed to hold, out of the shared day.
///
/// The tab's list, the count on Home's pill and anything else that asks "how
/// much is waiting on the other tab?" all read this, so the two screens
/// cannot report different numbers for the same day.
static List<Map<String, dynamic>> deliveryQueue(
Iterable<Map<String, dynamic>> day, {
Set<String> collectedIds = const <String>{},
Set<String> acceptedIds = const <String>{},
}) => [
for (final stop in day)
if (domainOf(
stop,
collectedIds: collectedIds,
acceptedIds: acceptedIds,
) ==
WorkDomain.delivery)
stop,
];
/// Everything Home is still responsible for, out of the shared day.
static List<Map<String, dynamic>> pickupQueue(
Iterable<Map<String, dynamic>> day, {
Set<String> collectedIds = const <String>{},
Set<String> acceptedIds = const <String>{},
}) => [
for (final stop in day)
if (domainOf(
stop,
collectedIds: collectedIds,
acceptedIds: acceptedIds,
) ==
WorkDomain.pickup)
stop,
];
}

View File

@@ -0,0 +1,321 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:miler/Models/stop_status.dart';
import 'package:miler/data/api_config.dart';
import 'package:miler/data/route_order.dart';
import 'package:miler/data/order_events.dart';
import 'package:miler/data/assignment_lookup.dart';
import 'package:miler/data/load_state.dart';
import 'package:miler/data/meal_run_mock.dart';
import 'package:miler/data/miler_api.dart';
import 'package:miler/data/service_profile.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// ONE DAY'S WORK, FETCHED ONCE
///
/// Home, Deliveries and Activity all answer questions about the same set of
/// bookings, and each of them fetched it independently: three timers, three
/// copies of the list, three ideas of what had been accepted. Switching tabs
/// re-fetched. A rebuild re-fetched. Accepting a stop on Home updated Home's
/// copy and left the other two showing yesterday's answer until their own poll
/// came round.
///
/// This is the one copy. The screens read [state] and listen to [changes]; none
/// of them fetches.
///
/// ── Three things it does that a plain `Future` does not ──
///
/// * **Collapses concurrent callers.** Three screens mounting in the same frame
/// produce one request, and all three get its result. The second and third
/// callers await the *same* future rather than starting their own.
/// * **Refuses stale writes.** Every load carries a sequence number, and a
/// response whose sequence is behind the newest one is dropped. A slow
/// refresh that lands after a fast one can no longer overwrite it — the
/// classic "pull to refresh, then the old response arrives and the list goes
/// backwards" bug.
/// * **Distinguishes the three empties.** Nothing today, could not ask, and no
/// endpoint for this line are different answers — see [LoadState].
///
/// ── What it deliberately does not do ──
///
/// No polling of its own, no retry loop, no cache written to disk. The screens
/// own when to ask; this owns making sure asking twice costs one request.
/// ─────────────────────────────────────────────────────────────────────────
class WorkRepository {
WorkRepository._();
/// The app's instance. A plain static rather than a DI registration, matching
/// how [ServiceProfile] and the stores in this layer are reached — data code
/// here must not need a widget tree to exist.
static final WorkRepository instance = WorkRepository._();
final _controller =
StreamController<LoadState<List<Map<String, dynamic>>>>.broadcast();
LoadState<List<Map<String, dynamic>>> _state =
const LoadLoading<List<Map<String, dynamic>>>();
/// The current answer. Safe to read during `build`.
LoadState<List<Map<String, dynamic>>> get state => _state;
/// Every change, for screens that want to rebuild without polling.
Stream<LoadState<List<Map<String, dynamic>>>> get changes =>
_controller.stream;
/// The request in flight, if any. A second caller awaits this rather than
/// starting a second request.
Future<LoadState<List<Map<String, dynamic>>>>? _inFlight;
/// Monotonic, so a late response can be recognised as late.
int _sequence = 0;
/// How long a result stays fresh enough to hand to a new caller without
/// asking again. Covers the case this exists for: three screens mounting
/// within a frame of each other, and a tab switch a second later.
static const Duration freshFor = Duration(seconds: 10);
DateTime? _loadedAt;
bool get _isFresh {
final at = _loadedAt;
return at != null && DateTime.now().difference(at) < freshFor;
}
/// Loads the day, or hands back what is already loading or already fresh.
///
/// [force] skips the freshness check — pull-to-refresh — but still collapses
/// into an in-flight request rather than racing it.
Future<LoadState<List<Map<String, dynamic>>>> load({bool force = false}) {
// ── Collapse, unless the rider asked ──
//
// An *incidental* load — a screen mounting, a tab switch, a rebuild —
// joins whatever is already running. That is the whole reason this exists,
// and it is what stops three screens producing three requests.
//
// A **forced** load does not, because a pull-to-refresh that quietly hands
// back the result of a request started before the rider's gesture is not a
// refresh. It starts its own, and the sequence guard in [_publish] drops
// whichever response lands out of order.
final existing = _inFlight;
if (existing != null && !force) return existing;
if (!force && _isFresh && _state is LoadData) {
return Future.value(_state);
}
final future = _fetch();
_inFlight = future;
return future.whenComplete(() {
if (identical(_inFlight, future)) _inFlight = null;
});
}
/// Marks the current answer stale and re-reads it.
///
/// Called after every mutation the server has confirmed, so the screens take
/// their new state from the API rather than from the button that was pressed.
Future<LoadState<List<Map<String, dynamic>>>> invalidate() {
_loadedAt = null;
return load(force: true);
}
Future<LoadState<List<Map<String, dynamic>>>> _fetch() async {
final seq = ++_sequence;
// Keep whatever is on screen visible while the refresh runs. A pull to
// refresh must not blank the list it is refreshing.
final current = _state;
_publish(
current is LoadData<List<Map<String, dynamic>>>
? LoadData(current.value, refreshing: true)
: const LoadLoading<List<Map<String, dynamic>>>(),
seq,
);
// The opt-in development fixture, checked first and named as such. See
// [MealRunMock]; it is unreachable unless somebody passed MOCK_BACKEND.
if (MealRunMock.active) {
final day = MealRunMock.stops().cast<Map<String, dynamic>>();
debugPrint('[WORK] OPT-IN FIXTURE, not the API: ${day.length} stops');
return _publish(
day.isEmpty
? const LoadEmpty<List<Map<String, dynamic>>>()
: LoadData(day),
seq,
);
}
// No fixture and no endpoint. Distinct from an empty day, because neither
// waiting nor retrying will change it.
if (!ServiceProfile.active.hasBookingsEndpoint) {
return _publish(
LoadUnavailable<List<Map<String, dynamic>>>(
'${ServiceProfile.active.label} work is not served by this backend '
'yet.',
),
seq,
);
}
final res = await MilerApi.bookings();
if (!res.ok) {
return _publish(
LoadFailure<List<Map<String, dynamic>>>(
_classify(res),
message: res.message,
),
seq,
);
}
final mapped = ApiConfig.pickupsFromBookings(res.list);
// ── The hub's solved order, stamped on here and nowhere else ──
//
// `step` now ships on the booking row itself (verified live 21 Aug 2026),
// so the common path is that this finds nothing to do — which is the
// intended end state. It stays because it is the delivery leg's safety
// net: the assignment row is the field's original home, and a booking that
// arrives without one but has a live assignment carrying it must not lose
// the hub's order. **A `step` already on the payload always wins.**
//
// Merged at the repository rather than on a screen, because all three
// screens order by it and a stop that is third on Home must not be second
// on Deliveries.
//
// Best-effort, and deliberately so: the flow contract says an optimizer
// outage leaves work *assigned but unordered*, never undone. A failure here
// leaves every stop as it arrived, and [RouteOrder] then falls back — and
// says that it has, rather than passing its own guess off as the route.
await _stampSequence(mapped);
if (mapped.isEmpty && res.list.isNotEmpty) {
// Rows arrived and none survived translation — a field-name mismatch,
// not an empty day. Said out loud rather than rendered as "no work".
ApiConfig.logGap(
'pickupFromBooking',
'${res.list.length} bookings returned but none mapped — check the '
'field names against a real payload.',
);
return _publish(
const LoadFailure<List<Map<String, dynamic>>>(
LoadFailureKind.server,
message: 'The hub sent work this app could not read.',
),
seq,
);
}
// ── When the work reached this rider ──
//
// The contract has no assignment timestamp: `GET /miler/assignments`
// returns the rows and no clock on them, and the booking's own `updatedat`
// moves every time anything touches it — the console warns against reading
// it as an assignment time for exactly that reason.
//
// What the app can say truthfully is when a booking *first appeared in this
// rider's queue*, which is the moment it became his. Stamped here because
// this is the one place every screen's day comes through, and stamped once:
// [stampOrderEvent] never overwrites, so a poll a second later cannot move
// it. Read only by the Activity timeline; nothing decides anything on it.
//
// **Only work still in front of him.** Stamping every row the fetch carries
// put an `Assigned 3:34 PM` on a stop that had been delivered at 11:57 that
// morning: the ledger did not exist when the work arrived, so the first
// sighting was the app being opened in the afternoon. A clock that lands
// after the completion it is supposed to precede is worse than no clock —
// it is the timeline contradicting itself in the rider's face. Anything the
// backend already reports as past his hands is skipped, and stays skipped
// forever because [stampOrderEvent] never overwrites.
unawaited(
stampOrderEvents(
mapped
.where((s) {
final st = stopStatusOf(s);
return !st.isWorkComplete &&
!st.isCancelled &&
!st.isRejected &&
!st.isSkipped &&
!st.isPicked &&
!st.isDeliveryLeg;
})
.map((s) => (s['orderid'] ?? '').toString())
.where((id) => id.isNotEmpty),
OrderEvent.assigned,
),
);
_loadedAt = DateTime.now();
return _publish(
mapped.isEmpty
? const LoadEmpty<List<Map<String, dynamic>>>()
: LoadData(mapped),
seq,
);
}
/// Writes the assignment sequence onto the day's stops, in place.
Future<void> _stampSequence(List<Map<String, dynamic>> day) async {
if (day.isEmpty) return;
try {
final steps = await AssignmentLookup.steps();
if (steps.isEmpty) return;
var stamped = 0;
for (final stop in day) {
// Read through [RouteOrder] so "already sequenced" means the same
// thing here as it does at every screen that orders by it.
if (RouteOrder.sequenceOf(stop) > 0) continue;
final key = (stop['bookingid'] ?? stop['orderheaderid'] ?? '')
.toString()
.trim();
final step = steps[key];
if (step == null) continue;
stop['step'] = step;
stamped++;
}
debugPrint('[WORK] sequenced $stamped/${day.length} stops from the hub');
final unsequenced = day
.where((s) => RouteOrder.sequenceOf(s) == 0)
.length;
RouteOrder.logUnsequenced('work-repository', unsequenced);
} catch (e) {
// Never fails the day's work: an unordered route is workable, a missing
// one is not.
debugPrint('[WORK] could not read the assignment sequence: $e');
}
}
/// Turns a refused call into the thing the rider does about it.
static LoadFailureKind _classify(ApiResult res) => switch (res.status) {
0 => LoadFailureKind.offline,
401 || 403 => LoadFailureKind.unauthorized,
429 => LoadFailureKind.rateLimited,
_ => LoadFailureKind.server,
};
/// Publishes [next] unless a newer load has already started.
LoadState<List<Map<String, dynamic>>> _publish(
LoadState<List<Map<String, dynamic>>> next,
int seq,
) {
if (seq < _sequence) {
// A slower earlier request finishing after a newer one. Dropping it is
// the whole reason the sequence exists.
debugPrint('[WORK] dropped stale response #$seq (newest is $_sequence)');
return _state;
}
_state = next;
if (!_controller.isClosed) _controller.add(next);
return next;
}
/// Test seam. Returns the repository to the state a fresh launch has.
@visibleForTesting
void resetForTest() {
_state = const LoadLoading<List<Map<String, dynamic>>>();
_inFlight = null;
_sequence = 0;
_loadedAt = null;
}
}

202
lib/data/work_scope.dart Normal file
View File

@@ -0,0 +1,202 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/data/api_config.dart';
import 'package:miler/data/service_profile.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// WHO A RECORD BELONGS TO
///
/// Miler runs two operations off one app and one login — a milk-man round and
/// a logistics day — and it stores finished work, skipped work, carried bags
/// and released orders in SharedPreferences. Every one of those keys was
/// **global**: `completed_bookings`, `skipped_bookings`,
/// `collected_order_ids`, `out_for_delivery_order_ids`. One phone, one key,
/// whoever wrote last.
///
/// That is three leaks in one:
///
/// • **Rider → rider.** Log out, log in as somebody else, and yesterday's
/// completed stops are sitting in the new rider's Activity.
/// • **Line → line.** A tenant switch moves the app from a round to a
/// logistics day; the milk-run's delivered lunches stayed behind in the
/// logistics history.
/// • **Tenant → tenant.** Same shape, one level up.
///
/// ── Why identity and not a label ──
///
/// The tempting fix is to filter Activity on something visible — a kitchen
/// name, the word "Milk", the tab's title. All of those are *display strings*:
/// they are localisable, they are chosen by hub staff, and two tenants can
/// legitimately use the same one. Ownership has to come from identity the
/// session actually proves:
///
/// **rider** `userid` — from the login response, held in prefs
/// **tenant** `tenantid` — from the JWT claim, signed by the server
/// **line** [ServiceLine] — the operation the profile resolves to
///
/// A [WorkScope] is those three together, and it is the only thing allowed to
/// decide which records a screen may see.
/// ─────────────────────────────────────────────────────────────────────────
@immutable
class WorkScope {
final int userId;
final int tenantId;
final ServiceLine line;
const WorkScope({
required this.userId,
required this.tenantId,
required this.line,
});
/// The scope of the session running right now.
///
/// Reads the rider from prefs and the tenant from the token's claim — the
/// same two sources the rest of the app authenticates with — and takes the
/// line from the resolved profile. Never throws: an unreadable session
/// yields the [anonymous] scope, whose records are visible to nobody but
/// itself.
static Future<WorkScope> current() async {
try {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.get('userid');
final userId = raw is int
? raw
: int.tryParse(raw?.toString() ?? '') ?? 0;
final tenantId = await ApiConfig.storedTenantId();
return WorkScope(
userId: userId,
tenantId: tenantId,
line: ServiceProfile.active.line,
);
} catch (e) {
debugPrint('[SCOPE] could not resolve the session scope: $e');
return WorkScope(
userId: 0,
tenantId: 0,
line: ServiceProfile.active.line,
);
}
}
/// A signed-out or unreadable session. Deliberately still a real scope
/// rather than null: code paths that run before login write to their own
/// drawer instead of into the last rider's.
static WorkScope get anonymous =>
WorkScope(userId: 0, tenantId: 0, line: ServiceProfile.active.line);
/// The suffix that turns a store key into this scope's key.
///
/// `completed_bookings` → `completed_bookings::u38.t13.milkMan`
///
/// All three parts are in it because all three can change independently: a
/// rider can move tenant, a tenant can run either line, and one device can
/// see several riders.
String get key => 'u$userId.t$tenantId.${line.name}';
/// Scopes a legacy global key.
String scoped(String baseKey) => '$baseKey::$key';
/// Does [record] provably belong to this scope?
///
/// Used on rows that were written before scoping existed, and as a
/// belt-and-braces check on rows read back from a scoped key. A row proves
/// ownership by carrying the identity itself — `mileruserid` / `userid` and
/// `tenantid` are what the API stamps on assignment and consignment rows.
///
/// **Absence is not proof.** A row with no identity on it returns false: it
/// might be this rider's and it might be the last one's, and the only safe
/// reading of "might" is no.
bool owns(Map<String, dynamic> record) {
int intOf(List<String> keys) {
for (final k in keys) {
final v = record[k];
if (v == null) continue;
final n = v is int ? v : int.tryParse(v.toString());
if (n != null && n != 0) return n;
}
return 0;
}
final rowUser = intOf(const [
'mileruserid',
'MilerUserId',
'assignedmileruserid',
'userid',
'scopeuserid',
]);
final rowTenant = intOf(const ['tenantid', 'TenantId', 'scopetenantid']);
final rowLine = (record['scopeline'] ?? '').toString();
if (rowUser == 0 && rowTenant == 0 && rowLine.isEmpty) return false;
if (rowUser != 0 && rowUser != userId) return false;
if (rowTenant != 0 && rowTenant != tenantId) return false;
if (rowLine.isNotEmpty && rowLine != line.name) return false;
return true;
}
/// Does [record] prove it belongs to **another** scope?
///
/// The mirror of [owns], and deliberately not its negation — they answer
/// different questions and treat silence differently:
///
/// [owns] "prove this is mine" — no identity ⇒ **false** (drop).
/// Used on legacy rows, where attributing the unattributable
/// is the leak itself.
/// [excludes] "prove this is someone
/// else's" — no identity ⇒ **false** (keep).
/// Used on rows the API just returned for the authenticated
/// session, which are already the rider's by construction and
/// mostly carry no identity of their own. Dropping those on
/// silence would empty the tab.
bool excludes(Map<String, dynamic> record) {
int intOf(List<String> keys) {
for (final k in keys) {
final v = record[k];
if (v == null) continue;
final n = v is int ? v : int.tryParse(v.toString());
if (n != null && n != 0) return n;
}
return 0;
}
final rowUser = intOf(const [
'mileruserid',
'MilerUserId',
'assignedmileruserid',
'scopeuserid',
]);
final rowTenant = intOf(const ['tenantid', 'TenantId', 'scopetenantid']);
final rowLine = (record['scopeline'] ?? '').toString();
if (rowUser != 0 && userId != 0 && rowUser != userId) return true;
if (rowTenant != 0 && tenantId != 0 && rowTenant != tenantId) return true;
if (rowLine.isNotEmpty && rowLine != line.name) return true;
return false;
}
/// Stamps [record] so a later read can prove ownership without a session.
///
/// Written at the moment a record is stored, which is the one moment the
/// scope is known for certain.
Map<String, dynamic> stamp(Map<String, dynamic> record) => {
...record,
'scopeuserid': userId,
'scopetenantid': tenantId,
'scopeline': line.name,
};
@override
bool operator ==(Object other) =>
other is WorkScope &&
other.userId == userId &&
other.tenantId == tenantId &&
other.line == line;
@override
int get hashCode => Object.hash(userId, tenantId, line);
@override
String toString() => 'WorkScope($key)';
}