part of 'pickups.dart'; Future _showMyOptionsSheet( BuildContext context, Map pickup, _MyPickupsState? parentState, ) async { int selected = -1; bool isLoading = false; // ── Why the list is this long, and no longer ── // // Two reasons was not a list, it was a shrug: every skip that was not a // missed call came back to the office as "customer not at the location", // which is the one thing operations cannot act on. These are the eight // outcomes a rider actually reports, and each one implies a different next // move for somebody — reschedule, re-address, call the client, refund. // // Eight is also the ceiling. A reason list a rider has to *read* at a gate is // a reason list he will pick the first item from. final List skipReasons = [ 'Customer unreachable', 'Customer not at the location', 'Wrong address', 'Access problem', 'Customer refused', 'Delivery paused', 'Quantity issue', 'Other', ]; // ── Through the kit ── // // Three violations in one sheet, and each is the kind that only shows up on // somebody else's phone: // // • its own `SafeArea(bottom: true)` **wrapping** the sheet, on top of the // 20 of bottom padding inside it — the double-inset rule exists for // exactly this, and it is the one thing the system calls non-negotiable; // • a hand-rolled scaffold — its own `milerGlassSheet`, its own gutter, its // own handle — which is four decisions re-made and one of them (`12` // top padding, not the kit's) already drifting; // • a close **✕**, the one affordance the system forbids. The handle and // the barrier already dismiss; a third way is a control that has to be // reached for with a thumb that is holding the phone. await showMilerSheet( context, builder: (context) { return StatefulBuilder( builder: (context, setState) { // The reason list is the same question Update Status asks, so it is // the same widget — see [MilerSheetChoiceRow]. It used to be two outlined // white cards on a white sheet, each with its icon inside a second // box, and the selected one swapped its border for a thicker // coloured one. Widget reasonRow(String title, int index, IconData icon) { return MilerSheetChoiceRow( label: title, icon: icon, accent: ColorConstants.primary, selected: selected == index, // No rule either side of the filled row: a hairline across the // tint turns the chosen answer back into a box. showRule: index > 0 && selected != index && selected != index - 1, onTap: isLoading ? null : () => setState(() => selected = index), ); } Future handleConfirm() async { if (selected == -1 || isLoading) return; final dc = Get.put(PickupsController(), permanent: true); final skipStatus = await dc.checkSkipStatus(); final int skipCount = skipStatus['count'] ?? 0; bool applyPenalty = false; if (skipCount >= 2) { final bool? confirm = await showAppDialog( context: context, barrierDismissible: false, builder: (BuildContext context) { return Dialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( DesignConstants.radiusXl, ), ), elevation: 0, backgroundColor: ColorConstants.pureSurface, child: Padding( padding: EdgeInsets.all(24.r), child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( padding: EdgeInsets.all(16.r), decoration: BoxDecoration( color: ColorConstants.errorLight, shape: BoxShape.circle, ), child: Icon( LucideIcons.triangleAlert, color: ColorConstants.errorRed, size: 36.sp, ), ), SizedBox(height: 20.h), Text( 'Skip Limit Reached', textAlign: TextAlign.center, style: TextStyle( fontSize: 20.sp, fontWeight: FontWeight.w700, fontFamily: FontConstants.fontFamily, color: ColorConstants.onSurface, ), ), SizedBox(height: 10.h), Text( 'You have exceeded the limit of 2 skips within 3 hours.', textAlign: TextAlign.center, style: TextStyle( fontSize: 15.sp, fontFamily: FontConstants.fontFamily, color: ColorConstants.onSurfaceVariant, height: 1.4, ), ), SizedBox(height: 14.h), Container( padding: EdgeInsets.all(12.r), // Fill, no outline — the same rule the sheets // now follow. A tinted panel is already a panel. decoration: BoxDecoration( color: ColorConstants.warningContainer, borderRadius: BorderRadius.circular( DesignConstants.radiusLg, ), ), child: Row( children: [ Icon( LucideIcons.info, color: ColorConstants.warning, size: 18.sp, ), SizedBox(width: 8.w), Expanded( child: Text( "Proceeding will forfeit your bonus points for this session.", style: TextStyle( fontSize: 13.sp, fontWeight: FontWeight.w600, color: ColorConstants.rewardAmber, fontFamily: FontConstants.fontFamily, height: 1.3, ), ), ), ], ), ), SizedBox(height: 24.h), Row( children: [ Expanded( child: MilerButton( label: "Cancel", onPressed: () => Navigator.pop(context, false), variant: MilerButtonVariant.outlined, color: ColorConstants.onSurfaceVariant, height: ButtonSizes.secondary, ), ), SizedBox(width: 12.w), Expanded( child: MilerButton( label: "Confirm skip", onPressed: () => Navigator.pop(context, true), color: ColorConstants.errorRed, height: ButtonSizes.secondary, ), ), ], ), ], ), ), ); }, ); if (confirm != true) return; applyPenalty = true; } setState(() => isLoading = true); try { final dc = Get.put(PickupsController(), permanent: true); final d = pickup; final int pickupId = int.tryParse('${d['pickupid'] ?? 0}') ?? 0; final int orderHeaderId = int.tryParse('${d['orderheaderid'] ?? 0}') ?? 0; final String reason = selected >= 0 && selected < skipReasons.length ? skipReasons[selected] : ''; if (pickupId > 0 && orderHeaderId > 0) { String riderLatStr = '0'; String riderLngStr = '0'; try { final position = await Geolocator.getCurrentPosition( desiredAccuracy: LocationAccuracy.high, timeLimit: const Duration(seconds: 5), ); riderLatStr = position.latitude.toStringAsFixed(6); riderLngStr = position.longitude.toStringAsFixed(6); } catch (e) { debugPrint('[SKIP] Error getting rider location: $e'); try { final lastPos = await Geolocator.getLastKnownPosition(); if (lastPos != null) { riderLatStr = lastPos.latitude.toStringAsFixed(6); riderLngStr = lastPos.longitude.toStringAsFixed(6); } } catch (_) {} } debugPrint( '[SKIP] Updating status for pickupId=$pickupId orderHeaderId=$orderHeaderId reason="$reason"', ); final ok = await dc.updateSkippedStatus( pickupId: pickupId, orderHeaderId: orderHeaderId, ridersLat: riderLatStr, ridersLng: riderLngStr, notes: reason, ); debugPrint('[SKIP] Status update result: $ok'); // Optimistic skip — reflect it locally and move on even if the // API fails (e.g. mock/offline data), so skip never appears // unresponsive and the rider can proceed to the next stop. await dc.registerSkip(applyPenalty: applyPenalty); // Recorded before the sheet closes, and regardless of which // screen the skip was taken from: the map screen passes no // `parentState`, so the branch below is the only thing that // remembered a skip and it did so in memory, on one screen. // Activity is where the rider now finds a skipped stop, and // it reads this store. See [addSkippedBooking]. await addSkippedBooking(d, reason: reason); if (context.mounted) { Navigator.pop(context); if (parentState != null) { parentState.markOrderAsSkipped(d, reason); } else { debugPrint( '[SKIP] Skipped from Nav/Map screen, sheet closed.', ); } } } else { debugPrint( '[SKIP] Invalid pickupId: $pickupId or orderHeaderId: $orderHeaderId', ); if (context.mounted) { AppFeedback.error(context, 'Invalid pickup information'); } } } catch (e) { debugPrint('[SKIP] Error updating skip status: $e'); if (context.mounted) { AppFeedback.error(context, 'Something went wrong — try again'); } } finally { if (context.mounted) { setState(() => isLoading = false); } } } return MilerSheetScaffold( child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // One header idiom. 18sp/w700 with a 13.5 subtitle, the // same as every other sheet — this was 19/12.5 with its own // colours, which is close enough to look like a mistake // rather than a variant. const MilerSheetHeader( title: 'Skip this pickup', subtitle: 'Tell us why, so your office can reassign it.', ), SizedBox(height: 16.h), // ── Which pickup ── // // The sheet asked the rider to skip "this pickup" and never // said which one. He arrives here from a swipe on a list of // near-identical rows, and skipping the wrong stop is not an // action he can take back — it goes to the office as a // refusal against a customer who was standing there waiting. _StopBrief(pickup: pickup, showRule: true), SizedBox(height: 8.h), reasonRow('Customer unreachable', 0, LucideIcons.phoneMissed), reasonRow( 'Customer not at the location', 1, LucideIcons.userX, ), reasonRow('Wrong address', 2, LucideIcons.mapPinOff), reasonRow('Access problem', 3, LucideIcons.lock), reasonRow('Customer refused', 4, LucideIcons.ban), reasonRow('Delivery paused', 5, LucideIcons.circlePause), reasonRow('Quantity issue', 6, LucideIcons.package), reasonRow('Other', 7, LucideIcons.ellipsis), SizedBox(height: 20.h), if (isLoading) Container( height: ButtonSizes.primary, alignment: Alignment.center, decoration: BoxDecoration( // Matches the button it stands in for. color: ColorConstants.errorRed, borderRadius: BorderRadius.circular(ButtonSizes.radius), ), child: const SizedBox( width: 22, height: 22, child: CircularProgressIndicator( strokeWidth: 2.5, color: Colors.white, ), ), ) else // ── Skipping is destructive, and looks it ── // // This was `primary` while the penalty confirmation // nested inside the same flow was `errorRed` — one action // in two colours, two taps apart. Skipping tells the hub // a customer was not served; it is the destructive branch // of this screen and takes the destructive colour, the // same one Reject uses on Home. MilerButton( label: 'Confirm skip', icon: LucideIcons.clock, color: ColorConstants.errorRed, onPressed: selected == -1 ? null : handleConfirm, ), ], ), ), ); }, ); }, ); }