Files
2026-08-11 13:16:33 +05:30

1262 lines
48 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 {
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;
}
bool _hasCollection() {
final raw = widget.pickup['collectionamt'];
if (raw == null) return false;
if (raw is num) return raw > 0;
final parsed = double.tryParse(raw.toString()) ?? 0.0;
return parsed > 0;
}
double _getCollectionAmount() {
final raw = widget.pickup['collectionamt'];
if (raw == null) return 0.0;
if (raw is num) return raw.toDouble();
return double.tryParse(raw.toString()) ?? 0.0;
}
double _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,
}) {
return SizedBox(
height: ButtonSizes.secondary,
child: OutlinedButton.icon(
onPressed: onTap,
icon: Icon(icon, color: color, size: 18.sp),
label: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w700,
color: color,
fontFamily: FontConstants.fontFamily,
),
),
style: OutlinedButton.styleFrom(
side: BorderSide(color: color.withValues(alpha: 0.4)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(ButtonSizes.radius),
),
// The theme gives OutlinedButton `vertical: 16` for full-width form
// buttons. In a 48dp box that leaves a 16px content band, which was
// painting this row's 18sp icon at 16px — measured, and silent.
padding: EdgeInsets.symmetric(horizontal: 14.w),
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.
final bool isDelivery = stopKindOf(widget.pickup).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;
final String modeLabel = isPickupsSelected
? (isDelivery ? 'Delivered' : 'Picked up')
: (isDelivery ? 'Undelivered' : 'Cancelled');
final IconData modeIcon = isPickupsSelected
? Icons.check_circle_rounded
: Icons.cancel_rounded;
// 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 ─────────────────────────────────────
Center(
child: Container(
width: 40.w,
height: 4.h,
decoration: BoxDecoration(
color: ColorConstants.borderSubtle,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
),
),
SizedBox(height: 14.h),
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: 14.sp),
SizedBox(width: 5.w),
Text(
modeLabel,
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w700,
color: confirmColor,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
SizedBox(width: 10.w),
],
Expanded(
child: Text(
showOptions
? (isPickupsSelected
? (isDelivery
? 'Confirm delivery'
: 'Collect payment')
: 'Select reason')
: 'Update Status',
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: ColorConstants.neutralLight,
shape: BoxShape.circle,
),
child: Icon(
showOptions
? Icons.arrow_back_rounded
: Icons.close_rounded,
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) ...[
// Primary: Picked up / Delivered
SizedBox(
width: double.infinity,
height: ButtonSizes.primary,
child: ElevatedButton.icon(
onPressed: () => _onPickedUp(isDelivery),
icon: const Icon(
Icons.check_circle_rounded,
color: Colors.white,
),
label: Text(
isDelivery ? 'Delivered' : 'Picked up',
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w800,
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: 'Skip',
icon: Icons.skip_next_rounded,
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(
label: isDelivery ? 'Undelivered' : 'Cancel',
icon: Icons.cancel_rounded,
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++)
_ReasonRow(
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
? Icons.check_rounded
: Icons.close_rounded,
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
? (isDelivery
? 'Confirm delivery'
: 'Confirm pickup')
: (isDelivery
? 'Mark undelivered'
: 'Cancel booking'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w800,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
),
),
],
),
),
),
),
);
}
/// The stop this sheet is closing. See [_StopBrief] — the same block the map
/// sheet and the skip sheet show, so the rider sees one identity for one stop
/// however he arrived at it.
Widget _buildCustomerInfo() =>
_StopBrief(pickup: widget.pickup, showRule: true);
// ── Icon helpers ──────────────────────────────────────────────────────────
/// "Picked up" / "Delivered", and what has to happen next.
///
/// ── One question about money, asked once ──
///
/// This sheet used to ask "how did the customer pay?" with its own list of
/// chips — Cash (₹450) / UPI (₹450) / Not Collected — and then, after the
/// rider confirmed, open a payment page that asked exactly the same question
/// again with its own tiles. Four taps and two answers for one fact, and the
/// two screens could disagree: the chip said UPI, the page defaulted to cash,
/// and whichever the rider left last is what the office was told.
///
/// The payment page owns the question now, because it is the screen that also
/// owns the amount, the QR, the short-payment path and the "customer cannot
/// pay" escape. This sheet just gets him there and takes the answer back.
///
/// money owed → Picked up → [payment page] → slide (2 actions)
/// nothing owed → Picked up → Confirm pickup (2 actions)
/// delivery → Delivered → Confirm delivery (2 actions)
///
/// It was four plus a slide.
Future<void> _onPickedUp(bool isDelivery) async {
if (isLoading || isProcessing) return;
// 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 Icons.person_off_outlined;
if (l.contains('reachable')) return Icons.phone_missed_rounded;
if (l.contains('switched')) return Icons.phone_disabled_rounded;
if (l.contains('location')) return Icons.location_off_rounded;
return Icons.cancel_outlined;
}
// ── 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;
final bool isDelivery = stopKindOf(d).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.
try {
apiSuccess = await dc.updatePickedupStatus(
pickupId: pickupIdInt,
orderHeaderId: orderHeaderId,
pickupLocationId: pickupLocationId,
smsPickup: 0,
ridersLat: riderLatStr,
ridersLng: riderLngStr,
pickupLat: pickupLat.toStringAsFixed(6),
pickupLng: pickupLng.toStringAsFixed(6),
notes: selectedOption,
pickupAmount: pickupAmount,
collectionAmount: collectionAmtToSend,
collectedAmount: collectedAmtToSend,
collectionStatus: collectionStatusToSend,
wasSkipped: wasSkipped,
orderId: orderId,
proofImage: '',
);
debugPrint('[PICKUP][CONFIRM] result=$apiSuccess');
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');
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,
});
@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.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),
Text(
orderId.isNotEmpty ? 'Booking #$orderId' : 'Booking',
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: 40.w,
child: Icon(
Icons.location_on_rounded,
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.w800,
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.w600,
color: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
],
),
if (showRule) ...[
SizedBox(height: 16.h),
Divider(height: 1, thickness: 1, color: ColorConstants.borderSubtle),
],
],
);
}
}
/// ── One answer in a reason list ──
///
/// Update Status and Skip ask the same shape of question — "why?", four or two
/// answers, one of them — and drew it two different ways, both as stacks of
/// outlined white cards on a white sheet with each icon inside a second box.
///
/// A hairline separates, the tick answers, and selection is carried by a fill
/// rather than by a thicker outline. A fill is what survives sunlight; a 1.5px
/// border is what does not — see [ColorConstants.cardSurface].
class _ReasonRow extends StatelessWidget {
final String label;
final IconData icon;
final Color accent;
final bool selected;
/// The hairline above this row. Suppressed either side of the selected row so
/// it does not cut across the tint and turn the one chosen answer back into a
/// box.
final bool showRule;
final VoidCallback? onTap;
const _ReasonRow({
required this.label,
required this.icon,
required this.accent,
required this.selected,
required this.showRule,
this.onTap,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
if (showRule)
Divider(
height: 1,
thickness: 1,
indent: 33.w,
color: ColorConstants.borderSubtle,
),
Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: EdgeInsets.symmetric(vertical: 14.h, horizontal: 11.w),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
color: selected
? accent.withValues(alpha: 0.10)
: Colors.transparent,
),
child: Row(
children: [
Icon(
icon,
size: 19.sp,
color: selected ? accent : ColorConstants.secondaryText,
),
SizedBox(width: 13.w),
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: 14.5.sp,
color: selected ? accent : ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
fontWeight: selected
? FontWeight.w800
: FontWeight.w600,
),
),
),
// The tick appears rather than an empty ring sitting on every
// row: hollow circles read as more things to deal with, when
// only one of them is ever the answer.
AnimatedScale(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutBack,
scale: selected ? 1 : 0.6,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 180),
opacity: selected ? 1 : 0,
child: Container(
width: 22.w,
height: 22.w,
decoration: BoxDecoration(
color: accent,
shape: BoxShape.circle,
),
child: Icon(
Icons.check_rounded,
color: Colors.white,
size: 14.sp,
),
),
),
),
],
),
),
),
),
],
);
}
}