// 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 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 get _carrying => (widget.parentState ?? context.findAncestorStateOfType<_MyPickupsState>()) ?._collectedIds ?? const {}; /// 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 _optionsFade; final List 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( 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 _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>( 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 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 _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 _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 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 _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 = []; 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 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.