device infos
This commit is contained in:
@@ -36,23 +36,23 @@ class PickupCard extends StatelessWidget {
|
||||
final bool enabled;
|
||||
final bool isSkipped;
|
||||
|
||||
/// The bag this order travels in, as recorded at the counter. Empty on a
|
||||
/// parcel route, where there is no load to match against. See [BagManifest].
|
||||
final String bag;
|
||||
/// 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 [OrderManifest].
|
||||
final String label;
|
||||
|
||||
/// Orders the rider is carrying.
|
||||
///
|
||||
/// Needed because "picked up" is not a *status* on this screen — the accepted
|
||||
/// store normalises these rows to `accepted`, and what actually records the
|
||||
/// 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.
|
||||
final Set<String> collectedIds;
|
||||
|
||||
/// Orders whose delivery leg the rider has actually started.
|
||||
///
|
||||
/// 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`
|
||||
/// stamps a hyperlocal consignment `Out_for_Delivery` at the counter — so the
|
||||
/// rung is resolved through [MilkRun.stageOf] against this set rather than
|
||||
@@ -67,7 +67,7 @@ class PickupCard extends StatelessWidget {
|
||||
this.riderSpeedMps,
|
||||
this.enabled = true,
|
||||
this.isSkipped = false,
|
||||
this.bag = '',
|
||||
this.label = '',
|
||||
this.collectedIds = 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
|
||||
// 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].
|
||||
final StopKind kind = MilkRun.workingKind(item, collectedIds: collectedIds);
|
||||
|
||||
@@ -201,7 +201,7 @@ class PickupCard extends StatelessWidget {
|
||||
// ── Identity left, state and journey right ──
|
||||
//
|
||||
// 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
|
||||
// already carry the journey as a narrow right-hand column at
|
||||
// the headline's shoulder; the live card was the one surface
|
||||
@@ -272,7 +272,7 @@ class PickupCard extends StatelessWidget {
|
||||
|
||||
// ── 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
|
||||
// entire operational risk on a meal run, and making
|
||||
// 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 written form earns its place further down the
|
||||
// queue — see [_LaterRow] — and on the manifest.)
|
||||
if (bag.isNotEmpty) ...[
|
||||
if (label.isNotEmpty) ...[
|
||||
SizedBox(height: 14.h),
|
||||
_BagPair(
|
||||
bag: bag,
|
||||
_LabelPair(
|
||||
label: label,
|
||||
customer: '',
|
||||
muted: !enabled,
|
||||
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
|
||||
// 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
|
||||
// above.
|
||||
if (dropLeads && pickupName.isNotEmpty) ...[
|
||||
@@ -534,7 +534,7 @@ class PickupCard extends StatelessWidget {
|
||||
// because this method opens the map screen directly rather than through
|
||||
// `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
|
||||
// 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].
|
||||
if (!MilkRun.worksOnOwnScreen(item, collectedIds: collectedIds)) {
|
||||
AppFeedback.info(
|
||||
@@ -919,7 +919,7 @@ class _SquareIconButton extends StatelessWidget {
|
||||
/// ```
|
||||
/// NOW one task card, full weight, one big 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
|
||||
/// ```
|
||||
///
|
||||
@@ -963,8 +963,8 @@ class _QueueRow {
|
||||
final QueueDensity density;
|
||||
final int displayStep;
|
||||
|
||||
/// The bag this order travels in, as recorded at the counter.
|
||||
final String bag;
|
||||
/// The label this order travels in, as recorded at the counter.
|
||||
final String label;
|
||||
|
||||
/// 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
|
||||
@@ -975,14 +975,14 @@ class _QueueRow {
|
||||
: stop = null,
|
||||
density = QueueDensity.later,
|
||||
displayStep = 0,
|
||||
bag = '',
|
||||
label = '',
|
||||
reason = '';
|
||||
|
||||
const _QueueRow.stop({
|
||||
required this.stop,
|
||||
required this.density,
|
||||
required this.displayStep,
|
||||
required this.bag,
|
||||
required this.label,
|
||||
this.reason = '',
|
||||
}) : heading = null,
|
||||
count = 0;
|
||||
@@ -1079,33 +1079,38 @@ class _QueueHeading extends StatelessWidget {
|
||||
///
|
||||
/// ── 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,
|
||||
/// 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.
|
||||
///
|
||||
/// So they are written the way he thinks them.
|
||||
class _BagPair extends StatelessWidget {
|
||||
final String bag;
|
||||
class _LabelPair extends StatelessWidget {
|
||||
final String label;
|
||||
final String customer;
|
||||
final bool muted;
|
||||
|
||||
/// Bigger on the live card, quieter down the queue.
|
||||
final double size;
|
||||
|
||||
const _BagPair({
|
||||
required this.bag,
|
||||
const _LabelPair({
|
||||
required this.label,
|
||||
required this.customer,
|
||||
this.muted = false,
|
||||
this.size = 15,
|
||||
});
|
||||
|
||||
String get _bagText =>
|
||||
bag.toLowerCase().startsWith('bag ') ? bag : 'Bag $bag';
|
||||
/// ── The word is gone; the code is not ──
|
||||
///
|
||||
/// 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
|
||||
Widget build(BuildContext context) {
|
||||
if (bag.isEmpty) return const SizedBox.shrink();
|
||||
if (label.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -1119,7 +1124,7 @@ class _BagPair extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
),
|
||||
child: Text(
|
||||
_bagText,
|
||||
_labelText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
@@ -1223,14 +1228,14 @@ class _TaskChip extends StatelessWidget {
|
||||
/// notes, no money. Preparing is a glance; acting is the card above.
|
||||
class _NextCard extends StatelessWidget {
|
||||
final Map<String, dynamic> item;
|
||||
final String bag;
|
||||
final String label;
|
||||
final double? distanceMeters;
|
||||
final double? riderSpeedMps;
|
||||
final VoidCallback onOpen;
|
||||
|
||||
const _NextCard({
|
||||
required this.item,
|
||||
required this.bag,
|
||||
required this.label,
|
||||
required this.distanceMeters,
|
||||
required this.riderSpeedMps,
|
||||
required this.onOpen,
|
||||
@@ -1293,9 +1298,9 @@ class _NextCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
],
|
||||
if (bag.isNotEmpty) ...[
|
||||
if (label.isNotEmpty) ...[
|
||||
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.
|
||||
class _LaterRow extends StatelessWidget {
|
||||
final Map<String, dynamic> item;
|
||||
final String bag;
|
||||
final String label;
|
||||
final double? distanceMeters;
|
||||
final VoidCallback onOpen;
|
||||
|
||||
const _LaterRow({
|
||||
required this.item,
|
||||
required this.bag,
|
||||
required this.label,
|
||||
required this.distanceMeters,
|
||||
required this.onOpen,
|
||||
});
|
||||
@@ -1333,8 +1338,8 @@ class _LaterRow extends StatelessWidget {
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h),
|
||||
child: Row(
|
||||
children: [
|
||||
if (bag.isNotEmpty) ...[
|
||||
_BagPair(bag: bag, customer: '', muted: true, size: 13),
|
||||
if (label.isNotEmpty) ...[
|
||||
_LabelPair(label: label, customer: '', muted: true, size: 13),
|
||||
SizedBox(width: 12.w),
|
||||
],
|
||||
Expanded(
|
||||
@@ -1375,14 +1380,14 @@ class _LaterRow extends StatelessWidget {
|
||||
/// and nothing else.
|
||||
class _SkippedRow extends StatelessWidget {
|
||||
final Map<String, dynamic> item;
|
||||
final String bag;
|
||||
final String label;
|
||||
final String reason;
|
||||
final VoidCallback onResume;
|
||||
final VoidCallback onOpen;
|
||||
|
||||
const _SkippedRow({
|
||||
required this.item,
|
||||
required this.bag,
|
||||
required this.label,
|
||||
required this.reason,
|
||||
required this.onResume,
|
||||
required this.onOpen,
|
||||
@@ -1441,8 +1446,8 @@ class _SkippedRow extends StatelessWidget {
|
||||
SizedBox(height: 12.h),
|
||||
Row(
|
||||
children: [
|
||||
if (bag.isNotEmpty) ...[
|
||||
_BagPair(bag: bag, customer: '', muted: true, size: 13.5),
|
||||
if (label.isNotEmpty) ...[
|
||||
_LabelPair(label: label, customer: '', muted: true, size: 13.5),
|
||||
SizedBox(width: 12.w),
|
||||
],
|
||||
Expanded(
|
||||
|
||||
@@ -116,7 +116,7 @@ extension _MethodUi on _Method {
|
||||
String get label => this == _Method.cash ? 'Cash' : 'UPI / QR';
|
||||
|
||||
String get blurb => this == _Method.cash
|
||||
? 'Notes and coins, into your bag'
|
||||
? 'Notes and coins, kept on you'
|
||||
: 'Customer scans and pays';
|
||||
|
||||
IconData get icon =>
|
||||
|
||||
@@ -227,7 +227,11 @@ Future<String> resolveConsignmentId(Map<String, dynamic> stop) async {
|
||||
/// rider can retry this until his battery dies.
|
||||
/// • **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.
|
||||
/// • **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.
|
||||
///
|
||||
/// 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
|
||||
// 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
|
||||
// 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
|
||||
// stop. See [_handoffHoldIsPossible].
|
||||
final heldElsewhere = state.awaitsHub && _handoffHoldIsPossible;
|
||||
if (state.awaitsHub && !_handoffHoldIsPossible) {
|
||||
debugPrint(
|
||||
'[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 != ConsignmentState.unknown) {
|
||||
debugPrint('[MILKRUN] $consignmentId is ${state.name} — not releasable');
|
||||
lastReleaseFailure = heldElsewhere
|
||||
// The honest answer for a cross-city parcel: it is not his round, and
|
||||
// saying so without naming a building he has never been to.
|
||||
? "This one has already been handed on — it's not yours to deliver."
|
||||
: 'This parcel cannot be started yet. Ask your office to check it.';
|
||||
// One sentence per state, written once — see
|
||||
// [ConsignmentStateX.startRefusalSentence]. This branch and the refusal
|
||||
// below are the same answer arrived at from two directions (a state the
|
||||
// app read, and a state the server named), and they used to word 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;
|
||||
}
|
||||
|
||||
@@ -361,16 +370,63 @@ Future<bool> releaseForDelivery(Map<String, dynamic> stop) async {
|
||||
final after = await ConsignmentGate.stateOf(consignmentId);
|
||||
if (after.isDeliverable || after.isDelivered) return true;
|
||||
|
||||
// `status: 0` is the request never leaving; anything else is the server
|
||||
// answering, and its own words beat a guess about the network.
|
||||
// `status: 0` is the request never leaving, and it is the one failure that
|
||||
// is really about the connection.
|
||||
lastReleaseFailure = res.status == 0
|
||||
? 'Could not reach your office. Check your connection and slide again.'
|
||||
: (res.message.trim().isNotEmpty
|
||||
? res.message.trim()
|
||||
: 'Your office would not start this delivery.');
|
||||
: _refusalSentence(res, after);
|
||||
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.
|
||||
///
|
||||
/// 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/stop_area.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_repository.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>
|
||||
with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
|
||||
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.
|
||||
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
|
||||
//
|
||||
@@ -467,20 +549,59 @@ class _MyPickupsState extends State<MyPickups>
|
||||
/// 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
|
||||
/// 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 {
|
||||
final open = _visibleStops;
|
||||
if (_completedToday.isEmpty) return open;
|
||||
|
||||
final openKeys = open.map(_getOrderKey).toSet();
|
||||
final done = _completedToday
|
||||
.where((o) => !openKeys.contains(_getOrderKey(o)))
|
||||
.toList()
|
||||
.reversed // the store hands them back newest first
|
||||
.toList();
|
||||
final trip = _currentTripNumber;
|
||||
|
||||
final done = <Map<String, dynamic>>[
|
||||
// The store hands them back newest first; the rail reads in route order.
|
||||
for (final o in _completedToday.reversed)
|
||||
if (!openKeys.contains(_getOrderKey(o)) && wasRiddenTo(o))
|
||||
if (trip == null || (_tripNumberOf(o) ?? trip) == trip) o,
|
||||
];
|
||||
|
||||
return [...done, ...open];
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// 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.
|
||||
///
|
||||
@@ -545,9 +666,28 @@ class _MyPickupsState extends State<MyPickups>
|
||||
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>{
|
||||
for (var i = 0; i < trips.length; i++)
|
||||
for (final id in trips[i].orderIds) id: i + 1,
|
||||
for (var i = 0; i < slots.length; i++)
|
||||
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
|
||||
/// 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 {
|
||||
final labels = await getBagLabels();
|
||||
Future<void> _loadOrderLabels() async {
|
||||
final labels = await getOrderLabels();
|
||||
if (!mounted) return;
|
||||
setState(() => _bagLabels = labels);
|
||||
setState(() => _orderLabels = labels);
|
||||
}
|
||||
|
||||
/// ── The queue, in the order it will be worked ──
|
||||
@@ -722,7 +862,7 @@ class _MyPickupsState extends State<MyPickups>
|
||||
_visibleStops,
|
||||
_visibleStops.indexOf(stop),
|
||||
),
|
||||
bag: _bagLabels[(stop['orderid'] ?? '').toString()] ?? '',
|
||||
label: _orderLabels[(stop['orderid'] ?? '').toString()] ?? '',
|
||||
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
|
||||
// shared name and the window is the detail: "Trip 2 · 10:00 AM – 1:00 PM".
|
||||
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
|
||||
? (window ?? 'Your route')
|
||||
: window == null
|
||||
@@ -1023,12 +1167,14 @@ class _MyPickupsState extends State<MyPickups>
|
||||
BottomPage.currentTab.addListener(_onTabChanged);
|
||||
|
||||
// Run these in parallel
|
||||
_armRollover();
|
||||
|
||||
_restoreSkips();
|
||||
_restoreClosedToday();
|
||||
_restoreActivePickup();
|
||||
_initializeLocation(); // Don't block
|
||||
_fetchPicked();
|
||||
_loadBagLabels();
|
||||
_loadOrderLabels();
|
||||
_startPollingStream();
|
||||
}
|
||||
|
||||
@@ -1178,6 +1324,11 @@ class _MyPickupsState extends State<MyPickups>
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState 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;
|
||||
if (foreground == _pollForeground) return;
|
||||
_pollForeground = foreground;
|
||||
@@ -1280,6 +1431,7 @@ class _MyPickupsState extends State<MyPickups>
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
BottomPage.currentTab.removeListener(_onTabChanged);
|
||||
_pollerSubscription?.cancel();
|
||||
_rollover?.cancel();
|
||||
for (final timer in _pickupTimers.values) {
|
||||
timer.cancel();
|
||||
}
|
||||
@@ -1376,114 +1528,8 @@ class _MyPickupsState extends State<MyPickups>
|
||||
];
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// START DELIVERY — the load leaves the counter
|
||||
//
|
||||
// ── 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.
|
||||
/// True while a round is being marked started, so [startRound] cannot run
|
||||
/// twice over the same stop.
|
||||
bool _starting = false;
|
||||
|
||||
/// 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
|
||||
/// whose consignment could not be reached or was refused keeps its PICKED
|
||||
/// word and stays on the bar, because the alternative — showing ACTIVE for
|
||||
/// a consignment the hub still calls collected — is the fiction that made
|
||||
/// `deliver` fail at the door with nothing on screen to explain why.
|
||||
/// word, because the alternative — showing ACTIVE for a consignment the hub
|
||||
/// still calls collected — is the fiction that made `deliver` fail at the
|
||||
/// 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 {
|
||||
if (_starting || stops.isEmpty) return 0;
|
||||
setState(() => _starting = true);
|
||||
try {
|
||||
final released = <String>[];
|
||||
var unreachable = 0;
|
||||
|
||||
for (final stop in stops) {
|
||||
final orderId = MilkRun.idOf(stop);
|
||||
if (orderId.isEmpty) continue;
|
||||
if (await releaseForDelivery(stop)) {
|
||||
released.add(orderId);
|
||||
} else {
|
||||
unreachable++;
|
||||
}
|
||||
if (await releaseForDelivery(stop)) released.add(orderId);
|
||||
}
|
||||
_unreleasedFailures = unreachable;
|
||||
if (released.isEmpty) return 0;
|
||||
|
||||
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.
|
||||
///
|
||||
/// Reads the same two conditions the slot itself reads, so the floor and the
|
||||
/// surface can never disagree about which one is on screen.
|
||||
/// Reads the same condition the slot itself reads, so the floor and the
|
||||
/// surface can never disagree about what is on screen.
|
||||
double _bottomReservation(BuildContext context) => bottomReservationFor(
|
||||
inset: BottomPage.bottomInset(context),
|
||||
live: _liveOnThisScreen.isNotEmpty,
|
||||
unreleased: _unreleased.isNotEmpty,
|
||||
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
|
||||
/// 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
|
||||
@@ -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
|
||||
// happened on Home a moment ago must show its bags here without the rider
|
||||
// pulling to refresh.
|
||||
unawaited(_loadBagLabels());
|
||||
unawaited(_loadOrderLabels());
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
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}');
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
// ── 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 ──
|
||||
//
|
||||
// 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
|
||||
// counter — same store as the
|
||||
// card, so the two agree.
|
||||
bag: row.bag,
|
||||
label: row.label,
|
||||
// The carried set — what turns
|
||||
// a raw pickup row into the
|
||||
// delivery leg the sheet must
|
||||
@@ -3236,14 +3281,14 @@ class _MyPickupsState extends State<MyPickups>
|
||||
distanceMeters,
|
||||
riderSpeedMps:
|
||||
_currentLocation?.speed,
|
||||
bag: row.bag,
|
||||
label: row.label,
|
||||
collectedIds: _collectedIds,
|
||||
outForDeliveryIds:
|
||||
_outForDeliveryIds,
|
||||
),
|
||||
QueueDensity.next => _NextCard(
|
||||
item: item,
|
||||
bag: row.bag,
|
||||
label: row.label,
|
||||
distanceMeters:
|
||||
distanceMeters,
|
||||
riderSpeedMps:
|
||||
@@ -3252,7 +3297,7 @@ class _MyPickupsState extends State<MyPickups>
|
||||
),
|
||||
QueueDensity.later => _LaterRow(
|
||||
item: item,
|
||||
bag: row.bag,
|
||||
label: row.label,
|
||||
distanceMeters:
|
||||
distanceMeters,
|
||||
onOpen: open,
|
||||
@@ -3260,7 +3305,7 @@ class _MyPickupsState extends State<MyPickups>
|
||||
QueueDensity.skipped =>
|
||||
_SkippedRow(
|
||||
item: item,
|
||||
bag: row.bag,
|
||||
label: row.label,
|
||||
reason: row.reason,
|
||||
onOpen: open,
|
||||
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')),
|
||||
),
|
||||
),
|
||||
@@ -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.
|
||||
///
|
||||
/// ── Why this is a function and not a number ──
|
||||
///
|
||||
/// It was two branches and a magic constant: the unreleased case was built
|
||||
/// from its bar's own pieces, and the live case was a flat `80.h` that forgot
|
||||
/// the device inset entirely — so on a gesture-navigation phone the last row of
|
||||
/// the run sat behind the banner *and* behind the navigation, with its controls
|
||||
/// unreachable and no way to scroll to them.
|
||||
/// It was two branches and a magic constant, and the live case was a flat
|
||||
/// `80.h` that forgot the device inset entirely — so on a gesture-navigation
|
||||
/// phone the last row of the run sat behind the banner *and* behind the
|
||||
/// navigation, with its controls unreachable and no way to scroll to them.
|
||||
///
|
||||
/// One rule, reading the same two conditions the slot itself reads, so the
|
||||
/// floor and the surface can never disagree about which one is on screen. Each
|
||||
/// surface states its own height, because the thing that overlays a list is the
|
||||
/// only thing that knows how much of the list it covers.
|
||||
/// One rule, reading the same condition the slot itself reads, so the floor and
|
||||
/// the surface can never disagree about what is on screen. The surface states
|
||||
/// its own height, because the thing that overlays a list is the only thing
|
||||
/// 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({
|
||||
required double inset,
|
||||
required bool live,
|
||||
required bool unreleased,
|
||||
double idle = 8,
|
||||
}) {
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}) => live ? inset + ActivePickupBanner.maxHeight : inset + idle;
|
||||
|
||||
@@ -1237,12 +1237,12 @@ class _StopBrief extends StatelessWidget {
|
||||
/// `Bag 5 · Vidhya Kitchen`, whichever halves exist; the trimmed booking
|
||||
/// reference only as a last resort.
|
||||
String _subtitle(String orderId) {
|
||||
final bag = (pickup['baglabel'] ?? pickup['BagLabel'] ?? '')
|
||||
final printed = (pickup['baglabel'] ?? pickup['BagLabel'] ?? '')
|
||||
.toString()
|
||||
.trim();
|
||||
final source = stopSourceName(pickup);
|
||||
final line = [
|
||||
if (bag.isNotEmpty) bag,
|
||||
if (printed.isNotEmpty) printed,
|
||||
if (source.isNotEmpty) source,
|
||||
].join(' \u00b7 ');
|
||||
if (line.isNotEmpty) return line;
|
||||
|
||||
@@ -169,12 +169,18 @@ String stopSourceId(Map<String, dynamic> item) =>
|
||||
.toString()
|
||||
.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
|
||||
/// the wrong bag. A printed label on the card is the cheapest thing that does.
|
||||
String stopBagLabel(Map<String, dynamic> item) =>
|
||||
/// the wrong one. A printed label on the card is the cheapest thing that does.
|
||||
///
|
||||
/// 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['bagid'] ??
|
||||
|
||||
Reference in New Issue
Block a user