Files
doormile_milderapp/lib/views/Dashboard/pickups/sheet.dart
Thiru-tenext d612916fe4 Session expiry, arrival geofence guard, multi-destination stops
Three fixes found by running the app on a real handset against production.

1. An expired token left the app looking signed in and unable to work.
   MilerApi.onUnauthorized was declared and called on every 401 but never
   assigned, so the token was dropped and nothing else happened: the profile
   stayed on disk, logged_out stayed false, and the rider saw his own name over
   a dashboard whose every call returned 401. He reads that as "no work today".
   The teardown now lives in endSession() and both ways out of a session — the
   Log out button and the 401 path — use it.

2. Arrived was written locally even when the rider was not there.
   updateArrivedStatus answers false for three different things and the caller
   treated all of them as "the write did not land", which is only true of one.
   A geofence refusal and a server refusal now stop the rung and hand back the
   reason; a dead network still advances, as it should.

3. A multi-destination customer pickup collapsed onto one stop.
   GET /miler/bookings returns a row per destination once collected, all with
   the same bookingid and reference. Every local store keys on that id, so the
   accepted store deduped two of three drops away and their consignment ids
   were unrecoverable. orderid is now the stop key; bookingreference stays the
   booking's name. Cards show "Stop 2 of 3" and the receiver's own name and
   number rather than the sender's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
2026-09-18 11:05:40 +05:30

1425 lines
58 KiB
Dart

