device infos
This commit is contained in:
@@ -71,7 +71,7 @@ Future<void> migrateLegacyStores() async {
|
||||
_kConsignmentIdsKeyBase,
|
||||
_kOutForDeliveryKeyBase,
|
||||
_kNotLoadedKeyBase,
|
||||
_kBagLabelsKeyBase,
|
||||
_kOrderLabelsKeyBase,
|
||||
]) {
|
||||
await _drainLegacy(base, scope);
|
||||
}
|
||||
@@ -117,7 +117,7 @@ Future<void> clearScopedStores() async {
|
||||
_kConsignmentIdsKeyBase,
|
||||
_kOutForDeliveryKeyBase,
|
||||
_kNotLoadedKeyBase,
|
||||
_kBagLabelsKeyBase,
|
||||
_kOrderLabelsKeyBase,
|
||||
]) {
|
||||
await prefs.remove(scope.scoped(base));
|
||||
await prefs.remove(base);
|
||||
@@ -296,6 +296,26 @@ String kStopStartedKey(Object pickupId) => 'stop_started_$pickupId';
|
||||
/// Written by the stop map screen when it starts the pickup.
|
||||
String kStopArrivedKey(Object pickupId) => 'pickup_start_$pickupId';
|
||||
|
||||
/// Prefs key holding the kilometres the app measured for a stop as it closed.
|
||||
///
|
||||
/// ── The app measured this, posted it, and kept no copy ──
|
||||
///
|
||||
/// `PickupsController.updateToPicked` works out how far the rider came —
|
||||
/// `Geolocator.distanceBetween`, rider fix to pickup point — and sends it as
|
||||
/// `actualkms`. Then it dropped it. The number existed for the length of one
|
||||
/// HTTP request.
|
||||
///
|
||||
/// Which is why Activity's `km ridden` read an em dash on a day the rider had
|
||||
/// plainly ridden: [StopCompliance] looks for `actualkms` on the row, the row
|
||||
/// never carried one, and `GET /miler/bookings` does not reliably send the
|
||||
/// distance back. The app was the only thing that knew, and it forgot.
|
||||
///
|
||||
/// So it is written here as it is posted, and [addCompletedBookings] merges it
|
||||
/// onto the record it files — the same rescue it already performs for the two
|
||||
/// clocks beside it, for the same reason: the moment the fact is true is the
|
||||
/// moment to write it down.
|
||||
String kStopKmKey(Object pickupId) => 'stop_km_$pickupId';
|
||||
|
||||
/// Record a finished stop. [cancelled] separates "could not complete" from a
|
||||
/// clean pickup/delivery — Activity shows both, worded differently.
|
||||
///
|
||||
@@ -362,10 +382,18 @@ Future<void> addCompletedBookings(
|
||||
final arrived = prefs.getString(kStopArrivedKey(pickupId));
|
||||
if (started != null && started.isNotEmpty) copy['startedat'] = started;
|
||||
if (arrived != null && arrived.isNotEmpty) copy['arrivedat'] = arrived;
|
||||
// The distance the app measured as it closed this stop — see
|
||||
// [kStopKmKey]. A figure already on the row wins: that one came from the
|
||||
// hub, and this is the app's own measurement standing in for it.
|
||||
final km = prefs.getString(kStopKmKey(pickupId));
|
||||
if (km != null && km.isNotEmpty && (copy['actualkms'] ?? '') == '') {
|
||||
copy['actualkms'] = km;
|
||||
}
|
||||
// Cleared, or a stop worked twice in a day (a resumed skip) reports the
|
||||
// first attempt's clock against the second attempt's completion.
|
||||
await prefs.remove(kStopStartedKey(pickupId));
|
||||
await prefs.remove(kStopArrivedKey(pickupId));
|
||||
await prefs.remove(kStopKmKey(pickupId));
|
||||
}
|
||||
|
||||
// Stamped with the session that produced it, so a row read back later
|
||||
@@ -795,26 +823,36 @@ Future<void> addNotLoadedOrderIds(List<String> ids) async {
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// BAG IDENTITY — which bag an order travels in, for as long as it travels
|
||||
// THE COUNTER'S OWN LABEL — carried for as long as the order 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.
|
||||
// A kitchen that prints labels prints them once, at the counter, and the label
|
||||
// is what the rider matches against the object in his hands for the rest of the
|
||||
// day. Recomputing anything from whatever list happens to be on screen is how
|
||||
// identity breaks — deliver one order and a position-derived label silently
|
||||
// moves to another, sending the rider looking for something that is already 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.
|
||||
// So the pairing is fixed once, at the moment the manifest is confirmed, and
|
||||
// read back unchanged through delivery, skip, completion and a day of
|
||||
// intermittent signal. See [OrderManifest.labelFor], which reads it off the
|
||||
// payload; this only remembers what was read.
|
||||
//
|
||||
// ── It is empty on most routes now, and that is correct ──
|
||||
//
|
||||
// This used to hold `Bag 1 … Bag n`, manufactured from each order's position
|
||||
// when the payload printed nothing — so every route stored a set of references
|
||||
// no counter had ever issued. Only a label the kitchen actually printed is
|
||||
// stored now, which on a route that prints none means nothing is stored at all
|
||||
// and every screen simply shows no tag.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
const String _kBagLabelsKeyBase = 'order_bag_labels';
|
||||
const String _kOrderLabelsKeyBase = '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 {
|
||||
/// Remembers the label a counter printed on each order. Merges, never
|
||||
/// replaces: a rider works two kitchens and the second load must not erase the
|
||||
/// first. Orders with no printed label contribute nothing.
|
||||
Future<void> saveOrderLabels(Map<String, String> byOrderId) async {
|
||||
final clean = {
|
||||
for (final e in byOrderId.entries)
|
||||
if (e.key.trim().isNotEmpty && e.value.trim().isNotEmpty)
|
||||
@@ -823,19 +861,20 @@ Future<void> saveBagLabels(Map<String, String> byOrderId) async {
|
||||
if (clean.isEmpty) return;
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final merged = {...await getBagLabels(), ...clean};
|
||||
final merged = {...await getOrderLabels(), ...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), [
|
||||
// NUL separator cannot collide with an order id or a printed label.
|
||||
await prefs.setStringList(await _scopedKey(_kOrderLabelsKeyBase), [
|
||||
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 {
|
||||
/// The label each order carries, as recorded at pickup. Empty before any load,
|
||||
/// and empty all day on a route whose counters print nothing.
|
||||
Future<Map<String, String>> getOrderLabels() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final rows =
|
||||
prefs.getStringList(await _scopedKey(_kBagLabelsKeyBase)) ??
|
||||
prefs.getStringList(await _scopedKey(_kOrderLabelsKeyBase)) ??
|
||||
const <String>[];
|
||||
return {
|
||||
for (final row in rows)
|
||||
@@ -854,7 +893,7 @@ Future<void> clearServiceRunState() async {
|
||||
await prefs.remove(await _scopedKey(_kCollectedOrderIdsKeyBase));
|
||||
await prefs.remove(await _scopedKey(_kOutForDeliveryKeyBase));
|
||||
await prefs.remove(await _scopedKey(_kNotLoadedKeyBase));
|
||||
await prefs.remove(await _scopedKey(_kBagLabelsKeyBase));
|
||||
await prefs.remove(await _scopedKey(_kOrderLabelsKeyBase));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -933,16 +972,16 @@ Future<int> purgeDemoRecords() async {
|
||||
// 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)) ??
|
||||
final labels =
|
||||
prefs.getStringList(await _scopedKey(_kOrderLabelsKeyBase)) ??
|
||||
const <String>[];
|
||||
if (bags.isNotEmpty) {
|
||||
final kept = bags
|
||||
if (labels.isNotEmpty) {
|
||||
final kept = labels
|
||||
.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 (kept.length != labels.length) {
|
||||
dropped += labels.length - kept.length;
|
||||
await prefs.setStringList(await _scopedKey(_kOrderLabelsKeyBase), kept);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -524,9 +524,92 @@ class ApiConfig {
|
||||
'parcels': booking['parcels'] ?? const [],
|
||||
'starttime': s(booking['createdat']),
|
||||
'eta': s(booking['eta']),
|
||||
|
||||
// ── The row's own clocks, carried verbatim ──
|
||||
//
|
||||
// This adapter builds a **fixed map**, so a field it does not name is a
|
||||
// field the app can never see — the same trap `step` and `stoptype` were
|
||||
// in. Every timestamp on the booking was in that trap, and the cost was
|
||||
// not cosmetic: [ServiceDay] dates a row by exactly these key names, so
|
||||
// an adapted row carried nothing to date it by and *every* screen that
|
||||
// asks "does this belong to today" had to answer "cannot tell".
|
||||
//
|
||||
// That is how yesterday's assignments stayed on Home. The filter was
|
||||
// never missing so much as starved: it was asking a question of fields
|
||||
// this method had already thrown away.
|
||||
//
|
||||
// Copied under the names the wire uses, with no reshaping and no
|
||||
// parsing. [ServiceDay] and [parseStamp] own the reading of them —
|
||||
// Doormile sends IST wall-clock in naive columns, sometimes with a
|
||||
// trailing `Z` it never had, and the one place that knows that should
|
||||
// stay the one place that knows it.
|
||||
for (final k in timestampFieldNames)
|
||||
if (booking[k] != null && booking[k].toString().trim().isNotEmpty)
|
||||
k: booking[k],
|
||||
|
||||
// ── And the row's distances, for the same reason ──
|
||||
//
|
||||
// `cumulativekms` was named above and the rest were not, so the whole
|
||||
// distance vocabulary was dropped here: `kms` (what the hub planned),
|
||||
// `riderkms` (what the rider actually covered) and the `compliance` block
|
||||
// the contract carries them in.
|
||||
//
|
||||
// [StopCompliance] reads exactly those names, so it answered `null` for
|
||||
// every adapted row — and Activity's `km ridden` totalled zero all day
|
||||
// and printed an em dash. Same failure as the timestamps, on a different
|
||||
// set of fields: a fixed map cannot pass on what it does not name.
|
||||
for (final k in distanceFieldNames)
|
||||
if (booking[k] != null && booking[k].toString().trim().isNotEmpty)
|
||||
k: booking[k],
|
||||
};
|
||||
}
|
||||
|
||||
/// Every distance key a booking row is known to carry, copied through
|
||||
/// [pickupFromBooking] untouched.
|
||||
///
|
||||
/// Kept in step with [StopCompliance], which is what reads them. `compliance`
|
||||
/// is a nested object rather than a number and is passed on whole — this
|
||||
/// adapter's job is to stop losing fields, not to reshape them.
|
||||
static const List<String> distanceFieldNames = [
|
||||
// What the hub planned for this stop.
|
||||
'kms',
|
||||
'km',
|
||||
// What the rider actually covered. `riderkms` is the backend's name;
|
||||
// `actualkms` is the name this app posts on its own pickup write.
|
||||
'riderkms',
|
||||
'actualkms',
|
||||
// The contract's block, carrying both plus the on-time verdict.
|
||||
'compliance',
|
||||
];
|
||||
|
||||
/// Every timestamp key a booking row is known to carry, copied through
|
||||
/// [pickupFromBooking] untouched.
|
||||
///
|
||||
/// Deliberately a superset of what any one endpoint sends: a name that is
|
||||
/// absent costs one map lookup, and a name that is missing costs a screen
|
||||
/// its ability to tell today from yesterday. Kept in step with
|
||||
/// `ServiceDay.timeKeys`, which is what reads them.
|
||||
static const List<String> timestampFieldNames = [
|
||||
'createdat',
|
||||
'createdon',
|
||||
'updatedat',
|
||||
'updatedon',
|
||||
'modifiedon',
|
||||
'pickedtime',
|
||||
'picked_time',
|
||||
'deliverytime',
|
||||
'deliveredat',
|
||||
'completedat',
|
||||
'expected_pickup_time',
|
||||
'expectedpickuptime',
|
||||
'slotstarttime',
|
||||
'slotendtime',
|
||||
'slotfrom',
|
||||
'slotto',
|
||||
'assignedat',
|
||||
'assignedon',
|
||||
];
|
||||
|
||||
static List<Map<String, dynamic>> pickupsFromBookings(dynamic data) {
|
||||
if (data is List) {
|
||||
return data.whereType<Map>().map((b) => pickupFromBooking(b)).toList();
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -176,8 +176,100 @@ extension ConsignmentStateX on ConsignmentState {
|
||||
/// 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;
|
||||
|
||||
/// Why **Start ride** could not set off, in words a rider can act on — or
|
||||
/// null when this state is not a reason to refuse one.
|
||||
///
|
||||
/// ── The screen was reading the state machine out loud ──
|
||||
///
|
||||
/// When `start-delivery` refuses, the backend answers with an assertion
|
||||
/// about its own model: *"consignment is Inwarded_At_Hub, not
|
||||
/// Collected_By_Miler"*. That sentence was passed through to the rider
|
||||
/// verbatim, on the reasoning that the server's own words beat a guess — and
|
||||
/// that reasoning is right about *whose* answer it is and wrong about *what
|
||||
/// kind of sentence* it is. `Inwarded_At_Hub` is a database enum. It is not
|
||||
/// something a man holding a phone at a kitchen counter can do anything
|
||||
/// with, and he had no way to tell it from a bug.
|
||||
///
|
||||
/// So the state is translated here, in the file that owns the vocabulary,
|
||||
/// exactly once. Every sentence answers the only two questions he has: is
|
||||
/// this mine, and is sliding again going to help.
|
||||
///
|
||||
/// Null for [outForDelivery], [collectedByMiler] and [unknown] — the first
|
||||
/// two are not refusals at all and the third is not an answer, so the caller
|
||||
/// falls back rather than inventing a cause.
|
||||
String? get startRefusalSentence => switch (this) {
|
||||
// Converted and not yet routed. The one refusal here that a second slide
|
||||
// in a minute genuinely can clear.
|
||||
ConsignmentState.created =>
|
||||
'Your office is still setting this one up. Give it a moment and slide '
|
||||
'again.',
|
||||
// The logistics network has it. Said without naming a building the rider
|
||||
// has never been to — what matters is that it is not his round.
|
||||
ConsignmentState.inwardedAtHub ||
|
||||
ConsignmentState.tripsheetLoaded ||
|
||||
ConsignmentState.inTransit =>
|
||||
"This one has already been handed on — it's not yours to deliver.",
|
||||
ConsignmentState.delivered =>
|
||||
'This one has already been delivered. Nothing left to start.',
|
||||
ConsignmentState.rtoInitiated || ConsignmentState.returnedToSender =>
|
||||
'This one is going back to the sender, so there is no delivery to '
|
||||
'start.',
|
||||
ConsignmentState.missing || ConsignmentState.damaged =>
|
||||
'Your office has flagged a problem with this parcel. They have to clear '
|
||||
'it before it can go out.',
|
||||
ConsignmentState.cancelled =>
|
||||
'This one has been cancelled. There is nothing to deliver.',
|
||||
ConsignmentState.outForDelivery ||
|
||||
ConsignmentState.collectedByMiler ||
|
||||
ConsignmentState.unknown => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// The consignment state a **start-delivery** refusal names, or
|
||||
/// [ConsignmentState.unknown] when it names none this build knows.
|
||||
///
|
||||
/// ── Reading a state out of a sentence, safely ──
|
||||
///
|
||||
/// Parsing prose is normally a bad idea, and this is the case where it is not,
|
||||
/// because it does not depend on the prose. `POST
|
||||
/// /miler/consignments/:id/start-delivery` accepts **exactly one** state —
|
||||
/// `Collected_By_Miler`. So of the state names a refusal from that endpoint
|
||||
/// mentions, the only one that cannot be describing where the consignment
|
||||
/// actually *is* is the one that would have been accepted. Drop that, and
|
||||
/// whatever is left is the answer — whichever order the words come in, and
|
||||
/// however the sentence is later reworded.
|
||||
///
|
||||
/// This exists because the state read can fail twice: the row need not carry
|
||||
/// `consignmentstatus`, and `GET /miler/consignments/:id` can 404 on an older
|
||||
/// deployment. In that case the refusal itself is the only thing that knows
|
||||
/// what happened, and throwing it away costs the rider a real answer.
|
||||
///
|
||||
/// Matches only the wire's own shape — capitalised words joined by
|
||||
/// underscores — so an ordinary sentence yields nothing.
|
||||
ConsignmentState consignmentStateNamedInRefusal(String message) {
|
||||
if (message.isEmpty) return ConsignmentState.unknown;
|
||||
for (final m in RegExp(
|
||||
r'[A-Za-z]+(?:_[A-Za-z]+)+',
|
||||
).allMatches(message)) {
|
||||
final state = consignmentStateFromRaw(m.group(0));
|
||||
if (state == ConsignmentState.unknown) continue;
|
||||
// The state the endpoint requires, not the state it found.
|
||||
if (state == ConsignmentState.collectedByMiler) continue;
|
||||
return state;
|
||||
}
|
||||
return ConsignmentState.unknown;
|
||||
}
|
||||
|
||||
/// True when [message] is the backend talking to itself.
|
||||
///
|
||||
/// A refusal carrying a wire enum — `Inwarded_At_Hub`, `Out_for_Delivery` — is
|
||||
/// a state-machine assertion, not a sentence for a rider, and it must never
|
||||
/// reach a screen. Anything else the server says is prose written for a person
|
||||
/// and is worth more than a generic apology, so it is still passed through.
|
||||
bool namesWireState(String message) =>
|
||||
RegExp(r'[A-Za-z]+(?:_[A-Za-z]+)+').hasMatch(message);
|
||||
|
||||
/// 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 {
|
||||
|
||||
133
lib/data/device_telemetry.dart
Normal file
133
lib/data/device_telemetry.dart
Normal file
@@ -0,0 +1,133 @@
|
||||
import 'package:battery_plus/battery_plus.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// WHAT THE CONSOLE NEEDS TO SEE ABOUT THE HANDSET
|
||||
///
|
||||
/// The dispatcher watching a rider is not only asking *where is he*. When a
|
||||
/// rider stops reporting, the question is **why** — and the answers are all
|
||||
/// facts about the phone: the battery died, the signal dropped, he turned
|
||||
/// location off, the app went to the background and the OS stopped waking it.
|
||||
/// `POST /miler/logs` has fields for every one of them:
|
||||
///
|
||||
/// ```
|
||||
/// battery is_charging connection location_service accuracy is_background
|
||||
/// ```
|
||||
///
|
||||
/// ── Why the console was showing an em dash for all of it ──
|
||||
///
|
||||
/// Not because nothing was measured. Every one of these was already being read
|
||||
/// on the heartbeat — and published to **MQTT only**, in a block that ran
|
||||
/// *after* the log had been posted. So the telemetry existed, reached a
|
||||
/// different transport, and the rider-log row the console actually reads
|
||||
/// carried nothing but a position.
|
||||
///
|
||||
/// It was two lanes for one fact, and the lane the console reads was the empty
|
||||
/// one. This is the reader both now share: one place that knows how to ask the
|
||||
/// handset, so the MQTT publish and the log post cannot report different
|
||||
/// batteries a second apart.
|
||||
///
|
||||
/// ── Never throws, never blocks ──
|
||||
///
|
||||
/// Telemetry is the least important thing the app does. A permissions prompt, a
|
||||
/// platform channel that hangs, a plugin missing on a desktop test host — none
|
||||
/// of them may cost the rider his heartbeat, so every read is guarded
|
||||
/// individually and an unavailable one simply does not appear in the map.
|
||||
/// A field that is absent renders as the em dash it already rendered as; a
|
||||
/// field that is *wrong* would be read as fact by somebody making a decision.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
class DeviceTelemetry {
|
||||
/// Battery percentage, or null when the platform will not say.
|
||||
final int? battery;
|
||||
|
||||
/// True while on the charger — a rider at 12% and charging is not the same
|
||||
/// problem as a rider at 12% and riding.
|
||||
final bool? isCharging;
|
||||
|
||||
/// `wifi`, `mobile`, `none` … as the platform names it.
|
||||
final String? connection;
|
||||
|
||||
/// `enabled`, `disabled`, `denied`, `denied_forever`, `unknown`.
|
||||
///
|
||||
/// The one field here that is about a *choice the rider made*, which is why
|
||||
/// it is worth a column of its own on the console: a disabled location
|
||||
/// service explains a silent rider completely, and no amount of staring at
|
||||
/// his last position does.
|
||||
final String? locationService;
|
||||
|
||||
const DeviceTelemetry({
|
||||
this.battery,
|
||||
this.isCharging,
|
||||
this.connection,
|
||||
this.locationService,
|
||||
});
|
||||
|
||||
/// Reads what the handset will say about itself, right now.
|
||||
///
|
||||
/// [locationService] can be supplied by a caller that has just resolved a
|
||||
/// fix and already knows — the position lookup has to ask the same question
|
||||
/// to get a fix at all, and asking twice can disagree with itself when the
|
||||
/// rider toggles the setting between the two calls.
|
||||
static Future<DeviceTelemetry> read({String? locationService}) async {
|
||||
int? level;
|
||||
bool? charging;
|
||||
try {
|
||||
final battery = Battery();
|
||||
level = await battery.batteryLevel;
|
||||
final state = await battery.batteryState;
|
||||
charging =
|
||||
state == BatteryState.charging || state == BatteryState.full;
|
||||
} catch (e) {
|
||||
debugPrint('[TELEMETRY] battery unavailable: $e');
|
||||
}
|
||||
|
||||
String? connection;
|
||||
try {
|
||||
final result = await Connectivity().checkConnectivity();
|
||||
connection = result.isNotEmpty
|
||||
? result.first.toString().split('.').last
|
||||
: 'none';
|
||||
} catch (e) {
|
||||
debugPrint('[TELEMETRY] connectivity unavailable: $e');
|
||||
}
|
||||
|
||||
var service = locationService;
|
||||
if (service == null || service.isEmpty || service == 'unknown') {
|
||||
try {
|
||||
service = await Geolocator.isLocationServiceEnabled()
|
||||
? 'enabled'
|
||||
: 'disabled';
|
||||
} catch (e) {
|
||||
debugPrint('[TELEMETRY] location service unavailable: $e');
|
||||
service = null;
|
||||
}
|
||||
}
|
||||
|
||||
return DeviceTelemetry(
|
||||
battery: level,
|
||||
isCharging: charging,
|
||||
connection: connection,
|
||||
locationService: service,
|
||||
);
|
||||
}
|
||||
|
||||
/// The heartbeat payload's own field names, ready to spread into it.
|
||||
///
|
||||
/// Only what was actually read: an absent key is a field the console draws as
|
||||
/// unknown, which is the truth. A `0` battery or a `none` connection standing
|
||||
/// in for "could not ask" is a fact somebody would act on.
|
||||
Map<String, dynamic> toPayload() => <String, dynamic>{
|
||||
if (battery != null) 'battery': '$battery',
|
||||
if (isCharging != null) 'is_charging': isCharging,
|
||||
if (connection != null && connection!.isNotEmpty) 'connection': connection,
|
||||
if (locationService != null && locationService!.isNotEmpty)
|
||||
'location_service': locationService,
|
||||
};
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'battery=$battery charging=$isCharging '
|
||||
'connection=$connection location=$locationService';
|
||||
}
|
||||
@@ -149,7 +149,7 @@ class MealRunMock {
|
||||
lat: 11.0271,
|
||||
lng: 76.9962,
|
||||
kitchen: _vidhya,
|
||||
bag: 'DG-1001',
|
||||
label: 'DG-1001',
|
||||
meals: 1,
|
||||
),
|
||||
_drop(
|
||||
@@ -161,7 +161,7 @@ class MealRunMock {
|
||||
lat: 11.0248,
|
||||
lng: 76.9941,
|
||||
kitchen: _vidhya,
|
||||
bag: 'DG-1002',
|
||||
label: 'DG-1002',
|
||||
meals: 1,
|
||||
),
|
||||
_drop(
|
||||
@@ -173,7 +173,7 @@ class MealRunMock {
|
||||
lat: 11.0043,
|
||||
lng: 76.9518,
|
||||
kitchen: _vidhya,
|
||||
bag: 'DG-1003',
|
||||
label: 'DG-1003',
|
||||
meals: 1,
|
||||
),
|
||||
_drop(
|
||||
@@ -185,7 +185,7 @@ class MealRunMock {
|
||||
lat: 11.0168,
|
||||
lng: 76.9558,
|
||||
kitchen: _vidhya,
|
||||
bag: 'DG-1004',
|
||||
label: 'DG-1004',
|
||||
meals: 1,
|
||||
),
|
||||
_drop(
|
||||
@@ -197,7 +197,7 @@ class MealRunMock {
|
||||
lat: 10.9925,
|
||||
lng: 77.0289,
|
||||
kitchen: _vidhya,
|
||||
bag: 'DG-1005',
|
||||
label: 'DG-1005',
|
||||
meals: 1,
|
||||
),
|
||||
|
||||
@@ -211,7 +211,7 @@ class MealRunMock {
|
||||
lat: 11.0181,
|
||||
lng: 76.9603,
|
||||
kitchen: _annapoorna,
|
||||
bag: 'AM-2001',
|
||||
label: 'AM-2001',
|
||||
meals: 1,
|
||||
),
|
||||
_drop(
|
||||
@@ -223,7 +223,7 @@ class MealRunMock {
|
||||
lat: 10.9871,
|
||||
lng: 76.9931,
|
||||
kitchen: _annapoorna,
|
||||
bag: 'AM-2002',
|
||||
label: 'AM-2002',
|
||||
meals: 1,
|
||||
),
|
||||
_drop(
|
||||
@@ -235,7 +235,7 @@ class MealRunMock {
|
||||
lat: 10.9903,
|
||||
lng: 76.9977,
|
||||
kitchen: _annapoorna,
|
||||
bag: 'AM-2003',
|
||||
label: 'AM-2003',
|
||||
meals: 1,
|
||||
),
|
||||
_drop(
|
||||
@@ -247,7 +247,7 @@ class MealRunMock {
|
||||
lat: 11.0221,
|
||||
lng: 76.9391,
|
||||
kitchen: _annapoorna,
|
||||
bag: 'AM-2004',
|
||||
label: 'AM-2004',
|
||||
meals: 1,
|
||||
),
|
||||
];
|
||||
@@ -303,7 +303,7 @@ class MealRunMock {
|
||||
int locationId,
|
||||
})
|
||||
kitchen,
|
||||
required String bag,
|
||||
required String label,
|
||||
required int meals,
|
||||
}) => <String, dynamic>{
|
||||
// `MOCK-` so `purgeDemoRecords()` recognises anything that leaks into a
|
||||
@@ -334,11 +334,11 @@ class MealRunMock {
|
||||
// bag identity all key off these.
|
||||
'kitchenid': kitchen.id,
|
||||
'kitchenname': kitchen.name,
|
||||
'baglabel': bag,
|
||||
'baglabel': label,
|
||||
|
||||
// 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].
|
||||
// count. See [OrderManifest].
|
||||
'type': 'delivery',
|
||||
'deliveryqty': meals,
|
||||
'quantity': meals,
|
||||
|
||||
@@ -102,7 +102,60 @@ class MilkRun {
|
||||
// fabricated kitchen.
|
||||
|
||||
/// The stable key a stop groups under for pickup.
|
||||
static String sourceKeyOf(Map<String, dynamic> stop) {
|
||||
///
|
||||
/// ── The address is the last witness, and it was not being asked ──
|
||||
///
|
||||
/// Id, then name, then `'none'` — and `'none'` is its own bucket per the note
|
||||
/// above, on the sound principle that the app must not fabricate a kitchen.
|
||||
///
|
||||
/// What that missed is that a stop with no source id and no source name is
|
||||
/// not a stop with no *place*. It still says where it is collected from: the
|
||||
/// pickup address, which is on every row, and which two orders off the same
|
||||
/// counter share exactly because it is the same counter.
|
||||
///
|
||||
/// The cost of not asking was visible on a real morning. Eight orders came
|
||||
/// from one kitchen, seven carrying its id and one not, and the eighth could
|
||||
/// not be grouped with its siblings — so it fell out of the dropdown into a
|
||||
/// flat header of its own, titled with the customer's name, reading as a
|
||||
/// second place the rider had to ride to. Seven bags in a list and one
|
||||
/// stranded above it, off the same shelf.
|
||||
///
|
||||
/// Grouping by address is not a fabricated kitchen: it is the weakest true
|
||||
/// statement available — *these are collected from the same address* — and it
|
||||
/// is only reached when the two stronger ones are absent. A row with no
|
||||
/// address at all still gets `'none'`, and still stands alone.
|
||||
/// [within] is the rest of the day, when the caller has it. A stop that
|
||||
/// names no counter **adopts the one its neighbours name at the same
|
||||
/// address** — which is the half of this that the reported case actually
|
||||
/// needed: seven of the eight carried the kitchen's id and the eighth carried
|
||||
/// nothing, so keying the odd one out by its address alone still left it in a
|
||||
/// bucket of its own, correctly grouped with nobody.
|
||||
///
|
||||
/// Only ever *adopts*, never overrides: a stop with its own identity is
|
||||
/// keyed by it and does not consult the day at all. So two counters that
|
||||
/// share a street cannot merge, and a row cannot be pulled away from the id
|
||||
/// the hub gave it.
|
||||
static String sourceKeyOf(
|
||||
Map<String, dynamic> stop, {
|
||||
List<Map<String, dynamic>> within = const [],
|
||||
}) {
|
||||
final own = _identityKeyOf(stop);
|
||||
if (own.isNotEmpty) return own;
|
||||
|
||||
final place = _addressKey(stop);
|
||||
if (place.isEmpty) return 'none';
|
||||
|
||||
for (final other in within) {
|
||||
if (_addressKey(other) != place) continue;
|
||||
final identified = _identityKeyOf(other);
|
||||
if (identified.isNotEmpty) return identified;
|
||||
}
|
||||
return 'at:$place';
|
||||
}
|
||||
|
||||
/// The counter this stop names for itself — `id:…`, `name:…`, or `''` when
|
||||
/// the payload names none.
|
||||
static String _identityKeyOf(Map<String, dynamic> stop) {
|
||||
final id =
|
||||
(stop['sourceid'] ??
|
||||
stop['kitchenid'] ??
|
||||
@@ -113,7 +166,25 @@ class MilkRun {
|
||||
if (id.isNotEmpty && id != '0') return 'id:$id';
|
||||
final name = sourceNameOf(stop).toLowerCase();
|
||||
if (name.isNotEmpty) return 'name:$name';
|
||||
return 'none';
|
||||
return '';
|
||||
}
|
||||
|
||||
/// A pickup address reduced to something two rows can be compared on.
|
||||
///
|
||||
/// Case, spacing and punctuation all vary between rows the same hub wrote —
|
||||
/// `12, SNS Colony` and `12 SNS COLONY ` are one place — so everything but
|
||||
/// the letters and digits is dropped before comparing. Deliberately not
|
||||
/// `compactAddress`: that is written to be *read*, and a key should not
|
||||
/// change because a display rule was tuned.
|
||||
static String _addressKey(Map<String, dynamic> stop) {
|
||||
final raw =
|
||||
(stop['pickupaddress'] ??
|
||||
stop['PickupAddress'] ??
|
||||
stop['pickup_address'] ??
|
||||
'')
|
||||
.toString()
|
||||
.toLowerCase();
|
||||
return raw.replaceAll(RegExp(r'[^a-z0-9]'), '');
|
||||
}
|
||||
|
||||
/// The kitchen's name as the rider reads it, or '' when the payload has none.
|
||||
@@ -177,10 +248,14 @@ class MilkRun {
|
||||
Map<String, dynamic> stop,
|
||||
List<Map<String, dynamic>> stops,
|
||||
) {
|
||||
final key = sourceKeyOf(stop);
|
||||
// The day is passed on both sides so a row that names no counter is
|
||||
// gathered with the ones that do — see [sourceKeyOf]. Without it the bulk
|
||||
// collect would leave behind exactly the order the route card had just
|
||||
// learned to group in, which is the two-readers failure in a new place.
|
||||
final key = sourceKeyOf(stop, within: stops);
|
||||
return [
|
||||
for (final s in stops)
|
||||
if (sourceKeyOf(s) == key) s,
|
||||
if (sourceKeyOf(s, within: stops) == key) s,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -131,10 +131,49 @@ Future<void> stampOrderEvent(
|
||||
|
||||
/// 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);
|
||||
Future<void> stampOrderEvents(Iterable<Object> orderIds, String event) async =>
|
||||
stampOrderEventsAndRead(orderIds, event);
|
||||
|
||||
/// [stampOrderEvents], and hands back the whole ledger it just wrote.
|
||||
///
|
||||
/// ── Why the batch is one read and one write ──
|
||||
///
|
||||
/// [stampOrderEvent] is a read-modify-write of the entire ledger, and the loop
|
||||
/// that called it did that **once per order**. A rider holding sixty bookings
|
||||
/// therefore paid sixty decodes and sixty encodes of a JSON blob that grows
|
||||
/// with his day, on every poll — which is why the call site had to fire it into
|
||||
/// the dark with `unawaited` and could never use the result.
|
||||
///
|
||||
/// It is used now: [WorkRepository] merges the assignment stamp back onto the
|
||||
/// rows it just fetched, so "when did this reach me" is a fact on the stop
|
||||
/// rather than a second store every screen has to join for itself. That needs
|
||||
/// the ledger *after* the write, which is the other half of why this exists.
|
||||
///
|
||||
/// Still written once — an order that already carries [event] keeps the clock
|
||||
/// it has.
|
||||
Future<Map<String, Map<String, String>>> stampOrderEventsAndRead(
|
||||
Iterable<Object> orderIds,
|
||||
String event,
|
||||
) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final all = await _read(prefs);
|
||||
final at = DateTime.now().toIso8601String();
|
||||
|
||||
var added = 0;
|
||||
for (final raw in orderIds) {
|
||||
final id = raw.toString().trim();
|
||||
if (id.isEmpty) continue;
|
||||
final mine = all.putIfAbsent(id, () => <String, String>{});
|
||||
if (mine.containsKey(event)) continue;
|
||||
mine[event] = at;
|
||||
added++;
|
||||
}
|
||||
if (added > 0) await prefs.setString(_kKey, jsonEncode(all));
|
||||
return all;
|
||||
} catch (e) {
|
||||
debugPrint('[EVENTS] could not stamp $event on a batch: $e');
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,9 +209,24 @@ Future<void> clearOrderEvents(Object orderId) async {
|
||||
///
|
||||
/// 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 {
|
||||
/// exists to avoid.
|
||||
///
|
||||
/// ── Why two days was not enough once Home read this ──
|
||||
///
|
||||
/// It was two, on the reasoning that a shift crossing midnight must not lose
|
||||
/// its own morning. That is the right floor for a *timeline*, which is all this
|
||||
/// fed. It is the wrong floor for a **day filter**.
|
||||
///
|
||||
/// [OrderEvent.assigned] is now what dates a booking the backend dates with
|
||||
/// nothing, and an order still sitting in the queue undecided carries that one
|
||||
/// stamp and no other. At two days it was pruned on the third morning, the next
|
||||
/// fetch stamped it afresh with *that* day's clock, and a booking from Monday
|
||||
/// reappeared on Thursday's Home as Thursday's work — the precise failure the
|
||||
/// filter was added to stop, arriving three days late.
|
||||
///
|
||||
/// A fortnight is well past any open booking's life, and the cost is a few
|
||||
/// hundred bytes: the entry is an id and up to five short strings.
|
||||
Future<void> pruneOrderEvents({int keepDays = 14}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final all = await _read(prefs);
|
||||
|
||||
114
lib/data/order_manifest.dart
Normal file
114
lib/data/order_manifest.dart
Normal file
@@ -0,0 +1,114 @@
|
||||
import 'package:miler/data/milk_run.dart';
|
||||
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// WHAT THE RIDER COLLECTS AT A COUNTER, AS A LIST HE CAN COUNT
|
||||
///
|
||||
/// One pickup order is one handover. If a kitchen has five pickup orders the
|
||||
/// rider is handed five things and ends up with five deliveries, each carrying
|
||||
/// what it arrived in. That rule lives here so it cannot be stated two
|
||||
/// different ways on two screens.
|
||||
///
|
||||
/// ── 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?" and
|
||||
/// "which one is this?", and every screen that answered independently answered
|
||||
/// differently: one showed a backend `Quantity` (an order's item count, not a
|
||||
/// count of handovers), one showed a bare printed label 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 items 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.
|
||||
///
|
||||
/// ── The invented numbering is gone ──
|
||||
///
|
||||
/// This used to name every line `Bag 1 … Bag n`, falling back to the order's
|
||||
/// position in its group whenever the payload printed no label of its own. Two
|
||||
/// things were wrong with that, and only the second is about the word.
|
||||
///
|
||||
/// The number was **the app's own invention presented as a physical fact**. It
|
||||
/// counted positions in a list the app had built, and it was drawn as a tag in
|
||||
/// the corner of every row where it read like something stencilled on a
|
||||
/// container. A rider matching a shelf against it was matching against the
|
||||
/// app's arithmetic, not against anything the kitchen had written; and the rail
|
||||
/// beside it already numbers the same rows, so the tag mostly restated the node
|
||||
/// two columns to its left in louder type.
|
||||
///
|
||||
/// A label the counter **actually printed** is a different thing entirely — it
|
||||
/// is a fact about an object in front of him — so it survives, verbatim, under
|
||||
/// whatever name the kitchen gave it. What is never done again is manufacturing
|
||||
/// one where none exists.
|
||||
///
|
||||
/// Nothing here fabricates a crate, a tote or a quantity the backend has not
|
||||
/// sent. If a future contract ever puts more than one handover on an order,
|
||||
/// this is the one file that changes.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
class OrderManifest {
|
||||
OrderManifest._();
|
||||
|
||||
/// One line per order: the stop, who it is for, and the label the counter
|
||||
/// printed on it — empty when it printed none.
|
||||
///
|
||||
/// 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<ManifestLine> forGroup(List<Map<String, dynamic>> stops) => [
|
||||
for (var i = 0; i < stops.length; i++)
|
||||
ManifestLine(
|
||||
stop: stops[i],
|
||||
orderId: MilkRun.idOf(stops[i]),
|
||||
customer: customerOf(stops[i]),
|
||||
label: labelFor(stops[i]),
|
||||
),
|
||||
];
|
||||
|
||||
/// The label physically printed on one order's handover, or `''`.
|
||||
///
|
||||
/// Read straight off the payload and never derived. A counter that prints
|
||||
/// nothing leaves this empty, and the row simply carries no tag — which is
|
||||
/// honest, where a manufactured `Bag 4` was a shelf reference the shelf had
|
||||
/// never heard of.
|
||||
static String labelFor(Map<String, dynamic> stop) => stopPrintedLabel(stop);
|
||||
|
||||
/// `5 orders` — the load, in the unit everything else on the screen counts.
|
||||
///
|
||||
/// It printed `5 orders · 5 bags` once: the two halves of one rule side by
|
||||
/// side, as a check the rider could eyeball. That cost the header the fact it
|
||||
/// exists for — a real kitchen name plus `13 orders · 13 bags` pushed
|
||||
/// `11 to accept` off the row — so it was cut to the physical half, `13 bags`.
|
||||
///
|
||||
/// Now that the app no longer names anything a bag, the surviving half is the
|
||||
/// one the rest of the screen already speaks: the rows underneath are orders,
|
||||
/// the chip counts orders, the hub assigns orders. One word, everywhere.
|
||||
static String countLabel(int orders) =>
|
||||
orders == 1 ? '1 order' : '$orders orders';
|
||||
|
||||
/// The name the order 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 [OrderManifest.forGroup].
|
||||
class ManifestLine {
|
||||
final Map<String, dynamic> stop;
|
||||
final String orderId;
|
||||
final String customer;
|
||||
|
||||
/// What the counter printed on this order, or `''` when it printed nothing.
|
||||
final String label;
|
||||
|
||||
const ManifestLine({
|
||||
required this.stop,
|
||||
required this.orderId,
|
||||
required this.customer,
|
||||
this.label = '',
|
||||
});
|
||||
}
|
||||
@@ -89,7 +89,22 @@ abstract final class ServiceDay {
|
||||
'modifiedon',
|
||||
'updatedat',
|
||||
'expected_pickup_time',
|
||||
'expectedpickuptime',
|
||||
'createdat',
|
||||
'createdon',
|
||||
// ── Last, because it is the app's own guess and not the hub's record ──
|
||||
//
|
||||
// When the booking first appeared in this rider's queue, written by
|
||||
// [WorkRepository] and merged onto the row there. It is the weakest answer
|
||||
// on this list — a booking the rider's phone met for the first time this
|
||||
// morning may have been raised last night — so every clock the backend
|
||||
// actually sends outranks it.
|
||||
//
|
||||
// It is on the list because the alternative is worse. A row the backend
|
||||
// dates with nothing is a row no screen can place, and the observable
|
||||
// result was yesterday's assignments sitting on Home under a heading that
|
||||
// says today. An approximate day beats no day.
|
||||
'assignedat',
|
||||
];
|
||||
|
||||
/// The service day [row] belongs to, or `''` when it carries nothing usable.
|
||||
@@ -154,6 +169,83 @@ abstract final class ServiceDay {
|
||||
return day == (now ?? today);
|
||||
}
|
||||
|
||||
/// The rows of [stops] that belong to [now], for a screen that shows the
|
||||
/// rider's **live** work — Home's run, Deliveries' queue.
|
||||
///
|
||||
/// ── Why the work screens needed this and Activity did not ──
|
||||
///
|
||||
/// `GET /miler/bookings` returns the rider's whole **open** set, not his day.
|
||||
/// A booking assigned on Tuesday and never closed comes back on Wednesday and
|
||||
/// on Thursday, so every screen built from that call was showing a week and
|
||||
/// calling it today: yesterday's stops inside the kitchen dropdown, inside
|
||||
/// the trip counts, inside the progress ring and inside the delivery queue.
|
||||
///
|
||||
/// Activity already filtered, with [belongsToToday], and that is why the two
|
||||
/// halves of the app disagreed about the same order. This is the same
|
||||
/// predicate, applied at the same kind of boundary — the fetch — so that
|
||||
/// every figure a screen prints is computed from one set rather than each
|
||||
/// widget filtering for itself. A filter inside a card would have left the
|
||||
/// counts above it still totalling Tuesday.
|
||||
///
|
||||
/// ── The burden of proof is on the row ──
|
||||
///
|
||||
/// Undated rows go too, exactly as they do on Activity. They are rare now:
|
||||
/// `ApiConfig.pickupFromBooking` carries every clock the backend sends
|
||||
/// instead of dropping them at the adapter, and `WorkRepository` stamps
|
||||
/// `assignedat` on everything still in front of the rider. A stop with no
|
||||
/// date at all is one the app has never seen as open work, which is not this
|
||||
/// morning's assignment.
|
||||
///
|
||||
/// ── One safety net, and it is deliberately narrow ──
|
||||
///
|
||||
/// If the day came back with rows and **not one of them** can be dated, that
|
||||
/// is not a stale queue — it is the app having lost the ability to date
|
||||
/// anything at all, and the answer to that is not to show a rider an empty
|
||||
/// screen while his hub believes he has twenty stops. That case keeps
|
||||
/// everything and says so in the log. One undated row among dated ones is a
|
||||
/// data defect and is dropped; *every* row undated is a broken build, and
|
||||
/// blanking the screen would hide it behind something that reads like good
|
||||
/// news.
|
||||
///
|
||||
/// [where] names the caller in the log line, so two screens filtering the
|
||||
/// same day are told apart. Nothing is deleted anywhere: this is a view
|
||||
/// filter, and both local stores and the backend keep every record they had.
|
||||
static List<Map<String, dynamic>> onlyToday(
|
||||
List<Map<String, dynamic>> stops, {
|
||||
String? now,
|
||||
String where = 'DAY',
|
||||
}) {
|
||||
if (stops.isEmpty) return stops;
|
||||
final day = now ?? today;
|
||||
|
||||
final kept = <Map<String, dynamic>>[];
|
||||
var undated = 0;
|
||||
for (final s in stops) {
|
||||
if (of(s).isEmpty) {
|
||||
undated++;
|
||||
logUndated(s);
|
||||
continue;
|
||||
}
|
||||
if (belongsToToday(s, now: day)) kept.add(s);
|
||||
}
|
||||
|
||||
if (undated == stops.length) {
|
||||
debugPrint(
|
||||
'[$where] $day — not one of ${stops.length} stops carries a date. '
|
||||
'Showing the lot rather than an empty day; check the adapter.',
|
||||
);
|
||||
return stops;
|
||||
}
|
||||
|
||||
if (kept.length != stops.length) {
|
||||
debugPrint(
|
||||
'[$where] $day — kept ${kept.length} of ${stops.length} stops'
|
||||
'${undated > 0 ? ' ($undated undated)' : ''}',
|
||||
);
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
/// The standard [belongsToToday] undated reporter: names the row, says which
|
||||
/// fields were looked for, and stays out of release logs.
|
||||
///
|
||||
|
||||
@@ -572,10 +572,12 @@ Future<ServiceProfile> resolveServiceProfile() async {
|
||||
// patterns collide. Tenant 13 carrying a display name that normalises to
|
||||
// `doormile` — which is, after all, the company that owns the tenant record —
|
||||
// resolved that rider to Logistics and never consulted the id at all. On the
|
||||
// Logistics line `_unreleased` is empty by design, so the **Start round** bar
|
||||
// does not render, and once `MILER_COLLECTED_STATE_ENABLED` is on his
|
||||
// collected parcels strand at `Collected_By_Miler` with no control to release
|
||||
// them. A global flag gets one safe shot, and this was the loose end in it.
|
||||
// Logistics line a rider has no customer round at all — he carries what he
|
||||
// collects to the hub — so nothing in the app will ever call
|
||||
// `start-delivery` for him. Once `MILER_COLLECTED_STATE_ENABLED` is on, a
|
||||
// meal rider resolved onto Logistics has his collected orders strand at
|
||||
// `Collected_By_Miler` with no control anywhere that releases them. A global
|
||||
// flag gets one safe shot, and this was the loose end in it.
|
||||
//
|
||||
// A deliberate statement about a known tenant outranks a match on a word
|
||||
// nobody on either side of the API controls the spelling of.
|
||||
|
||||
@@ -157,6 +157,36 @@ String areaOf(Map<String, dynamic> stop, {bool preferDrop = true}) {
|
||||
return '';
|
||||
}
|
||||
|
||||
/// Where a stop is **collected from**, as the payload writes it, or `''`.
|
||||
///
|
||||
/// ── Why this is not [areaOf] with `preferDrop: false` ──
|
||||
///
|
||||
/// [areaOf] answers "which neighbourhood", and deliberately throws away
|
||||
/// everything that is not a locality name — which is the right answer for a
|
||||
/// list of twenty stops and the wrong one for the counter the rider is riding
|
||||
/// to. He needs the street to find the door.
|
||||
///
|
||||
/// Three screens were each reading the same four key names for this and one of
|
||||
/// them, the route card, was reading none of them at all. One reader, so a
|
||||
/// payload that spells it `pickup_address` cannot answer on the preview sheet
|
||||
/// and stay blank on the card behind it.
|
||||
///
|
||||
/// Returns `''` rather than a placeholder: a card prints nothing where there is
|
||||
/// no address, which is honest, where "Address not available" is a line of
|
||||
/// furniture saying the app has failed.
|
||||
String pickupAddressOf(Map<String, dynamic> stop) {
|
||||
for (final key in const [
|
||||
'pickupaddress',
|
||||
'PickupAddress',
|
||||
'pickup_address',
|
||||
'address',
|
||||
]) {
|
||||
final raw = (stop[key] ?? '').toString().trim();
|
||||
if (raw.isNotEmpty && raw.toLowerCase() != 'null') return raw;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/// A full address, minus the boilerplate a geocoder appends.
|
||||
///
|
||||
/// The record page prints the address in full — it is the one screen whose
|
||||
|
||||
@@ -228,23 +228,19 @@ class WorkRepository {
|
||||
// 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,
|
||||
),
|
||||
);
|
||||
//
|
||||
// ── And it is read back onto the row ──
|
||||
//
|
||||
// It used to be fired into the dark with `unawaited`: written for one
|
||||
// screen's timeline, and joined by that screen alone. That left every other
|
||||
// screen with no clock at all on a row the backend dates with nothing, and
|
||||
// "does this belong to today" is a question three of them ask.
|
||||
//
|
||||
// So the stamp comes back as `assignedat` — a field on the stop, like any
|
||||
// other. Awaited rather than fired off, because a row that reaches Home
|
||||
// before its own clock does is a row Home cannot place, and the batch is
|
||||
// one read and one write of a small blob. See [stampOrderEventsAndRead].
|
||||
await _stampAssignment(mapped);
|
||||
|
||||
_loadedAt = DateTime.now();
|
||||
return _publish(
|
||||
@@ -286,6 +282,59 @@ class WorkRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes each stop's assignment clock onto it, in place, as `assignedat`.
|
||||
///
|
||||
/// ── Which rows are stamped, and which only read ──
|
||||
///
|
||||
/// Only work still in front of the rider is *stamped* — see the note at the
|
||||
/// call site: the ledger did not exist when a stop that is already delivered
|
||||
/// arrived, so its first sighting is whenever the app happened to be opened,
|
||||
/// and an `Assigned 3:34 PM` above a `Delivered 11:57 AM` is the timeline
|
||||
/// contradicting itself.
|
||||
///
|
||||
/// Every row is **read**, though, finished ones included. A stop the rider
|
||||
/// collected this morning was stamped this morning while it was still ahead
|
||||
/// of him, and that clock is exactly what keeps it inside today's trip — drop
|
||||
/// it and the progress ring loses its own denominator the moment the work is
|
||||
/// done.
|
||||
///
|
||||
/// A backend clock always wins: this only fills a gap, never overwrites.
|
||||
static Future<void> _stampAssignment(List<Map<String, dynamic>> day) async {
|
||||
if (day.isEmpty) return;
|
||||
try {
|
||||
final ledger = await stampOrderEventsAndRead(
|
||||
day
|
||||
.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,
|
||||
);
|
||||
if (ledger.isEmpty) return;
|
||||
|
||||
for (final stop in day) {
|
||||
final id = (stop['orderid'] ?? '').toString().trim();
|
||||
if (id.isEmpty) continue;
|
||||
final at = ledger[id]?[OrderEvent.assigned];
|
||||
if (at == null || at.isEmpty) continue;
|
||||
final existing = (stop['assignedat'] ?? '').toString().trim();
|
||||
if (existing.isNotEmpty) continue;
|
||||
stop['assignedat'] = at;
|
||||
}
|
||||
} catch (e) {
|
||||
// A day with no assignment clocks is a day the screens date by whatever
|
||||
// the backend sent. Never worth failing the fetch over.
|
||||
debugPrint('[WORK] could not read the assignment ledger: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns a refused call into the thing the rider does about it.
|
||||
static LoadFailureKind _classify(ApiResult res) => switch (res.status) {
|
||||
0 => LoadFailureKind.offline,
|
||||
|
||||
Reference in New Issue
Block a user