1032 lines
38 KiB
Dart
1032 lines
38 KiB
Dart
// ignore_for_file: invalid_return_type_for_catch_error
|
|
|
|
part of 'deliveries.dart';
|
|
|
|
// ignore_for_file: unused_element_parameter
|
|
// Imports should be in deliveries.dart
|
|
// import 'dart:io';
|
|
// import 'package:image_picker/image_picker.dart';
|
|
// import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
// -------------------------------------------------------------------------
|
|
// DELIVERY BOTTOM SHEET (Delivered/Cancel with payment options)
|
|
// -------------------------------------------------------------------------
|
|
class _DeliveryBottomSheet extends StatefulWidget {
|
|
final Map<String, dynamic> delivery;
|
|
final _MyDeliveriesState? parentState;
|
|
|
|
const _DeliveryBottomSheet({
|
|
super.key,
|
|
required this.delivery,
|
|
this.parentState,
|
|
});
|
|
|
|
@override
|
|
State<_DeliveryBottomSheet> createState() => _DeliveryBottomSheetState();
|
|
}
|
|
|
|
class _DeliveryBottomSheetState extends State<_DeliveryBottomSheet> {
|
|
String selectedOption = '';
|
|
bool isDeliveredSelected = true;
|
|
bool showOptions = false;
|
|
bool showSlider = true;
|
|
bool isLoading = false;
|
|
bool isProcessing = false; // Prevents any action until alert is dismissed
|
|
DateTime? lastFailureTime; // Track last failure to prevent duplicate alerts
|
|
|
|
final List<String> cancelOptions = [
|
|
'Customer refused',
|
|
'Not Reachable',
|
|
'Switched Off',
|
|
'Incorrect Location',
|
|
];
|
|
|
|
final List<String> deliveredOptions = [
|
|
'Pay Later',
|
|
'Cash Payment',
|
|
'QR Payment',
|
|
];
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_resetSheetState();
|
|
}
|
|
|
|
void _resetSheetState() {
|
|
selectedOption = '';
|
|
showOptions = false;
|
|
showSlider = true;
|
|
isDeliveredSelected = true;
|
|
isProcessing = false;
|
|
lastFailureTime = null;
|
|
}
|
|
|
|
bool _hasCollection() {
|
|
final raw = widget.delivery['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.delivery['collectionamt'];
|
|
if (raw == null) return 0.0;
|
|
if (raw is num) return raw.toDouble();
|
|
return double.tryParse(raw.toString()) ?? 0.0;
|
|
}
|
|
|
|
double _getDeliveryAmount() {
|
|
final raw = widget.delivery['deliveryamt'];
|
|
if (raw == null) return 0.0;
|
|
if (raw is num) return raw.toDouble();
|
|
return double.tryParse(raw.toString()) ?? 0.0;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// For delivered orders with collection amount, show custom payment choices
|
|
final options = isDeliveredSelected
|
|
? (_hasCollection()
|
|
? [
|
|
'Cash (₹${_getCollectionAmount().toStringAsFixed(0)})',
|
|
'UPI (₹${_getCollectionAmount().toStringAsFixed(0)})',
|
|
'Not Collected',
|
|
]
|
|
: deliveredOptions)
|
|
: cancelOptions;
|
|
final Color confirmColor = isDeliveredSelected
|
|
? ColorConstants.primaryColor
|
|
: Colors.red;
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: const BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
|
),
|
|
child: SafeArea(
|
|
top: false,
|
|
child: SingleChildScrollView(
|
|
physics: const BouncingScrollPhysics(),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
'Update Delivery Status',
|
|
style: TextStyle(
|
|
fontSize: 22,
|
|
fontWeight: FontWeight.bold,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
InkWell(
|
|
onTap: () {
|
|
if (!showSlider && showOptions) {
|
|
setState(() => _resetSheetState());
|
|
} else {
|
|
Navigator.pop(context);
|
|
}
|
|
},
|
|
child: const Icon(
|
|
Icons.cancel,
|
|
color: Colors.red,
|
|
size: 30,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 10),
|
|
const Divider(),
|
|
if (!showOptions && showSlider) ...[
|
|
_buildCustomerInfo(),
|
|
const SizedBox(height: 10),
|
|
],
|
|
if (showSlider)
|
|
Slidable(
|
|
key: ValueKey(widget.delivery['deliveryid']),
|
|
startActionPane: ActionPane(
|
|
motion: const ScrollMotion(),
|
|
extentRatio: 0.9, // Allocate 90% of width for the 3 actions to prevent truncation
|
|
children: [
|
|
SlidableAction(
|
|
borderRadius: BorderRadius.circular(6),
|
|
onPressed: (_) {
|
|
setState(() {
|
|
isDeliveredSelected = true;
|
|
selectedOption = '';
|
|
showOptions = true;
|
|
showSlider = false;
|
|
isProcessing = false; // Reset when switching
|
|
lastFailureTime = null; // Clear failure cache
|
|
});
|
|
},
|
|
backgroundColor: Colors.green,
|
|
foregroundColor: Colors.white,
|
|
icon: Icons.check_circle,
|
|
label: 'Delivered',
|
|
),
|
|
SlidableAction(
|
|
borderRadius: BorderRadius.circular(6),
|
|
onPressed: (_) {
|
|
setState(() {
|
|
isDeliveredSelected = false;
|
|
selectedOption = '';
|
|
showOptions = true;
|
|
showSlider = false;
|
|
isProcessing = false; // Reset when switching
|
|
lastFailureTime = null; // Clear failure cache
|
|
});
|
|
},
|
|
backgroundColor: Colors.red,
|
|
foregroundColor: Colors.white,
|
|
icon: Icons.cancel,
|
|
label: 'Cancelled',
|
|
),
|
|
SlidableAction(
|
|
borderRadius: BorderRadius.circular(6),
|
|
onPressed: (c) async {
|
|
// Resolve parent state reliably
|
|
final resolvedParentState = widget.parentState ??
|
|
context
|
|
.findAncestorStateOfType<_MyDeliveriesState>();
|
|
|
|
// Open skip sheet on TOP of this sheet (safer context usage)
|
|
// We await it so we know when it closes
|
|
await _showMyOptionsSheet(
|
|
context,
|
|
widget.delivery,
|
|
resolvedParentState,
|
|
);
|
|
|
|
// Close this update sheet after skip sheet is done
|
|
// Pass 'true' to indicate that an action *might* have been taken
|
|
// We could potentially return result from _showMyOptionsSheet but currently it returns void
|
|
// The skip sheet handles the logic, so we can assume if we return here we are done.
|
|
// Actually, we should only return true if the skip sheet *actually* skipped.
|
|
// Since _showMyOptionsSheet returns Future<void>, we can't know for sure.
|
|
// BUT, since the user interacted with the skip sheet and we are closing this sheet,
|
|
// passing 'true' is safer to trigger a refresh check upstream.
|
|
if (mounted) {
|
|
Navigator.pop(context, true);
|
|
}
|
|
},
|
|
backgroundColor: Colors.orange,
|
|
foregroundColor: Colors.white,
|
|
icon: Icons.skip_next,
|
|
label: 'Skip',
|
|
),
|
|
],
|
|
),
|
|
child: Container(
|
|
height: 65,
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey.shade300,
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: const Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.swipe, color: Colors.black),
|
|
SizedBox(width: 10),
|
|
Text(
|
|
'Slide to Update Delivery',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.black,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 5),
|
|
if (showOptions) ...[
|
|
ListView(
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
children: [
|
|
...options.map((option) {
|
|
final bool isSelected = option == selectedOption;
|
|
return GestureDetector(
|
|
onTap: () {
|
|
if (isLoading || isProcessing) return;
|
|
setState(() {
|
|
selectedOption = option;
|
|
lastFailureTime = null; // Clear on new selection
|
|
});
|
|
},
|
|
child: Container(
|
|
margin: const EdgeInsets.symmetric(vertical: 6),
|
|
padding: const EdgeInsets.symmetric(
|
|
vertical: 12,
|
|
horizontal: 16,
|
|
),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(12),
|
|
color: Colors.white,
|
|
border: Border.all(
|
|
color: isSelected
|
|
? confirmColor
|
|
: Colors.grey.shade300,
|
|
width: isSelected ? 2 : 1,
|
|
),
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
option,
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
color: isSelected
|
|
? confirmColor
|
|
: Colors.black,
|
|
fontFamily: FontConstants.fontFamily,
|
|
fontWeight: isSelected
|
|
? FontWeight.bold
|
|
: FontWeight.normal,
|
|
),
|
|
),
|
|
),
|
|
Icon(
|
|
isSelected
|
|
? Icons.check_circle
|
|
: Icons.radio_button_unchecked,
|
|
color: isSelected ? confirmColor : Colors.grey,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
],
|
|
const SizedBox(height: 15),
|
|
// Slider enabled when a menu option is chosen.
|
|
if (selectedOption.isNotEmpty)
|
|
SliderButton(
|
|
properties: SliderButtonProperties(
|
|
height: 55,
|
|
buttonSize: 50,
|
|
width: MediaQuery.of(context).size.width - 40,
|
|
backgroundColor: confirmColor,
|
|
dismissThresholds: 0.90,
|
|
|
|
/// ACTION
|
|
action: () async {
|
|
// Prevent action if already processing
|
|
if (isLoading || isProcessing) {
|
|
debugPrint(
|
|
'[SLIDER] ⚠️ Already processing, ignoring slide action',
|
|
);
|
|
return false;
|
|
}
|
|
// _handleConfirm manages isLoading and isProcessing internally
|
|
await _handleConfirm();
|
|
return false; // required by slider_button_lite
|
|
},
|
|
|
|
/// LABEL
|
|
label: Text(
|
|
isDeliveredSelected
|
|
? "Slide to Confirm Delivery"
|
|
: "Slide to Confirm Cancellation",
|
|
textAlign: TextAlign.left,
|
|
style: const TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
|
|
/// SLIDER BUTTON (Icon or Loader)
|
|
icon: ClipOval(
|
|
child: Material(
|
|
color: Colors.white,
|
|
child: SizedBox(
|
|
width: 50,
|
|
height: 50,
|
|
child: Center(
|
|
child: isLoading
|
|
? SizedBox(
|
|
width: 24,
|
|
height: 24,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 3,
|
|
valueColor: AlwaysStoppedAnimation<Color>(
|
|
confirmColor,
|
|
),
|
|
),
|
|
)
|
|
: Icon(
|
|
isDeliveredSelected
|
|
? Icons.check
|
|
: Icons.close,
|
|
size: 28,
|
|
color: confirmColor,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildCustomerInfo() {
|
|
final pickupCustomer = (widget.delivery['pickupcustomer'] ?? '')
|
|
.toString()
|
|
.trim();
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildInfoRow(
|
|
'Name:',
|
|
widget.delivery['deliverycustomer']?.toString() ?? 'Customer',
|
|
),
|
|
const SizedBox(height: 10),
|
|
if (pickupCustomer.isNotEmpty) ...[
|
|
_buildInfoRow('Kitchen:', pickupCustomer),
|
|
const SizedBox(height: 10),
|
|
],
|
|
_buildInfoRow(
|
|
'Address:',
|
|
widget.delivery['deliveryaddress']?.toString() ??
|
|
'Address not available',
|
|
isExpanded: true,
|
|
),
|
|
const SizedBox(height: 10),
|
|
_buildInfoRow(
|
|
'Order ID:',
|
|
'#${widget.delivery['orderid']?.toString() ?? ''}',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildInfoRow(String title, String value, {bool isExpanded = false}) {
|
|
final textWidget = Text(
|
|
value,
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 18,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
);
|
|
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'$title ',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 18,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
isExpanded ? Expanded(child: textWidget) : Flexible(child: textWidget),
|
|
],
|
|
);
|
|
}
|
|
|
|
bool _wasSkippedDelivery(
|
|
Map<String, dynamic> delivery,
|
|
_MyDeliveriesState? parentState,
|
|
) {
|
|
final status =
|
|
(delivery['orderstatus'] ??
|
|
delivery['status'] ??
|
|
delivery['orderStatus'] ??
|
|
'')
|
|
.toString()
|
|
.toLowerCase();
|
|
if (status == 'skipped') return true;
|
|
if (parentState == null) return false;
|
|
final key = parentState._getOrderKey(delivery);
|
|
return parentState._skippedOrdersCache.containsKey(key);
|
|
}
|
|
|
|
Future<void> _handleConfirm() async {
|
|
// CRITICAL: Prevent any re-entry during processing or alert display
|
|
if (isLoading || isProcessing) {
|
|
debugPrint(
|
|
'[CONFIRM] ⚠️ Already processing or alert showing, ignoring tap',
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Check if we recently had a failure (within last 4 seconds)
|
|
// This prevents cached/duplicate alerts from showing
|
|
if (lastFailureTime != null) {
|
|
final timeSinceLastFailure = DateTime.now().difference(lastFailureTime!);
|
|
if (timeSinceLastFailure.inSeconds < 4) {
|
|
debugPrint(
|
|
'[CONFIRM] ⚠️ Too soon after last failure (${timeSinceLastFailure.inSeconds}s), ignoring tap',
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
final d = widget.delivery;
|
|
debugPrint('[CONFIRM] ===== DELIVERY INFO =====');
|
|
debugPrint('[CONFIRM] deliveryid: ${d['deliveryid']}');
|
|
debugPrint('[CONFIRM] orderid: ${d['orderid']}');
|
|
debugPrint('[CONFIRM] orderstatus: ${d['orderstatus']}');
|
|
|
|
final parentState =
|
|
widget.parentState ??
|
|
context.findAncestorStateOfType<_MyDeliveriesState>();
|
|
final orderId = (d['orderid'] ?? '').toString();
|
|
final deliveryId = (d['deliveryid'] ?? '').toString();
|
|
|
|
final bool wasSkipped = _wasSkippedDelivery(d, parentState);
|
|
|
|
if (parentState != null) {
|
|
final key = parentState._getOrderKey(d);
|
|
debugPrint('[CONFIRM] Generated key: $key');
|
|
debugPrint(
|
|
'[CONFIRM] Is in cache: ${parentState._skippedOrdersCache.containsKey(key)}',
|
|
);
|
|
debugPrint(
|
|
'[CONFIRM] All cache keys: ${parentState._skippedOrdersCache.keys.toList()}',
|
|
);
|
|
}
|
|
debugPrint('[CONFIRM] wasSkipped=$wasSkipped');
|
|
debugPrint('[CONFIRM] ==========================');
|
|
|
|
// Set BOTH flags to block all interactions
|
|
if (mounted) {
|
|
setState(() {
|
|
isLoading = true;
|
|
isProcessing = true;
|
|
});
|
|
}
|
|
|
|
try {
|
|
final dc = Get.put(DeliveriesController(), permanent: true);
|
|
final int deliveryIdInt = int.tryParse('${d['deliveryid'] ?? 0}') ?? 0;
|
|
final int orderHeaderId = int.tryParse('${d['orderheaderid'] ?? 0}') ?? 0;
|
|
final int deliveryLocationId =
|
|
int.tryParse('${d['deliverylocationid'] ?? 0}') ?? 0;
|
|
|
|
bool apiSuccess = false;
|
|
|
|
if (isDeliveredSelected) {
|
|
// Delivery amount should come from API (deliveryamt), not from the
|
|
// selected payment option text (Cash/UPI), to avoid conflicts with
|
|
// collection amounts.
|
|
double deliveryAmount = _getDeliveryAmount();
|
|
|
|
// Fallback: if API did not send deliveryamt, keep old behavior of
|
|
// parsing a numeric value from the selected option (if any).
|
|
if (deliveryAmount == 0.0 && selectedOption.isNotEmpty) {
|
|
final m = RegExp(r'[0-9]+(?:\.[0-9]+)?').firstMatch(selectedOption);
|
|
if (m != null) {
|
|
deliveryAmount = double.tryParse(m.group(0)!) ?? 0.0;
|
|
}
|
|
}
|
|
|
|
// Collection amounts from API (if any)
|
|
final double apiCollectionAmt = _getCollectionAmount();
|
|
double collectionAmtToSend = 0.0;
|
|
double collectedAmtToSend = 0.0;
|
|
int collectionStatusToSend = 0; // 1 = cash, 2 = UPI, 3 = not collected
|
|
|
|
if (apiCollectionAmt > 0) {
|
|
collectionAmtToSend = apiCollectionAmt;
|
|
final lower = selectedOption.toLowerCase();
|
|
if (lower.startsWith('cash')) {
|
|
collectedAmtToSend = apiCollectionAmt;
|
|
collectionStatusToSend = 1; // Cash
|
|
} else if (lower.startsWith('upi')) {
|
|
collectedAmtToSend = apiCollectionAmt;
|
|
collectionStatusToSend = 2; // UPI
|
|
} else {
|
|
collectedAmtToSend = 0.0;
|
|
collectionStatusToSend = 3; // Not Collected
|
|
}
|
|
}
|
|
|
|
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);
|
|
final deliveryLat = parseD(
|
|
d['droplat'] ?? d['DropLat'] ?? d['deliverylat'] ?? 0,
|
|
);
|
|
final deliveryLng = parseD(
|
|
d['droplon'] ?? d['DropLon'] ?? d['deliverylong'] ?? 0,
|
|
);
|
|
|
|
// Start fetching location in background while user takes photo
|
|
final locationFuture = Geolocator.getCurrentPosition(
|
|
locationSettings: const LocationSettings(
|
|
accuracy: LocationAccuracy.high,
|
|
timeLimit: Duration(seconds: 5),
|
|
),
|
|
).then((p) => p).catchError((e) {
|
|
debugPrint('[DELIVER] Error getting rider location: $e');
|
|
return null;
|
|
});
|
|
|
|
debugPrint(
|
|
'[DELIVER][CONFIRM] deliveryId=$deliveryIdInt orderHeaderId=$orderHeaderId deliveryLocationId=$deliveryLocationId deliveryAmount=$deliveryAmount notes="$selectedOption"',
|
|
);
|
|
|
|
// TRIGGER CAMERA - Proof of Delivery
|
|
// We do this concurrently with location fetching to save time
|
|
String? proofImageUrl;
|
|
|
|
try {
|
|
final ImagePicker picker = ImagePicker();
|
|
// Optimize image: Resize to max 1024x1024 and 50% quality for faster upload
|
|
final XFile? photo = await picker.pickImage(
|
|
source: ImageSource.camera,
|
|
imageQuality: 50,
|
|
maxWidth: 1024,
|
|
maxHeight: 1024,
|
|
);
|
|
|
|
if (photo == null) {
|
|
// User cancelled camera, abort delivery confirmation
|
|
if (mounted) {
|
|
setState(() {
|
|
isLoading = false;
|
|
isProcessing = false;
|
|
});
|
|
}
|
|
debugPrint('[CONFIRM] Camera cancelled by user');
|
|
return;
|
|
}
|
|
|
|
// Upload Proof
|
|
// By now, location fetching should be nearly done
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final userId = prefs.getInt('userid') ?? 0;
|
|
if (userId > 0) {
|
|
proofImageUrl = await dc.uploadProofImage(
|
|
File(photo.path),
|
|
'delivered',
|
|
userId,
|
|
deliveryIdInt,
|
|
);
|
|
}
|
|
} catch (e) {
|
|
debugPrint('[CONFIRM] Camera/Upload error: $e');
|
|
}
|
|
|
|
// Now wait for location if it hasn't finished yet
|
|
String riderLatStr = '0';
|
|
String riderLngStr = '0';
|
|
final position = await locationFuture;
|
|
riderLatStr = position.latitude.toStringAsFixed(6);
|
|
riderLngStr = position.longitude.toStringAsFixed(6);
|
|
|
|
// Validate coordinates before API call
|
|
final double riderLat = double.tryParse(riderLatStr) ?? 0.0;
|
|
final double riderLng = double.tryParse(riderLngStr) ?? 0.0;
|
|
final bool hasValidRiderLocation =
|
|
riderLat != 0 &&
|
|
riderLng != 0 &&
|
|
riderLat.abs() <= 90 &&
|
|
riderLng.abs() <= 180;
|
|
final bool hasValidDeliveryLocation =
|
|
deliveryLat != 0 &&
|
|
deliveryLng != 0 &&
|
|
deliveryLat.abs() <= 90 &&
|
|
deliveryLng.abs() <= 180;
|
|
|
|
if (!hasValidRiderLocation || !hasValidDeliveryLocation) {
|
|
debugPrint(
|
|
'[DELIVER][CONFIRM] ⚠️ Invalid coordinates - Rider: ($riderLat, $riderLng), Delivery: ($deliveryLat, $deliveryLng)',
|
|
);
|
|
if (mounted) {
|
|
setState(() {
|
|
isLoading = false;
|
|
});
|
|
}
|
|
// Show error alert
|
|
Get.snackbar(
|
|
'Location Error',
|
|
'Unable to get your location. Please ensure GPS is enabled and try again.',
|
|
backgroundColor: Colors.red,
|
|
colorText: Colors.white,
|
|
duration: const Duration(seconds: 4),
|
|
snackPosition: SnackPosition.TOP,
|
|
isDismissible: true,
|
|
);
|
|
lastFailureTime = DateTime.now();
|
|
await Future.delayed(const Duration(seconds: 4));
|
|
if (mounted) {
|
|
setState(() {
|
|
isProcessing = false;
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
|
|
|
|
|
|
if (proofImageUrl == null) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Failed to upload proof image. Please try again.')),
|
|
);
|
|
setState(() {
|
|
isLoading = false;
|
|
isProcessing = false;
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
|
|
apiSuccess = await dc.updateDeliveredStatus(
|
|
deliveryId: deliveryIdInt,
|
|
orderHeaderId: orderHeaderId,
|
|
deliveryLocationId: deliveryLocationId,
|
|
smsDelivery: 0,
|
|
ridersLat: riderLatStr,
|
|
ridersLng: riderLngStr,
|
|
pickupLat: pickupLat.toStringAsFixed(6),
|
|
pickupLng: pickupLng.toStringAsFixed(6),
|
|
deliveryLat: deliveryLat.toStringAsFixed(6),
|
|
deliveryLng: deliveryLng.toStringAsFixed(6),
|
|
notes: selectedOption,
|
|
deliveryAmount: deliveryAmount,
|
|
collectionAmount: collectionAmtToSend,
|
|
collectedAmount: collectedAmtToSend,
|
|
collectionStatus: collectionStatusToSend,
|
|
wasSkipped: wasSkipped,
|
|
orderId: orderId, // ✅ Pass orderId for bonus points logic
|
|
proofImage: proofImageUrl,
|
|
);
|
|
debugPrint('[DELIVER][CONFIRM] result=$apiSuccess');
|
|
|
|
if (apiSuccess) {
|
|
// On success, cache and clean up before navigating to done screen
|
|
await _cacheDeliveredCancelled(
|
|
deliveryId,
|
|
orderId,
|
|
isDelivered: true,
|
|
);
|
|
await _cleanupAfterCompletion(
|
|
orderId,
|
|
deliveryId,
|
|
parentState,
|
|
isCancelled: false,
|
|
);
|
|
if (!mounted) return;
|
|
|
|
if (!mounted) return;
|
|
|
|
Navigator.pushReplacement(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => DeliveriesDone(
|
|
bonusPoints: dc.lastBonusPoints.value,
|
|
),
|
|
),
|
|
);
|
|
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');
|
|
}
|
|
|
|
// Validate coordinates before API call
|
|
final double riderLat = double.tryParse(riderLatStr) ?? 0.0;
|
|
final double riderLng = double.tryParse(riderLngStr) ?? 0.0;
|
|
final bool hasValidRiderLocation =
|
|
riderLat != 0 &&
|
|
riderLng != 0 &&
|
|
riderLat.abs() <= 90 &&
|
|
riderLng.abs() <= 180;
|
|
|
|
// For cancellation, we need delivery location from the delivery data
|
|
final double cancelDeliveryLat = parseD(
|
|
d['droplat'] ?? d['DropLat'] ?? d['deliverylat'] ?? 0,
|
|
);
|
|
final double cancelDeliveryLng = parseD(
|
|
d['droplon'] ?? d['DropLon'] ?? d['deliverylong'] ?? 0,
|
|
);
|
|
final bool hasValidDeliveryLocation =
|
|
cancelDeliveryLat != 0 &&
|
|
cancelDeliveryLng != 0 &&
|
|
cancelDeliveryLat.abs() <= 90 &&
|
|
cancelDeliveryLng.abs() <= 180;
|
|
|
|
if (!hasValidRiderLocation || !hasValidDeliveryLocation) {
|
|
debugPrint(
|
|
'[CANCEL][CONFIRM] ⚠️ Invalid coordinates - Rider: ($riderLat, $riderLng), Delivery: ($cancelDeliveryLat, $cancelDeliveryLng)',
|
|
);
|
|
if (mounted) {
|
|
setState(() {
|
|
isLoading = false;
|
|
});
|
|
}
|
|
// Show error alert
|
|
Get.snackbar(
|
|
'Location Error',
|
|
'Unable to get your location. Please ensure GPS is enabled and try again.',
|
|
backgroundColor: Colors.red,
|
|
colorText: Colors.white,
|
|
duration: const Duration(seconds: 4),
|
|
snackPosition: SnackPosition.TOP,
|
|
isDismissible: true,
|
|
);
|
|
lastFailureTime = DateTime.now();
|
|
await Future.delayed(const Duration(seconds: 4));
|
|
if (mounted) {
|
|
setState(() {
|
|
isProcessing = false;
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
apiSuccess = await dc.updateCancelledStatus(
|
|
deliveryId: deliveryIdInt,
|
|
orderHeaderId: orderHeaderId,
|
|
ridersLat: riderLatStr,
|
|
ridersLng: riderLngStr,
|
|
pickupLat: pickupLat.toStringAsFixed(6),
|
|
pickupLng: pickupLng.toStringAsFixed(6),
|
|
deliveryLat: cancelDeliveryLat.toStringAsFixed(6),
|
|
deliveryLng: cancelDeliveryLng.toStringAsFixed(6),
|
|
notes: selectedOption,
|
|
wasSkipped: wasSkipped,
|
|
);
|
|
debugPrint('[CANCEL][CONFIRM] result=$apiSuccess');
|
|
|
|
if (apiSuccess) {
|
|
// On success, cache and clean up before navigating to done screen
|
|
await _cacheDeliveredCancelled(
|
|
deliveryId,
|
|
orderId,
|
|
isDelivered: false,
|
|
);
|
|
await _cleanupAfterCompletion(
|
|
orderId,
|
|
deliveryId,
|
|
parentState,
|
|
isCancelled: true,
|
|
);
|
|
if (!mounted) return;
|
|
|
|
Navigator.pushReplacement(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => const DeliveriesDone(isCancelled: true),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// If API failed, record the failure time to prevent duplicate alerts
|
|
lastFailureTime = DateTime.now();
|
|
debugPrint(
|
|
'[CONFIRM] ⚠️ API failed at $lastFailureTime, waiting for alert to dismiss...',
|
|
);
|
|
|
|
// Stop the loading spinner immediately
|
|
if (mounted) {
|
|
setState(() {
|
|
isLoading = false;
|
|
});
|
|
}
|
|
|
|
// Wait for geofence/error snackbar to show and be dismissed
|
|
// The controller's _checkGeofence already shows the snackbar, so we just wait
|
|
// Don't show duplicate fallback error - controller already shows error message
|
|
await Future.delayed(const Duration(seconds: 3));
|
|
|
|
// Now re-enable the button
|
|
if (mounted) {
|
|
setState(() {
|
|
isProcessing = false;
|
|
});
|
|
debugPrint('[CONFIRM] ✅ Button re-enabled after alert dismissal');
|
|
}
|
|
} catch (e) {
|
|
debugPrint('[CONFIRM] ❌ Exception during confirm: $e');
|
|
|
|
// Record failure time
|
|
lastFailureTime = DateTime.now();
|
|
|
|
// Stop loading spinner
|
|
if (mounted) {
|
|
setState(() {
|
|
isLoading = false;
|
|
});
|
|
}
|
|
|
|
// Wait for potential error alert to dismiss (4 seconds)
|
|
await Future.delayed(const Duration(seconds: 4));
|
|
|
|
// Re-enable button
|
|
if (mounted) {
|
|
setState(() {
|
|
isProcessing = false;
|
|
});
|
|
debugPrint('[CONFIRM] ✅ Button re-enabled after exception handling');
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _cacheDeliveredCancelled(
|
|
String deliveryId,
|
|
String orderId, {
|
|
required bool isDelivered,
|
|
}) async {
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final userId = prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0;
|
|
if (userId > 0) {
|
|
final key = 'recently_delivered_cancelled_$userId';
|
|
final deliveredCancelledJson = prefs.getString(key);
|
|
List<dynamic> deliveredCancelledList = [];
|
|
if (deliveredCancelledJson != null &&
|
|
deliveredCancelledJson.isNotEmpty) {
|
|
deliveredCancelledList = jsonDecode(deliveredCancelledJson);
|
|
}
|
|
|
|
deliveredCancelledList.add({
|
|
'deliveryId': deliveryId,
|
|
'orderId': orderId,
|
|
'timestamp': DateTime.now().toIso8601String(),
|
|
'type': isDelivered ? 'delivered' : 'cancelled',
|
|
});
|
|
|
|
await prefs.setString(key, jsonEncode(deliveredCancelledList));
|
|
debugPrint(
|
|
'[${isDelivered ? 'DELIVER' : 'CANCEL'}] ✅ Stored order in SharedPreferences: deliveryId=$deliveryId, orderId=$orderId',
|
|
);
|
|
}
|
|
} catch (e) {
|
|
debugPrint(
|
|
'[${isDelivered ? 'DELIVER' : 'CANCEL'}] Error storing order in SharedPreferences: $e',
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _cleanupAfterCompletion(
|
|
String orderId,
|
|
String deliveryId,
|
|
_MyDeliveriesState? parentState, {
|
|
required bool isCancelled,
|
|
}) async {
|
|
final label = isCancelled ? 'CANCEL' : 'DELIVER';
|
|
if (orderId.isEmpty) {
|
|
debugPrint('[$label] ⚠️ Empty orderId, skipping cleanup');
|
|
return;
|
|
}
|
|
|
|
if (parentState == null) {
|
|
debugPrint(
|
|
'[$label] ⚠️ parentState null; relying on cache cleanup via SharedPreferences',
|
|
);
|
|
// Try to find and stop timer from cart page if it exists
|
|
// This is a workaround - ideally we'd pass a callback
|
|
try {
|
|
// The cart page will handle timer cleanup on next refresh
|
|
debugPrint('[$label] Cart page will cleanup timer on next refresh');
|
|
} catch (_) {}
|
|
return;
|
|
}
|
|
|
|
parentState._stopDeliveryPosting(orderId);
|
|
debugPrint('[$label] Stopped timer for completed delivery: $orderId');
|
|
|
|
final deliveryKey = 'delivery_$deliveryId';
|
|
final orderKey = 'order_$orderId';
|
|
final actualKey = parentState._getOrderKey(widget.delivery);
|
|
|
|
bool cacheUpdated = false;
|
|
|
|
void removeKey(String key, String reason) {
|
|
if (parentState._skippedOrdersCache.remove(key) != null) {
|
|
parentState._skippedOrderTimestamps.remove(key);
|
|
cacheUpdated = true;
|
|
debugPrint('[$label] ✅ Removed from cache ($reason): $key');
|
|
}
|
|
}
|
|
|
|
removeKey(deliveryKey, 'delivery key');
|
|
removeKey(orderKey, 'order key');
|
|
removeKey(actualKey, 'actual key');
|
|
|
|
if (!cacheUpdated) {
|
|
final keysToRemove = <String>[];
|
|
// NOTE: We rely on the explicitly constructed keys above to remove entries.
|
|
// Iterating via entry values isn't supported with Map<String, String> cache.
|
|
// If items were not found by keys above, they remain until valid keys are used.
|
|
|
|
for (final key in keysToRemove) {
|
|
parentState._skippedOrdersCache.remove(key);
|
|
parentState._skippedOrderTimestamps.remove(key);
|
|
cacheUpdated = true;
|
|
debugPrint('[$label] ✅ Removed from cache (matched by ID): $key');
|
|
}
|
|
}
|
|
|
|
if (cacheUpdated) {
|
|
await parentState._saveSkippedOrdersCache();
|
|
debugPrint(
|
|
'[$label] ✅ Cache saved. Remaining: ${parentState._skippedOrdersCache.length}',
|
|
);
|
|
} else {
|
|
debugPrint(
|
|
'[$label] ⚠️ Order not found in skipped cache (cleanup will run on next fetch)',
|
|
);
|
|
}
|
|
}
|
|
}
|