// ignore_for_file: invalid_return_type_for_catch_error
part of 'pickups.dart';
// ─────────────────────────────────────────────────────────────────────────────
// UPDATE STATUS BOTTOM SHEET — PREMIUM REDESIGN
// Preserves all Slidable swipe + SliderButton logic exactly
// ─────────────────────────────────────────────────────────────────────────────
class _PickupBottomSheet extends StatefulWidget {
final Map<String, dynamic> pickup;
final _MyPickupsState? parentState;
const _PickupBottomSheet({super.key, required this.pickup, this.parentState});
@override
State<_PickupBottomSheet> createState() => _PickupBottomSheetState();
}
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;
bool showSlider = true;
bool isLoading = false;
bool isProcessing = false;
DateTime? lastFailureTime;
late AnimationController _optionsAnimController;
late Animation<double> _optionsFade;
final List<String> cancelOptions = [
'Customer refused',
'Not Reachable',
'Switched Off',
'Incorrect Location',
];
/// What the payment page recorded, when this stop had money on it.
///
/// The sheet used to *ask* for the method itself, with its own chip list, and
/// then hand the answer to a payment page that asked again. These hold the one
/// answer, given once, on the screen that owns the question.
String? _paidMethod;
double _paidAmount = 0;
@override
void initState() {
super.initState();
_resetSheetState();
_optionsAnimController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_optionsFade = CurvedAnimation(
parent: _optionsAnimController,
curve: Curves.easeOut,
);
}
@override
void dispose() {
_optionsAnimController.dispose();
super.dispose();
}
void _resetSheetState() {
selectedOption = '';
showOptions = false;
showSlider = true;
isPickupsSelected = true;
isProcessing = false;
lastFailureTime = null;
}
// 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() => stopCollectionAmount(widget.pickup);
double _getPickupAmount() {
final raw = widget.pickup['pickupamt'];
if (raw == null) return 0.0;
if (raw is num) return raw.toDouble();
return double.tryParse(raw.toString()) ?? 0.0;
}
/// Outlined secondary action button used in the action row (Skip / Cancel).
Widget _sheetSecondaryButton({
required String label,
required IconData icon,
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: TextButton.icon(
onPressed: onTap,
icon: Icon(icon, color: color, size: 19.sp),
label: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14.5.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.2,
color: color,
fontFamily: FontConstants.fontFamily,
),
),
style: TextButton.styleFrom(
backgroundColor: color.withValues(alpha: 0.10),
foregroundColor: color,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(ButtonSizes.radius),
),
padding: EdgeInsets.symmetric(horizontal: 12.w),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
);
}
@override
Widget build(BuildContext context) {
// Delivery stops are prepaid: no payment step, the primary action is
// "Delivered" (a straight confirm) rather than "Picked up" + payment.
//
// 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].
final options = cancelOptions;
final Color confirmColor = isPickupsSelected
? 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'
: (WorkPolicy.initiatesShipment(widget.pickup)
? 'Initiate order'
: 'Confirm pickup');
final String modeLabel = isPickupsSelected
? (isDelivery ? 'Delivered' : 'Picked up')
// "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
? 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
// amount and any short-payment reason.
final bool showReasonList = showOptions && !isPickupsSelected;
// Same frosted material as the map sheet and the app bars — see
// [milerGlassSheet]. These two sheets are the ones a rider is in most, and
// being made of different stuff to everything around them is most of why
// they read as belonging to another app.
return milerGlassSheet(
child: SafeArea(
top: false,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 16.h),
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// ── Handle + Header ─────────────────────────────────────
const MilerSheetHandle(),
Row(
children: [
// Mode badge (only when options shown)
if (showOptions) ...[
Container(
padding: EdgeInsets.symmetric(
horizontal: 10.w,
vertical: 6.h,
),
decoration: BoxDecoration(
color: confirmColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(
DesignConstants.radiusXl,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(modeIcon, color: confirmColor, size: 15.sp),
SizedBox(width: 6.w),
Text(
modeLabel,
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w700,
color: confirmColor,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
SizedBox(width: 10.w),
],
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')
: 'What happened?')
: confirmLabel,
style: TextStyle(
fontSize: 19.sp,
fontWeight: FontWeight.w800,
fontFamily: FontConstants.fontFamily,
color: ColorConstants.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
// Close / Back button
GestureDetector(
onTap: () {
if (!showSlider && showOptions) {
setState(() {
_resetSheetState();
_optionsAnimController.reverse();
});
} else {
Navigator.pop(context);
}
},
child: Container(
padding: EdgeInsets.all(8.r),
decoration: BoxDecoration(
color: MilerSurface.canvas,
shape: BoxShape.circle,
),
child: Icon(
showOptions ? LucideIcons.arrowLeft : LucideIcons.x,
color: ColorConstants.onSurface,
size: 18.sp,
),
),
),
],
),
SizedBox(height: 16.h),
// ── Customer Info (shown before swipe) ──────────────────
if (!showOptions && showSlider) ...[
_buildCustomerInfo(),
SizedBox(height: 16.h),
],
// ── ACTION BUTTONS (tap, not swipe) ─────────────────────
// Three clear choices, one tap each. Primary = Picked up /
// Delivered; then Skip and Cancel / Undelivered.
if (showSlider) ...[
// ── 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(
LucideIcons.circleCheck,
color: Colors.white,
),
label: Text(
isDelivery ? 'Delivered' : 'Picked up',
style: TextStyle(
fontSize: 16.5.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: ColorConstants.acceptGreen,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
DesignConstants.radiusXl,
),
),
),
),
),
SizedBox(height: 10.h),
// Secondary row: Skip · Cancel/Undelivered
Row(
children: [
Expanded(
child: _sheetSecondaryButton(
label: 'Skipped',
icon: LucideIcons.clock,
color: ColorConstants.warning,
onTap: () async {
final resolvedParentState =
widget.parentState ??
context
.findAncestorStateOfType<_MyPickupsState>();
await _showMyOptionsSheet(
context,
widget.pickup,
resolvedParentState,
);
if (mounted) Navigator.pop(context, true);
},
),
),
SizedBox(width: 10.w),
Expanded(
child: _sheetSecondaryButton(
// "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(() {
isPickupsSelected = false;
selectedOption = '';
showOptions = true;
showSlider = false;
isProcessing = false;
lastFailureTime = null;
});
_optionsAnimController.forward(from: 0);
},
),
),
],
),
],
// ── Reason Options List ─────────────────────────────────
//
// ── Rows, not four outlined cards ──
//
// Every reason was a white box with a 1px border, on a white
// sheet — four rectangles whose only job was to say "these are
// separate", which the gap between them already said. Each one
// also carried its icon inside a *second* box. Eight enclosures
// to ask one question with four answers.
//
// A hairline between rows does the separating, the tick does
// the answering, and selection is carried by a fill rather than
// by a thicker outline — the same way this app marks a chosen
// condition on the verification page, and the same way Uber
// marks a chosen reason. The fill is what survives sunlight;
// a 1.5px border is what does not.
if (showReasonList)
FadeTransition(
opacity: _optionsFade,
child: Column(
children: [
for (var i = 0; i < options.length; i++)
MilerSheetChoiceRow(
label: options[i],
icon: _cancelIcon(options[i]),
accent: confirmColor,
selected: options[i] == selectedOption,
showRule:
i > 0 &&
options[i] != selectedOption &&
options[i - 1] != selectedOption,
onTap: (isLoading || isProcessing)
? null
: () => setState(() {
selectedOption = options[i];
lastFailureTime = null;
}),
),
],
),
),
SizedBox(height: 16.h),
// ── Confirm (tap). For pickups with a payment method the
// cash/QR dialog is the deliberate money confirmation; cancels
// require a reason to be selected first. ─────────────────
if (selectedOption.isNotEmpty)
SizedBox(
width: double.infinity,
height: ButtonSizes.primary,
child: ElevatedButton(
onPressed: (isLoading || isProcessing)
? null
: () => _handleConfirm(),
style: ElevatedButton.styleFrom(
backgroundColor: confirmColor,
disabledBackgroundColor: confirmColor.withValues(
alpha: 0.5,
),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
DesignConstants.radiusXl,
),
),
),
child: isLoading
? const SizedBox(
width: 26,
height: 26,
child: CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.white,
),
),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
isPickupsSelected
? LucideIcons.check
: LucideIcons.x,
size: 22.sp,
color: Colors.white,
),
SizedBox(width: 10.w),
// Flexible: "Confirm delivery" beside a 22sp
// icon is wider than the button from ~1.3x
// system text, and an overflow stripe across
// the primary action is the last thing the
// rider should be looking at.
Flexible(
child: Text(
isPickupsSelected
? confirmLabel
: (isDelivery
? 'Mark not delivered'
: 'Mark not picked'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w800,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
),
),
],
),
),
),
),
);
}
/// The stop this sheet is closing. See [_StopBrief] — the same block the map
/// sheet and the skip sheet show, so the rider sees one identity for one stop
/// however he arrived at it.
Widget _buildCustomerInfo() =>
_StopBrief(pickup: widget.pickup, showRule: true);
// ── Icon helpers ──────────────────────────────────────────────────────────
/// "Picked up" / "Delivered", and what has to happen next.
///
/// ── One question about money, asked once ──
///
/// This sheet used to ask "how did the customer pay?" with its own list of
/// chips — Cash (₹450) / UPI (₹450) / Not Collected — and then, after the
/// rider confirmed, open a payment page that asked exactly the same question
/// again with its own tiles. Four taps and two answers for one fact, and the
/// two screens could disagree: the chip said UPI, the page defaulted to cash,
/// and whichever the rider left last is what the office was told.
///
/// The payment page owns the question now, because it is the screen that also
/// owns the amount, the QR, the short-payment path and the "customer cannot
/// pay" escape. This sheet just gets him there and takes the answer back.
///
/// money owed → Picked up → [payment page] → slide (2 actions)
/// nothing owed → Picked up → Confirm pickup (2 actions)
/// delivery → Delivered → Confirm delivery (2 actions)
///
/// It was four plus a slide.
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()) {
setState(() {
isPickupsSelected = true;
selectedOption = isDelivery ? 'Delivered' : 'No collection';
showOptions = true;
showSlider = false;
isProcessing = false;
lastFailureTime = null;
});
_optionsAnimController.forward(from: 0);
return;
}
final result = await openSheet<Map<String, dynamic>>(
context,
CollectPaymentPage(pickup: widget.pickup),
);
// Dismissed, or the customer could not pay: the stop stays open and the
// sheet stays exactly where it was, on its three buttons.
if (!mounted || result == null || result['paid'] != true) return;
setState(() {
isPickupsSelected = true;
_paidMethod = (result['method'] ?? 'cash').toString();
_paidAmount = (result['amountCollected'] as num?)?.toDouble() ?? 0;
selectedOption = _paidMethod == 'upi'
? 'UPI (₹${_paidAmount.toStringAsFixed(0)})'
: 'Cash (₹${_paidAmount.toStringAsFixed(0)})';
});
// ── Step 3 of the v1 pickup flow: the money ──
//
// Sent before `_handleConfirm` because that posts `pickup-complete`, which
// is the pivot: it converts the booking into a consignment, and a payment
// against a booking that no longer exists is not a thing the backend can
// record. Until now the rider slid to confirm cash he was holding and
// nothing about it ever left the phone — so the only record that he had
// taken it was his own memory, on money he is personally accountable for.
//
// 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'];
if (bookingId != null) {
final payRes = await UpdatePickupProvider().submitPayment(
bookingId,
result,
);
if (payRes?['status'] != true && mounted) {
AppFeedback.error(
context,
"Payment recorded on your phone but not with your office — tell them "
"you collected ₹${_paidAmount.toStringAsFixed(0)}",
);
}
}
if (!mounted) return;
// The slide on the payment page WAS the deliberate confirmation — it is the
// gesture this app reserves for money. Asking him to confirm the confirm
// would be the fifth tap this change exists to remove.
await _handleConfirm();
}
IconData _cancelIcon(String option) {
final l = option.toLowerCase();
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 ────────────────────────
bool _wasSkippedPickup(
Map<String, dynamic> pickup,
_MyPickupsState? parentState,
) {
final status =
(pickup['orderstatus'] ??
pickup['status'] ??
pickup['orderStatus'] ??
'')
.toString()
.toLowerCase();
if (status == 'skipped') return true;
if (parentState == null) return false;
final key = parentState._getOrderKey(pickup);
return _MyPickupsState._skippedOrdersCache.containsKey(key);
}
Future<void> _handleConfirm() async {
if (isLoading || isProcessing) {
debugPrint('[CONFIRM] Already processing or alert showing, ignoring tap');
return;
}
if (lastFailureTime != null) {
final timeSinceLastFailure = DateTime.now().difference(lastFailureTime!);
if (timeSinceLastFailure.inSeconds < 4) {
final wait = 4 - timeSinceLastFailure.inSeconds;
debugPrint(
'[CONFIRM] Too soon after last failure (${timeSinceLastFailure.inSeconds}s), ignoring tap',
);
// Tell the rider WHY nothing happened instead of silently ignoring.
if (mounted) {
AppFeedback.info(context, 'Please wait ${wait}s and try again');
}
return;
}
}
final d = widget.pickup;
// 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']}');
debugPrint('[CONFIRM] orderstatus: ${d['orderstatus']}');
final parentState =
widget.parentState ??
context.findAncestorStateOfType<_MyPickupsState>();
final orderId = (d['orderid'] ?? '').toString();
final pickupId = (d['pickupid'] ?? '').toString();
final bool wasSkipped = _wasSkippedPickup(d, parentState);
if (parentState != null) {
final key = parentState._getOrderKey(d);
debugPrint('[CONFIRM] Generated key: $key');
debugPrint(
'[CONFIRM] Is in cache: ${_MyPickupsState._skippedOrdersCache.containsKey(key)}',
);
debugPrint(
'[CONFIRM] All cache keys: ${_MyPickupsState._skippedOrdersCache.keys.toList()}',
);
}
debugPrint('[CONFIRM] wasSkipped=$wasSkipped');
debugPrint('[CONFIRM] ==========================');
// ── Hold the navigator, not the context ──
//
// This method awaits a location fix, an upload and an API call before it
// hands its result back, and it used to end with `if (!mounted) return;`
// before popping. If this State was disposed during any of those awaits —
// a rebuild of the route's builder is enough — the sheet's *route* was
// still on screen, so the rider saw the sheet sit there doing nothing after
// pressing Confirm, and the caller never received the outcome that tells it
// to open the next-stop screen. The work had been done; only the hand-back
// was lost.
//
// A NavigatorState captured before the first await survives that: the route
// gets popped with its result whether or not this widget is still alive.
final NavigatorState navigator = Navigator.of(context);
if (mounted) {
setState(() {
isLoading = true;
isProcessing = true;
});
}
try {
final dc = Get.put(PickupsController(), permanent: true);
final int pickupIdInt = int.tryParse('${d['pickupid'] ?? 0}') ?? 0;
final int orderHeaderId = int.tryParse('${d['orderheaderid'] ?? 0}') ?? 0;
final int pickupLocationId =
int.tryParse('${d['pickuplocationid'] ?? 0}') ?? 0;
bool apiSuccess = false;
if (isPickupsSelected) {
double pickupAmount = _getPickupAmount();
if (pickupAmount == 0.0 && selectedOption.isNotEmpty) {
final m = RegExp(r'[0-9]+(?:\.[0-9]+)?').firstMatch(selectedOption);
if (m != null) {
pickupAmount = double.tryParse(m.group(0)!) ?? 0.0;
}
}
// ── What the payment page recorded ──
//
// Read from [_paidMethod] / [_paidAmount] rather than parsed back out
// of a label. The method used to be recovered by string-matching the
// chip the rider had tapped ("Cash (₹450)" → startsWith('cash')), which
// meant the wire value depended on copy, and a short payment could not
// be expressed at all — the amount sent was always the full figure,
// whatever he had actually taken.
//
// status: 1 cash · 2 UPI · 3 nothing collected.
final double apiCollectionAmt = _getCollectionAmount();
double collectionAmtToSend = 0.0;
double collectedAmtToSend = 0.0;
int collectionStatusToSend = 0;
if (apiCollectionAmt > 0) {
collectionAmtToSend = apiCollectionAmt;
collectedAmtToSend = _paidAmount;
collectionStatusToSend = _paidAmount <= 0
? 3
: (_paidMethod == 'upi' ? 2 : 1);
}
double parseD(dynamic v) {
if (v == null) return 0.0;
if (v is num) return v.toDouble();
return double.tryParse(v.toString()) ?? 0.0;
}
final pickupLat = parseD(d['pickuplat'] ?? d['PickupLat'] ?? 0);
final pickupLng = parseD(d['pickuplon'] ?? d['PickupLon'] ?? 0);
debugPrint(
'[PICKUP][CONFIRM] pickupId=$pickupIdInt orderHeaderId=$orderHeaderId pickupLocationId=$pickupLocationId pickupAmount=$pickupAmount notes="$selectedOption"',
);
// Best-effort rider location — no proof-photo camera step required.
String riderLatStr = '0';
String riderLngStr = '0';
try {
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
timeLimit: Duration(seconds: 5),
),
);
riderLatStr = position.latitude.toStringAsFixed(6);
riderLngStr = position.longitude.toStringAsFixed(6);
} catch (e) {
debugPrint('[PICKUP] Error getting rider location: $e');
}
// 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 {
// ── 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(
orderId,
pickupId,
parentState,
isCancelled: false,
);
} catch (e) {
debugPrint('[PICKUP][CONFIRM] API/cleanup error (ignored): $e');
}
// Pop the sheet and hand the outcome to the caller (a normal page
// route), which shows the success screen. Navigating from inside a
// modal sheet is unreliable, so the CALLER owns the navigation.
debugPrint('[PICKUP][CONFIRM] popping with outcome=completed');
navigator.pop({
'outcome': 'completed',
'isDelivery': isDelivery,
'bonusPoints': dc.lastBonusPoints.value,
'paymentMethod': selectedOption,
// What the payment page said was actually taken, which is not always
// what was due. Carried out so the completed record — and therefore
// Activity — can state it rather than infer it from the method chip.
'amountCollected': collectedAmtToSend,
});
return;
} else {
double parseD(dynamic v) {
if (v == null) return 0.0;
if (v is num) return v.toDouble();
return double.tryParse(v.toString()) ?? 0.0;
}
final pickupLat = parseD(d['pickuplat'] ?? d['PickupLat'] ?? 0);
final pickupLng = parseD(d['pickuplon'] ?? d['PickupLon'] ?? 0);
String riderLatStr = '0';
String riderLngStr = '0';
try {
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
timeLimit: Duration(seconds: 5),
),
);
riderLatStr = position.latitude.toStringAsFixed(6);
riderLngStr = position.longitude.toStringAsFixed(6);
} catch (e) {
debugPrint('[CANCEL] Error getting rider location: $e');
}
// Best-effort cancel — never block on GPS, and never let an API/cleanup
// error stop the success screen. Proceed optimistically so the rider
// can always move to the next stop (matches the pickup path).
try {
apiSuccess = await dc.updateCancelledStatus(
pickupId: pickupIdInt,
orderHeaderId: orderHeaderId,
ridersLat: riderLatStr,
ridersLng: riderLngStr,
pickupLat: pickupLat.toStringAsFixed(6),
pickupLng: pickupLng.toStringAsFixed(6),
notes: selectedOption,
wasSkipped: wasSkipped,
);
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,
pickupId,
parentState,
isCancelled: true,
);
} catch (e) {
debugPrint('[CANCEL][CONFIRM] API/cleanup error (ignored): $e');
}
// Pop with the outcome; the caller shows the success screen.
debugPrint('[CANCEL][CONFIRM] popping with outcome=cancelled');
navigator.pop({'outcome': 'cancelled', 'isDelivery': isDelivery});
return;
}
} catch (e) {
debugPrint('[CONFIRM] Exception during confirm: $e');
lastFailureTime = DateTime.now();
if (mounted) {
setState(() {
isLoading = false;
});
// Say so. This branch used to fail in complete silence and then lock
// 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",
);
}
await Future.delayed(const Duration(seconds: 4));
if (mounted) {
setState(() {
isProcessing = false;
});
debugPrint('[CONFIRM] Button re-enabled after exception handling');
}
}
}
Future<void> _cachePickupsCancelled(
String pickupId,
String orderId, {
required bool isPickups,
}) async {
try {
final prefs = await SharedPreferences.getInstance();
final userId = prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0;
if (userId > 0) {
final key = 'recently_pickuped_cancelled_$userId';
final pickedUpCancelledJson = prefs.getString(key);
List<dynamic> pickedUpCancelledList = [];
if (pickedUpCancelledJson != null && pickedUpCancelledJson.isNotEmpty) {
pickedUpCancelledList = jsonDecode(pickedUpCancelledJson);
}
pickedUpCancelledList.add({
'pickupId': pickupId,
'orderId': orderId,
'timestamp': DateTime.now().toIso8601String(),
'type': isPickups ? 'Picked up' : 'cancelled',
});
await prefs.setString(key, jsonEncode(pickedUpCancelledList));
debugPrint(
'[${isPickups ? 'PICKUP' : 'CANCEL'}] Stored Booking in SharedPreferences: pickupId=$pickupId, orderId=$orderId',
);
}
} catch (e) {
debugPrint(
'[${isPickups ? 'PICKUP' : 'CANCEL'}] Error storing Booking in SharedPreferences: $e',
);
}
}
Future<void> _cleanupAfterCompletion(
String orderId,
String pickupId,
_MyPickupsState? parentState, {
required bool isCancelled,
}) async {
final label = isCancelled ? 'CANCEL' : 'PICKUP';
if (orderId.isEmpty) {
debugPrint('[$label] Empty orderId, skipping cleanup');
return;
}
if (parentState == null) {
debugPrint(
'[$label] parentState null; relying on cache cleanup via SharedPreferences',
);
try {
debugPrint('[$label] Cart page will cleanup timer on next refresh');
} catch (_) {}
return;
}
parentState._stopPickupPosting(orderId);
debugPrint('[$label] Stopped timer for completed pickup: $orderId');
final pickupKey = 'pickup_$pickupId';
final orderKey = 'order_$orderId';
final actualKey = parentState._getOrderKey(widget.pickup);
bool cacheUpdated = false;
void removeKey(String key, String reason) {
if (_MyPickupsState._skippedOrdersCache.remove(key) != null) {
_MyPickupsState._skippedOrderTimestamps.remove(key);
cacheUpdated = true;
debugPrint('[$label] Removed from cache ($reason): $key');
}
}
removeKey(pickupKey, 'pickup key');
removeKey(orderKey, 'Booking key');
removeKey(actualKey, 'actual key');
if (!cacheUpdated) {
final keysToRemove = <String>[];
for (final key in keysToRemove) {
_MyPickupsState._skippedOrdersCache.remove(key);
_MyPickupsState._skippedOrderTimestamps.remove(key);
cacheUpdated = true;
debugPrint('[$label] Removed from cache (matched by ID): $key');
}
}
if (cacheUpdated) {
await parentState._saveSkippedOrdersCache();
debugPrint(
'[$label] Cache saved. Remaining: ${_MyPickupsState._skippedOrdersCache.length}',
);
} else {
debugPrint(
'[$label] Booking not found in skipped cache (cleanup will run on next fetch)',
);
}
}
}
/// ── Who the stop is, and where ──
///
/// Three screens ask this: the map sheet before the rider sets off, Update
/// Status when he closes the stop, and Skip when he cannot. They had three
/// answers — two different avatar sizes, two different address treatments and,
/// on the skip sheet, no answer at all: it asked "Skip this pickup" without ever
/// naming which pickup. One widget, so they cannot drift again and so the skip
/// sheet gains the identification it was missing.
class _StopBrief extends StatelessWidget {
final Map<String, dynamic> pickup;
/// Shown at the right of the identity row — a status pill on the map sheet,
/// nothing on the others.
final Widget? trailing;
/// Closes the block with a hairline, when what follows is a different subject.
final bool showRule;
const _StopBrief({
required this.pickup,
this.trailing,
this.showRule = false,
});
/// `Bag 5 · Vidhya Kitchen`, whichever halves exist; the trimmed booking
/// reference only as a last resort.
String _subtitle(String orderId) {
final printed = (pickup['baglabel'] ?? pickup['BagLabel'] ?? '')
.toString()
.trim();
final source = stopSourceName(pickup);
final line = [
if (printed.isNotEmpty) printed,
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);
final name =
(pickup['PickupCustomer'] ??
pickup['pickupcustomer'] ??
pickup['Pickupcustomer'] ??
'Customer')
.toString()
.trim();
final orderId = (pickup['orderid'] ?? pickup['OrderId'] ?? '')
.toString()
.trim();
final address =
(pickup['pickupaddress'] ??
pickup['PickupAddress'] ??
pickup['Pickupaddress'] ??
(kind.isDelivery ? 'Delivery location' : 'Pickup location'))
.toString()
.trim();
final initials = name.isNotEmpty
? name
.split(' ')
.map((w) => w.isNotEmpty ? w[0] : '')
.take(2)
.join()
.toUpperCase()
: 'C';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
// ── The avatar is a disc, and it is neutral ──
//
// A tinted rounded square in the leg's accent put a third coloured
// object on a sheet that already has a state pill and a coloured
// rail, and the colour said nothing the two of them had not
// already said. Initials are an identity, not a state: they take
// the app's quiet inset tone, and the round shape is what tells
// the eye it stands for a person.
Container(
width: 42.w,
height: 42.w,
alignment: Alignment.center,
decoration: const BoxDecoration(
color: ColorConstants.neutralLight,
shape: BoxShape.circle,
),
child: Text(
initials,
style: TextStyle(
color: ColorConstants.secondaryText,
fontSize: 14.sp,
fontWeight: FontWeight.w800,
fontFamily: FontConstants.fontFamily,
),
),
),
SizedBox(width: 12.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name.isNotEmpty ? name : 'Customer',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15.5.sp,
fontWeight: FontWeight.w800,
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(
_subtitle(orderId),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
if (trailing != null) ...[SizedBox(width: 10.w), trailing!],
],
),
SizedBox(height: 12.h),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Aligned to the avatar's column so the two rows read as one block
// rather than as two things that happen to be near each other.
SizedBox(
width: 42.w,
child: Icon(
LucideIcons.mapPin,
size: 20.sp,
color: ColorConstants.primary,
),
),
SizedBox(width: 12.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
// ── The destination label is brand ──
//
// It took the leg's accent — blue on a delivery — which is
// a *classification* colour, and this line is not
// classifying anything: the rider is already on the screen
// for this stop. What it is doing is pointing at where he
// has to go, and pointing is what the brand does on this
// sheet (the rail, the pin beside this label).
kind.locationLabel,
style: TextStyle(
fontSize: 9.5.sp,
fontWeight: FontWeight.w800,
letterSpacing: 0.6,
color: ColorConstants.primary,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 3.h),
Text(
address.isNotEmpty ? address : 'Address not available',
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.sp,
height: 1.35,
fontWeight: FontWeight.w600,
color: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
],
),
if (showRule) ...[
SizedBox(height: 16.h),
Divider(height: 1, thickness: 1, color: ColorConstants.borderSubtle),
],
],
);
}
}
// `_ReasonRow` was promoted to the sheet kit as [MilerSheetChoiceRow]
// the day the Reject sheet became its third caller.