production
This commit is contained in:
@@ -14,10 +14,12 @@ library;
|
||||
// what is removed here is this file's claim on them.
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart' show kDebugMode, setEquals;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:miler/views/helpers/constants/design_constants.dart';
|
||||
import 'package:miler/views/helpers/constants/narrative.dart';
|
||||
import 'package:miler/views/helpers/constants/miler_surface.dart';
|
||||
import 'package:miler/views/helpers/constants/miler_type.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
@@ -30,6 +32,7 @@ import 'package:miler/views/helpers/constants/Font_constant.dart';
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
import 'package:miler/helpers/poll_policy.dart';
|
||||
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
||||
import 'package:miler/views/helpers/widgets/slide_action.dart';
|
||||
import 'package:miler/views/helpers/widgets/page_transitions.dart';
|
||||
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
|
||||
import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
|
||||
@@ -1436,16 +1439,47 @@ class _MyPickupsState extends State<MyPickups>
|
||||
/// 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 local sets are the fallback for a row that carries no consignment
|
||||
/// status yet — an older deployment, or a stop collected seconds ago that
|
||||
/// the queue has not caught up with.
|
||||
/// ── 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 state = consignmentStateFromRaw(stop['consignmentstatus']);
|
||||
if (state != ConsignmentState.unknown) {
|
||||
return state.needsRelease;
|
||||
}
|
||||
final id = MilkRun.idOf(stop);
|
||||
return _collectedIds.contains(id) && !_outForDeliveryIds.contains(id);
|
||||
// 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
|
||||
@@ -1536,10 +1570,19 @@ class _MyPickupsState extends State<MyPickups>
|
||||
|
||||
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
|
||||
? 'Could not start that delivery. Check your connection and try '
|
||||
'again.'
|
||||
? (lastReleaseFailure ??
|
||||
'Could not start that delivery. Check your connection and '
|
||||
'try again.')
|
||||
: 'Could not start the round. Check your connection and try again.',
|
||||
);
|
||||
return;
|
||||
@@ -1751,23 +1794,46 @@ class _MyPickupsState extends State<MyPickups>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Opening a stop starts its leg, if the bar has not already ──
|
||||
// ── Opening a stop does NOT start its leg ──
|
||||
//
|
||||
// START DELIVERY is the normal way in and releases the whole load at once.
|
||||
// This is the other way in: a rider who taps a card without pressing the
|
||||
// bar is just as much on his way, and the stop has to be released before he
|
||||
// gets there — `deliver` refuses any consignment that is not
|
||||
// `Out_for_Delivery`, so opening one unreleased would walk him to a door
|
||||
// and fail him at it.
|
||||
// This used to call `startRound([item])` here — the same server release the
|
||||
// Start-ride slider makes — on the reasoning that a rider who taps a card
|
||||
// without pressing the bar is just as much on his way, and that `deliver`
|
||||
// refuses anything that is not `Out_for_Delivery`, so an unreleased stop
|
||||
// would walk him to a door and fail him at it.
|
||||
//
|
||||
// The same call the bar makes, for one order. [startRound] writes the local
|
||||
// marker only after the server agrees, so a failure here leaves the row
|
||||
// reading PICKED rather than lying about a round the hub has not started.
|
||||
if (MilkRun.navigatesToCustomer(item, collectedIds: _collectedIds) &&
|
||||
!_outForDeliveryIds.contains((item['orderid'] ?? '').toString())) {
|
||||
await startRound([item]);
|
||||
if (!mounted) return;
|
||||
}
|
||||
// ── Why that is the production lifecycle bug, one tap later ──
|
||||
//
|
||||
// [MilkRun.navigatesToCustomer] is true the moment a stop is in
|
||||
// `_collectedIds`, which is the moment **Picked** succeeds. So the sequence
|
||||
// that matters is:
|
||||
//
|
||||
// ```
|
||||
// Home slide Picked → pickup-complete → Collected_By_Miler
|
||||
// Work tap the card → start-delivery → Out_for_Delivery ← here
|
||||
// Map slide Start ride (already released)
|
||||
// ```
|
||||
//
|
||||
// Tapping a card is not a statement that the rider has set off. It is how
|
||||
// he reads an address, checks a phone number, or looks at the route — and
|
||||
// it fired the one call that is supposed to be the slider's alone, so the
|
||||
// console went **Active** while the load was still on the counter and the
|
||||
// rider had not slid anything.
|
||||
//
|
||||
// It is invisible today only because `MILER_COLLECTED_STATE_ENABLED` is
|
||||
// off: the pivot has already released the consignment, so
|
||||
// `releaseForDelivery` sees a deliverable state and returns true without
|
||||
// spending a request. **The flag flip is what arms this.** Flipping it
|
||||
// without this change moves the early-Active bug rather than fixing it.
|
||||
//
|
||||
// The door is still covered, and by the branch written for it: the
|
||||
// `DeliverGate.needsRelease` case in `_closeDelivery` releases a stop the
|
||||
// rider never started, at the moment he actually hands over, then re-reads
|
||||
// the state before posting. That is the safety net this pre-release was
|
||||
// duplicating — one tap too early and on the wrong side of the boundary.
|
||||
//
|
||||
// See `MilerLifecycle`, and the regression tests in
|
||||
// `test/picked_does_not_release_test.dart`.
|
||||
|
||||
await openScreen(
|
||||
context,
|
||||
@@ -1962,10 +2028,25 @@ class _MyPickupsState extends State<MyPickups>
|
||||
// `where` that used to sit here, re-admitting rows by raw status, is gone
|
||||
// with it: it was the hole an accepted-but-uncollected booking came
|
||||
// through, and a second expression of a fact that now has one.
|
||||
// ── What the rider has already written off today ──
|
||||
//
|
||||
// Read from the two stores that record a mutation *after* it came back
|
||||
// OK, so this is authoritative and not a display flag: `completed` holds
|
||||
// delivered and cancelled, `skipped` holds a parked attempt. Both are
|
||||
// day-stamped and scoped to this rider, tenant and line, and both survive
|
||||
// a cold start — which is what stops `/miler/bookings` re-admitting a
|
||||
// skipped stop on the next poll while the consignment is still
|
||||
// `Out_for_Delivery`. See [WorkBoundary.isClosed].
|
||||
final closedIds = <String>{
|
||||
...await getCompletedOrderIds(),
|
||||
...await getSkippedOrderIds(),
|
||||
};
|
||||
|
||||
var dedupedList = WorkBoundary.deliveryQueue(
|
||||
items.whereType<Map<String, dynamic>>(),
|
||||
collectedIds: collectedIds,
|
||||
acceptedIds: acceptedIds,
|
||||
closedIds: closedIds,
|
||||
);
|
||||
|
||||
// Drop stops the rider just finished. The backend may still return them
|
||||
@@ -1996,17 +2077,17 @@ class _MyPickupsState extends State<MyPickups>
|
||||
'${sortedList.map((o) => "${o['orderid']}:${o['orderstatus']}").toList()}',
|
||||
);
|
||||
|
||||
// Re-apply locally-skipped orders so a skip survives polling even when the
|
||||
// backend hasn't recorded it yet (e.g. mock/offline data). Without this,
|
||||
// the 3s poll would overwrite the skip with the raw API status. Applied
|
||||
// before the filter below, because the filter now acts on it.
|
||||
if (_skippedOrdersCache.isNotEmpty) {
|
||||
for (final o in sortedList) {
|
||||
if (_skippedOrdersCache.containsKey(_getOrderKey(o))) {
|
||||
o['orderstatus'] = 'skipped';
|
||||
}
|
||||
}
|
||||
}
|
||||
// ── The poll-time re-stamp is gone ──
|
||||
//
|
||||
// This used to walk the list writing `orderstatus = 'skipped'` onto every
|
||||
// row in the in-memory skip cache, so that the 3s poll could not revert a
|
||||
// skip the backend had not recorded. That was a display flag papering
|
||||
// over dual ownership: the row was still in the queue, still owned by
|
||||
// this tab, and merely wearing a different word.
|
||||
//
|
||||
// A skipped order no longer reaches this point — `closedIds` drops it at
|
||||
// the boundary, above both screens — so there is nothing left to stamp
|
||||
// and no second opinion to keep in sync. See [WorkBoundary.isClosed].
|
||||
|
||||
// Build the top list = the stops he is actually working.
|
||||
// - ACTIVE stops go to the banner, not the list.
|
||||
@@ -2895,14 +2976,20 @@ class _MyPickupsState extends State<MyPickups>
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
// The same white as Home, Activity and Account. This was `surface`
|
||||
// (#FCF9F8, a warm off-white); the difference is invisible under a full
|
||||
// list and obvious the moment the list is short, so the tab appeared to
|
||||
// change colour with the rider's workload.
|
||||
// The brand shows through the sheet's two top corners — see
|
||||
// [MilerSheet].
|
||||
// ── The corner has to have something behind it ──
|
||||
//
|
||||
// The sheet's two top corners were clipping onto a ground painted in
|
||||
// the same canvas the sheet itself uses, so the curve revealed the
|
||||
// page's own colour and was invisible: this tab read as a hard maroon
|
||||
// line above the queue while Home and Activity carried the shape. The
|
||||
// brand goes behind the sheet, which is what the other tabs do and
|
||||
// what makes the curve a curve. The page's own colour is the sheet's
|
||||
// (`MilerSurface.canvas`, one line below), not this.
|
||||
backgroundColor: ColorConstants.primary,
|
||||
appBar: MilerAppBar(
|
||||
// The bar's curve opens onto the sheet's canvas, not the app's
|
||||
// paper. See [milerBrandSurface].
|
||||
pageColor: MilerSurface.canvas,
|
||||
// ── The tab and the page it opens must agree ──
|
||||
//
|
||||
// This was the literal 'My Bookings', while the nav tab underneath it
|
||||
@@ -2936,322 +3023,342 @@ class _MyPickupsState extends State<MyPickups>
|
||||
// [MilerSurface].
|
||||
body: MilerSheet(
|
||||
color: MilerSurface.canvas,
|
||||
child: Stack(
|
||||
children: [
|
||||
// Three states, cross-faded: still asking, nothing to do, and the
|
||||
// queue itself. The middle one used to cover the first, and the
|
||||
// hand-off between any two of them used to be a single frame.
|
||||
SmoothSwap(
|
||||
child: _picked.isEmpty && !_firstLoadDone
|
||||
? const SkeletonList(key: ValueKey('bookings-loading'))
|
||||
: _picked.isEmpty && _fetchFailed
|
||||
? ErrorRetry(
|
||||
key: const ValueKey('bookings-error'),
|
||||
onRetry: () => _fetchPicked(force: true),
|
||||
)
|
||||
: _picked.isEmpty
|
||||
? SingleChildScrollView(
|
||||
key: const ValueKey('bookings-empty'),
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.72,
|
||||
// ── The artwork is the whole empty state ──
|
||||
//
|
||||
// This was a [MilerEmptyState] drawing a headline and a
|
||||
// body line above the illustration — and the
|
||||
// illustration has both baked into it, in the same
|
||||
// words: "You're all caught up", then the line about
|
||||
// new jobs appearing automatically. Rendered together
|
||||
// the screen said each sentence twice, which reads as a
|
||||
// rendering fault rather than as one message. The panel
|
||||
// is the picture now, at a size worth looking at, and
|
||||
// it keeps its own copy.
|
||||
//
|
||||
// [_fetchDiagLine] stays: it is `kDebugMode`-gated and
|
||||
// folds away entirely in release, so nothing ships
|
||||
// underneath the art.
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 16.w,
|
||||
),
|
||||
child: Image.asset(
|
||||
'assets/images/caught_up.png',
|
||||
width: 330.w,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, __, ___) => Icon(
|
||||
LucideIcons.truck,
|
||||
size: 64.sp,
|
||||
color: ColorConstants.borderStrong,
|
||||
// ── The sheet has to fill the body, not the queue ──
|
||||
//
|
||||
// A `Stack` hands its children loose constraints and then shrinks to
|
||||
// the tallest of them, so on a short queue — or the empty state —
|
||||
// the sheet ended where the content did and the rest of the body was
|
||||
// whatever the Scaffold was painted with. That was invisible while
|
||||
// the ground was the same canvas as the sheet; the moment the brand
|
||||
// went behind it (for the corners) it became a red band across the
|
||||
// bottom of the tab.
|
||||
child: SizedBox.expand(
|
||||
child: Stack(
|
||||
children: [
|
||||
// Three states, cross-faded: still asking, nothing to do, and the
|
||||
// queue itself. The middle one used to cover the first, and the
|
||||
// hand-off between any two of them used to be a single frame.
|
||||
SmoothSwap(
|
||||
child: _picked.isEmpty && !_firstLoadDone
|
||||
? const SkeletonList(key: ValueKey('bookings-loading'))
|
||||
: _picked.isEmpty && _fetchFailed
|
||||
? ErrorRetry(
|
||||
key: const ValueKey('bookings-error'),
|
||||
onRetry: () => _fetchPicked(force: true),
|
||||
)
|
||||
: _picked.isEmpty
|
||||
? SingleChildScrollView(
|
||||
key: const ValueKey('bookings-empty'),
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.72,
|
||||
// ── The artwork is the whole empty state ──
|
||||
//
|
||||
// This was a [MilerEmptyState] drawing a headline and a
|
||||
// body line above the illustration — and the
|
||||
// illustration has both baked into it, in the same
|
||||
// words: "You're all caught up", then the line about
|
||||
// new jobs appearing automatically. Rendered together
|
||||
// the screen said each sentence twice, which reads as a
|
||||
// rendering fault rather than as one message. The panel
|
||||
// is the picture now, at a size worth looking at, and
|
||||
// it keeps its own copy.
|
||||
//
|
||||
// [_fetchDiagLine] stays: it is `kDebugMode`-gated and
|
||||
// folds away entirely in release, so nothing ships
|
||||
// underneath the art.
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 16.w,
|
||||
),
|
||||
child: Image.asset(
|
||||
'assets/images/caught_up.png',
|
||||
width: 330.w,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, __, ___) => Icon(
|
||||
LucideIcons.truck,
|
||||
size: 64.sp,
|
||||
color: ColorConstants.borderStrong,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_fetchDiagLine(),
|
||||
],
|
||||
_fetchDiagLine(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
key: const ValueKey('bookings-list'),
|
||||
children: [
|
||||
// No trip tabs here. Everything on this screen is already
|
||||
// accepted, so the only live question is which stop is
|
||||
// next — and that has one answer across the whole day.
|
||||
// See [_visibleStops].
|
||||
_buildTripRail(),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _fetchPicked,
|
||||
color: ColorConstants.primary,
|
||||
// ── An empty ROW list, not an empty stop list ──
|
||||
//
|
||||
// This asked `_visibleStops.isEmpty`, but the rows
|
||||
// are built from a filtered subset of it. When the
|
||||
// stops were all finished-but-not-yet-cleared, the
|
||||
// page took the populated branch and rendered a
|
||||
// `ListView` with zero children: a blank screen
|
||||
// under the rail, with no explanation and nothing
|
||||
// to pull on. Ask the list that is actually drawn.
|
||||
child: _queueRows().isEmpty
|
||||
? _buildTripTabEmptyState()
|
||||
: Builder(
|
||||
builder: (context) {
|
||||
final rows = _queueRows();
|
||||
return ListView.builder(
|
||||
padding: EdgeInsets.only(
|
||||
top: 2.h,
|
||||
// Room for the live strip only when
|
||||
// one is actually drawn — see
|
||||
// [_liveOnThisScreen].
|
||||
// ── Room for whichever bar is
|
||||
// drawn ──
|
||||
//
|
||||
// A flat 80 was the live strip's
|
||||
// height, and the round bar is
|
||||
// taller: a caption, a primary
|
||||
// button and the nav-bar inset
|
||||
// under it. The last card sat
|
||||
// behind it with its controls
|
||||
// unreachable. Measured from the
|
||||
// pieces rather than guessed, so it
|
||||
// cannot drift when either grows.
|
||||
// ── One measured floor ──
|
||||
//
|
||||
// The unreleased branch was built
|
||||
// from its bar's own pieces; the
|
||||
// live branch was a flat `80.h`
|
||||
// that forgot the device inset
|
||||
// entirely, so on a gesture-nav
|
||||
// phone the last row of the run sat
|
||||
// behind the banner *and* behind
|
||||
// the navigation. Both surfaces now
|
||||
// state their own height and the
|
||||
// floor reserves whichever one is
|
||||
// actually showing, plus the inset.
|
||||
bottom: _bottomReservation(context),
|
||||
),
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (context, index) {
|
||||
final row = rows[index];
|
||||
if (row.isHeading) {
|
||||
return _QueueHeading(
|
||||
label: row.heading!,
|
||||
count: row.count,
|
||||
// On the first heading only:
|
||||
// it describes the whole
|
||||
// queue, not the section.
|
||||
order: index == 0
|
||||
? _orderSource
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
final item = row.stop!;
|
||||
final orderId =
|
||||
(item['orderid'] ?? '')
|
||||
.toString();
|
||||
// Live metres + speed drive the
|
||||
// distance / ETA on every density;
|
||||
// RouteMetricsHelper picks the unit
|
||||
// and falls back to an urban average
|
||||
// when the GPS reading is unusable.
|
||||
final distanceMeters =
|
||||
RouteMetricsHelper.metersToStop(
|
||||
item,
|
||||
riderLat: _currentLocation
|
||||
?.latitude,
|
||||
riderLng: _currentLocation
|
||||
?.longitude,
|
||||
// The leg decides the end:
|
||||
// a delivery row measures
|
||||
// to the DOOR. It measured
|
||||
// to the kitchen, so the
|
||||
// figure grew as the rider
|
||||
// approached the customer.
|
||||
toDrop: MilkRun.workingKind(
|
||||
item,
|
||||
collectedIds: _collectedIds,
|
||||
).isDelivery,
|
||||
)
|
||||
: Column(
|
||||
key: const ValueKey('bookings-list'),
|
||||
children: [
|
||||
// No trip tabs here. Everything on this screen is already
|
||||
// accepted, so the only live question is which stop is
|
||||
// next — and that has one answer across the whole day.
|
||||
// See [_visibleStops].
|
||||
_buildTripRail(),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _fetchPicked,
|
||||
color: ColorConstants.primary,
|
||||
// ── An empty ROW list, not an empty stop list ──
|
||||
//
|
||||
// This asked `_visibleStops.isEmpty`, but the rows
|
||||
// are built from a filtered subset of it. When the
|
||||
// stops were all finished-but-not-yet-cleared, the
|
||||
// page took the populated branch and rendered a
|
||||
// `ListView` with zero children: a blank screen
|
||||
// under the rail, with no explanation and nothing
|
||||
// to pull on. Ask the list that is actually drawn.
|
||||
child: _queueRows().isEmpty
|
||||
? _buildTripTabEmptyState()
|
||||
: Builder(
|
||||
builder: (context) {
|
||||
final rows = _queueRows();
|
||||
return ListView.builder(
|
||||
padding: EdgeInsets.only(
|
||||
top: 2.h,
|
||||
// Room for the live strip only when
|
||||
// one is actually drawn — see
|
||||
// [_liveOnThisScreen].
|
||||
// ── Room for whichever bar is
|
||||
// drawn ──
|
||||
//
|
||||
// A flat 80 was the live strip's
|
||||
// height, and the round bar is
|
||||
// taller: a caption, a primary
|
||||
// button and the nav-bar inset
|
||||
// under it. The last card sat
|
||||
// behind it with its controls
|
||||
// unreachable. Measured from the
|
||||
// pieces rather than guessed, so it
|
||||
// cannot drift when either grows.
|
||||
// ── One measured floor ──
|
||||
//
|
||||
// The unreleased branch was built
|
||||
// from its bar's own pieces; the
|
||||
// live branch was a flat `80.h`
|
||||
// that forgot the device inset
|
||||
// entirely, so on a gesture-nav
|
||||
// phone the last row of the run sat
|
||||
// behind the banner *and* behind
|
||||
// the navigation. Both surfaces now
|
||||
// state their own height and the
|
||||
// floor reserves whichever one is
|
||||
// actually showing, plus the inset.
|
||||
bottom: _bottomReservation(
|
||||
context,
|
||||
),
|
||||
),
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (context, index) {
|
||||
final row = rows[index];
|
||||
if (row.isHeading) {
|
||||
return _QueueHeading(
|
||||
label: row.heading!,
|
||||
count: row.count,
|
||||
// On the first heading only:
|
||||
// it describes the whole
|
||||
// queue, not the section.
|
||||
order: index == 0
|
||||
? _orderSource
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
void open() => StopDetailSheet.show(
|
||||
context,
|
||||
stop: item,
|
||||
stopNumber: row.displayStep,
|
||||
// The pairing recorded at the
|
||||
// counter — same store as the
|
||||
// card, so the two agree.
|
||||
bag: row.bag,
|
||||
// The carried set — what turns
|
||||
// a raw pickup row into the
|
||||
// delivery leg the sheet must
|
||||
// describe.
|
||||
collectedIds: _collectedIds,
|
||||
riderLat:
|
||||
_currentLocation?.latitude,
|
||||
riderLng:
|
||||
_currentLocation?.longitude,
|
||||
);
|
||||
final item = row.stop!;
|
||||
final orderId =
|
||||
(item['orderid'] ?? '')
|
||||
.toString();
|
||||
// Live metres + speed drive the
|
||||
// distance / ETA on every density;
|
||||
// RouteMetricsHelper picks the unit
|
||||
// and falls back to an urban average
|
||||
// when the GPS reading is unusable.
|
||||
final distanceMeters =
|
||||
RouteMetricsHelper.metersToStop(
|
||||
item,
|
||||
riderLat: _currentLocation
|
||||
?.latitude,
|
||||
riderLng: _currentLocation
|
||||
?.longitude,
|
||||
// The leg decides the end:
|
||||
// a delivery row measures
|
||||
// to the DOOR. It measured
|
||||
// to the kitchen, so the
|
||||
// figure grew as the rider
|
||||
// approached the customer.
|
||||
toDrop: MilkRun.workingKind(
|
||||
item,
|
||||
collectedIds:
|
||||
_collectedIds,
|
||||
).isDelivery,
|
||||
);
|
||||
|
||||
// ── One card shape per density ──
|
||||
//
|
||||
// Not one card dimmed four ways. See
|
||||
// [QueueDensity] for why the live stop
|
||||
// is physically bigger rather than
|
||||
// merely brighter.
|
||||
final Widget child = switch (row
|
||||
.density) {
|
||||
QueueDensity.now => PickupCard(
|
||||
key: ValueKey(
|
||||
'pickup_$orderId',
|
||||
),
|
||||
item: item,
|
||||
displayStep: row.displayStep,
|
||||
distanceMeters: distanceMeters,
|
||||
riderSpeedMps:
|
||||
_currentLocation?.speed,
|
||||
void
|
||||
open() => StopDetailSheet.show(
|
||||
context,
|
||||
stop: item,
|
||||
stopNumber: row.displayStep,
|
||||
// The pairing recorded at the
|
||||
// counter — same store as the
|
||||
// card, so the two agree.
|
||||
bag: row.bag,
|
||||
// The carried set — what turns
|
||||
// a raw pickup row into the
|
||||
// delivery leg the sheet must
|
||||
// describe.
|
||||
collectedIds: _collectedIds,
|
||||
outForDeliveryIds:
|
||||
_outForDeliveryIds,
|
||||
),
|
||||
QueueDensity.next => _NextCard(
|
||||
item: item,
|
||||
bag: row.bag,
|
||||
distanceMeters: distanceMeters,
|
||||
riderSpeedMps:
|
||||
_currentLocation?.speed,
|
||||
onOpen: open,
|
||||
),
|
||||
QueueDensity.later => _LaterRow(
|
||||
item: item,
|
||||
bag: row.bag,
|
||||
distanceMeters: distanceMeters,
|
||||
onOpen: open,
|
||||
),
|
||||
QueueDensity.skipped =>
|
||||
_SkippedRow(
|
||||
riderLat:
|
||||
_currentLocation?.latitude,
|
||||
riderLng:
|
||||
_currentLocation?.longitude,
|
||||
);
|
||||
|
||||
// ── One card shape per density ──
|
||||
//
|
||||
// Not one card dimmed four ways. See
|
||||
// [QueueDensity] for why the live stop
|
||||
// is physically bigger rather than
|
||||
// merely brighter.
|
||||
final Widget child = switch (row
|
||||
.density) {
|
||||
QueueDensity.now => PickupCard(
|
||||
key: ValueKey(
|
||||
'pickup_$orderId',
|
||||
),
|
||||
item: item,
|
||||
displayStep: row.displayStep,
|
||||
distanceMeters:
|
||||
distanceMeters,
|
||||
riderSpeedMps:
|
||||
_currentLocation?.speed,
|
||||
bag: row.bag,
|
||||
collectedIds: _collectedIds,
|
||||
outForDeliveryIds:
|
||||
_outForDeliveryIds,
|
||||
),
|
||||
QueueDensity.next => _NextCard(
|
||||
item: item,
|
||||
bag: row.bag,
|
||||
reason: row.reason,
|
||||
distanceMeters:
|
||||
distanceMeters,
|
||||
riderSpeedMps:
|
||||
_currentLocation?.speed,
|
||||
onOpen: open,
|
||||
onResume: () =>
|
||||
_resumeSkipped(item),
|
||||
),
|
||||
};
|
||||
QueueDensity.later => _LaterRow(
|
||||
item: item,
|
||||
bag: row.bag,
|
||||
distanceMeters:
|
||||
distanceMeters,
|
||||
onOpen: open,
|
||||
),
|
||||
QueueDensity.skipped =>
|
||||
_SkippedRow(
|
||||
item: item,
|
||||
bag: row.bag,
|
||||
reason: row.reason,
|
||||
onOpen: open,
|
||||
onResume: () =>
|
||||
_resumeSkipped(item),
|
||||
),
|
||||
};
|
||||
|
||||
return Reveal(
|
||||
key: ValueKey('reveal_$orderId'),
|
||||
delay: staggerDelay(index),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
return Reveal(
|
||||
key: ValueKey(
|
||||
'reveal_$orderId',
|
||||
),
|
||||
delay: staggerDelay(index),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// The live strip, shared with Home. It places itself clear of the
|
||||
// floating nav bar, so it is pinned flush here and left to it.
|
||||
//
|
||||
// Only for a stop this screen can actually work. On a kitchen line
|
||||
// a collection is Home's, in bulk, so a strip here offering to
|
||||
// "continue" one was an invitation into a flow the rider is not
|
||||
// supposed to be in — and the way most riders found it. See
|
||||
// [_liveOnThisScreen].
|
||||
// ── The hinge of the day, at the foot of the tab that holds the
|
||||
// load ──
|
||||
//
|
||||
// It shares this slot with the live strip and yields to it: once a
|
||||
// stop is running, "continue that stop" is the only thing the rider
|
||||
// wants from the bottom of this screen, and a second bar under his
|
||||
// thumb offering to start something else is how the wrong one gets
|
||||
// pressed. See [startRound].
|
||||
// ── One contextual slot, and the workflow advances through it ──
|
||||
//
|
||||
// These were two `if`s over the same corner. Logically exclusive
|
||||
// already — a live stop suppresses the start bar, because a
|
||||
// second bar under the rider's thumb offering to start something
|
||||
// else is how the wrong one gets pressed — but they *swapped* by
|
||||
// one disappearing and the other appearing in the same frame,
|
||||
// which reads as the screen glitching rather than as **Start
|
||||
// delivery → Continue this stop**.
|
||||
//
|
||||
// One switcher, one child at a time, on the app's own state
|
||||
// duration. Keyed by which surface it is rather than by its
|
||||
// contents, so a poll that returns the same live stop does not
|
||||
// replay the transition.
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: AnimatedSwitcher(
|
||||
duration: DesignConstants.motionState,
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeOutCubic,
|
||||
transitionBuilder: (child, anim) => FadeTransition(
|
||||
opacity: anim,
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0, 0.12),
|
||||
end: Offset.zero,
|
||||
).animate(anim),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: _liveOnThisScreen.isNotEmpty
|
||||
? ActivePickupBanner(
|
||||
key: const ValueKey('live'),
|
||||
activePickups: _liveOnThisScreen,
|
||||
onTap: (pickup) async {
|
||||
await startPickup(pickup);
|
||||
// Refresh after returning from map screen
|
||||
if (mounted) {
|
||||
await Future.delayed(
|
||||
const Duration(milliseconds: 500),
|
||||
);
|
||||
_fetchPicked();
|
||||
}
|
||||
},
|
||||
)
|
||||
: _unreleased.isNotEmpty
|
||||
? StartRoundBar(
|
||||
key: const ValueKey('start-round'),
|
||||
count: _unreleased.length,
|
||||
busy: _starting,
|
||||
onPressed: _startWholeRound,
|
||||
)
|
||||
: const SizedBox.shrink(key: ValueKey('none')),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
// The live strip, shared with Home. It places itself clear of the
|
||||
// floating nav bar, so it is pinned flush here and left to it.
|
||||
//
|
||||
// Only for a stop this screen can actually work. On a kitchen line
|
||||
// a collection is Home's, in bulk, so a strip here offering to
|
||||
// "continue" one was an invitation into a flow the rider is not
|
||||
// supposed to be in — and the way most riders found it. See
|
||||
// [_liveOnThisScreen].
|
||||
// ── The hinge of the day, at the foot of the tab that holds the
|
||||
// load ──
|
||||
//
|
||||
// It shares this slot with the live strip and yields to it: once a
|
||||
// stop is running, "continue that stop" is the only thing the rider
|
||||
// wants from the bottom of this screen, and a second bar under his
|
||||
// thumb offering to start something else is how the wrong one gets
|
||||
// pressed. See [startRound].
|
||||
// ── One contextual slot, and the workflow advances through it ──
|
||||
//
|
||||
// These were two `if`s over the same corner. Logically exclusive
|
||||
// already — a live stop suppresses the start bar, because a
|
||||
// second bar under the rider's thumb offering to start something
|
||||
// else is how the wrong one gets pressed — but they *swapped* by
|
||||
// one disappearing and the other appearing in the same frame,
|
||||
// which reads as the screen glitching rather than as **Start
|
||||
// delivery → Continue this stop**.
|
||||
//
|
||||
// One switcher, one child at a time, on the app's own state
|
||||
// duration. Keyed by which surface it is rather than by its
|
||||
// contents, so a poll that returns the same live stop does not
|
||||
// replay the transition.
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: AnimatedSwitcher(
|
||||
duration: DesignConstants.motionState,
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeOutCubic,
|
||||
transitionBuilder: (child, anim) => FadeTransition(
|
||||
opacity: anim,
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0, 0.12),
|
||||
end: Offset.zero,
|
||||
).animate(anim),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: _liveOnThisScreen.isNotEmpty
|
||||
? ActivePickupBanner(
|
||||
key: const ValueKey('live'),
|
||||
activePickups: _liveOnThisScreen,
|
||||
onTap: (pickup) async {
|
||||
await startPickup(pickup);
|
||||
// Refresh after returning from map screen
|
||||
if (mounted) {
|
||||
await Future.delayed(
|
||||
const Duration(milliseconds: 500),
|
||||
);
|
||||
_fetchPicked();
|
||||
}
|
||||
},
|
||||
)
|
||||
: _unreleased.isNotEmpty
|
||||
? StartRoundBar(
|
||||
key: const ValueKey('start-round'),
|
||||
count: _unreleased.length,
|
||||
busy: _starting,
|
||||
onPressed: _startWholeRound,
|
||||
)
|
||||
: const SizedBox.shrink(key: ValueKey('none')),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user