device infos
@@ -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
|
||||||
|
|||||||
@@ -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%');
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
import 'package:miler/data/milk_run.dart';
|
|
||||||
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
|
|
||||||
|
|
||||||
/// ─────────────────────────────────────────────────────────────────────────
|
|
||||||
/// ONE PICKUP ORDER = ONE BAG
|
|
||||||
///
|
|
||||||
/// The rule the whole rider workflow rests on, in one file so it cannot be
|
|
||||||
/// stated two different ways on two screens.
|
|
||||||
///
|
|
||||||
/// If a kitchen has five pickup orders, the rider is handed **five bags** — one
|
|
||||||
/// per order — and ends up with **five deliveries**, each carrying the bag it
|
|
||||||
/// arrived in:
|
|
||||||
///
|
|
||||||
/// ```
|
|
||||||
/// Order 1 → Bag 1 → Joe
|
|
||||||
/// Order 2 → Bag 2 → Arun
|
|
||||||
/// Order 3 → Bag 3 → Priya
|
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// ── Why this is a file and not a `+ 1` at each call site ──
|
|
||||||
///
|
|
||||||
/// Every screen in the pickup half of the app has to answer "how many bags?"
|
|
||||||
/// and "which bag is this?", and every screen that answered it independently
|
|
||||||
/// answered it differently: one showed a backend `Quantity` (an order's item
|
|
||||||
/// count, not a bag count), one showed a bare `baglabel` when the payload
|
|
||||||
/// happened to carry one and nothing at all when it did not, and the kitchen
|
|
||||||
/// heading counted "meals". A rider standing at a counter comparing "5 meals"
|
|
||||||
/// on his phone with four bags on the shelf has no way to tell which of the two
|
|
||||||
/// numbers is wrong.
|
|
||||||
///
|
|
||||||
/// So the count is **derived from the orders**, always, and there is no second
|
|
||||||
/// quantity anywhere that can disagree with it.
|
|
||||||
///
|
|
||||||
/// ── What is derived, and what is never invented ──
|
|
||||||
///
|
|
||||||
/// • **The count** is `orders.length`. It cannot drift, because it is not
|
|
||||||
/// stored — a bag is what an order arrives in.
|
|
||||||
/// • **The identity** prefers the label the backend printed on the physical
|
|
||||||
/// bag ([stopBagLabel]) and falls back to the order's **position in its own
|
|
||||||
/// pickup group** — `Bag 1`, `Bag 2` — which is what a rider counting a
|
|
||||||
/// shelf actually uses.
|
|
||||||
///
|
|
||||||
/// Nothing here fabricates a crate, a tote or a quantity the backend has not
|
|
||||||
/// sent. If a future contract ever puts more than one bag on an order, this is
|
|
||||||
/// the one file that changes.
|
|
||||||
/// ─────────────────────────────────────────────────────────────────────────
|
|
||||||
class BagManifest {
|
|
||||||
BagManifest._();
|
|
||||||
|
|
||||||
/// One line of a pickup manifest: the order, the customer it is for, and the
|
|
||||||
/// bag it travels in.
|
|
||||||
///
|
|
||||||
/// Deliberately carries the stop itself: every caller that renders a manifest
|
|
||||||
/// also needs to act on the orders behind it, and pairing them here is what
|
|
||||||
/// stops a screen from rendering five lines and posting four ids.
|
|
||||||
static List<BagLine> forGroup(List<Map<String, dynamic>> stops) => [
|
|
||||||
for (var i = 0; i < stops.length; i++)
|
|
||||||
BagLine(
|
|
||||||
stop: stops[i],
|
|
||||||
orderId: MilkRun.idOf(stops[i]),
|
|
||||||
customer: customerOf(stops[i]),
|
|
||||||
bag: _bagFor(stops[i], i),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
/// The bag one order travels in, given the group it was collected with.
|
|
||||||
///
|
|
||||||
/// [stops] must be the whole pickup group in route order — the position in it
|
|
||||||
/// *is* the bag number when the backend has not printed one.
|
|
||||||
static String bagFor(
|
|
||||||
Map<String, dynamic> stop,
|
|
||||||
List<Map<String, dynamic>> group,
|
|
||||||
) {
|
|
||||||
final id = MilkRun.idOf(stop);
|
|
||||||
final index = group.indexWhere((s) => MilkRun.idOf(s) == id);
|
|
||||||
return _bagFor(stop, index < 0 ? 0 : index);
|
|
||||||
}
|
|
||||||
|
|
||||||
static String _bagFor(Map<String, dynamic> stop, int index) {
|
|
||||||
final printed = stopBagLabel(stop);
|
|
||||||
return printed.isNotEmpty ? printed : 'Bag ${index + 1}';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `5 bags` — the load, in the unit the rider actually carries.
|
|
||||||
///
|
|
||||||
/// It used to print `5 orders · 5 bags`: the two halves of the one-bag-per-
|
|
||||||
/// order rule side by side, as a check the rider could eyeball. On a device
|
|
||||||
/// that check cost the header the fact it exists for — a real kitchen name
|
|
||||||
/// plus `13 orders · 13 bags` pushed `11 to accept` off the row, and the
|
|
||||||
/// clause that truncated was the one that changes what he does next.
|
|
||||||
///
|
|
||||||
/// One number survives, and it is the physical one: the rows underneath are
|
|
||||||
/// named `Bag 1 … Bag n` and a settled group says `n bags collected`, so
|
|
||||||
/// "bags" is the word this column already speaks. No information is lost —
|
|
||||||
/// the rule makes the counts identical — and the verifiable statement lives
|
|
||||||
/// where the verifying happens: the manifest list itself, one line per bag.
|
|
||||||
static String countLabel(int orders) {
|
|
||||||
return orders == 1 ? '1 bag' : '$orders bags';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The name the bag is going to, for a manifest line.
|
|
||||||
static String customerOf(Map<String, dynamic> stop) =>
|
|
||||||
(stop['pickupcustomer'] ??
|
|
||||||
stop['customername'] ??
|
|
||||||
stop['tenantname'] ??
|
|
||||||
'')
|
|
||||||
.toString()
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One row of a pickup manifest. See [BagManifest.forGroup].
|
|
||||||
class BagLine {
|
|
||||||
final Map<String, dynamic> stop;
|
|
||||||
final String orderId;
|
|
||||||
final String customer;
|
|
||||||
final String bag;
|
|
||||||
|
|
||||||
const BagLine({
|
|
||||||
required this.stop,
|
|
||||||
required this.orderId,
|
|
||||||
required this.customer,
|
|
||||||
required this.bag,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -176,8 +176,100 @@ extension ConsignmentStateX on ConsignmentState {
|
|||||||
/// widened it on 21 Aug 2026 so a failed attempt is reportable the moment
|
/// 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 {
|
||||||
|
|||||||
133
lib/data/device_telemetry.dart
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import 'package:battery_plus/battery_plus.dart';
|
||||||
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
/// WHAT THE CONSOLE NEEDS TO SEE ABOUT THE HANDSET
|
||||||
|
///
|
||||||
|
/// The dispatcher watching a rider is not only asking *where is he*. When a
|
||||||
|
/// rider stops reporting, the question is **why** — and the answers are all
|
||||||
|
/// facts about the phone: the battery died, the signal dropped, he turned
|
||||||
|
/// location off, the app went to the background and the OS stopped waking it.
|
||||||
|
/// `POST /miler/logs` has fields for every one of them:
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// battery is_charging connection location_service accuracy is_background
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// ── Why the console was showing an em dash for all of it ──
|
||||||
|
///
|
||||||
|
/// Not because nothing was measured. Every one of these was already being read
|
||||||
|
/// on the heartbeat — and published to **MQTT only**, in a block that ran
|
||||||
|
/// *after* the log had been posted. So the telemetry existed, reached a
|
||||||
|
/// different transport, and the rider-log row the console actually reads
|
||||||
|
/// carried nothing but a position.
|
||||||
|
///
|
||||||
|
/// It was two lanes for one fact, and the lane the console reads was the empty
|
||||||
|
/// one. This is the reader both now share: one place that knows how to ask the
|
||||||
|
/// handset, so the MQTT publish and the log post cannot report different
|
||||||
|
/// batteries a second apart.
|
||||||
|
///
|
||||||
|
/// ── Never throws, never blocks ──
|
||||||
|
///
|
||||||
|
/// Telemetry is the least important thing the app does. A permissions prompt, a
|
||||||
|
/// platform channel that hangs, a plugin missing on a desktop test host — none
|
||||||
|
/// of them may cost the rider his heartbeat, so every read is guarded
|
||||||
|
/// individually and an unavailable one simply does not appear in the map.
|
||||||
|
/// A field that is absent renders as the em dash it already rendered as; a
|
||||||
|
/// field that is *wrong* would be read as fact by somebody making a decision.
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
class DeviceTelemetry {
|
||||||
|
/// Battery percentage, or null when the platform will not say.
|
||||||
|
final int? battery;
|
||||||
|
|
||||||
|
/// True while on the charger — a rider at 12% and charging is not the same
|
||||||
|
/// problem as a rider at 12% and riding.
|
||||||
|
final bool? isCharging;
|
||||||
|
|
||||||
|
/// `wifi`, `mobile`, `none` … as the platform names it.
|
||||||
|
final String? connection;
|
||||||
|
|
||||||
|
/// `enabled`, `disabled`, `denied`, `denied_forever`, `unknown`.
|
||||||
|
///
|
||||||
|
/// The one field here that is about a *choice the rider made*, which is why
|
||||||
|
/// it is worth a column of its own on the console: a disabled location
|
||||||
|
/// service explains a silent rider completely, and no amount of staring at
|
||||||
|
/// his last position does.
|
||||||
|
final String? locationService;
|
||||||
|
|
||||||
|
const DeviceTelemetry({
|
||||||
|
this.battery,
|
||||||
|
this.isCharging,
|
||||||
|
this.connection,
|
||||||
|
this.locationService,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Reads what the handset will say about itself, right now.
|
||||||
|
///
|
||||||
|
/// [locationService] can be supplied by a caller that has just resolved a
|
||||||
|
/// fix and already knows — the position lookup has to ask the same question
|
||||||
|
/// to get a fix at all, and asking twice can disagree with itself when the
|
||||||
|
/// rider toggles the setting between the two calls.
|
||||||
|
static Future<DeviceTelemetry> read({String? locationService}) async {
|
||||||
|
int? level;
|
||||||
|
bool? charging;
|
||||||
|
try {
|
||||||
|
final battery = Battery();
|
||||||
|
level = await battery.batteryLevel;
|
||||||
|
final state = await battery.batteryState;
|
||||||
|
charging =
|
||||||
|
state == BatteryState.charging || state == BatteryState.full;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[TELEMETRY] battery unavailable: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
String? connection;
|
||||||
|
try {
|
||||||
|
final result = await Connectivity().checkConnectivity();
|
||||||
|
connection = result.isNotEmpty
|
||||||
|
? result.first.toString().split('.').last
|
||||||
|
: 'none';
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[TELEMETRY] connectivity unavailable: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
var service = locationService;
|
||||||
|
if (service == null || service.isEmpty || service == 'unknown') {
|
||||||
|
try {
|
||||||
|
service = await Geolocator.isLocationServiceEnabled()
|
||||||
|
? 'enabled'
|
||||||
|
: 'disabled';
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[TELEMETRY] location service unavailable: $e');
|
||||||
|
service = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return DeviceTelemetry(
|
||||||
|
battery: level,
|
||||||
|
isCharging: charging,
|
||||||
|
connection: connection,
|
||||||
|
locationService: service,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The heartbeat payload's own field names, ready to spread into it.
|
||||||
|
///
|
||||||
|
/// Only what was actually read: an absent key is a field the console draws as
|
||||||
|
/// unknown, which is the truth. A `0` battery or a `none` connection standing
|
||||||
|
/// in for "could not ask" is a fact somebody would act on.
|
||||||
|
Map<String, dynamic> toPayload() => <String, dynamic>{
|
||||||
|
if (battery != null) 'battery': '$battery',
|
||||||
|
if (isCharging != null) 'is_charging': isCharging,
|
||||||
|
if (connection != null && connection!.isNotEmpty) 'connection': connection,
|
||||||
|
if (locationService != null && locationService!.isNotEmpty)
|
||||||
|
'location_service': locationService,
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'battery=$battery charging=$isCharging '
|
||||||
|
'connection=$connection location=$locationService';
|
||||||
|
}
|
||||||
@@ -149,7 +149,7 @@ class MealRunMock {
|
|||||||
lat: 11.0271,
|
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,
|
||||||
|
|||||||
@@ -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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
114
lib/data/order_manifest.dart
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import 'package:miler/data/milk_run.dart';
|
||||||
|
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
|
||||||
|
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
/// WHAT THE RIDER COLLECTS AT A COUNTER, AS A LIST HE CAN COUNT
|
||||||
|
///
|
||||||
|
/// One pickup order is one handover. If a kitchen has five pickup orders the
|
||||||
|
/// rider is handed five things and ends up with five deliveries, each carrying
|
||||||
|
/// what it arrived in. That rule lives here so it cannot be stated two
|
||||||
|
/// different ways on two screens.
|
||||||
|
///
|
||||||
|
/// ── Why this is a file and not a `+ 1` at each call site ──
|
||||||
|
///
|
||||||
|
/// Every screen in the pickup half of the app has to answer "how many?" and
|
||||||
|
/// "which one is this?", and every screen that answered independently answered
|
||||||
|
/// differently: one showed a backend `Quantity` (an order's item count, not a
|
||||||
|
/// count of handovers), one showed a bare printed label when the payload
|
||||||
|
/// happened to carry one and nothing at all when it did not, and the kitchen
|
||||||
|
/// heading counted "meals". A rider standing at a counter comparing "5 meals"
|
||||||
|
/// on his phone with four items on the shelf has no way to tell which of the
|
||||||
|
/// two numbers is wrong.
|
||||||
|
///
|
||||||
|
/// So the count is **derived from the orders**, always, and there is no second
|
||||||
|
/// quantity anywhere that can disagree with it.
|
||||||
|
///
|
||||||
|
/// ── The invented numbering is gone ──
|
||||||
|
///
|
||||||
|
/// This used to name every line `Bag 1 … Bag n`, falling back to the order's
|
||||||
|
/// position in its group whenever the payload printed no label of its own. Two
|
||||||
|
/// things were wrong with that, and only the second is about the word.
|
||||||
|
///
|
||||||
|
/// The number was **the app's own invention presented as a physical fact**. It
|
||||||
|
/// counted positions in a list the app had built, and it was drawn as a tag in
|
||||||
|
/// the corner of every row where it read like something stencilled on a
|
||||||
|
/// container. A rider matching a shelf against it was matching against the
|
||||||
|
/// app's arithmetic, not against anything the kitchen had written; and the rail
|
||||||
|
/// beside it already numbers the same rows, so the tag mostly restated the node
|
||||||
|
/// two columns to its left in louder type.
|
||||||
|
///
|
||||||
|
/// A label the counter **actually printed** is a different thing entirely — it
|
||||||
|
/// is a fact about an object in front of him — so it survives, verbatim, under
|
||||||
|
/// whatever name the kitchen gave it. What is never done again is manufacturing
|
||||||
|
/// one where none exists.
|
||||||
|
///
|
||||||
|
/// Nothing here fabricates a crate, a tote or a quantity the backend has not
|
||||||
|
/// sent. If a future contract ever puts more than one handover on an order,
|
||||||
|
/// this is the one file that changes.
|
||||||
|
/// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
class OrderManifest {
|
||||||
|
OrderManifest._();
|
||||||
|
|
||||||
|
/// One line per order: the stop, who it is for, and the label the counter
|
||||||
|
/// printed on it — empty when it printed none.
|
||||||
|
///
|
||||||
|
/// Deliberately carries the stop itself: every caller that renders a manifest
|
||||||
|
/// also needs to act on the orders behind it, and pairing them here is what
|
||||||
|
/// stops a screen from rendering five lines and posting four ids.
|
||||||
|
static List<ManifestLine> forGroup(List<Map<String, dynamic>> stops) => [
|
||||||
|
for (var i = 0; i < stops.length; i++)
|
||||||
|
ManifestLine(
|
||||||
|
stop: stops[i],
|
||||||
|
orderId: MilkRun.idOf(stops[i]),
|
||||||
|
customer: customerOf(stops[i]),
|
||||||
|
label: labelFor(stops[i]),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The label physically printed on one order's handover, or `''`.
|
||||||
|
///
|
||||||
|
/// Read straight off the payload and never derived. A counter that prints
|
||||||
|
/// nothing leaves this empty, and the row simply carries no tag — which is
|
||||||
|
/// honest, where a manufactured `Bag 4` was a shelf reference the shelf had
|
||||||
|
/// never heard of.
|
||||||
|
static String labelFor(Map<String, dynamic> stop) => stopPrintedLabel(stop);
|
||||||
|
|
||||||
|
/// `5 orders` — the load, in the unit everything else on the screen counts.
|
||||||
|
///
|
||||||
|
/// It printed `5 orders · 5 bags` once: the two halves of one rule side by
|
||||||
|
/// side, as a check the rider could eyeball. That cost the header the fact it
|
||||||
|
/// exists for — a real kitchen name plus `13 orders · 13 bags` pushed
|
||||||
|
/// `11 to accept` off the row — so it was cut to the physical half, `13 bags`.
|
||||||
|
///
|
||||||
|
/// Now that the app no longer names anything a bag, the surviving half is the
|
||||||
|
/// one the rest of the screen already speaks: the rows underneath are orders,
|
||||||
|
/// the chip counts orders, the hub assigns orders. One word, everywhere.
|
||||||
|
static String countLabel(int orders) =>
|
||||||
|
orders == 1 ? '1 order' : '$orders orders';
|
||||||
|
|
||||||
|
/// The name the order is going to, for a manifest line.
|
||||||
|
static String customerOf(Map<String, dynamic> stop) =>
|
||||||
|
(stop['pickupcustomer'] ??
|
||||||
|
stop['customername'] ??
|
||||||
|
stop['tenantname'] ??
|
||||||
|
'')
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row of a pickup manifest. See [OrderManifest.forGroup].
|
||||||
|
class ManifestLine {
|
||||||
|
final Map<String, dynamic> stop;
|
||||||
|
final String orderId;
|
||||||
|
final String customer;
|
||||||
|
|
||||||
|
/// What the counter printed on this order, or `''` when it printed nothing.
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
const ManifestLine({
|
||||||
|
required this.stop,
|
||||||
|
required this.orderId,
|
||||||
|
required this.customer,
|
||||||
|
this.label = '',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -89,7 +89,22 @@ abstract final class ServiceDay {
|
|||||||
'modifiedon',
|
'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.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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', () {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 =>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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'] ??
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 ──
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
100
test/device_telemetry_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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', () {
|
||||||
|
|||||||
305
test/home_today_only_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
248
test/one_counter_one_dropdown_test.dart
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
133
test/rail_counts_the_route_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 272 KiB After Width: | Height: | Size: 302 KiB |
|
Before Width: | Height: | Size: 281 KiB After Width: | Height: | Size: 305 KiB |
|
Before Width: | Height: | Size: 269 KiB After Width: | Height: | Size: 299 KiB |
|
Before Width: | Height: | Size: 247 KiB After Width: | Height: | Size: 272 KiB |
|
Before Width: | Height: | Size: 216 KiB After Width: | Height: | Size: 225 KiB |
|
Before Width: | Height: | Size: 206 KiB After Width: | Height: | Size: 223 KiB |
|
Before Width: | Height: | Size: 240 KiB After Width: | Height: | Size: 249 KiB |
|
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 251 KiB After Width: | Height: | Size: 252 KiB |
|
Before Width: | Height: | Size: 270 KiB After Width: | Height: | Size: 302 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 320 KiB After Width: | Height: | Size: 347 KiB |
|
Before Width: | Height: | Size: 286 KiB After Width: | Height: | Size: 293 KiB |
|
Before Width: | Height: | Size: 278 KiB After Width: | Height: | Size: 304 KiB |
|
Before Width: | Height: | Size: 316 KiB After Width: | Height: | Size: 370 KiB |
|
Before Width: | Height: | Size: 270 KiB After Width: | Height: | Size: 302 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||