device infos

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

@@ -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) {