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

@@ -31,11 +31,23 @@
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- ===== Application ===== --> <!-- ===== Application ===== -->
<!-- usesCleartextTraffic was `true`, commented "allows http if any". There
is no http:// endpoint left in the app: every host it talks to —
api.doormile.com, queue.workolik.com, the OSM tile server and the OSRM
router — is https, and all of them present valid publicly trusted
certificates.
Left on, the flag is a standing invitation: a URL that later arrives
from a payload or a config would be fetched in the clear on the
rider's mobile network, carrying his session and his position. Off,
that request fails and somebody fixes the URL. Turned off alongside
the certificate bypass in MyHttpOverrides, which was the other half
of the same hole. -->
<application <application
android:label="Doormile Rider" android:label="Doormile Rider"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true"> <!-- allows http if any --> android:usesCleartextTraffic="false">
<!-- No maps key. The Google Maps SDK is no longer used: tiles come <!-- No maps key. The Google Maps SDK is no longer used: tiles come
from OpenStreetMap and routes from OSRM, both keyless, drawn by from OpenStreetMap and routes from OSRM, both keyless, drawn by

File diff suppressed because it is too large Load Diff

View File

@@ -10,11 +10,10 @@ import 'package:miler/views/helpers/constants/apiconstants.dart';
import 'package:miler/providers/Riderlog/riderlog_provider.dart'; import 'package:miler/providers/Riderlog/riderlog_provider.dart';
import 'package:miler/background/backgroundservice.dart'; import 'package:miler/background/backgroundservice.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:miler/data/device_telemetry.dart';
import 'package:miler/utils/kalman_filter.dart'; import 'package:miler/utils/kalman_filter.dart';
import 'package:miler/utils/mqtt_service.dart'; import 'package:miler/utils/mqtt_service.dart';
import 'package:miler/views/helpers/constants/mqtt_constants.dart'; import 'package:miler/views/helpers/constants/mqtt_constants.dart';
import 'package:battery_plus/battery_plus.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
class _BackgroundRiderLog { class _BackgroundRiderLog {
static MilerKalmanFilter? _kf; static MilerKalmanFilter? _kf;
@@ -165,6 +164,24 @@ class _BackgroundRiderLog {
final int? logid = prefs.getInt('logId') ?? prefs.getInt('logid'); final int? logid = prefs.getInt('logId') ?? prefs.getInt('logid');
final String orderId = prefs.getString('current_riding_order_id') ?? ''; final String orderId = prefs.getString('current_riding_order_id') ?? '';
// ── The handset's own state goes IN the heartbeat ──
//
// It used to be gathered further down, after this post had already gone,
// and published to MQTT alone — so the console reading `/miler/logs` had
// a position and nothing else, and drew an em dash against Battery,
// Connection, GPS Accuracy and Location Service on a rider whose phone
// was reporting all four.
//
// Read here, once, and spread into the payload below. The MQTT publish
// further down reuses the same values rather than asking again, so the
// two lanes cannot disagree about the same second. See [DeviceTelemetry].
final telemetry = await DeviceTelemetry.read(
// The fix lookup already resolved this to get a position at all;
// asking a second time can disagree with itself if the rider toggles
// the setting in between.
locationService: loc['status'],
);
final payload = { final payload = {
"logid": logid ?? 0, "logid": logid ?? 0,
"userid": userid, "userid": userid,
@@ -189,6 +206,15 @@ class _BackgroundRiderLog {
"userfcmtoken": userfcmtoken ?? '', "userfcmtoken": userfcmtoken ?? '',
"username": (username ?? '').trim(), "username": (username ?? '').trim(),
"orderid": orderId, "orderid": orderId,
// What the dispatcher needs when a rider goes quiet: the battery, the
// signal, whether location is even on, and how good the last fix was.
...telemetry.toPayload(),
// Only a measured one — a `0` reads on the console as a perfect fix.
if ((loc['accuracy'] ?? '0') != '0') "accuracy": loc['accuracy'],
// This heartbeat is the *background* one — it is what keeps reporting
// while the app is not on screen, which is exactly the state the
// console's App State column exists to show.
"is_background": true,
}; };
final firstName = prefs.getString('firstname') ?? ''; final firstName = prefs.getString('firstname') ?? '';
@@ -223,18 +249,13 @@ class _BackgroundRiderLog {
} }
if (mqttService.isConnected) { if (mqttService.isConnected) {
// Gather Telemetry // The same reading the heartbeat above carried — not a second one.
final battery = Battery(); // Two reads a second apart can report two batteries, and the console
final int batteryLevel = await battery.batteryLevel; // then shows one number on the live tile and a different one in the
final BatteryState batteryState = await battery.batteryState; // trail it is drawn from.
final isCharging = final int batteryLevel = telemetry.battery ?? 0;
batteryState == BatteryState.charging || final bool isCharging = telemetry.isCharging ?? false;
batteryState == BatteryState.full; final String connType = telemetry.connection ?? 'none';
final connectivity = await Connectivity().checkConnectivity();
final String connType = connectivity.isNotEmpty
? connectivity.first.toString().split('.').last
: 'none';
// 1. Direct Telemetry (Feeding the /full API) // 1. Direct Telemetry (Feeding the /full API)
mqttService.publish('battery', '$batteryLevel%'); mqttService.publish('battery', '$batteryLevel%');

View File

@@ -9,6 +9,7 @@ import 'package:latlong2/latlong.dart' show LatLng;
import 'package:miler/helpers/miler_router.dart'; import 'package:miler/helpers/miler_router.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/data/accepted_store.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:miler/views/helpers/constants/apiconstants.dart'; import 'package:miler/views/helpers/constants/apiconstants.dart';
@@ -574,6 +575,29 @@ class PickupsController extends GetxController
} }
} }
// ── Written down, not only sent ──
//
// This figure is the only measurement of the leg that exists: the backend
// does not reliably send a distance back on `GET /miler/bookings`, and
// until now the app posted this and kept nothing, so Activity's `km
// ridden` had no row to total and printed an em dash on a day of real
// riding. Recorded against the stop so the record that gets filed carries
// it — see [kStopKmKey] and `addCompletedBookings`.
//
// Best-effort: a preferences write must never cost the rider a pickup
// that has otherwise succeeded.
if (calculatedKms > 0) {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
kStopKmKey(pickupId),
calculatedKms.toStringAsFixed(2),
);
} catch (e) {
debugPrint('[PICKED] could not record the distance: $e');
}
}
final payload = <String, dynamic>{ final payload = <String, dynamic>{
'pickupid': pickupId, 'pickupid': pickupId,
// Not sent to the backend — the legacy shape carries it and the mapper // Not sent to the backend — the legacy shape carries it and the mapper

View File

@@ -15,6 +15,7 @@ import 'package:miler/controllers/logcontroller.dart';
import 'package:miler/background/foreground_service.dart' as fg; import 'package:miler/background/foreground_service.dart' as fg;
import 'package:miler/utils/kalman_filter.dart'; import 'package:miler/utils/kalman_filter.dart';
import 'package:miler/utils/mqtt_service.dart'; import 'package:miler/utils/mqtt_service.dart';
import 'package:miler/data/device_telemetry.dart';
import 'package:miler/views/helpers/constants/mqtt_constants.dart'; import 'package:miler/views/helpers/constants/mqtt_constants.dart';
import 'package:battery_plus/battery_plus.dart'; import 'package:battery_plus/battery_plus.dart';
import 'package:miler/controllers/connectivity_mixin.dart'; import 'package:miler/controllers/connectivity_mixin.dart';
@@ -260,6 +261,14 @@ class RiderLogController extends GetxController
} }
} catch (_) {} } catch (_) {}
// The handset's own state — see [DeviceTelemetry] for why this is read
// here and not published to MQTT alone. The console's Battery,
// Connection, GPS Accuracy and Location Service columns are read off the
// rider log, and until now nothing put them in it.
final telemetry = await DeviceTelemetry.read(
locationService: loc['status'],
);
final Map<String, dynamic> payload = { final Map<String, dynamic> payload = {
// Base from server template // Base from server template
...baseTemplate, ...baseTemplate,
@@ -288,6 +297,14 @@ class RiderLogController extends GetxController
'applocationid': applocationid ?? 0, 'applocationid': applocationid ?? 0,
'userfcmtoken': userfcmtoken ?? '', 'userfcmtoken': userfcmtoken ?? '',
'orderid': orderId, 'orderid': orderId,
...telemetry.toPayload(),
// Only a measured one. A `0` here would draw on the console as a
// perfect fix rather than as no fix at all.
if ((loc['accuracy'] ?? '').isNotEmpty) 'accuracy': loc['accuracy'],
// This is the *foreground* controller — the rider has the app open.
// The background heartbeat sends true, and the difference between the
// two is what the console's App State column reports.
'is_background': false,
}; };
// --- MQTT LOGIC ( Lane Split ) --- // --- MQTT LOGIC ( Lane Split ) ---
@@ -325,6 +342,10 @@ class RiderLogController extends GetxController
'heading': loc['heading'] ?? '0', 'heading': loc['heading'] ?? '0',
'status': riderStatus, 'status': riderStatus,
'orderid': orderId, 'orderid': orderId,
// The same reading the log above carries, so the live lane and the
// trail cannot report two different batteries a second apart.
...telemetry.toPayload(),
if ((loc['accuracy'] ?? '').isNotEmpty) 'accuracy': loc['accuracy'],
}); });
// Ensure rider identity fields are clean: // Ensure rider identity fields are clean:
@@ -779,6 +800,19 @@ class RiderLogController extends GetxController
'heading': '0', 'heading': '0',
'velocity_lat': '0', 'velocity_lat': '0',
'velocity_lng': '0', 'velocity_lng': '0',
// ── Two fields the background lookup has always returned and this one
// did not ──
//
// `accuracy` is how good the fix is and `status` is whether location is
// switched on at all — the two the console draws under GPS Accuracy and
// Location Service. Absent here, the foreground heartbeat had nothing to
// send and would have had to invent a `0`, which reads as a *perfect*
// fix rather than as no fix.
//
// Empty, not zero: an unknown accuracy is dropped from the payload by
// the caller and drawn as an em dash, which is the truth.
'accuracy': '',
'status': 'unknown',
}; };
try { try {
final needsFetch = final needsFetch =
@@ -786,7 +820,10 @@ class RiderLogController extends GetxController
if (!needsFetch) return result; if (!needsFetch) return result;
final serviceEnabled = await Geolocator.isLocationServiceEnabled(); final serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) return result; if (!serviceEnabled) {
result['status'] = 'disabled';
return result;
}
LocationPermission permission = await Geolocator.checkPermission(); LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) { if (permission == LocationPermission.denied) {
@@ -794,8 +831,12 @@ class RiderLogController extends GetxController
} }
if (permission == LocationPermission.denied || if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) { permission == LocationPermission.deniedForever) {
result['status'] = permission == LocationPermission.deniedForever
? 'denied_forever'
: 'denied';
return result; return result;
} }
result['status'] = 'enabled';
final Position pos = await Geolocator.getCurrentPosition( final Position pos = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings( locationSettings: const LocationSettings(
@@ -836,6 +877,8 @@ class RiderLogController extends GetxController
'heading': heading.toStringAsFixed(2), 'heading': heading.toStringAsFixed(2),
'velocity_lat': velocityLat.toStringAsFixed(4), 'velocity_lat': velocityLat.toStringAsFixed(4),
'velocity_lng': velocityLng.toStringAsFixed(4), 'velocity_lng': velocityLng.toStringAsFixed(4),
'accuracy': pos.accuracy.toStringAsFixed(1),
'status': 'enabled',
}; };
} catch (_) { } catch (_) {
return result; return result;

View File

@@ -71,7 +71,7 @@ Future<void> migrateLegacyStores() async {
_kConsignmentIdsKeyBase, _kConsignmentIdsKeyBase,
_kOutForDeliveryKeyBase, _kOutForDeliveryKeyBase,
_kNotLoadedKeyBase, _kNotLoadedKeyBase,
_kBagLabelsKeyBase, _kOrderLabelsKeyBase,
]) { ]) {
await _drainLegacy(base, scope); await _drainLegacy(base, scope);
} }
@@ -117,7 +117,7 @@ Future<void> clearScopedStores() async {
_kConsignmentIdsKeyBase, _kConsignmentIdsKeyBase,
_kOutForDeliveryKeyBase, _kOutForDeliveryKeyBase,
_kNotLoadedKeyBase, _kNotLoadedKeyBase,
_kBagLabelsKeyBase, _kOrderLabelsKeyBase,
]) { ]) {
await prefs.remove(scope.scoped(base)); await prefs.remove(scope.scoped(base));
await prefs.remove(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. /// Written by the stop map screen when it starts the pickup.
String kStopArrivedKey(Object pickupId) => 'pickup_start_$pickupId'; 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 /// Record a finished stop. [cancelled] separates "could not complete" from a
/// clean pickup/delivery — Activity shows both, worded differently. /// clean pickup/delivery — Activity shows both, worded differently.
/// ///
@@ -362,10 +382,18 @@ Future<void> addCompletedBookings(
final arrived = prefs.getString(kStopArrivedKey(pickupId)); final arrived = prefs.getString(kStopArrivedKey(pickupId));
if (started != null && started.isNotEmpty) copy['startedat'] = started; if (started != null && started.isNotEmpty) copy['startedat'] = started;
if (arrived != null && arrived.isNotEmpty) copy['arrivedat'] = arrived; 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 // Cleared, or a stop worked twice in a day (a resumed skip) reports the
// first attempt's clock against the second attempt's completion. // first attempt's clock against the second attempt's completion.
await prefs.remove(kStopStartedKey(pickupId)); await prefs.remove(kStopStartedKey(pickupId));
await prefs.remove(kStopArrivedKey(pickupId)); await prefs.remove(kStopArrivedKey(pickupId));
await prefs.remove(kStopKmKey(pickupId));
} }
// Stamped with the session that produced it, so a row read back later // 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 ── // ── 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 // A kitchen that prints labels prints them once, at the counter, and the label
// of the five that left Vidhya Kitchen. Recomputing that later from whatever // is what the rider matches against the object in his hands for the rest of the
// list happens to be on screen is how identity breaks: deliver Joe, and a // day. Recomputing anything from whatever list happens to be on screen is how
// position-derived Arun silently becomes Bag 1. The rider is then looking for a // identity breaks — deliver one order and a position-derived label silently
// bag labelled 1 that is in a customer's hallway. // 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 // So the pairing is fixed once, at the moment the manifest is confirmed, and
// confirmed — and read back unchanged through delivery, skip, completion and a // read back unchanged through delivery, skip, completion and a day of
// day of intermittent signal. See [BagManifest], which derives it; this only // intermittent signal. See [OrderManifest.labelFor], which reads it off the
// remembers what it derived. // 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 /// Remembers the label a counter printed on each order. Merges, never
/// works two kitchens and the second load must not erase the first. /// replaces: a rider works two kitchens and the second load must not erase the
Future<void> saveBagLabels(Map<String, String> byOrderId) async { /// first. Orders with no printed label contribute nothing.
Future<void> saveOrderLabels(Map<String, String> byOrderId) async {
final clean = { final clean = {
for (final e in byOrderId.entries) for (final e in byOrderId.entries)
if (e.key.trim().isNotEmpty && e.value.trim().isNotEmpty) 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; if (clean.isEmpty) return;
final prefs = await SharedPreferences.getInstance(); 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 // Stored as `id\u0000label` rows: SharedPreferences has no map type, and a
// NUL separator cannot collide with an order id or a bag label. // NUL separator cannot collide with an order id or a printed label.
await prefs.setStringList(await _scopedKey(_kBagLabelsKeyBase), [ await prefs.setStringList(await _scopedKey(_kOrderLabelsKeyBase), [
for (final e in merged.entries) '${e.key}\u0000${e.value}', 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. /// The label each order carries, as recorded at pickup. Empty before any load,
Future<Map<String, String>> getBagLabels() async { /// and empty all day on a route whose counters print nothing.
Future<Map<String, String>> getOrderLabels() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final rows = final rows =
prefs.getStringList(await _scopedKey(_kBagLabelsKeyBase)) ?? prefs.getStringList(await _scopedKey(_kOrderLabelsKeyBase)) ??
const <String>[]; const <String>[];
return { return {
for (final row in rows) for (final row in rows)
@@ -854,7 +893,7 @@ Future<void> clearServiceRunState() async {
await prefs.remove(await _scopedKey(_kCollectedOrderIdsKeyBase)); await prefs.remove(await _scopedKey(_kCollectedOrderIdsKeyBase));
await prefs.remove(await _scopedKey(_kOutForDeliveryKeyBase)); await prefs.remove(await _scopedKey(_kOutForDeliveryKeyBase));
await prefs.remove(await _scopedKey(_kNotLoadedKeyBase)); 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 // 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 // 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. // that is trusted later precisely because it is stored.
final bags = final labels =
prefs.getStringList(await _scopedKey(_kBagLabelsKeyBase)) ?? prefs.getStringList(await _scopedKey(_kOrderLabelsKeyBase)) ??
const <String>[]; const <String>[];
if (bags.isNotEmpty) { if (labels.isNotEmpty) {
final kept = bags final kept = labels
.where((row) => !_looksLikeDemoId(row.split('\u0000').first)) .where((row) => !_looksLikeDemoId(row.split('\u0000').first))
.toList(); .toList();
if (kept.length != bags.length) { if (kept.length != labels.length) {
dropped += bags.length - kept.length; dropped += labels.length - kept.length;
await prefs.setStringList(await _scopedKey(_kBagLabelsKeyBase), kept); await prefs.setStringList(await _scopedKey(_kOrderLabelsKeyBase), kept);
} }
} }

View File

@@ -524,9 +524,92 @@ class ApiConfig {
'parcels': booking['parcels'] ?? const [], 'parcels': booking['parcels'] ?? const [],
'starttime': s(booking['createdat']), 'starttime': s(booking['createdat']),
'eta': s(booking['eta']), '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) { static List<Map<String, dynamic>> pickupsFromBookings(dynamic data) {
if (data is List) { if (data is List) {
return data.whereType<Map>().map((b) => pickupFromBooking(b)).toList(); 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 /// 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. /// the parcel is collected, not only once the round has started.
bool get canSkip => needsRelease || isDeliverable; 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 /// 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. /// authoritative server state rather than from a booking row or a local flag.
enum DeliverGate { 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, lat: 11.0271,
lng: 76.9962, lng: 76.9962,
kitchen: _vidhya, kitchen: _vidhya,
bag: 'DG-1001', label: 'DG-1001',
meals: 1, meals: 1,
), ),
_drop( _drop(
@@ -161,7 +161,7 @@ class MealRunMock {
lat: 11.0248, lat: 11.0248,
lng: 76.9941, lng: 76.9941,
kitchen: _vidhya, kitchen: _vidhya,
bag: 'DG-1002', label: 'DG-1002',
meals: 1, meals: 1,
), ),
_drop( _drop(
@@ -173,7 +173,7 @@ class MealRunMock {
lat: 11.0043, lat: 11.0043,
lng: 76.9518, lng: 76.9518,
kitchen: _vidhya, kitchen: _vidhya,
bag: 'DG-1003', label: 'DG-1003',
meals: 1, meals: 1,
), ),
_drop( _drop(
@@ -185,7 +185,7 @@ class MealRunMock {
lat: 11.0168, lat: 11.0168,
lng: 76.9558, lng: 76.9558,
kitchen: _vidhya, kitchen: _vidhya,
bag: 'DG-1004', label: 'DG-1004',
meals: 1, meals: 1,
), ),
_drop( _drop(
@@ -197,7 +197,7 @@ class MealRunMock {
lat: 10.9925, lat: 10.9925,
lng: 77.0289, lng: 77.0289,
kitchen: _vidhya, kitchen: _vidhya,
bag: 'DG-1005', label: 'DG-1005',
meals: 1, meals: 1,
), ),
@@ -211,7 +211,7 @@ class MealRunMock {
lat: 11.0181, lat: 11.0181,
lng: 76.9603, lng: 76.9603,
kitchen: _annapoorna, kitchen: _annapoorna,
bag: 'AM-2001', label: 'AM-2001',
meals: 1, meals: 1,
), ),
_drop( _drop(
@@ -223,7 +223,7 @@ class MealRunMock {
lat: 10.9871, lat: 10.9871,
lng: 76.9931, lng: 76.9931,
kitchen: _annapoorna, kitchen: _annapoorna,
bag: 'AM-2002', label: 'AM-2002',
meals: 1, meals: 1,
), ),
_drop( _drop(
@@ -235,7 +235,7 @@ class MealRunMock {
lat: 10.9903, lat: 10.9903,
lng: 76.9977, lng: 76.9977,
kitchen: _annapoorna, kitchen: _annapoorna,
bag: 'AM-2003', label: 'AM-2003',
meals: 1, meals: 1,
), ),
_drop( _drop(
@@ -247,7 +247,7 @@ class MealRunMock {
lat: 11.0221, lat: 11.0221,
lng: 76.9391, lng: 76.9391,
kitchen: _annapoorna, kitchen: _annapoorna,
bag: 'AM-2004', label: 'AM-2004',
meals: 1, meals: 1,
), ),
]; ];
@@ -303,7 +303,7 @@ class MealRunMock {
int locationId, int locationId,
}) })
kitchen, kitchen,
required String bag, required String label,
required int meals, required int meals,
}) => <String, dynamic>{ }) => <String, dynamic>{
// `MOCK-` so `purgeDemoRecords()` recognises anything that leaks into a // `MOCK-` so `purgeDemoRecords()` recognises anything that leaks into a
@@ -334,11 +334,11 @@ class MealRunMock {
// bag identity all key off these. // bag identity all key off these.
'kitchenid': kitchen.id, 'kitchenid': kitchen.id,
'kitchenname': kitchen.name, 'kitchenname': kitchen.name,
'baglabel': bag, 'baglabel': label,
// A drop, and how many boxes are in it. One order is one bag — the count // 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 // here is the *meal* count inside that bag, and nothing reads it as a bag
// count. See [BagManifest]. // count. See [OrderManifest].
'type': 'delivery', 'type': 'delivery',
'deliveryqty': meals, 'deliveryqty': meals,
'quantity': meals, 'quantity': meals,

View File

@@ -102,7 +102,60 @@ class MilkRun {
// fabricated kitchen. // fabricated kitchen.
/// The stable key a stop groups under for pickup. /// 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 = final id =
(stop['sourceid'] ?? (stop['sourceid'] ??
stop['kitchenid'] ?? stop['kitchenid'] ??
@@ -113,7 +166,25 @@ class MilkRun {
if (id.isNotEmpty && id != '0') return 'id:$id'; if (id.isNotEmpty && id != '0') return 'id:$id';
final name = sourceNameOf(stop).toLowerCase(); final name = sourceNameOf(stop).toLowerCase();
if (name.isNotEmpty) return 'name:$name'; 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. /// 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, Map<String, dynamic> stop,
List<Map<String, dynamic>> stops, 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 [ return [
for (final s in stops) 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 /// Stamps one event against several orders at once — a kitchen handover, an
/// accept-all, a released round. /// accept-all, a released round.
Future<void> stampOrderEvents(Iterable<Object> orderIds, String event) async { Future<void> stampOrderEvents(Iterable<Object> orderIds, String event) async =>
final at = DateTime.now(); stampOrderEventsAndRead(orderIds, event);
for (final id in orderIds) {
await stampOrderEvent(id, event, at: at); /// [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 /// 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` /// grows for the life of the install — the same trap `removeCollectedOrderIds`
/// exists to avoid. Two days rather than one, because a shift that crosses /// exists to avoid.
/// midnight must not lose its own morning. ///
Future<void> pruneOrderEvents({int keepDays = 2}) async { /// ── 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 { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final all = await _read(prefs); 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', 'modifiedon',
'updatedat', 'updatedat',
'expected_pickup_time', 'expected_pickup_time',
'expectedpickuptime',
'createdat', '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. /// The service day [row] belongs to, or `''` when it carries nothing usable.
@@ -154,6 +169,83 @@ abstract final class ServiceDay {
return day == (now ?? today); 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 /// The standard [belongsToToday] undated reporter: names the row, says which
/// fields were looked for, and stays out of release logs. /// 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 // patterns collide. Tenant 13 carrying a display name that normalises to
// `doormile` — which is, after all, the company that owns the tenant record — // `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 // 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 // Logistics line a rider has no customer round at all — he carries what he
// does not render, and once `MILER_COLLECTED_STATE_ENABLED` is on his // collects to the hub — so nothing in the app will ever call
// collected parcels strand at `Collected_By_Miler` with no control to release // `start-delivery` for him. Once `MILER_COLLECTED_STATE_ENABLED` is on, a
// them. A global flag gets one safe shot, and this was the loose end in it. // 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 // A deliberate statement about a known tenant outranks a match on a word
// nobody on either side of the API controls the spelling of. // 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 ''; 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. /// A full address, minus the boilerplate a geocoder appends.
/// ///
/// The record page prints the address in full — it is the one screen whose /// 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 // 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 // backend already reports as past his hands is skipped, and stays skipped
// forever because [stampOrderEvent] never overwrites. // forever because [stampOrderEvent] never overwrites.
unawaited( //
stampOrderEvents( // ── And it is read back onto the row ──
mapped //
.where((s) { // It used to be fired into the dark with `unawaited`: written for one
final st = stopStatusOf(s); // screen's timeline, and joined by that screen alone. That left every other
return !st.isWorkComplete && // screen with no clock at all on a row the backend dates with nothing, and
!st.isCancelled && // "does this belong to today" is a question three of them ask.
!st.isRejected && //
!st.isSkipped && // So the stamp comes back as `assignedat` — a field on the stop, like any
!st.isPicked && // other. Awaited rather than fired off, because a row that reaches Home
!st.isDeliveryLeg; // 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].
.map((s) => (s['orderid'] ?? '').toString()) await _stampAssignment(mapped);
.where((id) => id.isNotEmpty),
OrderEvent.assigned,
),
);
_loadedAt = DateTime.now(); _loadedAt = DateTime.now();
return _publish( 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. /// Turns a refused call into the thing the rider does about it.
static LoadFailureKind _classify(ApiResult res) => switch (res.status) { static LoadFailureKind _classify(ApiResult res) => switch (res.status) {
0 => LoadFailureKind.offline, 0 => LoadFailureKind.offline,

View File

@@ -1,10 +1,36 @@
import 'dart:io'; import 'dart:io';
/// ─────────────────────────────────────────────────────────────────────────
/// THE APP VALIDATES CERTIFICATES AGAIN
///
/// This class used to override `badCertificateCallback` to return `true` for
/// every certificate, on every host, and it was installed globally in three
/// places — `main`, the background isolate and the notification handler. That
/// is not a lenient setting; it is **TLS switched off**. Any network the rider's
/// phone joins could present a self-signed certificate for `api.doormile.com`
/// and the app would hand over his session token, his live position and the
/// day's cash figures without a word.
///
/// It is the kind of thing that gets added once to get past a staging server
/// and then ships. Both live hosts were checked before removing it and both
/// present valid, publicly-trusted certificates — `api.doormile.com` and
/// `queue.workolik.com` verify strictly under TLS 1.3 against Let's Encrypt.
/// Nothing needed the bypass.
///
/// ── What is left, and why the class is still here ──
///
/// Dart's default `HttpClient` already validates properly, so the honest
/// version of this override is one that changes nothing about trust. It stays
/// as a named place to put a real connection policy — a timeout, a proxy, a
/// pinned certificate — so that the next person who needs one has somewhere
/// obvious to put it that is not "turn the checks off".
///
/// **Never restore the callback.** If a host genuinely cannot present a valid
/// certificate, pin *that host's* certificate here; do not trust every host on
/// the internet to work around one.
/// ─────────────────────────────────────────────────────────────────────────
class MyHttpOverrides extends HttpOverrides { class MyHttpOverrides extends HttpOverrides {
@override @override
HttpClient createHttpClient(SecurityContext? context) { HttpClient createHttpClient(SecurityContext? context) =>
return super.createHttpClient(context) super.createHttpClient(context);
..badCertificateCallback =
(X509Certificate cert, String host, int port) => true;
}
} }

View File

@@ -125,6 +125,17 @@ Future<Map<String, dynamic>?> _heartbeat(Map data) async {
connection: _s(data['connection']).isEmpty connection: _s(data['connection']).isEmpty
? null ? null
: _s(data['connection']), : _s(data['connection']),
// ── This was being dropped on the floor ──
//
// Every other device field on the heartbeat was forwarded and this one
// was not, so `location_service` could never reach `/miler/logs` however
// faithfully the caller supplied it — and the console's Location Service
// column had no field to read. It is the single most useful thing on
// that panel: a rider who has turned location off explains his own
// silence, and nothing else on the row can.
locationService: _s(data['location_service']).isEmpty
? null
: _s(data['location_service']),
isBackground: data['is_background'] == true, isBackground: data['is_background'] == true,
), ),
); );

View File

@@ -1,84 +0,0 @@
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
/// Mirrors `_calloutBitmap` in map.dart so the drawing maths can be checked
/// headless — the real one is private to a State inside a `part` file.
Future<({int w, int h})> callout(String text, double dpr) async {
const double padH = 12, padV = 9, radius = 12, tail = 9, tailW = 14;
const double maxTextWidth = 190;
final painter = TextPainter(
text: TextSpan(
text: text,
style: TextStyle(
fontSize: 12 * dpr,
height: 1.25,
fontWeight: FontWeight.w600,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
maxLines: 2,
ellipsis: '…',
textDirection: TextDirection.ltr,
)..layout(maxWidth: maxTextWidth * dpr);
final w = painter.width + padH * 2 * dpr;
final h = painter.height + padV * 2 * dpr;
final totalH = h + tail * dpr;
final rec = ui.PictureRecorder();
final canvas = Canvas(rec);
final body = RRect.fromRectAndRadius(
Rect.fromLTWH(0, 0, w, h),
Radius.circular(radius * dpr),
);
canvas.drawRRect(
body.shift(Offset(0, 1.5 * dpr)),
Paint()
..color = Colors.black.withValues(alpha: 0.18)
..maskFilter = MaskFilter.blur(BlurStyle.normal, 3 * dpr),
);
canvas.drawRRect(body, Paint()..color = Colors.white);
final tip = Path()
..moveTo(w / 2 - tailW / 2 * dpr, h - 1)
..lineTo(w / 2, totalH)
..lineTo(w / 2 + tailW / 2 * dpr, h - 1)
..close();
canvas.drawPath(tip, Paint()..color = Colors.white);
canvas.drawRRect(
RRect.fromRectAndCorners(
Rect.fromLTWH(0, 0, 4 * dpr, h),
topLeft: Radius.circular(radius * dpr),
bottomLeft: Radius.circular(radius * dpr),
),
Paint()..color = ColorConstants.primary,
);
painter.paint(canvas, Offset(padH * dpr + 2 * dpr, padV * dpr));
final img = await rec.endRecording().toImage(w.ceil(), totalH.ceil());
final bytes = await img.toByteData(format: ui.ImageByteFormat.png);
if (bytes == null) throw StateError('no png');
return (w: img.width, h: img.height);
}
void main() {
testWidgets('callout rasterises at every dpr', (tester) async {
for (final dpr in [1.0, 2.0, 3.0]) {
final r = await callout('18, Chinnavedampatti Road, Coimbatore', dpr);
print('dpr=$dpr ${r.w}x${r.h}px');
expect(r.w, greaterThan(0));
expect(r.h, greaterThan(0));
}
// A very long address must clamp, not grow without bound.
final long = await callout(
'Plot 42B, Second Cross Street, Behind Sri Ramakrishna Mills, '
'Ganapathy Post, Coimbatore, Tamil Nadu 641006',
3.0,
);
print('long address at dpr=3: ${long.w}x${long.h}px');
expect(long.w, lessThanOrEqualTo((190 + 24) * 3 + 2));
});
}

View File

@@ -64,12 +64,13 @@ import 'package:miler/views/Dashboard/pickups/collect_payment.dart';
import 'package:miler/views/Dashboard/pickups/stop_type.dart'; import 'package:miler/views/Dashboard/pickups/stop_type.dart';
import 'package:miler/widget/Bottom_page.dart'; import 'package:miler/widget/Bottom_page.dart';
import 'package:miler/data/meal_run_mock.dart'; import 'package:miler/data/meal_run_mock.dart';
import 'package:miler/data/bag_manifest.dart'; import 'package:miler/data/order_manifest.dart';
import 'package:miler/data/milk_run.dart'; import 'package:miler/data/milk_run.dart';
import 'package:miler/data/order_events.dart'; import 'package:miler/data/order_events.dart';
import 'package:miler/data/work_domain.dart'; import 'package:miler/data/work_domain.dart';
import 'package:miler/data/work_repository.dart'; import 'package:miler/data/work_repository.dart';
import 'package:miler/data/pickup_locations.dart'; import 'package:miler/data/pickup_locations.dart';
import 'package:miler/data/service_day.dart';
import 'package:miler/data/service_profile.dart'; import 'package:miler/data/service_profile.dart';
import 'package:miler/views/helpers/constants/miler_type.dart'; import 'package:miler/views/helpers/constants/miler_type.dart';
import 'package:miler/views/Dashboard/home/pickup_preview_sheet.dart'; import 'package:miler/views/Dashboard/home/pickup_preview_sheet.dart';
@@ -241,9 +242,22 @@ class _HomepageState extends State<Homepage>
Set<String> _closedIds = <String>{}; Set<String> _closedIds = <String>{};
Set<String> _rejectedIds = <String>{}; Set<String> _rejectedIds = <String>{};
/// Notices when the day underneath the screen has moved on.
///
/// [_fetchQueues] filters against `ServiceDay.today` *as read at fetch time*,
/// so the route on screen is only ever as fresh as the last fetch. See
/// [_rolloverCheck].
final ServiceDayRollover _day = ServiceDayRollover();
/// Fires at the next local midnight. See [_armRollover].
Timer? _rollover;
/// Which trip tab is open. /// Which trip tab is open.
int _selectedTripIndex = 0; int _selectedTripIndex = 0;
/// True once the rider has picked a tab himself. See [_settleTripTab].
bool _tripTabChosen = false;
/// Stops the rider has ticked, for "accept just these". /// Stops the rider has ticked, for "accept just these".
Set<String> _selectedStopIds = <String>{}; Set<String> _selectedStopIds = <String>{};
@@ -258,6 +272,7 @@ class _HomepageState extends State<Homepage>
} }
_pollerSubscription?.cancel(); _pollerSubscription?.cancel();
_locationTimer?.cancel(); _locationTimer?.cancel();
_rollover?.cancel();
_httpClient.close(); _httpClient.close();
super.dispose(); super.dispose();
} }
@@ -271,6 +286,7 @@ class _HomepageState extends State<Homepage>
_ensureLocationPermission(); _ensureLocationPermission();
_ensureCameraPermission(); _ensureCameraPermission();
_startPolling(); _startPolling();
_armRollover();
// What the counters are actually called, joined on the location id the // What the counters are actually called, joined on the location id the
// booking already carries. One request, cached, and every failure resolves // booking already carries. One request, cached, and every failure resolves
// to "use the row's own name" — see [PickupLocations]. // to "use the row's own name" — see [PickupLocations].
@@ -971,6 +987,11 @@ class _HomepageState extends State<Homepage>
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state); super.didChangeAppLifecycleState(state);
// Before the early return below: a resume inside the same foreground state
// still has to notice a midnight that passed while the phone was in the
// rider's pocket, and that is the case the timer cannot cover — both
// platforms suspend timers in the background.
if (state == AppLifecycleState.resumed) _rolloverCheck();
final bool foreground = state == AppLifecycleState.resumed; final bool foreground = state == AppLifecycleState.resumed;
if (foreground == _pollForeground) return; if (foreground == _pollForeground) return;
_pollForeground = foreground; _pollForeground = foreground;
@@ -1175,7 +1196,15 @@ class _HomepageState extends State<Homepage>
// are still on the rider's route — their parcels are still to be // are still on the rider's route — their parcels are still to be
// collected and their kilometres still to be ridden. The route brief // collected and their kilometres still to be ridden. The route brief
// must count them or it understates the day. // must count them or it understates the day.
final acceptedStops = await getAcceptedBookings(); // Through the day filter as well. The accepted store is deliberately
// *not* pruned to today — it is "what have I taken on", and an id set
// that forgets a stop would flip that stop back to pending under the
// rider's thumb — but the route brief above the run is a statement about
// **this** day's kilometres and parcels. Yesterday's accepted-and-unclosed
// booking belongs in the set and not in that total.
//
// Only the rows are filtered; `acceptedIds` above is left whole.
final acceptedStops = _todayOnly(await getAcceptedBookings());
if (mounted && acceptedStops.length != _acceptedStops.length) { if (mounted && acceptedStops.length != _acceptedStops.length) {
setState(() => _acceptedStops = acceptedStops); setState(() => _acceptedStops = acceptedStops);
} else { } else {
@@ -1192,7 +1221,9 @@ class _HomepageState extends State<Homepage>
// //
// Accepted stops still also appear on Bookings — that is where he WORKS // Accepted stops still also appear on Bookings — that is where he WORKS
// them. Home is where he watches the trip fill up. // them. Home is where he watches the trip fill up.
final allStops = items.whereType<Map<String, dynamic>>().toList(); final allStops = _todayOnly(
items.whereType<Map<String, dynamic>>().toList(),
);
// ── The skip store is the truth about what is parked ── // ── The skip store is the truth about what is parked ──
// //
@@ -1311,6 +1342,8 @@ class _HomepageState extends State<Homepage>
} }
} }
}); });
// After the state is applied, because the slots are derived from it.
_settleTripTab();
} catch (e) { } catch (e) {
debugPrint('[FETCH_QUEUES] Error: $e'); debugPrint('[FETCH_QUEUES] Error: $e');
if (mounted) setState(() => _fetchFailed = true); if (mounted) setState(() => _fetchFailed = true);
@@ -1322,6 +1355,108 @@ class _HomepageState extends State<Homepage>
} }
} }
/// Opens Home on a tab that has work on it.
///
/// ── Why this became necessary ──
///
/// The tab index is a **day part**, not a position: slot 0 is the morning
/// whether or not the rider has morning work. That is the right model, and
/// while nothing could place a trip by day part it cost nothing — every trip
/// fell into the undated bucket and landed on Trip 1, so Trip 1 was where the
/// work was.
///
/// Now that [Trip.dayPartTimeOf] can place them, a rider whose whole day is
/// an afternoon run has his work under Trip 2 — correctly — and Home opened
/// on Trip 1 and told him his hub had assigned him nothing. The fix for one
/// screen must not hand him a worse version of the same lie.
///
/// So the landing tab is the first slot that actually holds stops. Three
/// things keep it from fighting the rider:
///
/// • It gives up the moment he taps a tab himself — [_tripTabChosen]. An
/// empty Trip 3 he chose deliberately is an answer he asked for.
/// • It gives up the moment the tab it is on has work, so it lands once and
/// then stops moving under him.
/// • It never *hides* a slot. The tabs still show all three, empty ones
/// included, because "the day has room left in it" is a fact he plans
/// around.
void _settleTripTab() {
if (_tripTabChosen || !mounted) return;
final trips = _trips;
final here = trips.tripAt(_selectedTripIndex);
if (here != null && here.stops.isNotEmpty) return;
final slots = trips.slots;
for (var i = 0; i < slots.length; i++) {
final t = slots[i];
if (t == null || t.stops.isEmpty) continue;
if (i != _selectedTripIndex) {
setState(() {
_selectedTripIndex = i;
_selectedStopIds.clear();
});
}
return;
}
}
/// ── Home is today's work, and only today's ──
///
/// The rule, and the reasoning behind every part of it, is
/// [ServiceDay.onlyToday] — shared with the Deliveries queue, because the two
/// screens showing the rider's live work must not disagree about which day it
/// is. What is local here is only *which* day: the one the screen is
/// currently showing, adopted so the midnight check below compares against
/// the pixels rather than reading the wall clock twice.
List<Map<String, dynamic>> _todayOnly(List<Map<String, dynamic>> stops) {
_day.rollIfNeeded();
return ServiceDay.onlyToday(stops, now: _day.day, where: 'HOME');
}
/// ── The day ending under a screen that is already open ──
///
/// [ServiceDay] answers "which day is this row" at read time, which is right
/// for the data and not sufficient for the screen: [_fetchQueues] filters
/// against the day *as read when it ran*. A rider who leaves Home open
/// through 23:59, or backgrounds the app on it and picks the phone up at six,
/// is looking at a route filtered against a day that has ended.
///
/// Two triggers, because a phone gives no single reliable one — a timer to
/// the next local midnight for the app left in the foreground, and resume for
/// every other case. Both land in [_rolloverCheck], which is idempotent.
/// The same arrangement `ActivityPage` uses, for the same reason.
void _armRollover() {
_rollover?.cancel();
_rollover = Timer(_day.untilNextDay, _rolloverCheck);
}
/// Turns Home over when the service day has moved on.
///
/// Yesterday's stops are cleared **before** the refetch, not after it. A new
/// day starts empty even with no signal, because the alternative is a rider
/// in a dead spot at six in the morning reading yesterday's route as today's
/// assignment — the exact failure this exists to stop. Anything that really
/// is the new day's comes straight back from the fetch.
void _rolloverCheck() {
if (!mounted) return;
_armRollover();
if (!_day.rollIfNeeded()) return;
setState(() {
_allTripStops = <Map<String, dynamic>>[];
_ordersMap = <String, Map<String, dynamic>>{};
_orderIds = <String>[];
_selectedOrders = <String, bool>{};
_selectedStopIds = <String>{};
_acceptedStops = <Map<String, dynamic>>[];
isAllSelected = false;
// The fetch short-circuits on an unchanged payload, and the payload has
// not changed — only the day it is read against has. Clearing the key is
// what lets the new day's answer through.
_lastQueuesJson = '';
});
_fetchQueues();
}
/// See [HomepageArrivalPrecedence]. Kept as a one-line forwarder so the call /// See [HomepageArrivalPrecedence]. Kept as a one-line forwarder so the call
/// site reads the same as it did while the rule itself is reachable from a /// site reads the same as it did while the rule itself is reachable from a
/// test — a precedence contract that can only be exercised by building the /// test — a precedence contract that can only be exercised by building the
@@ -1388,14 +1523,21 @@ class _HomepageState extends State<Homepage>
...await getSkippedOrderIds(), ...await getSkippedOrderIds(),
}; };
final activeOrders = itemsV2.whereType<Map<String, dynamic>>().where(( // ── Through the same day filter as the queue ──
booking, //
) { // These are merged into the trip by [_tripStops], and a row this call
final status = (booking['orderstatus']?.toString().toLowerCase() ?? '') // carries that the queue does not is **appended** — so an unfinished
.trim(); // stop still reporting `active` from a shift two days ago walked past
if (status != 'active') return false; // [_todayOnly] and back onto Home through the side door, wearing a LIVE
return !closedIds.contains((booking['orderid'] ?? '').toString()); // mark. One day, one rule, however the row arrived.
}).toList(); final activeOrders = _todayOnly(
itemsV2.whereType<Map<String, dynamic>>().where((booking) {
final status =
(booking['orderstatus']?.toString().toLowerCase() ?? '').trim();
if (status != 'active') return false;
return !closedIds.contains((booking['orderid'] ?? '').toString());
}).toList(),
);
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -2048,7 +2190,7 @@ class _HomepageState extends State<Homepage>
stage: rung, stage: rung,
placeName: place, placeName: place,
count: ids.length, count: ids.length,
lines: BagManifest.forGroup(batch), lines: OrderManifest.forGroup(batch),
onConfirm: (outcome) async { onConfirm: (outcome) async {
setState(() => _busyStopIds.addAll(ids)); setState(() => _busyStopIds.addAll(ids));
try { try {
@@ -2069,9 +2211,9 @@ class _HomepageState extends State<Homepage>
} }
if (refusal != null) return refusal; if (refusal != null) return refusal;
if (rung == StopStatus.arrived) { if (rung == StopStatus.arrived) {
await saveBagLabels({ await saveOrderLabels({
for (final line in BagManifest.forGroup(loaded)) for (final line in OrderManifest.forGroup(loaded))
line.orderId: line.bag, line.orderId: line.label,
}); });
} }
if (missing.isNotEmpty) { if (missing.isNotEmpty) {
@@ -2230,8 +2372,8 @@ class _HomepageState extends State<Homepage>
count: ids.length, count: ids.length,
// Named, with the bag each one travels in — this is the list he checks // Named, with the bag each one travels in — this is the list he checks
// against the shelf in front of him. One order, one bag; see // against the shelf in front of him. One order, one bag; see
// [BagManifest]. // [OrderManifest].
lines: BagManifest.forGroup(stops), lines: OrderManifest.forGroup(stops),
onConfirm: (outcome) async { onConfirm: (outcome) async {
setState(() => _busyStopIds.addAll(ids)); setState(() => _busyStopIds.addAll(ids));
try { try {
@@ -2259,11 +2401,11 @@ class _HomepageState extends State<Homepage>
// Joe is Bag 1 of the five that left this counter. Deriving that // Joe is Bag 1 of the five that left this counter. Deriving that
// later from whatever list is on screen breaks it the moment one is // later from whatever list is on screen breaks it the moment one is
// delivered, so it is written down at the one moment it is true and // delivered, so it is written down at the one moment it is true and
// read back unchanged for the rest of the round. See [BagManifest]. // read back unchanged for the rest of the round. See [OrderManifest].
if (action == SelectionAction.pickUp) { if (action == SelectionAction.pickUp) {
await saveBagLabels({ await saveOrderLabels({
for (final line in BagManifest.forGroup(loaded)) for (final line in OrderManifest.forGroup(loaded))
line.orderId: line.bag, line.orderId: line.label,
}); });
} }
if (missing.isNotEmpty) { if (missing.isNotEmpty) {
@@ -3738,6 +3880,9 @@ class _HomepageState extends State<Homepage>
// Trip 1 must not be counted by the bar while Trip 2 // Trip 1 must not be counted by the bar while Trip 2
// is on screen. See [_selectedPendingIds]. // is on screen. See [_selectedPendingIds].
onSelect: (index) { onSelect: (index) {
// His choice from here on, even if it is an empty
// slot — see [_settleTripTab].
_tripTabChosen = true;
if (index == _selectedTripIndex) return; if (index == _selectedTripIndex) return;
setState(() { setState(() {
_selectedTripIndex = index; _selectedTripIndex = index;

View File

@@ -3,7 +3,8 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:latlong2/latlong.dart' show LatLng; import 'package:latlong2/latlong.dart' show LatLng;
import 'package:miler/data/bag_manifest.dart'; import 'package:miler/data/stop_area.dart';
import 'package:miler/data/order_manifest.dart';
import 'package:miler/views/helpers/constants/miler_surface.dart'; import 'package:miler/views/helpers/constants/miler_surface.dart';
import 'package:miler/views/Dashboard/pickups/route_metrics.dart'; import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
import 'package:miler/views/Dashboard/pickups/stop_type.dart'; import 'package:miler/views/Dashboard/pickups/stop_type.dart';
@@ -53,7 +54,7 @@ import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart';
/// [ ➤ Start navigation ] [ 📞 ] /// [ ➤ Start navigation ] [ 📞 ]
/// ``` /// ```
/// ///
/// The manifest is read straight off [BagManifest] — the same source as the /// The manifest is read straight off [OrderManifest] — the same source as the
/// card, the confirmation sheet and the row, so the number he checks against the /// card, the confirmation sheet and the row, so the number he checks against the
/// shelf cannot differ between the screen he decided on and the screen he /// shelf cannot differ between the screen he decided on and the screen he
/// confirms on. **One order is one bag**, here as everywhere. /// confirms on. **One order is one bag**, here as everywhere.
@@ -63,7 +64,7 @@ class PickupPreviewSheet extends StatelessWidget {
final String placeName; final String placeName;
/// Every order the rider collects here, in route order. /// Every order the rider collects here, in route order.
final List<BagLine> lines; final List<ManifestLine> lines;
/// Straight-line metres from the rider to the place. Null when unknown — /// Straight-line metres from the rider to the place. Null when unknown —
/// nothing is invented, exactly as on the card. /// nothing is invented, exactly as on the card.
@@ -100,7 +101,7 @@ class PickupPreviewSheet extends StatelessWidget {
static Future<void> show( static Future<void> show(
BuildContext context, { BuildContext context, {
required String placeName, required String placeName,
required List<BagLine> lines, required List<ManifestLine> lines,
required double? meters, required double? meters,
required Duration ride, required Duration ride,
double? riderLat, double? riderLat,
@@ -147,20 +148,13 @@ class PickupPreviewSheet extends StatelessWidget {
/// every order here shares one counter, so they share one address, and /// every order here shares one counter, so they share one address, and
/// taking both from the same row means the pin and the words can never /// taking both from the same row means the pin and the words can never
/// describe different places. /// describe different places.
String get _address { ///
if (lines.isEmpty) return ''; /// The four key names it reads live in [pickupAddressOf] now, shared with the
final stop = lines.first.stop; /// route card behind this sheet — the card had no address at all while this
for (final key in const [ /// one did, and a counter that is named on the sheet and anonymous on the
'pickupaddress', /// card is the same payload answering two different questions.
'PickupAddress', String get _address =>
'pickup_address', lines.isEmpty ? '' : pickupAddressOf(lines.first.stop);
'address',
]) {
final raw = (stop[key] ?? '').toString().trim();
if (raw.isNotEmpty && raw.toLowerCase() != 'null') return raw;
}
return '';
}
static double _d(dynamic v) { static double _d(dynamic v) {
if (v == null) return 0; if (v == null) return 0;
@@ -438,32 +432,37 @@ class PickupPreviewSheet extends StatelessWidget {
), ),
), ),
), ),
SizedBox(width: 10.w), // The counter's own printed label, where there is
// Ranked under the name, exactly as on the route // one. Ranked under the name, exactly as on the route
// timeline: w700 slate against a w500 mid-tone name // timeline: w700 slate against a w500 mid-tone name
// made a column of shelf references the first thing // made a column of references the first thing read on
// read on a manifest of people. // a manifest of people. Absent on a route whose
Container( // counters print nothing, which is most of them — the
padding: EdgeInsets.symmetric( // row is then the person, which is what it is for.
horizontal: 7.w, if (line.label.isNotEmpty) ...[
vertical: 3.h, SizedBox(width: 10.w),
), Container(
decoration: BoxDecoration( padding: EdgeInsets.symmetric(
color: MilerSurface.canvas, horizontal: 7.w,
borderRadius: BorderRadius.circular( vertical: 3.h,
DesignConstants.radiusLg, ),
decoration: BoxDecoration(
color: MilerSurface.canvas,
borderRadius: BorderRadius.circular(
DesignConstants.radiusLg,
),
),
child: Text(
line.label,
maxLines: 1,
style: MilerType.label.copyWith(
fontSize: 12.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
),
), ),
), ),
child: Text( ],
line.bag,
maxLines: 1,
style: MilerType.label.copyWith(
fontSize: 12.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
),
),
),
], ],
), ),
), ),
@@ -474,7 +473,7 @@ class PickupPreviewSheet extends StatelessWidget {
), ),
SizedBox(height: 8.h), SizedBox(height: 8.h),
Text( Text(
BagManifest.countLabel(lines.length), OrderManifest.countLabel(lines.length),
style: MilerType.micro.copyWith( style: MilerType.micro.copyWith(
fontSize: 12.5.sp, fontSize: 12.5.sp,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:miler/data/bag_manifest.dart'; import 'package:miler/data/order_manifest.dart';
import 'package:miler/views/helpers/constants/miler_surface.dart'; import 'package:miler/views/helpers/constants/miler_surface.dart';
import 'package:miler/data/service_profile.dart'; import 'package:miler/data/service_profile.dart';
import 'package:miler/data/stop_area.dart'; import 'package:miler/data/stop_area.dart';
@@ -87,7 +87,7 @@ import 'package:miler/views/helpers/constants/miler_type.dart';
/// One pickup place and every order the rider collects there. /// One pickup place and every order the rider collects there.
/// ///
/// Built by the page from the trip's own stops — this holds no state of its /// Built by the page from the trip's own stops — this holds no state of its
/// own and derives no counts. The bag lines come from [BagManifest.forGroup], /// own and derives no counts. The bag lines come from [OrderManifest.forGroup],
/// which is the single source for "how many bags" and "which bag": the count is /// which is the single source for "how many bags" and "which bag": the count is
/// `orders.length` because a bag is what an order arrives in, and the identity /// `orders.length` because a bag is what an order arrives in, and the identity
/// is the printed label or the order's position **in this group**. The timeline /// is the printed label or the order's position **in this group**. The timeline
@@ -101,6 +101,26 @@ class RouteGroup {
/// which case the group is drawn flat (see [RouteTimeline]). /// which case the group is drawn flat (see [RouteTimeline]).
final String name; final String name;
/// The street the counter is on — where [name] actually is.
///
/// ── Why a name was not enough ──
///
/// The card gave the place a name, a load and a distance, and then expected
/// the rider to know where "Vidhya Kitchen" is. He often does; on his first
/// week, or on a kitchen the hub added yesterday, he does not — and the only
/// way to the address was to tap through to the preview sheet, which is a
/// sheet and a dismiss to answer *where am I going*, the plainest question
/// the card exists for.
///
/// It sits directly above the load, so the header reads as one descending
/// statement: **what this place is, where it is, what is waiting there**.
/// Put below the load it would have separated the counts from the journey
/// figures they are read against.
///
/// Empty is normal and prints nothing — a payload without an address, or a
/// flat stop whose own name already *is* the address.
final String location;
/// The orders still shown on Home, in route order. /// The orders still shown on Home, in route order.
final List<Map<String, dynamic>> stops; final List<Map<String, dynamic>> stops;
@@ -112,7 +132,7 @@ class RouteGroup {
/// the rider has collected Bag 1 and it leaves Home, numbering the four that /// the rider has collected Bag 1 and it leaves Home, numbering the four that
/// remain from the visible list would renumber Bag 2 as Bag 1 — and he would /// remain from the visible list would renumber Bag 2 as Bag 1 — and he would
/// be reading a different number off the phone from the one printed on the /// be reading a different number off the phone from the one printed on the
/// shelf. See [BagManifest]. /// shelf. See [OrderManifest].
final List<Map<String, dynamic>> allStops; final List<Map<String, dynamic>> allStops;
/// The trip index of the first stop, for travel time and for numbering. /// The trip index of the first stop, for travel time and for numbering.
@@ -207,6 +227,7 @@ class RouteGroup {
const RouteGroup({ const RouteGroup({
required this.key, required this.key,
required this.name, required this.name,
this.location = '',
required this.stops, required this.stops,
required this.allStops, required this.allStops,
required this.firstIndex, required this.firstIndex,
@@ -256,17 +277,17 @@ class RouteGroup {
/// The manifest is built from [allStops] and then filtered, never built from /// The manifest is built from [allStops] and then filtered, never built from
/// [stops]: the count and the identity both come from the pickup group the /// [stops]: the count and the identity both come from the pickup group the
/// rider was handed, not from whatever is left of it. /// rider was handed, not from whatever is left of it.
List<BagLine> get manifest { List<ManifestLine> get manifest {
final shown = {for (final s in stops) (s['orderid'] ?? '').toString()}; final shown = {for (final s in stops) (s['orderid'] ?? '').toString()};
return [ return [
for (final line in BagManifest.forGroup(allStops)) for (final line in OrderManifest.forGroup(allStops))
if (shown.contains(line.orderId)) line, if (shown.contains(line.orderId)) line,
]; ];
} }
/// `5 orders · 5 bags`. The two halves of the rule, side by side, so the /// `5 orders · 5 bags`. The two halves of the rule, side by side, so the
/// rider can check the shelf against the phone without doing arithmetic. /// rider can check the shelf against the phone without doing arithmetic.
String get countLabel => BagManifest.countLabel(stops.length); String get countLabel => OrderManifest.countLabel(stops.length);
/// Orders off this counter the rider has not taken on yet. /// Orders off this counter the rider has not taken on yet.
int get pendingCount => int get pendingCount =>
@@ -1200,7 +1221,7 @@ class PickupTimelineGroup extends StatelessWidget {
} }
if (group.isSettled) { if (group.isSettled) {
final n = group.allStops.length; final n = group.allStops.length;
return '$n ${n == 1 ? 'bag' : 'bags'} collected'; return n == 1 ? '1 collected' : '$n collected';
} }
return <String>[ return <String>[
// ── A flat node is a stop, and a stop has a position ── // ── A flat node is a stop, and a stop has a position ──
@@ -1210,12 +1231,24 @@ class PickupTimelineGroup extends StatelessWidget {
// in the run — the trip's own index, which does **not** renumber as stops // in the run — the trip's own index, which does **not** renumber as stops
// finish, so a stop is never renamed mid-shift. // finish, so a stop is never renamed mid-shift.
if (group.flat) 'Stop ${group.firstIndex + 1}' else group.countLabel, if (group.flat) 'Stop ${group.firstIndex + 1}' else group.countLabel,
// How much of this counter is not his yet. A group can hold one accepted
// order and one still pending, and the count above cannot say so alone.
if (!group.flat && if (!group.flat &&
group.pendingCount > 0 && group.pendingCount > 0 &&
group.pendingCount < group.stops.length) group.pendingCount < group.stops.length)
'${group.pendingCount} to accept', '${group.pendingCount} to accept',
// ── How much of this counter is still waiting on him ──
//
// The chip beside it states the group's **own rung** with a fraction —
// `Pending 8/8` on an untouched counter, `Accepted 1/2` on one he has
// started. That answers the all-pending case, which used to say nothing
// at all: a fresh morning where the card read `Pending` and `10 bags` and
// left the rider to join them into *ten decisions are waiting on me*.
//
// It cannot answer the **mixed** case, and that is what this clause is
// for. A group holding one accepted order and one pending one is on the
// accepted rung, so the chip says `Accepted 1/2` — true, and silent about
// the one still to decide. The two never overlap: this appears only when
// some but not all of the counter is pending, which is precisely when the
// chip is showing a different rung.
// ── The selection count is not stated here ── // ── The selection count is not stated here ──
// //
// It used to append `· 5 selected`, which made this the **third** place // It used to append `· 5 selected`, which made this the **third** place
@@ -1253,6 +1286,10 @@ class PickupTimelineGroup extends StatelessWidget {
final journey = rejected || group.isSettled ? '' : group.journeyLabel; final journey = rejected || group.isSettled ? '' : group.journeyLabel;
final showJourney = journey.isNotEmpty && twoColumn; final showJourney = journey.isNotEmpty && twoColumn;
// Suppressed on a place the rider has finished with and on one he declined:
// the address is wayfinding, and there is nowhere left to go.
final location = rejected || group.isSettled ? '' : group.location;
final load = [ final load = [
_loadLine(chosen), _loadLine(chosen),
if (journey.isNotEmpty && !twoColumn) journey, if (journey.isNotEmpty && !twoColumn) journey,
@@ -1361,7 +1398,12 @@ class PickupTimelineGroup extends StatelessWidget {
// rider needs them: where, how much, how far. Unaffected by // rider needs them: where, how much, how far. Unaffected by
// the two-column break — a reader hears the same three facts // the two-column break — a reader hears the same three facts
// either way. // either way.
label: [group.name, load, if (showJourney) journey].join(', '), label: [
group.name,
if (location.isNotEmpty) location,
load,
if (showJourney) journey,
].join(', '),
// The non-flat hint said "Opens the pickup preview" — a // The non-flat hint said "Opens the pickup preview" — a
// sentence about a tap this header stopped owning when the // sentence about a tap this header stopped owning when the
// preview moved to the utility circle. A wrong hint is worse // preview moved to the utility circle. A wrong hint is worse
@@ -1472,6 +1514,49 @@ class PickupTimelineGroup extends StatelessWidget {
// that row was already spending on a `Spacer`. Same // that row was already spending on a `Spacer`. Same
// reading order — identity, load, state — and the // reading order — identity, load, state — and the
// geometry is now identical in every state. // geometry is now identical in every state.
// ── Where the counter is, above what is on it ──
//
// Between the name and the load, because that is
// the order the questions arrive in: which place,
// where is it, how much is waiting. A pin rather
// than a label — "Pickup location" would be a word
// spent saying what the glyph and the position
// already say.
//
// Two lines, matching the name above it: real
// Coimbatore addresses are long, and an address cut
// to `Gandhipuram Main…` answers nothing a blank
// line would not have answered. Set in the page's
// quiet grey — it is orientation, not a figure the
// rider acts on.
if (location.isNotEmpty) ...[
SizedBox(height: 3.h),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.only(top: 1.h),
child: Icon(
LucideIcons.mapPin,
size: 12.sp,
color: ColorConstants.secondaryText,
),
),
SizedBox(width: 5.w),
Flexible(
child: Text(
location,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: MilerType.micro.copyWith(
fontSize: 12.5.sp,
color: ColorConstants.secondaryText,
),
),
),
],
),
],
SizedBox(height: 4.h), SizedBox(height: 4.h),
Row( Row(
children: [ children: [
@@ -1620,23 +1705,38 @@ class PickupTimelineGroup extends StatelessWidget {
// silence was worse than the repetition: a rider scanning a run had to // silence was worse than the repetition: a rider scanning a run had to
// infer "not mine yet" from the *absence* of a word, which is the one thing // infer "not mine yet" from the *absence* of a word, which is the one thing
// a glance cannot do. // a glance cannot do.
// ── Pending is neutral, because nothing has gone wrong ── // ── Pending has its own amber, and it is not the warning one ──
// //
// It was amber, on the argument that undecided work "wants something" and // It was grey, and before that it was [ColorConstants.warning]. Both were
// should therefore be visible. The argument is right about visibility and // half right. Grey said "nothing has gone wrong", which is true; warning
// wrong about the colour: amber is this app's exception hue — a skipped // said "this wants something from you", which is also true — and warning
// stop, a late run, a bag that never arrived — and on a fresh morning every // is the hue this app spends on a skip, a short payment, a bag that never
// kitchen is pending, so the whole screen opened in warning. // came, so a morning where every kitchen is pending opened the whole screen
// in exception and taught the rider to stop reading the colour.
// //
// A state that is true of *all* the work is not an exception; it is the // [ColorConstants.pendingAmber] is the third answer: a gold that reads as
// starting condition. Spending the attention colour on it leaves nothing // *waiting* rather than as *wrong*, far enough from the burnt orange of
// to say with when a stop genuinely does go wrong, and a rider who sees // warning that Pending and Skipped are two distinct marks down a run. The
// amber on every group learns to stop reading it. Neutral, and the reason // words differ too; this is what makes them differ at a glance.
// it is still readable is that it sits on its own metadata line rather // ── And it says how many ──
// than competing with the kitchen name. //
StopState.pending => const _StateWord( // A bare `Pending` made the rider join two facts to get the one he wants.
label: 'Pending', // The card said `Pending` and, twenty points above, `10 orders · 10 bags`;
color: ColorConstants.secondaryText, // *ten decisions are waiting on me* was the arithmetic between them, and on
// a fresh morning — every group pending, nothing accepted — that was the
// whole screen asking him to do it.
//
// Stated as a fraction because that is what Accepted has always done here,
// and the two must read as one vocabulary: `Pending 10/10` on a counter
// untouched, `Pending 2/4` on one half-taken, `Accepted 2/4` from the other
// side of the same decision. The bare word survives where a fraction would
// be noise — a flat parcel stop is one order, and `Pending 1/1` says
// nothing `Pending` does not.
StopState.pending => _StateWord(
label: group.groupSize > 1
? 'Pending ${group.pendingCount}/${group.groupSize}'
: 'Pending',
color: ColorConstants.pendingAmber,
), ),
// ── "Accepted" alone does not say how much of the counter he took ── // ── "Accepted" alone does not say how much of the counter he took ──
// //
@@ -1649,19 +1749,25 @@ class PickupTimelineGroup extends StatelessWidget {
// Only where there is more than one order to be a fraction of — a parcel // Only where there is more than one order to be a fraction of — a parcel
// route is one order per place, and `Accepted (1 of 1)` is noise. // route is one order per place, and `Accepted (1 of 1)` is noise.
StopState.accepted => _StateWord( StopState.accepted => _StateWord(
// Brand only on the group he is riding to; slate on the ones he has taken // ── Accepted is green ──
// but is not working. Red marks the mission, not the commitment — and it //
// is now the only thing on the card that can be confused with amber, so // It was brand red on the group being ridden to and slate on the rest,
// the two states read apart at a glance. // which made the word carry two facts at once — *taken on* and *this is
// the one you are working* — and the second was the louder of them. A
// rider glancing down a run read the red as the state.
//
// Green says the one thing the word is for: this is yours, it is settled,
// nothing here needs deciding. Which one he is *on* is already said three
// ways that do not need a hue — the lit node, the larger title, and the
// Navigate button that only the current place carries.
//
// `Accepted (2 of 4)` truncated to `Accepted (2 o…` the moment the row // `Accepted (2 of 4)` truncated to `Accepted (2 o…` the moment the row
// also carried Navigate, Call and the "i" — which is every accepted // also carried Navigate, Call and the "i" — which is every accepted
// group. A state that ellipsises has lost the number it exists to state. // group. A state that ellipsises has lost the number it exists to state.
label: group.groupSize > 1 label: group.groupSize > 1
? 'Accepted ${group.onRungCount}/${group.groupSize}' ? 'Accepted ${group.onRungCount}/${group.groupSize}'
: 'Accepted', : 'Accepted',
color: isCurrent color: ColorConstants.acceptGreen,
? ColorConstants.primary
: ColorConstants.onSurfaceVariant,
), ),
// ── The one the rider must not miss ── // ── The one the rider must not miss ──
// //
@@ -1674,9 +1780,19 @@ class PickupTimelineGroup extends StatelessWidget {
// fact is *this is the one he is on* — and each says which rung it is. // fact is *this is the one he is on* — and each says which rung it is.
StopState.arrived => const LiveMark(label: 'Arrived'), StopState.arrived => const LiveMark(label: 'Arrived'),
StopState.active => const LiveMark(), StopState.active => const LiveMark(),
// ── Picked is blue, now that green means accepted ──
//
// It was green, back when green was free. It cannot stay green beside an
// accepted state that is also green: the two rungs a rider most needs to
// tell apart on one counter — *taken on* and *in my hands* — would be one
// colour and two words.
//
// Blue is not an arbitrary third: it is [ColorConstants.deliveryAccent],
// what this app already paints the delivery leg in, and a picked bag has
// become exactly that. The colour says where the work went.
StopState.collected || StopState.done => const _StateWord( StopState.collected || StopState.done => const _StateWord(
label: 'Collected', label: 'Collected',
color: ColorConstants.acceptGreen, color: ColorConstants.pickedBlue,
), ),
// ── The two ends of "not going to happen here" ── // ── The two ends of "not going to happen here" ──
// //
@@ -1715,7 +1831,7 @@ class PickupTimelineGroup extends StatelessWidget {
// ── The body: the journey, the selection helper, then the milestones ───── // ── The body: the journey, the selection helper, then the milestones ─────
Widget _body(BuildContext context, List<BagLine> manifest, int chosen) { Widget _body(BuildContext context, List<ManifestLine> manifest, int chosen) {
final ids = _choosableIds; final ids = _choosableIds;
final selectable = _isChoosable(group.state) && onToggleSelect != null; final selectable = _isChoosable(group.state) && onToggleSelect != null;
@@ -1864,7 +1980,7 @@ class PickupTimelineGroup extends StatelessWidget {
/// bag* — the two facts he matches against a shelf — and everything else is one /// bag* — the two facts he matches against a shelf — and everything else is one
/// tap away in the sheet that already exists. /// tap away in the sheet that already exists.
class PickupTimelineStep extends StatelessWidget { class PickupTimelineStep extends StatelessWidget {
final BagLine line; final ManifestLine line;
/// 1-based position **within its own pickup group**, which is also its bag /// 1-based position **within its own pickup group**, which is also its bag
/// position when the kitchen printed no label. Never route-global. /// position when the kitchen printed no label. Never route-global.
@@ -2013,9 +2129,7 @@ class PickupTimelineStep extends StatelessWidget {
child: Semantics( child: Semantics(
button: true, button: true,
checked: selectable ? selected : null, checked: selectable ? selected : null,
label: selectable label: selectable ? 'Select $customer' : customer,
? 'Select $customer, ${line.bag}'
: '$customer, ${line.bag}',
excludeSemantics: true, excludeSemantics: true,
child: InkWell( child: InkWell(
onTap: selectable && !busy onTap: selectable && !busy
@@ -2070,7 +2184,11 @@ class PickupTimelineStep extends StatelessWidget {
), ),
), ),
_OrderOpenBlock( _OrderOpenBlock(
bag: rejected ? 'Rejected' : line.bag, // The counter's own printed label when there is one,
// and the word `Rejected` where the row has been
// declined. Never an invented number — see
// [OrderManifest.labelFor].
label: rejected ? 'Rejected' : line.label,
orderId: line.orderId, orderId: line.orderId,
selected: selected, selected: selected,
onTap: canOpen ? onOpen : null, onTap: canOpen ? onOpen : null,
@@ -2087,23 +2205,34 @@ class PickupTimelineStep extends StatelessWidget {
} }
} }
/// `Bag 1 ›` over the order's own id — the step's way into its detail sheet. /// The step's way into its detail sheet: the counter's printed label, when
/// there is one, and the chevron that opens the order.
/// ///
/// One block rather than three loose elements, because they answer one /// ── It used to manufacture a reference ──
/// question between them: *which order is this, and show me it*. The chevron is ///
/// the affordance; the bag and the id are what it is about. /// The tag read `Bag 1 … Bag n`, falling back to the order's position in its
/// group whenever the payload printed nothing — so on the common route, where
/// no counter prints labels, every row carried a shelf reference the shelf had
/// never heard of. It was the app numbering its own list and drawing the result
/// as a physical fact, two columns from a rail that numbers the same rows.
///
/// What the counter really printed is a fact about an object in the rider's
/// hands, so that still shows, verbatim. Where nothing was printed the corner
/// carries the chevron alone, which is the affordance the block existed for.
/// The neighbourhood a stop is in. One implementation, shared with the /// The neighbourhood a stop is in. One implementation, shared with the
/// Deliveries queue — see [areaOf]. /// Deliveries queue — see [areaOf].
String _areaOf(Map<String, dynamic> stop) => areaOf(stop); String _areaOf(Map<String, dynamic> stop) => areaOf(stop);
class _OrderOpenBlock extends StatelessWidget { class _OrderOpenBlock extends StatelessWidget {
final String bag; /// The counter's own printed label, or `''` when it printed none.
final String label;
final String orderId; final String orderId;
final bool selected; final bool selected;
final VoidCallback? onTap; final VoidCallback? onTap;
const _OrderOpenBlock({ const _OrderOpenBlock({
required this.bag, required this.label,
required this.orderId, required this.orderId,
required this.selected, required this.selected,
required this.onTap, required this.onTap,
@@ -2115,7 +2244,10 @@ class _OrderOpenBlock extends StatelessWidget {
return Semantics( return Semantics(
button: onTap != null, button: onTap != null,
label: id.isEmpty ? bag : '$bag, order $id', label: [
if (label.isNotEmpty) label,
if (id.isNotEmpty) 'order $id',
].join(', '),
hint: onTap == null ? null : 'Opens order details', hint: onTap == null ? null : 'Opens order details',
excludeSemantics: true, excludeSemantics: true,
child: InkWell( child: InkWell(
@@ -2128,99 +2260,73 @@ class _OrderOpenBlock extends StatelessWidget {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
ConstrainedBox( if (label.isNotEmpty)
// Capped so a kitchen's own long printed label (`DG-100042-A`) ConstrainedBox(
// cannot take the customer's name with it. // Capped so a kitchen's own long printed label
// // (`DG-100042-A`) cannot take the customer's name with it.
// ── And the column stops growing before the name does ── //
// // ── And the column stops growing before the name does ──
// `Bag 1` is a shelf reference: it is *compared*, not read as //
// prose, so it does not need to keep pace with a rider's text // A printed label is *compared*, not read as prose, so it
// size. Left to scale it took ~150pt of a 320pt row at 2×, and // does not need to keep pace with a rider's text size. Left
// the thing that yielded was the customer's name — which is // to scale it took ~150pt of a 320pt row at 2×, and the
// the one item on the row the brief says must yield last. // thing that yielded was the customer's name — the one item
// // on the row the brief says must yield last.
// Clamped at 1.25×, which keeps it comfortably legible against //
// a physical shelf while handing the rest of the row back to // Clamped at 1.25×, which keeps it legible against a
// the name and the area. // physical shelf while handing the rest of the row back to
constraints: BoxConstraints(maxWidth: 104.w), // the name and the area.
child: Column( constraints: BoxConstraints(maxWidth: 104.w),
crossAxisAlignment: CrossAxisAlignment.end, child: Container(
mainAxisSize: MainAxisSize.min, padding: EdgeInsets.symmetric(
children: [ horizontal: 7.w,
// ── One order, one bag ── vertical: 3.h,
// ),
// Read straight off [BagManifest]: never a quantity, never a decoration: BoxDecoration(
// route-global number. Right-aligned on every row so a // White, because these rows live inside the canvas
// column of them lines up against a shelf being counted. // well: layer-1-on-canvas, the same 1.29 step the rest
// of the system stands on. (Canvas-on-canvas was
// invisible the moment the well returned.)
color: selected
? ColorConstants.primary.withValues(alpha: 0.10)
: MilerSurface.working,
borderRadius: BorderRadius.circular(
DesignConstants.radiusLg,
),
),
// ── It ranks under the name, not over it ── // ── It ranks under the name, not over it ──
// //
// Drawn at w700 in full slate this sat *above* the // Drawn at w700 in full slate this sat *above* the
// customer, who is w500 in the mid-tone until he is // customer, who is w500 in the mid-tone until he is
// ticked. So on a fresh route — every row undecided, // ticked — so on a fresh route the boldest thing on every
// which is how the screen opens — the boldest thing on // line was a shelf reference, and the eye read a column of
// every line was a shelf number, and the eye read a // them before it read a single person. It steps to w600 in
// column of bags before it read a single person. // the mid-tone instead, one rung under the undecided name,
// // and follows the row up to slate the moment it is taken.
// The reference still needs to be scannable against a child: Text(
// physical shelf, so it keeps a weight of its own: it label,
// steps to w600 in the mid-tone, one rung under the maxLines: 1,
// undecided name, and follows the row up to slate the overflow: TextOverflow.ellipsis,
// moment the row is taken. The bag never leads; it style: MilerType.label.copyWith(
// travels with whatever the row currently is. fontSize: 12.sp,
// ── And it is a tag, because it is a label ── fontWeight: selected
// ? FontWeight.w700
// A bag number is a physical reference — the sticker on : FontWeight.w600,
// the shelf — not a sentence, and every delivery app the
// rider carries sets that kind of fact as a small tag.
// The fill is the page's canvas tone, the only grey that
// measurably stands on this white panel (1.29:1), so the
// tag reads as an object without borrowing a border. It
// still ranks under the name: quiet ink at rest, brand
// tint the moment the row is ticked.
Container(
padding: EdgeInsets.symmetric(
horizontal: 7.w,
vertical: 3.h,
),
decoration: BoxDecoration(
// White, because these rows live inside the canvas
// well: layer-1-on-canvas, the same 1.29 step the
// rest of the system stands on. (Canvas-on-canvas
// was invisible the moment the well returned.)
color: selected color: selected
? ColorConstants.primary.withValues(alpha: 0.10) ? ColorConstants.primary
: MilerSurface.working, : ColorConstants.secondaryText,
borderRadius: BorderRadius.circular(
DesignConstants.radiusLg,
),
),
child: Text(
bag,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: MilerType.label.copyWith(
fontSize: 12.sp,
fontWeight: selected
? FontWeight.w700
: FontWeight.w600,
color: selected
? ColorConstants.primary
: ColorConstants.secondaryText,
),
), ),
), ),
// ── The reference is not on the row any more ── ),
//
// It is what the office reads down a phone once, when
// something has gone wrong. The line it occupied now
// carries the drop area, which is what the rider is
// deciding on every time he looks at the list. The id is on
// the detail sheet this block opens, and on the Activity
// record — both places the rare question is actually asked.
],
), ),
), // ── The order id is not on the row ──
//
// It is what the office reads down a phone once, when something
// has gone wrong. The line it occupied carries the drop area,
// which is what the rider is deciding on every time he looks at
// the list. The id is on the detail sheet this block opens, and
// on the Activity record — both places the rare question is
// actually asked.
if (onTap != null) if (onTap != null)
Icon( Icon(
LucideIcons.chevronRight, LucideIcons.chevronRight,
@@ -2629,7 +2735,7 @@ class _StepNode extends StatelessWidget {
// //
// It keeps its **number**. The numeral is the order's bag identity, matched // It keeps its **number**. The numeral is the order's bag identity, matched
// against a printed label on a shelf, and it must not change or disappear // against a printed label on a shelf, and it must not change or disappear
// because the rider accepted it — see [BagManifest]. So accepted is a // because the rider accepted it — see [OrderManifest]. So accepted is a
// filled disc with a white numeral, not a tick: a tick would claim work // filled disc with a white numeral, not a tick: a tick would claim work
// that has not happened. // that has not happened.
final filled = selected || done || accepted; final filled = selected || done || accepted;

View File

@@ -7,7 +7,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:miler/Models/stop_status.dart'; import 'package:miler/Models/stop_status.dart';
import 'package:miler/views/helpers/constants/miler_surface.dart'; import 'package:miler/views/helpers/constants/miler_surface.dart';
import 'package:miler/data/bag_manifest.dart'; import 'package:miler/data/order_manifest.dart';
import 'package:miler/data/service_profile.dart'; import 'package:miler/data/service_profile.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/design_constants.dart'; import 'package:miler/views/helpers/constants/design_constants.dart';
@@ -94,7 +94,7 @@ class StopActionSheet extends StatefulWidget {
/// ///
/// Empty renders the sheet as it was — a heading, a count and the slide — /// Empty renders the sheet as it was — a heading, a count and the slide —
/// which is correct for a rung that covers a single anonymous stop. /// which is correct for a rung that covers a single anonymous stop.
final List<BagLine> lines; final List<ManifestLine> lines;
/// Runs the write, given what the rider actually confirmed. Returns null on /// Runs the write, given what the rider actually confirmed. Returns null on
/// success, or a message to show without closing — the sheet stays open so he /// success, or a message to show without closing — the sheet stays open so he
@@ -117,7 +117,7 @@ class StopActionSheet extends StatefulWidget {
required String placeName, required String placeName,
required int count, required int count,
required Future<String?> Function(RungOutcome) onConfirm, required Future<String?> Function(RungOutcome) onConfirm,
List<BagLine> lines = const [], List<ManifestLine> lines = const [],
}) async { }) async {
// Through the kit — which also gives this sheet the app's entrance curve. // Through the kit — which also gives this sheet the app's entrance curve.
// It was the one sheet still on Flutter's default 250ms decelerate: the // It was the one sheet still on Flutter's default 250ms decelerate: the
@@ -188,11 +188,11 @@ class _StopActionSheetState extends State<StopActionSheet> {
} }
/// `5 orders · 5 bags` — the rule, stated where he can check it against the /// `5 orders · 5 bags` — the rule, stated where he can check it against the
/// shelf. See [BagManifest]. Once he reports a shortfall it counts what he is /// shelf. See [OrderManifest]. Once he reports a shortfall it counts what he is
/// actually leaving with, and names the gap rather than quietly shrinking. /// actually leaving with, and names the gap rather than quietly shrinking.
String get _subtitle => _missing.isEmpty String get _subtitle => _missing.isEmpty
? BagManifest.countLabel(widget.count) ? OrderManifest.countLabel(widget.count)
: '${BagManifest.countLabel(_loaded)} · ${_missing.length} missing'; : '${OrderManifest.countLabel(_loaded)} · ${_missing.length} missing';
/// What the slide will actually change, in the rider's words. "Slide to /// What the slide will actually change, in the rider's words. "Slide to
/// update" said nothing a rider could be wrong about; these name the claim — /// update" said nothing a rider could be wrong about; these name the claim —
@@ -223,7 +223,7 @@ class _StopActionSheetState extends State<StopActionSheet> {
} }
if (_missing.isEmpty) { if (_missing.isEmpty) {
return ( return (
'Make sure all ${widget.count} bags are loaded before continuing.', 'Make sure all ${widget.count} orders are loaded before continuing.',
false, false,
); );
} }
@@ -426,7 +426,7 @@ class _StopActionSheetState extends State<StopActionSheet> {
color: ColorConstants.errorRed, color: ColorConstants.errorRed,
), ),
label: Text( label: Text(
'Report missing bag', 'Report a missing order',
style: TextStyle( style: TextStyle(
fontSize: 14.sp, fontSize: 14.sp,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
@@ -443,7 +443,7 @@ class _StopActionSheetState extends State<StopActionSheet> {
Expanded( Expanded(
child: Text( child: Text(
_missing.isEmpty _missing.isEmpty
? 'Tap any bag the counter could not supply' ? 'Tap any order the counter could not supply'
: '${_missing.length} marked missing', : '${_missing.length} marked missing',
style: TextStyle( style: TextStyle(
fontSize: 13.5.sp, fontSize: 13.5.sp,
@@ -588,9 +588,16 @@ class _StopActionSheetState extends State<StopActionSheet> {
SizedBox(width: 10.w), SizedBox(width: 10.w),
// The same tag the route rows wear — one language for one // The same tag the route rows wear — one language for one
// fact, from Home to the sheet it opens. White stands on the // fact, from Home to the sheet it opens. White stands on the
// manifest's tinted panel; a missing bag keeps the error ink // manifest's tinted panel; a missing order keeps the error ink
// in the same body so the exception is the colour, not the // in the same body so the exception is the colour, not the
// shape. // shape.
//
// Drawn whenever there is something to say: the counter's own
// printed label, or `Missing` once the rider has reported a
// shortfall. A route whose counters print nothing shows the tag
// only on the exception, which is the one time it carries a
// fact the row does not already state.
if (gone || line.label.isNotEmpty)
Container( Container(
padding: EdgeInsets.symmetric(horizontal: 7.w, vertical: 3.h), padding: EdgeInsets.symmetric(horizontal: 7.w, vertical: 3.h),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -602,7 +609,7 @@ class _StopActionSheetState extends State<StopActionSheet> {
), ),
), ),
child: Text( child: Text(
gone ? 'Missing' : line.bag, gone ? 'Missing' : line.label,
maxLines: 1, maxLines: 1,
style: TextStyle( style: TextStyle(
fontSize: 12.5.sp, fontSize: 12.5.sp,

View File

@@ -66,10 +66,10 @@ class StopCard extends StatelessWidget {
/// Time at the door, shown in the card's header. /// Time at the door, shown in the card's header.
final Duration service; final Duration service;
/// Which bag this order travels in — `Bag 3`, or the label the kitchen /// Which label this order travels in — `Bag 3`, or the label the kitchen
/// printed. One order, one bag; the count is never a separate figure. See /// printed. One order, one label; the count is never a separate figure. See
/// [BagManifest]. /// [OrderManifest].
final String bag; final String label;
/// True when this card sits under a heading that already names its pickup /// True when this card sits under a heading that already names its pickup
/// location. /// location.
@@ -122,7 +122,7 @@ class StopCard extends StatelessWidget {
required this.state, required this.state,
required this.address, required this.address,
required this.service, required this.service,
this.bag = '', this.label = '',
this.groupedUnderSource = false, this.groupedUnderSource = false,
this.selected = false, this.selected = false,
this.busy = false, this.busy = false,
@@ -256,7 +256,7 @@ class StopCard extends StatelessWidget {
SizedBox(height: 12.h), SizedBox(height: 12.h),
_factsRow( _factsRow(
kind, kind,
bagShownAbove: twoLeg && !grouped && bag.isNotEmpty, labelShownAbove: twoLeg && !grouped && label.isNotEmpty,
), ),
if (action != null) ...[SizedBox(height: 16.h), action], if (action != null) ...[SizedBox(height: 16.h), action],
], ],
@@ -318,7 +318,7 @@ class StopCard extends StatelessWidget {
/// `→ Joe · Bag 1` — the far end, in one line. /// `→ Joe · Bag 1` — the far end, in one line.
/// ///
/// Everything the second leg used to spend a whole block on: who it is for, /// Everything the second leg used to spend a whole block on: who it is for,
/// and which bag it is. The address is one tap away in the detail sheet, /// and which label it is. The address is one tap away in the detail sheet,
/// where a rider going to a *kitchen* has no use for it yet. /// where a rider going to a *kitchen* has no use for it yet.
Widget _dropLine(String customer) { Widget _dropLine(String customer) {
return Row( return Row(
@@ -343,7 +343,10 @@ class StopCard extends StatelessWidget {
), ),
), ),
), ),
if (bag.isNotEmpty) ...[SizedBox(width: 10.w), _BagChip(label: bag)], if (label.isNotEmpty) ...[
SizedBox(width: 10.w),
_LabelChip(label: label),
],
], ],
); );
} }
@@ -352,7 +355,7 @@ class StopCard extends StatelessWidget {
/// ///
/// This is the line a rider re-reads at a red light, so nothing on it is /// This is the line a rider re-reads at a red light, so nothing on it is
/// under 14sp and none of it is in a box. /// under 14sp and none of it is in a box.
Widget _factsRow(StopKind kind, {required bool bagShownAbove}) { Widget _factsRow(StopKind kind, {required bool labelShownAbove}) {
final due = DateTime.tryParse( final due = DateTime.tryParse(
(stop['expected_pickup_time'] ?? stop['eta'] ?? '').toString().trim(), (stop['expected_pickup_time'] ?? stop['eta'] ?? '').toString().trim(),
); );
@@ -393,33 +396,33 @@ class StopCard extends StatelessWidget {
), ),
], ],
), ),
// What is in it, and which bag it is. The bag appears here whenever the // What is in it, and the counter's own label where there is one. It
// drop line above is not already carrying it — a card must never be // appears here whenever the drop line above is not already carrying it:
// silent about which box this order is, and it must never say it twice. // a card must never say the same thing twice.
Builder( Builder(
builder: (_) { builder: (_) {
final label = bag.isNotEmpty ? bag : stopBagLabel(stop); final printed = label.isNotEmpty ? label : stopPrintedLabel(stop);
final meta = _MetaLine( final meta = _MetaLine(
deliver: kind.hasDelivery ? _qty(deliveryParcelCount(stop)) : 0, deliver: kind.hasDelivery ? _qty(deliveryParcelCount(stop)) : 0,
collect: kind.hasPickup ? _qty(pickupParcelCount(stop)) : 0, collect: kind.hasPickup ? _qty(pickupParcelCount(stop)) : 0,
cash: cash, cash: cash,
muted: _rejected || state == StopState.done, muted: _rejected || state == StopState.done,
// On a meal run the parcel counts are the one-bag rule restated // On a meal run the parcel counts are the one-label rule restated
// as an item count, which is exactly the second number this app // as an item count, which is exactly the second number this app
// does not allow. Only the money survives. // does not allow. Only the money survives.
countsHidden: ServiceProfile.active.deliversToCustomer, countsHidden: ServiceProfile.active.deliversToCustomer,
); );
final showBag = !bagShownAbove && label.isNotEmpty; final showLabel = !labelShownAbove && printed.isNotEmpty;
if (!showBag && meta.isEmpty) return const SizedBox.shrink(); if (!showLabel && meta.isEmpty) return const SizedBox.shrink();
return Padding( return Padding(
padding: EdgeInsets.only(top: 8.h), padding: EdgeInsets.only(top: 8.h),
child: Row( child: Row(
children: [ children: [
if (!meta.isEmpty) Flexible(child: meta), if (!meta.isEmpty) Flexible(child: meta),
if (showBag) ...[ if (showLabel) ...[
if (!meta.isEmpty) SizedBox(width: 10.w), if (!meta.isEmpty) SizedBox(width: 10.w),
_BagChip(label: label), _LabelChip(label: printed),
], ],
], ],
), ),
@@ -441,7 +444,7 @@ class StopCard extends StatelessWidget {
/// journey and the bar offers the claim, and neither duplicates the other. /// journey and the bar offers the claim, and neither duplicates the other.
/// ///
/// A stop that is already *arrived* is the one exception: the single-order /// A stop that is already *arrived* is the one exception: the single-order
/// case ("one bag, one counter") would otherwise make him tick a card he is /// case ("one label, one counter") would otherwise make him tick a card he is
/// looking at to reach a control that talks about "the selection". That /// looking at to reach a control that talks about "the selection". That
/// button opens the same slide sheet the bar does, over the same batch. /// button opens the same slide sheet the bar does, over the same batch.
Widget? _primaryAction() { Widget? _primaryAction() {
@@ -598,16 +601,19 @@ class _IconAction extends StatelessWidget {
/// the card has exactly one colour to spend. It is a chip rather than plain /// the card has exactly one colour to spend. It is a chip rather than plain
/// text because it is read *against an object* — a bordered word is findable on /// text because it is read *against an object* — a bordered word is findable on
/// a card at arm's length in a way a run of grey text is not. /// a card at arm's length in a way a run of grey text is not.
class _BagChip extends StatelessWidget { class _LabelChip extends StatelessWidget {
final String label; final String label;
const _BagChip({required this.label}); const _LabelChip({required this.label});
/// Backend labels are codes — `DG-1042`, `BAG-1` — and derived ones already /// ── The word is gone; the code is not ──
/// read `Bag 3`. The word belongs on both, and the test for "already has it" ///
/// is the *word*, not the prefix: `BAG-1` starts with those three letters and /// This prefixed everything with `Bag ` — so a counter's own code rendered
/// is still a code, so it becomes `Bag BAG-1` exactly as it always read. /// as `Bag DG-1042`, and a number the app had derived rendered as `Bag 3`
String get _text => /// and read like something stencilled on an object nobody had stencilled.
label.toLowerCase().startsWith('bag ') ? label : 'Bag $label'; ///
/// What a counter prints is printed on it already. It is shown exactly as it
/// was sent, and nothing is shown at all where nothing was sent.
String get _text => label;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@@ -51,10 +51,10 @@ class StopDetailSheet extends StatelessWidget {
final double? riderLat; final double? riderLat;
final double? riderLng; final double? riderLng;
/// The bag this order travels in, as the caller's store recorded it at the /// The label this order travels in, as the caller's store recorded it at the
/// counter. Empty falls back to the payload's own `baglabel`, and a line /// counter. Empty falls back to the payload's own `baglabel`, and a line
/// with neither simply has no Bag row — nothing is invented. /// with neither simply has no Bag row — nothing is invented.
final String bag; final String label;
/// Orders the rider is carrying — what turns a raw `pickup` row into the /// Orders the rider is carrying — what turns a raw `pickup` row into the
/// delivery leg. Empty (Home's caller) leaves the raw kind in charge. /// delivery leg. Empty (Home's caller) leaves the raw kind in charge.
@@ -91,7 +91,7 @@ class StopDetailSheet extends StatelessWidget {
this.stopCount = 0, this.stopCount = 0,
this.riderLat, this.riderLat,
this.riderLng, this.riderLng,
this.bag = '', this.label = '',
this.collectedIds = const {}, this.collectedIds = const {},
this.ofOrder = false, this.ofOrder = false,
}); });
@@ -102,7 +102,7 @@ class StopDetailSheet extends StatelessWidget {
required Map<String, dynamic> stop, required Map<String, dynamic> stop,
required int stopNumber, required int stopNumber,
int stopCount = 0, int stopCount = 0,
String bag = '', String label = '',
Set<String> collectedIds = const {}, Set<String> collectedIds = const {},
double? riderLat, double? riderLat,
double? riderLng, double? riderLng,
@@ -119,7 +119,7 @@ class StopDetailSheet extends StatelessWidget {
stop: stop, stop: stop,
stopNumber: stopNumber, stopNumber: stopNumber,
stopCount: stopCount, stopCount: stopCount,
bag: bag, label: label,
collectedIds: collectedIds, collectedIds: collectedIds,
riderLat: riderLat, riderLat: riderLat,
riderLng: riderLng, riderLng: riderLng,
@@ -721,15 +721,18 @@ class StopDetailSheet extends StatelessWidget {
Widget _detailGrid() { Widget _detailGrid() {
final rows = <MapEntry<String, String>>[ final rows = <MapEntry<String, String>>[
// Whose counter the bag came off — the fact the delivery card carries // Whose counter the label came off — the fact the delivery card carries
// ("PICKED UP FROM …") and this sheet used to drop. Only on the drop // ("PICKED UP FROM …") and this sheet used to drop. Only on the drop
// leg: on a pickup the header IS the source context. One reader, // leg: on a pickup the header IS the source context. One reader,
// [stopSourceName], as the project rule demands. // [stopSourceName], as the project rule demands.
MapEntry('From', _isDropLeg ? stopSourceName(stop) : ''), MapEntry('From', _isDropLeg ? stopSourceName(stop) : ''),
// The bag is what a rider matches against a shelf, so it outranks the // The label is what a rider matches against a shelf, so it outranks the
// reference. Stored pairing first ([bag], written at the counter), the // reference. Stored pairing first ([label], written at the counter), the
// kitchen-printed label as fallback, absent on a line with no load. // kitchen-printed label as fallback, absent on a line with no load.
MapEntry('Bag', bag.isNotEmpty ? bag : _s(['baglabel', 'BagLabel'])), MapEntry(
'Label',
label.isNotEmpty ? label : _s(['baglabel', 'BagLabel']),
),
MapEntry('Order', _s(['orderid', 'OrderId'])), MapEntry('Order', _s(['orderid', 'OrderId'])),
MapEntry('Product', _s(['description', 'productname', 'product'])), MapEntry('Product', _s(['description', 'productname', 'product'])),
MapEntry('Weight', () { MapEntry('Weight', () {

View File

@@ -254,8 +254,17 @@ class Trip {
/// ///
/// Returns `this` unchanged when nothing has been collected yet, so the /// Returns `this` unchanged when nothing has been collected yet, so the
/// common case allocates nothing. /// common case allocates nothing.
/// ── Declined work is not outstanding work ──
///
/// [rejectedIds] is the set the rider's own decision writes, and it has to be
/// passed in because a decline is **local first**: the stop stays in the
/// payload reading `assigned` until the backend catches up, so
/// `stopStatusOf` cannot see it and the figures counted a stop the rider had
/// refused. He declined four of eight and the card still measured the
/// kilometres to all eight.
Trip outstanding({ Trip outstanding({
Set<String> collectedIds = const <String>{}, Set<String> collectedIds = const <String>{},
Set<String> rejectedIds = const <String>{},
double? originLat, double? originLat,
double? originLng, double? originLng,
}) { }) {
@@ -263,7 +272,9 @@ class Trip {
for (final s in stops) for (final s in stops)
if (!WorkBoundary.pickupComplete(s, collectedIds: collectedIds) && if (!WorkBoundary.pickupComplete(s, collectedIds: collectedIds) &&
!stopStatusOf(s).isCancelled && !stopStatusOf(s).isCancelled &&
!stopStatusOf(s).isSkipped) !stopStatusOf(s).isSkipped &&
!stopStatusOf(s).isRejected &&
!rejectedIds.contains((s['orderid'] ?? '').toString()))
s, s,
]; ];
if (left.length == stops.length) return this; if (left.length == stops.length) return this;
@@ -300,6 +311,35 @@ class Trip {
'${RouteMetricsHelper.formatClock(e)}'; '${RouteMetricsHelper.formatClock(e)}';
} }
/// The slot as a **clock range**, or `''` when it has none.
///
/// ── Why a day part is not printed at clock position ──
///
/// [slotLabel] answers "what is this trip called" and names a whole day part
/// as *Morning* / *Afternoon* / *Evening*, which is right for a heading.
/// It is wrong wherever the app is printing a live figure.
///
/// Two things happen when it lands there. The word is a **label pretending to
/// be data**: it sits where "3h 10m left" sits, in the corner a rider checks
/// for how much time he has, and answers with a noun he cannot act on. And it
/// is redundant twice over — the trip is already numbered `Trip 1`, and the
/// number *is* the day part, so `Trip 1 · Morning` says one thing twice.
///
/// This became visible when trips started being placed by day part at all:
/// before that they carried no window, [slotLabel] fell through to "Today's
/// route", and the same call sites printed nothing. So the fix is not to undo
/// the placement — it is to stop reading a heading as a measurement.
///
/// Returns a range only when the hub sent a real one: `10:00 AM – 1:00 PM`.
String get slotClockLabel {
final s = slotStart;
final e = slotEnd;
if (s == null || e == null) return '';
if (DayPart.spans(s, e)) return '';
return '${RouteMetricsHelper.formatClock(s)} – '
'${RouteMetricsHelper.formatClock(e)}';
}
/// True when the slot window has already closed. /// True when the slot window has already closed.
bool isExpired([DateTime? now]) { bool isExpired([DateTime? now]) {
final e = slotEnd; final e = slotEnd;
@@ -410,15 +450,47 @@ class Trip {
DateTime? get _earliestDue { DateTime? get _earliestDue {
DateTime? best; DateTime? best;
for (final s in stops) { for (final s in stops) {
final due = _parseTimestamp( final due = dayPartTimeOf(s);
s['expected_pickup_time'] ?? s['expectedpickuptime'] ?? s['eta'],
);
if (due == null) continue; if (due == null) continue;
if (best == null || due.isBefore(best)) best = due; if (best == null || due.isBefore(best)) best = due;
} }
return best; return best;
} }
/// The clock that decides which part of the day a stop belongs to.
///
/// ── Due time first, assignment time second ──
///
/// The right answer is when the work is *due*: an order to be collected at
/// 10am is morning work whenever the hub happened to raise it. That is
/// `expected_pickup_time`, and where the backend sends it nothing here
/// changes.
///
/// Where it does not — which is the live Doormile contract today — the day
/// part had nothing to read at all, and every stop fell into the undated
/// `day:` bucket. One trip, no day part, and [TripSlots.slots] put it in the
/// first free slot: **everything was Trip 1, all day**. A rider given an
/// evening run opened Home to find it filed under the morning.
///
/// So the fallback is when the work reached him — `assignedat`, merged onto
/// the row by [WorkRepository] from the assignment ledger. It is the hub's
/// own act as the phone observed it, and it is what dispatch means on the
/// telephone: work handed over in the morning is the morning run. Approximate
/// where a due time would be exact, and exactly right for the common case,
/// which is a slot assigned at the top of the slot it is for.
///
/// `eta` is read last and usually answers nothing — on the v1 adapter it is
/// a duration, not a clock, and [_parseTimestamp] rejects it rather than
/// inventing a 15th of the month out of "15 min".
static DateTime? dayPartTimeOf(Map<String, dynamic> stop) =>
_parseTimestamp(
stop['expected_pickup_time'] ??
stop['expectedpickuptime'] ??
stop['assignedat'] ??
stop['assignedon'] ??
stop['eta'],
);
/// Address components every stop on this trip shares — city, state, /// Address components every stop on this trip shares — city, state,
/// pincode and so on. Cached per build by the caller. /// pincode and so on. Cached per build by the caller.
List<String> get sharedAddressTail => commonAddressTail([ List<String> get sharedAddressTail => commonAddressTail([
@@ -663,11 +735,11 @@ class Trip {
from = slotFrom; from = slotFrom;
to = slotTo; to = slotTo;
} else { } else {
final due = _parseTimestamp( // Due time where the hub sends one, and the moment the work reached
stop['expected_pickup_time'] ?? // the rider where it does not — see [Trip.dayPartTimeOf]. Without the
stop['expectedpickuptime'] ?? // second half every stop landed in the undated bucket below and the
stop['eta'], // whole day rendered as Trip 1.
); final due = dayPartTimeOf(stop);
if (due != null) { if (due != null) {
// Morning, afternoon or evening — see [DayPart]. Not a rolling // Morning, afternoon or evening — see [DayPart]. Not a rolling
// window: two stops due an hour apart are on the same run, and the // window: two stops due an hour apart are on the same run, and the

View File

@@ -418,65 +418,30 @@ class _TripBriefStripState extends State<TripBriefStrip> {
// matching the one above the bar, so the bar sits evenly between // matching the one above the bar, so the bar sits evenly between
// the two things it separates. // the two things it separates.
SizedBox(height: 8.h), SizedBox(height: 8.h),
Row( // ── The journey glance is gone from the collapsed head ──
children: [ //
// ── The journey leads the line now ── // It printed `≈50 min · 2.4 km` under the bar, and it was the third
// // place the same estimate appeared: the grid one tap below states
// It used to be the *fallback* — shown only when there was no // Duration and Distance as two of its four figures, and every group
// shift and no slot, because the clock had the row. With the // on the route under it carries its own leg in the right-hand
// ratio gone there is space for both, and how long and how far // column the rider scans down.
// is the pair a rider is actually planning against. It yields //
// while the grid below is open so nothing prints twice. // Collapsed is a *summary* state, and what a summary is for is the
Flexible( // one fact the card is answering with — how much is left, and the
child: _open ? const SizedBox.shrink() : _journeyGlance(), // bar that measures it. An estimate repeated at 12.5pt beside it
), // added a number to read without adding anything to know, and it is
const Spacer(), // the number a rider trusts least: a straight-line distance at an
// The clock keeps its own colour rules — the one line here // average speed, printed where it reads like a promise.
// allowed to carry bad news; see [_timeLeftLine]. It prints //
// nothing at all when the hub has set neither a shift nor a // Nothing is lost. Open the card and both figures are there, stated
// slot, which is when the journey has the row to itself. // properly, with the parcels and the cash beside them.
Flexible(child: _timeLeftLine()), Row(children: [const Spacer(), Flexible(child: _timeLeftLine())]),
],
),
], ],
), ),
), ),
); );
} }
/// The collapsed panel's journey, in one quiet line: `≈4h · 38.9 km`.
///
/// Only the figures that exist — a route with no coordinates prints no
/// kilometres rather than a zero — and never a colour: this is context, and
/// the headline beside it is the thing being contextualised.
Widget _journeyGlance() {
final duration = roundTripDuration(_trip.totalDuration);
final parts = <String>[
if (duration > Duration.zero) '\u2248${formatTripDuration(duration)}',
if (_trip.routeMeters > 0)
RouteMetricsHelper.formatDistance(_trip.routeMeters),
];
if (parts.isEmpty) return const SizedBox.shrink();
// Scale-down, never ellipsis: this line is nothing but figures, and
// `10.3 …` is the unit cut off the number it qualifies — seen the first
// time it shared a row with the headline, both flexible.
return FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
parts.join(' \u00b7 '),
maxLines: 1,
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w600,
fontFeatures: const [FontFeature.tabularFigures()],
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
);
}
/// The one line on this widget that can carry bad news, so it is the one line /// The one line on this widget that can carry bad news, so it is the one line
/// that is allowed to change colour. /// that is allowed to change colour.
Widget _timeLeftLine() { Widget _timeLeftLine() {
@@ -490,13 +455,19 @@ class _TripBriefStripState extends State<TripBriefStrip> {
// inventing a state — see the note on [ShiftBanner] for why an absent // inventing a state — see the note on [ShiftBanner] for why an absent
// shift is not the rider's problem to solve. // shift is not the rider's problem to solve.
// //
// And when the trip has no slot either, say nothing AT ALL: // And when the trip has no clock window either, say nothing AT ALL. A
// `slotLabel`'s fallback is the words "Today's route", which set at
// clock position read as a fact the rider should be able to act on. A
// headline row carries a live figure or it carries silence — a label // headline row carries a live figure or it carries silence — a label
// pretending to be data is worse than the gap it fills. // pretending to be data is worse than the gap it fills, and `slotLabel`
if (_trip.slotStart == null) return const SizedBox.shrink(); // has two fallbacks that are exactly that: the words "Today's route", and
text = _trip.slotLabel; // the day part's name. **Morning** where "3h 10m left" belongs is a noun
// in the corner a rider checks for how much time he has; the trip tab
// above already says which part of the day this is, in a number.
//
// See [Trip.slotClockLabel], which is empty unless the hub sent a real
// range.
final window = _trip.slotClockLabel;
if (window.isEmpty) return const SizedBox.shrink();
text = window;
color = ColorConstants.secondaryText; color = ColorConstants.secondaryText;
} else if (left == Duration.zero) { } else if (left == Duration.zero) {
text = 'Shift ended'; text = 'Shift ended';

View File

@@ -16,7 +16,8 @@ import 'package:miler/views/helpers/constants/design_constants.dart';
import 'package:miler/views/helpers/constants/miler_type.dart'; import 'package:miler/views/helpers/constants/miler_type.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/Models/stop_status.dart'; import 'package:miler/Models/stop_status.dart';
import 'package:miler/data/bag_manifest.dart'; import 'package:miler/data/order_manifest.dart';
import 'package:miler/data/stop_area.dart';
import 'package:miler/data/milk_run.dart'; import 'package:miler/data/milk_run.dart';
import 'package:miler/data/service_profile.dart'; import 'package:miler/data/service_profile.dart';
@@ -258,6 +259,10 @@ class TripCard extends StatelessWidget {
// on the same footing. See [Trip.outstanding]. // on the same footing. See [Trip.outstanding].
remaining: trip.outstanding( remaining: trip.outstanding(
collectedIds: collectedIds, collectedIds: collectedIds,
// Work he has declined is not work in front of him — see the note on
// [Trip.outstanding]. Without this the Distance cell measured a route
// through stops the rider had refused.
rejectedIds: rejectedIds,
originLat: riderLat, originLat: riderLat,
originLng: riderLng, originLng: riderLng,
), ),
@@ -311,7 +316,7 @@ class TripCard extends StatelessWidget {
for (var i = 0; i < trip.stops.length; i++) { for (var i = 0; i < trip.stops.length; i++) {
if (_stateOf(trip.stops[i]) != rung) continue; if (_stateOf(trip.stops[i]) != rung) continue;
final stop = trip.stops[i]; final stop = trip.stops[i];
final key = MilkRun.sourceKeyOf(stop); final key = MilkRun.sourceKeyOf(stop, within: trip.stops);
final grouped = ServiceProfile.active.sourceIsKitchen && key.isNotEmpty; final grouped = ServiceProfile.active.sourceIsKitchen && key.isNotEmpty;
final stops = grouped final stops = grouped
? [ ? [
@@ -430,13 +435,22 @@ class TripCard extends StatelessWidget {
// the screen. See [RouteGroup.allStops]. // the screen. See [RouteGroup.allStops].
for (var i = 0; i < trip.stops.length; i++) { for (var i = 0; i < trip.stops.length; i++) {
final stop = trip.stops[i]; final stop = trip.stops[i];
// Key on the id where there is one, falling back to the name: the id is // ── One reader for "which counter is this", not two ──
// what the hub controls, but a payload that only carries a name still has //
// to group rather than collapsing every stop into one nameless pile. // This built its own key — `stopSourceId`, falling back to
// `stopSourceName` — while [MilkRun.sourceKeyOf] is the function that
// exists to answer exactly this, and is what the bulk collect, the
// per-kitchen gate and `sameSourceAs` all key on.
//
// They did not agree. `stopSourceId` reads `kitchenid` and `sourceid`;
// `sourceKeyOf` reads those **and `pickuplocationid`** — so a row
// identified only by its location id was one place according to the
// collect and a different one according to the card that offers it. That
// is the same two-readers-one-question failure `sourceNameOf` documents,
// repeated on the id, and it splits a kitchen's orders across two groups
// on the screen while the collect still treats them as one.
final key = grouped final key = grouped
? (stopSourceId(stop).isNotEmpty ? MilkRun.sourceKeyOf(stop, within: trip.stops)
? stopSourceId(stop)
: stopSourceName(stop))
: 'stop:${_idOf(stop)}'; : 'stop:${_idOf(stop)}';
if (!buckets.containsKey(key)) { if (!buckets.containsKey(key)) {
buckets[key] = <int>[]; buckets[key] = <int>[];
@@ -473,17 +487,56 @@ class TripCard extends StatelessWidget {
groups.add(() { groups.add(() {
final stops = visible; final stops = visible;
final first = all.first; final first = all.first;
// ── An unnamed counter is named by where it is ──
//
// A grouped place with no source name fell through to
// `MilkRun.navigationLabel`, which answers with the **customer** — so
// the heading over a dropdown read as one more order rather than as the
// place the orders under it are collected from.
//
// The address is what names a place the payload did not name, and it is
// what the rider wants off that line anyway: he is deciding where to
// ride, not who to hand a bag to.
final place = compactAddress(pickupAddressOf(first));
// ── When a lone stop is a *place*, and when it is the stop ──
//
// `flat` means the header **is** the stop: no counter above it worth
// naming, so the card is titled with the customer and opens the detail
// sheet instead of a list. That is always right on a parcel route,
// where the rider goes to one door for one consignment.
//
// On a kitchen line it was reached by a different route and gave the
// wrong answer. A lone stop was flat whenever the payload carried **no
// source name** — and a missing name is a gap in the data, not evidence
// that there is no counter. So an order whose row lost its kitchen name
// was drawn as a bare header with the customer's name on it, sitting
// above the dropdown its seven siblings were in: seven bags in a list
// and one stranded over it, off the same shelf.
//
// [MilkRun.sourceKeyOf] now groups such a row back in by its address,
// which fixes that case at the root. This is the same correction one
// level down, for a place that genuinely does hold a single order: on a
// kitchen line a lone stop is only flat when there is **nothing at all**
// to name its place with — no counter name and no address. With an
// address it is a place holding one order, headed by where it is, with
// the order in the list under it.
final flat = final flat =
all.length == 1 && (!grouped || stopSourceName(first).isEmpty); all.length == 1 &&
// A grouped place is named by its counter. A flat one *is* the stop, so (!grouped || (stopSourceName(first).isEmpty && place.isEmpty));
// it is named by the customer — `MilkRun.navigationLabel` answers
// "pickup" for a parcel that has not been collected yet, which is a // A grouped place is named by its counter, or failing that by where it
// description of the leg rather than a name the rider can read off a // is. A flat one *is* the stop, so it is named by the customer —
// gate. // `MilkRun.navigationLabel` answers "pickup" for a parcel that has not
// been collected yet, which is a description of the leg rather than a
// name the rider can read off a gate.
final name = flat final name = flat
? BagManifest.customerOf(first) ? OrderManifest.customerOf(first)
: (grouped : (grouped
? stopSourceName(first) ? (stopSourceName(first).trim().isNotEmpty
? stopSourceName(first)
: place)
: MilkRun.navigationLabel(first)); : MilkRun.navigationLabel(first));
return RouteGroup( return RouteGroup(
key: key, key: key,
@@ -493,6 +546,20 @@ class TripCard extends StatelessWidget {
name: name.trim().isEmpty name: name.trim().isEmpty
? MilkRun.navigationLabel(first) ? MilkRun.navigationLabel(first)
: name.trim(), : name.trim(),
// ── The counter's street, under its name ──
//
// Grouped places only. A flat node *is* the stop and is already named
// by its customer or its own address, so an address line under it
// would print the same place twice; a kitchen is a name the rider has
// to convert into a location, and until now the only thing that could
// do that conversion was the preview sheet.
//
// Trimmed through [compactAddress] — the geocoder appends
// `Tamil Nadu, 641002, India` to every row, which is three
// components that are identical on every stop of every route and
// would push the part that names the street off the end of the line.
// Not repeated when it is already the heading — see [name] above.
location: flat || name.trim() == place ? '' : place,
stops: stops, stops: stops,
allStops: all, allStops: all,
firstIndex: indices.first, firstIndex: indices.first,
@@ -706,13 +773,12 @@ class TripCard extends StatelessWidget {
for (var i = 0; i < trip.stops.length; i++) { for (var i = 0; i < trip.stops.length; i++) {
if (!_showsOnHome(_stateOf(trip.stops[i]))) continue; if (!_showsOnHome(_stateOf(trip.stops[i]))) continue;
// Key on the id where there is one, falling back to the name: the id is // [MilkRun.sourceKeyOf], the same reader [routeGroups] uses and the same
// what the hub controls, but a payload that only carries a name still has // one the bulk collect keys on. This had its own id-then-name expression,
// to group rather than collapsing every stop into one nameless pile. // which misses `pickuplocationid` and therefore split a kitchen the
// collect still treats as one — see the note in [routeGroups].
final stop = trip.stops[i]; final stop = trip.stops[i];
final key = stopSourceId(stop).isNotEmpty final key = MilkRun.sourceKeyOf(stop);
? stopSourceId(stop)
: stopSourceName(stop);
if (!groups.containsKey(key)) { if (!groups.containsKey(key)) {
groups[key] = <int>[]; groups[key] = <int>[];
order.add(key); order.add(key);
@@ -804,11 +870,11 @@ class TripCard extends StatelessWidget {
// him comparing "5 meals" on the phone against a shelf of bags. // him comparing "5 meals" on the phone against a shelf of bags.
// The count he can *check* is orders against bags, and under // The count he can *check* is orders against bags, and under
// this workflow they are the same number by definition. See // this workflow they are the same number by definition. See
// [BagManifest]. // [OrderManifest].
Text( Text(
pending > 0 pending > 0
? '${BagManifest.countLabel(indices.length)} · $pending to accept' ? '${OrderManifest.countLabel(indices.length)} · $pending to accept'
: BagManifest.countLabel(indices.length), : OrderManifest.countLabel(indices.length),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
@@ -1204,7 +1270,7 @@ class TripCard extends StatelessWidget {
state: state, state: state,
address: address, address: address,
service: roundTripDuration(Trip.serviceTimeFor(stop)), service: roundTripDuration(Trip.serviceTimeFor(stop)),
bag: _bagOf(stop), label: _labelOf(stop),
// The kitchen heading above already names where this came from. // The kitchen heading above already names where this came from.
groupedUnderSource: ServiceProfile.active.sourceIsKitchen, groupedUnderSource: ServiceProfile.active.sourceIsKitchen,
selected: selectedIds.contains(id), selected: selectedIds.contains(id),
@@ -1258,21 +1324,19 @@ class TripCard extends StatelessWidget {
// `_tag` is gone — it built the tinted Deliver / Collect / ₹ chips. See // `_tag` is gone — it built the tinted Deliver / Collect / ₹ chips. See
// [_MetaLine], which says the same three things on one uncoloured line. // [_MetaLine], which says the same three things on one uncoloured line.
/// Which bag this order travels in. /// The label the counter printed on this order, or `''`.
/// ///
/// Derived from its position in **its own pickup group**, not from the whole /// ── It used to be derived, and that was the problem ──
/// route: the rider counts bags off one counter at a time, so the third order ///
/// from Kitchen 2 is *its* Bag 3 and not the route's ninth. A label the /// This computed the order's position in its own pickup group and returned
/// kitchen actually printed always wins — see [BagManifest]. /// `Bag 3` — the app numbering a list it had built and presenting the result
String _bagOf(Map<String, dynamic> stop) { /// as something stencilled on an object. On a route where no counter prints
if (!ServiceProfile.active.sourceIsKitchen) return stopBagLabel(stop); /// labels that was every row, and it sat two columns from a rail numbering
final key = MilkRun.sourceKeyOf(stop); /// the same rows.
final group = [ ///
for (final s in trip.stops) /// Only what the payload actually carries survives. See
if (MilkRun.sourceKeyOf(s) == key) s, /// [OrderManifest.labelFor].
]; String _labelOf(Map<String, dynamic> stop) => OrderManifest.labelFor(stop);
return BagManifest.bagFor(stop, group);
}
// ── Footer: accept remaining, or go work the trip ────────────────────── // ── Footer: accept remaining, or go work the trip ──────────────────────
Widget _footerBar(List<String> pendingIds) { Widget _footerBar(List<String> pendingIds) {

View File

@@ -36,23 +36,23 @@ class PickupCard extends StatelessWidget {
final bool enabled; final bool enabled;
final bool isSkipped; final bool isSkipped;
/// The bag this order travels in, as recorded at the counter. Empty on a /// The label this order travels in, as recorded at the counter. Empty on a
/// parcel route, where there is no load to match against. See [BagManifest]. /// parcel route, where there is no load to match against. See [OrderManifest].
final String bag; final String label;
/// Orders the rider is carrying. /// Orders the rider is carrying.
/// ///
/// Needed because "picked up" is not a *status* on this screen — the accepted /// Needed because "picked up" is not a *status* on this screen — the accepted
/// store normalises these rows to `accepted`, and what actually records the /// store normalises these rows to `accepted`, and what actually records the
/// hand-over is the collected set. Without it, [MilkRun.workingKind] reads a /// hand-over is the collected set. Without it, [MilkRun.workingKind] reads a
/// bag already in the rider's box as still needing collection, and the button /// label already in the rider's box as still needing collection, and the button
/// says **Start Pickup** for a delivery. /// says **Start Pickup** for a delivery.
final Set<String> collectedIds; final Set<String> collectedIds;
/// Orders whose delivery leg the rider has actually started. /// Orders whose delivery leg the rider has actually started.
/// ///
/// Distinct from [collectedIds] on purpose, and the distinction is the whole /// Distinct from [collectedIds] on purpose, and the distinction is the whole
/// point of the chip: a bag in his box is **PICKED**, a round he has set off /// point of the chip: a label in his box is **PICKED**, a round he has set off
/// on is **ACTIVE**. The backend cannot answer this — `pickup-complete` /// on is **ACTIVE**. The backend cannot answer this — `pickup-complete`
/// stamps a hyperlocal consignment `Out_for_Delivery` at the counter — so the /// stamps a hyperlocal consignment `Out_for_Delivery` at the counter — so the
/// rung is resolved through [MilkRun.stageOf] against this set rather than /// rung is resolved through [MilkRun.stageOf] against this set rather than
@@ -67,7 +67,7 @@ class PickupCard extends StatelessWidget {
this.riderSpeedMps, this.riderSpeedMps,
this.enabled = true, this.enabled = true,
this.isSkipped = false, this.isSkipped = false,
this.bag = '', this.label = '',
this.collectedIds = const {}, this.collectedIds = const {},
this.outForDeliveryIds = const {}, this.outForDeliveryIds = const {},
}); });
@@ -97,7 +97,7 @@ class PickupCard extends StatelessWidget {
// //
// The booking adapter stamps `type: pickup` on every row it builds, so a // The booking adapter stamps `type: pickup` on every row it builds, so a
// milk-run order the rider has already collected still described itself as // milk-run order the rider has already collected still described itself as
// a pickup — and this card offered **Start Pickup** for a bag sitting in // a pickup — and this card offered **Start Pickup** for a label sitting in
// his box. See [MilkRun.workingKind]. // his box. See [MilkRun.workingKind].
final StopKind kind = MilkRun.workingKind(item, collectedIds: collectedIds); final StopKind kind = MilkRun.workingKind(item, collectedIds: collectedIds);
@@ -201,7 +201,7 @@ class PickupCard extends StatelessWidget {
// ── Identity left, state and journey right ── // ── Identity left, state and journey right ──
// //
// The distance and ETA had a full-width row of their own under // The distance and ETA had a full-width row of their own under
// the bag — a fifth storey on a card the rider reads at a red // the label — a fifth storey on a card the rider reads at a red
// light. Home's group node and this queue's own UP NEXT tile // light. Home's group node and this queue's own UP NEXT tile
// already carry the journey as a narrow right-hand column at // already carry the journey as a narrow right-hand column at
// the headline's shoulder; the live card was the one surface // the headline's shoulder; the live card was the one surface
@@ -272,7 +272,7 @@ class PickupCard extends StatelessWidget {
// ── The pairing, as one object ── // ── The pairing, as one object ──
// //
// `Bag 1 · Joe`, not a bag chip in one place and a // `Bag 1 · Joe`, not a label chip in one place and a
// name in another. The join between them is the // name in another. The join between them is the
// entire operational risk on a meal run, and making // entire operational risk on a meal run, and making
// the rider do it himself at a gate is where the // the rider do it himself at a gate is where the
@@ -280,10 +280,10 @@ class PickupCard extends StatelessWidget {
// the headline above, so the pair is spatial here; // the headline above, so the pair is spatial here;
// the written form earns its place further down the // the written form earns its place further down the
// queue — see [_LaterRow] — and on the manifest.) // queue — see [_LaterRow] — and on the manifest.)
if (bag.isNotEmpty) ...[ if (label.isNotEmpty) ...[
SizedBox(height: 14.h), SizedBox(height: 14.h),
_BagPair( _LabelPair(
bag: bag, label: label,
customer: '', customer: '',
muted: !enabled, muted: !enabled,
size: 15.5, size: 15.5,
@@ -371,7 +371,7 @@ class PickupCard extends StatelessWidget {
// //
// Once the food is in the box the pickup is history — but it is // Once the food is in the box the pickup is history — but it is
// still what establishes custody, and on a run with two kitchens // still what establishes custody, and on a run with two kitchens
// it is how he knows which shelf this bag came off. So it stays, // it is how he knows which shelf this label came off. So it stays,
// one line, under a hairline, at half the weight of the name // one line, under a hairline, at half the weight of the name
// above. // above.
if (dropLeads && pickupName.isNotEmpty) ...[ if (dropLeads && pickupName.isNotEmpty) ...[
@@ -534,7 +534,7 @@ class PickupCard extends StatelessWidget {
// because this method opens the map screen directly rather than through // because this method opens the map screen directly rather than through
// `startPickup`, and a stop that reached it in the pickup phase would put // `startPickup`, and a stop that reached it in the pickup phase would put
// the rider through a map, an I'VE ARRIVED and a confirmation sheet for a // the rider through a map, an I'VE ARRIVED and a confirmation sheet for a
// bag he is supposed to collect at the counter with the rest of them. See // label he is supposed to collect at the counter with the rest of them. See
// [MilkRun.worksOnOwnScreen]. // [MilkRun.worksOnOwnScreen].
if (!MilkRun.worksOnOwnScreen(item, collectedIds: collectedIds)) { if (!MilkRun.worksOnOwnScreen(item, collectedIds: collectedIds)) {
AppFeedback.info( AppFeedback.info(
@@ -919,7 +919,7 @@ class _SquareIconButton extends StatelessWidget {
/// ``` /// ```
/// NOW one task card, full weight, one big action /// NOW one task card, full weight, one big action
/// UP NEXT who and where, quietly — no action /// UP NEXT who and where, quietly — no action
/// LATER one line each: bag, name, distance /// LATER one line each: label, name, distance
/// SKIPPED the reason, and the way back in /// SKIPPED the reason, and the way back in
/// ``` /// ```
/// ///
@@ -963,8 +963,8 @@ class _QueueRow {
final QueueDensity density; final QueueDensity density;
final int displayStep; final int displayStep;
/// The bag this order travels in, as recorded at the counter. /// The label this order travels in, as recorded at the counter.
final String bag; final String label;
/// Why a skipped stop was skipped. Read from the skip cache rather than the /// Why a skipped stop was skipped. Read from the skip cache rather than the
/// stop, because the stop is refetched from the API every three seconds and /// stop, because the stop is refetched from the API every three seconds and
@@ -975,14 +975,14 @@ class _QueueRow {
: stop = null, : stop = null,
density = QueueDensity.later, density = QueueDensity.later,
displayStep = 0, displayStep = 0,
bag = '', label = '',
reason = ''; reason = '';
const _QueueRow.stop({ const _QueueRow.stop({
required this.stop, required this.stop,
required this.density, required this.density,
required this.displayStep, required this.displayStep,
required this.bag, required this.label,
this.reason = '', this.reason = '',
}) : heading = null, }) : heading = null,
count = 0; count = 0;
@@ -1079,33 +1079,38 @@ class _QueueHeading extends StatelessWidget {
/// ///
/// ── Why they are one line and not two facts ── /// ── Why they are one line and not two facts ──
/// ///
/// The rider is holding a bag and looking for a door. Printing the bag in one /// The rider is holding a label and looking for a door. Printing the label in one
/// place and the customer in another makes him do the join himself, on a phone, /// place and the customer in another makes him do the join himself, on a phone,
/// at a gate — and that join is the entire operational risk on a meal run: five /// at a gate — and that join is the entire operational risk on a meal run: five
/// near-identical lunch bags, one confident rider, the wrong door. /// near-identical lunch bags, one confident rider, the wrong door.
/// ///
/// So they are written the way he thinks them. /// So they are written the way he thinks them.
class _BagPair extends StatelessWidget { class _LabelPair extends StatelessWidget {
final String bag; final String label;
final String customer; final String customer;
final bool muted; final bool muted;
/// Bigger on the live card, quieter down the queue. /// Bigger on the live card, quieter down the queue.
final double size; final double size;
const _BagPair({ const _LabelPair({
required this.bag, required this.label,
required this.customer, required this.customer,
this.muted = false, this.muted = false,
this.size = 15, this.size = 15,
}); });
String get _bagText => /// ── The word is gone; the code is not ──
bag.toLowerCase().startsWith('bag ') ? bag : 'Bag $bag'; ///
/// This prefixed everything with `Bag `, so a counter's own code rendered as
/// `Bag DG-1042` and an app-derived number rendered as `Bag 3` — a reference
/// to an object nobody had labelled. What a counter prints is shown exactly
/// as it was sent, and nothing is shown where nothing was sent.
String get _labelText => label;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (bag.isEmpty) return const SizedBox.shrink(); if (label.isEmpty) return const SizedBox.shrink();
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -1119,7 +1124,7 @@ class _BagPair extends StatelessWidget {
borderRadius: BorderRadius.circular(DesignConstants.radiusLg), borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
), ),
child: Text( child: Text(
_bagText, _labelText,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
@@ -1223,14 +1228,14 @@ class _TaskChip extends StatelessWidget {
/// notes, no money. Preparing is a glance; acting is the card above. /// notes, no money. Preparing is a glance; acting is the card above.
class _NextCard extends StatelessWidget { class _NextCard extends StatelessWidget {
final Map<String, dynamic> item; final Map<String, dynamic> item;
final String bag; final String label;
final double? distanceMeters; final double? distanceMeters;
final double? riderSpeedMps; final double? riderSpeedMps;
final VoidCallback onOpen; final VoidCallback onOpen;
const _NextCard({ const _NextCard({
required this.item, required this.item,
required this.bag, required this.label,
required this.distanceMeters, required this.distanceMeters,
required this.riderSpeedMps, required this.riderSpeedMps,
required this.onOpen, required this.onOpen,
@@ -1293,9 +1298,9 @@ class _NextCard extends StatelessWidget {
), ),
), ),
], ],
if (bag.isNotEmpty) ...[ if (label.isNotEmpty) ...[
SizedBox(height: 10.h), SizedBox(height: 10.h),
_BagPair(bag: bag, customer: '', muted: true, size: 14), _LabelPair(label: label, customer: '', muted: true, size: 14),
], ],
], ],
), ),
@@ -1312,13 +1317,13 @@ class _NextCard extends StatelessWidget {
/// take in at once. /// take in at once.
class _LaterRow extends StatelessWidget { class _LaterRow extends StatelessWidget {
final Map<String, dynamic> item; final Map<String, dynamic> item;
final String bag; final String label;
final double? distanceMeters; final double? distanceMeters;
final VoidCallback onOpen; final VoidCallback onOpen;
const _LaterRow({ const _LaterRow({
required this.item, required this.item,
required this.bag, required this.label,
required this.distanceMeters, required this.distanceMeters,
required this.onOpen, required this.onOpen,
}); });
@@ -1333,8 +1338,8 @@ class _LaterRow extends StatelessWidget {
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h), padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h),
child: Row( child: Row(
children: [ children: [
if (bag.isNotEmpty) ...[ if (label.isNotEmpty) ...[
_BagPair(bag: bag, customer: '', muted: true, size: 13), _LabelPair(label: label, customer: '', muted: true, size: 13),
SizedBox(width: 12.w), SizedBox(width: 12.w),
], ],
Expanded( Expanded(
@@ -1375,14 +1380,14 @@ class _LaterRow extends StatelessWidget {
/// and nothing else. /// and nothing else.
class _SkippedRow extends StatelessWidget { class _SkippedRow extends StatelessWidget {
final Map<String, dynamic> item; final Map<String, dynamic> item;
final String bag; final String label;
final String reason; final String reason;
final VoidCallback onResume; final VoidCallback onResume;
final VoidCallback onOpen; final VoidCallback onOpen;
const _SkippedRow({ const _SkippedRow({
required this.item, required this.item,
required this.bag, required this.label,
required this.reason, required this.reason,
required this.onResume, required this.onResume,
required this.onOpen, required this.onOpen,
@@ -1441,8 +1446,8 @@ class _SkippedRow extends StatelessWidget {
SizedBox(height: 12.h), SizedBox(height: 12.h),
Row( Row(
children: [ children: [
if (bag.isNotEmpty) ...[ if (label.isNotEmpty) ...[
_BagPair(bag: bag, customer: '', muted: true, size: 13.5), _LabelPair(label: label, customer: '', muted: true, size: 13.5),
SizedBox(width: 12.w), SizedBox(width: 12.w),
], ],
Expanded( Expanded(

View File

@@ -116,7 +116,7 @@ extension _MethodUi on _Method {
String get label => this == _Method.cash ? 'Cash' : 'UPI / QR'; String get label => this == _Method.cash ? 'Cash' : 'UPI / QR';
String get blurb => this == _Method.cash String get blurb => this == _Method.cash
? 'Notes and coins, into your bag' ? 'Notes and coins, kept on you'
: 'Customer scans and pays'; : 'Customer scans and pays';
IconData get icon => IconData get icon =>

View File

@@ -227,7 +227,11 @@ Future<String> resolveConsignmentId(Map<String, dynamic> stop) async {
/// rider can retry this until his battery dies. /// rider can retry this until his battery dies.
/// • **At a hub.** A cross-city parcel is `Inwarded_at_Hub` and will be /// • **At a hub.** A cross-city parcel is `Inwarded_at_Hub` and will be
/// delivered by somebody else. There is nothing here for him to start. /// delivered by somebody else. There is nothing here for him to start.
/// • **Refused.** The server gave a reason and it was thrown away. /// • **Refused.** The server gave a reason and it was thrown away. It is
/// read now — and *translated*, because the reason arrives as a state
/// machine talking to itself (`consignment is Inwarded_At_Hub, not
/// Collected_By_Miler`) and that is not a sentence for a rider. See
/// [_refusalSentence].
/// • **Unreachable.** The only one where "check your connection" is true. /// • **Unreachable.** The only one where "check your connection" is true.
/// ///
/// Telling a rider to check a working connection, about a parcel that is not /// Telling a rider to check a working connection, about a parcel that is not
@@ -318,10 +322,10 @@ Future<bool> releaseForDelivery(Map<String, dynamic> stop) async {
// account for, and one 400 carrying the backend's own sentence is worth more // account for, and one 400 carrying the backend's own sentence is worth more
// than a client-side refusal that invents one. A hyperlocal consignment the // than a client-side refusal that invents one. A hyperlocal consignment the
// pivot has not finished releasing is let through; a hub-routed one is // pivot has not finished releasing is let through; a hub-routed one is
// refused by the server, and the rider is told what the server said. // refused by the server, and the rider is told what the server *meant* —
// translated out of the state machine's vocabulary, see [_refusalSentence].
// A hold that cannot exist on this line is a bad read, not a fact about the // A hold that cannot exist on this line is a bad read, not a fact about the
// stop. See [_handoffHoldIsPossible]. // stop. See [_handoffHoldIsPossible].
final heldElsewhere = state.awaitsHub && _handoffHoldIsPossible;
if (state.awaitsHub && !_handoffHoldIsPossible) { if (state.awaitsHub && !_handoffHoldIsPossible) {
debugPrint( debugPrint(
'[MILKRUN] $consignmentId read as ${state.name} on a line with no ' '[MILKRUN] $consignmentId read as ${state.name} on a line with no '
@@ -333,11 +337,16 @@ Future<bool> releaseForDelivery(Map<String, dynamic> stop) async {
!(state.awaitsHub && !_handoffHoldIsPossible) && !(state.awaitsHub && !_handoffHoldIsPossible) &&
state != ConsignmentState.unknown) { state != ConsignmentState.unknown) {
debugPrint('[MILKRUN] $consignmentId is ${state.name} — not releasable'); debugPrint('[MILKRUN] $consignmentId is ${state.name} — not releasable');
lastReleaseFailure = heldElsewhere // One sentence per state, written once — see
// The honest answer for a cross-city parcel: it is not his round, and // [ConsignmentStateX.startRefusalSentence]. This branch and the refusal
// saying so without naming a building he has never been to. // below are the same answer arrived at from two directions (a state the
? "This one has already been handed on — it's not yours to deliver." // app read, and a state the server named), and they used to word it
: 'This parcel cannot be started yet. Ask your office to check it.'; // differently: a cross-city parcel blocked here said "it's not yours to
// deliver" while the identical parcel blocked one request later read out
// a database enum.
lastReleaseFailure =
state.startRefusalSentence ??
'This parcel cannot be started yet. Ask your office to check it.';
return false; return false;
} }
@@ -361,16 +370,63 @@ Future<bool> releaseForDelivery(Map<String, dynamic> stop) async {
final after = await ConsignmentGate.stateOf(consignmentId); final after = await ConsignmentGate.stateOf(consignmentId);
if (after.isDeliverable || after.isDelivered) return true; if (after.isDeliverable || after.isDelivered) return true;
// `status: 0` is the request never leaving; anything else is the server // `status: 0` is the request never leaving, and it is the one failure that
// answering, and its own words beat a guess about the network. // is really about the connection.
lastReleaseFailure = res.status == 0 lastReleaseFailure = res.status == 0
? 'Could not reach your office. Check your connection and slide again.' ? 'Could not reach your office. Check your connection and slide again.'
: (res.message.trim().isNotEmpty : _refusalSentence(res, after);
? res.message.trim()
: 'Your office would not start this delivery.');
return false; return false;
} }
/// The rider's version of a refused **Start ride**.
///
/// ── What the rider was actually reading ──
///
/// The server's own words were passed straight to the screen, on the argument
/// that one refusal carrying the backend's reason beats a client-side guess
/// that invents one. That argument holds for *whose* answer it is. It does not
/// hold for what the answer turned out to be: `start-delivery` refuses with an
/// assertion about its own model — **"consignment is Inwarded_At_Hub, not
/// Collected_By_Miler"** — and that is what a rider saw on the slider, in
/// database vocabulary, with no way to tell it from a crash.
///
/// The reason is worth keeping; the wording was never the server's to write.
/// So the state is what travels, and [ConsignmentStateX.startRefusalSentence]
/// says it once, in one place, in words that answer the only two questions he
/// has: is this mine, and will sliding again help.
///
/// ── Three sources for that state, best first ──
///
/// 1. **A fresh read** — [after], asked immediately after the refusal, which
/// is the newest thing anyone knows about the consignment.
/// 2. **The refusal itself.** The read can fail — the row need not carry a
/// status and the detail route 404s on older deployments — and when it
/// does the refusal is the only thing left that knows. See
/// [consignmentStateNamedInRefusal] for why reading it is safe.
/// 3. **The failure code.** `INVALID_STATE` alone still says the one useful
/// thing: the consignment has moved on from the rung this press acts at,
/// so sliding again is not the answer.
///
/// Prose from the server is still preferred over a generic sentence — some
/// routes really do answer in words meant for a person. What is filtered out
/// is the wire vocabulary. See [namesWireState].
String _refusalSentence(ApiResult res, ConsignmentState after) {
final state = after != ConsignmentState.unknown
? after
: consignmentStateNamedInRefusal(res.message);
final fromState = state.startRefusalSentence;
if (fromState != null) return fromState;
final said = res.message.trim();
if (said.isNotEmpty && !namesWireState(said)) return said;
return res.isInvalidState
? 'Your office has already moved this parcel on, so it cannot be '
'started from here. Ask them to check it.'
: 'Your office would not start this delivery.';
}
/// Brings this device into line with a delivery the server already has. /// Brings this device into line with a delivery the server already has.
/// ///
/// The same local bookkeeping a successful `deliver` performs — the order /// The same local bookkeeping a successful `deliver` performs — the order

View File

@@ -77,6 +77,7 @@ import 'package:miler/data/order_events.dart';
import 'package:miler/data/route_order.dart'; import 'package:miler/data/route_order.dart';
import 'package:miler/data/stop_area.dart'; import 'package:miler/data/stop_area.dart';
import 'package:miler/data/stop_contact.dart'; import 'package:miler/data/stop_contact.dart';
import 'package:miler/data/service_day.dart';
import 'package:miler/data/work_domain.dart'; import 'package:miler/data/work_domain.dart';
import 'package:miler/data/work_repository.dart'; import 'package:miler/data/work_repository.dart';
import 'package:miler/data/miler_api.dart'; import 'package:miler/data/miler_api.dart';
@@ -291,6 +292,28 @@ class MyPickups extends StatefulWidget {
} }
} }
/// True when [stop] is part of the route the rider actually rode.
///
/// The completed store is not a list of finished deliveries — it is where every
/// order that left the rider's hands is filed, whatever ended it. A stop he
/// **declined** on Home is in there (`terminalStatus: 'rejected'`) and so is
/// one the office **cancelled**. Neither was ever ridden to, so neither belongs
/// on a progress rail: they inflate its count and they are drawn in states that
/// assert something false — `rejected` reads as work still ahead of him,
/// `cancelled` reads as green, completed work.
///
/// A **skip** is not in that group and is deliberately kept. It is a door he
/// went to and could not finish, which is unfinished business, and the rail has
/// an amber node for exactly that.
///
/// Top-level rather than a method so [_railStops]' rule can be exercised
/// without building the screen — the arithmetic it protects is four lines and
/// took a rider's whole rail with it when it was wrong.
bool wasRiddenTo(Map<String, dynamic> stop) {
final status = stopStatusOf(stop);
return !status.isRejected && !status.isCancelled;
}
class _MyPickupsState extends State<MyPickups> class _MyPickupsState extends State<MyPickups>
with AutomaticKeepAliveClientMixin, WidgetsBindingObserver { with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
final PickupProvider _provider = PickupProvider(); final PickupProvider _provider = PickupProvider();
@@ -428,6 +451,65 @@ class _MyPickupsState extends State<MyPickups>
/// Refreshed with every fetch. See [_railStops] for why the rail needs them. /// Refreshed with every fetch. See [_railStops] for why the rail needs them.
List<Map<String, dynamic>> _completedToday = <Map<String, dynamic>>[]; List<Map<String, dynamic>> _completedToday = <Map<String, dynamic>>[];
/// Notices when the day underneath the queue has moved on.
///
/// [_fetchPicked] filters against the service day *as read when it ran*, so
/// the queue on screen is only ever as fresh as the last fetch. See
/// [_rolloverCheck].
final ServiceDayRollover _day = ServiceDayRollover();
/// Fires at the next local midnight. See [_armRollover].
Timer? _rollover;
/// This queue's stops, for today and no other day.
///
/// ── Why Deliveries needed the same filter Home just got ──
///
/// The two screens read the same call. `GET /miler/bookings` returns the
/// rider's whole **open** set, so a stop he picked up on Tuesday and never
/// closed came back on Wednesday, went through [WorkBoundary.deliveryQueue]
/// as perfectly workable — it *is* collected and it *is* unclosed — and sat
/// in this morning's round. Worse than a stale row: the bar at the foot of
/// this screen offers to start the whole round, so yesterday's leftovers
/// were work the rider could be sent out on.
///
/// The rule and every part of its reasoning is [ServiceDay.onlyToday],
/// shared with Home so the two screens cannot disagree about which day it
/// is. What is local is only *which* day — the one this screen is showing.
///
/// The completed store is not put through it: [getCompletedBookings] already
/// prunes itself to today on read.
List<Map<String, dynamic>> _todayOnly(List<Map<String, dynamic>> stops) {
_day.rollIfNeeded();
return ServiceDay.onlyToday(stops, now: _day.day, where: 'MYPICKUPS');
}
/// ── The day ending under a queue that is already open ──
///
/// [ServiceDay] answers "which day is this row" at read time, which is right
/// for the data and not sufficient for the screen: the filter runs at fetch
/// time. A rider who leaves this tab open through 23:59, or backgrounds the
/// app on it and picks the phone up at six, is looking at a queue filtered
/// against a day that has ended.
///
/// A timer to the next local midnight for the app left in the foreground,
/// and resume for every other case — both platforms suspend timers in the
/// background. Both land in [_rolloverCheck], which is idempotent. The same
/// arrangement Home and Activity use.
void _armRollover() {
_rollover?.cancel();
_rollover = Timer(_day.untilNextDay, _rolloverCheck);
}
/// Turns the queue over when the service day has moved on.
void _rolloverCheck() {
if (!mounted) return;
_armRollover();
if (!_day.rollIfNeeded()) return;
debugPrint('[MYPICKUPS] the day rolled over to ${_day.day} — refetching');
_fetchPicked(force: true);
}
// ══════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════
// THE MILK RUN'S ROUND // THE MILK RUN'S ROUND
// //
@@ -467,20 +549,59 @@ class _MyPickupsState extends State<MyPickups>
/// The completed stops come from the same store the Activity tab reads, so /// The completed stops come from the same store the Activity tab reads, so
/// the two agree about what was finished, and they lead because they were /// the two agree about what was finished, and they lead because they were
/// done first. The open ones follow in the hub's order. /// done first. The open ones follow in the hub's order.
///
/// ── Why the rail was counting stops the rider never rode ──
///
/// The completed store is not a list of finished *deliveries*. It is where
/// every order that left the rider's hands is filed, whatever ended it — and
/// **a declined stop is filed there too** (`addCompletedBookings(...,
/// terminalStatus: 'rejected')`, from Home's decline). That was invisible for
/// as long as nothing read the store as a route.
///
/// This reads it as a route, and the arithmetic was plainly wrong: a rider
/// who took one order out of five and declined the other four got a rail of
/// **five** nodes. Worse than the count, each wore the wrong colour —
/// `rejected` maps to [StopProgress.pending], so four stops he had refused
/// were drawn ahead of him as work still to come, and a `cancelled` one is
/// [StopStatusX.isWorkComplete] and draws **green**, claiming a delivery that
/// never happened.
///
/// So the rail draws what he actually rode. A skip stays — it is unfinished
/// business and the rail has an amber node to say so, which is the whole
/// reason that colour exists. A stop he declined and a stop the office called
/// off are not on his route at all.
///
/// ── And it draws the trip it is labelled with ──
///
/// The strip is headed `Trip 2 · 10:00 AM – 1:00 PM` — see [_buildTripRail] —
/// while the store it reads holds the **whole day**, so on a second run the
/// heading named one trip and the nodes counted two. Completed stops are kept
/// to the trip in front of the rider whenever both numbers are known;
/// anything unplaced is kept, because dropping a stop for want of a trip
/// number would understate a day the rider really did work.
///
/// With no open stops there is no current trip and nothing is scoped away:
/// a rider who has just closed his last stop still sees the full green rail,
/// which is the case this getter was written for.
List<Map<String, dynamic>> get _railStops { List<Map<String, dynamic>> get _railStops {
final open = _visibleStops; final open = _visibleStops;
if (_completedToday.isEmpty) return open; if (_completedToday.isEmpty) return open;
final openKeys = open.map(_getOrderKey).toSet(); final openKeys = open.map(_getOrderKey).toSet();
final done = _completedToday final trip = _currentTripNumber;
.where((o) => !openKeys.contains(_getOrderKey(o)))
.toList() final done = <Map<String, dynamic>>[
.reversed // the store hands them back newest first // The store hands them back newest first; the rail reads in route order.
.toList(); for (final o in _completedToday.reversed)
if (!openKeys.contains(_getOrderKey(o)) && wasRiddenTo(o))
if (trip == null || (_tripNumberOf(o) ?? trip) == trip) o,
];
return [...done, ...open]; return [...done, ...open];
} }
/// Progress state for every stop on the accepted trip, in the admin's fixed /// Progress state for every stop on the accepted trip, in the admin's fixed
/// order — what drives the red→green rail at the top of Bookings. /// order — what drives the red→green rail at the top of Bookings.
/// ///
@@ -545,9 +666,28 @@ class _MyPickupsState extends State<MyPickups>
hubLng: _currentLocation?.longitude, hubLng: _currentLocation?.longitude,
); );
// ── The number is the slot, not the position in the list ──
//
// This read `i + 1` over the trips themselves, and it agreed with Home only
// by accident: while nothing could place a trip by day part, every run fell
// into one undated bucket and position and slot were the same number.
//
// They are not any more. [Trip.dayPartTimeOf] places a run in the part of
// the day it was assigned in, so a rider whose whole day is an afternoon
// run has one trip — at **slot 1**, which Home's tabs and the hub both call
// Trip 2, and which this method would have called Trip 1. That is precisely
// the failure the doc above says it exists to prevent, arriving through the
// arithmetic rather than through the input.
//
// [TripSlots.slots] is the same laying-out Home's tab bar uses, including
// its fallback to arrival order when the day parts cannot place every trip
// — so the two screens cannot produce different numbers for one stop
// without the tabs themselves being wrong.
final slots = trips.slots;
return <String, int>{ return <String, int>{
for (var i = 0; i < trips.length; i++) for (var i = 0; i < slots.length; i++)
for (final id in trips[i].orderIds) id: i + 1, if (slots[i] != null)
for (final id in slots[i]!.orderIds) id: i + 1,
}; };
} }
@@ -629,12 +769,12 @@ class _MyPickupsState extends State<MyPickups>
/// ///
/// Read once per refresh rather than per card: it is one preferences hit, and /// Read once per refresh rather than per card: it is one preferences hit, and
/// a `FutureBuilder` per row would blink a bagless card in on every rebuild. /// a `FutureBuilder` per row would blink a bagless card in on every rebuild.
Map<String, String> _bagLabels = const {}; Map<String, String> _orderLabels = const {};
Future<void> _loadBagLabels() async { Future<void> _loadOrderLabels() async {
final labels = await getBagLabels(); final labels = await getOrderLabels();
if (!mounted) return; if (!mounted) return;
setState(() => _bagLabels = labels); setState(() => _orderLabels = labels);
} }
/// ── The queue, in the order it will be worked ── /// ── The queue, in the order it will be worked ──
@@ -722,7 +862,7 @@ class _MyPickupsState extends State<MyPickups>
_visibleStops, _visibleStops,
_visibleStops.indexOf(stop), _visibleStops.indexOf(stop),
), ),
bag: _bagLabels[(stop['orderid'] ?? '').toString()] ?? '', label: _orderLabels[(stop['orderid'] ?? '').toString()] ?? '',
reason: _skippedOrdersCache[_getOrderKey(stop)] ?? '', reason: _skippedOrdersCache[_getOrderKey(stop)] ?? '',
), ),
); );
@@ -870,7 +1010,11 @@ class _MyPickupsState extends State<MyPickups>
// The number leads and the window follows it, because the number is the // The number leads and the window follows it, because the number is the
// shared name and the window is the detail: "Trip 2 · 10:00 AM – 1:00 PM". // shared name and the window is the detail: "Trip 2 · 10:00 AM – 1:00 PM".
final tripNo = _currentTripNumber; final tripNo = _currentTripNumber;
final window = trip.slotStart != null ? trip.slotLabel : null; // A clock range only. The day part's name would print `Trip 1 · Morning`,
// which is the same fact twice — the number *is* the day part. See
// [Trip.slotClockLabel].
final clock = trip.slotClockLabel;
final window = clock.isEmpty ? null : clock;
final label = tripNo == null final label = tripNo == null
? (window ?? 'Your route') ? (window ?? 'Your route')
: window == null : window == null
@@ -1023,12 +1167,14 @@ class _MyPickupsState extends State<MyPickups>
BottomPage.currentTab.addListener(_onTabChanged); BottomPage.currentTab.addListener(_onTabChanged);
// Run these in parallel // Run these in parallel
_armRollover();
_restoreSkips(); _restoreSkips();
_restoreClosedToday(); _restoreClosedToday();
_restoreActivePickup(); _restoreActivePickup();
_initializeLocation(); // Don't block _initializeLocation(); // Don't block
_fetchPicked(); _fetchPicked();
_loadBagLabels(); _loadOrderLabels();
_startPollingStream(); _startPollingStream();
} }
@@ -1178,6 +1324,11 @@ class _MyPickupsState extends State<MyPickups>
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state); super.didChangeAppLifecycleState(state);
// Before the early return below: a resume that does not change the
// foreground flag still has to notice a midnight that passed while the
// phone was in the rider's pocket, which is the case the timer cannot
// cover — both platforms suspend timers in the background.
if (state == AppLifecycleState.resumed) _rolloverCheck();
final bool foreground = state == AppLifecycleState.resumed; final bool foreground = state == AppLifecycleState.resumed;
if (foreground == _pollForeground) return; if (foreground == _pollForeground) return;
_pollForeground = foreground; _pollForeground = foreground;
@@ -1280,6 +1431,7 @@ class _MyPickupsState extends State<MyPickups>
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
BottomPage.currentTab.removeListener(_onTabChanged); BottomPage.currentTab.removeListener(_onTabChanged);
_pollerSubscription?.cancel(); _pollerSubscription?.cancel();
_rollover?.cancel();
for (final timer in _pickupTimers.values) { for (final timer in _pickupTimers.values) {
timer.cancel(); timer.cancel();
} }
@@ -1376,114 +1528,8 @@ class _MyPickupsState extends State<MyPickups>
]; ];
// ══════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════
// START DELIVERY — the load leaves the counter /// True while a round is being marked started, so [startRound] cannot run
// /// twice over the same stop.
// ── Why this is a press and not a side effect of the pickup ──
//
// The release used to ride along with `pickup-complete`, back on Home: the
// rider slid to confirm five bags at a kitchen and the same gesture posted
// all five consignments `Out_for_Delivery`. The hub's board then showed five
// *active* deliveries for food still on the shelf in front of him, and there
// was no moment left in the day that meant "I have set off" — picked and
// out-for-delivery had been collapsed into one claim.
//
// They are two different claims about where the load is, made minutes or an
// hour apart, and only the rider can make the second one. So it is his press,
// on the tab that holds the load.
//
// ── Why one bar for the whole load ──
//
// `POST /miler/deliveries/start` takes a batch because the gesture is one
// press after the last counter. Releasing per card would be five presses for
// one act and would leave the round half-open — some rows deliverable, some
// refused at the door by a server the rider cannot argue with.
// ══════════════════════════════════════════════════════════════════════
/// Orders in the rider's hands that he has not yet declared himself riding.
///
/// The bar's subject, and the reason it appears at all: an empty answer means
/// the whole load is already on the road and there is nothing left to press.
///
/// Empty on every line but the milk run, and deliberately: a logistics parcel
/// is collected *for the hub* and delivered by somebody else, so its rider
/// has no round to start and must never be shown a control that says he has.
/// [MilkRun.navigatesToCustomer] asks the same question one stop at a time.
List<Map<String, dynamic>> get _unreleased => !_isMilkRun
? const []
: [
for (final s in _visibleStops)
// The same three exclusions the queue itself applies, so the bar
// can never count a row the rider cannot see: work he has already
// closed, work he has put down, and work already on the road.
if (!stopStatusOf(s).isWorkComplete &&
!stopStatusOf(s).isCancelled &&
!stopStatusOf(s).isSkipped &&
!_completedOrderKeys.contains(_getOrderKey(s)) &&
_needsRelease(s))
s,
];
/// Whether this stop is still waiting on the rider's **Start delivery**.
///
/// ── Read from the consignment, not from a flag in this app ──
///
/// Two backend behaviours are live at once and the app must not care which
/// it is talking to. `pickup-complete` either leaves a hyperlocal
/// consignment `Out_for_Delivery` (as it does today) or stops it at
/// `Collected_By_Miler` and waits for `start-delivery` — a server-side
/// setting, not something this app is told about.
///
/// So the row is asked. `consignmentstatus` ships on every
/// `GET /miler/bookings` row, and it answers the question exactly: needing
/// release means needing release, and already out for delivery means the bar
/// has nothing to offer. **No feature flag is mirrored here**, because a
/// mirrored flag is a second source of truth that can be wrong.
///
/// ── The rider's press decides this, not the server's rung ──
///
/// This read the consignment state first and only fell back to the local
/// sets when the row carried none. That is the right instinct — a local flag
/// must never outrank a server fact — and it produced the wrong answer for
/// the case that is live on production today.
///
/// Two backend behaviours ship at once. With `MILER_COLLECTED_STATE_ENABLED`
/// **on**, `pickup-complete` stops at `Collected_By_Miler` and this method's
/// old reading is exactly right. With it **off** — compatibility mode, which
/// is the default and what the app meets in production — `pickup-complete`
/// releases the consignment itself, so a stop arrives on this tab already
/// `Out_for_Delivery`, `needsRelease` answers false, and the round starts
/// itself. The rider never sees PICKED and **Start delivery** never appears:
/// he collects at the kitchen and the app has him on the road before he has
/// left the counter.
///
/// So the two questions are separated, because they were never the same one:
///
/// ```
/// may this be delivered? → the consignment. ConsignmentGate, deliver
/// has he set off? → the rider. this method
/// ```
///
/// Nothing here contradicts the server. A stop the app holds at PICKED is
/// still `Out_for_Delivery` on the wire, `deliver` is still gated on the
/// consignment's own state, and pressing **Start delivery** on a consignment
/// that is already released costs no request at all — `releaseForDelivery`
/// returns true on sight of a deliverable state. What changes is only which
/// of the two facts the rider's card is showing him, and the answer is the
/// one he can act on.
///
/// The consignment state stays the fallback for a stop this device has no
/// record of — collected on another handset, or after a reinstall.
bool _needsRelease(Map<String, dynamic> stop) {
final id = MilkRun.idOf(stop);
// He pressed it. Nothing outranks that.
if (_outForDeliveryIds.contains(id)) return false;
// He collected it here and has not pressed it.
if (_collectedIds.contains(id)) return true;
return consignmentStateFromRaw(stop['consignmentstatus']).needsRelease;
}
/// True while the round is being marked started, so the bar cannot be
/// double-pressed.
bool _starting = false; bool _starting = false;
/// Sets off: releases each collected consignment for delivery, server first. /// Sets off: releases each collected consignment for delivery, server first.
@@ -1508,28 +1554,48 @@ class _MyPickupsState extends State<MyPickups>
/// ///
/// **The local set is written only for what the server released.** A stop /// **The local set is written only for what the server released.** A stop
/// whose consignment could not be reached or was refused keeps its PICKED /// whose consignment could not be reached or was refused keeps its PICKED
/// word and stays on the bar, because the alternative — showing ACTIVE for /// word, because the alternative — showing ACTIVE for a consignment the hub
/// a consignment the hub still calls collected — is the fiction that made /// still calls collected — is the fiction that made `deliver` fail at the
/// `deliver` fail at the door with nothing on screen to explain why. /// door with nothing on screen to explain why.
/// ///
/// Returns the number of orders actually released. /// ── The bar that used to call this is gone ──
///
/// There was a **Start delivery** bar pinned above the nav on this screen:
/// `5 orders in hand`, one press, release the lot. It was removed at the
/// rider's request, and the argument for it does not survive contact with how
/// the round is actually ridden.
///
/// It existed because "I have set off" is a claim only the rider can make,
/// and that is still true — but he already makes it, once per stop, when he
/// slides **Start ride** on the map. That slider calls straight into this
/// method (see `map.dart`), so the release was never exclusive to the bar:
/// the bar was a second way to make the same claim, earlier, in bulk, for
/// stops he had not set off for yet.
///
/// Which is what made it worth removing rather than merely redundant. A
/// batch release says the whole load is on the road at a moment when at most
/// one bag is, so the hub's board read *active* for four stops still in the
/// rider's box — the exact failure the bar was introduced to fix, moved one
/// step later in the day. Releasing per stop, at the moment he rides to it,
/// is the honest version of the same act.
///
/// Nothing is stranded: a consignment that needs releasing is released when
/// he sets off for it, and one already released costs no request at all —
/// `releaseForDelivery` returns true on sight of a deliverable state.
///
/// Still takes a list, because the caller passes one and a batch of one is
/// the common case. Returns the number of orders actually released.
Future<int> startRound(List<Map<String, dynamic>> stops) async { Future<int> startRound(List<Map<String, dynamic>> stops) async {
if (_starting || stops.isEmpty) return 0; if (_starting || stops.isEmpty) return 0;
setState(() => _starting = true); setState(() => _starting = true);
try { try {
final released = <String>[]; final released = <String>[];
var unreachable = 0;
for (final stop in stops) { for (final stop in stops) {
final orderId = MilkRun.idOf(stop); final orderId = MilkRun.idOf(stop);
if (orderId.isEmpty) continue; if (orderId.isEmpty) continue;
if (await releaseForDelivery(stop)) { if (await releaseForDelivery(stop)) released.add(orderId);
released.add(orderId);
} else {
unreachable++;
}
} }
_unreleasedFailures = unreachable;
if (released.isEmpty) return 0; if (released.isEmpty) return 0;
await addOutForDeliveryOrderIds(released); await addOutForDeliveryOrderIds(released);
@@ -1548,52 +1614,16 @@ class _MyPickupsState extends State<MyPickups>
} }
} }
/// How many stops the last [startRound] could not release. Read once by
/// [_startWholeRound] to word the message honestly.
int _unreleasedFailures = 0;
/// Scroll extent to reserve under the queue for whatever floats over it. /// Scroll extent to reserve under the queue for whatever floats over it.
/// ///
/// Reads the same two conditions the slot itself reads, so the floor and the /// Reads the same condition the slot itself reads, so the floor and the
/// surface can never disagree about which one is on screen. /// surface can never disagree about what is on screen.
double _bottomReservation(BuildContext context) => bottomReservationFor( double _bottomReservation(BuildContext context) => bottomReservationFor(
inset: BottomPage.bottomInset(context), inset: BottomPage.bottomInset(context),
live: _liveOnThisScreen.isNotEmpty, live: _liveOnThisScreen.isNotEmpty,
unreleased: _unreleased.isNotEmpty,
idle: 8.h, idle: 8.h,
); );
Future<void> _startWholeRound() async {
final stops = _unreleased;
final n = await startRound(stops);
if (!mounted) return;
final held = _unreleasedFailures;
if (n == 0) {
// One stop, one reason: say it. `releaseForDelivery` knows whether the
// parcel is at a hub, missing its reference, refused, or genuinely
// unreachable — see [lastReleaseFailure] — and "check your connection"
// is true for only the last of those.
//
// A whole round that failed keeps the general wording: with several
// stops there is no single reason to give, and the per-stop detail is in
// the log.
AppFeedback.errorGlobal(
held == 1
? (lastReleaseFailure ??
'Could not start that delivery. Check your connection and '
'try again.')
: 'Could not start the round. Check your connection and try again.',
);
return;
}
AppFeedback.successGlobal(
held > 0
? '$n started · $held could not be started'
: (n == 1 ? 'Delivery started' : '$n deliveries started'),
);
}
/// Marks a stop as finished (picked up or cancelled) in local state right /// Marks a stop as finished (picked up or cancelled) in local state right
/// away, so the UI unblocks without waiting for the backend/poll to catch up: /// away, so the UI unblocks without waiting for the backend/poll to catch up:
/// it leaves the active banner, drops out of the top list, and no longer /// it leaves the active banner, drops out of the top list, and no longer
@@ -1874,7 +1904,7 @@ class _MyPickupsState extends State<MyPickups>
// The bag each order is in, re-read with the list it labels: a pickup that // The bag each order is in, re-read with the list it labels: a pickup that
// happened on Home a moment ago must show its bags here without the rider // happened on Home a moment ago must show its bags here without the rider
// pulling to refresh. // pulling to refresh.
unawaited(_loadBagLabels()); unawaited(_loadOrderLabels());
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final userId = prefs.getInt('userId') ?? prefs.getInt('userid') ?? 0; final userId = prefs.getInt('userId') ?? prefs.getInt('userid') ?? 0;
@@ -1934,7 +1964,7 @@ class _MyPickupsState extends State<MyPickups>
} }
} }
final items = mergedOrders.values.toList(); var items = mergedOrders.values.toList();
debugPrint('[MYPICKUPS] Merged items count: ${items.length}'); debugPrint('[MYPICKUPS] Merged items count: ${items.length}');
// Today's finished stops, for the rail. Read on every fetch rather than // Today's finished stops, for the rail. Read on every fetch rather than
@@ -1957,6 +1987,21 @@ class _MyPickupsState extends State<MyPickups>
debugPrint('[MYPICKUPS] Merged $added accepted bookings from Home'); debugPrint('[MYPICKUPS] Merged $added accepted bookings from Home');
} }
// ── Today's, and only today's ──
//
// Applied here, once, on the merged day: after both endpoints and the
// accepted store, and before anything is derived from it. Everything
// below reads this list — the trip numbering, the milk run's whole-day
// set, the delivery queue, the rail — so filtering at one boundary is
// what stops a count above the queue from totalling a different set of
// days than the queue under it.
//
// `_completedToday` is deliberately not put through it: the completed
// store prunes itself to today on read, and it was already correct.
//
// See [_todayOnly], and [ServiceDay.onlyToday] for the rule Home shares.
items = _todayOnly(items);
// ── The day's shape, before any of this screen's filtering ── // ── The day's shape, before any of this screen's filtering ──
// //
// Everything today: what is assigned, what he took, what he finished. // Everything today: what is assigned, what he took, what he finished.
@@ -3206,7 +3251,7 @@ class _MyPickupsState extends State<MyPickups>
// The pairing recorded at the // The pairing recorded at the
// counter — same store as the // counter — same store as the
// card, so the two agree. // card, so the two agree.
bag: row.bag, label: row.label,
// The carried set — what turns // The carried set — what turns
// a raw pickup row into the // a raw pickup row into the
// delivery leg the sheet must // delivery leg the sheet must
@@ -3236,14 +3281,14 @@ class _MyPickupsState extends State<MyPickups>
distanceMeters, distanceMeters,
riderSpeedMps: riderSpeedMps:
_currentLocation?.speed, _currentLocation?.speed,
bag: row.bag, label: row.label,
collectedIds: _collectedIds, collectedIds: _collectedIds,
outForDeliveryIds: outForDeliveryIds:
_outForDeliveryIds, _outForDeliveryIds,
), ),
QueueDensity.next => _NextCard( QueueDensity.next => _NextCard(
item: item, item: item,
bag: row.bag, label: row.label,
distanceMeters: distanceMeters:
distanceMeters, distanceMeters,
riderSpeedMps: riderSpeedMps:
@@ -3252,7 +3297,7 @@ class _MyPickupsState extends State<MyPickups>
), ),
QueueDensity.later => _LaterRow( QueueDensity.later => _LaterRow(
item: item, item: item,
bag: row.bag, label: row.label,
distanceMeters: distanceMeters:
distanceMeters, distanceMeters,
onOpen: open, onOpen: open,
@@ -3260,7 +3305,7 @@ class _MyPickupsState extends State<MyPickups>
QueueDensity.skipped => QueueDensity.skipped =>
_SkippedRow( _SkippedRow(
item: item, item: item,
bag: row.bag, label: row.label,
reason: row.reason, reason: row.reason,
onOpen: open, onOpen: open,
onResume: () => onResume: () =>
@@ -3347,13 +3392,6 @@ class _MyPickupsState extends State<MyPickups>
} }
}, },
) )
: _unreleased.isNotEmpty
? StartRoundBar(
key: const ValueKey('start-round'),
count: _unreleased.length,
busy: _starting,
onPressed: _startWholeRound,
)
: const SizedBox.shrink(key: ValueKey('none')), : const SizedBox.shrink(key: ValueKey('none')),
), ),
), ),
@@ -3366,113 +3404,27 @@ class _MyPickupsState extends State<MyPickups>
} }
} }
/// ─────────────────────────────────────────────────────────────────────────
/// START DELIVERY — one press, the whole load
///
/// The bar the rider hits after the last counter. It states what it is about to
/// act on — `5 orders in hand` — because the press releases every one of them,
/// and a control acting on a set the rider cannot see is one he learns not to
/// trust.
///
/// ── Not a slide ──
///
/// Slides are reserved for the writes a resting thumb must not be able to make:
/// "picked" is the rider asserting he is holding somebody's lunch, "delivered"
/// that he has handed it over. Setting off is neither irreversible nor a claim
/// about somebody else's property — pressing it early costs a hub board that
/// reads *active* a few minutes early — so it is a button, at the size of every
/// other primary action in the app.
///
/// It sits above the floating nav bar rather than under it: [BottomPage] owns
/// that inset, and using it here is what keeps the button clear of the tab
/// strip on a tall phone.
/// How much scroll extent the queue must leave under itself. /// How much scroll extent the queue must leave under itself.
/// ///
/// ── Why this is a function and not a number ── /// ── Why this is a function and not a number ──
/// ///
/// It was two branches and a magic constant: the unreleased case was built /// It was two branches and a magic constant, and the live case was a flat
/// from its bar's own pieces, and the live case was a flat `80.h` that forgot /// `80.h` that forgot the device inset entirely — so on a gesture-navigation
/// the device inset entirely — so on a gesture-navigation phone the last row of /// phone the last row of the run sat behind the banner *and* behind the
/// the run sat behind the banner *and* behind the navigation, with its controls /// navigation, with its controls unreachable and no way to scroll to them.
/// unreachable and no way to scroll to them.
/// ///
/// One rule, reading the same two conditions the slot itself reads, so the /// One rule, reading the same condition the slot itself reads, so the floor and
/// floor and the surface can never disagree about which one is on screen. Each /// the surface can never disagree about what is on screen. The surface states
/// surface states its own height, because the thing that overlays a list is the /// its own height, because the thing that overlays a list is the only thing
/// only thing that knows how much of the list it covers. /// that knows how much of the list it covers.
///
/// ── One surface, where there were two ──
///
/// The other was the **Start delivery** bar, and it is gone with the control —
/// see the note above `startRound`. Nothing floats over this
/// list now except the live strip.
double bottomReservationFor({ double bottomReservationFor({
required double inset, required double inset,
required bool live, required bool live,
required bool unreleased,
double idle = 8, double idle = 8,
}) { }) => live ? inset + ActivePickupBanner.maxHeight : inset + idle;
if (live) return inset + ActivePickupBanner.maxHeight;
if (unreleased) return inset + StartRoundBar.maxHeight;
return inset + idle;
}
class StartRoundBar extends StatelessWidget {
/// The tallest this bar gets, excluding the device inset. See
/// [ActivePickupBanner.maxHeight] for why the surface declares its own.
static double get maxHeight => ButtonSizes.primary + 54.h;
final int count;
final bool busy;
final VoidCallback onPressed;
const StartRoundBar({
super.key,
required this.count,
required this.busy,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.fromLTRB(
16.w,
12.h,
16.w,
BottomPage.bottomInset(context) + 10.h,
),
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
boxShadow: [
// The list scrolls under this, so the edge has to read as an edge — a
// shadow rather than a rule, for the same reason the route cards have
// one: a hairline is not there on a scratched screen in sun, and a
// rule across a scrolling list reads as a divider between two rows.
BoxShadow(
color: ColorConstants.slateText.withValues(alpha: 0.10),
blurRadius: 18,
offset: const Offset(0, -4),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$count ${count == 1 ? 'order' : 'orders'} in hand',
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 8.h),
MilerButton(
label: 'Start delivery',
icon: LucideIcons.truck,
color: ColorConstants.primary,
loading: busy,
onPressed: busy ? null : onPressed,
),
],
),
);
}
}

View File

@@ -1237,12 +1237,12 @@ class _StopBrief extends StatelessWidget {
/// `Bag 5 · Vidhya Kitchen`, whichever halves exist; the trimmed booking /// `Bag 5 · Vidhya Kitchen`, whichever halves exist; the trimmed booking
/// reference only as a last resort. /// reference only as a last resort.
String _subtitle(String orderId) { String _subtitle(String orderId) {
final bag = (pickup['baglabel'] ?? pickup['BagLabel'] ?? '') final printed = (pickup['baglabel'] ?? pickup['BagLabel'] ?? '')
.toString() .toString()
.trim(); .trim();
final source = stopSourceName(pickup); final source = stopSourceName(pickup);
final line = [ final line = [
if (bag.isNotEmpty) bag, if (printed.isNotEmpty) printed,
if (source.isNotEmpty) source, if (source.isNotEmpty) source,
].join(' \u00b7 '); ].join(' \u00b7 ');
if (line.isNotEmpty) return line; if (line.isNotEmpty) return line;

View File

@@ -169,12 +169,18 @@ String stopSourceId(Map<String, dynamic> item) =>
.toString() .toString()
.trim(); .trim();
/// The label physically on the bag, matched at the kitchen and read at the door. /// The label the counter physically printed on an order, matched at the kitchen
/// and read at the door. Empty when it printed none.
/// ///
/// Ten near-identical lunch bags is where mis-delivery comes from, and no OTP or /// Ten near-identical lunches is where mis-delivery comes from, and no OTP or
/// photo catches it — the rider is confident, he is just at the wrong door with /// photo catches it — the rider is confident, he is just at the wrong door with
/// the wrong bag. A printed label on the card is the cheapest thing that does. /// the wrong one. A printed label on the card is the cheapest thing that does.
String stopBagLabel(Map<String, dynamic> item) => ///
/// The key names are the wire's and stay as the wire spells them; what the app
/// does with the value is its own business. Nothing is derived here — a counter
/// that prints nothing yields `''`, and every screen then shows no tag rather
/// than a number the app made up. See [OrderManifest.labelFor].
String stopPrintedLabel(Map<String, dynamic> item) =>
(item['baglabel'] ?? (item['baglabel'] ??
item['BagLabel'] ?? item['BagLabel'] ??
item['bagid'] ?? item['bagid'] ??

View File

@@ -107,6 +107,44 @@ class ColorConstants {
static const Color onWarning = Color(0xFFFFFFFF); static const Color onWarning = Color(0xFFFFFFFF);
static const Color warningContainer = Color(0xFFFEF3C7); static const Color warningContainer = Color(0xFFFEF3C7);
/// **Waiting on the rider** — the state word on a stop he has not decided yet.
///
/// ── Why a fourth amber, and not one of the three above ──
///
/// Pending was neutral grey, on a real argument: on a fresh morning *every*
/// stop is pending, so painting them all in the exception hue opens the whole
/// screen in warning and leaves nothing to say with when something actually
/// goes wrong. That argument is still right about [warning], which is this
/// app's word for *something has gone off plan* — a skipped stop, a short
/// payment, a bag that never arrived.
///
/// What it got wrong is that "nobody has done anything about this yet" is
/// itself worth a colour. A rider scanning a run had to infer it from the
/// *absence* of one, which is the single thing a glance cannot do, and
/// undecided work is precisely the work the screen exists to move.
///
/// So it gets its own: a gold that reads as "waiting" rather than as
/// "wrong", far enough round the wheel from the burnt orange of [warning]
/// that a pending stop and a skipped one are two different marks — and dark
/// enough to be *text*, which no honest yellow on a light ground can be
/// without going most of the way to brown. 5.54:1 on white, 4.83:1 on the
/// canvas, 4.81:1 on [cardSurface]: AA at the 13.5sp these words are set at,
/// on every surface Home draws them on.
static const Color pendingAmber = Color(0xFF8A6100);
/// **In the rider's hands** — collected from the counter, not yet delivered.
///
/// Green is the wrong end of the story for it. [acceptGreen] now marks the
/// moment the rider *takes a stop on*, and green everywhere else in this app
/// means finished — so a bag that has been picked up but not delivered would
/// have worn either "already decided" or "already done", and it is neither.
///
/// This is the app's own delivery hue, which is exactly what a picked bag has
/// become: work that has left the pickup leg and belongs to the round. Same
/// value as [deliveryAccent], named separately so the two can part company if
/// the route map ever needs its own.
static const Color pickedBlue = deliveryAccent;
// Extended Color Palette for Professional UI // Extended Color Palette for Professional UI
/// Captions, labels and secondary body text — the second most-used colour in /// Captions, labels and secondary body text — the second most-used colour in
/// the app after [slateText], on 89 call sites across every screen. /// the app after [slateText], on 89 call sites across every screen.

View File

@@ -1989,7 +1989,7 @@ class ErrorRetry extends StatelessWidget {
/// ───────────────────────────────────────────────────────────────────────── /// ─────────────────────────────────────────────────────────────────────────
class StopSourceLine extends StatelessWidget { class StopSourceLine extends StatelessWidget {
final String source; final String source;
final String bagLabel; final String printedLabel;
/// Dims both when the stop is not actionable, matching the card around it. /// Dims both when the stop is not actionable, matching the card around it.
final bool muted; final bool muted;
@@ -2001,12 +2001,12 @@ class StopSourceLine extends StatelessWidget {
const StopSourceLine({ const StopSourceLine({
super.key, super.key,
required this.source, required this.source,
required this.bagLabel, required this.printedLabel,
this.muted = false, this.muted = false,
this.topGap = 6, this.topGap = 6,
}); });
bool get _isEmpty => source.isEmpty && bagLabel.isEmpty; bool get _isEmpty => source.isEmpty && printedLabel.isEmpty;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -2053,12 +2053,12 @@ class StopSourceLine extends StatelessWidget {
), ),
), ),
), ),
if (source.isNotEmpty && bagLabel.isNotEmpty) if (source.isNotEmpty && printedLabel.isNotEmpty)
const SizedBox(width: 8), const SizedBox(width: 8),
if (bagLabel.isNotEmpty) if (printedLabel.isNotEmpty)
Flexible( Flexible(
child: Text( child: Text(
'Bag $bagLabel', printedLabel,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(

View File

@@ -1199,7 +1199,7 @@ void main() {
); );
expect(find.textContaining('0 m'), findsNothing); expect(find.textContaining('0 m'), findsNothing);
// The load still reads, so the node degrades rather than emptying. // The load still reads, so the node degrades rather than emptying.
expect(find.text('5 bags'), findsOneWidget); expect(find.text('5 orders'), findsOneWidget);
}); });
// ── The destination never truncates first ── // ── The destination never truncates first ──

View File

@@ -6,7 +6,6 @@ import 'package:miler/data/service_profile.dart';
import 'package:miler/data/stop_area.dart'; import 'package:miler/data/stop_area.dart';
import 'package:miler/views/Dashboard/home/homepage_banner.dart'; import 'package:miler/views/Dashboard/home/homepage_banner.dart';
import 'package:miler/views/Dashboard/pickups/pickups.dart'; import 'package:miler/views/Dashboard/pickups/pickups.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
/// ───────────────────────────────────────────────────────────────────────── /// ─────────────────────────────────────────────────────────────────────────
/// THE WORK QUEUE, NOT A LIST OF CARDS /// THE WORK QUEUE, NOT A LIST OF CARDS
@@ -96,22 +95,19 @@ void main() {
await ready(tester); await ready(tester);
const inset = 34.0; const inset = 34.0;
// Live stop: the banner is up, and the floor clears the banner. // ── One surface, where there were two ──
//
// This case used to be a three-way check, because a **Start delivery**
// bar shared this corner with the live strip. That bar is gone — the
// release it made now happens per stop, when the rider slides Start ride
// on the map, which is the moment he actually sets off. See `startRound`.
//
// What the rule protects is unchanged: the floor reserves exactly what is
// floating over the list, plus the device inset.
expect( expect(
bottomReservationFor(inset: inset, live: true, unreleased: true), bottomReservationFor(inset: inset, live: true),
inset + ActivePickupBanner.maxHeight, inset + ActivePickupBanner.maxHeight,
); );
// A live stop suppresses the start bar, so the banner wins even when
// there is unreleased work — the two never stack.
expect(
bottomReservationFor(inset: inset, live: true, unreleased: false),
inset + ActivePickupBanner.maxHeight,
);
// Nothing running, load in hand: the start bar.
expect(
bottomReservationFor(inset: inset, live: false, unreleased: true),
inset + StartRoundBar.maxHeight,
);
}); });
testWidgets('the device inset is always counted', (tester) async { testWidgets('the device inset is always counted', (tester) async {
@@ -120,16 +116,8 @@ void main() {
// inset, so on a gesture-navigation phone the last row of the run sat // inset, so on a gesture-navigation phone the last row of the run sat
// behind the banner *and* behind the navigation. // behind the banner *and* behind the navigation.
for (final live in const [true, false]) { for (final live in const [true, false]) {
final withInset = bottomReservationFor( final withInset = bottomReservationFor(inset: 34, live: live);
inset: 34, final without = bottomReservationFor(inset: 0, live: live);
live: live,
unreleased: true,
);
final without = bottomReservationFor(
inset: 0,
live: live,
unreleased: true,
);
expect(withInset - without, 34); expect(withInset - without, 34);
} }
}); });
@@ -137,98 +125,29 @@ void main() {
testWidgets('an idle queue reserves almost nothing', (tester) async { testWidgets('an idle queue reserves almost nothing', (tester) async {
await ready(tester); await ready(tester);
expect( expect(
bottomReservationFor(inset: 0, live: false, unreleased: false), bottomReservationFor(inset: 0, live: false),
lessThan(StartRoundBar.maxHeight), lessThan(ActivePickupBanner.maxHeight),
); );
}); });
testWidgets('both surfaces declare a real height', (tester) async { testWidgets('the floating surface declares a real height', (tester) async {
await ready(tester); await ready(tester);
// Measured, not guessed. Each is the only thing that knows how much of // Measured, not guessed. It is the only thing that knows how much of the
// the list it covers. // list it covers.
expect(ActivePickupBanner.maxHeight, greaterThan(48)); expect(ActivePickupBanner.maxHeight, greaterThan(48));
expect(StartRoundBar.maxHeight, greaterThan(48));
}); });
}); });
group('the command surface', () { // ── The "command surface" group is gone with its control ──
Future<void> pumpBar( //
WidgetTester tester, { // It covered the **Start delivery** bar: what it said, how it laid out at six
double width = 390, // widths and text scales, and that it never double-counted the device inset.
double textScale = 1.0, // The bar was removed at the rider's request — the release it made is made
int count = 5, // per stop now, by the Start ride slider on the map, at the moment he
}) async { // actually sets off. See `startRound` for the whole argument.
ServiceProfile.setActive(ServiceProfile.milkMan); //
tester.view.physicalSize = Size(width * 3, 844 * 3); // Nothing replaced it in that corner, so there is nothing here to test. The
tester.view.devicePixelRatio = 3.0; // reservation rule above still pins the geometry the corner has to keep.
addTearDown(tester.view.reset);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(
builder: (context, child) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(textScaler: TextScaler.linear(textScale)),
child: child!,
),
home: Scaffold(
backgroundColor: ColorConstants.daylightSurface,
body: Align(
alignment: Alignment.bottomCenter,
child: StartRoundBar(
count: count,
busy: false,
onPressed: () {},
),
),
),
),
),
);
await tester.pumpAndSettle();
}
testWidgets('it says what is in hand and what the press does', (
tester,
) async {
await pumpBar(tester);
// Setting off is reversible and operational, so it is a press — unlike
// Picked and Delivered, which are assertions about custody.
expect(find.textContaining('Start'), findsOneWidget);
});
for (final (width, scale) in const [
(390.0, 1.0),
(390.0, 1.3),
(390.0, 2.0),
(375.0, 2.0),
(320.0, 1.0),
(320.0, 2.0),
]) {
testWidgets('${width.toInt()}px @ ${scale}x lays out clean', (
tester,
) async {
await pumpBar(tester, width: width, textScale: scale, count: 12);
expect(tester.takeException(), isNull);
});
}
testWidgets('it never wraps itself in a second SafeArea', (tester) async {
await pumpBar(tester);
// `BottomPage.bottomInset` is the one owner of the device inset on this
// screen. A `SafeArea` here would count it twice and float the bar a
// navigation bar's height off the bottom of the screen.
expect(
find.descendant(
of: find.byType(StartRoundBar),
matching: find.byType(SafeArea),
),
findsNothing,
);
});
});
} }
/// Real addresses from the live tenant, added after seeing them render on a /// Real addresses from the live tenant, added after seeing them render on a

View File

@@ -48,7 +48,7 @@ void main() {
String id, String id,
String customer, { String customer, {
String status = 'picked', String status = 'picked',
String bag = '', String label = '',
}) => { }) => {
'orderid': id, 'orderid': id,
'tripid': 'T1', 'tripid': 'T1',
@@ -64,7 +64,7 @@ void main() {
'orderstatus': status, 'orderstatus': status,
'pickupcontactno': '9787698259', 'pickupcontactno': '9787698259',
'deliverycontactno': '9787698259', 'deliverycontactno': '9787698259',
if (bag.isNotEmpty) 'baglabel': bag, if (label.isNotEmpty) 'baglabel': label,
'Quantity': 1, 'Quantity': 1,
'pickuplat': 11.0168, 'pickuplat': 11.0168,
'pickuplon': 76.9558, 'pickuplon': 76.9558,
@@ -191,10 +191,10 @@ void main() {
tester, tester,
'card_picked', 'card_picked',
PickupCard( PickupCard(
item: drop('D1', 'Joe Mathew', bag: 'Bag 8'), item: drop('D1', 'Joe Mathew', label: 'DG-8'),
displayStep: 7, displayStep: 7,
distanceMeters: 1100, distanceMeters: 1100,
bag: 'Bag 8', label: 'DG-8',
collectedIds: const {'D1'}, collectedIds: const {'D1'},
), ),
height: 320, height: 320,
@@ -206,10 +206,10 @@ void main() {
tester, tester,
'card_active', 'card_active',
PickupCard( PickupCard(
item: drop('D1', 'Joe Mathew', bag: 'Bag 8'), item: drop('D1', 'Joe Mathew', label: 'DG-8'),
displayStep: 7, displayStep: 7,
distanceMeters: 1100, distanceMeters: 1100,
bag: 'Bag 8', label: 'DG-8',
collectedIds: const {'D1'}, collectedIds: const {'D1'},
outForDeliveryIds: const {'D1'}, outForDeliveryIds: const {'D1'},
), ),

View File

@@ -2,25 +2,35 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/data/accepted_store.dart'; import 'package:miler/data/accepted_store.dart';
import 'package:miler/data/bag_manifest.dart'; import 'package:miler/data/order_manifest.dart';
import 'package:miler/data/service_profile.dart'; import 'package:miler/data/service_profile.dart';
/// ───────────────────────────────────────────────────────────────────────── /// ─────────────────────────────────────────────────────────────────────────
/// THE DELIVERY QUEUE, AND THE BAG THAT SURVIVES IT /// THE DELIVERY QUEUE, AND THE LABEL THAT SURVIVES IT
/// ///
/// Two promises are made at a kitchen counter and both have to hold all the way /// Two promises are made at a kitchen counter and both have to hold all the way
/// to a doorstep: /// to a doorstep:
/// ///
/// 1. **Five orders picked is five deliveries** — no order goes missing in the /// 1. **Five orders picked is five deliveries** — no order goes missing in the
/// hand-off, and nothing appears that was not collected. /// hand-off, and nothing appears that was not collected.
/// 2. **Joe is Bag 1 and stays Bag 1** — even after Joe is delivered and the /// 2. **A label recorded at the counter is the label at the door** — even
/// list underneath him shrinks. /// after the first order is delivered and the list underneath it shrinks,
/// and even after a second counter loads on top of it.
/// ///
/// The second one is the subtle one, and it is why bag identity is *stored* at /// The second is the subtle one, and it is why the label is *stored* at pickup
/// pickup rather than recomputed from whatever is on screen. A position-derived /// rather than recomputed from whatever is on screen. Anything derived from a
/// bag number silently renames every remaining order the moment one leaves the /// position silently renames every remaining order the moment one leaves the
/// list, which sends the rider looking for a bag that is already in somebody's /// list, which sends the rider looking for something already in somebody's
/// hallway. /// hallway.
///
/// ── The derived numbering is gone, and these tests changed with it ──
///
/// They used to assert `Bag 1 … Bag 5`, manufactured from each order's position
/// when the payload printed nothing — the app numbering its own list and
/// presenting the result as a physical fact. Only a label a counter actually
/// printed is stored now, so the fixtures carry real codes and a counter that
/// prints nothing stores nothing. The store's contract is unchanged and is
/// still what is pinned here.
/// ───────────────────────────────────────────────────────────────────────── /// ─────────────────────────────────────────────────────────────────────────
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
@@ -28,47 +38,50 @@ void main() {
setUp(() => SharedPreferences.setMockInitialValues({})); setUp(() => SharedPreferences.setMockInitialValues({}));
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
Map<String, dynamic> order(String id, String customer) => { /// [printed] is what the counter stuck on the order. Omitted, the counter
/// printed nothing — which is the common route, and stores nothing.
Map<String, dynamic> order(String id, String customer, {String? printed}) => {
'orderid': id, 'orderid': id,
'pickupcustomer': customer, 'pickupcustomer': customer,
'kitchenname': 'Vidhya Kitchen', 'kitchenname': 'Vidhya Kitchen',
'kitchenid': 'K1', 'kitchenid': 'K1',
'quantity': 3, 'quantity': 3,
if (printed != null) 'baglabel': printed,
}; };
final five = [ final five = [
order('1', 'Joe'), order('1', 'Joe', printed: 'DG-1001'),
order('2', 'Arun'), order('2', 'Arun', printed: 'DG-1002'),
order('3', 'Priya'), order('3', 'Priya', printed: 'DG-1003'),
order('4', 'Kumar'), order('4', 'Kumar', printed: 'DG-1004'),
order('5', 'Ravi'), order('5', 'Ravi', printed: 'DG-1005'),
]; ];
group('the hand-off', () { group('the hand-off', () {
test( test(
'five picked orders become five deliveries, each with its bag', 'five picked orders become five deliveries, each with its label',
() async { () async {
ServiceProfile.setActive(ServiceProfile.meals); ServiceProfile.setActive(ServiceProfile.meals);
// What the pickup confirmation writes when the slide lands. // What the pickup confirmation writes when the slide lands.
final manifest = BagManifest.forGroup(five); final manifest = OrderManifest.forGroup(five);
await addCollectedOrderIds([for (final l in manifest) l.orderId]); await addCollectedOrderIds([for (final l in manifest) l.orderId]);
await saveBagLabels({for (final l in manifest) l.orderId: l.bag}); await saveOrderLabels({for (final l in manifest) l.orderId: l.label});
final carrying = await getCollectedOrderIds(); final carrying = await getCollectedOrderIds();
final bags = await getBagLabels(); final labels = await getOrderLabels();
expect(carrying.length, 5, reason: 'five picked is five to deliver'); expect(carrying.length, 5, reason: 'five picked is five to deliver');
expect(bags.length, 5, reason: 'and five bags, one each'); expect(labels.length, 5, reason: 'and five labels, one each');
expect(bags['1'], 'Bag 1'); expect(labels['1'], 'DG-1001');
expect(bags['5'], 'Bag 5'); expect(labels['5'], 'DG-1005');
// No aggregate anywhere: the count of deliveries and the count of bags // No aggregate anywhere: the count of deliveries and the count of
// are the same set, not two numbers that can drift. // labels are the same set, not two numbers that can drift.
expect(bags.keys.toSet(), carrying); expect(labels.keys.toSet(), carrying);
}, },
); );
test('a bag the counter could not supply never becomes a delivery', () async { test('an order the counter could not supply never becomes a delivery', () async {
ServiceProfile.setActive(ServiceProfile.meals); ServiceProfile.setActive(ServiceProfile.meals);
// Four handed over, one short — the shortfall path the pickup sheet owns. // Four handed over, one short — the shortfall path the pickup sheet owns.
@@ -76,8 +89,8 @@ void main() {
await addCollectedOrderIds([ await addCollectedOrderIds([
for (final o in loaded) o['orderid'] as String, for (final o in loaded) o['orderid'] as String,
]); ]);
await saveBagLabels({ await saveOrderLabels({
for (final l in BagManifest.forGroup(loaded)) l.orderId: l.bag, for (final l in OrderManifest.forGroup(loaded)) l.orderId: l.label,
}); });
await addNotLoadedOrderIds(['3']); await addNotLoadedOrderIds(['3']);
@@ -86,16 +99,16 @@ void main() {
// A short pick is its own record. It is not a skip: nobody was visited // A short pick is its own record. It is not a skip: nobody was visited
// and the customer did nothing. // and the customer did nothing.
expect(await getSkippedOrderIds(), isEmpty); expect(await getSkippedOrderIds(), isEmpty);
// And it is not carrying a bag, because it was never in the box. // And it carries no label, because it was never handed over.
expect((await getBagLabels()).containsKey('3'), isFalse); expect((await getOrderLabels()).containsKey('3'), isFalse);
}); });
}); });
group('bag identity outlives the queue', () { group('label identity outlives the queue', () {
test('delivering the first does not renumber the rest', () async { test('delivering the first does not disturb the rest', () async {
ServiceProfile.setActive(ServiceProfile.meals); ServiceProfile.setActive(ServiceProfile.meals);
await saveBagLabels({ await saveOrderLabels({
for (final l in BagManifest.forGroup(five)) l.orderId: l.bag, for (final l in OrderManifest.forGroup(five)) l.orderId: l.label,
}); });
// Joe is delivered and leaves the carrying set. // Joe is delivered and leaves the carrying set.
@@ -104,38 +117,42 @@ void main() {
]); ]);
await removeCollectedOrderIds(['1']); await removeCollectedOrderIds(['1']);
final bags = await getBagLabels(); final labels = await getOrderLabels();
expect(await getCollectedOrderIds(), {'2', '3', '4', '5'}); expect(await getCollectedOrderIds(), {'2', '3', '4', '5'});
// Arun does *not* become Bag 1 because the list in front of him shrank. // Arun keeps the code the counter stuck on his order. Nothing is read
expect(bags['2'], 'Bag 2'); // off a position, so a list that shrinks under him changes nothing.
expect(bags['3'], 'Bag 3'); expect(labels['2'], 'DG-1002');
expect(bags['5'], 'Bag 5'); expect(labels['3'], 'DG-1003');
expect(labels['5'], 'DG-1005');
}); });
test('a second kitchen adds to the map rather than replacing it', () async { test('a second kitchen adds to the map rather than replacing it', () async {
ServiceProfile.setActive(ServiceProfile.meals); ServiceProfile.setActive(ServiceProfile.meals);
await saveBagLabels({ await saveOrderLabels({
for (final l in BagManifest.forGroup(five.take(2).toList())) for (final l in OrderManifest.forGroup(five.take(2).toList()))
l.orderId: l.bag, l.orderId: l.label,
}); });
// A different counter, its own Bag 1 and Bag 2. // A different counter, printing its own codes.
final second = [order('9', 'Meena'), order('10', 'Sai')]; final second = [
await saveBagLabels({ order('9', 'Meena', printed: 'AM-2001'),
for (final l in BagManifest.forGroup(second)) l.orderId: l.bag, order('10', 'Sai', printed: 'AM-2002'),
];
await saveOrderLabels({
for (final l in OrderManifest.forGroup(second)) l.orderId: l.label,
}); });
final bags = await getBagLabels(); final labels = await getOrderLabels();
expect(bags.length, 4, reason: 'the first load must survive the second'); expect(labels.length, 4, reason: 'the first load must survive the second');
expect(bags['1'], 'Bag 1'); expect(labels['1'], 'DG-1001');
expect(bags['9'], 'Bag 1', reason: 'each counter counts its own shelf'); expect(labels['9'], 'AM-2001');
expect(bags['10'], 'Bag 2'); expect(labels['10'], 'AM-2002');
}); });
test('a skipped delivery keeps its bag so it can be resumed', () async { test('a skipped delivery keeps its label so it can be resumed', () async {
ServiceProfile.setActive(ServiceProfile.meals); ServiceProfile.setActive(ServiceProfile.meals);
await saveBagLabels({ await saveOrderLabels({
for (final l in BagManifest.forGroup(five)) l.orderId: l.bag, for (final l in OrderManifest.forGroup(five)) l.orderId: l.label,
}); });
await addSkippedBooking(five[2], reason: 'Customer unavailable'); await addSkippedBooking(five[2], reason: 'Customer unavailable');
@@ -149,13 +166,13 @@ void main() {
); );
// He is still carrying Priya's lunch — the bag identity is exactly what // He is still carrying Priya's lunch — the bag identity is exactly what
// he needs when he comes back to it. // he needs when he comes back to it.
expect((await getBagLabels())['3'], 'Bag 3'); expect((await getOrderLabels())['3'], 'DG-1003');
}); });
test("a day's run is wiped so bags never leak into tomorrow", () async { test("a day's run is wiped so bags never leak into tomorrow", () async {
await saveBagLabels({'1': 'Bag 1'}); await saveOrderLabels({'1': 'Bag 1'});
await clearServiceRunState(); await clearServiceRunState();
expect(await getBagLabels(), isEmpty); expect(await getOrderLabels(), isEmpty);
}); });
}); });
@@ -164,10 +181,10 @@ void main() {
// bag map is keyed by order id, so it is swept by the same rule — a stored // bag map is keyed by order id, so it is swept by the same rule — a stored
// pairing is trusted later *because* it is stored, and a stale one is worse // pairing is trusted later *because* it is stored, and a stale one is worse
// than none. // than none.
await saveBagLabels({'MOCK-M-1001': 'Bag 1', '77': 'Bag 2'}); await saveOrderLabels({'MOCK-M-1001': 'Bag 1', '77': 'Bag 2'});
await purgeDemoRecords(); await purgeDemoRecords();
final bags = await getBagLabels(); final bags = await getOrderLabels();
expect(bags.containsKey('MOCK-M-1001'), isFalse); expect(bags.containsKey('MOCK-M-1001'), isFalse);
expect(bags['77'], 'Bag 2', reason: 'real work is untouched'); expect(bags['77'], 'Bag 2', reason: 'real work is untouched');
}); });

View File

@@ -0,0 +1,100 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/data/device_telemetry.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// THE CONSOLE'S DEVICE PANEL WAS READING AN EMPTY FIELD
///
/// ── What the dispatcher saw ──
///
/// Battery —, Connection —, GPS Accuracy —, Location Service —, Current
/// Order —. Every column on the panel that answers *why has this rider gone
/// quiet* was blank, on riders whose phones were reporting all of it.
///
/// ── Why ──
///
/// Not because nothing was measured. The heartbeat read the battery, the
/// charging state, the connection type and the location-service status on every
/// tick — and published them to **MQTT only**, in a block that ran *after* the
/// rider log had already been posted. `POST /miler/logs` has a named field for
/// each one and carried none of them.
///
/// Two lanes for one fact, and the lane the console reads was the empty one.
/// On top of that, `location_service` was dropped a second time further down:
/// the provider forwarded every other device field to [MilerApi.postLog] and
/// silently skipped that one, so it could not have arrived even if the caller
/// had sent it.
///
/// These pin the shape of the payload, which is the part a wrong refactor would
/// break silently — a missing key renders as an em dash, which is exactly what
/// it looked like before anyone noticed.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
group('the payload carries what the console reads', () {
test('every field the contract names is emitted under its own name', () {
const t = DeviceTelemetry(
battery: 72,
isCharging: false,
connection: '4G',
locationService: 'enabled',
);
// The names are the wire's, from `POST /miler/logs`. A rename here is a
// column that goes blank on the console with nothing else failing.
expect(t.toPayload(), {
'battery': '72',
'is_charging': false,
'connection': '4G',
'location_service': 'enabled',
});
});
test('numbers go as strings, as every other numeric field does', () {
// Documented at the top of `MilerApi`: real numbers fail to parse
// server-side on `/logs` and `/consignments/logs`.
const t = DeviceTelemetry(battery: 5);
expect(t.toPayload()['battery'], isA<String>());
expect(t.toPayload()['battery'], '5');
});
test('is_charging stays a bool, because the contract says so', () {
const t = DeviceTelemetry(isCharging: true);
expect(t.toPayload()['is_charging'], isTrue);
expect(t.toPayload()['is_charging'], isA<bool>());
});
});
group('unknown is absent, never a plausible number', () {
test('a reading that failed emits no key at all', () {
// The whole panel is read by somebody deciding whether to call a rider.
// A `0` battery standing in for "could not ask" is a fact they would act
// on; an absent key draws the em dash it already drew, which is true.
const t = DeviceTelemetry();
expect(t.toPayload(), isEmpty);
});
test('a partial reading sends the half it has', () {
// The reads are guarded individually, so a platform that will not answer
// about the battery must not cost the connection its column too.
const t = DeviceTelemetry(connection: 'wifi');
expect(t.toPayload(), {'connection': 'wifi'});
});
test('an empty string is treated as no answer', () {
const t = DeviceTelemetry(connection: '', locationService: '');
expect(t.toPayload(), isEmpty);
});
test('a zero battery is a real reading and is sent', () {
// The one number that must survive the "absent, never plausible" rule:
// 0% is not a missing measurement, it is a rider about to disappear.
const t = DeviceTelemetry(battery: 0);
expect(t.toPayload()['battery'], '0');
});
test('not charging is a real reading and is sent', () {
const t = DeviceTelemetry(isCharging: false);
expect(t.toPayload().containsKey('is_charging'), isTrue);
});
});
}

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@@ -99,11 +100,24 @@ void main() {
} }
}); });
testWidgets('every Bag ends on one axis', (tester) async { testWidgets('every order row ends on one axis', (tester) async {
// ── What this used to measure ──
//
// The right edge of `Bag 1 … Bag 4`, which was a tag the app derived from
// each row's position. That numbering is gone — it was the app's own
// arithmetic drawn as a physical reference, beside a rail already
// numbering the same rows — so the column it formed is gone with it.
//
// The rule it existed to protect is not: whatever closes a step row, the
// rows close together. A ragged right edge down a list of people is what
// makes a route unscannable, and it is now the chevrons that hold that
// edge.
await pumpHome(tester, orders: 4); await pumpHome(tester, orders: 4);
final rights = [ final rights = tester
for (var i = 1; i <= 4; i++) tester.getRect(find.text('Bag $i')).right, .widgetList<Icon>(find.byIcon(LucideIcons.chevronRight))
]; .map((i) => tester.getRect(find.byWidget(i)).right)
.toList();
expect(rights.length, greaterThanOrEqualTo(4));
for (final r in rights) { for (final r in rights) {
expect(r, closeTo(rights.first, 0.5)); expect(r, closeTo(rights.first, 0.5));
} }
@@ -117,27 +131,21 @@ void main() {
// This pinned the label to the *name* axis, when the control headed its // This pinned the label to the *name* axis, when the control headed its
// list from the left. Inside a group it is a control over a list rather // list from the left. Inside a group it is a control over a list rather
// than a heading for one, so it sits on the edge a list's own controls // than a heading for one, so it sits on the edge a list's own controls
// use — the same right edge the bag labels and the chevrons below it // use — the same right edge the chevrons below it already keep. What must
// already keep. What must not happen is that it floats between the two. // not happen is that it floats between the two.
final label = tester.getRect(find.text('Select all 3')).right; final label = tester.getRect(find.text('Select all 3')).right;
final list = tester.getRect(find.byType(RouteTimeline)).right; final list = tester.getRect(find.byType(RouteTimeline)).right;
final bag = tester.getRect(find.text('Bag 1')).right;
expect( expect(
label, label,
closeTo(list, 4), closeTo(list, 4),
reason: 'the select-all ends at $label, the list at $list', reason: 'the select-all ends at $label, the list at $list',
); );
expect(
label,
greaterThan(bag),
reason: 'and it closes past the bag column rather than inside it',
);
}); });
testWidgets('the kitchen and its state share one axis', (tester) async { testWidgets('the kitchen and its state share one axis', (tester) async {
await pumpHome(tester); await pumpHome(tester);
expect( expect(
tester.getRect(find.text('Pending')).left, tester.getRect(find.textContaining('Pending')).left,
closeTo(tester.getRect(find.text('Vidhya Kitchen')).left, 0.5), closeTo(tester.getRect(find.text('Vidhya Kitchen')).left, 0.5),
); );
}); });

View File

@@ -27,8 +27,14 @@ import 'package:miler/views/helpers/constants/Colorconstants.dart';
void main() { void main() {
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
Map<String, dynamic> meal(String id, String who, {int step = 1}) => { Map<String, dynamic> meal(
String id,
String who, {
int step = 1,
String? printed,
}) => {
'orderid': id, 'orderid': id,
if (printed != null) 'baglabel': printed,
'type': 'delivery', 'type': 'delivery',
'step': step, 'step': step,
'pickupcustomer': who, 'pickupcustomer': who,
@@ -48,6 +54,10 @@ void main() {
void Function(String)? onToggleSelect, void Function(String)? onToggleSelect,
double width = 390, double width = 390,
double textScale = 1.0, double textScale = 1.0,
/// What each counter printed on an order, by id. Empty by default: most
/// counters print nothing, and the app invents nothing for them.
Map<String, String> printedLabels = const {},
}) async { }) async {
ServiceProfile.setActive(ServiceProfile.milkMan); ServiceProfile.setActive(ServiceProfile.milkMan);
tester.view.physicalSize = Size(width * 3, 2600 * 3); tester.view.physicalSize = Size(width * 3, 2600 * 3);
@@ -57,9 +67,9 @@ void main() {
final trip = Trip.fromStops( final trip = Trip.fromStops(
id: 'run', id: 'run',
stops: [ stops: [
meal('1', 'Joe Mathew', step: 1), meal('1', 'Joe Mathew', step: 1, printed: printedLabels['1']),
meal('2', 'Arun Prakash', step: 2), meal('2', 'Arun Prakash', step: 2, printed: printedLabels['2']),
meal('3', 'Ravi Kumar', step: 3), meal('3', 'Ravi Kumar', step: 3, printed: printedLabels['3']),
], ],
); );
@@ -276,35 +286,72 @@ void main() {
expect(opened, isEmpty); expect(opened, isEmpty);
}); });
testWidgets('the bag block opens details, and does not select', ( testWidgets('the corner block opens details, and does not select', (
tester, tester,
) async { ) async {
final selected = <String>[]; final selected = <String>[];
await pumpHome(tester, onToggleSelect: selected.add); // The tag is the counter's printed label now — the derived `Bag 1` is
// gone — so the block is tapped by the label the kitchen really stuck on.
await pumpHome(
tester,
onToggleSelect: selected.add,
printedLabels: const {'1': 'DG-1042'},
);
await openKitchen(tester); await openKitchen(tester);
// Siblings, never nested: two recognisers over the same pixels resolve // Siblings, never nested: two recognisers over the same pixels resolve
// to neither, which is how the chevron was once dead for a whole build. // to neither, which is how the chevron was once dead for a whole build.
await tester.tap(find.text('Bag 1')); await tester.tap(find.text('DG-1042'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(
selected, selected,
isEmpty, isEmpty,
reason: 'the bag corner is the way in, not a second tick', reason: 'the corner is the way in, not a second tick',
); );
}); });
}); });
group('the metadata is metadata', () { group('the metadata is metadata', () {
testWidgets('Pending is neutral, not a warning', (tester) async { testWidgets('Pending is its own amber, and never the warning one', (
tester,
) async {
await pumpHome(tester); await pumpHome(tester);
// Amber is this app's exception hue. On a fresh morning every kitchen is // ── What this test used to assert, and what survives of it ──
// pending, so spending it here opens the whole screen in warning and //
// leaves nothing to say with when a stop genuinely goes wrong. // It demanded neutral grey, on the argument that amber is this app's
final word = tester.widget<Text>(find.text('Pending')); // exception hue and every kitchen is pending on a fresh morning — so
expect(word.style!.color, ColorConstants.secondaryText); // spending it here opens the whole screen in warning.
//
// Half of that argument is still load-bearing and is still checked: the
// word must not be [ColorConstants.warning]. The other half was wrong —
// "waiting on you" is worth a colour of its own, and grey made the rider
// read undecided work by the *absence* of a mark. Pending has its own
// gold now; the thing that must never happen is it colliding with the
// hue reserved for a skip or a short payment.
// `Pending 3/3` — the word carries the count now, the same way Accepted
// states its share of a counter. See the chip in `route_timeline.dart`.
final word = tester.widget<Text>(find.textContaining('Pending'));
expect(word.data, 'Pending 3/3');
expect(word.style!.color, ColorConstants.pendingAmber);
expect(word.style!.color, isNot(ColorConstants.warning)); expect(word.style!.color, isNot(ColorConstants.warning));
expect(word.style!.color, isNot(ColorConstants.primary));
});
testWidgets('Accepted is green and Collected is not', (tester) async {
// The two rungs a rider most needs to tell apart on one counter. They
// were both green once — accepted took the brand red only on the group
// being ridden to, and collected took [ColorConstants.acceptGreen] — so
// the pair could render as one hue and two words.
await pumpHome(tester, accepted: const {'1', '2', '3'});
final accepted = tester.widget<Text>(find.textContaining('Accepted'));
expect(accepted.style!.color, ColorConstants.acceptGreen);
expect(
ColorConstants.pickedBlue,
isNot(ColorConstants.acceptGreen),
reason: 'in-hand and taken-on must not share a colour',
);
}); });
testWidgets('a partly accepted kitchen says so as a fraction', ( testWidgets('a partly accepted kitchen says so as a fraction', (
@@ -333,9 +380,9 @@ void main() {
// plus its padding. A page surface is not. Filtering by width keeps the // plus its padding. A page surface is not. Filtering by width keeps the
// original claim — the state is set, not stamped — and makes it immune // original claim — the state is set, not stamped — and makes it immune
// to whatever the page is standing on. // to whatever the page is standing on.
final word = tester.getRect(find.text('Pending')); final word = tester.getRect(find.textContaining('Pending'));
final chip = find.ancestor( final chip = find.ancestor(
of: find.text('Pending'), of: find.textContaining('Pending'),
matching: find.byType(DecoratedBox), matching: find.byType(DecoratedBox),
); );
for (final e in chip.evaluate()) { for (final e in chip.evaluate()) {
@@ -366,44 +413,40 @@ void main() {
expect(find.text('Peelamedu'), findsWidgets); expect(find.text('Peelamedu'), findsWidgets);
}); });
testWidgets('one order, one bag — and the column is stable', ( testWidgets('a counter that prints nothing shows no tag', (tester) async {
tester, // ── What this file used to assert here ──
) async { //
// That every step carried `Bag 1 … Bag 3`, right-aligned on one axis so
// the phone could be read against a shelf. The numbering was the app's
// own — derived from each row's position whenever the payload printed
// nothing, which on this fixture is every row — and it sat two columns
// from a rail already numbering the same steps.
//
// Only what a counter actually printed survives. The fixture prints
// nothing, so the rows carry the person and the chevron and no tag at
// all.
await pumpHome(tester); await pumpHome(tester);
await openKitchen(tester); await openKitchen(tester);
for (final bag in const ['Bag 1', 'Bag 2', 'Bag 3']) { for (final label in const ['Bag 1', 'Bag 2', 'Bag 3']) {
expect(find.text(bag), findsOneWidget); expect(find.text(label), findsNothing);
}
// Right-aligned on one axis, so the phone can be read against a shelf.
final rights = [
for (final bag in const ['Bag 1', 'Bag 2', 'Bag 3'])
tester.getRect(find.text(bag)).right,
];
for (final r in rights) {
expect(r, closeTo(rights.first, 0.5));
} }
// The people are still there, one row each.
expect(find.text('Joe Mathew'), findsOneWidget);
}); });
testWidgets('the name outranks the bag, and the bag yields first', ( testWidgets('a printed label is shown exactly as it was sent', (
tester, tester,
) async { ) async {
await pumpHome(tester, textScale: 2.0, width: 320); // A code the kitchen stuck on the order is a fact about an object in the
// rider's hands, so it travels verbatim — never prefixed, never renamed.
await pumpHome(tester, printedLabels: const {'1': 'DG-1042'});
await openKitchen(tester); await openKitchen(tester);
// `Bag 1` is compared against a shelf, not read as prose, so it stops expect(find.text('DG-1042'), findsOneWidget);
// scaling before the customer's name does. Left uncapped it took half a expect(find.textContaining('Bag'), findsNothing);
// 320pt row at 2× and the name — the one item that must yield last —
// was what ellipsised.
final bag = tester.getRect(find.text('Bag 1'));
expect(
bag.width,
lessThan(120),
reason: 'the bag column does not grow into the name',
);
expect(tester.takeException(), isNull, reason: 'and nothing overflows');
}); });
}); });
group('the offer states its own number', () { group('the offer states its own number', () {

View File

@@ -0,0 +1,305 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/data/api_config.dart';
import 'package:miler/data/service_day.dart';
import 'package:miler/views/Dashboard/home/trip.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// HOME IS TODAY, AND TODAY IS THREE TRIPS
///
/// Two bugs with one cause, and this is the file that holds the cause down.
///
/// ── What the rider saw ──
///
/// 1. **Yesterday's assignments were on Home.** `GET /miler/bookings` returns
/// his whole *open* set, not his day, so a booking raised on Tuesday and
/// never closed came back on Wednesday and on Thursday — inside the kitchen
/// dropdown, and inside every count above it.
/// 2. **Every run was Trip 1.** The hub assigns morning, afternoon and evening
/// slots and the tabs are those slots, but an evening run opened filed
/// under the morning.
///
/// ── The one cause ──
///
/// `ApiConfig.pickupFromBooking` builds a **fixed map**: a field it does not
/// name is a field no screen can ever see. Every timestamp on the booking was
/// unnamed, so it was thrown away at the adapter — and both features downstream
/// are clocks. [ServiceDay] asked "which day is this" in field names the
/// adapted row no longer spoke and got `''`; [Trip] asked "which part of the
/// day" and got the same silence, dropped every stop into one undated bucket,
/// and that bucket landed in the first free slot.
///
/// So the fix is at the adapter, and these tests start there. The two features
/// are then checked on the shapes the wire really produces.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
/// A booking as `GET /miler/bookings` returns it — the new shape, which is
/// the one that goes through the translation.
Map<String, dynamic> booking({
required String id,
String status = 'Miler_Assigned',
Map<String, dynamic> extra = const {},
}) => <String, dynamic>{
'bookingid': id,
'bookingreference': 'DM-$id',
'status': status,
'customername': 'Joe Mathew',
'pickupaddress': '12 SNS Colony, Peelamedu, Coimbatore',
...extra,
};
group('the adapter keeps the clocks', () {
test('a booking that carries a timestamp still carries it afterwards', () {
final row = ApiConfig.pickupFromBooking(
booking(
id: '1',
extra: const {
'createdat': '2026-08-27T09:14:00',
'updatedon': '2026-08-27T11:02:00',
},
),
);
// The whole failure was here: these came back and went nowhere.
expect(row['createdat'], '2026-08-27T09:14:00');
expect(row['updatedon'], '2026-08-27T11:02:00');
expect(ServiceDay.of(row), '2026-08-27');
});
test('every name ServiceDay reads survives the translation', () {
// A regression net for the shape of the bug rather than one instance of
// it: the adapter and the day filter must agree on the vocabulary, or a
// key added to one and not the other silently un-dates the row again.
for (final key in ServiceDay.timeKeys) {
// Written by the local stores, never by the wire.
if (key == 'completedat' || key == 'skippedat') continue;
final row = ApiConfig.pickupFromBooking(
booking(id: 'x', extra: {key: '2026-08-27T10:00:00'}),
);
expect(
ServiceDay.of(row),
'2026-08-27',
reason: '$key was dropped at the adapter',
);
}
});
test('an absent timestamp is not invented', () {
final row = ApiConfig.pickupFromBooking(booking(id: '2'));
expect(row.containsKey('pickedtime'), isFalse);
expect(ServiceDay.of(row), '');
});
});
group("yesterday's assignment is not today's work", () {
const today = '2026-08-27';
test('a booking still open from yesterday does not belong to today', () {
final row = ApiConfig.pickupFromBooking(
booking(id: '3', extra: const {'createdat': '2026-08-26T18:40:00'}),
);
expect(ServiceDay.belongsToToday(row, now: today), isFalse);
});
test('the same booking, raised this morning, does', () {
final row = ApiConfig.pickupFromBooking(
booking(id: '4', extra: const {'createdat': '2026-08-27T07:05:00'}),
);
expect(ServiceDay.belongsToToday(row, now: today), isTrue);
});
test('tomorrow is not today either', () {
// A pre-assigned booking is real work and it is not this shift's. It
// reappears on its own morning, when the same predicate admits it.
final row = ApiConfig.pickupFromBooking(
booking(id: '5', extra: const {'createdat': '2026-08-28T06:00:00'}),
);
expect(ServiceDay.belongsToToday(row, now: today), isFalse);
});
test('the local assignment stamp dates a row the backend does not', () {
// The live contract sends no clock the app can trust as an assignment
// time, so [WorkRepository] writes when the booking first reached this
// rider. It is the weakest key on the list and it is the one that
// actually fires on Doormile.
final row = ApiConfig.pickupFromBooking(booking(id: '6'))
..['assignedat'] = '2026-08-26T09:00:00';
expect(ServiceDay.of(row), '2026-08-26');
expect(ServiceDay.belongsToToday(row, now: today), isFalse);
});
test('a clock the backend sends outranks the local guess', () {
// The rider's phone met this booking on the 27th; the hub finished it on
// the 26th. The hub is right — the app's first sighting says only when
// the app was open.
final row = ApiConfig.pickupFromBooking(
booking(
id: '7',
status: 'Picked',
extra: const {'pickedtime': '2026-08-26T20:10:00'},
),
)..['assignedat'] = '2026-08-27T08:00:00';
expect(ServiceDay.of(row), '2026-08-26');
});
});
group('the work screens filter one day, by one rule', () {
const today = '2026-08-27';
Map<String, dynamic> dated(String id, String at) =>
ApiConfig.pickupFromBooking(booking(id: id))..['assignedat'] = at;
test("yesterday's leftovers are dropped and today's kept", () {
final kept = ServiceDay.onlyToday([
dated('old', '2026-08-26T09:00:00'),
dated('new', '2026-08-27T09:00:00'),
], now: today);
expect(kept.map((s) => s['orderid']), ['DM-new']);
});
test('an undated row among dated ones is dropped', () {
// The burden of proof is on the row — the same rule Activity applies.
final kept = ServiceDay.onlyToday([
dated('a', '2026-08-27T09:00:00'),
ApiConfig.pickupFromBooking(booking(id: 'nodate')),
], now: today);
expect(kept.map((s) => s['orderid']), ['DM-a']);
});
test('a day where NOTHING can be dated is kept whole', () {
// The narrow safety net. Not a stale queue — a build that has lost the
// ability to date anything — and blanking a rider's screen would hide
// that behind something that reads like good news.
final rows = [
ApiConfig.pickupFromBooking(booking(id: 'a')),
ApiConfig.pickupFromBooking(booking(id: 'b')),
];
expect(ServiceDay.onlyToday(rows, now: today).length, 2);
});
test('an empty day stays empty', () {
expect(ServiceDay.onlyToday(const [], now: today), isEmpty);
});
test('a picked stop finished today stays — the rail needs it', () {
// Deliveries and Home both count finished work into the day's figures.
// Dropping it would empty the progress ring every time a rider succeeds.
final done = ApiConfig.pickupFromBooking(
booking(
id: 'p',
status: 'Picked',
extra: const {'pickedtime': '2026-08-27T11:20:00'},
),
);
expect(ServiceDay.onlyToday([done], now: today).length, 1);
});
});
group('the slot a stop lands in is the part of the day', () {
/// The adapted shape plus the stamp the repository merges on.
Map<String, dynamic> assigned(String id, DateTime at) =>
ApiConfig.pickupFromBooking(booking(id: id))
..['assignedat'] = at.toIso8601String();
test('assigned in the morning is Trip 1', () {
final trips = Trip.groupIntoTrips([
assigned('a', DateTime(2026, 8, 27, 8, 30)),
]);
expect(trips.single.dayPart, DayPart.morning);
expect(trips.single.tripNumber, 1);
expect(trips.slots.indexOf(trips.single), 0);
});
test('assigned in the afternoon is Trip 2, on a day with no morning', () {
// The point of the whole slot model. A rider with only afternoon work
// used to see it as Trip 1, so his "Trip 2" and the hub's were different
// runs — and dispatch rings him about the hub's.
final trips = Trip.groupIntoTrips([
assigned('b', DateTime(2026, 8, 27, 14, 5)),
]);
expect(trips.single.tripNumber, 2);
final slots = trips.slots;
expect(slots[0], isNull);
expect(slots[1], isNotNull);
});
test('assigned in the evening is Trip 3', () {
final trips = Trip.groupIntoTrips([
assigned('c', DateTime(2026, 8, 27, 19, 0)),
]);
expect(trips.single.tripNumber, 3);
expect(trips.slots[2], isNotNull);
});
test('a full day fills all three slots in order', () {
final trips = Trip.groupIntoTrips([
assigned('m', DateTime(2026, 8, 27, 9, 0)),
assigned('a', DateTime(2026, 8, 27, 13, 0)),
assigned('e', DateTime(2026, 8, 27, 18, 0)),
]);
expect(trips.length, 3);
expect([for (final t in trips) t.tripNumber], [1, 2, 3]);
// `orderid` is the booking *reference* after translation — the id the
// rider reads, not the row's primary key.
expect(trips.slots.map((t) => t?.stops.single['orderid']), [
'DM-m',
'DM-a',
'DM-e',
]);
});
test('two runs in one part stay one trip', () {
// A day part is not a rolling window: two stops handed over an hour apart
// in the same morning are the same run, and which trip a stop is on must
// never depend on what else is in the day.
final trips = Trip.groupIntoTrips([
assigned('a', DateTime(2026, 8, 27, 7, 30)),
assigned('b', DateTime(2026, 8, 27, 11, 45)),
]);
expect(trips.length, 1);
expect(trips.single.stops.length, 2);
expect(trips.single.tripNumber, 1);
});
test('a real due time still outranks the assignment clock', () {
// Handed over at eight in the morning, due at three in the afternoon. The
// work is the afternoon's; the handover time was only ever a stand-in for
// a field the backend does not send.
final stop = assigned('d', DateTime(2026, 8, 27, 8, 0));
stop['expected_pickup_time'] = DateTime(
2026,
8,
27,
15,
0,
).toIso8601String();
expect(Trip.groupIntoTrips([stop]).single.tripNumber, 2);
});
test('an explicit backend slot window still wins outright', () {
// If the console ever grows real slots, they are the answer and none of
// the inference above runs.
final stop = assigned('s', DateTime(2026, 8, 27, 19, 30))
..['slotstarttime'] = DateTime(2026, 8, 27, 10).toIso8601String()
..['slotendtime'] = DateTime(2026, 8, 27, 13).toIso8601String();
final trip = Trip.groupIntoTrips([stop]).single;
expect(trip.slotLabel, '10:00 AM – 1:00 PM');
expect(trip.tripNumber, 1);
});
test('a stop with no clock at all keeps its arrival order', () {
// Unchanged behaviour, stated so it stays deliberate: a trip that cannot
// be placed by day part must not be guessed at from the wall clock, or it
// would move between tabs as the afternoon wore on.
final trips = Trip.groupIntoTrips([
ApiConfig.pickupFromBooking(booking(id: 'z')),
]);
expect(trips.single.dayPart, isNull);
expect(trips.slots[0], isNotNull);
});
});
}

View File

@@ -14,7 +14,7 @@ import 'package:miler/views/Dashboard/home/trip_card.dart';
/// under it, ticked in batches and advanced with one gesture: /// under it, ticked in batches and advanced with one gesture:
/// ///
/// ``` /// ```
/// Vidhya Kitchen · 4 bags · 2 to accept ˅ /// Vidhya Kitchen · 4 orders · 2 to accept ˅
/// ◉ Joe Gandhipuram Bag 1 /// ◉ Joe Gandhipuram Bag 1
/// ◉ Priya RS Puram Bag 2 /// ◉ Priya RS Puram Bag 2
/// ``` /// ```
@@ -153,7 +153,9 @@ void main() {
// the detail sheet and the Activity record. // the detail sheet and the Activity record.
expect(find.text('Gandhipuram'), findsOneWidget); expect(find.text('Gandhipuram'), findsOneWidget);
expect(find.textContaining('1001'), findsNothing); expect(find.textContaining('1001'), findsNothing);
expect(find.text('Bag 1'), findsOneWidget); // And no invented shelf reference beside it: this counter printed
// nothing, so the row carries the person and the area and stops there.
expect(find.text('Bag 1'), findsNothing);
}); });
// Updated when `areaOf` was corrected against real Coimbatore addresses // Updated when `areaOf` was corrected against real Coimbatore addresses
@@ -181,19 +183,25 @@ void main() {
expect(find.text(''), findsNothing); expect(find.text(''), findsNothing);
}); });
testWidgets('one order is one bag, counted off the orders', (tester) async { testWidgets('the load is stated once, and counted off the orders', (
tester,
) async {
await pump(tester, [ await pump(tester, [
order('1001', 'Joe', 'Gandhipuram, Coimbatore'), order('1001', 'Joe', 'Gandhipuram, Coimbatore'),
order('1002', 'Priya', 'RS Puram, Coimbatore'), order('1002', 'Priya', 'RS Puram, Coimbatore'),
order('1003', 'Arun', 'Peelamedu, Coimbatore'), order('1003', 'Arun', 'Peelamedu, Coimbatore'),
]); ]);
// The header states the load once, in the unit the rider carries; the // The header states the load once. `N orders · N bags` was the same
// check that orders and bags agree is the manifest itself, one named // number twice and it cost the header its `to accept` clause on a real
// bag per order. `N orders · N bags` was the same number twice and it // route; the derived per-row numbering that made up the second half is
// cost the header its `to accept` clause on a real route. // gone entirely.
expect(find.textContaining('3 bags'), findsWidgets); expect(find.textContaining('3 orders'), findsWidgets);
for (final bag in const ['Bag 1', 'Bag 2', 'Bag 3']) { for (final invented in const ['Bag 1', 'Bag 2', 'Bag 3']) {
expect(find.text(bag), findsOneWidget); expect(find.text(invented), findsNothing);
}
// One row per order, named by the person it is for.
for (final who in const ['Joe', 'Priya', 'Arun']) {
expect(find.textContaining(who), findsWidgets);
} }
}); });
}); });
@@ -213,7 +221,7 @@ void main() {
// The rider is mid-decision at one counter; the count of what is still // The rider is mid-decision at one counter; the count of what is still
// undecided is the whole content of that state. // undecided is the whole content of that state.
expect(find.textContaining('4 bags'), findsWidgets); expect(find.textContaining('4 orders'), findsWidgets);
expect(find.textContaining('to accept'), findsWidgets); expect(find.textContaining('to accept'), findsWidgets);
}); });
}); });

View File

@@ -110,7 +110,7 @@ void main() {
test('every stop is a drop, with a bag the rider can read off a box', () { test('every stop is a drop, with a bag the rider can read off a box', () {
for (final s in MealRunMock.stops()) { for (final s in MealRunMock.stops()) {
expect(stopKindOf(s).isDelivery, isTrue); expect(stopKindOf(s).isDelivery, isTrue);
expect(stopBagLabel(s), isNotEmpty); expect(stopPrintedLabel(s), isNotEmpty);
expect((s['pickupcustomer'] as String), isNotEmpty); expect((s['pickupcustomer'] as String), isNotEmpty);
expect((s['pickupaddress'] as String), contains('Coimbatore')); expect((s['pickupaddress'] as String), contains('Coimbatore'));
} }

View File

@@ -402,17 +402,73 @@ void main() {
expect(s['dropaddress'], 'Flat 3, Anna Nagar'); expect(s['dropaddress'], 'Flat 3, Anna Nagar');
}); });
test('a booking without source fields does not invent one', () { test('a booking without source fields is never given a name', () {
// BACKEND DEPENDENCY: sourceid/sourcename are not deployed. Until they // BACKEND DEPENDENCY: sourceid/sourcename are not deployed everywhere.
// are, every order groups under one unnamed bucket — visibly wrong, and // The rule that matters is unchanged and is what this asserts: the app
// deliberately not papered over with a guessed kitchen name. // does not paper over a missing counter with a guessed kitchen name.
final s = ApiConfig.pickupFromBooking({ final s = ApiConfig.pickupFromBooking({
'bookingid': 43, 'bookingid': 43,
'bookingreference': 'DM-43', 'bookingreference': 'DM-43',
'pickupaddress': '9 Mettupalayam Road', 'pickupaddress': '9 Mettupalayam Road',
}); });
expect(MilkRun.sourceNameOf(s), ''); expect(MilkRun.sourceNameOf(s), '');
expect(MilkRun.sourceKeyOf(s), 'none'); });
test('...but it still groups by the address it does carry', () {
// ── What changed, and why it is not "inventing" one ──
//
// This used to demand `'none'`, which put every source-less order in one
// bucket regardless of where it was collected. On a real morning that
// failed in the other direction: eight orders off one kitchen, seven
// carrying its id and one not, and the eighth could not join them — it
// fell out of the dropdown into a flat header of its own titled with the
// customer's name.
//
// Keying on the address invents nothing. It is the weakest *true*
// statement on the row — these are collected from the same address — and
// it is only reached when the id and the name are both absent. The name
// is still empty, which is what the test above pins.
Map<String, dynamic> at(String address) =>
ApiConfig.pickupFromBooking({
'bookingid': address.hashCode,
'bookingreference': 'DM-$address',
'pickupaddress': address,
});
// Same counter, spelled as two different rows would spell it.
expect(
MilkRun.sourceKeyOf(at('9 Mettupalayam Road')),
MilkRun.sourceKeyOf(at('9, METTUPALAYAM ROAD ')),
);
// A different place stays a different group.
expect(
MilkRun.sourceKeyOf(at('9 Mettupalayam Road')),
isNot(MilkRun.sourceKeyOf(at('12 SNS Colony'))),
);
// Nothing to group on at all is still its own bucket, never a guess.
expect(
MilkRun.sourceKeyOf(
ApiConfig.pickupFromBooking({
'bookingid': 44,
'bookingreference': 'DM-44',
}),
),
'none',
);
});
test('an identified counter still outranks the address', () {
// Order of trust: the hub's id, then the name it reads by, then where it
// is. Two kitchens at one address must not merge because a row lost its
// id; two rows with the same id must not split because someone retyped
// the address.
final byId = ApiConfig.pickupFromBooking({
'bookingid': 45,
'bookingreference': 'DM-45',
'sourceid': 'K1',
'pickupaddress': '9 Mettupalayam Road',
});
expect(MilkRun.sourceKeyOf(byId), 'id:K1');
}); });
}); });
} }

View File

@@ -3,7 +3,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:miler/Models/stop_status.dart'; import 'package:miler/Models/stop_status.dart';
import 'package:miler/data/bag_manifest.dart'; import 'package:miler/data/order_manifest.dart';
import 'package:miler/data/service_profile.dart'; import 'package:miler/data/service_profile.dart';
import 'package:miler/views/Dashboard/home/stop_action_sheet.dart'; import 'package:miler/views/Dashboard/home/stop_action_sheet.dart';
import 'package:miler/views/Dashboard/home/trip.dart'; import 'package:miler/views/Dashboard/home/trip.dart';
@@ -63,22 +63,21 @@ void main() {
]; ];
group('the rule itself', () { group('the rule itself', () {
test('bags are counted from orders, never stored', () { test('the load is counted from the orders, never stored', () {
expect(BagManifest.countLabel(5), '5 bags'); expect(OrderManifest.countLabel(5), '5 orders');
expect(BagManifest.countLabel(1), '1 bag'); expect(OrderManifest.countLabel(1), '1 order');
}); });
test('every order gets exactly one bag, in group order', () { test('every order gets exactly one line, in group order', () {
final lines = BagManifest.forGroup(fiveOrders); // ── The derived numbering is gone ──
//
// This demanded `Bag 1 … Bag 5` — the app numbering a list it had built
// and drawing the result as a physical reference. What it was really
// protecting is the pairing, and that is unchanged: one line per order,
// in the counter's order, each carrying its own customer.
final lines = OrderManifest.forGroup(fiveOrders);
expect(lines.length, 5, reason: 'five orders is five manifest lines'); expect(lines.length, 5, reason: 'five orders is five manifest lines');
expect(lines.map((l) => l.bag).toList(), [
'Bag 1',
'Bag 2',
'Bag 3',
'Bag 4',
'Bag 5',
]);
expect(lines.map((l) => l.customer).toList(), [ expect(lines.map((l) => l.customer).toList(), [
'Joe', 'Joe',
'Arun', 'Arun',
@@ -86,41 +85,53 @@ void main() {
'Kumar', 'Kumar',
'Ravi', 'Ravi',
]); ]);
// Order 1 → Bag 1 → Joe, and it stays that way.
expect(lines.first.orderId, '1'); expect(lines.first.orderId, '1');
expect( expect(
lines.map((l) => l.bag).toSet().length, lines.map((l) => l.orderId).toSet().length,
5, 5,
reason: 'two orders may never claim the same bag', reason: 'two lines may never claim the same order',
); );
// A counter that printed nothing yields no labels at all.
expect(lines.every((l) => l.label.isEmpty), isTrue);
}); });
test('a label the kitchen actually printed wins over the position', () { test('a label the kitchen printed travels verbatim, and only that', () {
final lines = BagManifest.forGroup([ final lines = OrderManifest.forGroup([
order('1', customer: 'Joe', printedBag: 'DG-1042'), order('1', customer: 'Joe', printedBag: 'DG-1042'),
order('2', customer: 'Arun'), order('2', customer: 'Arun'),
]); ]);
expect(lines[0].bag, 'DG-1042', reason: 'what is on the physical bag'); // Exactly as it was sent — never prefixed, never renamed.
expect(lines[1].bag, 'Bag 2'); expect(lines[0].label, 'DG-1042');
// And nothing is manufactured for the order beside it.
expect(lines[1].label, '');
}); });
test('the item quantity is never read as a bag count', () { test('the item quantity is never read as a count of handovers', () {
// Each of these orders carries `quantity: 4`. Four is an item count; the // Each of these orders carries `quantity: 4`. Four is an item count; the
// bag count is one, per order, always. // count of handovers is one, per order, always.
final lines = BagManifest.forGroup(fiveOrders); final lines = OrderManifest.forGroup(fiveOrders);
expect(lines.length, 5); expect(lines.length, 5);
expect(BagManifest.countLabel(lines.length), '5 bags'); expect(OrderManifest.countLabel(lines.length), '5 orders');
}); });
test('a bag belongs to its own counter, not the whole route', () { test('nothing is invented for a counter that prints nothing', () {
// Third order from the second kitchen is *its* Bag 3, not the route's // ── What this test used to assert ──
// eighth — the rider counts one shelf at a time. //
final kitchenTwo = [ // That the third order from the second kitchen was *its* `Bag 3` and not
order('6', customer: 'Meena', kitchen: 'Anna Kitchen', kitchenId: 'K2'), // the route's eighth — the app numbering a list it had built and drawing
order('7', customer: 'Sai', kitchen: 'Anna Kitchen', kitchenId: 'K2'), // the result as a shelf reference.
order('8', customer: 'Devi', kitchen: 'Anna Kitchen', kitchenId: 'K2'), //
]; // The numbering is gone. It was the app's own arithmetic presented as a
expect(BagManifest.bagFor(kitchenTwo[2], kitchenTwo), 'Bag 3'); // physical fact, on rows that already carry a rail numbering them two
// columns to the left. What a counter really printed still travels
// verbatim; where it printed nothing, nothing shows.
final unlabelled = order(
'6',
customer: 'Meena',
kitchen: 'Anna Kitchen',
kitchenId: 'K2',
);
expect(OrderManifest.labelFor(unlabelled), '');
}); });
}); });
@@ -158,7 +169,7 @@ void main() {
).buildStops(), ).buildStops(),
); );
expect(find.text('5 bags'), findsOneWidget); expect(find.text('5 orders'), findsOneWidget);
// Nothing anywhere offers a second, contradictable count. // Nothing anywhere offers a second, contradictable count.
expect(find.textContaining('meals'), findsNothing); expect(find.textContaining('meals'), findsNothing);
expect(find.textContaining('Quantity'), findsNothing); expect(find.textContaining('Quantity'), findsNothing);
@@ -191,7 +202,7 @@ void main() {
); );
expect(find.text('Vidhya Kitchen'), findsOneWidget); expect(find.text('Vidhya Kitchen'), findsOneWidget);
expect(find.text('5 bags'), findsOneWidget); expect(find.text('5 orders'), findsOneWidget);
// One obvious next action on the whole screen, and it is the journey. // One obvious next action on the whole screen, and it is the journey.
expect(find.bySemanticsLabel('Navigate to this pickup'), findsOneWidget); expect(find.bySemanticsLabel('Navigate to this pickup'), findsOneWidget);
}); });
@@ -218,10 +229,10 @@ void main() {
// node still names the kitchen and its load, so nothing is hidden. // node still names the kitchen and its load, so nothing is hidden.
expect(find.bySemanticsLabel('Navigate to this pickup'), findsNothing); expect(find.bySemanticsLabel('Navigate to this pickup'), findsNothing);
expect(find.text('Vidhya Kitchen'), findsOneWidget); expect(find.text('Vidhya Kitchen'), findsOneWidget);
expect(find.text('5 bags'), findsOneWidget); expect(find.text('5 orders'), findsOneWidget);
}); });
testWidgets('the confirmation names every order and its bag', ( testWidgets('the confirmation names every order it covers', (
tester, tester,
) async { ) async {
ServiceProfile.setActive(ServiceProfile.meals); ServiceProfile.setActive(ServiceProfile.meals);
@@ -231,24 +242,23 @@ void main() {
stage: StopStatus.accepted, stage: StopStatus.accepted,
placeName: 'Vidhya Kitchen', placeName: 'Vidhya Kitchen',
count: fiveOrders.length, count: fiveOrders.length,
lines: BagManifest.forGroup(fiveOrders), lines: OrderManifest.forGroup(fiveOrders),
onConfirm: (_) async => null, onConfirm: (_) async => null,
), ),
); );
expect(find.text('Arrived at Vidhya Kitchen'), findsOneWidget); expect(find.text('Arrived at Vidhya Kitchen'), findsOneWidget);
expect(find.text('5 bags'), findsOneWidget); expect(find.text('5 orders'), findsOneWidget);
// The list he checks against the shelf: five names, five bags, paired. // The list he checks against the shelf: five people, one line each. The
for (final pair in const [ // derived `Bag 1 … Bag 5` that used to sit beside them was the app's own
('Joe', 'Bag 1'), // numbering of a list it had built, and it is gone; a label a counter
('Arun', 'Bag 2'), // really printed still appears, and this fixture prints none.
('Priya', 'Bag 3'), for (final who in const ['Joe', 'Arun', 'Priya', 'Kumar', 'Ravi']) {
('Kumar', 'Bag 4'), expect(find.text(who), findsOneWidget);
('Ravi', 'Bag 5'), }
]) { for (final invented in const ['Bag 1', 'Bag 2', 'Bag 5']) {
expect(find.text(pair.$1), findsOneWidget); expect(find.text(invented), findsNothing);
expect(find.text(pair.$2), findsOneWidget);
} }
}); });
@@ -262,36 +272,46 @@ void main() {
stage: StopStatus.arrived, stage: StopStatus.arrived,
placeName: 'Vidhya Kitchen', placeName: 'Vidhya Kitchen',
count: fiveOrders.length, count: fiveOrders.length,
lines: BagManifest.forGroup(fiveOrders), lines: OrderManifest.forGroup(fiveOrders),
onConfirm: (_) async => null, onConfirm: (_) async => null,
), ),
); );
expect(find.text('Picked up from Vidhya Kitchen'), findsOneWidget); expect(find.text('Picked up from Vidhya Kitchen'), findsOneWidget);
expect( expect(
find.text('Make sure all 5 bags are loaded before continuing.'), find.text('Make sure all 5 orders are loaded before continuing.'),
findsOneWidget, findsOneWidget,
); );
// Both rungs are the same interaction, named for what they claim. // Both rungs are the same interaction, named for what they claim.
expect(find.textContaining('Slide to confirm'), findsOneWidget); expect(find.textContaining('Slide to confirm'), findsOneWidget);
}); });
testWidgets('a card says which bag it is carrying', (tester) async { testWidgets('a card carries the label its counter printed', (tester) async {
ServiceProfile.setActive(ServiceProfile.meals); ServiceProfile.setActive(ServiceProfile.meals);
// A counter that really prints labels. Five near-identical lunches is
// where mis-delivery comes from, and a printed code is the cheapest thing
// that catches it — so where one exists it must reach the card, verbatim.
final printed = [
order('1', customer: 'Joe', printedBag: 'DG-1001'),
order('2', customer: 'Arun', printedBag: 'DG-1002'),
order('3', customer: 'Priya', printedBag: 'DG-1003'),
order('4', customer: 'Kumar', printedBag: 'DG-1004'),
order('5', customer: 'Ravi', printedBag: 'DG-1005'),
];
await pump( await pump(
tester, tester,
() => TripCard( () => TripCard(
trip: Trip.fromStops(id: 't', stops: fiveOrders), trip: Trip.fromStops(id: 't', stops: printed),
acceptedIds: const {'1', '2', '3', '4', '5'}, acceptedIds: const {'1', '2', '3', '4', '5'},
onToggleSelect: (_) {}, onToggleSelect: (_) {},
).buildStops(), ).buildStops(),
); );
// Five near-identical lunch bags is where mis-delivery comes from, so expect(find.text('DG-1001'), findsOneWidget);
// every card names the one it is for. expect(find.text('DG-1005'), findsOneWidget);
expect(find.text('Bag 1'), findsOneWidget);
expect(find.text('Bag 5'), findsOneWidget);
expect(find.text('Ravi'), findsWidgets); expect(find.text('Ravi'), findsWidgets);
// Shown as the counter wrote it — never dressed up with a word.
expect(find.textContaining('Bag'), findsNothing);
}); });
}); });
} }

View File

@@ -0,0 +1,248 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:get/get.dart';
import 'package:miler/data/milk_run.dart';
import 'package:miler/data/service_profile.dart';
import 'package:miler/views/Dashboard/home/route_timeline.dart';
import 'package:miler/views/Dashboard/home/trip.dart';
import 'package:miler/views/Dashboard/home/trip_card.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// EIGHT ORDERS OFF ONE COUNTER MAKE ONE DROPDOWN OF EIGHT
///
/// ── What the rider saw ──
///
/// The hub assigned eight orders from one kitchen. Home drew a dropdown holding
/// **seven**, and the eighth sat above it as a bare header titled with the
/// customer's name — reading as a second place he had to ride to, off the same
/// shelf as the other seven.
///
/// ── Two causes, one level apart ──
///
/// **The grouping key.** `routeGroups` built its own — `stopSourceId`, falling
/// back to `stopSourceName` — while [MilkRun.sourceKeyOf] is the function that
/// exists to answer *which counter is this*, and is what the bulk collect keys
/// on. They disagreed: `sourceKeyOf` reads `pickuplocationid` and the card's
/// own expression did not. A row identified only by its location id was one
/// place according to the collect and another according to the card offering
/// it. And a row carrying no source identity at all got its own bucket, with
/// nothing consulted about *where it is collected from* — which is on every
/// row, as the pickup address.
///
/// **The `flat` flag.** A lone stop was drawn as a bare header — the header
/// *is* the stop — whenever the payload carried no source name. A missing name
/// is a gap in the data, not evidence that there is no counter, so the one
/// order that lost its kitchen name was demoted from a place to a row.
///
/// Both now answer from the same fact: a place is identified by its id, then
/// its name, then **where it is**.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
tearDown(() {
Get.reset();
ServiceProfile.setActive(ServiceProfile.parcel);
});
const kitchen = '9 Mettupalayam Road, RS Puram, Coimbatore 641002';
/// One order off the kitchen. [sourceId] and [sourceName] are omitted on the
/// row that lost them, which is the case the whole file is about.
Map<String, dynamic> order(
int id, {
String? sourceId = 'K1',
String? sourceName = 'Vidhya Kitchen',
String address = kitchen,
String status = 'assigned',
}) => <String, dynamic>{
'orderid': 'M-$id',
'pickupid': id,
'step': id,
if (sourceId != null) 'sourceid': sourceId,
if (sourceName != null) 'kitchenname': sourceName,
'pickupcustomer': 'Customer $id',
'pickupaddress': address,
'dropaddress': 'Door $id, Peelamedu, Coimbatore',
'orderstatus': status,
'type': 'delivery',
'pickuplat': 11.01,
'pickuplon': 76.95,
};
List<RouteGroup> groupsFor(List<Map<String, dynamic>> stops) {
ServiceProfile.setActive(ServiceProfile.milkMan);
return TripCard(trip: Trip.fromStops(id: 'T1', stops: stops)).routeGroups();
}
group('the reported case: eight assigned, seven in the list', () {
test('the odd row out joins its siblings', () {
// Seven carry the kitchen's id; the eighth lost both id and name and has
// only the address it shares with them.
final stops = [
for (var i = 1; i <= 7; i++) order(i),
order(8, sourceId: null, sourceName: null),
];
final groups = groupsFor(stops);
expect(groups.length, 1, reason: 'one counter, one dropdown');
expect(groups.single.stops.length, 8);
expect(groups.single.flat, isFalse);
});
test('the count the rider reads says eight', () {
final stops = [
for (var i = 1; i <= 7; i++) order(i),
order(8, sourceId: null, sourceName: null),
];
final group = groupsFor(stops).single;
// The load line and the list under it are the same eight.
expect(group.countLabel, '8 orders');
expect(group.manifest.length, 8);
expect(group.pendingCount, 8);
});
test('a row from a genuinely different place still splits', () {
// The fix must not merge two counters. Grouping by address is the weakest
// true statement available, not a licence to collapse the route.
final stops = [
for (var i = 1; i <= 7; i++) order(i),
order(
8,
sourceId: null,
sourceName: null,
address: '12 SNS Colony, Peelamedu, Coimbatore 641004',
),
];
expect(groupsFor(stops).length, 2);
});
});
group('the heading names the place, never an order', () {
test('a counter with no name is headed by its address', () {
final stops = [
for (var i = 1; i <= 3; i++)
order(i, sourceId: null, sourceName: null),
];
final group = groupsFor(stops).single;
// It used to fall through to `MilkRun.navigationLabel`, which answers
// with the customer — so the heading read as one more order.
expect(group.name, isNot(contains('Customer')));
expect(group.name, contains('Mettupalayam'));
});
test('a named counter still leads with its name, address beneath', () {
final group = groupsFor([for (var i = 1; i <= 3; i++) order(i)]).single;
expect(group.name, 'Vidhya Kitchen');
expect(group.location, contains('Mettupalayam'));
});
test('the address is not printed twice when it is the heading', () {
final stops = [
for (var i = 1; i <= 3; i++)
order(i, sourceId: null, sourceName: null),
];
final group = groupsFor(stops).single;
expect(group.name, isNotEmpty);
expect(
group.location,
isEmpty,
reason: 'the heading already says where it is',
);
});
});
group('a lone stop is a place when it has one', () {
test('one order off an unnamed counter still opens as a list', () {
// The same demotion, one level down: a single order whose row lost its
// kitchen name was drawn as a bare customer header rather than as a place
// holding one order.
final group = groupsFor([
order(1, sourceId: null, sourceName: null),
]).single;
expect(group.flat, isFalse, reason: 'it has an address to be a place by');
expect(group.name, contains('Mettupalayam'));
expect(group.manifest.length, 1);
});
test('with nothing at all to name a place by, it is the stop', () {
// Unchanged, and deliberately: no counter, no name, no address means
// there is no place above the order worth drawing, so the header is the
// stop and opens its detail sheet.
final group = groupsFor([
order(1, sourceId: null, sourceName: null, address: ''),
]).single;
expect(group.flat, isTrue);
});
test('a parcel route is always flat, whatever the address says', () {
// Every stop is its own group there — the rider really does ride to each
// customer for one consignment, and a header plus a single child would
// print the same name twice.
ServiceProfile.setActive(ServiceProfile.parcel);
final groups = TripCard(
trip: Trip.fromStops(
id: 'T1',
stops: [for (var i = 1; i <= 3; i++) order(i)],
),
).routeGroups();
expect(groups.length, 3);
expect(groups.every((g) => g.flat), isTrue);
});
});
group('adopting a counter, and the limits on it', () {
test('an identified stop never consults the day', () {
// Adoption only ever fills a gap. A stop carrying its own id keeps it,
// so two counters that happen to share a street cannot merge and a row
// cannot be pulled off the id the hub gave it.
final mine = <String, dynamic>{
'orderid': 'M-1',
'sourceid': 'K1',
'pickupaddress': kitchen,
};
final neighbour = <String, dynamic>{
'orderid': 'M-2',
'sourceid': 'K2',
'pickupaddress': kitchen,
};
expect(MilkRun.sourceKeyOf(mine, within: [mine, neighbour]), 'id:K1');
expect(MilkRun.sourceKeyOf(neighbour, within: [mine, neighbour]), 'id:K2');
});
test('with no day to consult it falls back to the address', () {
final orphan = <String, dynamic>{
'orderid': 'M-3',
'pickupaddress': kitchen,
};
expect(MilkRun.sourceKeyOf(orphan), startsWith('at:'));
});
test('the bulk collect gathers the adopted order too', () {
// The collect must not leave behind the order the card has just learned
// to group in — that is the two-readers failure in a new place.
final day = [
for (var i = 1; i <= 7; i++) order(i),
order(8, sourceId: null, sourceName: null),
];
expect(MilkRun.sameSourceAs(day.first, day).length, 8);
expect(MilkRun.sameSourceAs(day.last, day).length, 8);
});
});
group('one reader for which counter this is', () {
test('the card groups on the same key the bulk collect does', () {
// The two disagreed, so a kitchen could be split on screen while the
// collect still treated it as one operation.
final byLocationId = <String, dynamic>{
'orderid': 'M-9',
'pickuplocationid': 'L7',
'pickupaddress': kitchen,
};
expect(MilkRun.sourceKeyOf(byLocationId), 'id:L7');
});
});
}

View File

@@ -10,7 +10,7 @@ import 'package:miler/views/Dashboard/home/pickup_preview_sheet.dart';
import 'package:miler/views/Dashboard/home/stop_detail_sheet.dart'; import 'package:miler/views/Dashboard/home/stop_detail_sheet.dart';
import 'package:miler/views/Dashboard/home/trip.dart'; import 'package:miler/views/Dashboard/home/trip.dart';
import 'package:miler/views/Dashboard/home/trip_card.dart'; import 'package:miler/views/Dashboard/home/trip_card.dart';
import 'package:miler/data/bag_manifest.dart'; import 'package:miler/data/order_manifest.dart';
import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart'; import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart';
/// ───────────────────────────────────────────────────────────────────────── /// ─────────────────────────────────────────────────────────────────────────
@@ -189,17 +189,17 @@ void main() {
c, c,
placeName: 'Vidhya Kitchen', placeName: 'Vidhya Kitchen',
lines: [ lines: [
BagLine( ManifestLine(
stop: order(), stop: order(),
orderId: 'D1', orderId: 'D1',
customer: 'Joe Mathew', customer: 'Joe Mathew',
bag: 'Bag 1', label: 'Bag 1',
), ),
BagLine( ManifestLine(
stop: order(), stop: order(),
orderId: 'D2', orderId: 'D2',
customer: 'Priya Nair', customer: 'Priya Nair',
bag: 'Bag 2', label: 'Bag 2',
), ),
], ],
meters: 3100, meters: 3100,
@@ -311,7 +311,7 @@ void main() {
stop: long, stop: long,
stopNumber: 7, stopNumber: 7,
ofOrder: true, ofOrder: true,
bag: 'Bag 1', label: 'Bag 1',
), ),
); );

View File

@@ -0,0 +1,133 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/Models/stop_status.dart';
import 'package:miler/data/service_profile.dart';
import 'package:miler/views/Dashboard/home/trip.dart';
import 'package:miler/views/Dashboard/pickups/pickups.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// THE RAIL COUNTS THE ROUTE, NOT THE RECORD
///
/// ── What the rider saw ──
///
/// He accepted one order out of five, picked it up, and the progress rail at
/// the top of Deliveries drew **five nodes**. One of them was his.
///
/// ── Where the other four came from ──
///
/// The rail reads the completed store, and that store is not a list of
/// finished deliveries. It is where every order that leaves the rider's hands
/// is filed, whatever ended it — and Home files a **declined** stop there too,
/// with `terminalStatus: 'rejected'`, so the decision survives on Activity.
/// That was harmless for as long as nothing read the store as a route. The rail
/// reads it as a route.
///
/// The count was the visible half. The colours were worse, and this is what
/// these tests really pin:
///
/// `rejected` → [StopProgress.pending] — four stops he had **refused**,
/// drawn on the rail ahead of him as work still to come.
/// `cancelled` → [StopStatusX.isWorkComplete] → [StopProgress.done] —
/// drawn **green**, claiming a delivery that never happened.
///
/// A skip is the case that must survive the fix: it is a door he went to and
/// could not finish, the rail has an amber node for precisely that, and losing
/// it would hide work he still owes.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
/// A row as the completed store hands it back — `orderstatus` stamped with
/// whatever ended it. See `addCompletedBookings`.
Map<String, dynamic> filed(String id, String terminalStatus) => {
'orderid': id,
'orderstatus': terminalStatus,
'completedday': '2026-08-27',
'completedat': '2026-08-27T10:00:00',
};
group('what belongs on a progress rail', () {
test('a declined stop does not', () {
// The four that made the rail read five. He said no to these.
expect(wasRiddenTo(filed('r', 'rejected')), isFalse);
});
test('a cancelled stop does not', () {
// The office called it off. It was never a door he stood at, and on a
// milk run it would have drawn green.
expect(wasRiddenTo(filed('c', 'cancelled')), isFalse);
});
test('a skipped stop does', () {
// Unfinished business, and the one state the rail has an amber node for.
// Dropping it with the others would hide a door he still owes a visit.
expect(wasRiddenTo(filed('s', 'skipped')), isTrue);
});
test('a delivered stop does', () {
expect(wasRiddenTo(filed('d', 'delivered')), isTrue);
});
test('a picked stop does', () {
expect(wasRiddenTo(filed('p', 'picked')), isTrue);
});
});
group('the states the excluded rows would have been drawn in', () {
test('a declined stop reads as work still ahead', () {
// Not a subtle wrong colour — [StopProgress.pending] is the same node a
// stop he has yet to ride to gets. This is why exclusion is the fix and
// recolouring is not: it is not on his route at all.
expect(
stopProgressFor(stopStatusFromRaw('rejected')),
StopProgress.pending,
);
});
test('a cancelled stop reads as done', () {
ServiceProfile.setActive(ServiceProfile.milkMan);
expect(stopStatusFromRaw('cancelled').isWorkComplete, isTrue);
expect(
stopProgressFor(stopStatusFromRaw('cancelled')),
StopProgress.done,
);
});
test('a skipped stop reads as skipped, on either line', () {
for (final profile in [ServiceProfile.parcel, ServiceProfile.milkMan]) {
ServiceProfile.setActive(profile);
expect(
stopProgressFor(stopStatusFromRaw('skipped')),
StopProgress.skipped,
reason: 'the amber node is the whole reason a skip stays',
);
}
});
});
group("the rider's own case: one of five", () {
test('four declined and one picked leaves a rail of one', () {
// The report, as arithmetic. The store holds all five because Home files
// a decline; only one of them is a place he rode to.
final store = [
filed('1', 'picked'),
filed('2', 'rejected'),
filed('3', 'rejected'),
filed('4', 'rejected'),
filed('5', 'rejected'),
];
expect(store.length, 5);
expect(store.where(wasRiddenTo).length, 1);
});
test('a day genuinely worked is not trimmed', () {
// The fix must not cost a rider his progress. Four real completions and
// one in hand is still a five-node rail, four of them green.
final store = [
for (final id in ['1', '2', '3', '4']) filed(id, 'delivered'),
];
expect(store.where(wasRiddenTo).length, 4);
});
});
}

View File

@@ -87,27 +87,27 @@ void main() {
expect(stopSourceName({'kitchenname': 'Kitchen 1'}), 'Kitchen 1'); expect(stopSourceName({'kitchenname': 'Kitchen 1'}), 'Kitchen 1');
expect(stopSourceName({'SourceName': ' Anna Nagar '}), 'Anna Nagar'); expect(stopSourceName({'SourceName': ' Anna Nagar '}), 'Anna Nagar');
expect(stopSourceId({'kitchenid': 'K-77'}), 'K-77'); expect(stopSourceId({'kitchenid': 'K-77'}), 'K-77');
expect(stopBagLabel({'baglabel': 'DG-1042'}), 'DG-1042'); expect(stopPrintedLabel({'baglabel': 'DG-1042'}), 'DG-1042');
expect(stopBagLabel({'labelcode': 'DG-9'}), 'DG-9'); expect(stopPrintedLabel({'labelcode': 'DG-9'}), 'DG-9');
}); });
test( test(
'absent on a parcel stop rather than empty-stringed onto the card', 'absent on a parcel stop rather than empty-stringed onto the card',
() { () {
expect(stopSourceName({'orderid': '7'}), ''); expect(stopSourceName({'orderid': '7'}), '');
expect(stopBagLabel({'orderid': '7'}), ''); expect(stopPrintedLabel({'orderid': '7'}), '');
}, },
); );
}); });
group('StopSourceLine', () { group('StopSourceLine', () {
Future<void> pump(WidgetTester tester, String source, String bag) async { Future<void> pump(WidgetTester tester, String source, String label) async {
await tester.pumpWidget( await tester.pumpWidget(
ScreenUtilInit( ScreenUtilInit(
designSize: const Size(390, 844), designSize: const Size(390, 844),
builder: (_, _) => MaterialApp( builder: (_, _) => MaterialApp(
home: Scaffold( home: Scaffold(
body: StopSourceLine(source: source, bagLabel: bag), body: StopSourceLine(source: source, printedLabel: label),
), ),
), ),
), ),
@@ -126,12 +126,17 @@ void main() {
testWidgets('shows both when the stop carries both', (tester) async { testWidgets('shows both when the stop carries both', (tester) async {
await pump(tester, 'Kitchen 1', 'DG-1042'); await pump(tester, 'Kitchen 1', 'DG-1042');
expect(find.text('Kitchen 1'), findsOneWidget); expect(find.text('Kitchen 1'), findsOneWidget);
expect(find.text('Bag DG-1042'), findsOneWidget); // The counter's code, exactly as it was sent. It used to be prefixed
// `Bag DG-1042` — a word bolted onto a reference that already names
// itself.
expect(find.text('DG-1042'), findsOneWidget);
}); });
testWidgets('shows the bag alone when there is no source', (tester) async { testWidgets('shows the label alone when there is no source', (
tester,
) async {
await pump(tester, '', 'DG-1042'); await pump(tester, '', 'DG-1042');
expect(find.text('Bag DG-1042'), findsOneWidget); expect(find.text('DG-1042'), findsOneWidget);
expect( expect(
tester.getSize(find.byType(StopSourceLine)).height, tester.getSize(find.byType(StopSourceLine)).height,
greaterThan(0), greaterThan(0),
@@ -206,12 +211,12 @@ void main() {
// Named once, by the place — not again on every order under it. // Named once, by the place — not again on every order under it.
expect(find.text('Kitchen 1'), findsOneWidget); expect(find.text('Kitchen 1'), findsOneWidget);
// The label the kitchen printed, straight from [BagManifest]. The `Bag ` // The label the kitchen printed, straight from [OrderManifest]. The `Bag `
// prefix belonged to the card's chip and went with it, which stopped a // prefix belonged to the card's chip and went with it, which stopped a
// printed `DG-1042` reading as `Bag DG-1042`. // printed `DG-1042` reading as `Bag DG-1042`.
expect(find.text('DG-1042'), findsOneWidget); expect(find.text('DG-1042'), findsOneWidget);
// The count the rider checks the shelf against. // The count the rider checks the shelf against.
expect(find.text('1 bag'), findsOneWidget); expect(find.text('1 order'), findsOneWidget);
}); });
testWidgets('a parcel route keeps a bare pending row', (tester) async { testWidgets('a parcel route keeps a bare pending row', (tester) async {

View File

@@ -9,7 +9,7 @@ import 'package:miler/data/accepted_store.dart';
import 'package:miler/data/service_profile.dart'; import 'package:miler/data/service_profile.dart';
import 'package:miler/Models/stop_status.dart'; import 'package:miler/Models/stop_status.dart';
import 'package:miler/data/bag_manifest.dart'; import 'package:miler/data/order_manifest.dart';
import 'package:miler/views/Dashboard/home/stop_action_sheet.dart'; import 'package:miler/views/Dashboard/home/stop_action_sheet.dart';
import 'package:miler/views/Dashboard/home/trip.dart'; import 'package:miler/views/Dashboard/home/trip.dart';
import 'package:miler/views/Dashboard/home/selection_bar.dart'; import 'package:miler/views/Dashboard/home/selection_bar.dart';
@@ -196,9 +196,9 @@ void main() {
// orders for the bar, and the bar is the only thing that commits. // orders for the bar, and the bar is the only thing that commits.
expect(find.textContaining('Collect '), findsNothing); expect(find.textContaining('Collect '), findsNothing);
// One order, one bag — the two numbers the rider checks against the // One order, one bag — the two numbers the rider checks against the
// shelf, and they are the same number by definition. See [BagManifest]. // shelf, and they are the same number by definition. See [OrderManifest].
expect(find.text('2 bags'), findsOneWidget); expect(find.text('2 orders'), findsOneWidget);
expect(find.text('1 bag'), findsOneWidget); expect(find.text('1 order'), findsOneWidget);
}); });
testWidgets('a heading gathers one rung, never a mixed batch', ( testWidgets('a heading gathers one rung, never a mixed batch', (
@@ -246,7 +246,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// The header still says how much of the counter is not his yet. // The header still says how much of the counter is not his yet.
expect(find.text('2 bags · 1 to accept'), findsOneWidget); expect(find.text('2 orders · 1 to accept'), findsOneWidget);
// And its tick gathers only the orders on one rung. He cannot be handed // And its tick gathers only the orders on one rung. He cannot be handed
// food for an order he has not taken on, so a heading that swept both // food for an order he has not taken on, so a heading that swept both
@@ -295,7 +295,7 @@ void main() {
stage: StopStatus.arrived, stage: StopStatus.arrived,
placeName: 'Kitchen 1', placeName: 'Kitchen 1',
count: stops.length, count: stops.length,
lines: BagManifest.forGroup(stops), lines: OrderManifest.forGroup(stops),
onConfirm: (outcome) async { onConfirm: (outcome) async {
seen = outcome; seen = outcome;
return null; return null;
@@ -308,7 +308,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
if (markMissing.isNotEmpty) { if (markMissing.isNotEmpty) {
await tester.tap(find.text('Report missing bag')); await tester.tap(find.text('Report a missing order'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
for (final name in markMissing) { for (final name in markMissing) {
await tester.tap(find.text(name)); await tester.tap(find.text(name));
@@ -326,11 +326,11 @@ void main() {
// A screen that makes him tick three boxes to record "nothing was wrong" // A screen that makes him tick three boxes to record "nothing was wrong"
// is a screen he learns to dismiss without reading. So the common case is // is a screen he learns to dismiss without reading. So the common case is
// a list he reads, and the shortfall is one tap away. // a list he reads, and the shortfall is one tap away.
expect(find.text('3 bags'), findsOneWidget); expect(find.text('3 orders'), findsOneWidget);
expect(find.text('Report missing bag'), findsOneWidget); expect(find.text('Report a missing order'), findsOneWidget);
expect(find.text('Missing'), findsNothing); expect(find.text('Missing'), findsNothing);
expect( expect(
find.text('Make sure all 3 bags are loaded before continuing.'), find.text('Make sure all 3 orders are loaded before continuing.'),
findsOneWidget, findsOneWidget,
); );
}); });
@@ -340,7 +340,7 @@ void main() {
) async { ) async {
await open(tester, 3, markMissing: ['Customer 2']); await open(tester, 3, markMissing: ['Customer 2']);
expect(find.text('2 bags · 1 missing'), findsOneWidget); expect(find.text('2 orders · 1 missing'), findsOneWidget);
expect(find.text('Missing'), findsOneWidget); expect(find.text('Missing'), findsOneWidget);
expect( expect(
find.text('1 of 3 not supplied — they will leave your route.'), find.text('1 of 3 not supplied — they will leave your route.'),
@@ -391,7 +391,7 @@ void main() {
stage: StopStatus.arrived, stage: StopStatus.arrived,
placeName: 'Kitchen 1', placeName: 'Kitchen 1',
count: stops.length, count: stops.length,
lines: BagManifest.forGroup(stops), lines: OrderManifest.forGroup(stops),
onConfirm: (outcome) async { onConfirm: (outcome) async {
captured = outcome; captured = outcome;
return null; return null;
@@ -406,7 +406,7 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Report missing bag')); await tester.tap(find.text('Report a missing order'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Customer 2')); await tester.tap(find.text('Customer 2'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();

View File

@@ -4,7 +4,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:miler/Models/stop_status.dart'; import 'package:miler/Models/stop_status.dart';
import 'package:miler/data/bag_manifest.dart'; import 'package:miler/data/order_manifest.dart';
import 'package:miler/data/service_profile.dart'; import 'package:miler/data/service_profile.dart';
import 'package:miler/views/Dashboard/home/duty_sheet.dart'; import 'package:miler/views/Dashboard/home/duty_sheet.dart';
import 'package:miler/views/Dashboard/home/stop_action_sheet.dart'; import 'package:miler/views/Dashboard/home/stop_action_sheet.dart';
@@ -123,8 +123,8 @@ void main() {
placeName: 'Vidhya Kitchen', placeName: 'Vidhya Kitchen',
count: 2, count: 2,
lines: const [ lines: const [
BagLine(stop: {}, orderId: 'A', customer: 'Joe', bag: 'Bag 1'), ManifestLine(stop: {}, orderId: 'A', customer: 'Joe', label: 'Bag 1'),
BagLine(stop: {}, orderId: 'B', customer: 'Arun', bag: 'Bag 2'), ManifestLine(stop: {}, orderId: 'B', customer: 'Arun', label: 'Bag 2'),
], ],
onConfirm: (_) async => null, onConfirm: (_) async => null,
), ),

View File

@@ -9,7 +9,7 @@ import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/Models/stop_status.dart'; import 'package:miler/Models/stop_status.dart';
import 'package:miler/data/bag_manifest.dart'; import 'package:miler/data/order_manifest.dart';
import 'package:miler/data/service_profile.dart'; import 'package:miler/data/service_profile.dart';
import 'package:miler/views/Dashboard/home/duty_sheet.dart'; import 'package:miler/views/Dashboard/home/duty_sheet.dart';
import 'package:miler/views/Dashboard/home/pickup_preview_sheet.dart'; import 'package:miler/views/Dashboard/home/pickup_preview_sheet.dart';
@@ -49,14 +49,14 @@ void main() {
const textScales = [1.0, 1.3, 2.0]; const textScales = [1.0, 1.3, 2.0];
const lines = [ const lines = [
BagLine( ManifestLine(
stop: {}, stop: {},
orderId: 'D1', orderId: 'D1',
customer: 'Venkataraman Subramanian', customer: 'Venkataraman Subramanian',
bag: 'Bag 1', label: 'Bag 1',
), ),
BagLine(stop: {}, orderId: 'D2', customer: 'Arun Prakash', bag: 'Bag 2'), ManifestLine(stop: {}, orderId: 'D2', customer: 'Arun Prakash', label: 'Bag 2'),
BagLine(stop: {}, orderId: 'D3', customer: 'Priya Nair', bag: 'Bag 3'), ManifestLine(stop: {}, orderId: 'D3', customer: 'Priya Nair', label: 'Bag 3'),
]; ];
Map<String, dynamic> stop() => { Map<String, dynamic> stop() => {
@@ -181,7 +181,7 @@ void main() {
c, c,
stop: stop(), stop: stop(),
stopNumber: 7, stopNumber: 7,
bag: 'Bag 1', label: 'Bag 1',
riderLat: 11.0168, riderLat: 11.0168,
riderLng: 76.9558, riderLng: 76.9558,
), ),

View File

@@ -14,7 +14,7 @@ import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/Models/stop_status.dart'; import 'package:miler/Models/stop_status.dart';
import 'package:miler/data/bag_manifest.dart'; import 'package:miler/data/order_manifest.dart';
import 'package:miler/data/service_profile.dart'; import 'package:miler/data/service_profile.dart';
import 'package:miler/views/Dashboard/home/duty_sheet.dart'; import 'package:miler/views/Dashboard/home/duty_sheet.dart';
import 'package:miler/views/Dashboard/home/pickup_preview_sheet.dart'; import 'package:miler/views/Dashboard/home/pickup_preview_sheet.dart';
@@ -52,11 +52,11 @@ void main() {
}); });
const lines = [ const lines = [
BagLine(stop: {}, orderId: 'D1', customer: 'Joe Mathew', bag: 'Bag 1'), ManifestLine(stop: {}, orderId: 'D1', customer: 'Joe Mathew', label: 'Bag 1'),
BagLine(stop: {}, orderId: 'D2', customer: 'Arun Prakash', bag: 'Bag 2'), ManifestLine(stop: {}, orderId: 'D2', customer: 'Arun Prakash', label: 'Bag 2'),
BagLine(stop: {}, orderId: 'D3', customer: 'Priya Nair', bag: 'Bag 3'), ManifestLine(stop: {}, orderId: 'D3', customer: 'Priya Nair', label: 'Bag 3'),
BagLine(stop: {}, orderId: 'D4', customer: 'Ravi Kumar', bag: 'Bag 4'), ManifestLine(stop: {}, orderId: 'D4', customer: 'Ravi Kumar', label: 'Bag 4'),
BagLine(stop: {}, orderId: 'D5', customer: 'Kumar S', bag: 'Bag 5'), ManifestLine(stop: {}, orderId: 'D5', customer: 'Kumar S', label: 'Bag 5'),
]; ];
Map<String, dynamic> stop() => { Map<String, dynamic> stop() => {
@@ -253,7 +253,7 @@ void main() {
'notes': 'Gate code 4411, leave with security if unreachable.', 'notes': 'Gate code 4411, leave with security if unreachable.',
}, },
stopNumber: 7, stopNumber: 7,
bag: 'Bag 1', label: 'Bag 1',
riderLat: 11.0168, riderLat: 11.0168,
riderLng: 76.9558, riderLng: 76.9558,
), ),

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 KiB

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 269 KiB

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 KiB

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 320 KiB

After

Width:  |  Height:  |  Size: 347 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 KiB

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 278 KiB

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 316 KiB

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -234,10 +234,10 @@ void main() {
// normalising to `doormile` matched the Logistics list, won on // normalising to `doormile` matched the Logistics list, won on
// name-first, and the id was never consulted. // name-first, and the id was never consulted.
// //
// On Logistics `MyPickups._unreleased` is empty by design, so the // On Logistics a rider has no customer round — he carries what he
// **Start round** bar does not render. With // collects to the hub — so nothing in the app calls `start-delivery` for
// `MILER_COLLECTED_STATE_ENABLED` on, that rider's collected parcels // him. With `MILER_COLLECTED_STATE_ENABLED` on, that rider's collected
// strand at `Collected_By_Miler` with no control to release them. // orders strand at `Collected_By_Miler` with nothing to release them.
for (final name in const ['Doormile', 'doormile', 'DOOR-MILE']) { for (final name in const ['Doormile', 'doormile', 'DOOR-MILE']) {
final p = await resolveWith({'tenantname': name, 'tenantid': 13}); final p = await resolveWith({'tenantname': name, 'tenantid': 13});
expect( expect(

View File

@@ -124,7 +124,7 @@ void main() {
expect(find.text('Kitchen 1'), findsOneWidget); expect(find.text('Kitchen 1'), findsOneWidget);
expect(find.text('Kitchen 2'), findsOneWidget); expect(find.text('Kitchen 2'), findsOneWidget);
// And the bag, which is what he checks against the box in his hand. // And the bag, which is what he checks against the box in his hand.
// The label the kitchen printed, as [BagManifest] hands it over — the // The label the kitchen printed, as [OrderManifest] hands it over — the
// `Bag ` prefix belonged to the card's chip and went with it, which // `Bag ` prefix belonged to the card's chip and went with it, which
// stopped a printed `BAG-1` reading as `Bag BAG-1`. // stopped a printed `BAG-1` reading as `Bag BAG-1`.
expect(find.text('BAG-1'), findsOneWidget); expect(find.text('BAG-1'), findsOneWidget);