device infos

This commit is contained in:
2026-08-28 15:07:30 +05:30
parent 5723d373b2
commit 69f4f3e909
84 changed files with 4337 additions and 2339 deletions

View File

@@ -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;