Miler rider app: surface system, visible design language, backend lifecycle
Design system - MilerSurface ladder (canvas → working → raised → floating) with MilerPanel as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1. - Visible vocabulary applied across Home, Deliveries, Activity, Account and the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x), canvas wells for anything that opens, small filled tags for shelf labels, demoted placeholders. Recorded in DESIGN_SYSTEM.md §6. - One icon family: 222 Material glyphs migrated to Lucide; none left outside lib/xpress. - Colour semantics corrected: amber only for what is genuinely owed, brand red reserved for the live stop, disabled primaries go neutral rather than pale. Data and lifecycle - lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart makes admin sequence the single ordering authority; service_day.dart, and stop_area.dart rewritten against live Coimbatore addresses (digit-token stripping, city stoplist, street suffixes, stammer collapse). - countLabel states the load once, in bags. Testing - 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity, sheets and verify, with test/failures/ now gitignored (diff debris). - New pins: home_gutter_test, stop_area_test, plus updated structural bounds. Note: this commit also carries pre-existing working-tree deletions that were present before this work (API_SPEC.md, README.md, demo test fixtures). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,23 @@ class _PickupBottomSheet extends StatefulWidget {
|
||||
|
||||
class _PickupBottomSheetState extends State<_PickupBottomSheet>
|
||||
with SingleTickerProviderStateMixin {
|
||||
/// Orders the rider is carrying, as the queue screen knows them.
|
||||
///
|
||||
/// "Picked up" is not a status here — the accepted store normalises these
|
||||
/// rows to `accepted`, and the hand-over is recorded in the collected set. So
|
||||
/// the leg has to be asked with that set in hand or a bag already in the box
|
||||
/// reads as still needing collection. See [MilkRun.workingKind].
|
||||
Set<String> get _carrying =>
|
||||
(widget.parentState ?? context.findAncestorStateOfType<_MyPickupsState>())
|
||||
?._collectedIds ??
|
||||
const <String>{};
|
||||
|
||||
/// Where this stop's *delivery* goes, or null when it carries no coordinates
|
||||
/// for that leg. Read from [MilkRun] so this sheet, the route row and the map
|
||||
/// screen cannot disagree about the destination.
|
||||
({double lat, double lng})? get _deliveryTarget =>
|
||||
MilkRun.navigationTarget(widget.pickup, collectedIds: _carrying);
|
||||
|
||||
String selectedOption = '';
|
||||
bool isPickupsSelected = true;
|
||||
bool showOptions = false;
|
||||
@@ -73,20 +90,17 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
|
||||
lastFailureTime = null;
|
||||
}
|
||||
|
||||
bool _hasCollection() {
|
||||
final raw = widget.pickup['collectionamt'];
|
||||
if (raw == null) return false;
|
||||
if (raw is num) return raw > 0;
|
||||
final parsed = double.tryParse(raw.toString()) ?? 0.0;
|
||||
return parsed > 0;
|
||||
}
|
||||
// Both of these read the amount through [stopCollectionAmount] rather than
|
||||
// reaching into the map themselves.
|
||||
//
|
||||
// They used to parse `collectionamt` inline, which meant they missed the
|
||||
// `pickupamt` / `codamount` spellings the shared helper accepts — this sheet
|
||||
// could show "No collection" on a stop the card behind it had priced. It is
|
||||
// also the gate that retires cash entirely for a subscription tenant, and a
|
||||
// second reader would have walked straight past it.
|
||||
bool _hasCollection() => stopCollectionAmount(widget.pickup) > 0;
|
||||
|
||||
double _getCollectionAmount() {
|
||||
final raw = widget.pickup['collectionamt'];
|
||||
if (raw == null) return 0.0;
|
||||
if (raw is num) return raw.toDouble();
|
||||
return double.tryParse(raw.toString()) ?? 0.0;
|
||||
}
|
||||
double _getCollectionAmount() => stopCollectionAmount(widget.pickup);
|
||||
|
||||
double _getPickupAmount() {
|
||||
final raw = widget.pickup['pickupamt'];
|
||||
@@ -102,31 +116,41 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
|
||||
required Color color,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
// ── Tonal, not outlined ──
|
||||
//
|
||||
// Three bordered buttons stacked under one filled button is four rectangles
|
||||
// competing on a sheet whose question has one likely answer. The rider
|
||||
// delivered it; the other two are what he reaches for when something went
|
||||
// wrong.
|
||||
//
|
||||
// A tinted fill with no border is the same treatment the state chips on the
|
||||
// route use, so "amber means parked, red means it did not happen" is one
|
||||
// vocabulary across the app rather than a per-screen invention — and it
|
||||
// drops an edge from a surface that already has plenty.
|
||||
return SizedBox(
|
||||
height: ButtonSizes.secondary,
|
||||
child: OutlinedButton.icon(
|
||||
child: TextButton.icon(
|
||||
onPressed: onTap,
|
||||
icon: Icon(icon, color: color, size: 18.sp),
|
||||
icon: Icon(icon, color: color, size: 19.sp),
|
||||
label: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontSize: 14.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.2,
|
||||
color: color,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: BorderSide(color: color.withValues(alpha: 0.4)),
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor: color.withValues(alpha: 0.10),
|
||||
foregroundColor: color,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(ButtonSizes.radius),
|
||||
),
|
||||
// The theme gives OutlinedButton `vertical: 16` for full-width form
|
||||
// buttons. In a 48dp box that leaves a 16px content band, which was
|
||||
// painting this row's 18sp icon at 16px — measured, and silent.
|
||||
padding: EdgeInsets.symmetric(horizontal: 14.w),
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
@@ -138,7 +162,15 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
|
||||
Widget build(BuildContext context) {
|
||||
// Delivery stops are prepaid: no payment step, the primary action is
|
||||
// "Delivered" (a straight confirm) rather than "Picked up" + payment.
|
||||
final bool isDelivery = stopKindOf(widget.pickup).isDelivery;
|
||||
//
|
||||
// Asked of the *leg*, not the payload. The booking adapter stamps
|
||||
// `type: pickup` on every row, so a milk-run order already in the rider's
|
||||
// box still called itself a pickup — and this sheet asked him to confirm
|
||||
// collecting something he was carrying. See [MilkRun.workingKind].
|
||||
final bool isDelivery = MilkRun.workingKind(
|
||||
widget.pickup,
|
||||
collectedIds: _carrying,
|
||||
).isDelivery;
|
||||
|
||||
// Only the cancel path asks a question here now. Money is asked about once,
|
||||
// on the payment page — see [_onPickedUp].
|
||||
@@ -148,12 +180,32 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
|
||||
? ColorConstants.acceptGreen
|
||||
: ColorConstants.errorRed;
|
||||
|
||||
// ── Name the thing the button actually does ──
|
||||
//
|
||||
// On logistics this press is not "confirm a pickup". It posts
|
||||
// `pickup-complete`, which converts the booking into a live consignment
|
||||
// with a tracking number and routes it to a hub — the shipment begins to
|
||||
// exist here, and the rider is the one who starts it. The app has always
|
||||
// done that and never said so, which is why the flow reads as five
|
||||
// unrelated forms rather than one order being raised.
|
||||
//
|
||||
// A milk-run drop is a hand-over and keeps its own word; anything else
|
||||
// stays "Confirm pickup".
|
||||
final String confirmLabel = isDelivery
|
||||
? 'Confirm delivery'
|
||||
: (ServiceProfile.active.initiatesShipment
|
||||
? 'Initiate order'
|
||||
: 'Confirm pickup');
|
||||
|
||||
final String modeLabel = isPickupsSelected
|
||||
? (isDelivery ? 'Delivered' : 'Picked up')
|
||||
: (isDelivery ? 'Undelivered' : 'Cancelled');
|
||||
// "Cancelled" was the rider cancelling a *booking*, which is the hub's
|
||||
// word for it and not his. What happened at the door is that he did not
|
||||
// get the parcel — see the action row below.
|
||||
: (isDelivery ? 'Not delivered' : 'Not picked');
|
||||
final IconData modeIcon = isPickupsSelected
|
||||
? Icons.check_circle_rounded
|
||||
: Icons.cancel_rounded;
|
||||
? LucideIcons.circleCheck
|
||||
: LucideIcons.circleX;
|
||||
|
||||
// A reason list belongs to the cancel path alone. A completed pickup has
|
||||
// nothing left to ask: the payment page has already taken the method, the
|
||||
@@ -175,17 +227,7 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// ── Handle + Header ─────────────────────────────────────
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40.w,
|
||||
height: 4.h,
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.borderSubtle,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 14.h),
|
||||
const MilerSheetHandle(),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
@@ -198,18 +240,20 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: confirmColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
borderRadius: BorderRadius.circular(
|
||||
DesignConstants.radiusXl,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(modeIcon, color: confirmColor, size: 14.sp),
|
||||
SizedBox(width: 5.w),
|
||||
Icon(modeIcon, color: confirmColor, size: 15.sp),
|
||||
SizedBox(width: 6.w),
|
||||
Text(
|
||||
modeLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 12.5.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: confirmColor,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -221,16 +265,23 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
// ── This sheet is the confirmation page now ──
|
||||
//
|
||||
// Nothing opens before it any more: the rider taps "I've
|
||||
// arrived" on the map and lands here. So the heading
|
||||
// names the stop he is closing rather than announcing a
|
||||
// status update, and the three answers underneath are
|
||||
// the whole of what the app asks him at a door.
|
||||
showOptions
|
||||
? (isPickupsSelected
|
||||
? (isDelivery
|
||||
? 'Confirm delivery'
|
||||
: 'Collect payment')
|
||||
: 'Select reason')
|
||||
: 'Update Status',
|
||||
: 'What happened?')
|
||||
: confirmLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 19.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
color: ColorConstants.onSurface,
|
||||
),
|
||||
@@ -253,13 +304,11 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
|
||||
child: Container(
|
||||
padding: EdgeInsets.all(8.r),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.neutralLight,
|
||||
color: MilerSurface.canvas,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
showOptions
|
||||
? Icons.arrow_back_rounded
|
||||
: Icons.close_rounded,
|
||||
showOptions ? LucideIcons.arrowLeft : LucideIcons.x,
|
||||
color: ColorConstants.onSurface,
|
||||
size: 18.sp,
|
||||
),
|
||||
@@ -280,21 +329,62 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
|
||||
// Three clear choices, one tap each. Primary = Picked up /
|
||||
// Delivered; then Skip and Cancel / Undelivered.
|
||||
if (showSlider) ...[
|
||||
// Primary: Picked up / Delivered
|
||||
// ── Navigate, on the sheet the rider is actually looking at ──
|
||||
//
|
||||
// The delivery leg is the one where he is driving to an
|
||||
// address he has never been to, and re-opening maps meant
|
||||
// dismissing this sheet, finding the button on the screen
|
||||
// behind it, and coming back. Every delivery app a rider has
|
||||
// used keeps the journey action on the live-stop surface for
|
||||
// exactly that reason.
|
||||
//
|
||||
// Only on the delivery leg: a collection is worked in bulk at
|
||||
// one counter from Home, where the kitchen card carries its
|
||||
// own Navigate.
|
||||
//
|
||||
// Hidden — not disabled — when the stop has no coordinates for
|
||||
// this leg: a Navigate that opens a map on nothing is worse
|
||||
// than none, because the rider only finds out after
|
||||
// committing to follow it. Same rule as the route row's.
|
||||
if (isDelivery && _deliveryTarget != null) ...[
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: ButtonSizes.secondary,
|
||||
child: MilerButton(
|
||||
label: 'Navigate',
|
||||
icon: LucideIcons.navigation,
|
||||
color: ColorConstants.primary,
|
||||
height: ButtonSizes.secondary,
|
||||
onPressed: () {
|
||||
final t = _deliveryTarget!;
|
||||
openExternalNavigation(t.lat, t.lng);
|
||||
},
|
||||
),
|
||||
),
|
||||
SizedBox(height: 10.h),
|
||||
],
|
||||
// Primary: Picked up / Delivered.
|
||||
//
|
||||
// The three outcomes are the whole vocabulary of a stop:
|
||||
// **Picked up** (he has it), **Not picked** (he was there and
|
||||
// did not get it — the sheet then asks why), **Skipped** (he
|
||||
// is parking it for a return visit). One tap chooses; nothing
|
||||
// else is asked of him at the door.
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: ButtonSizes.primary,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => _onPickedUp(isDelivery),
|
||||
icon: const Icon(
|
||||
Icons.check_circle_rounded,
|
||||
LucideIcons.circleCheck,
|
||||
color: Colors.white,
|
||||
),
|
||||
label: Text(
|
||||
isDelivery ? 'Delivered' : 'Picked up',
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 16.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.3,
|
||||
color: Colors.white,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -303,7 +393,9 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
|
||||
backgroundColor: ColorConstants.acceptGreen,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
borderRadius: BorderRadius.circular(
|
||||
DesignConstants.radiusXl,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -314,8 +406,8 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
|
||||
children: [
|
||||
Expanded(
|
||||
child: _sheetSecondaryButton(
|
||||
label: 'Skip',
|
||||
icon: Icons.skip_next_rounded,
|
||||
label: 'Skipped',
|
||||
icon: LucideIcons.clock,
|
||||
color: ColorConstants.warning,
|
||||
onTap: () async {
|
||||
final resolvedParentState =
|
||||
@@ -334,8 +426,14 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
|
||||
SizedBox(width: 10.w),
|
||||
Expanded(
|
||||
child: _sheetSecondaryButton(
|
||||
label: isDelivery ? 'Undelivered' : 'Cancel',
|
||||
icon: Icons.cancel_rounded,
|
||||
// "Cancelled" on the delivery leg, by product
|
||||
// decision, and it is also what the press actually
|
||||
// does: the reason list behind it posts a cancel. The
|
||||
// pickup leg keeps "Not picked", which is the honest
|
||||
// description of standing at a counter and being
|
||||
// handed nothing.
|
||||
label: isDelivery ? 'Cancelled' : 'Not picked',
|
||||
icon: LucideIcons.circleX,
|
||||
color: ColorConstants.errorRed,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
@@ -376,7 +474,7 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < options.length; i++)
|
||||
_ReasonRow(
|
||||
MilerSheetChoiceRow(
|
||||
label: options[i],
|
||||
icon: _cancelIcon(options[i]),
|
||||
accent: confirmColor,
|
||||
@@ -416,7 +514,9 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
|
||||
),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
borderRadius: BorderRadius.circular(
|
||||
DesignConstants.radiusXl,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: isLoading
|
||||
@@ -435,8 +535,8 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
|
||||
children: [
|
||||
Icon(
|
||||
isPickupsSelected
|
||||
? Icons.check_rounded
|
||||
: Icons.close_rounded,
|
||||
? LucideIcons.check
|
||||
: LucideIcons.x,
|
||||
size: 22.sp,
|
||||
color: Colors.white,
|
||||
),
|
||||
@@ -449,17 +549,15 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
|
||||
Flexible(
|
||||
child: Text(
|
||||
isPickupsSelected
|
||||
? (isDelivery
|
||||
? 'Confirm delivery'
|
||||
: 'Confirm pickup')
|
||||
? confirmLabel
|
||||
: (isDelivery
|
||||
? 'Mark undelivered'
|
||||
: 'Cancel booking'),
|
||||
? 'Mark not delivered'
|
||||
: 'Mark not picked'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -507,6 +605,30 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
|
||||
Future<void> _onPickedUp(bool isDelivery) async {
|
||||
if (isLoading || isProcessing) return;
|
||||
|
||||
// ── One press on a service run, two on a parcel ──
|
||||
//
|
||||
// The two-step exists because a parcel stop has something to confirm: what
|
||||
// was collected, whether anything was owed, what the rider is signing off.
|
||||
// The rider states it, then commits it.
|
||||
//
|
||||
// A subscription drop has none of that. He is at a door with one bag that
|
||||
// is already paid for, and the whole chain is "I've arrived" → "I've picked
|
||||
// up" → next. Making him press *Delivered* and then *Confirm delivery* is
|
||||
// the same answer twice, ten times a lunchtime, for a stop that carries no
|
||||
// decision at all.
|
||||
if (ServiceProfile.active.handsOffAtCollection) {
|
||||
setState(() {
|
||||
isPickupsSelected = true;
|
||||
selectedOption = isDelivery ? 'Delivered' : 'Picked up';
|
||||
showOptions = false;
|
||||
showSlider = false;
|
||||
isProcessing = false;
|
||||
lastFailureTime = null;
|
||||
});
|
||||
await _handleConfirm();
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepaid, or nothing to collect: there is no money question to ask, so go
|
||||
// straight to the deliberate confirm.
|
||||
if (isDelivery || !_hasCollection()) {
|
||||
@@ -551,8 +673,8 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
|
||||
//
|
||||
// A failure here is worth telling him about, unlike the parcel step: this
|
||||
// one is his liability.
|
||||
final bookingId = widget.pickup['pickupid'] ??
|
||||
widget.pickup['orderheaderid'];
|
||||
final bookingId =
|
||||
widget.pickup['pickupid'] ?? widget.pickup['orderheaderid'];
|
||||
if (bookingId != null) {
|
||||
final payRes = await UpdatePickupProvider().submitPayment(
|
||||
bookingId,
|
||||
@@ -577,11 +699,11 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
|
||||
|
||||
IconData _cancelIcon(String option) {
|
||||
final l = option.toLowerCase();
|
||||
if (l.contains('refused')) return Icons.person_off_outlined;
|
||||
if (l.contains('reachable')) return Icons.phone_missed_rounded;
|
||||
if (l.contains('switched')) return Icons.phone_disabled_rounded;
|
||||
if (l.contains('location')) return Icons.location_off_rounded;
|
||||
return Icons.cancel_outlined;
|
||||
if (l.contains('refused')) return LucideIcons.userX;
|
||||
if (l.contains('reachable')) return LucideIcons.phoneMissed;
|
||||
if (l.contains('switched')) return LucideIcons.phoneOff;
|
||||
if (l.contains('location')) return LucideIcons.mapPinOff;
|
||||
return LucideIcons.circleX;
|
||||
}
|
||||
|
||||
// ── ALL ORIGINAL BUSINESS LOGIC BELOW — UNTOUCHED ────────────────────────
|
||||
@@ -625,7 +747,12 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
|
||||
}
|
||||
|
||||
final d = widget.pickup;
|
||||
final bool isDelivery = stopKindOf(d).isDelivery;
|
||||
// The same question the labels ask, so the button cannot say one thing and
|
||||
// post another. See [MilkRun.workingKind].
|
||||
final bool isDelivery = MilkRun.workingKind(
|
||||
d,
|
||||
collectedIds: _carrying,
|
||||
).isDelivery;
|
||||
debugPrint('[CONFIRM] ===== PICKUP INFO =====');
|
||||
debugPrint('[CONFIRM] pickupid: ${d['pickupid']}');
|
||||
debugPrint('[CONFIRM] orderid: ${d['orderid']}');
|
||||
@@ -748,26 +875,110 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
|
||||
// Best-effort API + cleanup — must NOT prevent the success screen from
|
||||
// showing. With mock/offline data these can throw; we still complete
|
||||
// the pickup locally and move on to the success screen.
|
||||
//
|
||||
// With one exception, below: a stop the app *refused to send* is not a
|
||||
// stop that failed to send.
|
||||
try {
|
||||
apiSuccess = await dc.updatePickedupStatus(
|
||||
pickupId: pickupIdInt,
|
||||
orderHeaderId: orderHeaderId,
|
||||
pickupLocationId: pickupLocationId,
|
||||
smsPickup: 0,
|
||||
ridersLat: riderLatStr,
|
||||
ridersLng: riderLngStr,
|
||||
pickupLat: pickupLat.toStringAsFixed(6),
|
||||
pickupLng: pickupLng.toStringAsFixed(6),
|
||||
notes: selectedOption,
|
||||
pickupAmount: pickupAmount,
|
||||
collectionAmount: collectionAmtToSend,
|
||||
collectedAmount: collectedAmtToSend,
|
||||
collectionStatus: collectionStatusToSend,
|
||||
wasSkipped: wasSkipped,
|
||||
orderId: orderId,
|
||||
proofImage: '',
|
||||
);
|
||||
debugPrint('[PICKUP][CONFIRM] result=$apiSuccess');
|
||||
// ── Which ending this stop has ──
|
||||
//
|
||||
// On a milk run this tab is the rider's *round*, so closing a stop
|
||||
// here is a hand-over at a customer's door, not a collection: it
|
||||
// writes `deliver` against the consignment. Sending `pickup-complete`
|
||||
// instead would re-collect an order that is already in his hands and
|
||||
// file the crate as the finished job.
|
||||
//
|
||||
// A logistics stop closes exactly as it always has.
|
||||
if (ServiceProfile.active.deliversToCustomer) {
|
||||
final dropLat = parseD(d['droplat'] ?? d['DropLat'] ?? 0);
|
||||
final dropLng = parseD(d['droplon'] ?? d['DropLon'] ?? 0);
|
||||
// ── Resolved, never read raw ──
|
||||
//
|
||||
// `GET /miler/bookings` does not return `consignmentid` at all
|
||||
// (verified live 2026-08-20), so the raw key this used to read
|
||||
// was empty on every fresh session and `updateDeliveredStatus`
|
||||
// refused before anything left the phone — the rider pressed
|
||||
// Delivered and was told "Could not record delivered" with the
|
||||
// network never touched. [resolveConsignmentId] is the same
|
||||
// four-source chain `closeDelivery` uses: the row, the pivot's
|
||||
// local record, then the assignment row on the backend, which
|
||||
// does carry the id.
|
||||
final consignmentId = await resolveConsignmentId(d);
|
||||
if (consignmentId.isEmpty) {
|
||||
// The pickup never converted this booking — deliver cannot
|
||||
// exist yet. Same message the queue's own close path shows.
|
||||
apiSuccess = false;
|
||||
if (mounted) {
|
||||
AppFeedback.error(
|
||||
context,
|
||||
'This order was never picked up on the system — mark it '
|
||||
'picked up first, then deliver.',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
apiSuccess = await dc.updateDeliveredStatus(
|
||||
pickupId: pickupIdInt,
|
||||
consignmentId: consignmentId,
|
||||
deliveredToName:
|
||||
(d['pickupcustomer'] ?? d['PickupCustomer'] ?? 'Customer')
|
||||
.toString(),
|
||||
ridersLat: riderLatStr,
|
||||
ridersLng: riderLngStr,
|
||||
// Fall back to the stop's own coordinates when the payload
|
||||
// carries no separate drop point: on a milk run the stop IS the
|
||||
// customer's address, so they are the same place.
|
||||
dropLat: (dropLat != 0 ? dropLat : pickupLat).toStringAsFixed(
|
||||
6,
|
||||
),
|
||||
dropLng: (dropLng != 0 ? dropLng : pickupLng).toStringAsFixed(
|
||||
6,
|
||||
),
|
||||
notes: selectedOption,
|
||||
);
|
||||
}
|
||||
debugPrint('[DELIVERY][CONFIRM] result=$apiSuccess');
|
||||
} else {
|
||||
apiSuccess = await dc.updatePickedupStatus(
|
||||
pickupId: pickupIdInt,
|
||||
orderHeaderId: orderHeaderId,
|
||||
pickupLocationId: pickupLocationId,
|
||||
smsPickup: 0,
|
||||
ridersLat: riderLatStr,
|
||||
ridersLng: riderLngStr,
|
||||
pickupLat: pickupLat.toStringAsFixed(6),
|
||||
pickupLng: pickupLng.toStringAsFixed(6),
|
||||
notes: selectedOption,
|
||||
pickupAmount: pickupAmount,
|
||||
collectionAmount: collectionAmtToSend,
|
||||
collectedAmount: collectedAmtToSend,
|
||||
collectionStatus: collectionStatusToSend,
|
||||
wasSkipped: wasSkipped,
|
||||
orderId: orderId,
|
||||
proofImage: '',
|
||||
);
|
||||
debugPrint('[PICKUP][CONFIRM] result=$apiSuccess');
|
||||
}
|
||||
|
||||
// ── Refused, not failed ──
|
||||
//
|
||||
// The rider is not at the address, so nothing was sent and nothing
|
||||
// will be. Completing locally here is what made the app show the
|
||||
// done screen, drop the stop off Bookings and leave Home holding a
|
||||
// pickup the hub had never been told about — the rider's phone said
|
||||
// picked, the office said active, and only the office was right.
|
||||
//
|
||||
// He keeps the sheet, the stop and a message saying how far he still
|
||||
// has to go. See [PickupsController.lastBlockedReason].
|
||||
if (!apiSuccess && dc.lastBlockedReason != null) {
|
||||
debugPrint('[PICKUP][CONFIRM] blocked: ${dc.lastBlockedReason}');
|
||||
lastFailureTime = null;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
isProcessing = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await _cachePickupsCancelled(pickupId, orderId, isPickups: true);
|
||||
await _cleanupAfterCompletion(
|
||||
@@ -836,6 +1047,22 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
|
||||
);
|
||||
debugPrint('[CANCEL][CONFIRM] result=$apiSuccess');
|
||||
|
||||
// Same rule as the pickup branch: a proximity block is the app
|
||||
// declining to send, not the wire failing. ("Incorrect location" is
|
||||
// exempt from the fence upstream — a rider reporting that the address
|
||||
// is wrong is by definition not standing at it.)
|
||||
if (!apiSuccess && dc.lastBlockedReason != null) {
|
||||
debugPrint('[CANCEL][CONFIRM] blocked: ${dc.lastBlockedReason}');
|
||||
lastFailureTime = null;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
isProcessing = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await _cachePickupsCancelled(pickupId, orderId, isPickups: false);
|
||||
await _cleanupAfterCompletion(
|
||||
orderId,
|
||||
@@ -865,7 +1092,10 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
|
||||
// the button for four seconds, so a rider whose confirm threw pressed
|
||||
// it, watched nothing happen, pressed again and was told to wait —
|
||||
// indistinguishable from a dead button.
|
||||
AppFeedback.error(context, "Couldn't complete that stop — check your signal and try again");
|
||||
AppFeedback.error(
|
||||
context,
|
||||
"Couldn't complete that stop — check your signal and try again",
|
||||
);
|
||||
}
|
||||
|
||||
await Future.delayed(const Duration(seconds: 4));
|
||||
@@ -1004,6 +1234,22 @@ class _StopBrief extends StatelessWidget {
|
||||
this.showRule = false,
|
||||
});
|
||||
|
||||
/// `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'] ?? '')
|
||||
.toString()
|
||||
.trim();
|
||||
final source = stopSourceName(pickup);
|
||||
final line = [
|
||||
if (bag.isNotEmpty) bag,
|
||||
if (source.isNotEmpty) source,
|
||||
].join(' \u00b7 ');
|
||||
if (line.isNotEmpty) return line;
|
||||
if (orderId.isEmpty) return 'Booking';
|
||||
return '#${orderId.replaceFirst(RegExp(r'^DM-BK-', caseSensitive: false), '')}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kind = stopKindOf(pickup);
|
||||
@@ -1052,7 +1298,7 @@ class _StopBrief extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
color: kind.accent,
|
||||
fontSize: 13.5.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
@@ -1068,20 +1314,30 @@ class _StopBrief extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 15.5.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.3,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 2.h),
|
||||
// ── The subtitle is what the rider matches, not files ──
|
||||
//
|
||||
// This printed `Booking #DM-BK-…` — a routing reference in
|
||||
// the second-loudest slot on three sheets, saying nothing a
|
||||
// rider acts on at a door. The line now carries the bag and
|
||||
// the counter it came off (`Bag 5 · Vidhya Kitchen`) — the
|
||||
// pairing that stops the wrong lunch reaching the wrong
|
||||
// door — and falls back to the trimmed reference only when
|
||||
// the payload offers nothing better. The full id stays one
|
||||
// tap away on the detail sheet, with its copy control.
|
||||
Text(
|
||||
orderId.isNotEmpty ? 'Booking #$orderId' : 'Booking',
|
||||
_subtitle(orderId),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -1100,11 +1356,7 @@ class _StopBrief extends StatelessWidget {
|
||||
// rather than as two things that happen to be near each other.
|
||||
SizedBox(
|
||||
width: 40.w,
|
||||
child: Icon(
|
||||
Icons.location_on_rounded,
|
||||
size: 20.sp,
|
||||
color: kind.accent,
|
||||
),
|
||||
child: Icon(LucideIcons.mapPin, size: 20.sp, color: kind.accent),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
Expanded(
|
||||
@@ -1115,7 +1367,7 @@ class _StopBrief extends StatelessWidget {
|
||||
kind.locationLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 9.5.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.6,
|
||||
color: kind.accent,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -1129,7 +1381,7 @@ class _StopBrief extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
height: 1.35,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -1148,114 +1400,5 @@ class _StopBrief extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// ── One answer in a reason list ──
|
||||
///
|
||||
/// Update Status and Skip ask the same shape of question — "why?", four or two
|
||||
/// answers, one of them — and drew it two different ways, both as stacks of
|
||||
/// outlined white cards on a white sheet with each icon inside a second box.
|
||||
///
|
||||
/// A hairline separates, the tick answers, and selection is carried by a fill
|
||||
/// rather than by a thicker outline. A fill is what survives sunlight; a 1.5px
|
||||
/// border is what does not — see [ColorConstants.cardSurface].
|
||||
class _ReasonRow extends StatelessWidget {
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final Color accent;
|
||||
final bool selected;
|
||||
|
||||
/// The hairline above this row. Suppressed either side of the selected row so
|
||||
/// it does not cut across the tint and turn the one chosen answer back into a
|
||||
/// box.
|
||||
final bool showRule;
|
||||
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _ReasonRow({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.accent,
|
||||
required this.selected,
|
||||
required this.showRule,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
if (showRule)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: 33.w,
|
||||
color: ColorConstants.borderSubtle,
|
||||
),
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
padding: EdgeInsets.symmetric(vertical: 14.h, horizontal: 11.w),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
color: selected
|
||||
? accent.withValues(alpha: 0.10)
|
||||
: Colors.transparent,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 19.sp,
|
||||
color: selected ? accent : ColorConstants.secondaryText,
|
||||
),
|
||||
SizedBox(width: 13.w),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 14.5.sp,
|
||||
color: selected ? accent : ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
fontWeight: selected
|
||||
? FontWeight.w800
|
||||
: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
// The tick appears rather than an empty ring sitting on every
|
||||
// row: hollow circles read as more things to deal with, when
|
||||
// only one of them is ever the answer.
|
||||
AnimatedScale(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOutBack,
|
||||
scale: selected ? 1 : 0.6,
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
opacity: selected ? 1 : 0,
|
||||
child: Container(
|
||||
width: 22.w,
|
||||
height: 22.w,
|
||||
decoration: BoxDecoration(
|
||||
color: accent,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.check_rounded,
|
||||
color: Colors.white,
|
||||
size: 14.sp,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
// `_ReasonRow` was promoted to the sheet kit as [MilerSheetChoiceRow]
|
||||
// the day the Reject sheet became its third caller.
|
||||
|
||||
Reference in New Issue
Block a user