device infos

This commit is contained in:
2026-08-28 15:07:30 +05:30
parent 5723d373b2
commit 69f4f3e909
84 changed files with 4337 additions and 2339 deletions

View File

@@ -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);
}
}

View File

@@ -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();

View File

@@ -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,
});
}

View File

@@ -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 {

View 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';
}

View File

@@ -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,

View File

@@ -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,
];
}

View File

@@ -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);

View 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 = '',
});
}

View File

@@ -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.
///

View File

@@ -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.

View File

@@ -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

View File

@@ -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,