production

This commit is contained in:
2026-08-28 11:13:15 +05:30
parent d7348e253f
commit 5723d373b2
162 changed files with 17924 additions and 7026 deletions

View File

@@ -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')),
),
),
],
),
),
),
),