1763 lines
63 KiB
Dart
1763 lines
63 KiB
Dart
// ignore_for_file: unuse, unused_element, duplicate_ignore, unnecessary_cast
|
|
library;
|
|
|
|
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:ui' as ui;
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_polyline_points/flutter_polyline_points.dart';
|
|
import 'package:flutter_slidable/flutter_slidable.dart';
|
|
import 'package:geolocator/geolocator.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:google_maps_flutter/google_maps_flutter.dart';
|
|
import 'package:lottie/lottie.dart' hide Marker;
|
|
import 'package:nearle/views/helpers/constants/Colorconstants.dart';
|
|
import 'package:nearle/views/helpers/constants/Font_constant.dart';
|
|
import 'package:slider_button_lite/feature/presentation/slider_button/slider.dart';
|
|
import 'package:slider_button_lite/feature/presentation/slider_button/slider_button_prop.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
import 'package:nearle/providers/delivery/delivery_provider.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:nearle/controllers/deliveries_controller.dart';
|
|
import 'package:wakelock_plus/wakelock_plus.dart';
|
|
import 'package:nearle/widget/Bottom_page.dart';
|
|
import 'package:circular_countdown_timer/circular_countdown_timer.dart';
|
|
import 'dart:math' as math;
|
|
import 'package:nearle/controllers/riderlog.dart';
|
|
import 'package:nearle/providers/deliverylog/deliverylog_provider.dart';
|
|
import 'package:nearle/views/helpers/constants/apiconstants.dart';
|
|
import 'dart:io' show Platform, File;
|
|
import 'package:image_picker/image_picker.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:floating/floating.dart';
|
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:scratcher/scratcher.dart';
|
|
import 'package:confetti/confetti.dart';
|
|
|
|
part 'card.dart';
|
|
part 'map.dart';
|
|
part 'nav.dart';
|
|
part 'pip.dart';
|
|
part 'sheet.dart';
|
|
part 'map_btn.dart';
|
|
part 'multi_map.dart';
|
|
part 'done.dart';
|
|
part 'skip_sheet.dart';
|
|
part '../Cart/cartpage.dart';
|
|
|
|
/// Helper function to launch phone dialer - works in both debug and release builds
|
|
/// In release builds, canLaunchUrl may fail due to R8/ProGuard, so we always try to launch
|
|
Future<bool> launchPhoneDialer(String phoneNumber) async {
|
|
try {
|
|
// Sanitize phone number: keep only digits and '+'
|
|
final phone = phoneNumber.replaceAll(RegExp(r'[^\d+]'), '');
|
|
|
|
if (phone.isEmpty) {
|
|
debugPrint(
|
|
'[PHONE] Empty phone number after sanitization, skipping dial',
|
|
);
|
|
return false;
|
|
}
|
|
|
|
final Uri uri = Uri(scheme: 'tel', path: phone);
|
|
|
|
// Try canLaunchUrl first (works in debug, may fail in release)
|
|
bool canLaunch = false;
|
|
try {
|
|
canLaunch = await canLaunchUrl(uri);
|
|
debugPrint('[PHONE] canLaunchUrl result: $canLaunch');
|
|
} catch (e) {
|
|
debugPrint('[PHONE] canLaunchUrl check failed (common in release): $e');
|
|
// Continue anyway - launch might still work
|
|
}
|
|
|
|
// Always attempt to launch, even if canLaunchUrl returned false
|
|
// Using LaunchMode.platformDefault is often safer for system intents like dialing
|
|
try {
|
|
final launched = await launchUrl(uri, mode: LaunchMode.platformDefault);
|
|
if (launched) {
|
|
debugPrint('[PHONE] Successfully launched dialer for: $phone');
|
|
return true;
|
|
} else {
|
|
debugPrint('[PHONE] launchUrl returned false for: $phone');
|
|
}
|
|
} catch (e) {
|
|
debugPrint('[PHONE] Failed to launch dialer: $e');
|
|
}
|
|
return false;
|
|
} catch (e) {
|
|
debugPrint('[PHONE] Error in launchPhoneDialer: $e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
class MyDeliveries extends StatefulWidget {
|
|
const MyDeliveries({super.key});
|
|
|
|
@override
|
|
State<MyDeliveries> createState() => _MyDeliveriesState();
|
|
|
|
// ✅ Public static method to navigate to delivery map screen from outside (e.g., home page)
|
|
static Future<void> navigateToDeliveryMap(
|
|
BuildContext context,
|
|
Map<String, dynamic> delivery,
|
|
) async {
|
|
await Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => _RiderNavigationScreen(
|
|
delivery: delivery,
|
|
parentState: null, // No parent state when called from outside
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MyDeliveriesState extends State<MyDeliveries>
|
|
with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
|
|
final DeliveryProvider _provider = DeliveryProvider();
|
|
final CreateDeliveryLogProvider _deliveryLogProvider =
|
|
CreateDeliveryLogProvider();
|
|
List<Map<String, dynamic>> _picked = <Map<String, dynamic>>[];
|
|
List<Map<String, dynamic>> _activeDeliveries =
|
|
<Map<String, dynamic>>[]; // Track active deliveries for banner
|
|
|
|
StreamSubscription<void>? _pollerSubscription;
|
|
bool _fetching = false;
|
|
Position? _currentLocation;
|
|
final Map<String, Timer> _deliveryTimers = <String, Timer>{};
|
|
final Map<String, Map<String, dynamic>> _deliveryBasePayload =
|
|
<String, Map<String, dynamic>>{};
|
|
// Preserve original step numbers so they don't change when deliveries are completed
|
|
final Map<String, int> _preservedStepNumbers = <String, int>{};
|
|
String? _activeDeliveryOrderId;
|
|
// Cache for skipped orders
|
|
final Map<String, String> _skippedOrdersCache = {};
|
|
final Map<String, int> _skippedOrderTimestamps = {};
|
|
|
|
Future<void> _saveSkippedOrdersCache() async {
|
|
// Implementation can be empty if we rely on API now,
|
|
// or strictly local. For now, we'll keep it simple or empty
|
|
// to satisfy the interface expected by child widgets.
|
|
// If child widgets call this, they expect it to exist.
|
|
}
|
|
|
|
@override
|
|
bool get wantKeepAlive => true;
|
|
|
|
double _parseD(dynamic v) {
|
|
if (v == null) return 0.0;
|
|
if (v is num) return v.toDouble();
|
|
return double.tryParse(v.toString()) ?? 0.0;
|
|
}
|
|
|
|
double _haversineKm(double lat1, double lon1, double lat2, double lon2) {
|
|
const double R = 6371.0;
|
|
final double dLat = _toRadians(lat2 - lat1);
|
|
final double dLon = _toRadians(lon2 - lon1);
|
|
|
|
final double a =
|
|
math.sin(dLat / 2) * math.sin(dLat / 2) +
|
|
math.cos(_toRadians(lat1)) *
|
|
math.cos(_toRadians(lat2)) *
|
|
math.sin(dLon / 2) *
|
|
math.sin(dLon / 2);
|
|
|
|
final double c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a));
|
|
return R * c;
|
|
}
|
|
|
|
double _toRadians(double degrees) {
|
|
return degrees * math.pi / 180.0;
|
|
}
|
|
|
|
int _getStepNumber(Map<String, dynamic> order) {
|
|
final dynamic raw = order['step'] ?? order['Step'];
|
|
final int step = raw == null
|
|
? 0
|
|
: (raw is num ? raw.toInt() : int.tryParse(raw.toString()) ?? 0);
|
|
return step;
|
|
}
|
|
|
|
String _getOrderKey(Map<String, dynamic> order) {
|
|
// Use deliveryId as primary key, fallback to orderId
|
|
final deliveryId = (order['deliveryid'] ?? '').toString();
|
|
final orderId = (order['orderid'] ?? '').toString();
|
|
return deliveryId.isNotEmpty ? 'delivery_$deliveryId' : 'order_$orderId';
|
|
}
|
|
|
|
int _getPreservedOrCurrentStep(Map<String, dynamic> order) {
|
|
final orderKey = _getOrderKey(order);
|
|
// If we have a preserved step number, use it; otherwise use current API step
|
|
if (_preservedStepNumbers.containsKey(orderKey)) {
|
|
return _preservedStepNumbers[orderKey]!;
|
|
}
|
|
final currentStep = _getStepNumber(order);
|
|
// Preserve the step number if it's valid (> 0)
|
|
if (currentStep > 0) {
|
|
_preservedStepNumbers[orderKey] = currentStep;
|
|
}
|
|
return currentStep;
|
|
}
|
|
|
|
String _distanceKmDisplay(Map<String, dynamic> m) {
|
|
final String apiKmsStr = (m['kms'] ?? '').toString().trim();
|
|
final double apiKms = double.tryParse(apiKmsStr) ?? 0.0;
|
|
if (apiKms > 0) {
|
|
return apiKms < 10 ? apiKmsStr : apiKms.toStringAsFixed(0);
|
|
}
|
|
|
|
final double rLat = _parseD(m['riderslat']);
|
|
final double rLon = _parseD(m['riderslon']);
|
|
final double dLat = _parseD(m['droplat'] ?? m['deliverylat']);
|
|
final double dLon = _parseD(m['droplon'] ?? m['deliverylong']);
|
|
|
|
if (rLat != 0 && rLon != 0 && dLat != 0 && dLon != 0) {
|
|
final double km = _haversineKm(rLat, rLon, dLat, dLon);
|
|
return km.toStringAsFixed(km < 10 ? 1 : 0);
|
|
}
|
|
|
|
final double pLat = _parseD(m['pickuplat']);
|
|
final double pLon = _parseD(m['pickuplon']);
|
|
if (pLat != 0 && pLon != 0 && dLat != 0 && dLon != 0) {
|
|
final double km = _haversineKm(pLat, pLon, dLat, dLon);
|
|
return km.toStringAsFixed(km < 10 ? 1 : 0);
|
|
}
|
|
|
|
return '0';
|
|
}
|
|
|
|
double _calculateDistanceFromCurrent(Map<String, dynamic> order) {
|
|
if (_currentLocation == null) return double.infinity;
|
|
|
|
final double dropLat = _parseD(order['droplat'] ?? order['deliverylat']);
|
|
final double dropLon = _parseD(order['droplon'] ?? order['deliverylong']);
|
|
|
|
if (dropLat == 0 || dropLon == 0) return double.infinity;
|
|
|
|
return _haversineKm(
|
|
_currentLocation!.latitude,
|
|
_currentLocation!.longitude,
|
|
dropLat,
|
|
dropLon,
|
|
);
|
|
}
|
|
|
|
List<Map<String, dynamic>> _sortOrders(List<Map<String, dynamic>> orders) {
|
|
if (orders.isEmpty) return orders;
|
|
|
|
// Use preserved step numbers for sorting to maintain original order
|
|
final ordersWithStep = orders
|
|
.where((o) => _getPreservedOrCurrentStep(o) > 0)
|
|
.toList();
|
|
final ordersWithoutStep = orders
|
|
.where((o) => _getPreservedOrCurrentStep(o) == 0)
|
|
.toList();
|
|
|
|
ordersWithStep.sort(
|
|
(a, b) => _getPreservedOrCurrentStep(
|
|
a,
|
|
).compareTo(_getPreservedOrCurrentStep(b)),
|
|
);
|
|
|
|
if (_currentLocation != null && ordersWithoutStep.isNotEmpty) {
|
|
ordersWithoutStep.sort((a, b) {
|
|
final distA = _calculateDistanceFromCurrent(a);
|
|
final distB = _calculateDistanceFromCurrent(b);
|
|
return distA.compareTo(distB);
|
|
});
|
|
}
|
|
|
|
return [...ordersWithStep, ...ordersWithoutStep];
|
|
}
|
|
|
|
int _getDisplayStepNumber(List<Map<String, dynamic>> allOrders, int index) {
|
|
final order = allOrders[index];
|
|
// Use preserved step number if available, otherwise get current step
|
|
final step = _getPreservedOrCurrentStep(order);
|
|
|
|
if (step > 0) return step;
|
|
|
|
// For orders without step numbers, calculate based on orders that have step numbers
|
|
final ordersWithStep = allOrders
|
|
.where((o) => _getPreservedOrCurrentStep(o) > 0)
|
|
.length;
|
|
return ordersWithStep + (index - ordersWithStep) + 1;
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addObserver(this);
|
|
WakelockPlus.enable();
|
|
|
|
// Listen for external refresh triggers
|
|
try {
|
|
final dc = Get.isRegistered<DeliveriesController>()
|
|
? Get.find<DeliveriesController>()
|
|
: Get.put(DeliveriesController());
|
|
|
|
ever(dc.refreshTrigger, (_) {
|
|
if (mounted) {
|
|
debugPrint('[MYDELIVERIES] External trigger -> Refreshing picked orders');
|
|
_fetchPicked();
|
|
}
|
|
});
|
|
} catch (_) {}
|
|
|
|
// Run these in parallel
|
|
_restoreActiveDelivery();
|
|
_initializeLocation(); // Don't block
|
|
_fetchPicked();
|
|
_startPollingStream();
|
|
}
|
|
|
|
Future<void> _restoreActiveDelivery() async {
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final savedActiveId = prefs.getString('active_delivery_order_id');
|
|
if (savedActiveId != null && savedActiveId.isNotEmpty) {
|
|
_activeDeliveryOrderId = savedActiveId;
|
|
debugPrint(
|
|
'[MYDELIVERIES] Restored active delivery orderId: $savedActiveId',
|
|
);
|
|
}
|
|
} catch (e) {
|
|
debugPrint('[MYDELIVERIES] Error restoring active delivery: $e');
|
|
}
|
|
}
|
|
|
|
void _startPollingStream() {
|
|
_pollerSubscription?.cancel();
|
|
_pollerSubscription = Stream.periodic(const Duration(seconds: 3), (_) {})
|
|
.asyncMap((_) async {
|
|
if (mounted && !_fetching) {
|
|
await _fetchPicked();
|
|
}
|
|
})
|
|
.listen(
|
|
(_) {}, // Success handler
|
|
onError: (error) {
|
|
// Handle errors gracefully without crashing
|
|
debugPrint('[MYDELIVERIES][STREAM ERROR] $error');
|
|
},
|
|
cancelOnError: false, // Continue even on errors
|
|
);
|
|
}
|
|
|
|
Future<void> _initializeLocation() async {
|
|
try {
|
|
// 1. Try Last Known Position (Instant & Preferred)
|
|
try {
|
|
final lastPos = await Geolocator.getLastKnownPosition();
|
|
if (lastPos != null && mounted) {
|
|
setState(() {
|
|
_currentLocation = lastPos;
|
|
});
|
|
// If we have a cached location, don't block waiting for fresh GPS
|
|
// We can let the background stream update it later
|
|
return;
|
|
}
|
|
} catch (_) {}
|
|
|
|
// 2. Try Current Position (Optimized)
|
|
final bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
|
if (!serviceEnabled) return;
|
|
|
|
LocationPermission permission = await Geolocator.checkPermission();
|
|
if (permission == LocationPermission.denied) {
|
|
permission = await Geolocator.requestPermission();
|
|
}
|
|
|
|
if (permission == LocationPermission.deniedForever ||
|
|
permission == LocationPermission.denied) {
|
|
return;
|
|
}
|
|
|
|
// Single attempt with balanced accuracy/timeout
|
|
try {
|
|
final position = await Geolocator.getCurrentPosition(
|
|
locationSettings: const LocationSettings(
|
|
accuracy: LocationAccuracy.high,
|
|
timeLimit: Duration(seconds: 3),
|
|
),
|
|
);
|
|
|
|
if (mounted) {
|
|
setState(() => _currentLocation = position);
|
|
}
|
|
} catch (_) {
|
|
// Fallback to low accuracy if high fails
|
|
try {
|
|
final position = await Geolocator.getCurrentPosition(
|
|
locationSettings: const LocationSettings(
|
|
accuracy: LocationAccuracy.low,
|
|
timeLimit: Duration(seconds: 2),
|
|
),
|
|
);
|
|
if (mounted) {
|
|
setState(() => _currentLocation = position);
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
} catch (e) {
|
|
debugPrint('[MYDELIVERIES] Error initializing location: $e');
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
_pollerSubscription?.cancel();
|
|
for (final timer in _deliveryTimers.values) {
|
|
timer.cancel();
|
|
}
|
|
_deliveryTimers.clear();
|
|
_deliveryBasePayload.clear();
|
|
// Persist current live deliveries state for other screens
|
|
SharedPreferences.getInstance()
|
|
.then((p) => p.setBool('has_live_deliveries', _picked.isNotEmpty))
|
|
// ignore: body_might_complete_normally_catch_error
|
|
.catchError((_) {});
|
|
WakelockPlus.disable();
|
|
super.dispose();
|
|
}
|
|
|
|
bool _shallowMapEquals(Map<String, dynamic> a, Map<String, dynamic> b) {
|
|
if (identical(a, b)) return true;
|
|
if (a.length != b.length) return false;
|
|
for (final entry in a.entries) {
|
|
if (b[entry.key] != entry.value) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Helper to check if two lists of orders are effectively equal
|
|
// This prevents unnecessary rebuilds when polling
|
|
bool _areOrdersEqual(
|
|
List<Map<String, dynamic>> oldList,
|
|
List<Map<String, dynamic>> newList,
|
|
) {
|
|
if (oldList.length != newList.length) return false;
|
|
|
|
for (int i = 0; i < oldList.length; i++) {
|
|
final oldItem = oldList[i];
|
|
final newItem = newList[i];
|
|
|
|
// Compare critical fields that affect UI or logic
|
|
if (oldItem['orderid'] != newItem['orderid']) return false;
|
|
if (oldItem['orderstatus'] != newItem['orderstatus']) return false;
|
|
if (oldItem['step'] != newItem['step']) return false;
|
|
|
|
// Compare location data (important for map updates)
|
|
if (oldItem['riderslat'] != newItem['riderslat']) return false;
|
|
if (oldItem['riderslon'] != newItem['riderslon']) return false;
|
|
if (oldItem['deliverylat'] != newItem['deliverylat']) return false;
|
|
if (oldItem['deliverylong'] != newItem['deliverylong']) return false;
|
|
|
|
// Compare notes/instructions if they might change
|
|
if (oldItem['notes'] != newItem['notes']) return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// Manually mark skipped to trigger immediate refresh
|
|
void markOrderAsSkipped(Map<String, dynamic> order, String reason) {
|
|
if (!mounted) return;
|
|
debugPrint('[MYDELIVERIES] Mark skipped -> Refreshing from API...');
|
|
// We don't update local state manually anymore, we trust the API to return the 'skipped' status.
|
|
// Just trigger a fetch.
|
|
_fetchPicked();
|
|
}
|
|
|
|
// ✅ Check if there's an active delivery (blocks all actions)
|
|
bool _hasActiveDelivery() {
|
|
return _activeDeliveries.isNotEmpty;
|
|
}
|
|
|
|
// Previously showed a blocking dialog; now we just disable conflicting buttons in the UI.
|
|
void _showActiveDeliveryBlockMessage() {}
|
|
|
|
Future<void> startDelivery(Map<String, dynamic> item) async {
|
|
if (!mounted) return;
|
|
|
|
// ✅ BLOCK: Check if there's already an active delivery (and this isn't it)
|
|
final currentOrderId = (item['orderid'] ?? '').toString();
|
|
final activeOrderIds = _activeDeliveries
|
|
.map((d) => (d['orderid'] ?? '').toString())
|
|
.where((id) => id.isNotEmpty)
|
|
.toSet();
|
|
|
|
// Allow if this IS the active delivery, block if there's a different active delivery
|
|
if (_hasActiveDelivery() && !activeOrderIds.contains(currentOrderId)) {
|
|
// Just ignore taps on other cards when a different active delivery exists.
|
|
return;
|
|
}
|
|
|
|
// Verify status is updated to ACTIVE before navigating
|
|
final dc = Get.put(DeliveriesController());
|
|
final dId = int.tryParse((item['deliveryid'] ?? 0).toString()) ?? 0;
|
|
final ohId = int.tryParse((item['orderheaderid'] ?? 0).toString()) ?? 0;
|
|
|
|
if (dId > 0 && ohId > 0) {
|
|
// We don't block navigation on failure, but we try to update
|
|
// This ensures 'starttime' is generated and saved
|
|
await dc.updateActiveStatus(
|
|
deliveryId: dId,
|
|
orderHeaderId: ohId,
|
|
orderId: (item['orderid'] ?? '').toString(),
|
|
);
|
|
}
|
|
|
|
await Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) =>
|
|
_RiderNavigationScreen(delivery: item, parentState: this),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> resumeDelivery(Map<String, dynamic> item) async {
|
|
// ✅ BLOCK: Check if there's already an active delivery (and this isn't it)
|
|
final currentOrderId = (item['orderid'] ?? '').toString();
|
|
final activeOrderIds = _activeDeliveries
|
|
.map((d) => (d['orderid'] ?? '').toString())
|
|
.where((id) => id.isNotEmpty)
|
|
.toSet();
|
|
|
|
// Allow if this IS the active delivery, block if there's a different active delivery
|
|
if (_hasActiveDelivery() && !activeOrderIds.contains(currentOrderId)) {
|
|
// Ignore resume taps when some other delivery is active.
|
|
return;
|
|
}
|
|
|
|
// For now, resume behaves same as start (navigates to nav screen)
|
|
// You can add specific resume logic here if needed (e.g. un-skip)
|
|
await startDelivery(item);
|
|
}
|
|
|
|
Future<void> _fetchPicked() async {
|
|
if (_fetching) return;
|
|
|
|
_fetching = true;
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final userId = prefs.getInt('userId') ?? prefs.getInt('userid') ?? 0;
|
|
|
|
// ✅ CRITICAL: Fetch from v3 API (picked orders) AND v2 API (all statuses) IN PARALLEL
|
|
|
|
// Define V2 fetcher function
|
|
Future<List<dynamic>> fetchV2() async {
|
|
try {
|
|
final now = DateTime.now();
|
|
final today =
|
|
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
|
|
final bool isLive = ApiConstants.mainRoute == 'live';
|
|
final baseUrl = isLive
|
|
? 'https://jupiter.nearle.app/live/api/v2/deliveries/getdeliveries'
|
|
: 'https://jupiter.nearle.app/dev/api/v2/deliveries/getdeliveries';
|
|
|
|
final uri = Uri.parse(baseUrl).replace(
|
|
queryParameters: {
|
|
'userid': userId.toString(),
|
|
'fromdate': today,
|
|
'todate': today,
|
|
't': DateTime.now().millisecondsSinceEpoch.toString(),
|
|
},
|
|
);
|
|
|
|
final httpClient = http.Client();
|
|
try {
|
|
final response = await httpClient.get(uri);
|
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
|
final decoded = json.decode(response.body);
|
|
final data = decoded is Map<String, dynamic>
|
|
? (decoded['details'] ?? decoded['data'] ?? decoded)
|
|
: decoded;
|
|
return data is List
|
|
? data
|
|
: (data is Map && data['items'] is List
|
|
? data['items'] as List
|
|
: []);
|
|
}
|
|
} finally {
|
|
httpClient.close();
|
|
}
|
|
} catch (e) {
|
|
debugPrint('[MYDELIVERIES] Error fetching from v2 API: $e');
|
|
}
|
|
return [];
|
|
}
|
|
|
|
// Execute in parallel
|
|
final results = await Future.wait([
|
|
_provider.getDeliveryQueuesPicked(live: true, userid: userId),
|
|
fetchV2(),
|
|
]);
|
|
|
|
final itemsV3 = results[0] as List<dynamic>;
|
|
final itemsV2 = results[1] as List<dynamic>;
|
|
|
|
// Merge v3 orders (picked) with v2 orders (all statuses)
|
|
final Map<String, Map<String, dynamic>> mergedOrders = {};
|
|
|
|
// First add v3 orders (picked)
|
|
for (final order in itemsV3.whereType<Map<String, dynamic>>()) {
|
|
final key = _getOrderKey(order);
|
|
mergedOrders[key] = order;
|
|
}
|
|
|
|
// Then add/update with v2 orders - V2 STATUS TAKES PRECEDENCE
|
|
for (final order in itemsV2.whereType<Map<String, dynamic>>()) {
|
|
final key = _getOrderKey(order);
|
|
final existing = mergedOrders[key];
|
|
|
|
if (existing == null) {
|
|
mergedOrders[key] = order;
|
|
} else {
|
|
// If it exists in V3 (picked) but V2 says something else, trust V2.
|
|
// Especially for 'skipped', 'delivered', 'cancelled'.
|
|
mergedOrders[key] = {
|
|
...existing,
|
|
...order, // Overwrite with V2 data
|
|
};
|
|
}
|
|
}
|
|
|
|
final items = mergedOrders.values.toList();
|
|
debugPrint('[MYDELIVERIES] Merged items count: ${items.length}');
|
|
|
|
final dedupedList = items; // Already deduped by key map
|
|
|
|
// Preserve step numbers for existing orders
|
|
for (final order in dedupedList) {
|
|
final orderKey = _getOrderKey(order);
|
|
final currentStep = _getStepNumber(order);
|
|
if (currentStep > 0 && !_preservedStepNumbers.containsKey(orderKey)) {
|
|
_preservedStepNumbers[orderKey] = currentStep;
|
|
}
|
|
}
|
|
|
|
// Sort the list
|
|
final sortedList = _sortOrders(dedupedList);
|
|
|
|
// Filter out ACTIVE orders from top list (they go to banner)
|
|
final listForTop = sortedList.where((o) {
|
|
final status = (o['orderstatus']?.toString().toLowerCase() ?? '')
|
|
.trim();
|
|
return status != 'active';
|
|
}).toList();
|
|
|
|
final skippedInFinal = listForTop.where((m) {
|
|
final status = (m['orderstatus']?.toString().toLowerCase() ?? '')
|
|
.trim();
|
|
return status == 'skipped';
|
|
}).length;
|
|
|
|
debugPrint(
|
|
'[MYDELIVERIES] Final top list: ${listForTop.length} (skipped: $skippedInFinal)',
|
|
);
|
|
|
|
// Check if data has actually changed before rebuilding
|
|
// This avoids unnecessary setState calls during polling
|
|
final bool hasChanged = !_areOrdersEqual(_picked, listForTop);
|
|
|
|
if (hasChanged) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_picked = listForTop;
|
|
});
|
|
debugPrint(
|
|
'[MYDELIVERIES] 🔄 UI Updated: ${listForTop.length} orders ($skippedInFinal skipped)',
|
|
);
|
|
}
|
|
} else {
|
|
// No changes
|
|
}
|
|
|
|
// Get all order IDs from the current list
|
|
final currentOrderIds = sortedList
|
|
.map((o) => (o['orderid'] ?? '').toString())
|
|
.where((id) => id.isNotEmpty)
|
|
.toSet();
|
|
|
|
// ✅ Find all ACTIVE deliveries and ensure timers are running
|
|
final activeOrders = sortedList.where((o) {
|
|
final status = (o['orderstatus']?.toString().toLowerCase() ?? '')
|
|
.trim();
|
|
return status == 'active';
|
|
}).toList();
|
|
|
|
debugPrint(
|
|
'[MYDELIVERIES] Found ${activeOrders.length} active deliveries',
|
|
);
|
|
|
|
// ✅ CRITICAL: Only set has_live_deliveries to true if there are ACTIVE orders (status = "active")
|
|
// Don't set it to true for "picked" or other statuses - only for "active"
|
|
try {
|
|
final hasActiveOrders = activeOrders.isNotEmpty;
|
|
await prefs.setBool('has_live_deliveries', hasActiveOrders);
|
|
debugPrint(
|
|
'[MYDELIVERIES] Set has_live_deliveries: $hasActiveOrders (${activeOrders.length} active orders)',
|
|
);
|
|
|
|
// Clear active_delivery_order_id if no active deliveries
|
|
if (!hasActiveOrders) {
|
|
final activeOrderId = prefs.getString('active_delivery_order_id');
|
|
if (activeOrderId != null && activeOrderId.isNotEmpty) {
|
|
debugPrint(
|
|
'[MYDELIVERIES] Clearing stale active_delivery_order_id: $activeOrderId (no active deliveries)',
|
|
);
|
|
await prefs.remove('active_delivery_order_id');
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
|
|
// Update active deliveries list for banner display
|
|
if (mounted) {
|
|
setState(() {
|
|
_activeDeliveries = activeOrders;
|
|
});
|
|
}
|
|
|
|
// Get set of active order IDs
|
|
final activeOrderIds = activeOrders
|
|
.map((o) => (o['orderid'] ?? '').toString())
|
|
.where((id) => id.isNotEmpty)
|
|
.toSet();
|
|
|
|
// ✅ CRITICAL: Start timers ONLY for active deliveries that don't have one yet
|
|
// This ensures logs are posted every 30 seconds WITHOUT restarting timers on every fetch
|
|
for (final order in activeOrders) {
|
|
final orderId = (order['orderid'] ?? '').toString();
|
|
if (orderId.isEmpty) continue;
|
|
|
|
// ✅ ONLY start timer if it doesn't already exist
|
|
// This prevents restarting timers on every _fetchPicked() call (which happens frequently)
|
|
if (!_deliveryTimers.containsKey(orderId)) {
|
|
debugPrint(
|
|
'[MYDELIVERIES] Active delivery: $orderId - Starting delivery log posting (timer not found)',
|
|
);
|
|
// ✅ RE-ENABLED: Delivery logs now posted from deliveries page with robust validation
|
|
await _startDeliveryPosting(order);
|
|
} else {
|
|
debugPrint(
|
|
'[MYDELIVERIES] Active delivery: $orderId - Timer already running, skipping restart',
|
|
);
|
|
}
|
|
|
|
_activeDeliveryOrderId = orderId; // Track the active delivery
|
|
|
|
// Persist active delivery ID so it survives app restarts
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString('active_delivery_order_id', orderId);
|
|
} catch (e) {
|
|
debugPrint('[MYDELIVERIES] Error saving active delivery ID: $e');
|
|
}
|
|
}
|
|
|
|
// ✅ Stop timers ONLY for deliveries that are no longer active or in the list
|
|
final timersToStop = _deliveryTimers.keys
|
|
.where(
|
|
(id) =>
|
|
!activeOrderIds.contains(id) || !currentOrderIds.contains(id),
|
|
)
|
|
.toList();
|
|
|
|
for (final id in timersToStop) {
|
|
debugPrint(
|
|
'[MYDELIVERIES] Stopping timer for orderId: $id (no longer active)',
|
|
);
|
|
_stopDeliveryPosting(id);
|
|
}
|
|
|
|
debugPrint(
|
|
'[MYDELIVERIES] Active timers: ${_deliveryTimers.keys.toList()}',
|
|
);
|
|
debugPrint(
|
|
'[MYDELIVERIES] Active delivery orderId: $_activeDeliveryOrderId',
|
|
);
|
|
} catch (e) {
|
|
debugPrint('[MYDELIVERIES] Error fetching picked orders: $e');
|
|
} finally {
|
|
_fetching = false;
|
|
}
|
|
}
|
|
|
|
// ignore: unused_element
|
|
Map<String, dynamic> _createBasePayload(Map<String, dynamic> it) {
|
|
return <String, dynamic>{
|
|
'logid': 0,
|
|
'tenantid': it['tenantid'] ?? 0,
|
|
'partnerid': it['partnerid'] ?? 0,
|
|
'locationid': it['locationid'] ?? 0,
|
|
'orderheaderid': it['orderheaderid'] ?? 0,
|
|
'deliveryid': it['deliveryid'] ?? 0,
|
|
'userid': it['userid'] ?? 0,
|
|
'orderid': (it['orderid'] ?? '').toString(),
|
|
'orderstatus': 'active',
|
|
};
|
|
}
|
|
|
|
// Stop posting logs for a specific delivery
|
|
void _stopDeliveryPosting(String orderId) {
|
|
final timer = _deliveryTimers.remove(orderId);
|
|
if (timer != null) {
|
|
timer.cancel();
|
|
debugPrint('[DELIVERYLOG] Stopped posting logs for orderId: $orderId');
|
|
} else {
|
|
debugPrint('[DELIVERYLOG] No timer to stop for orderId: $orderId');
|
|
}
|
|
}
|
|
|
|
Future<(String lat, String lng)?> _getValidCoordinates({
|
|
int retryCount = 0,
|
|
}) async {
|
|
const maxRetries = 3;
|
|
|
|
try {
|
|
final bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
|
if (!serviceEnabled) {
|
|
debugPrint(
|
|
'[DELIVERIES][COORDS] Location service disabled, trying last known position',
|
|
);
|
|
final lastPos = await Geolocator.getLastKnownPosition();
|
|
if (lastPos != null &&
|
|
lastPos.latitude != 0 &&
|
|
lastPos.longitude != 0) {
|
|
final lat = lastPos.latitude.toString();
|
|
final lng = lastPos.longitude.toString();
|
|
debugPrint(
|
|
'[DELIVERIES][COORDS] ✅ Using last known position: $lat, $lng',
|
|
);
|
|
return (lat, lng);
|
|
}
|
|
// Retry if we haven't exceeded max retries
|
|
if (retryCount < maxRetries) {
|
|
await Future.delayed(const Duration(milliseconds: 500));
|
|
return _getValidCoordinates(retryCount: retryCount + 1);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
LocationPermission permission = await Geolocator.checkPermission();
|
|
if (permission == LocationPermission.denied) {
|
|
permission = await Geolocator.requestPermission();
|
|
}
|
|
|
|
if (permission == LocationPermission.deniedForever ||
|
|
permission == LocationPermission.denied) {
|
|
debugPrint(
|
|
'[DELIVERIES][COORDS] Permission denied, trying last known position',
|
|
);
|
|
final lastPos = await Geolocator.getLastKnownPosition();
|
|
if (lastPos != null &&
|
|
lastPos.latitude != 0 &&
|
|
lastPos.longitude != 0) {
|
|
final lat = lastPos.latitude.toString();
|
|
final lng = lastPos.longitude.toString();
|
|
debugPrint(
|
|
'[DELIVERIES][COORDS] ✅ Using last known position: $lat, $lng',
|
|
);
|
|
return (lat, lng);
|
|
}
|
|
// Retry if we haven't exceeded max retries
|
|
if (retryCount < maxRetries) {
|
|
await Future.delayed(const Duration(milliseconds: 500));
|
|
return _getValidCoordinates(retryCount: retryCount + 1);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
Position? position;
|
|
try {
|
|
// Try to get current position with higher accuracy
|
|
position = await Geolocator.getCurrentPosition(
|
|
locationSettings: const LocationSettings(
|
|
accuracy:
|
|
LocationAccuracy.high, // Changed to high for better accuracy
|
|
timeLimit: Duration(seconds: 8), // Increased timeout
|
|
),
|
|
).timeout(const Duration(seconds: 8));
|
|
} catch (e) {
|
|
debugPrint(
|
|
'[DELIVERIES][COORDS] Timeout getting current position: $e, trying last known',
|
|
);
|
|
position = await Geolocator.getLastKnownPosition();
|
|
}
|
|
|
|
if (position != null &&
|
|
position.latitude != 0 &&
|
|
position.longitude != 0) {
|
|
final lat = position.latitude.toString();
|
|
final lng = position.longitude.toString();
|
|
|
|
// Validate coordinates are within valid GPS ranges
|
|
final latDouble = double.tryParse(lat) ?? 0.0;
|
|
final lngDouble = double.tryParse(lng) ?? 0.0;
|
|
if (latDouble.abs() <= 90 && lngDouble.abs() <= 180) {
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString('last_lat', lat);
|
|
await prefs.setString('last_lng', lng);
|
|
} catch (_) {}
|
|
debugPrint(
|
|
'[DELIVERIES][COORDS] ✅ Got valid coordinates: $lat, $lng',
|
|
);
|
|
return (lat, lng);
|
|
} else {
|
|
debugPrint(
|
|
'[DELIVERIES][COORDS] ⚠️ Invalid coordinate ranges: $lat, $lng',
|
|
);
|
|
}
|
|
}
|
|
|
|
// Fallback to SharedPreferences cached coordinates
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final lat = (prefs.getString('last_lat') ?? '').trim();
|
|
final lng = (prefs.getString('last_lng') ?? '').trim();
|
|
if (lat.isNotEmpty && lng.isNotEmpty && lat != '0' && lng != '0') {
|
|
final latDouble = double.tryParse(lat) ?? 0.0;
|
|
final lngDouble = double.tryParse(lng) ?? 0.0;
|
|
if (latDouble != 0 &&
|
|
lngDouble != 0 &&
|
|
latDouble.abs() <= 90 &&
|
|
lngDouble.abs() <= 180) {
|
|
debugPrint(
|
|
'[DELIVERIES][COORDS] ✅ Using cached coordinates: $lat, $lng',
|
|
);
|
|
return (lat, lng);
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
|
|
// Retry if we haven't exceeded max retries
|
|
if (retryCount < maxRetries) {
|
|
debugPrint(
|
|
'[DELIVERIES][COORDS] ⚠️ Retry ${retryCount + 1}/$maxRetries to get coordinates',
|
|
);
|
|
await Future.delayed(const Duration(milliseconds: 500));
|
|
return _getValidCoordinates(retryCount: retryCount + 1);
|
|
}
|
|
|
|
debugPrint(
|
|
'[DELIVERIES][COORDS] ❌ Failed to get valid coordinates after $maxRetries retries',
|
|
);
|
|
return null;
|
|
} catch (e) {
|
|
debugPrint('[DELIVERIES][COORDS] ❌ Error getting coordinates: $e');
|
|
// Retry if we haven't exceeded max retries
|
|
if (retryCount < maxRetries) {
|
|
await Future.delayed(const Duration(milliseconds: 500));
|
|
return _getValidCoordinates(retryCount: retryCount + 1);
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<void> _startDeliveryPosting(Map<String, dynamic> order) async {
|
|
final orderId = (order['orderid'] ?? '').toString();
|
|
if (orderId.isEmpty) {
|
|
debugPrint(
|
|
'[DELIVERIES][DELIVERYLOG] ⚠️ Cannot start timer: empty orderId',
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Safety check: If timer already exists, cancel it first (shouldn't happen after cleanup above)
|
|
if (_deliveryTimers.containsKey(orderId)) {
|
|
debugPrint(
|
|
'[DELIVERIES][DELIVERYLOG] ⚠️ Timer already exists for $orderId, canceling old one',
|
|
);
|
|
_deliveryTimers[orderId]?.cancel();
|
|
_deliveryTimers.remove(orderId);
|
|
}
|
|
|
|
debugPrint(
|
|
'[DELIVERIES][DELIVERYLOG] 🚀 Starting 30-second timer for orderId: $orderId',
|
|
);
|
|
|
|
debugPrint(
|
|
'[DELIVERIES][DELIVERYLOG] 🚀 Starting 30-second timer for orderId: $orderId',
|
|
);
|
|
|
|
// REMOVED: Do not reset cumulative distance here.
|
|
// It is already reset in DeliveriesController.updateActiveStatus when the status actually changes.
|
|
// Resetting here causes data loss if the app is restarted while a delivery is in progress.
|
|
|
|
// START FOREGROUND SERVICE PROTECTION
|
|
// Ensure the RiderLogController knows we are active so the Foreground Service stays alive
|
|
// This protects THIS timer from being killed by the OS
|
|
try {
|
|
if (Get.isRegistered<RiderLogController>()) {
|
|
final riderLog = Get.find<RiderLogController>();
|
|
debugPrint(
|
|
'[DELIVERIES][DELIVERYLOG] 🛡️ Activating Foreground Service via RiderLogController...',
|
|
);
|
|
// Force 30s interval to match delivery logging
|
|
riderLog.startAutoCreateLoginLoop(seconds: 30);
|
|
}
|
|
} catch (e) {
|
|
debugPrint('[DELIVERIES][DELIVERYLOG] ⚠️ Could not start Foreground Service: $e');
|
|
}
|
|
|
|
final deliveryId = (order['deliveryid'] ?? 0).toString();
|
|
|
|
// Get starttime from SharedPreferences (saved when order became active via updateActiveStatus)
|
|
// If not found, use activetime from order data, or current time as fallback
|
|
String startTime = '';
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
|
|
// Method 1: Get from SharedPreferences (saved when order became active)
|
|
startTime = prefs.getString('delivery_starttime_$deliveryId') ?? '';
|
|
if (startTime.isNotEmpty) {
|
|
debugPrint(
|
|
'[DELIVERIES][DELIVERYLOG] ✅ Loaded starttime from SharedPreferences: $startTime',
|
|
);
|
|
}
|
|
|
|
// Method 2: Fallback - try to get from order data (starttime field)
|
|
if (startTime.isEmpty) {
|
|
startTime = (order['starttime'] ?? order['startTime'] ?? '').toString();
|
|
if (startTime.isNotEmpty) {
|
|
debugPrint(
|
|
'[DELIVERIES][DELIVERYLOG] ✅ Loaded starttime from order data: $startTime',
|
|
);
|
|
}
|
|
}
|
|
|
|
// Method 3: Fallback - try activetime from order data
|
|
if (startTime.isEmpty) {
|
|
final activetime = (order['activetime'] ?? order['activTime'] ?? '')
|
|
.toString();
|
|
if (activetime.isNotEmpty) {
|
|
startTime = activetime;
|
|
debugPrint(
|
|
'[DELIVERIES][DELIVERYLOG] ✅ Loaded starttime from activetime: $startTime',
|
|
);
|
|
}
|
|
}
|
|
|
|
// Method 4: Last fallback - current time (shouldn't happen if updateActiveStatus was called)
|
|
if (startTime.isEmpty) {
|
|
final now = DateTime.now();
|
|
startTime =
|
|
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}';
|
|
debugPrint(
|
|
'[DELIVERIES][DELIVERYLOG] ⚠️ Using current time as starttime fallback: $startTime',
|
|
);
|
|
}
|
|
} catch (e) {
|
|
debugPrint('[DELIVERIES][DELIVERYLOG] ❌ Error getting starttime: $e');
|
|
// Set a fallback starttime even on error
|
|
final now = DateTime.now();
|
|
startTime =
|
|
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}';
|
|
}
|
|
|
|
// CRITICAL: Ensure starttime is never empty
|
|
if (startTime.isEmpty) {
|
|
final now = DateTime.now();
|
|
startTime =
|
|
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}';
|
|
debugPrint(
|
|
'[DELIVERIES][DELIVERYLOG] ⚠️ Final fallback: starttime was empty, using: $startTime',
|
|
);
|
|
}
|
|
|
|
debugPrint(
|
|
'[DELIVERIES][DELIVERYLOG] 📝 Final starttime for orderId $orderId: $startTime',
|
|
);
|
|
|
|
// Create base payload with starttime
|
|
final base = <String, dynamic>{
|
|
'logid': 0,
|
|
'tenantid': order['tenantid'] ?? 0,
|
|
'partnerid': order['partnerid'] ?? 0,
|
|
'locationid': order['locationid'] ?? 0,
|
|
'orderheaderid': order['orderheaderid'] ?? 0,
|
|
'deliveryid': order['deliveryid'] ?? 0,
|
|
'userid': order['userid'] ?? 0,
|
|
'orderid': orderId,
|
|
'orderstatus': 'active',
|
|
'starttime': startTime, // Include starttime in base payload
|
|
};
|
|
|
|
_deliveryBasePayload[orderId] = base;
|
|
|
|
// Save to SharedPreferences for persistence
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString(
|
|
'deliverylog_${orderId}_tenantid',
|
|
(base['tenantid'] ?? 0).toString(),
|
|
);
|
|
await prefs.setString(
|
|
'deliverylog_${orderId}_partnerid',
|
|
(base['partnerid'] ?? 0).toString(),
|
|
);
|
|
await prefs.setString(
|
|
'deliverylog_${orderId}_locationid',
|
|
(base['locationid'] ?? 0).toString(),
|
|
);
|
|
await prefs.setString(
|
|
'deliverylog_${orderId}_orderheaderid',
|
|
(base['orderheaderid'] ?? 0).toString(),
|
|
);
|
|
await prefs.setString(
|
|
'deliverylog_${orderId}_deliveryid',
|
|
(base['deliveryid'] ?? 0).toString(),
|
|
);
|
|
await prefs.setString(
|
|
'deliverylog_${orderId}_userid',
|
|
(base['userid'] ?? 0).toString(),
|
|
);
|
|
await prefs.setString('deliverylog_${orderId}_orderid', orderId);
|
|
await prefs.setString('deliverylog_${orderId}_orderstatus', 'active');
|
|
await prefs.setString(
|
|
'deliverylog_${orderId}_starttime',
|
|
startTime,
|
|
); // Save starttime
|
|
} catch (e) {
|
|
debugPrint('[DELIVERIES][DELIVERYLOG] Error saving payload: $e');
|
|
}
|
|
|
|
// Post once immediately (don't await - let it run in background)
|
|
_postDeliveryLog(orderId, order);
|
|
debugPrint(
|
|
'[DELIVERIES][DELIVERYLOG] 📤 Posted initial log for orderId: $orderId',
|
|
);
|
|
|
|
// Then every 30 seconds - CRITICAL: This ensures logs are posted every 30 seconds
|
|
final timer = Timer.periodic(const Duration(seconds: 30), (t) {
|
|
debugPrint(
|
|
'[DELIVERIES][DELIVERYLOG] ⏰ Timer tick for orderId: $orderId (30 seconds elapsed)',
|
|
);
|
|
_postDeliveryLog(orderId, order);
|
|
});
|
|
|
|
_deliveryTimers[orderId] = timer;
|
|
debugPrint(
|
|
'[DELIVERIES][DELIVERYLOG] ✅ Timer registered for orderId: $orderId (will post every 30 seconds)',
|
|
);
|
|
}
|
|
|
|
// Reset cumulative distance when order becomes active
|
|
Future<void> _resetCumulativeDistance(String deliveryId) async {
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.remove('delivery_tracking_${deliveryId}_lastLat');
|
|
await prefs.remove('delivery_tracking_${deliveryId}_lastLng');
|
|
await prefs.remove('delivery_tracking_${deliveryId}_cumulativeKm');
|
|
debugPrint(
|
|
'[DELIVERIES] 🧹 Reset cumulative distance tracking for deliveryId: $deliveryId',
|
|
);
|
|
} catch (e) {
|
|
debugPrint('[DELIVERIES] Error resetting cumulative distance: $e');
|
|
}
|
|
}
|
|
|
|
void _postDeliveryLog(String orderId, Map<String, dynamic> order) {
|
|
if (!mounted) {
|
|
debugPrint('[DELIVERIES][DELIVERYLOG][POST] Widget disposed, skipping');
|
|
return;
|
|
}
|
|
|
|
debugPrint(
|
|
'[DELIVERIES][DELIVERYLOG][POST] ⏰ Posting log for orderId: $orderId at ${DateTime.now()}',
|
|
);
|
|
|
|
// Use Future.microtask to ensure the async operation runs independently
|
|
Future.microtask(() => _performPost(orderId));
|
|
}
|
|
|
|
Future<void> _performPost(String orderId) async {
|
|
try {
|
|
debugPrint(
|
|
'[DELIVERIES][DELIVERYLOG][POST] 🔄 Starting _performPost for orderId: $orderId',
|
|
);
|
|
|
|
Map<String, dynamic>? base = _deliveryBasePayload[orderId];
|
|
|
|
if (base == null) {
|
|
debugPrint(
|
|
'[DELIVERIES][DELIVERYLOG][POST] Base is null, loading from SharedPreferences',
|
|
);
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
if (!mounted) {
|
|
debugPrint(
|
|
'[DELIVERIES][DELIVERYLOG][POST] Widget unmounted after prefs load',
|
|
);
|
|
return;
|
|
}
|
|
|
|
base = {
|
|
'logid': 0,
|
|
'tenantid':
|
|
int.tryParse(
|
|
prefs.getString('deliverylog_${orderId}_tenantid') ?? '0',
|
|
) ??
|
|
0,
|
|
'partnerid':
|
|
int.tryParse(
|
|
prefs.getString('deliverylog_${orderId}_partnerid') ?? '0',
|
|
) ??
|
|
0,
|
|
'locationid':
|
|
int.tryParse(
|
|
prefs.getString('deliverylog_${orderId}_locationid') ?? '0',
|
|
) ??
|
|
0,
|
|
'orderheaderid':
|
|
int.tryParse(
|
|
prefs.getString('deliverylog_${orderId}_orderheaderid') ??
|
|
'0',
|
|
) ??
|
|
0,
|
|
'deliveryid':
|
|
int.tryParse(
|
|
prefs.getString('deliverylog_${orderId}_deliveryid') ?? '0',
|
|
) ??
|
|
0,
|
|
'userid':
|
|
int.tryParse(
|
|
prefs.getString('deliverylog_${orderId}_userid') ?? '0',
|
|
) ??
|
|
0,
|
|
'orderid':
|
|
prefs.getString('deliverylog_${orderId}_orderid') ?? orderId,
|
|
'orderstatus':
|
|
prefs.getString('deliverylog_${orderId}_orderstatus') ??
|
|
'active',
|
|
'starttime':
|
|
prefs.getString('deliverylog_${orderId}_starttime') ??
|
|
'', // Load starttime
|
|
};
|
|
debugPrint(
|
|
'[DELIVERIES][DELIVERYLOG][POST] Base loaded from prefs: $base',
|
|
);
|
|
} catch (e) {
|
|
debugPrint('[DELIVERIES][DELIVERYLOG][POST] Error loading base: $e');
|
|
return;
|
|
}
|
|
}
|
|
|
|
// At this point, base is guaranteed to be non-null (either from cache or created above)
|
|
final basePayload = base; // Flow analysis ensures base is non-null here
|
|
|
|
debugPrint('[DELIVERIES][DELIVERYLOG][POST] Getting coordinates...');
|
|
|
|
// CRITICAL: Get coordinates with retry logic - NEVER post with null or '0' coordinates
|
|
final coords = await _getValidCoordinates().timeout(
|
|
const Duration(seconds: 10), // Increased timeout to allow retries
|
|
onTimeout: () {
|
|
debugPrint(
|
|
'[DELIVERIES][DELIVERYLOG][POST] ❌ Coordinate timeout after retries',
|
|
);
|
|
return null;
|
|
},
|
|
);
|
|
|
|
if (!mounted) {
|
|
debugPrint(
|
|
'[DELIVERIES][DELIVERYLOG][POST] Widget unmounted after coords',
|
|
);
|
|
return;
|
|
}
|
|
|
|
// CRITICAL: Validate coordinates - NEVER post with null, '0', or invalid coordinates
|
|
if (coords == null ||
|
|
coords.$1.isEmpty ||
|
|
coords.$2.isEmpty ||
|
|
coords.$1 == '0' ||
|
|
coords.$2 == '0') {
|
|
debugPrint(
|
|
'[DELIVERIES][DELIVERYLOG][POST] ❌ SKIPPING POST: Invalid coordinates (lat=${coords?.$1 ?? 'null'}, lng=${coords?.$2 ?? 'null'})',
|
|
);
|
|
debugPrint(
|
|
'[DELIVERIES][DELIVERYLOG][POST] ⚠️ Will retry on next timer tick (30 seconds)',
|
|
);
|
|
return; // Skip this post - don't send invalid coordinates
|
|
}
|
|
|
|
// Validate coordinate ranges
|
|
final latDouble = double.tryParse(coords.$1) ?? 0.0;
|
|
final lngDouble = double.tryParse(coords.$2) ?? 0.0;
|
|
if (latDouble == 0 ||
|
|
lngDouble == 0 ||
|
|
latDouble.abs() > 90 ||
|
|
lngDouble.abs() > 180) {
|
|
debugPrint(
|
|
'[DELIVERIES][DELIVERYLOG][POST] ❌ SKIPPING POST: Invalid coordinate ranges (lat=$latDouble, lng=$lngDouble)',
|
|
);
|
|
debugPrint(
|
|
'[DELIVERIES][DELIVERYLOG][POST] ⚠️ Will retry on next timer tick (30 seconds)',
|
|
);
|
|
return; // Skip this post - don't send invalid coordinates
|
|
}
|
|
|
|
debugPrint(
|
|
'[DELIVERIES][DELIVERYLOG][POST] ✅ Valid coordinates: lat=${coords.$1}, lng=${coords.$2}',
|
|
);
|
|
|
|
// Cumulative KM is tracked exclusively by LiveTrackingService (high-frequency, every 3s).
|
|
// Do not accumulate here to avoid race conditions with concurrent writers.
|
|
|
|
final now = DateTime.now();
|
|
final logdate =
|
|
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}';
|
|
|
|
// CRITICAL: Ensure starttime is always included in payload
|
|
final starttimeValue = basePayload['starttime']?.toString() ?? '';
|
|
if (starttimeValue.isEmpty) {
|
|
debugPrint(
|
|
'[DELIVERIES][DELIVERYLOG][POST] ⚠️ WARNING: starttime is empty in basePayload, using fallback',
|
|
);
|
|
}
|
|
|
|
// CRITICAL: Use validated coordinates - guaranteed to be non-null and valid at this point
|
|
final payload = {
|
|
...basePayload,
|
|
'logdate': logdate,
|
|
'latitude': coords.$1, // Guaranteed non-null and valid
|
|
'longitude': coords.$2, // Guaranteed non-null and valid
|
|
'starttime': starttimeValue.isNotEmpty
|
|
? starttimeValue
|
|
: '', // CRITICAL: Always include starttime
|
|
};
|
|
|
|
// Validate payload has all required fields
|
|
final requiredFields = [
|
|
'tenantid',
|
|
'partnerid',
|
|
'locationid',
|
|
'orderheaderid',
|
|
'deliveryid',
|
|
'userid',
|
|
'orderid',
|
|
'orderstatus',
|
|
'starttime',
|
|
];
|
|
final missingFields = requiredFields
|
|
.where((field) => payload[field] == null || payload[field] == '')
|
|
.toList();
|
|
if (missingFields.isNotEmpty) {
|
|
debugPrint(
|
|
'[DELIVERIES][DELIVERYLOG][POST] ⚠️ WARNING: Missing fields in payload: $missingFields',
|
|
);
|
|
}
|
|
|
|
final url = ApiConstants.mainRoute == 'live'
|
|
? ApiConstants.createDeliveryLogLive
|
|
: ApiConstants.createDeliveryLogDev;
|
|
|
|
debugPrint('[DELIVERIES][DELIVERYLOG][POST] 📤 Sending to API: $url');
|
|
debugPrint('[DELIVERIES][DELIVERYLOG][POST] 📦 Payload: $payload');
|
|
debugPrint(
|
|
'[DELIVERIES][DELIVERYLOG][POST] ✅ starttime in payload: "${payload['starttime']}"',
|
|
);
|
|
|
|
await _deliveryLogProvider
|
|
.createDeliveryLog(url, payload)
|
|
.timeout(
|
|
const Duration(seconds: 8),
|
|
onTimeout: () {
|
|
debugPrint(
|
|
'[DELIVERIES][DELIVERYLOG][POST] ⚠️ API timeout for orderId: $orderId',
|
|
);
|
|
throw TimeoutException('API timeout', const Duration(seconds: 8));
|
|
},
|
|
);
|
|
|
|
debugPrint(
|
|
'[DELIVERIES][DELIVERYLOG][POST] ✅ SUCCESS for orderId: $orderId at ${DateTime.now()}',
|
|
);
|
|
} catch (e, stackTrace) {
|
|
debugPrint(
|
|
'[DELIVERIES][DELIVERYLOG][POST] ❌ ERROR for orderId: $orderId - $e',
|
|
);
|
|
debugPrint('[DELIVERIES][DELIVERYLOG][POST] Stack trace: $stackTrace');
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
super.build(context);
|
|
|
|
return PopScope(
|
|
canPop: false,
|
|
onPopInvokedWithResult: (didPop, result) async {
|
|
if (didPop) return;
|
|
|
|
if (_picked.isNotEmpty) {
|
|
final confirm = await showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: const Text('Pending deliveries'),
|
|
content: const Text(
|
|
'Are you sure you want to close? There are deliveries pending.',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, false),
|
|
child: const Text('No'),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, true),
|
|
child: const Text('Yes'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (confirm == true && context.mounted) {
|
|
Navigator.pop(context);
|
|
}
|
|
} else {
|
|
Navigator.pop(context);
|
|
}
|
|
},
|
|
child: SafeArea(
|
|
child: Scaffold(
|
|
backgroundColor: Colors.grey.shade200,
|
|
appBar: AppBar(
|
|
backgroundColor: Colors.grey.shade200,
|
|
elevation: 0,
|
|
centerTitle: false,
|
|
toolbarHeight: 70, // Same as your summary page
|
|
titleSpacing: 0,
|
|
title: Padding(
|
|
padding: const EdgeInsets.only(left: 16),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Transform.translate(
|
|
offset: Offset(0, 6),
|
|
child: Text(
|
|
"DELIVERIES",
|
|
style: TextStyle(
|
|
fontSize: FontConstants.xxxLarge(context).sp,
|
|
fontWeight: FontWeight.bold,
|
|
fontFamily: FontConstants.fontFamily,
|
|
color: Colors.black87,
|
|
),
|
|
),
|
|
),
|
|
|
|
/// Map view row on right side
|
|
Transform.translate(
|
|
offset: Offset(-20, 2),
|
|
child: MapViewRow(
|
|
deliveries: _picked,
|
|
preservedStepNumbers: _preservedStepNumbers,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
/// Divider under AppBar (same as summary page)
|
|
bottom: PreferredSize(
|
|
preferredSize: Size.fromHeight(1),
|
|
child: Divider(color: Colors.grey, height: 1),
|
|
),
|
|
),
|
|
body: Stack(
|
|
children: [
|
|
_picked.isEmpty
|
|
? Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const SizedBox(height: 80),
|
|
Transform.translate(
|
|
offset: Offset(0, -1.5),
|
|
child: Image.asset(
|
|
"assets/images/Nearle Bike.png",
|
|
errorBuilder: (c, e, s) => const Icon(
|
|
Icons.delivery_dining,
|
|
size: 120,
|
|
color: Colors.grey,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Transform.translate(
|
|
offset: Offset(0, -1.5),
|
|
child: Text(
|
|
"No Deliveries at the moment",
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: FontConstants.xxxLarge(context),
|
|
fontFamily: FontConstants.fontFamily,
|
|
color: Colors.grey.shade500,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
: RefreshIndicator(
|
|
onRefresh: _fetchPicked,
|
|
child: ListView.builder(
|
|
padding: EdgeInsets.only(
|
|
left: 2,
|
|
right: 2,
|
|
top: 8,
|
|
bottom: _activeDeliveries.isNotEmpty
|
|
? 80
|
|
: 8, // Add bottom padding for banner
|
|
),
|
|
itemCount: _picked.length,
|
|
itemBuilder: (context, index) {
|
|
final item = _picked[index];
|
|
final int displayStep = _getDisplayStepNumber(
|
|
_picked,
|
|
index,
|
|
);
|
|
final String distanceStr = _distanceKmDisplay(item);
|
|
final String orderId = (item['orderid'] ?? '')
|
|
.toString();
|
|
final String status =
|
|
(item['orderstatus']?.toString().toLowerCase() ??
|
|
'')
|
|
.trim();
|
|
|
|
// Logic for enabling cards:
|
|
// 1. Skipped orders are ALWAYS enabled (so they can be un-skipped or completed)
|
|
// 2. The first non-skipped, non-cancelled, non-delivered order is enabled
|
|
// 3. All other orders are disabled
|
|
|
|
final bool isSkipped = status == 'skipped';
|
|
final bool isCancelledOrDelivered =
|
|
status == 'cancelled' || status == 'delivered';
|
|
final bool hasActive = _hasActiveDelivery();
|
|
|
|
// Don't show cancelled or delivered orders
|
|
if (isCancelledOrDelivered) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
// Skipped orders: only enable when there is NO other active delivery
|
|
if (isSkipped && !isCancelledOrDelivered) {
|
|
return DeliveryCard(
|
|
key: ValueKey('delivery_$orderId'),
|
|
item: item,
|
|
displayStep: displayStep,
|
|
distanceStr: distanceStr,
|
|
enabled: !hasActive,
|
|
isSkipped: true,
|
|
);
|
|
}
|
|
|
|
// Find the first non-skipped order to enable
|
|
int firstEnabledIndex = -1;
|
|
for (int i = 0; i < _picked.length; i++) {
|
|
final orderStatus =
|
|
(_picked[i]['orderstatus']
|
|
?.toString()
|
|
.toLowerCase() ??
|
|
'')
|
|
.trim();
|
|
if (orderStatus != 'skipped' &&
|
|
orderStatus != 'cancelled' &&
|
|
orderStatus != 'delivered') {
|
|
firstEnabledIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Enable if this is the first available non-skipped order
|
|
// AND there is no active delivery running.
|
|
final bool enabled =
|
|
!hasActive &&
|
|
firstEnabledIndex >= 0 &&
|
|
index == firstEnabledIndex;
|
|
|
|
return DeliveryCard(
|
|
key: ValueKey('delivery_$orderId'),
|
|
item: item,
|
|
displayStep: displayStep,
|
|
distanceStr: distanceStr,
|
|
enabled: enabled,
|
|
isSkipped: false,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
// Bottom banner for active deliveries (Swiggy/Zomato style)
|
|
if (_activeDeliveries.isNotEmpty)
|
|
Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
child: _ActiveDeliveryBanner(
|
|
activeDeliveries: _activeDeliveries,
|
|
onTap: (delivery) async {
|
|
await startDelivery(delivery);
|
|
// Refresh after returning from map screen
|
|
if (mounted) {
|
|
await Future.delayed(const Duration(milliseconds: 500));
|
|
_fetchPicked();
|
|
}
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// ACTIVE DELIVERY BANNER (Swiggy/Zomato style)
|
|
// -------------------------------------------------------------------------
|
|
class _ActiveDeliveryBanner extends StatelessWidget {
|
|
final List<Map<String, dynamic>> activeDeliveries;
|
|
final Function(Map<String, dynamic>) onTap;
|
|
|
|
const _ActiveDeliveryBanner({
|
|
required this.activeDeliveries,
|
|
required this.onTap,
|
|
});
|
|
|
|
String _getDeliveryAddress(Map<String, dynamic> delivery) {
|
|
final address =
|
|
delivery['deliveryaddress'] ??
|
|
delivery['DeliveryAddress'] ??
|
|
delivery['address'] ??
|
|
'';
|
|
if (address.toString().length > 40) {
|
|
return '${address.toString().substring(0, 40)}...';
|
|
}
|
|
return address.toString();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (activeDeliveries.isEmpty) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
// Show first active delivery (or show count if multiple)
|
|
final delivery = activeDeliveries.first;
|
|
final count = activeDeliveries.length;
|
|
|
|
return Container(
|
|
margin: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.green,
|
|
borderRadius: BorderRadius.circular(12),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.2),
|
|
blurRadius: 8,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: InkWell(
|
|
onTap: () => onTap(delivery),
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
|
child: Row(
|
|
children: [
|
|
// Active indicator icon
|
|
Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.2),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: const Icon(
|
|
Icons.two_wheeler,
|
|
color: Colors.white,
|
|
size: 24,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
// Delivery info
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Text(
|
|
count > 1
|
|
? '$count Active Deliveries'
|
|
: 'Active Delivery',
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 19,
|
|
fontWeight: FontWeight.bold,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
if (count > 1) ...[
|
|
const SizedBox(width: 8),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8,
|
|
vertical: 2,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.3),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Text(
|
|
'$count',
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontSize: FontConstants.small(context).sp,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
_getDeliveryAddress(delivery),
|
|
style: TextStyle(
|
|
color: Colors.white.withValues(alpha: 0.9),
|
|
fontSize: 17,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// Arrow icon
|
|
const Icon(
|
|
Icons.arrow_forward_ios,
|
|
color: Colors.white,
|
|
size: 20,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|