Design system - MilerSurface ladder (canvas → working → raised → floating) with MilerPanel as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1. - Visible vocabulary applied across Home, Deliveries, Activity, Account and the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x), canvas wells for anything that opens, small filled tags for shelf labels, demoted placeholders. Recorded in DESIGN_SYSTEM.md §6. - One icon family: 222 Material glyphs migrated to Lucide; none left outside lib/xpress. - Colour semantics corrected: amber only for what is genuinely owed, brand red reserved for the live stop, disabled primaries go neutral rather than pale. Data and lifecycle - lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart makes admin sequence the single ordering authority; service_day.dart, and stop_area.dart rewritten against live Coimbatore addresses (digit-token stripping, city stoplist, street suffixes, stammer collapse). - countLabel states the load once, in bags. Testing - 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity, sheets and verify, with test/failures/ now gitignored (diff debris). - New pins: home_gutter_test, stop_area_test, plus updated structural bounds. Note: this commit also carries pre-existing working-tree deletions that were present before this work (API_SPEC.md, README.md, demo test fixtures). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1405 lines
57 KiB
Dart
1405 lines
57 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.w700,
|
|
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'
|
|
: (ServiceProfile.active.initiatesShipment
|
|
? '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.w600,
|
|
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.w700,
|
|
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.w700,
|
|
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.w700,
|
|
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 the hub — 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 bag = (pickup['baglabel'] ?? pickup['BagLabel'] ?? '')
|
|
.toString()
|
|
.trim();
|
|
final source = stopSourceName(pickup);
|
|
final line = [
|
|
if (bag.isNotEmpty) bag,
|
|
if (source.isNotEmpty) source,
|
|
].join(' \u00b7 ');
|
|
if (line.isNotEmpty) return line;
|
|
if (orderId.isEmpty) return 'Booking';
|
|
return '#${orderId.replaceFirst(RegExp(r'^DM-BK-', caseSensitive: false), '')}';
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final kind = stopKindOf(pickup);
|
|
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: [
|
|
Container(
|
|
width: 40.w,
|
|
height: 40.w,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: kind.accent.withValues(alpha: 0.12),
|
|
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
|
),
|
|
child: Text(
|
|
initials,
|
|
style: TextStyle(
|
|
color: kind.accent,
|
|
fontSize: 13.5.sp,
|
|
fontWeight: FontWeight.w700,
|
|
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.w700,
|
|
letterSpacing: -0.3,
|
|
color: ColorConstants.slateText,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
SizedBox(height: 2.h),
|
|
// ── The subtitle is what the rider matches, not files ──
|
|
//
|
|
// This printed `Booking #DM-BK-…` — a routing reference in
|
|
// the second-loudest slot on three sheets, saying nothing a
|
|
// rider acts on at a door. The line now carries the bag and
|
|
// the counter it came off (`Bag 5 · Vidhya Kitchen`) — the
|
|
// pairing that stops the wrong lunch reaching the wrong
|
|
// door — and falls back to the trimmed reference only when
|
|
// the payload offers nothing better. The full id stays one
|
|
// tap away on the detail sheet, with its copy control.
|
|
Text(
|
|
_subtitle(orderId),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 12.5.sp,
|
|
fontWeight: FontWeight.w500,
|
|
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: 40.w,
|
|
child: Icon(LucideIcons.mapPin, size: 20.sp, color: kind.accent),
|
|
),
|
|
SizedBox(width: 12.w),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
kind.locationLabel,
|
|
style: TextStyle(
|
|
fontSize: 9.5.sp,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: 0.6,
|
|
color: kind.accent,
|
|
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.w500,
|
|
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.
|