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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
270
lib/data/api_status.dart
Normal 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;
|
||||
}
|
||||
@@ -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
124
lib/data/bag_manifest.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
316
lib/data/consignment_state.dart
Normal file
316
lib/data/consignment_state.dart
Normal 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
276
lib/data/lifecycle.dart
Normal 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
100
lib/data/load_state.dart
Normal 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
358
lib/data/meal_run_mock.dart
Normal 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
441
lib/data/milk_run.dart
Normal 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
182
lib/data/mock_backend.dart
Normal 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() : '';
|
||||
}
|
||||
70
lib/data/mutation_guard.dart
Normal file
70
lib/data/mutation_guard.dart
Normal 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
200
lib/data/order_events.dart
Normal 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
160
lib/data/proof_store.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
218
lib/data/route_order.dart
Normal 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
95
lib/data/service_day.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
700
lib/data/service_profile.dart
Normal file
700
lib/data/service_profile.dart
Normal 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
203
lib/data/stop_area.dart
Normal 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(', ');
|
||||
}
|
||||
89
lib/data/stop_contact.dart
Normal file
89
lib/data/stop_contact.dart
Normal 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
168
lib/data/work_domain.dart
Normal 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,
|
||||
];
|
||||
}
|
||||
321
lib/data/work_repository.dart
Normal file
321
lib/data/work_repository.dart
Normal 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
202
lib/data/work_scope.dart
Normal 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)';
|
||||
}
|
||||
Reference in New Issue
Block a user