device infos
This commit is contained in:
@@ -64,12 +64,13 @@ import 'package:miler/views/Dashboard/pickups/collect_payment.dart';
|
||||
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
|
||||
import 'package:miler/widget/Bottom_page.dart';
|
||||
import 'package:miler/data/meal_run_mock.dart';
|
||||
import 'package:miler/data/bag_manifest.dart';
|
||||
import 'package:miler/data/order_manifest.dart';
|
||||
import 'package:miler/data/milk_run.dart';
|
||||
import 'package:miler/data/order_events.dart';
|
||||
import 'package:miler/data/work_domain.dart';
|
||||
import 'package:miler/data/work_repository.dart';
|
||||
import 'package:miler/data/pickup_locations.dart';
|
||||
import 'package:miler/data/service_day.dart';
|
||||
import 'package:miler/data/service_profile.dart';
|
||||
import 'package:miler/views/helpers/constants/miler_type.dart';
|
||||
import 'package:miler/views/Dashboard/home/pickup_preview_sheet.dart';
|
||||
@@ -241,9 +242,22 @@ class _HomepageState extends State<Homepage>
|
||||
Set<String> _closedIds = <String>{};
|
||||
Set<String> _rejectedIds = <String>{};
|
||||
|
||||
/// Notices when the day underneath the screen has moved on.
|
||||
///
|
||||
/// [_fetchQueues] filters against `ServiceDay.today` *as read at fetch time*,
|
||||
/// so the route on screen is only ever as fresh as the last fetch. See
|
||||
/// [_rolloverCheck].
|
||||
final ServiceDayRollover _day = ServiceDayRollover();
|
||||
|
||||
/// Fires at the next local midnight. See [_armRollover].
|
||||
Timer? _rollover;
|
||||
|
||||
/// Which trip tab is open.
|
||||
int _selectedTripIndex = 0;
|
||||
|
||||
/// True once the rider has picked a tab himself. See [_settleTripTab].
|
||||
bool _tripTabChosen = false;
|
||||
|
||||
/// Stops the rider has ticked, for "accept just these".
|
||||
Set<String> _selectedStopIds = <String>{};
|
||||
|
||||
@@ -258,6 +272,7 @@ class _HomepageState extends State<Homepage>
|
||||
}
|
||||
_pollerSubscription?.cancel();
|
||||
_locationTimer?.cancel();
|
||||
_rollover?.cancel();
|
||||
_httpClient.close();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -271,6 +286,7 @@ class _HomepageState extends State<Homepage>
|
||||
_ensureLocationPermission();
|
||||
_ensureCameraPermission();
|
||||
_startPolling();
|
||||
_armRollover();
|
||||
// What the counters are actually called, joined on the location id the
|
||||
// booking already carries. One request, cached, and every failure resolves
|
||||
// to "use the row's own name" — see [PickupLocations].
|
||||
@@ -971,6 +987,11 @@ class _HomepageState extends State<Homepage>
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
super.didChangeAppLifecycleState(state);
|
||||
// Before the early return below: a resume inside the same foreground state
|
||||
// still has to notice a midnight that passed while the phone was in the
|
||||
// rider's pocket, and that is the case the timer cannot cover — both
|
||||
// platforms suspend timers in the background.
|
||||
if (state == AppLifecycleState.resumed) _rolloverCheck();
|
||||
final bool foreground = state == AppLifecycleState.resumed;
|
||||
if (foreground == _pollForeground) return;
|
||||
_pollForeground = foreground;
|
||||
@@ -1175,7 +1196,15 @@ class _HomepageState extends State<Homepage>
|
||||
// are still on the rider's route — their parcels are still to be
|
||||
// collected and their kilometres still to be ridden. The route brief
|
||||
// must count them or it understates the day.
|
||||
final acceptedStops = await getAcceptedBookings();
|
||||
// Through the day filter as well. The accepted store is deliberately
|
||||
// *not* pruned to today — it is "what have I taken on", and an id set
|
||||
// that forgets a stop would flip that stop back to pending under the
|
||||
// rider's thumb — but the route brief above the run is a statement about
|
||||
// **this** day's kilometres and parcels. Yesterday's accepted-and-unclosed
|
||||
// booking belongs in the set and not in that total.
|
||||
//
|
||||
// Only the rows are filtered; `acceptedIds` above is left whole.
|
||||
final acceptedStops = _todayOnly(await getAcceptedBookings());
|
||||
if (mounted && acceptedStops.length != _acceptedStops.length) {
|
||||
setState(() => _acceptedStops = acceptedStops);
|
||||
} else {
|
||||
@@ -1192,7 +1221,9 @@ class _HomepageState extends State<Homepage>
|
||||
//
|
||||
// Accepted stops still also appear on Bookings — that is where he WORKS
|
||||
// them. Home is where he watches the trip fill up.
|
||||
final allStops = items.whereType<Map<String, dynamic>>().toList();
|
||||
final allStops = _todayOnly(
|
||||
items.whereType<Map<String, dynamic>>().toList(),
|
||||
);
|
||||
|
||||
// ── The skip store is the truth about what is parked ──
|
||||
//
|
||||
@@ -1311,6 +1342,8 @@ class _HomepageState extends State<Homepage>
|
||||
}
|
||||
}
|
||||
});
|
||||
// After the state is applied, because the slots are derived from it.
|
||||
_settleTripTab();
|
||||
} catch (e) {
|
||||
debugPrint('[FETCH_QUEUES] Error: $e');
|
||||
if (mounted) setState(() => _fetchFailed = true);
|
||||
@@ -1322,6 +1355,108 @@ class _HomepageState extends State<Homepage>
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens Home on a tab that has work on it.
|
||||
///
|
||||
/// ── Why this became necessary ──
|
||||
///
|
||||
/// The tab index is a **day part**, not a position: slot 0 is the morning
|
||||
/// whether or not the rider has morning work. That is the right model, and
|
||||
/// while nothing could place a trip by day part it cost nothing — every trip
|
||||
/// fell into the undated bucket and landed on Trip 1, so Trip 1 was where the
|
||||
/// work was.
|
||||
///
|
||||
/// Now that [Trip.dayPartTimeOf] can place them, a rider whose whole day is
|
||||
/// an afternoon run has his work under Trip 2 — correctly — and Home opened
|
||||
/// on Trip 1 and told him his hub had assigned him nothing. The fix for one
|
||||
/// screen must not hand him a worse version of the same lie.
|
||||
///
|
||||
/// So the landing tab is the first slot that actually holds stops. Three
|
||||
/// things keep it from fighting the rider:
|
||||
///
|
||||
/// • It gives up the moment he taps a tab himself — [_tripTabChosen]. An
|
||||
/// empty Trip 3 he chose deliberately is an answer he asked for.
|
||||
/// • It gives up the moment the tab it is on has work, so it lands once and
|
||||
/// then stops moving under him.
|
||||
/// • It never *hides* a slot. The tabs still show all three, empty ones
|
||||
/// included, because "the day has room left in it" is a fact he plans
|
||||
/// around.
|
||||
void _settleTripTab() {
|
||||
if (_tripTabChosen || !mounted) return;
|
||||
final trips = _trips;
|
||||
final here = trips.tripAt(_selectedTripIndex);
|
||||
if (here != null && here.stops.isNotEmpty) return;
|
||||
|
||||
final slots = trips.slots;
|
||||
for (var i = 0; i < slots.length; i++) {
|
||||
final t = slots[i];
|
||||
if (t == null || t.stops.isEmpty) continue;
|
||||
if (i != _selectedTripIndex) {
|
||||
setState(() {
|
||||
_selectedTripIndex = i;
|
||||
_selectedStopIds.clear();
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// ── Home is today's work, and only today's ──
|
||||
///
|
||||
/// The rule, and the reasoning behind every part of it, is
|
||||
/// [ServiceDay.onlyToday] — shared with the Deliveries queue, because the two
|
||||
/// screens showing the rider's live work must not disagree about which day it
|
||||
/// is. What is local here is only *which* day: the one the screen is
|
||||
/// currently showing, adopted so the midnight check below compares against
|
||||
/// the pixels rather than reading the wall clock twice.
|
||||
List<Map<String, dynamic>> _todayOnly(List<Map<String, dynamic>> stops) {
|
||||
_day.rollIfNeeded();
|
||||
return ServiceDay.onlyToday(stops, now: _day.day, where: 'HOME');
|
||||
}
|
||||
|
||||
/// ── The day ending under a screen that is already open ──
|
||||
///
|
||||
/// [ServiceDay] answers "which day is this row" at read time, which is right
|
||||
/// for the data and not sufficient for the screen: [_fetchQueues] filters
|
||||
/// against the day *as read when it ran*. A rider who leaves Home open
|
||||
/// through 23:59, or backgrounds the app on it and picks the phone up at six,
|
||||
/// is looking at a route filtered against a day that has ended.
|
||||
///
|
||||
/// Two triggers, because a phone gives no single reliable one — a timer to
|
||||
/// the next local midnight for the app left in the foreground, and resume for
|
||||
/// every other case. Both land in [_rolloverCheck], which is idempotent.
|
||||
/// The same arrangement `ActivityPage` uses, for the same reason.
|
||||
void _armRollover() {
|
||||
_rollover?.cancel();
|
||||
_rollover = Timer(_day.untilNextDay, _rolloverCheck);
|
||||
}
|
||||
|
||||
/// Turns Home over when the service day has moved on.
|
||||
///
|
||||
/// Yesterday's stops are cleared **before** the refetch, not after it. A new
|
||||
/// day starts empty even with no signal, because the alternative is a rider
|
||||
/// in a dead spot at six in the morning reading yesterday's route as today's
|
||||
/// assignment — the exact failure this exists to stop. Anything that really
|
||||
/// is the new day's comes straight back from the fetch.
|
||||
void _rolloverCheck() {
|
||||
if (!mounted) return;
|
||||
_armRollover();
|
||||
if (!_day.rollIfNeeded()) return;
|
||||
setState(() {
|
||||
_allTripStops = <Map<String, dynamic>>[];
|
||||
_ordersMap = <String, Map<String, dynamic>>{};
|
||||
_orderIds = <String>[];
|
||||
_selectedOrders = <String, bool>{};
|
||||
_selectedStopIds = <String>{};
|
||||
_acceptedStops = <Map<String, dynamic>>[];
|
||||
isAllSelected = false;
|
||||
// The fetch short-circuits on an unchanged payload, and the payload has
|
||||
// not changed — only the day it is read against has. Clearing the key is
|
||||
// what lets the new day's answer through.
|
||||
_lastQueuesJson = '';
|
||||
});
|
||||
_fetchQueues();
|
||||
}
|
||||
|
||||
/// See [HomepageArrivalPrecedence]. Kept as a one-line forwarder so the call
|
||||
/// site reads the same as it did while the rule itself is reachable from a
|
||||
/// test — a precedence contract that can only be exercised by building the
|
||||
@@ -1388,14 +1523,21 @@ class _HomepageState extends State<Homepage>
|
||||
...await getSkippedOrderIds(),
|
||||
};
|
||||
|
||||
final activeOrders = itemsV2.whereType<Map<String, dynamic>>().where((
|
||||
booking,
|
||||
) {
|
||||
final status = (booking['orderstatus']?.toString().toLowerCase() ?? '')
|
||||
.trim();
|
||||
if (status != 'active') return false;
|
||||
return !closedIds.contains((booking['orderid'] ?? '').toString());
|
||||
}).toList();
|
||||
// ── Through the same day filter as the queue ──
|
||||
//
|
||||
// These are merged into the trip by [_tripStops], and a row this call
|
||||
// carries that the queue does not is **appended** — so an unfinished
|
||||
// stop still reporting `active` from a shift two days ago walked past
|
||||
// [_todayOnly] and back onto Home through the side door, wearing a LIVE
|
||||
// mark. One day, one rule, however the row arrived.
|
||||
final activeOrders = _todayOnly(
|
||||
itemsV2.whereType<Map<String, dynamic>>().where((booking) {
|
||||
final status =
|
||||
(booking['orderstatus']?.toString().toLowerCase() ?? '').trim();
|
||||
if (status != 'active') return false;
|
||||
return !closedIds.contains((booking['orderid'] ?? '').toString());
|
||||
}).toList(),
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -2048,7 +2190,7 @@ class _HomepageState extends State<Homepage>
|
||||
stage: rung,
|
||||
placeName: place,
|
||||
count: ids.length,
|
||||
lines: BagManifest.forGroup(batch),
|
||||
lines: OrderManifest.forGroup(batch),
|
||||
onConfirm: (outcome) async {
|
||||
setState(() => _busyStopIds.addAll(ids));
|
||||
try {
|
||||
@@ -2069,9 +2211,9 @@ class _HomepageState extends State<Homepage>
|
||||
}
|
||||
if (refusal != null) return refusal;
|
||||
if (rung == StopStatus.arrived) {
|
||||
await saveBagLabels({
|
||||
for (final line in BagManifest.forGroup(loaded))
|
||||
line.orderId: line.bag,
|
||||
await saveOrderLabels({
|
||||
for (final line in OrderManifest.forGroup(loaded))
|
||||
line.orderId: line.label,
|
||||
});
|
||||
}
|
||||
if (missing.isNotEmpty) {
|
||||
@@ -2230,8 +2372,8 @@ class _HomepageState extends State<Homepage>
|
||||
count: ids.length,
|
||||
// Named, with the bag each one travels in — this is the list he checks
|
||||
// against the shelf in front of him. One order, one bag; see
|
||||
// [BagManifest].
|
||||
lines: BagManifest.forGroup(stops),
|
||||
// [OrderManifest].
|
||||
lines: OrderManifest.forGroup(stops),
|
||||
onConfirm: (outcome) async {
|
||||
setState(() => _busyStopIds.addAll(ids));
|
||||
try {
|
||||
@@ -2259,11 +2401,11 @@ class _HomepageState extends State<Homepage>
|
||||
// Joe is Bag 1 of the five that left this counter. Deriving that
|
||||
// later from whatever list is on screen breaks it the moment one is
|
||||
// delivered, so it is written down at the one moment it is true and
|
||||
// read back unchanged for the rest of the round. See [BagManifest].
|
||||
// read back unchanged for the rest of the round. See [OrderManifest].
|
||||
if (action == SelectionAction.pickUp) {
|
||||
await saveBagLabels({
|
||||
for (final line in BagManifest.forGroup(loaded))
|
||||
line.orderId: line.bag,
|
||||
await saveOrderLabels({
|
||||
for (final line in OrderManifest.forGroup(loaded))
|
||||
line.orderId: line.label,
|
||||
});
|
||||
}
|
||||
if (missing.isNotEmpty) {
|
||||
@@ -3738,6 +3880,9 @@ class _HomepageState extends State<Homepage>
|
||||
// Trip 1 must not be counted by the bar while Trip 2
|
||||
// is on screen. See [_selectedPendingIds].
|
||||
onSelect: (index) {
|
||||
// His choice from here on, even if it is an empty
|
||||
// slot — see [_settleTripTab].
|
||||
_tripTabChosen = true;
|
||||
if (index == _selectedTripIndex) return;
|
||||
setState(() {
|
||||
_selectedTripIndex = index;
|
||||
|
||||
@@ -3,7 +3,8 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:latlong2/latlong.dart' show LatLng;
|
||||
|
||||
import 'package:miler/data/bag_manifest.dart';
|
||||
import 'package:miler/data/stop_area.dart';
|
||||
import 'package:miler/data/order_manifest.dart';
|
||||
import 'package:miler/views/helpers/constants/miler_surface.dart';
|
||||
import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
|
||||
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
|
||||
@@ -53,7 +54,7 @@ import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart';
|
||||
/// [ ➤ Start navigation ] [ 📞 ]
|
||||
/// ```
|
||||
///
|
||||
/// The manifest is read straight off [BagManifest] — the same source as the
|
||||
/// The manifest is read straight off [OrderManifest] — the same source as the
|
||||
/// card, the confirmation sheet and the row, so the number he checks against the
|
||||
/// shelf cannot differ between the screen he decided on and the screen he
|
||||
/// confirms on. **One order is one bag**, here as everywhere.
|
||||
@@ -63,7 +64,7 @@ class PickupPreviewSheet extends StatelessWidget {
|
||||
final String placeName;
|
||||
|
||||
/// Every order the rider collects here, in route order.
|
||||
final List<BagLine> lines;
|
||||
final List<ManifestLine> lines;
|
||||
|
||||
/// Straight-line metres from the rider to the place. Null when unknown —
|
||||
/// nothing is invented, exactly as on the card.
|
||||
@@ -100,7 +101,7 @@ class PickupPreviewSheet extends StatelessWidget {
|
||||
static Future<void> show(
|
||||
BuildContext context, {
|
||||
required String placeName,
|
||||
required List<BagLine> lines,
|
||||
required List<ManifestLine> lines,
|
||||
required double? meters,
|
||||
required Duration ride,
|
||||
double? riderLat,
|
||||
@@ -147,20 +148,13 @@ class PickupPreviewSheet extends StatelessWidget {
|
||||
/// every order here shares one counter, so they share one address, and
|
||||
/// taking both from the same row means the pin and the words can never
|
||||
/// describe different places.
|
||||
String get _address {
|
||||
if (lines.isEmpty) return '';
|
||||
final stop = lines.first.stop;
|
||||
for (final key in const [
|
||||
'pickupaddress',
|
||||
'PickupAddress',
|
||||
'pickup_address',
|
||||
'address',
|
||||
]) {
|
||||
final raw = (stop[key] ?? '').toString().trim();
|
||||
if (raw.isNotEmpty && raw.toLowerCase() != 'null') return raw;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
///
|
||||
/// The four key names it reads live in [pickupAddressOf] now, shared with the
|
||||
/// route card behind this sheet — the card had no address at all while this
|
||||
/// one did, and a counter that is named on the sheet and anonymous on the
|
||||
/// card is the same payload answering two different questions.
|
||||
String get _address =>
|
||||
lines.isEmpty ? '' : pickupAddressOf(lines.first.stop);
|
||||
|
||||
static double _d(dynamic v) {
|
||||
if (v == null) return 0;
|
||||
@@ -438,32 +432,37 @@ class PickupPreviewSheet extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
// Ranked under the name, exactly as on the route
|
||||
// The counter's own printed label, where there is
|
||||
// one. Ranked under the name, exactly as on the route
|
||||
// timeline: w700 slate against a w500 mid-tone name
|
||||
// made a column of shelf references the first thing
|
||||
// read on a manifest of people.
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 7.w,
|
||||
vertical: 3.h,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: MilerSurface.canvas,
|
||||
borderRadius: BorderRadius.circular(
|
||||
DesignConstants.radiusLg,
|
||||
// made a column of references the first thing read on
|
||||
// a manifest of people. Absent on a route whose
|
||||
// counters print nothing, which is most of them — the
|
||||
// row is then the person, which is what it is for.
|
||||
if (line.label.isNotEmpty) ...[
|
||||
SizedBox(width: 10.w),
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 7.w,
|
||||
vertical: 3.h,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: MilerSurface.canvas,
|
||||
borderRadius: BorderRadius.circular(
|
||||
DesignConstants.radiusLg,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
line.label,
|
||||
maxLines: 1,
|
||||
style: MilerType.label.copyWith(
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.secondaryText,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
line.bag,
|
||||
maxLines: 1,
|
||||
style: MilerType.label.copyWith(
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.secondaryText,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -474,7 +473,7 @@ class PickupPreviewSheet extends StatelessWidget {
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
Text(
|
||||
BagManifest.countLabel(lines.length),
|
||||
OrderManifest.countLabel(lines.length),
|
||||
style: MilerType.micro.copyWith(
|
||||
fontSize: 12.5.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
import 'package:miler/data/bag_manifest.dart';
|
||||
import 'package:miler/data/order_manifest.dart';
|
||||
import 'package:miler/views/helpers/constants/miler_surface.dart';
|
||||
import 'package:miler/data/service_profile.dart';
|
||||
import 'package:miler/data/stop_area.dart';
|
||||
@@ -87,7 +87,7 @@ import 'package:miler/views/helpers/constants/miler_type.dart';
|
||||
/// One pickup place and every order the rider collects there.
|
||||
///
|
||||
/// Built by the page from the trip's own stops — this holds no state of its
|
||||
/// own and derives no counts. The bag lines come from [BagManifest.forGroup],
|
||||
/// own and derives no counts. The bag lines come from [OrderManifest.forGroup],
|
||||
/// which is the single source for "how many bags" and "which bag": the count is
|
||||
/// `orders.length` because a bag is what an order arrives in, and the identity
|
||||
/// is the printed label or the order's position **in this group**. The timeline
|
||||
@@ -101,6 +101,26 @@ class RouteGroup {
|
||||
/// which case the group is drawn flat (see [RouteTimeline]).
|
||||
final String name;
|
||||
|
||||
/// The street the counter is on — where [name] actually is.
|
||||
///
|
||||
/// ── Why a name was not enough ──
|
||||
///
|
||||
/// The card gave the place a name, a load and a distance, and then expected
|
||||
/// the rider to know where "Vidhya Kitchen" is. He often does; on his first
|
||||
/// week, or on a kitchen the hub added yesterday, he does not — and the only
|
||||
/// way to the address was to tap through to the preview sheet, which is a
|
||||
/// sheet and a dismiss to answer *where am I going*, the plainest question
|
||||
/// the card exists for.
|
||||
///
|
||||
/// It sits directly above the load, so the header reads as one descending
|
||||
/// statement: **what this place is, where it is, what is waiting there**.
|
||||
/// Put below the load it would have separated the counts from the journey
|
||||
/// figures they are read against.
|
||||
///
|
||||
/// Empty is normal and prints nothing — a payload without an address, or a
|
||||
/// flat stop whose own name already *is* the address.
|
||||
final String location;
|
||||
|
||||
/// The orders still shown on Home, in route order.
|
||||
final List<Map<String, dynamic>> stops;
|
||||
|
||||
@@ -112,7 +132,7 @@ class RouteGroup {
|
||||
/// the rider has collected Bag 1 and it leaves Home, numbering the four that
|
||||
/// remain from the visible list would renumber Bag 2 as Bag 1 — and he would
|
||||
/// be reading a different number off the phone from the one printed on the
|
||||
/// shelf. See [BagManifest].
|
||||
/// shelf. See [OrderManifest].
|
||||
final List<Map<String, dynamic>> allStops;
|
||||
|
||||
/// The trip index of the first stop, for travel time and for numbering.
|
||||
@@ -207,6 +227,7 @@ class RouteGroup {
|
||||
const RouteGroup({
|
||||
required this.key,
|
||||
required this.name,
|
||||
this.location = '',
|
||||
required this.stops,
|
||||
required this.allStops,
|
||||
required this.firstIndex,
|
||||
@@ -256,17 +277,17 @@ class RouteGroup {
|
||||
/// The manifest is built from [allStops] and then filtered, never built from
|
||||
/// [stops]: the count and the identity both come from the pickup group the
|
||||
/// rider was handed, not from whatever is left of it.
|
||||
List<BagLine> get manifest {
|
||||
List<ManifestLine> get manifest {
|
||||
final shown = {for (final s in stops) (s['orderid'] ?? '').toString()};
|
||||
return [
|
||||
for (final line in BagManifest.forGroup(allStops))
|
||||
for (final line in OrderManifest.forGroup(allStops))
|
||||
if (shown.contains(line.orderId)) line,
|
||||
];
|
||||
}
|
||||
|
||||
/// `5 orders · 5 bags`. The two halves of the rule, side by side, so the
|
||||
/// rider can check the shelf against the phone without doing arithmetic.
|
||||
String get countLabel => BagManifest.countLabel(stops.length);
|
||||
String get countLabel => OrderManifest.countLabel(stops.length);
|
||||
|
||||
/// Orders off this counter the rider has not taken on yet.
|
||||
int get pendingCount =>
|
||||
@@ -1200,7 +1221,7 @@ class PickupTimelineGroup extends StatelessWidget {
|
||||
}
|
||||
if (group.isSettled) {
|
||||
final n = group.allStops.length;
|
||||
return '$n ${n == 1 ? 'bag' : 'bags'} collected';
|
||||
return n == 1 ? '1 collected' : '$n collected';
|
||||
}
|
||||
return <String>[
|
||||
// ── A flat node is a stop, and a stop has a position ──
|
||||
@@ -1210,12 +1231,24 @@ class PickupTimelineGroup extends StatelessWidget {
|
||||
// in the run — the trip's own index, which does **not** renumber as stops
|
||||
// finish, so a stop is never renamed mid-shift.
|
||||
if (group.flat) 'Stop ${group.firstIndex + 1}' else group.countLabel,
|
||||
// How much of this counter is not his yet. A group can hold one accepted
|
||||
// order and one still pending, and the count above cannot say so alone.
|
||||
if (!group.flat &&
|
||||
group.pendingCount > 0 &&
|
||||
group.pendingCount < group.stops.length)
|
||||
'${group.pendingCount} to accept',
|
||||
// ── How much of this counter is still waiting on him ──
|
||||
//
|
||||
// The chip beside it states the group's **own rung** with a fraction —
|
||||
// `Pending 8/8` on an untouched counter, `Accepted 1/2` on one he has
|
||||
// started. That answers the all-pending case, which used to say nothing
|
||||
// at all: a fresh morning where the card read `Pending` and `10 bags` and
|
||||
// left the rider to join them into *ten decisions are waiting on me*.
|
||||
//
|
||||
// It cannot answer the **mixed** case, and that is what this clause is
|
||||
// for. A group holding one accepted order and one pending one is on the
|
||||
// accepted rung, so the chip says `Accepted 1/2` — true, and silent about
|
||||
// the one still to decide. The two never overlap: this appears only when
|
||||
// some but not all of the counter is pending, which is precisely when the
|
||||
// chip is showing a different rung.
|
||||
// ── The selection count is not stated here ──
|
||||
//
|
||||
// It used to append `· 5 selected`, which made this the **third** place
|
||||
@@ -1253,6 +1286,10 @@ class PickupTimelineGroup extends StatelessWidget {
|
||||
final journey = rejected || group.isSettled ? '' : group.journeyLabel;
|
||||
final showJourney = journey.isNotEmpty && twoColumn;
|
||||
|
||||
// Suppressed on a place the rider has finished with and on one he declined:
|
||||
// the address is wayfinding, and there is nowhere left to go.
|
||||
final location = rejected || group.isSettled ? '' : group.location;
|
||||
|
||||
final load = [
|
||||
_loadLine(chosen),
|
||||
if (journey.isNotEmpty && !twoColumn) journey,
|
||||
@@ -1361,7 +1398,12 @@ class PickupTimelineGroup extends StatelessWidget {
|
||||
// rider needs them: where, how much, how far. Unaffected by
|
||||
// the two-column break — a reader hears the same three facts
|
||||
// either way.
|
||||
label: [group.name, load, if (showJourney) journey].join(', '),
|
||||
label: [
|
||||
group.name,
|
||||
if (location.isNotEmpty) location,
|
||||
load,
|
||||
if (showJourney) journey,
|
||||
].join(', '),
|
||||
// The non-flat hint said "Opens the pickup preview" — a
|
||||
// sentence about a tap this header stopped owning when the
|
||||
// preview moved to the utility circle. A wrong hint is worse
|
||||
@@ -1472,6 +1514,49 @@ class PickupTimelineGroup extends StatelessWidget {
|
||||
// that row was already spending on a `Spacer`. Same
|
||||
// reading order — identity, load, state — and the
|
||||
// geometry is now identical in every state.
|
||||
// ── Where the counter is, above what is on it ──
|
||||
//
|
||||
// Between the name and the load, because that is
|
||||
// the order the questions arrive in: which place,
|
||||
// where is it, how much is waiting. A pin rather
|
||||
// than a label — "Pickup location" would be a word
|
||||
// spent saying what the glyph and the position
|
||||
// already say.
|
||||
//
|
||||
// Two lines, matching the name above it: real
|
||||
// Coimbatore addresses are long, and an address cut
|
||||
// to `Gandhipuram Main…` answers nothing a blank
|
||||
// line would not have answered. Set in the page's
|
||||
// quiet grey — it is orientation, not a figure the
|
||||
// rider acts on.
|
||||
if (location.isNotEmpty) ...[
|
||||
SizedBox(height: 3.h),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 1.h),
|
||||
child: Icon(
|
||||
LucideIcons.mapPin,
|
||||
size: 12.sp,
|
||||
color: ColorConstants.secondaryText,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 5.w),
|
||||
Flexible(
|
||||
child: Text(
|
||||
location,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: MilerType.micro.copyWith(
|
||||
fontSize: 12.5.sp,
|
||||
color: ColorConstants.secondaryText,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
SizedBox(height: 4.h),
|
||||
Row(
|
||||
children: [
|
||||
@@ -1620,23 +1705,38 @@ class PickupTimelineGroup extends StatelessWidget {
|
||||
// silence was worse than the repetition: a rider scanning a run had to
|
||||
// infer "not mine yet" from the *absence* of a word, which is the one thing
|
||||
// a glance cannot do.
|
||||
// ── Pending is neutral, because nothing has gone wrong ──
|
||||
// ── Pending has its own amber, and it is not the warning one ──
|
||||
//
|
||||
// It was amber, on the argument that undecided work "wants something" and
|
||||
// should therefore be visible. The argument is right about visibility and
|
||||
// wrong about the colour: amber is this app's exception hue — a skipped
|
||||
// stop, a late run, a bag that never arrived — and on a fresh morning every
|
||||
// kitchen is pending, so the whole screen opened in warning.
|
||||
// It was grey, and before that it was [ColorConstants.warning]. Both were
|
||||
// half right. Grey said "nothing has gone wrong", which is true; warning
|
||||
// said "this wants something from you", which is also true — and warning
|
||||
// is the hue this app spends on a skip, a short payment, a bag that never
|
||||
// came, so a morning where every kitchen is pending opened the whole screen
|
||||
// in exception and taught the rider to stop reading the colour.
|
||||
//
|
||||
// A state that is true of *all* the work is not an exception; it is the
|
||||
// starting condition. Spending the attention colour on it leaves nothing
|
||||
// to say with when a stop genuinely does go wrong, and a rider who sees
|
||||
// amber on every group learns to stop reading it. Neutral, and the reason
|
||||
// it is still readable is that it sits on its own metadata line rather
|
||||
// than competing with the kitchen name.
|
||||
StopState.pending => const _StateWord(
|
||||
label: 'Pending',
|
||||
color: ColorConstants.secondaryText,
|
||||
// [ColorConstants.pendingAmber] is the third answer: a gold that reads as
|
||||
// *waiting* rather than as *wrong*, far enough from the burnt orange of
|
||||
// warning that Pending and Skipped are two distinct marks down a run. The
|
||||
// words differ too; this is what makes them differ at a glance.
|
||||
// ── And it says how many ──
|
||||
//
|
||||
// A bare `Pending` made the rider join two facts to get the one he wants.
|
||||
// The card said `Pending` and, twenty points above, `10 orders · 10 bags`;
|
||||
// *ten decisions are waiting on me* was the arithmetic between them, and on
|
||||
// a fresh morning — every group pending, nothing accepted — that was the
|
||||
// whole screen asking him to do it.
|
||||
//
|
||||
// Stated as a fraction because that is what Accepted has always done here,
|
||||
// and the two must read as one vocabulary: `Pending 10/10` on a counter
|
||||
// untouched, `Pending 2/4` on one half-taken, `Accepted 2/4` from the other
|
||||
// side of the same decision. The bare word survives where a fraction would
|
||||
// be noise — a flat parcel stop is one order, and `Pending 1/1` says
|
||||
// nothing `Pending` does not.
|
||||
StopState.pending => _StateWord(
|
||||
label: group.groupSize > 1
|
||||
? 'Pending ${group.pendingCount}/${group.groupSize}'
|
||||
: 'Pending',
|
||||
color: ColorConstants.pendingAmber,
|
||||
),
|
||||
// ── "Accepted" alone does not say how much of the counter he took ──
|
||||
//
|
||||
@@ -1649,19 +1749,25 @@ class PickupTimelineGroup extends StatelessWidget {
|
||||
// Only where there is more than one order to be a fraction of — a parcel
|
||||
// route is one order per place, and `Accepted (1 of 1)` is noise.
|
||||
StopState.accepted => _StateWord(
|
||||
// Brand only on the group he is riding to; slate on the ones he has taken
|
||||
// but is not working. Red marks the mission, not the commitment — and it
|
||||
// is now the only thing on the card that can be confused with amber, so
|
||||
// the two states read apart at a glance.
|
||||
// ── Accepted is green ──
|
||||
//
|
||||
// It was brand red on the group being ridden to and slate on the rest,
|
||||
// which made the word carry two facts at once — *taken on* and *this is
|
||||
// the one you are working* — and the second was the louder of them. A
|
||||
// rider glancing down a run read the red as the state.
|
||||
//
|
||||
// Green says the one thing the word is for: this is yours, it is settled,
|
||||
// nothing here needs deciding. Which one he is *on* is already said three
|
||||
// ways that do not need a hue — the lit node, the larger title, and the
|
||||
// Navigate button that only the current place carries.
|
||||
//
|
||||
// `Accepted (2 of 4)` truncated to `Accepted (2 o…` the moment the row
|
||||
// also carried Navigate, Call and the "i" — which is every accepted
|
||||
// group. A state that ellipsises has lost the number it exists to state.
|
||||
label: group.groupSize > 1
|
||||
? 'Accepted ${group.onRungCount}/${group.groupSize}'
|
||||
: 'Accepted',
|
||||
color: isCurrent
|
||||
? ColorConstants.primary
|
||||
: ColorConstants.onSurfaceVariant,
|
||||
color: ColorConstants.acceptGreen,
|
||||
),
|
||||
// ── The one the rider must not miss ──
|
||||
//
|
||||
@@ -1674,9 +1780,19 @@ class PickupTimelineGroup extends StatelessWidget {
|
||||
// fact is *this is the one he is on* — and each says which rung it is.
|
||||
StopState.arrived => const LiveMark(label: 'Arrived'),
|
||||
StopState.active => const LiveMark(),
|
||||
// ── Picked is blue, now that green means accepted ──
|
||||
//
|
||||
// It was green, back when green was free. It cannot stay green beside an
|
||||
// accepted state that is also green: the two rungs a rider most needs to
|
||||
// tell apart on one counter — *taken on* and *in my hands* — would be one
|
||||
// colour and two words.
|
||||
//
|
||||
// Blue is not an arbitrary third: it is [ColorConstants.deliveryAccent],
|
||||
// what this app already paints the delivery leg in, and a picked bag has
|
||||
// become exactly that. The colour says where the work went.
|
||||
StopState.collected || StopState.done => const _StateWord(
|
||||
label: 'Collected',
|
||||
color: ColorConstants.acceptGreen,
|
||||
color: ColorConstants.pickedBlue,
|
||||
),
|
||||
// ── The two ends of "not going to happen here" ──
|
||||
//
|
||||
@@ -1715,7 +1831,7 @@ class PickupTimelineGroup extends StatelessWidget {
|
||||
|
||||
// ── The body: the journey, the selection helper, then the milestones ─────
|
||||
|
||||
Widget _body(BuildContext context, List<BagLine> manifest, int chosen) {
|
||||
Widget _body(BuildContext context, List<ManifestLine> manifest, int chosen) {
|
||||
final ids = _choosableIds;
|
||||
final selectable = _isChoosable(group.state) && onToggleSelect != null;
|
||||
|
||||
@@ -1864,7 +1980,7 @@ class PickupTimelineGroup extends StatelessWidget {
|
||||
/// bag* — the two facts he matches against a shelf — and everything else is one
|
||||
/// tap away in the sheet that already exists.
|
||||
class PickupTimelineStep extends StatelessWidget {
|
||||
final BagLine line;
|
||||
final ManifestLine line;
|
||||
|
||||
/// 1-based position **within its own pickup group**, which is also its bag
|
||||
/// position when the kitchen printed no label. Never route-global.
|
||||
@@ -2013,9 +2129,7 @@ class PickupTimelineStep extends StatelessWidget {
|
||||
child: Semantics(
|
||||
button: true,
|
||||
checked: selectable ? selected : null,
|
||||
label: selectable
|
||||
? 'Select $customer, ${line.bag}'
|
||||
: '$customer, ${line.bag}',
|
||||
label: selectable ? 'Select $customer' : customer,
|
||||
excludeSemantics: true,
|
||||
child: InkWell(
|
||||
onTap: selectable && !busy
|
||||
@@ -2070,7 +2184,11 @@ class PickupTimelineStep extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
_OrderOpenBlock(
|
||||
bag: rejected ? 'Rejected' : line.bag,
|
||||
// The counter's own printed label when there is one,
|
||||
// and the word `Rejected` where the row has been
|
||||
// declined. Never an invented number — see
|
||||
// [OrderManifest.labelFor].
|
||||
label: rejected ? 'Rejected' : line.label,
|
||||
orderId: line.orderId,
|
||||
selected: selected,
|
||||
onTap: canOpen ? onOpen : null,
|
||||
@@ -2087,23 +2205,34 @@ class PickupTimelineStep extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Bag 1 ›` over the order's own id — the step's way into its detail sheet.
|
||||
/// The step's way into its detail sheet: the counter's printed label, when
|
||||
/// there is one, and the chevron that opens the order.
|
||||
///
|
||||
/// One block rather than three loose elements, because they answer one
|
||||
/// question between them: *which order is this, and show me it*. The chevron is
|
||||
/// the affordance; the bag and the id are what it is about.
|
||||
/// ── It used to manufacture a reference ──
|
||||
///
|
||||
/// The tag read `Bag 1 … Bag n`, falling back to the order's position in its
|
||||
/// group whenever the payload printed nothing — so on the common route, where
|
||||
/// no counter prints labels, every row carried a shelf reference the shelf had
|
||||
/// never heard of. It was the app numbering its own list and drawing the result
|
||||
/// as a physical fact, two columns from a rail that numbers the same rows.
|
||||
///
|
||||
/// What the counter really printed is a fact about an object in the rider's
|
||||
/// hands, so that still shows, verbatim. Where nothing was printed the corner
|
||||
/// carries the chevron alone, which is the affordance the block existed for.
|
||||
/// The neighbourhood a stop is in. One implementation, shared with the
|
||||
/// Deliveries queue — see [areaOf].
|
||||
String _areaOf(Map<String, dynamic> stop) => areaOf(stop);
|
||||
|
||||
class _OrderOpenBlock extends StatelessWidget {
|
||||
final String bag;
|
||||
/// The counter's own printed label, or `''` when it printed none.
|
||||
final String label;
|
||||
|
||||
final String orderId;
|
||||
final bool selected;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _OrderOpenBlock({
|
||||
required this.bag,
|
||||
required this.label,
|
||||
required this.orderId,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
@@ -2115,7 +2244,10 @@ class _OrderOpenBlock extends StatelessWidget {
|
||||
|
||||
return Semantics(
|
||||
button: onTap != null,
|
||||
label: id.isEmpty ? bag : '$bag, order $id',
|
||||
label: [
|
||||
if (label.isNotEmpty) label,
|
||||
if (id.isNotEmpty) 'order $id',
|
||||
].join(', '),
|
||||
hint: onTap == null ? null : 'Opens order details',
|
||||
excludeSemantics: true,
|
||||
child: InkWell(
|
||||
@@ -2128,99 +2260,73 @@ class _OrderOpenBlock extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
// Capped so a kitchen's own long printed label (`DG-100042-A`)
|
||||
// cannot take the customer's name with it.
|
||||
//
|
||||
// ── And the column stops growing before the name does ──
|
||||
//
|
||||
// `Bag 1` is a shelf reference: it is *compared*, not read as
|
||||
// prose, so it does not need to keep pace with a rider's text
|
||||
// size. Left to scale it took ~150pt of a 320pt row at 2×, and
|
||||
// the thing that yielded was the customer's name — which is
|
||||
// the one item on the row the brief says must yield last.
|
||||
//
|
||||
// Clamped at 1.25×, which keeps it comfortably legible against
|
||||
// a physical shelf while handing the rest of the row back to
|
||||
// the name and the area.
|
||||
constraints: BoxConstraints(maxWidth: 104.w),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// ── One order, one bag ──
|
||||
//
|
||||
// Read straight off [BagManifest]: never a quantity, never a
|
||||
// route-global number. Right-aligned on every row so a
|
||||
// column of them lines up against a shelf being counted.
|
||||
if (label.isNotEmpty)
|
||||
ConstrainedBox(
|
||||
// Capped so a kitchen's own long printed label
|
||||
// (`DG-100042-A`) cannot take the customer's name with it.
|
||||
//
|
||||
// ── And the column stops growing before the name does ──
|
||||
//
|
||||
// A printed label is *compared*, not read as prose, so it
|
||||
// does not need to keep pace with a rider's text size. Left
|
||||
// to scale it took ~150pt of a 320pt row at 2×, and the
|
||||
// thing that yielded was the customer's name — the one item
|
||||
// on the row the brief says must yield last.
|
||||
//
|
||||
// Clamped at 1.25×, which keeps it legible against a
|
||||
// physical shelf while handing the rest of the row back to
|
||||
// the name and the area.
|
||||
constraints: BoxConstraints(maxWidth: 104.w),
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 7.w,
|
||||
vertical: 3.h,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
// White, because these rows live inside the canvas
|
||||
// well: layer-1-on-canvas, the same 1.29 step the rest
|
||||
// of the system stands on. (Canvas-on-canvas was
|
||||
// invisible the moment the well returned.)
|
||||
color: selected
|
||||
? ColorConstants.primary.withValues(alpha: 0.10)
|
||||
: MilerSurface.working,
|
||||
borderRadius: BorderRadius.circular(
|
||||
DesignConstants.radiusLg,
|
||||
),
|
||||
),
|
||||
// ── It ranks under the name, not over it ──
|
||||
//
|
||||
// Drawn at w700 in full slate this sat *above* the
|
||||
// customer, who is w500 in the mid-tone until he is
|
||||
// ticked. So on a fresh route — every row undecided,
|
||||
// which is how the screen opens — the boldest thing on
|
||||
// every line was a shelf number, and the eye read a
|
||||
// column of bags before it read a single person.
|
||||
//
|
||||
// The reference still needs to be scannable against a
|
||||
// physical shelf, so it keeps a weight of its own: it
|
||||
// steps to w600 in the mid-tone, one rung under the
|
||||
// undecided name, and follows the row up to slate the
|
||||
// moment the row is taken. The bag never leads; it
|
||||
// travels with whatever the row currently is.
|
||||
// ── And it is a tag, because it is a label ──
|
||||
//
|
||||
// A bag number is a physical reference — the sticker on
|
||||
// the shelf — not a sentence, and every delivery app the
|
||||
// rider carries sets that kind of fact as a small tag.
|
||||
// The fill is the page's canvas tone, the only grey that
|
||||
// measurably stands on this white panel (1.29:1), so the
|
||||
// tag reads as an object without borrowing a border. It
|
||||
// still ranks under the name: quiet ink at rest, brand
|
||||
// tint the moment the row is ticked.
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 7.w,
|
||||
vertical: 3.h,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
// White, because these rows live inside the canvas
|
||||
// well: layer-1-on-canvas, the same 1.29 step the
|
||||
// rest of the system stands on. (Canvas-on-canvas
|
||||
// was invisible the moment the well returned.)
|
||||
// ticked — so on a fresh route the boldest thing on every
|
||||
// line was a shelf reference, and the eye read a column of
|
||||
// them before it read a single person. It steps to w600 in
|
||||
// the mid-tone instead, one rung under the undecided name,
|
||||
// and follows the row up to slate the moment it is taken.
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: MilerType.label.copyWith(
|
||||
fontSize: 12.sp,
|
||||
fontWeight: selected
|
||||
? FontWeight.w700
|
||||
: FontWeight.w600,
|
||||
color: selected
|
||||
? ColorConstants.primary.withValues(alpha: 0.10)
|
||||
: MilerSurface.working,
|
||||
borderRadius: BorderRadius.circular(
|
||||
DesignConstants.radiusLg,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
bag,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: MilerType.label.copyWith(
|
||||
fontSize: 12.sp,
|
||||
fontWeight: selected
|
||||
? FontWeight.w700
|
||||
: FontWeight.w600,
|
||||
color: selected
|
||||
? ColorConstants.primary
|
||||
: ColorConstants.secondaryText,
|
||||
),
|
||||
? ColorConstants.primary
|
||||
: ColorConstants.secondaryText,
|
||||
),
|
||||
),
|
||||
// ── The reference is not on the row any more ──
|
||||
//
|
||||
// It is what the office reads down a phone once, when
|
||||
// something has gone wrong. The line it occupied now
|
||||
// carries the drop area, which is what the rider is
|
||||
// deciding on every time he looks at the list. The id is on
|
||||
// the detail sheet this block opens, and on the Activity
|
||||
// record — both places the rare question is actually asked.
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// ── The order id is not on the row ──
|
||||
//
|
||||
// It is what the office reads down a phone once, when something
|
||||
// has gone wrong. The line it occupied carries the drop area,
|
||||
// which is what the rider is deciding on every time he looks at
|
||||
// the list. The id is on the detail sheet this block opens, and
|
||||
// on the Activity record — both places the rare question is
|
||||
// actually asked.
|
||||
if (onTap != null)
|
||||
Icon(
|
||||
LucideIcons.chevronRight,
|
||||
@@ -2629,7 +2735,7 @@ class _StepNode extends StatelessWidget {
|
||||
//
|
||||
// It keeps its **number**. The numeral is the order's bag identity, matched
|
||||
// against a printed label on a shelf, and it must not change or disappear
|
||||
// because the rider accepted it — see [BagManifest]. So accepted is a
|
||||
// because the rider accepted it — see [OrderManifest]. So accepted is a
|
||||
// filled disc with a white numeral, not a tick: a tick would claim work
|
||||
// that has not happened.
|
||||
final filled = selected || done || accepted;
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:miler/Models/stop_status.dart';
|
||||
import 'package:miler/views/helpers/constants/miler_surface.dart';
|
||||
import 'package:miler/data/bag_manifest.dart';
|
||||
import 'package:miler/data/order_manifest.dart';
|
||||
import 'package:miler/data/service_profile.dart';
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
import 'package:miler/views/helpers/constants/design_constants.dart';
|
||||
@@ -94,7 +94,7 @@ class StopActionSheet extends StatefulWidget {
|
||||
///
|
||||
/// Empty renders the sheet as it was — a heading, a count and the slide —
|
||||
/// which is correct for a rung that covers a single anonymous stop.
|
||||
final List<BagLine> lines;
|
||||
final List<ManifestLine> lines;
|
||||
|
||||
/// Runs the write, given what the rider actually confirmed. Returns null on
|
||||
/// success, or a message to show without closing — the sheet stays open so he
|
||||
@@ -117,7 +117,7 @@ class StopActionSheet extends StatefulWidget {
|
||||
required String placeName,
|
||||
required int count,
|
||||
required Future<String?> Function(RungOutcome) onConfirm,
|
||||
List<BagLine> lines = const [],
|
||||
List<ManifestLine> lines = const [],
|
||||
}) async {
|
||||
// Through the kit — which also gives this sheet the app's entrance curve.
|
||||
// It was the one sheet still on Flutter's default 250ms decelerate: the
|
||||
@@ -188,11 +188,11 @@ class _StopActionSheetState extends State<StopActionSheet> {
|
||||
}
|
||||
|
||||
/// `5 orders · 5 bags` — the rule, stated where he can check it against the
|
||||
/// shelf. See [BagManifest]. Once he reports a shortfall it counts what he is
|
||||
/// shelf. See [OrderManifest]. Once he reports a shortfall it counts what he is
|
||||
/// actually leaving with, and names the gap rather than quietly shrinking.
|
||||
String get _subtitle => _missing.isEmpty
|
||||
? BagManifest.countLabel(widget.count)
|
||||
: '${BagManifest.countLabel(_loaded)} · ${_missing.length} missing';
|
||||
? OrderManifest.countLabel(widget.count)
|
||||
: '${OrderManifest.countLabel(_loaded)} · ${_missing.length} missing';
|
||||
|
||||
/// What the slide will actually change, in the rider's words. "Slide to
|
||||
/// update" said nothing a rider could be wrong about; these name the claim —
|
||||
@@ -223,7 +223,7 @@ class _StopActionSheetState extends State<StopActionSheet> {
|
||||
}
|
||||
if (_missing.isEmpty) {
|
||||
return (
|
||||
'Make sure all ${widget.count} bags are loaded before continuing.',
|
||||
'Make sure all ${widget.count} orders are loaded before continuing.',
|
||||
false,
|
||||
);
|
||||
}
|
||||
@@ -426,7 +426,7 @@ class _StopActionSheetState extends State<StopActionSheet> {
|
||||
color: ColorConstants.errorRed,
|
||||
),
|
||||
label: Text(
|
||||
'Report missing bag',
|
||||
'Report a missing order',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
@@ -443,7 +443,7 @@ class _StopActionSheetState extends State<StopActionSheet> {
|
||||
Expanded(
|
||||
child: Text(
|
||||
_missing.isEmpty
|
||||
? 'Tap any bag the counter could not supply'
|
||||
? 'Tap any order the counter could not supply'
|
||||
: '${_missing.length} marked missing',
|
||||
style: TextStyle(
|
||||
fontSize: 13.5.sp,
|
||||
@@ -588,9 +588,16 @@ class _StopActionSheetState extends State<StopActionSheet> {
|
||||
SizedBox(width: 10.w),
|
||||
// The same tag the route rows wear — one language for one
|
||||
// fact, from Home to the sheet it opens. White stands on the
|
||||
// manifest's tinted panel; a missing bag keeps the error ink
|
||||
// manifest's tinted panel; a missing order keeps the error ink
|
||||
// in the same body so the exception is the colour, not the
|
||||
// shape.
|
||||
//
|
||||
// Drawn whenever there is something to say: the counter's own
|
||||
// printed label, or `Missing` once the rider has reported a
|
||||
// shortfall. A route whose counters print nothing shows the tag
|
||||
// only on the exception, which is the one time it carries a
|
||||
// fact the row does not already state.
|
||||
if (gone || line.label.isNotEmpty)
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 7.w, vertical: 3.h),
|
||||
decoration: BoxDecoration(
|
||||
@@ -602,7 +609,7 @@ class _StopActionSheetState extends State<StopActionSheet> {
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
gone ? 'Missing' : line.bag,
|
||||
gone ? 'Missing' : line.label,
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5.sp,
|
||||
|
||||
@@ -66,10 +66,10 @@ class StopCard extends StatelessWidget {
|
||||
/// Time at the door, shown in the card's header.
|
||||
final Duration service;
|
||||
|
||||
/// Which bag this order travels in — `Bag 3`, or the label the kitchen
|
||||
/// printed. One order, one bag; the count is never a separate figure. See
|
||||
/// [BagManifest].
|
||||
final String bag;
|
||||
/// Which label this order travels in — `Bag 3`, or the label the kitchen
|
||||
/// printed. One order, one label; the count is never a separate figure. See
|
||||
/// [OrderManifest].
|
||||
final String label;
|
||||
|
||||
/// True when this card sits under a heading that already names its pickup
|
||||
/// location.
|
||||
@@ -122,7 +122,7 @@ class StopCard extends StatelessWidget {
|
||||
required this.state,
|
||||
required this.address,
|
||||
required this.service,
|
||||
this.bag = '',
|
||||
this.label = '',
|
||||
this.groupedUnderSource = false,
|
||||
this.selected = false,
|
||||
this.busy = false,
|
||||
@@ -256,7 +256,7 @@ class StopCard extends StatelessWidget {
|
||||
SizedBox(height: 12.h),
|
||||
_factsRow(
|
||||
kind,
|
||||
bagShownAbove: twoLeg && !grouped && bag.isNotEmpty,
|
||||
labelShownAbove: twoLeg && !grouped && label.isNotEmpty,
|
||||
),
|
||||
if (action != null) ...[SizedBox(height: 16.h), action],
|
||||
],
|
||||
@@ -318,7 +318,7 @@ class StopCard extends StatelessWidget {
|
||||
/// `→ Joe · Bag 1` — the far end, in one line.
|
||||
///
|
||||
/// Everything the second leg used to spend a whole block on: who it is for,
|
||||
/// and which bag it is. The address is one tap away in the detail sheet,
|
||||
/// and which label it is. The address is one tap away in the detail sheet,
|
||||
/// where a rider going to a *kitchen* has no use for it yet.
|
||||
Widget _dropLine(String customer) {
|
||||
return Row(
|
||||
@@ -343,7 +343,10 @@ class StopCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (bag.isNotEmpty) ...[SizedBox(width: 10.w), _BagChip(label: bag)],
|
||||
if (label.isNotEmpty) ...[
|
||||
SizedBox(width: 10.w),
|
||||
_LabelChip(label: label),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -352,7 +355,7 @@ class StopCard extends StatelessWidget {
|
||||
///
|
||||
/// This is the line a rider re-reads at a red light, so nothing on it is
|
||||
/// under 14sp and none of it is in a box.
|
||||
Widget _factsRow(StopKind kind, {required bool bagShownAbove}) {
|
||||
Widget _factsRow(StopKind kind, {required bool labelShownAbove}) {
|
||||
final due = DateTime.tryParse(
|
||||
(stop['expected_pickup_time'] ?? stop['eta'] ?? '').toString().trim(),
|
||||
);
|
||||
@@ -393,33 +396,33 @@ class StopCard extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
// What is in it, and which bag it is. The bag appears here whenever the
|
||||
// drop line above is not already carrying it — a card must never be
|
||||
// silent about which box this order is, and it must never say it twice.
|
||||
// What is in it, and the counter's own label where there is one. It
|
||||
// appears here whenever the drop line above is not already carrying it:
|
||||
// a card must never say the same thing twice.
|
||||
Builder(
|
||||
builder: (_) {
|
||||
final label = bag.isNotEmpty ? bag : stopBagLabel(stop);
|
||||
final printed = label.isNotEmpty ? label : stopPrintedLabel(stop);
|
||||
final meta = _MetaLine(
|
||||
deliver: kind.hasDelivery ? _qty(deliveryParcelCount(stop)) : 0,
|
||||
collect: kind.hasPickup ? _qty(pickupParcelCount(stop)) : 0,
|
||||
cash: cash,
|
||||
muted: _rejected || state == StopState.done,
|
||||
// On a meal run the parcel counts are the one-bag rule restated
|
||||
// On a meal run the parcel counts are the one-label rule restated
|
||||
// as an item count, which is exactly the second number this app
|
||||
// does not allow. Only the money survives.
|
||||
countsHidden: ServiceProfile.active.deliversToCustomer,
|
||||
);
|
||||
final showBag = !bagShownAbove && label.isNotEmpty;
|
||||
if (!showBag && meta.isEmpty) return const SizedBox.shrink();
|
||||
final showLabel = !labelShownAbove && printed.isNotEmpty;
|
||||
if (!showLabel && meta.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: 8.h),
|
||||
child: Row(
|
||||
children: [
|
||||
if (!meta.isEmpty) Flexible(child: meta),
|
||||
if (showBag) ...[
|
||||
if (showLabel) ...[
|
||||
if (!meta.isEmpty) SizedBox(width: 10.w),
|
||||
_BagChip(label: label),
|
||||
_LabelChip(label: printed),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -441,7 +444,7 @@ class StopCard extends StatelessWidget {
|
||||
/// journey and the bar offers the claim, and neither duplicates the other.
|
||||
///
|
||||
/// A stop that is already *arrived* is the one exception: the single-order
|
||||
/// case ("one bag, one counter") would otherwise make him tick a card he is
|
||||
/// case ("one label, one counter") would otherwise make him tick a card he is
|
||||
/// looking at to reach a control that talks about "the selection". That
|
||||
/// button opens the same slide sheet the bar does, over the same batch.
|
||||
Widget? _primaryAction() {
|
||||
@@ -598,16 +601,19 @@ class _IconAction extends StatelessWidget {
|
||||
/// the card has exactly one colour to spend. It is a chip rather than plain
|
||||
/// text because it is read *against an object* — a bordered word is findable on
|
||||
/// a card at arm's length in a way a run of grey text is not.
|
||||
class _BagChip extends StatelessWidget {
|
||||
class _LabelChip extends StatelessWidget {
|
||||
final String label;
|
||||
const _BagChip({required this.label});
|
||||
const _LabelChip({required this.label});
|
||||
|
||||
/// Backend labels are codes — `DG-1042`, `BAG-1` — and derived ones already
|
||||
/// read `Bag 3`. The word belongs on both, and the test for "already has it"
|
||||
/// is the *word*, not the prefix: `BAG-1` starts with those three letters and
|
||||
/// is still a code, so it becomes `Bag BAG-1` exactly as it always read.
|
||||
String get _text =>
|
||||
label.toLowerCase().startsWith('bag ') ? label : 'Bag $label';
|
||||
/// ── The word is gone; the code is not ──
|
||||
///
|
||||
/// This prefixed everything with `Bag ` — so a counter's own code rendered
|
||||
/// as `Bag DG-1042`, and a number the app had derived rendered as `Bag 3`
|
||||
/// and read like something stencilled on an object nobody had stencilled.
|
||||
///
|
||||
/// What a counter prints is printed on it already. It is shown exactly as it
|
||||
/// was sent, and nothing is shown at all where nothing was sent.
|
||||
String get _text => label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@@ -51,10 +51,10 @@ class StopDetailSheet extends StatelessWidget {
|
||||
final double? riderLat;
|
||||
final double? riderLng;
|
||||
|
||||
/// The bag this order travels in, as the caller's store recorded it at the
|
||||
/// The label this order travels in, as the caller's store recorded it at the
|
||||
/// counter. Empty falls back to the payload's own `baglabel`, and a line
|
||||
/// with neither simply has no Bag row — nothing is invented.
|
||||
final String bag;
|
||||
final String label;
|
||||
|
||||
/// Orders the rider is carrying — what turns a raw `pickup` row into the
|
||||
/// delivery leg. Empty (Home's caller) leaves the raw kind in charge.
|
||||
@@ -91,7 +91,7 @@ class StopDetailSheet extends StatelessWidget {
|
||||
this.stopCount = 0,
|
||||
this.riderLat,
|
||||
this.riderLng,
|
||||
this.bag = '',
|
||||
this.label = '',
|
||||
this.collectedIds = const {},
|
||||
this.ofOrder = false,
|
||||
});
|
||||
@@ -102,7 +102,7 @@ class StopDetailSheet extends StatelessWidget {
|
||||
required Map<String, dynamic> stop,
|
||||
required int stopNumber,
|
||||
int stopCount = 0,
|
||||
String bag = '',
|
||||
String label = '',
|
||||
Set<String> collectedIds = const {},
|
||||
double? riderLat,
|
||||
double? riderLng,
|
||||
@@ -119,7 +119,7 @@ class StopDetailSheet extends StatelessWidget {
|
||||
stop: stop,
|
||||
stopNumber: stopNumber,
|
||||
stopCount: stopCount,
|
||||
bag: bag,
|
||||
label: label,
|
||||
collectedIds: collectedIds,
|
||||
riderLat: riderLat,
|
||||
riderLng: riderLng,
|
||||
@@ -721,15 +721,18 @@ class StopDetailSheet extends StatelessWidget {
|
||||
|
||||
Widget _detailGrid() {
|
||||
final rows = <MapEntry<String, String>>[
|
||||
// Whose counter the bag came off — the fact the delivery card carries
|
||||
// Whose counter the label came off — the fact the delivery card carries
|
||||
// ("PICKED UP FROM …") and this sheet used to drop. Only on the drop
|
||||
// leg: on a pickup the header IS the source context. One reader,
|
||||
// [stopSourceName], as the project rule demands.
|
||||
MapEntry('From', _isDropLeg ? stopSourceName(stop) : ''),
|
||||
// The bag is what a rider matches against a shelf, so it outranks the
|
||||
// reference. Stored pairing first ([bag], written at the counter), the
|
||||
// The label is what a rider matches against a shelf, so it outranks the
|
||||
// reference. Stored pairing first ([label], written at the counter), the
|
||||
// kitchen-printed label as fallback, absent on a line with no load.
|
||||
MapEntry('Bag', bag.isNotEmpty ? bag : _s(['baglabel', 'BagLabel'])),
|
||||
MapEntry(
|
||||
'Label',
|
||||
label.isNotEmpty ? label : _s(['baglabel', 'BagLabel']),
|
||||
),
|
||||
MapEntry('Order', _s(['orderid', 'OrderId'])),
|
||||
MapEntry('Product', _s(['description', 'productname', 'product'])),
|
||||
MapEntry('Weight', () {
|
||||
|
||||
@@ -254,8 +254,17 @@ class Trip {
|
||||
///
|
||||
/// Returns `this` unchanged when nothing has been collected yet, so the
|
||||
/// common case allocates nothing.
|
||||
/// ── Declined work is not outstanding work ──
|
||||
///
|
||||
/// [rejectedIds] is the set the rider's own decision writes, and it has to be
|
||||
/// passed in because a decline is **local first**: the stop stays in the
|
||||
/// payload reading `assigned` until the backend catches up, so
|
||||
/// `stopStatusOf` cannot see it and the figures counted a stop the rider had
|
||||
/// refused. He declined four of eight and the card still measured the
|
||||
/// kilometres to all eight.
|
||||
Trip outstanding({
|
||||
Set<String> collectedIds = const <String>{},
|
||||
Set<String> rejectedIds = const <String>{},
|
||||
double? originLat,
|
||||
double? originLng,
|
||||
}) {
|
||||
@@ -263,7 +272,9 @@ class Trip {
|
||||
for (final s in stops)
|
||||
if (!WorkBoundary.pickupComplete(s, collectedIds: collectedIds) &&
|
||||
!stopStatusOf(s).isCancelled &&
|
||||
!stopStatusOf(s).isSkipped)
|
||||
!stopStatusOf(s).isSkipped &&
|
||||
!stopStatusOf(s).isRejected &&
|
||||
!rejectedIds.contains((s['orderid'] ?? '').toString()))
|
||||
s,
|
||||
];
|
||||
if (left.length == stops.length) return this;
|
||||
@@ -300,6 +311,35 @@ class Trip {
|
||||
'${RouteMetricsHelper.formatClock(e)}';
|
||||
}
|
||||
|
||||
/// The slot as a **clock range**, or `''` when it has none.
|
||||
///
|
||||
/// ── Why a day part is not printed at clock position ──
|
||||
///
|
||||
/// [slotLabel] answers "what is this trip called" and names a whole day part
|
||||
/// as *Morning* / *Afternoon* / *Evening*, which is right for a heading.
|
||||
/// It is wrong wherever the app is printing a live figure.
|
||||
///
|
||||
/// Two things happen when it lands there. The word is a **label pretending to
|
||||
/// be data**: it sits where "3h 10m left" sits, in the corner a rider checks
|
||||
/// for how much time he has, and answers with a noun he cannot act on. And it
|
||||
/// is redundant twice over — the trip is already numbered `Trip 1`, and the
|
||||
/// number *is* the day part, so `Trip 1 · Morning` says one thing twice.
|
||||
///
|
||||
/// This became visible when trips started being placed by day part at all:
|
||||
/// before that they carried no window, [slotLabel] fell through to "Today's
|
||||
/// route", and the same call sites printed nothing. So the fix is not to undo
|
||||
/// the placement — it is to stop reading a heading as a measurement.
|
||||
///
|
||||
/// Returns a range only when the hub sent a real one: `10:00 AM – 1:00 PM`.
|
||||
String get slotClockLabel {
|
||||
final s = slotStart;
|
||||
final e = slotEnd;
|
||||
if (s == null || e == null) return '';
|
||||
if (DayPart.spans(s, e)) return '';
|
||||
return '${RouteMetricsHelper.formatClock(s)} – '
|
||||
'${RouteMetricsHelper.formatClock(e)}';
|
||||
}
|
||||
|
||||
/// True when the slot window has already closed.
|
||||
bool isExpired([DateTime? now]) {
|
||||
final e = slotEnd;
|
||||
@@ -410,15 +450,47 @@ class Trip {
|
||||
DateTime? get _earliestDue {
|
||||
DateTime? best;
|
||||
for (final s in stops) {
|
||||
final due = _parseTimestamp(
|
||||
s['expected_pickup_time'] ?? s['expectedpickuptime'] ?? s['eta'],
|
||||
);
|
||||
final due = dayPartTimeOf(s);
|
||||
if (due == null) continue;
|
||||
if (best == null || due.isBefore(best)) best = due;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/// The clock that decides which part of the day a stop belongs to.
|
||||
///
|
||||
/// ── Due time first, assignment time second ──
|
||||
///
|
||||
/// The right answer is when the work is *due*: an order to be collected at
|
||||
/// 10am is morning work whenever the hub happened to raise it. That is
|
||||
/// `expected_pickup_time`, and where the backend sends it nothing here
|
||||
/// changes.
|
||||
///
|
||||
/// Where it does not — which is the live Doormile contract today — the day
|
||||
/// part had nothing to read at all, and every stop fell into the undated
|
||||
/// `day:` bucket. One trip, no day part, and [TripSlots.slots] put it in the
|
||||
/// first free slot: **everything was Trip 1, all day**. A rider given an
|
||||
/// evening run opened Home to find it filed under the morning.
|
||||
///
|
||||
/// So the fallback is when the work reached him — `assignedat`, merged onto
|
||||
/// the row by [WorkRepository] from the assignment ledger. It is the hub's
|
||||
/// own act as the phone observed it, and it is what dispatch means on the
|
||||
/// telephone: work handed over in the morning is the morning run. Approximate
|
||||
/// where a due time would be exact, and exactly right for the common case,
|
||||
/// which is a slot assigned at the top of the slot it is for.
|
||||
///
|
||||
/// `eta` is read last and usually answers nothing — on the v1 adapter it is
|
||||
/// a duration, not a clock, and [_parseTimestamp] rejects it rather than
|
||||
/// inventing a 15th of the month out of "15 min".
|
||||
static DateTime? dayPartTimeOf(Map<String, dynamic> stop) =>
|
||||
_parseTimestamp(
|
||||
stop['expected_pickup_time'] ??
|
||||
stop['expectedpickuptime'] ??
|
||||
stop['assignedat'] ??
|
||||
stop['assignedon'] ??
|
||||
stop['eta'],
|
||||
);
|
||||
|
||||
/// Address components every stop on this trip shares — city, state,
|
||||
/// pincode and so on. Cached per build by the caller.
|
||||
List<String> get sharedAddressTail => commonAddressTail([
|
||||
@@ -663,11 +735,11 @@ class Trip {
|
||||
from = slotFrom;
|
||||
to = slotTo;
|
||||
} else {
|
||||
final due = _parseTimestamp(
|
||||
stop['expected_pickup_time'] ??
|
||||
stop['expectedpickuptime'] ??
|
||||
stop['eta'],
|
||||
);
|
||||
// Due time where the hub sends one, and the moment the work reached
|
||||
// the rider where it does not — see [Trip.dayPartTimeOf]. Without the
|
||||
// second half every stop landed in the undated bucket below and the
|
||||
// whole day rendered as Trip 1.
|
||||
final due = dayPartTimeOf(stop);
|
||||
if (due != null) {
|
||||
// Morning, afternoon or evening — see [DayPart]. Not a rolling
|
||||
// window: two stops due an hour apart are on the same run, and the
|
||||
|
||||
@@ -418,65 +418,30 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
||||
// matching the one above the bar, so the bar sits evenly between
|
||||
// the two things it separates.
|
||||
SizedBox(height: 8.h),
|
||||
Row(
|
||||
children: [
|
||||
// ── The journey leads the line now ──
|
||||
//
|
||||
// It used to be the *fallback* — shown only when there was no
|
||||
// shift and no slot, because the clock had the row. With the
|
||||
// ratio gone there is space for both, and how long and how far
|
||||
// is the pair a rider is actually planning against. It yields
|
||||
// while the grid below is open so nothing prints twice.
|
||||
Flexible(
|
||||
child: _open ? const SizedBox.shrink() : _journeyGlance(),
|
||||
),
|
||||
const Spacer(),
|
||||
// The clock keeps its own colour rules — the one line here
|
||||
// allowed to carry bad news; see [_timeLeftLine]. It prints
|
||||
// nothing at all when the hub has set neither a shift nor a
|
||||
// slot, which is when the journey has the row to itself.
|
||||
Flexible(child: _timeLeftLine()),
|
||||
],
|
||||
),
|
||||
// ── The journey glance is gone from the collapsed head ──
|
||||
//
|
||||
// It printed `≈50 min · 2.4 km` under the bar, and it was the third
|
||||
// place the same estimate appeared: the grid one tap below states
|
||||
// Duration and Distance as two of its four figures, and every group
|
||||
// on the route under it carries its own leg in the right-hand
|
||||
// column the rider scans down.
|
||||
//
|
||||
// Collapsed is a *summary* state, and what a summary is for is the
|
||||
// one fact the card is answering with — how much is left, and the
|
||||
// bar that measures it. An estimate repeated at 12.5pt beside it
|
||||
// added a number to read without adding anything to know, and it is
|
||||
// the number a rider trusts least: a straight-line distance at an
|
||||
// average speed, printed where it reads like a promise.
|
||||
//
|
||||
// Nothing is lost. Open the card and both figures are there, stated
|
||||
// properly, with the parcels and the cash beside them.
|
||||
Row(children: [const Spacer(), Flexible(child: _timeLeftLine())]),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The collapsed panel's journey, in one quiet line: `≈4h · 38.9 km`.
|
||||
///
|
||||
/// Only the figures that exist — a route with no coordinates prints no
|
||||
/// kilometres rather than a zero — and never a colour: this is context, and
|
||||
/// the headline beside it is the thing being contextualised.
|
||||
Widget _journeyGlance() {
|
||||
final duration = roundTripDuration(_trip.totalDuration);
|
||||
final parts = <String>[
|
||||
if (duration > Duration.zero) '\u2248${formatTripDuration(duration)}',
|
||||
if (_trip.routeMeters > 0)
|
||||
RouteMetricsHelper.formatDistance(_trip.routeMeters),
|
||||
];
|
||||
if (parts.isEmpty) return const SizedBox.shrink();
|
||||
// Scale-down, never ellipsis: this line is nothing but figures, and
|
||||
// `10.3 …` is the unit cut off the number it qualifies — seen the first
|
||||
// time it shared a row with the headline, both flexible.
|
||||
return FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
parts.join(' \u00b7 '),
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The one line on this widget that can carry bad news, so it is the one line
|
||||
/// that is allowed to change colour.
|
||||
Widget _timeLeftLine() {
|
||||
@@ -490,13 +455,19 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
||||
// inventing a state — see the note on [ShiftBanner] for why an absent
|
||||
// shift is not the rider's problem to solve.
|
||||
//
|
||||
// And when the trip has no slot either, say nothing AT ALL:
|
||||
// `slotLabel`'s fallback is the words "Today's route", which set at
|
||||
// clock position read as a fact the rider should be able to act on. A
|
||||
// And when the trip has no clock window either, say nothing AT ALL. A
|
||||
// headline row carries a live figure or it carries silence — a label
|
||||
// pretending to be data is worse than the gap it fills.
|
||||
if (_trip.slotStart == null) return const SizedBox.shrink();
|
||||
text = _trip.slotLabel;
|
||||
// pretending to be data is worse than the gap it fills, and `slotLabel`
|
||||
// has two fallbacks that are exactly that: the words "Today's route", and
|
||||
// the day part's name. **Morning** where "3h 10m left" belongs is a noun
|
||||
// in the corner a rider checks for how much time he has; the trip tab
|
||||
// above already says which part of the day this is, in a number.
|
||||
//
|
||||
// See [Trip.slotClockLabel], which is empty unless the hub sent a real
|
||||
// range.
|
||||
final window = _trip.slotClockLabel;
|
||||
if (window.isEmpty) return const SizedBox.shrink();
|
||||
text = window;
|
||||
color = ColorConstants.secondaryText;
|
||||
} else if (left == Duration.zero) {
|
||||
text = 'Shift ended';
|
||||
|
||||
@@ -16,7 +16,8 @@ import 'package:miler/views/helpers/constants/design_constants.dart';
|
||||
import 'package:miler/views/helpers/constants/miler_type.dart';
|
||||
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
||||
import 'package:miler/Models/stop_status.dart';
|
||||
import 'package:miler/data/bag_manifest.dart';
|
||||
import 'package:miler/data/order_manifest.dart';
|
||||
import 'package:miler/data/stop_area.dart';
|
||||
import 'package:miler/data/milk_run.dart';
|
||||
import 'package:miler/data/service_profile.dart';
|
||||
|
||||
@@ -258,6 +259,10 @@ class TripCard extends StatelessWidget {
|
||||
// on the same footing. See [Trip.outstanding].
|
||||
remaining: trip.outstanding(
|
||||
collectedIds: collectedIds,
|
||||
// Work he has declined is not work in front of him — see the note on
|
||||
// [Trip.outstanding]. Without this the Distance cell measured a route
|
||||
// through stops the rider had refused.
|
||||
rejectedIds: rejectedIds,
|
||||
originLat: riderLat,
|
||||
originLng: riderLng,
|
||||
),
|
||||
@@ -311,7 +316,7 @@ class TripCard extends StatelessWidget {
|
||||
for (var i = 0; i < trip.stops.length; i++) {
|
||||
if (_stateOf(trip.stops[i]) != rung) continue;
|
||||
final stop = trip.stops[i];
|
||||
final key = MilkRun.sourceKeyOf(stop);
|
||||
final key = MilkRun.sourceKeyOf(stop, within: trip.stops);
|
||||
final grouped = ServiceProfile.active.sourceIsKitchen && key.isNotEmpty;
|
||||
final stops = grouped
|
||||
? [
|
||||
@@ -430,13 +435,22 @@ class TripCard extends StatelessWidget {
|
||||
// the screen. See [RouteGroup.allStops].
|
||||
for (var i = 0; i < trip.stops.length; i++) {
|
||||
final stop = trip.stops[i];
|
||||
// Key on the id where there is one, falling back to the name: the id is
|
||||
// what the hub controls, but a payload that only carries a name still has
|
||||
// to group rather than collapsing every stop into one nameless pile.
|
||||
// ── One reader for "which counter is this", not two ──
|
||||
//
|
||||
// This built its own key — `stopSourceId`, falling back to
|
||||
// `stopSourceName` — while [MilkRun.sourceKeyOf] is the function that
|
||||
// exists to answer exactly this, and is what the bulk collect, the
|
||||
// per-kitchen gate and `sameSourceAs` all key on.
|
||||
//
|
||||
// They did not agree. `stopSourceId` reads `kitchenid` and `sourceid`;
|
||||
// `sourceKeyOf` reads those **and `pickuplocationid`** — so a row
|
||||
// identified only by its location id was one place according to the
|
||||
// collect and a different one according to the card that offers it. That
|
||||
// is the same two-readers-one-question failure `sourceNameOf` documents,
|
||||
// repeated on the id, and it splits a kitchen's orders across two groups
|
||||
// on the screen while the collect still treats them as one.
|
||||
final key = grouped
|
||||
? (stopSourceId(stop).isNotEmpty
|
||||
? stopSourceId(stop)
|
||||
: stopSourceName(stop))
|
||||
? MilkRun.sourceKeyOf(stop, within: trip.stops)
|
||||
: 'stop:${_idOf(stop)}';
|
||||
if (!buckets.containsKey(key)) {
|
||||
buckets[key] = <int>[];
|
||||
@@ -473,17 +487,56 @@ class TripCard extends StatelessWidget {
|
||||
groups.add(() {
|
||||
final stops = visible;
|
||||
final first = all.first;
|
||||
|
||||
// ── An unnamed counter is named by where it is ──
|
||||
//
|
||||
// A grouped place with no source name fell through to
|
||||
// `MilkRun.navigationLabel`, which answers with the **customer** — so
|
||||
// the heading over a dropdown read as one more order rather than as the
|
||||
// place the orders under it are collected from.
|
||||
//
|
||||
// The address is what names a place the payload did not name, and it is
|
||||
// what the rider wants off that line anyway: he is deciding where to
|
||||
// ride, not who to hand a bag to.
|
||||
final place = compactAddress(pickupAddressOf(first));
|
||||
|
||||
// ── When a lone stop is a *place*, and when it is the stop ──
|
||||
//
|
||||
// `flat` means the header **is** the stop: no counter above it worth
|
||||
// naming, so the card is titled with the customer and opens the detail
|
||||
// sheet instead of a list. That is always right on a parcel route,
|
||||
// where the rider goes to one door for one consignment.
|
||||
//
|
||||
// On a kitchen line it was reached by a different route and gave the
|
||||
// wrong answer. A lone stop was flat whenever the payload carried **no
|
||||
// source name** — and a missing name is a gap in the data, not evidence
|
||||
// that there is no counter. So an order whose row lost its kitchen name
|
||||
// was drawn as a bare header with the customer's name on it, sitting
|
||||
// above the dropdown its seven siblings were in: seven bags in a list
|
||||
// and one stranded over it, off the same shelf.
|
||||
//
|
||||
// [MilkRun.sourceKeyOf] now groups such a row back in by its address,
|
||||
// which fixes that case at the root. This is the same correction one
|
||||
// level down, for a place that genuinely does hold a single order: on a
|
||||
// kitchen line a lone stop is only flat when there is **nothing at all**
|
||||
// to name its place with — no counter name and no address. With an
|
||||
// address it is a place holding one order, headed by where it is, with
|
||||
// the order in the list under it.
|
||||
final flat =
|
||||
all.length == 1 && (!grouped || stopSourceName(first).isEmpty);
|
||||
// A grouped place is named by its counter. A flat one *is* the stop, so
|
||||
// it is named by the customer — `MilkRun.navigationLabel` answers
|
||||
// "pickup" for a parcel that has not been collected yet, which is a
|
||||
// description of the leg rather than a name the rider can read off a
|
||||
// gate.
|
||||
all.length == 1 &&
|
||||
(!grouped || (stopSourceName(first).isEmpty && place.isEmpty));
|
||||
|
||||
// A grouped place is named by its counter, or failing that by where it
|
||||
// is. A flat one *is* the stop, so it is named by the customer —
|
||||
// `MilkRun.navigationLabel` answers "pickup" for a parcel that has not
|
||||
// been collected yet, which is a description of the leg rather than a
|
||||
// name the rider can read off a gate.
|
||||
final name = flat
|
||||
? BagManifest.customerOf(first)
|
||||
? OrderManifest.customerOf(first)
|
||||
: (grouped
|
||||
? stopSourceName(first)
|
||||
? (stopSourceName(first).trim().isNotEmpty
|
||||
? stopSourceName(first)
|
||||
: place)
|
||||
: MilkRun.navigationLabel(first));
|
||||
return RouteGroup(
|
||||
key: key,
|
||||
@@ -493,6 +546,20 @@ class TripCard extends StatelessWidget {
|
||||
name: name.trim().isEmpty
|
||||
? MilkRun.navigationLabel(first)
|
||||
: name.trim(),
|
||||
// ── The counter's street, under its name ──
|
||||
//
|
||||
// Grouped places only. A flat node *is* the stop and is already named
|
||||
// by its customer or its own address, so an address line under it
|
||||
// would print the same place twice; a kitchen is a name the rider has
|
||||
// to convert into a location, and until now the only thing that could
|
||||
// do that conversion was the preview sheet.
|
||||
//
|
||||
// Trimmed through [compactAddress] — the geocoder appends
|
||||
// `Tamil Nadu, 641002, India` to every row, which is three
|
||||
// components that are identical on every stop of every route and
|
||||
// would push the part that names the street off the end of the line.
|
||||
// Not repeated when it is already the heading — see [name] above.
|
||||
location: flat || name.trim() == place ? '' : place,
|
||||
stops: stops,
|
||||
allStops: all,
|
||||
firstIndex: indices.first,
|
||||
@@ -706,13 +773,12 @@ class TripCard extends StatelessWidget {
|
||||
|
||||
for (var i = 0; i < trip.stops.length; i++) {
|
||||
if (!_showsOnHome(_stateOf(trip.stops[i]))) continue;
|
||||
// Key on the id where there is one, falling back to the name: the id is
|
||||
// what the hub controls, but a payload that only carries a name still has
|
||||
// to group rather than collapsing every stop into one nameless pile.
|
||||
// [MilkRun.sourceKeyOf], the same reader [routeGroups] uses and the same
|
||||
// one the bulk collect keys on. This had its own id-then-name expression,
|
||||
// which misses `pickuplocationid` and therefore split a kitchen the
|
||||
// collect still treats as one — see the note in [routeGroups].
|
||||
final stop = trip.stops[i];
|
||||
final key = stopSourceId(stop).isNotEmpty
|
||||
? stopSourceId(stop)
|
||||
: stopSourceName(stop);
|
||||
final key = MilkRun.sourceKeyOf(stop);
|
||||
if (!groups.containsKey(key)) {
|
||||
groups[key] = <int>[];
|
||||
order.add(key);
|
||||
@@ -804,11 +870,11 @@ class TripCard extends StatelessWidget {
|
||||
// him comparing "5 meals" on the phone against a shelf of bags.
|
||||
// The count he can *check* is orders against bags, and under
|
||||
// this workflow they are the same number by definition. See
|
||||
// [BagManifest].
|
||||
// [OrderManifest].
|
||||
Text(
|
||||
pending > 0
|
||||
? '${BagManifest.countLabel(indices.length)} · $pending to accept'
|
||||
: BagManifest.countLabel(indices.length),
|
||||
? '${OrderManifest.countLabel(indices.length)} · $pending to accept'
|
||||
: OrderManifest.countLabel(indices.length),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
@@ -1204,7 +1270,7 @@ class TripCard extends StatelessWidget {
|
||||
state: state,
|
||||
address: address,
|
||||
service: roundTripDuration(Trip.serviceTimeFor(stop)),
|
||||
bag: _bagOf(stop),
|
||||
label: _labelOf(stop),
|
||||
// The kitchen heading above already names where this came from.
|
||||
groupedUnderSource: ServiceProfile.active.sourceIsKitchen,
|
||||
selected: selectedIds.contains(id),
|
||||
@@ -1258,21 +1324,19 @@ class TripCard extends StatelessWidget {
|
||||
// `_tag` is gone — it built the tinted Deliver / Collect / ₹ chips. See
|
||||
// [_MetaLine], which says the same three things on one uncoloured line.
|
||||
|
||||
/// Which bag this order travels in.
|
||||
/// The label the counter printed on this order, or `''`.
|
||||
///
|
||||
/// Derived from its position in **its own pickup group**, not from the whole
|
||||
/// route: the rider counts bags off one counter at a time, so the third order
|
||||
/// from Kitchen 2 is *its* Bag 3 and not the route's ninth. A label the
|
||||
/// kitchen actually printed always wins — see [BagManifest].
|
||||
String _bagOf(Map<String, dynamic> stop) {
|
||||
if (!ServiceProfile.active.sourceIsKitchen) return stopBagLabel(stop);
|
||||
final key = MilkRun.sourceKeyOf(stop);
|
||||
final group = [
|
||||
for (final s in trip.stops)
|
||||
if (MilkRun.sourceKeyOf(s) == key) s,
|
||||
];
|
||||
return BagManifest.bagFor(stop, group);
|
||||
}
|
||||
/// ── It used to be derived, and that was the problem ──
|
||||
///
|
||||
/// This computed the order's position in its own pickup group and returned
|
||||
/// `Bag 3` — the app numbering a list it had built and presenting the result
|
||||
/// as something stencilled on an object. On a route where no counter prints
|
||||
/// labels that was every row, and it sat two columns from a rail numbering
|
||||
/// the same rows.
|
||||
///
|
||||
/// Only what the payload actually carries survives. See
|
||||
/// [OrderManifest.labelFor].
|
||||
String _labelOf(Map<String, dynamic> stop) => OrderManifest.labelFor(stop);
|
||||
|
||||
// ── Footer: accept remaining, or go work the trip ──────────────────────
|
||||
Widget _footerBar(List<String> pendingIds) {
|
||||
|
||||
@@ -36,23 +36,23 @@ class PickupCard extends StatelessWidget {
|
||||
final bool enabled;
|
||||
final bool isSkipped;
|
||||
|
||||
/// The bag this order travels in, as recorded at the counter. Empty on a
|
||||
/// parcel route, where there is no load to match against. See [BagManifest].
|
||||
final String bag;
|
||||
/// The label this order travels in, as recorded at the counter. Empty on a
|
||||
/// parcel route, where there is no load to match against. See [OrderManifest].
|
||||
final String label;
|
||||
|
||||
/// Orders the rider is carrying.
|
||||
///
|
||||
/// Needed because "picked up" is not a *status* on this screen — the accepted
|
||||
/// store normalises these rows to `accepted`, and what actually records the
|
||||
/// hand-over is the collected set. Without it, [MilkRun.workingKind] reads a
|
||||
/// bag already in the rider's box as still needing collection, and the button
|
||||
/// label already in the rider's box as still needing collection, and the button
|
||||
/// says **Start Pickup** for a delivery.
|
||||
final Set<String> collectedIds;
|
||||
|
||||
/// Orders whose delivery leg the rider has actually started.
|
||||
///
|
||||
/// Distinct from [collectedIds] on purpose, and the distinction is the whole
|
||||
/// point of the chip: a bag in his box is **PICKED**, a round he has set off
|
||||
/// point of the chip: a label in his box is **PICKED**, a round he has set off
|
||||
/// on is **ACTIVE**. The backend cannot answer this — `pickup-complete`
|
||||
/// stamps a hyperlocal consignment `Out_for_Delivery` at the counter — so the
|
||||
/// rung is resolved through [MilkRun.stageOf] against this set rather than
|
||||
@@ -67,7 +67,7 @@ class PickupCard extends StatelessWidget {
|
||||
this.riderSpeedMps,
|
||||
this.enabled = true,
|
||||
this.isSkipped = false,
|
||||
this.bag = '',
|
||||
this.label = '',
|
||||
this.collectedIds = const {},
|
||||
this.outForDeliveryIds = const {},
|
||||
});
|
||||
@@ -97,7 +97,7 @@ class PickupCard extends StatelessWidget {
|
||||
//
|
||||
// The booking adapter stamps `type: pickup` on every row it builds, so a
|
||||
// milk-run order the rider has already collected still described itself as
|
||||
// a pickup — and this card offered **Start Pickup** for a bag sitting in
|
||||
// a pickup — and this card offered **Start Pickup** for a label sitting in
|
||||
// his box. See [MilkRun.workingKind].
|
||||
final StopKind kind = MilkRun.workingKind(item, collectedIds: collectedIds);
|
||||
|
||||
@@ -201,7 +201,7 @@ class PickupCard extends StatelessWidget {
|
||||
// ── Identity left, state and journey right ──
|
||||
//
|
||||
// The distance and ETA had a full-width row of their own under
|
||||
// the bag — a fifth storey on a card the rider reads at a red
|
||||
// the label — a fifth storey on a card the rider reads at a red
|
||||
// light. Home's group node and this queue's own UP NEXT tile
|
||||
// already carry the journey as a narrow right-hand column at
|
||||
// the headline's shoulder; the live card was the one surface
|
||||
@@ -272,7 +272,7 @@ class PickupCard extends StatelessWidget {
|
||||
|
||||
// ── The pairing, as one object ──
|
||||
//
|
||||
// `Bag 1 · Joe`, not a bag chip in one place and a
|
||||
// `Bag 1 · Joe`, not a label chip in one place and a
|
||||
// name in another. The join between them is the
|
||||
// entire operational risk on a meal run, and making
|
||||
// the rider do it himself at a gate is where the
|
||||
@@ -280,10 +280,10 @@ class PickupCard extends StatelessWidget {
|
||||
// the headline above, so the pair is spatial here;
|
||||
// the written form earns its place further down the
|
||||
// queue — see [_LaterRow] — and on the manifest.)
|
||||
if (bag.isNotEmpty) ...[
|
||||
if (label.isNotEmpty) ...[
|
||||
SizedBox(height: 14.h),
|
||||
_BagPair(
|
||||
bag: bag,
|
||||
_LabelPair(
|
||||
label: label,
|
||||
customer: '',
|
||||
muted: !enabled,
|
||||
size: 15.5,
|
||||
@@ -371,7 +371,7 @@ class PickupCard extends StatelessWidget {
|
||||
//
|
||||
// Once the food is in the box the pickup is history — but it is
|
||||
// still what establishes custody, and on a run with two kitchens
|
||||
// it is how he knows which shelf this bag came off. So it stays,
|
||||
// it is how he knows which shelf this label came off. So it stays,
|
||||
// one line, under a hairline, at half the weight of the name
|
||||
// above.
|
||||
if (dropLeads && pickupName.isNotEmpty) ...[
|
||||
@@ -534,7 +534,7 @@ class PickupCard extends StatelessWidget {
|
||||
// because this method opens the map screen directly rather than through
|
||||
// `startPickup`, and a stop that reached it in the pickup phase would put
|
||||
// the rider through a map, an I'VE ARRIVED and a confirmation sheet for a
|
||||
// bag he is supposed to collect at the counter with the rest of them. See
|
||||
// label he is supposed to collect at the counter with the rest of them. See
|
||||
// [MilkRun.worksOnOwnScreen].
|
||||
if (!MilkRun.worksOnOwnScreen(item, collectedIds: collectedIds)) {
|
||||
AppFeedback.info(
|
||||
@@ -919,7 +919,7 @@ class _SquareIconButton extends StatelessWidget {
|
||||
/// ```
|
||||
/// NOW one task card, full weight, one big action
|
||||
/// UP NEXT who and where, quietly — no action
|
||||
/// LATER one line each: bag, name, distance
|
||||
/// LATER one line each: label, name, distance
|
||||
/// SKIPPED the reason, and the way back in
|
||||
/// ```
|
||||
///
|
||||
@@ -963,8 +963,8 @@ class _QueueRow {
|
||||
final QueueDensity density;
|
||||
final int displayStep;
|
||||
|
||||
/// The bag this order travels in, as recorded at the counter.
|
||||
final String bag;
|
||||
/// The label this order travels in, as recorded at the counter.
|
||||
final String label;
|
||||
|
||||
/// Why a skipped stop was skipped. Read from the skip cache rather than the
|
||||
/// stop, because the stop is refetched from the API every three seconds and
|
||||
@@ -975,14 +975,14 @@ class _QueueRow {
|
||||
: stop = null,
|
||||
density = QueueDensity.later,
|
||||
displayStep = 0,
|
||||
bag = '',
|
||||
label = '',
|
||||
reason = '';
|
||||
|
||||
const _QueueRow.stop({
|
||||
required this.stop,
|
||||
required this.density,
|
||||
required this.displayStep,
|
||||
required this.bag,
|
||||
required this.label,
|
||||
this.reason = '',
|
||||
}) : heading = null,
|
||||
count = 0;
|
||||
@@ -1079,33 +1079,38 @@ class _QueueHeading extends StatelessWidget {
|
||||
///
|
||||
/// ── Why they are one line and not two facts ──
|
||||
///
|
||||
/// The rider is holding a bag and looking for a door. Printing the bag in one
|
||||
/// The rider is holding a label and looking for a door. Printing the label in one
|
||||
/// place and the customer in another makes him do the join himself, on a phone,
|
||||
/// at a gate — and that join is the entire operational risk on a meal run: five
|
||||
/// near-identical lunch bags, one confident rider, the wrong door.
|
||||
///
|
||||
/// So they are written the way he thinks them.
|
||||
class _BagPair extends StatelessWidget {
|
||||
final String bag;
|
||||
class _LabelPair extends StatelessWidget {
|
||||
final String label;
|
||||
final String customer;
|
||||
final bool muted;
|
||||
|
||||
/// Bigger on the live card, quieter down the queue.
|
||||
final double size;
|
||||
|
||||
const _BagPair({
|
||||
required this.bag,
|
||||
const _LabelPair({
|
||||
required this.label,
|
||||
required this.customer,
|
||||
this.muted = false,
|
||||
this.size = 15,
|
||||
});
|
||||
|
||||
String get _bagText =>
|
||||
bag.toLowerCase().startsWith('bag ') ? bag : 'Bag $bag';
|
||||
/// ── The word is gone; the code is not ──
|
||||
///
|
||||
/// This prefixed everything with `Bag `, so a counter's own code rendered as
|
||||
/// `Bag DG-1042` and an app-derived number rendered as `Bag 3` — a reference
|
||||
/// to an object nobody had labelled. What a counter prints is shown exactly
|
||||
/// as it was sent, and nothing is shown where nothing was sent.
|
||||
String get _labelText => label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (bag.isEmpty) return const SizedBox.shrink();
|
||||
if (label.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -1119,7 +1124,7 @@ class _BagPair extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
),
|
||||
child: Text(
|
||||
_bagText,
|
||||
_labelText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
@@ -1223,14 +1228,14 @@ class _TaskChip extends StatelessWidget {
|
||||
/// notes, no money. Preparing is a glance; acting is the card above.
|
||||
class _NextCard extends StatelessWidget {
|
||||
final Map<String, dynamic> item;
|
||||
final String bag;
|
||||
final String label;
|
||||
final double? distanceMeters;
|
||||
final double? riderSpeedMps;
|
||||
final VoidCallback onOpen;
|
||||
|
||||
const _NextCard({
|
||||
required this.item,
|
||||
required this.bag,
|
||||
required this.label,
|
||||
required this.distanceMeters,
|
||||
required this.riderSpeedMps,
|
||||
required this.onOpen,
|
||||
@@ -1293,9 +1298,9 @@ class _NextCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
],
|
||||
if (bag.isNotEmpty) ...[
|
||||
if (label.isNotEmpty) ...[
|
||||
SizedBox(height: 10.h),
|
||||
_BagPair(bag: bag, customer: '', muted: true, size: 14),
|
||||
_LabelPair(label: label, customer: '', muted: true, size: 14),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -1312,13 +1317,13 @@ class _NextCard extends StatelessWidget {
|
||||
/// take in at once.
|
||||
class _LaterRow extends StatelessWidget {
|
||||
final Map<String, dynamic> item;
|
||||
final String bag;
|
||||
final String label;
|
||||
final double? distanceMeters;
|
||||
final VoidCallback onOpen;
|
||||
|
||||
const _LaterRow({
|
||||
required this.item,
|
||||
required this.bag,
|
||||
required this.label,
|
||||
required this.distanceMeters,
|
||||
required this.onOpen,
|
||||
});
|
||||
@@ -1333,8 +1338,8 @@ class _LaterRow extends StatelessWidget {
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h),
|
||||
child: Row(
|
||||
children: [
|
||||
if (bag.isNotEmpty) ...[
|
||||
_BagPair(bag: bag, customer: '', muted: true, size: 13),
|
||||
if (label.isNotEmpty) ...[
|
||||
_LabelPair(label: label, customer: '', muted: true, size: 13),
|
||||
SizedBox(width: 12.w),
|
||||
],
|
||||
Expanded(
|
||||
@@ -1375,14 +1380,14 @@ class _LaterRow extends StatelessWidget {
|
||||
/// and nothing else.
|
||||
class _SkippedRow extends StatelessWidget {
|
||||
final Map<String, dynamic> item;
|
||||
final String bag;
|
||||
final String label;
|
||||
final String reason;
|
||||
final VoidCallback onResume;
|
||||
final VoidCallback onOpen;
|
||||
|
||||
const _SkippedRow({
|
||||
required this.item,
|
||||
required this.bag,
|
||||
required this.label,
|
||||
required this.reason,
|
||||
required this.onResume,
|
||||
required this.onOpen,
|
||||
@@ -1441,8 +1446,8 @@ class _SkippedRow extends StatelessWidget {
|
||||
SizedBox(height: 12.h),
|
||||
Row(
|
||||
children: [
|
||||
if (bag.isNotEmpty) ...[
|
||||
_BagPair(bag: bag, customer: '', muted: true, size: 13.5),
|
||||
if (label.isNotEmpty) ...[
|
||||
_LabelPair(label: label, customer: '', muted: true, size: 13.5),
|
||||
SizedBox(width: 12.w),
|
||||
],
|
||||
Expanded(
|
||||
|
||||
@@ -116,7 +116,7 @@ extension _MethodUi on _Method {
|
||||
String get label => this == _Method.cash ? 'Cash' : 'UPI / QR';
|
||||
|
||||
String get blurb => this == _Method.cash
|
||||
? 'Notes and coins, into your bag'
|
||||
? 'Notes and coins, kept on you'
|
||||
: 'Customer scans and pays';
|
||||
|
||||
IconData get icon =>
|
||||
|
||||
@@ -227,7 +227,11 @@ Future<String> resolveConsignmentId(Map<String, dynamic> stop) async {
|
||||
/// rider can retry this until his battery dies.
|
||||
/// • **At a hub.** A cross-city parcel is `Inwarded_at_Hub` and will be
|
||||
/// delivered by somebody else. There is nothing here for him to start.
|
||||
/// • **Refused.** The server gave a reason and it was thrown away.
|
||||
/// • **Refused.** The server gave a reason and it was thrown away. It is
|
||||
/// read now — and *translated*, because the reason arrives as a state
|
||||
/// machine talking to itself (`consignment is Inwarded_At_Hub, not
|
||||
/// Collected_By_Miler`) and that is not a sentence for a rider. See
|
||||
/// [_refusalSentence].
|
||||
/// • **Unreachable.** The only one where "check your connection" is true.
|
||||
///
|
||||
/// Telling a rider to check a working connection, about a parcel that is not
|
||||
@@ -318,10 +322,10 @@ Future<bool> releaseForDelivery(Map<String, dynamic> stop) async {
|
||||
// account for, and one 400 carrying the backend's own sentence is worth more
|
||||
// than a client-side refusal that invents one. A hyperlocal consignment the
|
||||
// pivot has not finished releasing is let through; a hub-routed one is
|
||||
// refused by the server, and the rider is told what the server said.
|
||||
// refused by the server, and the rider is told what the server *meant* —
|
||||
// translated out of the state machine's vocabulary, see [_refusalSentence].
|
||||
// A hold that cannot exist on this line is a bad read, not a fact about the
|
||||
// stop. See [_handoffHoldIsPossible].
|
||||
final heldElsewhere = state.awaitsHub && _handoffHoldIsPossible;
|
||||
if (state.awaitsHub && !_handoffHoldIsPossible) {
|
||||
debugPrint(
|
||||
'[MILKRUN] $consignmentId read as ${state.name} on a line with no '
|
||||
@@ -333,11 +337,16 @@ Future<bool> releaseForDelivery(Map<String, dynamic> stop) async {
|
||||
!(state.awaitsHub && !_handoffHoldIsPossible) &&
|
||||
state != ConsignmentState.unknown) {
|
||||
debugPrint('[MILKRUN] $consignmentId is ${state.name} — not releasable');
|
||||
lastReleaseFailure = heldElsewhere
|
||||
// The honest answer for a cross-city parcel: it is not his round, and
|
||||
// saying so without naming a building he has never been to.
|
||||
? "This one has already been handed on — it's not yours to deliver."
|
||||
: 'This parcel cannot be started yet. Ask your office to check it.';
|
||||
// One sentence per state, written once — see
|
||||
// [ConsignmentStateX.startRefusalSentence]. This branch and the refusal
|
||||
// below are the same answer arrived at from two directions (a state the
|
||||
// app read, and a state the server named), and they used to word it
|
||||
// differently: a cross-city parcel blocked here said "it's not yours to
|
||||
// deliver" while the identical parcel blocked one request later read out
|
||||
// a database enum.
|
||||
lastReleaseFailure =
|
||||
state.startRefusalSentence ??
|
||||
'This parcel cannot be started yet. Ask your office to check it.';
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -361,16 +370,63 @@ Future<bool> releaseForDelivery(Map<String, dynamic> stop) async {
|
||||
final after = await ConsignmentGate.stateOf(consignmentId);
|
||||
if (after.isDeliverable || after.isDelivered) return true;
|
||||
|
||||
// `status: 0` is the request never leaving; anything else is the server
|
||||
// answering, and its own words beat a guess about the network.
|
||||
// `status: 0` is the request never leaving, and it is the one failure that
|
||||
// is really about the connection.
|
||||
lastReleaseFailure = res.status == 0
|
||||
? 'Could not reach your office. Check your connection and slide again.'
|
||||
: (res.message.trim().isNotEmpty
|
||||
? res.message.trim()
|
||||
: 'Your office would not start this delivery.');
|
||||
: _refusalSentence(res, after);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// The rider's version of a refused **Start ride**.
|
||||
///
|
||||
/// ── What the rider was actually reading ──
|
||||
///
|
||||
/// The server's own words were passed straight to the screen, on the argument
|
||||
/// that one refusal carrying the backend's reason beats a client-side guess
|
||||
/// that invents one. That argument holds for *whose* answer it is. It does not
|
||||
/// hold for what the answer turned out to be: `start-delivery` refuses with an
|
||||
/// assertion about its own model — **"consignment is Inwarded_At_Hub, not
|
||||
/// Collected_By_Miler"** — and that is what a rider saw on the slider, in
|
||||
/// database vocabulary, with no way to tell it from a crash.
|
||||
///
|
||||
/// The reason is worth keeping; the wording was never the server's to write.
|
||||
/// So the state is what travels, and [ConsignmentStateX.startRefusalSentence]
|
||||
/// says it once, in one place, in words that answer the only two questions he
|
||||
/// has: is this mine, and will sliding again help.
|
||||
///
|
||||
/// ── Three sources for that state, best first ──
|
||||
///
|
||||
/// 1. **A fresh read** — [after], asked immediately after the refusal, which
|
||||
/// is the newest thing anyone knows about the consignment.
|
||||
/// 2. **The refusal itself.** The read can fail — the row need not carry a
|
||||
/// status and the detail route 404s on older deployments — and when it
|
||||
/// does the refusal is the only thing left that knows. See
|
||||
/// [consignmentStateNamedInRefusal] for why reading it is safe.
|
||||
/// 3. **The failure code.** `INVALID_STATE` alone still says the one useful
|
||||
/// thing: the consignment has moved on from the rung this press acts at,
|
||||
/// so sliding again is not the answer.
|
||||
///
|
||||
/// Prose from the server is still preferred over a generic sentence — some
|
||||
/// routes really do answer in words meant for a person. What is filtered out
|
||||
/// is the wire vocabulary. See [namesWireState].
|
||||
String _refusalSentence(ApiResult res, ConsignmentState after) {
|
||||
final state = after != ConsignmentState.unknown
|
||||
? after
|
||||
: consignmentStateNamedInRefusal(res.message);
|
||||
|
||||
final fromState = state.startRefusalSentence;
|
||||
if (fromState != null) return fromState;
|
||||
|
||||
final said = res.message.trim();
|
||||
if (said.isNotEmpty && !namesWireState(said)) return said;
|
||||
|
||||
return res.isInvalidState
|
||||
? 'Your office has already moved this parcel on, so it cannot be '
|
||||
'started from here. Ask them to check it.'
|
||||
: 'Your office would not start this delivery.';
|
||||
}
|
||||
|
||||
/// Brings this device into line with a delivery the server already has.
|
||||
///
|
||||
/// The same local bookkeeping a successful `deliver` performs — the order
|
||||
|
||||
@@ -77,6 +77,7 @@ import 'package:miler/data/order_events.dart';
|
||||
import 'package:miler/data/route_order.dart';
|
||||
import 'package:miler/data/stop_area.dart';
|
||||
import 'package:miler/data/stop_contact.dart';
|
||||
import 'package:miler/data/service_day.dart';
|
||||
import 'package:miler/data/work_domain.dart';
|
||||
import 'package:miler/data/work_repository.dart';
|
||||
import 'package:miler/data/miler_api.dart';
|
||||
@@ -291,6 +292,28 @@ class MyPickups extends StatefulWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// True when [stop] is part of the route the rider actually rode.
|
||||
///
|
||||
/// The completed store is not a list of finished deliveries — it is where every
|
||||
/// order that left the rider's hands is filed, whatever ended it. A stop he
|
||||
/// **declined** on Home is in there (`terminalStatus: 'rejected'`) and so is
|
||||
/// one the office **cancelled**. Neither was ever ridden to, so neither belongs
|
||||
/// on a progress rail: they inflate its count and they are drawn in states that
|
||||
/// assert something false — `rejected` reads as work still ahead of him,
|
||||
/// `cancelled` reads as green, completed work.
|
||||
///
|
||||
/// A **skip** is not in that group and is deliberately kept. It is a door he
|
||||
/// went to and could not finish, which is unfinished business, and the rail has
|
||||
/// an amber node for exactly that.
|
||||
///
|
||||
/// Top-level rather than a method so [_railStops]' rule can be exercised
|
||||
/// without building the screen — the arithmetic it protects is four lines and
|
||||
/// took a rider's whole rail with it when it was wrong.
|
||||
bool wasRiddenTo(Map<String, dynamic> stop) {
|
||||
final status = stopStatusOf(stop);
|
||||
return !status.isRejected && !status.isCancelled;
|
||||
}
|
||||
|
||||
class _MyPickupsState extends State<MyPickups>
|
||||
with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
|
||||
final PickupProvider _provider = PickupProvider();
|
||||
@@ -428,6 +451,65 @@ class _MyPickupsState extends State<MyPickups>
|
||||
/// Refreshed with every fetch. See [_railStops] for why the rail needs them.
|
||||
List<Map<String, dynamic>> _completedToday = <Map<String, dynamic>>[];
|
||||
|
||||
/// Notices when the day underneath the queue has moved on.
|
||||
///
|
||||
/// [_fetchPicked] filters against the service day *as read when it ran*, so
|
||||
/// the queue on screen is only ever as fresh as the last fetch. See
|
||||
/// [_rolloverCheck].
|
||||
final ServiceDayRollover _day = ServiceDayRollover();
|
||||
|
||||
/// Fires at the next local midnight. See [_armRollover].
|
||||
Timer? _rollover;
|
||||
|
||||
/// This queue's stops, for today and no other day.
|
||||
///
|
||||
/// ── Why Deliveries needed the same filter Home just got ──
|
||||
///
|
||||
/// The two screens read the same call. `GET /miler/bookings` returns the
|
||||
/// rider's whole **open** set, so a stop he picked up on Tuesday and never
|
||||
/// closed came back on Wednesday, went through [WorkBoundary.deliveryQueue]
|
||||
/// as perfectly workable — it *is* collected and it *is* unclosed — and sat
|
||||
/// in this morning's round. Worse than a stale row: the bar at the foot of
|
||||
/// this screen offers to start the whole round, so yesterday's leftovers
|
||||
/// were work the rider could be sent out on.
|
||||
///
|
||||
/// The rule and every part of its reasoning is [ServiceDay.onlyToday],
|
||||
/// shared with Home so the two screens cannot disagree about which day it
|
||||
/// is. What is local is only *which* day — the one this screen is showing.
|
||||
///
|
||||
/// The completed store is not put through it: [getCompletedBookings] already
|
||||
/// prunes itself to today on read.
|
||||
List<Map<String, dynamic>> _todayOnly(List<Map<String, dynamic>> stops) {
|
||||
_day.rollIfNeeded();
|
||||
return ServiceDay.onlyToday(stops, now: _day.day, where: 'MYPICKUPS');
|
||||
}
|
||||
|
||||
/// ── The day ending under a queue that is already open ──
|
||||
///
|
||||
/// [ServiceDay] answers "which day is this row" at read time, which is right
|
||||
/// for the data and not sufficient for the screen: the filter runs at fetch
|
||||
/// time. A rider who leaves this tab open through 23:59, or backgrounds the
|
||||
/// app on it and picks the phone up at six, is looking at a queue filtered
|
||||
/// against a day that has ended.
|
||||
///
|
||||
/// A timer to the next local midnight for the app left in the foreground,
|
||||
/// and resume for every other case — both platforms suspend timers in the
|
||||
/// background. Both land in [_rolloverCheck], which is idempotent. The same
|
||||
/// arrangement Home and Activity use.
|
||||
void _armRollover() {
|
||||
_rollover?.cancel();
|
||||
_rollover = Timer(_day.untilNextDay, _rolloverCheck);
|
||||
}
|
||||
|
||||
/// Turns the queue over when the service day has moved on.
|
||||
void _rolloverCheck() {
|
||||
if (!mounted) return;
|
||||
_armRollover();
|
||||
if (!_day.rollIfNeeded()) return;
|
||||
debugPrint('[MYPICKUPS] the day rolled over to ${_day.day} — refetching');
|
||||
_fetchPicked(force: true);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// THE MILK RUN'S ROUND
|
||||
//
|
||||
@@ -467,20 +549,59 @@ class _MyPickupsState extends State<MyPickups>
|
||||
/// The completed stops come from the same store the Activity tab reads, so
|
||||
/// the two agree about what was finished, and they lead because they were
|
||||
/// done first. The open ones follow in the hub's order.
|
||||
///
|
||||
/// ── Why the rail was counting stops the rider never rode ──
|
||||
///
|
||||
/// The completed store is not a list of finished *deliveries*. It is where
|
||||
/// every order that left the rider's hands is filed, whatever ended it — and
|
||||
/// **a declined stop is filed there too** (`addCompletedBookings(...,
|
||||
/// terminalStatus: 'rejected')`, from Home's decline). That was invisible for
|
||||
/// as long as nothing read the store as a route.
|
||||
///
|
||||
/// This reads it as a route, and the arithmetic was plainly wrong: a rider
|
||||
/// who took one order out of five and declined the other four got a rail of
|
||||
/// **five** nodes. Worse than the count, each wore the wrong colour —
|
||||
/// `rejected` maps to [StopProgress.pending], so four stops he had refused
|
||||
/// were drawn ahead of him as work still to come, and a `cancelled` one is
|
||||
/// [StopStatusX.isWorkComplete] and draws **green**, claiming a delivery that
|
||||
/// never happened.
|
||||
///
|
||||
/// So the rail draws what he actually rode. A skip stays — it is unfinished
|
||||
/// business and the rail has an amber node to say so, which is the whole
|
||||
/// reason that colour exists. A stop he declined and a stop the office called
|
||||
/// off are not on his route at all.
|
||||
///
|
||||
/// ── And it draws the trip it is labelled with ──
|
||||
///
|
||||
/// The strip is headed `Trip 2 · 10:00 AM – 1:00 PM` — see [_buildTripRail] —
|
||||
/// while the store it reads holds the **whole day**, so on a second run the
|
||||
/// heading named one trip and the nodes counted two. Completed stops are kept
|
||||
/// to the trip in front of the rider whenever both numbers are known;
|
||||
/// anything unplaced is kept, because dropping a stop for want of a trip
|
||||
/// number would understate a day the rider really did work.
|
||||
///
|
||||
/// With no open stops there is no current trip and nothing is scoped away:
|
||||
/// a rider who has just closed his last stop still sees the full green rail,
|
||||
/// which is the case this getter was written for.
|
||||
List<Map<String, dynamic>> get _railStops {
|
||||
final open = _visibleStops;
|
||||
if (_completedToday.isEmpty) return open;
|
||||
|
||||
final openKeys = open.map(_getOrderKey).toSet();
|
||||
final done = _completedToday
|
||||
.where((o) => !openKeys.contains(_getOrderKey(o)))
|
||||
.toList()
|
||||
.reversed // the store hands them back newest first
|
||||
.toList();
|
||||
final trip = _currentTripNumber;
|
||||
|
||||
final done = <Map<String, dynamic>>[
|
||||
// The store hands them back newest first; the rail reads in route order.
|
||||
for (final o in _completedToday.reversed)
|
||||
if (!openKeys.contains(_getOrderKey(o)) && wasRiddenTo(o))
|
||||
if (trip == null || (_tripNumberOf(o) ?? trip) == trip) o,
|
||||
];
|
||||
|
||||
return [...done, ...open];
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Progress state for every stop on the accepted trip, in the admin's fixed
|
||||
/// order — what drives the red→green rail at the top of Bookings.
|
||||
///
|
||||
@@ -545,9 +666,28 @@ class _MyPickupsState extends State<MyPickups>
|
||||
hubLng: _currentLocation?.longitude,
|
||||
);
|
||||
|
||||
// ── The number is the slot, not the position in the list ──
|
||||
//
|
||||
// This read `i + 1` over the trips themselves, and it agreed with Home only
|
||||
// by accident: while nothing could place a trip by day part, every run fell
|
||||
// into one undated bucket and position and slot were the same number.
|
||||
//
|
||||
// They are not any more. [Trip.dayPartTimeOf] places a run in the part of
|
||||
// the day it was assigned in, so a rider whose whole day is an afternoon
|
||||
// run has one trip — at **slot 1**, which Home's tabs and the hub both call
|
||||
// Trip 2, and which this method would have called Trip 1. That is precisely
|
||||
// the failure the doc above says it exists to prevent, arriving through the
|
||||
// arithmetic rather than through the input.
|
||||
//
|
||||
// [TripSlots.slots] is the same laying-out Home's tab bar uses, including
|
||||
// its fallback to arrival order when the day parts cannot place every trip
|
||||
// — so the two screens cannot produce different numbers for one stop
|
||||
// without the tabs themselves being wrong.
|
||||
final slots = trips.slots;
|
||||
return <String, int>{
|
||||
for (var i = 0; i < trips.length; i++)
|
||||
for (final id in trips[i].orderIds) id: i + 1,
|
||||
for (var i = 0; i < slots.length; i++)
|
||||
if (slots[i] != null)
|
||||
for (final id in slots[i]!.orderIds) id: i + 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -629,12 +769,12 @@ class _MyPickupsState extends State<MyPickups>
|
||||
///
|
||||
/// Read once per refresh rather than per card: it is one preferences hit, and
|
||||
/// a `FutureBuilder` per row would blink a bagless card in on every rebuild.
|
||||
Map<String, String> _bagLabels = const {};
|
||||
Map<String, String> _orderLabels = const {};
|
||||
|
||||
Future<void> _loadBagLabels() async {
|
||||
final labels = await getBagLabels();
|
||||
Future<void> _loadOrderLabels() async {
|
||||
final labels = await getOrderLabels();
|
||||
if (!mounted) return;
|
||||
setState(() => _bagLabels = labels);
|
||||
setState(() => _orderLabels = labels);
|
||||
}
|
||||
|
||||
/// ── The queue, in the order it will be worked ──
|
||||
@@ -722,7 +862,7 @@ class _MyPickupsState extends State<MyPickups>
|
||||
_visibleStops,
|
||||
_visibleStops.indexOf(stop),
|
||||
),
|
||||
bag: _bagLabels[(stop['orderid'] ?? '').toString()] ?? '',
|
||||
label: _orderLabels[(stop['orderid'] ?? '').toString()] ?? '',
|
||||
reason: _skippedOrdersCache[_getOrderKey(stop)] ?? '',
|
||||
),
|
||||
);
|
||||
@@ -870,7 +1010,11 @@ class _MyPickupsState extends State<MyPickups>
|
||||
// The number leads and the window follows it, because the number is the
|
||||
// shared name and the window is the detail: "Trip 2 · 10:00 AM – 1:00 PM".
|
||||
final tripNo = _currentTripNumber;
|
||||
final window = trip.slotStart != null ? trip.slotLabel : null;
|
||||
// A clock range only. The day part's name would print `Trip 1 · Morning`,
|
||||
// which is the same fact twice — the number *is* the day part. See
|
||||
// [Trip.slotClockLabel].
|
||||
final clock = trip.slotClockLabel;
|
||||
final window = clock.isEmpty ? null : clock;
|
||||
final label = tripNo == null
|
||||
? (window ?? 'Your route')
|
||||
: window == null
|
||||
@@ -1023,12 +1167,14 @@ class _MyPickupsState extends State<MyPickups>
|
||||
BottomPage.currentTab.addListener(_onTabChanged);
|
||||
|
||||
// Run these in parallel
|
||||
_armRollover();
|
||||
|
||||
_restoreSkips();
|
||||
_restoreClosedToday();
|
||||
_restoreActivePickup();
|
||||
_initializeLocation(); // Don't block
|
||||
_fetchPicked();
|
||||
_loadBagLabels();
|
||||
_loadOrderLabels();
|
||||
_startPollingStream();
|
||||
}
|
||||
|
||||
@@ -1178,6 +1324,11 @@ class _MyPickupsState extends State<MyPickups>
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
super.didChangeAppLifecycleState(state);
|
||||
// Before the early return below: a resume that does not change the
|
||||
// foreground flag still has to notice a midnight that passed while the
|
||||
// phone was in the rider's pocket, which is the case the timer cannot
|
||||
// cover — both platforms suspend timers in the background.
|
||||
if (state == AppLifecycleState.resumed) _rolloverCheck();
|
||||
final bool foreground = state == AppLifecycleState.resumed;
|
||||
if (foreground == _pollForeground) return;
|
||||
_pollForeground = foreground;
|
||||
@@ -1280,6 +1431,7 @@ class _MyPickupsState extends State<MyPickups>
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
BottomPage.currentTab.removeListener(_onTabChanged);
|
||||
_pollerSubscription?.cancel();
|
||||
_rollover?.cancel();
|
||||
for (final timer in _pickupTimers.values) {
|
||||
timer.cancel();
|
||||
}
|
||||
@@ -1376,114 +1528,8 @@ class _MyPickupsState extends State<MyPickups>
|
||||
];
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// START DELIVERY — the load leaves the counter
|
||||
//
|
||||
// ── Why this is a press and not a side effect of the pickup ──
|
||||
//
|
||||
// The release used to ride along with `pickup-complete`, back on Home: the
|
||||
// rider slid to confirm five bags at a kitchen and the same gesture posted
|
||||
// all five consignments `Out_for_Delivery`. The hub's board then showed five
|
||||
// *active* deliveries for food still on the shelf in front of him, and there
|
||||
// was no moment left in the day that meant "I have set off" — picked and
|
||||
// out-for-delivery had been collapsed into one claim.
|
||||
//
|
||||
// They are two different claims about where the load is, made minutes or an
|
||||
// hour apart, and only the rider can make the second one. So it is his press,
|
||||
// on the tab that holds the load.
|
||||
//
|
||||
// ── Why one bar for the whole load ──
|
||||
//
|
||||
// `POST /miler/deliveries/start` takes a batch because the gesture is one
|
||||
// press after the last counter. Releasing per card would be five presses for
|
||||
// one act and would leave the round half-open — some rows deliverable, some
|
||||
// refused at the door by a server the rider cannot argue with.
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Orders in the rider's hands that he has not yet declared himself riding.
|
||||
///
|
||||
/// The bar's subject, and the reason it appears at all: an empty answer means
|
||||
/// the whole load is already on the road and there is nothing left to press.
|
||||
///
|
||||
/// Empty on every line but the milk run, and deliberately: a logistics parcel
|
||||
/// is collected *for the hub* and delivered by somebody else, so its rider
|
||||
/// has no round to start and must never be shown a control that says he has.
|
||||
/// [MilkRun.navigatesToCustomer] asks the same question one stop at a time.
|
||||
List<Map<String, dynamic>> get _unreleased => !_isMilkRun
|
||||
? const []
|
||||
: [
|
||||
for (final s in _visibleStops)
|
||||
// The same three exclusions the queue itself applies, so the bar
|
||||
// can never count a row the rider cannot see: work he has already
|
||||
// closed, work he has put down, and work already on the road.
|
||||
if (!stopStatusOf(s).isWorkComplete &&
|
||||
!stopStatusOf(s).isCancelled &&
|
||||
!stopStatusOf(s).isSkipped &&
|
||||
!_completedOrderKeys.contains(_getOrderKey(s)) &&
|
||||
_needsRelease(s))
|
||||
s,
|
||||
];
|
||||
|
||||
/// Whether this stop is still waiting on the rider's **Start delivery**.
|
||||
///
|
||||
/// ── Read from the consignment, not from a flag in this app ──
|
||||
///
|
||||
/// Two backend behaviours are live at once and the app must not care which
|
||||
/// it is talking to. `pickup-complete` either leaves a hyperlocal
|
||||
/// consignment `Out_for_Delivery` (as it does today) or stops it at
|
||||
/// `Collected_By_Miler` and waits for `start-delivery` — a server-side
|
||||
/// setting, not something this app is told about.
|
||||
///
|
||||
/// So the row is asked. `consignmentstatus` ships on every
|
||||
/// `GET /miler/bookings` row, and it answers the question exactly: needing
|
||||
/// release means needing release, and already out for delivery means the bar
|
||||
/// has nothing to offer. **No feature flag is mirrored here**, because a
|
||||
/// mirrored flag is a second source of truth that can be wrong.
|
||||
///
|
||||
/// ── The rider's press decides this, not the server's rung ──
|
||||
///
|
||||
/// This read the consignment state first and only fell back to the local
|
||||
/// sets when the row carried none. That is the right instinct — a local flag
|
||||
/// must never outrank a server fact — and it produced the wrong answer for
|
||||
/// the case that is live on production today.
|
||||
///
|
||||
/// Two backend behaviours ship at once. With `MILER_COLLECTED_STATE_ENABLED`
|
||||
/// **on**, `pickup-complete` stops at `Collected_By_Miler` and this method's
|
||||
/// old reading is exactly right. With it **off** — compatibility mode, which
|
||||
/// is the default and what the app meets in production — `pickup-complete`
|
||||
/// releases the consignment itself, so a stop arrives on this tab already
|
||||
/// `Out_for_Delivery`, `needsRelease` answers false, and the round starts
|
||||
/// itself. The rider never sees PICKED and **Start delivery** never appears:
|
||||
/// he collects at the kitchen and the app has him on the road before he has
|
||||
/// left the counter.
|
||||
///
|
||||
/// So the two questions are separated, because they were never the same one:
|
||||
///
|
||||
/// ```
|
||||
/// may this be delivered? → the consignment. ConsignmentGate, deliver
|
||||
/// has he set off? → the rider. this method
|
||||
/// ```
|
||||
///
|
||||
/// Nothing here contradicts the server. A stop the app holds at PICKED is
|
||||
/// still `Out_for_Delivery` on the wire, `deliver` is still gated on the
|
||||
/// consignment's own state, and pressing **Start delivery** on a consignment
|
||||
/// that is already released costs no request at all — `releaseForDelivery`
|
||||
/// returns true on sight of a deliverable state. What changes is only which
|
||||
/// of the two facts the rider's card is showing him, and the answer is the
|
||||
/// one he can act on.
|
||||
///
|
||||
/// The consignment state stays the fallback for a stop this device has no
|
||||
/// record of — collected on another handset, or after a reinstall.
|
||||
bool _needsRelease(Map<String, dynamic> stop) {
|
||||
final id = MilkRun.idOf(stop);
|
||||
// He pressed it. Nothing outranks that.
|
||||
if (_outForDeliveryIds.contains(id)) return false;
|
||||
// He collected it here and has not pressed it.
|
||||
if (_collectedIds.contains(id)) return true;
|
||||
return consignmentStateFromRaw(stop['consignmentstatus']).needsRelease;
|
||||
}
|
||||
|
||||
/// True while the round is being marked started, so the bar cannot be
|
||||
/// double-pressed.
|
||||
/// True while a round is being marked started, so [startRound] cannot run
|
||||
/// twice over the same stop.
|
||||
bool _starting = false;
|
||||
|
||||
/// Sets off: releases each collected consignment for delivery, server first.
|
||||
@@ -1508,28 +1554,48 @@ class _MyPickupsState extends State<MyPickups>
|
||||
///
|
||||
/// **The local set is written only for what the server released.** A stop
|
||||
/// whose consignment could not be reached or was refused keeps its PICKED
|
||||
/// word and stays on the bar, because the alternative — showing ACTIVE for
|
||||
/// a consignment the hub still calls collected — is the fiction that made
|
||||
/// `deliver` fail at the door with nothing on screen to explain why.
|
||||
/// word, because the alternative — showing ACTIVE for a consignment the hub
|
||||
/// still calls collected — is the fiction that made `deliver` fail at the
|
||||
/// door with nothing on screen to explain why.
|
||||
///
|
||||
/// Returns the number of orders actually released.
|
||||
/// ── The bar that used to call this is gone ──
|
||||
///
|
||||
/// There was a **Start delivery** bar pinned above the nav on this screen:
|
||||
/// `5 orders in hand`, one press, release the lot. It was removed at the
|
||||
/// rider's request, and the argument for it does not survive contact with how
|
||||
/// the round is actually ridden.
|
||||
///
|
||||
/// It existed because "I have set off" is a claim only the rider can make,
|
||||
/// and that is still true — but he already makes it, once per stop, when he
|
||||
/// slides **Start ride** on the map. That slider calls straight into this
|
||||
/// method (see `map.dart`), so the release was never exclusive to the bar:
|
||||
/// the bar was a second way to make the same claim, earlier, in bulk, for
|
||||
/// stops he had not set off for yet.
|
||||
///
|
||||
/// Which is what made it worth removing rather than merely redundant. A
|
||||
/// batch release says the whole load is on the road at a moment when at most
|
||||
/// one bag is, so the hub's board read *active* for four stops still in the
|
||||
/// rider's box — the exact failure the bar was introduced to fix, moved one
|
||||
/// step later in the day. Releasing per stop, at the moment he rides to it,
|
||||
/// is the honest version of the same act.
|
||||
///
|
||||
/// Nothing is stranded: a consignment that needs releasing is released when
|
||||
/// he sets off for it, and one already released costs no request at all —
|
||||
/// `releaseForDelivery` returns true on sight of a deliverable state.
|
||||
///
|
||||
/// Still takes a list, because the caller passes one and a batch of one is
|
||||
/// the common case. Returns the number of orders actually released.
|
||||
Future<int> startRound(List<Map<String, dynamic>> stops) async {
|
||||
if (_starting || stops.isEmpty) return 0;
|
||||
setState(() => _starting = true);
|
||||
try {
|
||||
final released = <String>[];
|
||||
var unreachable = 0;
|
||||
|
||||
for (final stop in stops) {
|
||||
final orderId = MilkRun.idOf(stop);
|
||||
if (orderId.isEmpty) continue;
|
||||
if (await releaseForDelivery(stop)) {
|
||||
released.add(orderId);
|
||||
} else {
|
||||
unreachable++;
|
||||
}
|
||||
if (await releaseForDelivery(stop)) released.add(orderId);
|
||||
}
|
||||
_unreleasedFailures = unreachable;
|
||||
if (released.isEmpty) return 0;
|
||||
|
||||
await addOutForDeliveryOrderIds(released);
|
||||
@@ -1548,52 +1614,16 @@ class _MyPickupsState extends State<MyPickups>
|
||||
}
|
||||
}
|
||||
|
||||
/// How many stops the last [startRound] could not release. Read once by
|
||||
/// [_startWholeRound] to word the message honestly.
|
||||
int _unreleasedFailures = 0;
|
||||
|
||||
/// Scroll extent to reserve under the queue for whatever floats over it.
|
||||
///
|
||||
/// Reads the same two conditions the slot itself reads, so the floor and the
|
||||
/// surface can never disagree about which one is on screen.
|
||||
/// Reads the same condition the slot itself reads, so the floor and the
|
||||
/// surface can never disagree about what is on screen.
|
||||
double _bottomReservation(BuildContext context) => bottomReservationFor(
|
||||
inset: BottomPage.bottomInset(context),
|
||||
live: _liveOnThisScreen.isNotEmpty,
|
||||
unreleased: _unreleased.isNotEmpty,
|
||||
idle: 8.h,
|
||||
);
|
||||
|
||||
Future<void> _startWholeRound() async {
|
||||
final stops = _unreleased;
|
||||
final n = await startRound(stops);
|
||||
if (!mounted) return;
|
||||
|
||||
final held = _unreleasedFailures;
|
||||
if (n == 0) {
|
||||
// One stop, one reason: say it. `releaseForDelivery` knows whether the
|
||||
// parcel is at a hub, missing its reference, refused, or genuinely
|
||||
// unreachable — see [lastReleaseFailure] — and "check your connection"
|
||||
// is true for only the last of those.
|
||||
//
|
||||
// A whole round that failed keeps the general wording: with several
|
||||
// stops there is no single reason to give, and the per-stop detail is in
|
||||
// the log.
|
||||
AppFeedback.errorGlobal(
|
||||
held == 1
|
||||
? (lastReleaseFailure ??
|
||||
'Could not start that delivery. Check your connection and '
|
||||
'try again.')
|
||||
: 'Could not start the round. Check your connection and try again.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
AppFeedback.successGlobal(
|
||||
held > 0
|
||||
? '$n started · $held could not be started'
|
||||
: (n == 1 ? 'Delivery started' : '$n deliveries started'),
|
||||
);
|
||||
}
|
||||
|
||||
/// Marks a stop as finished (picked up or cancelled) in local state right
|
||||
/// away, so the UI unblocks without waiting for the backend/poll to catch up:
|
||||
/// it leaves the active banner, drops out of the top list, and no longer
|
||||
@@ -1874,7 +1904,7 @@ class _MyPickupsState extends State<MyPickups>
|
||||
// The bag each order is in, re-read with the list it labels: a pickup that
|
||||
// happened on Home a moment ago must show its bags here without the rider
|
||||
// pulling to refresh.
|
||||
unawaited(_loadBagLabels());
|
||||
unawaited(_loadOrderLabels());
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final userId = prefs.getInt('userId') ?? prefs.getInt('userid') ?? 0;
|
||||
@@ -1934,7 +1964,7 @@ class _MyPickupsState extends State<MyPickups>
|
||||
}
|
||||
}
|
||||
|
||||
final items = mergedOrders.values.toList();
|
||||
var items = mergedOrders.values.toList();
|
||||
debugPrint('[MYPICKUPS] Merged items count: ${items.length}');
|
||||
|
||||
// Today's finished stops, for the rail. Read on every fetch rather than
|
||||
@@ -1957,6 +1987,21 @@ class _MyPickupsState extends State<MyPickups>
|
||||
debugPrint('[MYPICKUPS] Merged $added accepted bookings from Home');
|
||||
}
|
||||
|
||||
// ── Today's, and only today's ──
|
||||
//
|
||||
// Applied here, once, on the merged day: after both endpoints and the
|
||||
// accepted store, and before anything is derived from it. Everything
|
||||
// below reads this list — the trip numbering, the milk run's whole-day
|
||||
// set, the delivery queue, the rail — so filtering at one boundary is
|
||||
// what stops a count above the queue from totalling a different set of
|
||||
// days than the queue under it.
|
||||
//
|
||||
// `_completedToday` is deliberately not put through it: the completed
|
||||
// store prunes itself to today on read, and it was already correct.
|
||||
//
|
||||
// See [_todayOnly], and [ServiceDay.onlyToday] for the rule Home shares.
|
||||
items = _todayOnly(items);
|
||||
|
||||
// ── The day's shape, before any of this screen's filtering ──
|
||||
//
|
||||
// Everything today: what is assigned, what he took, what he finished.
|
||||
@@ -3206,7 +3251,7 @@ class _MyPickupsState extends State<MyPickups>
|
||||
// The pairing recorded at the
|
||||
// counter — same store as the
|
||||
// card, so the two agree.
|
||||
bag: row.bag,
|
||||
label: row.label,
|
||||
// The carried set — what turns
|
||||
// a raw pickup row into the
|
||||
// delivery leg the sheet must
|
||||
@@ -3236,14 +3281,14 @@ class _MyPickupsState extends State<MyPickups>
|
||||
distanceMeters,
|
||||
riderSpeedMps:
|
||||
_currentLocation?.speed,
|
||||
bag: row.bag,
|
||||
label: row.label,
|
||||
collectedIds: _collectedIds,
|
||||
outForDeliveryIds:
|
||||
_outForDeliveryIds,
|
||||
),
|
||||
QueueDensity.next => _NextCard(
|
||||
item: item,
|
||||
bag: row.bag,
|
||||
label: row.label,
|
||||
distanceMeters:
|
||||
distanceMeters,
|
||||
riderSpeedMps:
|
||||
@@ -3252,7 +3297,7 @@ class _MyPickupsState extends State<MyPickups>
|
||||
),
|
||||
QueueDensity.later => _LaterRow(
|
||||
item: item,
|
||||
bag: row.bag,
|
||||
label: row.label,
|
||||
distanceMeters:
|
||||
distanceMeters,
|
||||
onOpen: open,
|
||||
@@ -3260,7 +3305,7 @@ class _MyPickupsState extends State<MyPickups>
|
||||
QueueDensity.skipped =>
|
||||
_SkippedRow(
|
||||
item: item,
|
||||
bag: row.bag,
|
||||
label: row.label,
|
||||
reason: row.reason,
|
||||
onOpen: open,
|
||||
onResume: () =>
|
||||
@@ -3347,13 +3392,6 @@ class _MyPickupsState extends State<MyPickups>
|
||||
}
|
||||
},
|
||||
)
|
||||
: _unreleased.isNotEmpty
|
||||
? StartRoundBar(
|
||||
key: const ValueKey('start-round'),
|
||||
count: _unreleased.length,
|
||||
busy: _starting,
|
||||
onPressed: _startWholeRound,
|
||||
)
|
||||
: const SizedBox.shrink(key: ValueKey('none')),
|
||||
),
|
||||
),
|
||||
@@ -3366,113 +3404,27 @@ class _MyPickupsState extends State<MyPickups>
|
||||
}
|
||||
}
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// START DELIVERY — one press, the whole load
|
||||
///
|
||||
/// The bar the rider hits after the last counter. It states what it is about to
|
||||
/// act on — `5 orders in hand` — because the press releases every one of them,
|
||||
/// and a control acting on a set the rider cannot see is one he learns not to
|
||||
/// trust.
|
||||
///
|
||||
/// ── Not a slide ──
|
||||
///
|
||||
/// Slides are reserved for the writes a resting thumb must not be able to make:
|
||||
/// "picked" is the rider asserting he is holding somebody's lunch, "delivered"
|
||||
/// that he has handed it over. Setting off is neither irreversible nor a claim
|
||||
/// about somebody else's property — pressing it early costs a hub board that
|
||||
/// reads *active* a few minutes early — so it is a button, at the size of every
|
||||
/// other primary action in the app.
|
||||
///
|
||||
/// It sits above the floating nav bar rather than under it: [BottomPage] owns
|
||||
/// that inset, and using it here is what keeps the button clear of the tab
|
||||
/// strip on a tall phone.
|
||||
/// How much scroll extent the queue must leave under itself.
|
||||
///
|
||||
/// ── Why this is a function and not a number ──
|
||||
///
|
||||
/// It was two branches and a magic constant: the unreleased case was built
|
||||
/// from its bar's own pieces, and the live case was a flat `80.h` that forgot
|
||||
/// the device inset entirely — so on a gesture-navigation phone the last row of
|
||||
/// the run sat behind the banner *and* behind the navigation, with its controls
|
||||
/// unreachable and no way to scroll to them.
|
||||
/// It was two branches and a magic constant, and the live case was a flat
|
||||
/// `80.h` that forgot the device inset entirely — so on a gesture-navigation
|
||||
/// phone the last row of the run sat behind the banner *and* behind the
|
||||
/// navigation, with its controls unreachable and no way to scroll to them.
|
||||
///
|
||||
/// One rule, reading the same two conditions the slot itself reads, so the
|
||||
/// floor and the surface can never disagree about which one is on screen. Each
|
||||
/// surface states its own height, because the thing that overlays a list is the
|
||||
/// only thing that knows how much of the list it covers.
|
||||
/// One rule, reading the same condition the slot itself reads, so the floor and
|
||||
/// the surface can never disagree about what is on screen. The surface states
|
||||
/// its own height, because the thing that overlays a list is the only thing
|
||||
/// that knows how much of the list it covers.
|
||||
///
|
||||
/// ── One surface, where there were two ──
|
||||
///
|
||||
/// The other was the **Start delivery** bar, and it is gone with the control —
|
||||
/// see the note above `startRound`. Nothing floats over this
|
||||
/// list now except the live strip.
|
||||
double bottomReservationFor({
|
||||
required double inset,
|
||||
required bool live,
|
||||
required bool unreleased,
|
||||
double idle = 8,
|
||||
}) {
|
||||
if (live) return inset + ActivePickupBanner.maxHeight;
|
||||
if (unreleased) return inset + StartRoundBar.maxHeight;
|
||||
return inset + idle;
|
||||
}
|
||||
|
||||
class StartRoundBar extends StatelessWidget {
|
||||
/// The tallest this bar gets, excluding the device inset. See
|
||||
/// [ActivePickupBanner.maxHeight] for why the surface declares its own.
|
||||
static double get maxHeight => ButtonSizes.primary + 54.h;
|
||||
|
||||
final int count;
|
||||
final bool busy;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const StartRoundBar({
|
||||
super.key,
|
||||
required this.count,
|
||||
required this.busy,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16.w,
|
||||
12.h,
|
||||
16.w,
|
||||
BottomPage.bottomInset(context) + 10.h,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.pureSurface,
|
||||
boxShadow: [
|
||||
// The list scrolls under this, so the edge has to read as an edge — a
|
||||
// shadow rather than a rule, for the same reason the route cards have
|
||||
// one: a hairline is not there on a scratched screen in sun, and a
|
||||
// rule across a scrolling list reads as a divider between two rows.
|
||||
BoxShadow(
|
||||
color: ColorConstants.slateText.withValues(alpha: 0.10),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, -4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'$count ${count == 1 ? 'order' : 'orders'} in hand',
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
MilerButton(
|
||||
label: 'Start delivery',
|
||||
icon: LucideIcons.truck,
|
||||
color: ColorConstants.primary,
|
||||
loading: busy,
|
||||
onPressed: busy ? null : onPressed,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}) => live ? inset + ActivePickupBanner.maxHeight : inset + idle;
|
||||
|
||||
@@ -1237,12 +1237,12 @@ class _StopBrief extends StatelessWidget {
|
||||
/// `Bag 5 · Vidhya Kitchen`, whichever halves exist; the trimmed booking
|
||||
/// reference only as a last resort.
|
||||
String _subtitle(String orderId) {
|
||||
final bag = (pickup['baglabel'] ?? pickup['BagLabel'] ?? '')
|
||||
final printed = (pickup['baglabel'] ?? pickup['BagLabel'] ?? '')
|
||||
.toString()
|
||||
.trim();
|
||||
final source = stopSourceName(pickup);
|
||||
final line = [
|
||||
if (bag.isNotEmpty) bag,
|
||||
if (printed.isNotEmpty) printed,
|
||||
if (source.isNotEmpty) source,
|
||||
].join(' \u00b7 ');
|
||||
if (line.isNotEmpty) return line;
|
||||
|
||||
@@ -169,12 +169,18 @@ String stopSourceId(Map<String, dynamic> item) =>
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
/// The label physically on the bag, matched at the kitchen and read at the door.
|
||||
/// The label the counter physically printed on an order, matched at the kitchen
|
||||
/// and read at the door. Empty when it printed none.
|
||||
///
|
||||
/// Ten near-identical lunch bags is where mis-delivery comes from, and no OTP or
|
||||
/// Ten near-identical lunches is where mis-delivery comes from, and no OTP or
|
||||
/// photo catches it — the rider is confident, he is just at the wrong door with
|
||||
/// the wrong bag. A printed label on the card is the cheapest thing that does.
|
||||
String stopBagLabel(Map<String, dynamic> item) =>
|
||||
/// the wrong one. A printed label on the card is the cheapest thing that does.
|
||||
///
|
||||
/// The key names are the wire's and stay as the wire spells them; what the app
|
||||
/// does with the value is its own business. Nothing is derived here — a counter
|
||||
/// that prints nothing yields `''`, and every screen then shows no tag rather
|
||||
/// than a number the app made up. See [OrderManifest.labelFor].
|
||||
String stopPrintedLabel(Map<String, dynamic> item) =>
|
||||
(item['baglabel'] ??
|
||||
item['BagLabel'] ??
|
||||
item['bagid'] ??
|
||||
|
||||
@@ -107,6 +107,44 @@ class ColorConstants {
|
||||
static const Color onWarning = Color(0xFFFFFFFF);
|
||||
static const Color warningContainer = Color(0xFFFEF3C7);
|
||||
|
||||
/// **Waiting on the rider** — the state word on a stop he has not decided yet.
|
||||
///
|
||||
/// ── Why a fourth amber, and not one of the three above ──
|
||||
///
|
||||
/// Pending was neutral grey, on a real argument: on a fresh morning *every*
|
||||
/// stop is pending, so painting them all in the exception hue opens the whole
|
||||
/// screen in warning and leaves nothing to say with when something actually
|
||||
/// goes wrong. That argument is still right about [warning], which is this
|
||||
/// app's word for *something has gone off plan* — a skipped stop, a short
|
||||
/// payment, a bag that never arrived.
|
||||
///
|
||||
/// What it got wrong is that "nobody has done anything about this yet" is
|
||||
/// itself worth a colour. A rider scanning a run had to infer it from the
|
||||
/// *absence* of one, which is the single thing a glance cannot do, and
|
||||
/// undecided work is precisely the work the screen exists to move.
|
||||
///
|
||||
/// So it gets its own: a gold that reads as "waiting" rather than as
|
||||
/// "wrong", far enough round the wheel from the burnt orange of [warning]
|
||||
/// that a pending stop and a skipped one are two different marks — and dark
|
||||
/// enough to be *text*, which no honest yellow on a light ground can be
|
||||
/// without going most of the way to brown. 5.54:1 on white, 4.83:1 on the
|
||||
/// canvas, 4.81:1 on [cardSurface]: AA at the 13.5sp these words are set at,
|
||||
/// on every surface Home draws them on.
|
||||
static const Color pendingAmber = Color(0xFF8A6100);
|
||||
|
||||
/// **In the rider's hands** — collected from the counter, not yet delivered.
|
||||
///
|
||||
/// Green is the wrong end of the story for it. [acceptGreen] now marks the
|
||||
/// moment the rider *takes a stop on*, and green everywhere else in this app
|
||||
/// means finished — so a bag that has been picked up but not delivered would
|
||||
/// have worn either "already decided" or "already done", and it is neither.
|
||||
///
|
||||
/// This is the app's own delivery hue, which is exactly what a picked bag has
|
||||
/// become: work that has left the pickup leg and belongs to the round. Same
|
||||
/// value as [deliveryAccent], named separately so the two can part company if
|
||||
/// the route map ever needs its own.
|
||||
static const Color pickedBlue = deliveryAccent;
|
||||
|
||||
// Extended Color Palette for Professional UI
|
||||
/// Captions, labels and secondary body text — the second most-used colour in
|
||||
/// the app after [slateText], on 89 call sites across every screen.
|
||||
|
||||
@@ -1989,7 +1989,7 @@ class ErrorRetry extends StatelessWidget {
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
class StopSourceLine extends StatelessWidget {
|
||||
final String source;
|
||||
final String bagLabel;
|
||||
final String printedLabel;
|
||||
|
||||
/// Dims both when the stop is not actionable, matching the card around it.
|
||||
final bool muted;
|
||||
@@ -2001,12 +2001,12 @@ class StopSourceLine extends StatelessWidget {
|
||||
const StopSourceLine({
|
||||
super.key,
|
||||
required this.source,
|
||||
required this.bagLabel,
|
||||
required this.printedLabel,
|
||||
this.muted = false,
|
||||
this.topGap = 6,
|
||||
});
|
||||
|
||||
bool get _isEmpty => source.isEmpty && bagLabel.isEmpty;
|
||||
bool get _isEmpty => source.isEmpty && printedLabel.isEmpty;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -2053,12 +2053,12 @@ class StopSourceLine extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (source.isNotEmpty && bagLabel.isNotEmpty)
|
||||
if (source.isNotEmpty && printedLabel.isNotEmpty)
|
||||
const SizedBox(width: 8),
|
||||
if (bagLabel.isNotEmpty)
|
||||
if (printedLabel.isNotEmpty)
|
||||
Flexible(
|
||||
child: Text(
|
||||
'Bag $bagLabel',
|
||||
printedLabel,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
|
||||
Reference in New Issue
Block a user