Design system - MilerSurface ladder (canvas → working → raised → floating) with MilerPanel as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1. - Visible vocabulary applied across Home, Deliveries, Activity, Account and the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x), canvas wells for anything that opens, small filled tags for shelf labels, demoted placeholders. Recorded in DESIGN_SYSTEM.md §6. - One icon family: 222 Material glyphs migrated to Lucide; none left outside lib/xpress. - Colour semantics corrected: amber only for what is genuinely owed, brand red reserved for the live stop, disabled primaries go neutral rather than pale. Data and lifecycle - lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart makes admin sequence the single ordering authority; service_day.dart, and stop_area.dart rewritten against live Coimbatore addresses (digit-token stripping, city stoplist, street suffixes, stammer collapse). - countLabel states the load once, in bags. Testing - 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity, sheets and verify, with test/failures/ now gitignored (diff debris). - New pins: home_gutter_test, stop_area_test, plus updated structural bounds. Note: this commit also carries pre-existing working-tree deletions that were present before this work (API_SPEC.md, README.md, demo test fixtures). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1200 lines
46 KiB
Dart
1200 lines
46 KiB
Dart
part of '../deliveries/deliveries.dart';
|
|
|
|
// -------------------------------------------------------------------------
|
|
// CART PAGE (Active Deliveries)
|
|
// -------------------------------------------------------------------------
|
|
class Cartpage extends StatefulWidget {
|
|
const Cartpage({super.key});
|
|
|
|
@override
|
|
State<Cartpage> createState() => _CartpageState();
|
|
}
|
|
|
|
class _CartpageState extends State<Cartpage>
|
|
with AutomaticKeepAliveClientMixin {
|
|
final DeliveryProvider _provider = DeliveryProvider();
|
|
final CreateDeliveryLogProvider _deliveryLogProvider =
|
|
CreateDeliveryLogProvider();
|
|
|
|
List<Map<String, dynamic>> _activeDeliveries = <Map<String, dynamic>>[];
|
|
StreamSubscription<void>? _pollerSubscription;
|
|
bool _fetching = false;
|
|
final Map<String, Timer> _deliveryTimers = <String, Timer>{};
|
|
final Map<String, Map<String, dynamic>> _deliveryBasePayload =
|
|
<String, Map<String, dynamic>>{};
|
|
|
|
@override
|
|
bool get wantKeepAlive => true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_fetchActive();
|
|
_startPolling();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_pollerSubscription?.cancel();
|
|
_stopAllTimers();
|
|
super.dispose();
|
|
}
|
|
|
|
void _startPolling() {
|
|
_pollerSubscription?.cancel();
|
|
_pollerSubscription = Stream.periodic(const Duration(seconds: 10), (_) {})
|
|
.asyncMap((_) async {
|
|
if (!_fetching && mounted) {
|
|
await _fetchActive();
|
|
}
|
|
})
|
|
.listen(
|
|
(_) {},
|
|
onError: (error) {
|
|
debugPrint('[CART][STREAM ERROR] $error');
|
|
},
|
|
cancelOnError: false,
|
|
);
|
|
}
|
|
|
|
Future<void> _fetchActive() async {
|
|
if (_fetching) return;
|
|
|
|
_fetching = true;
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final userId = prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0;
|
|
|
|
if (userId == 0) {
|
|
debugPrint('[CART] No user ID found');
|
|
_fetching = false;
|
|
return;
|
|
}
|
|
|
|
// Get current date in YYYY-MM-DD format
|
|
final now = DateTime.now();
|
|
final today =
|
|
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
|
|
|
|
// One Doormile route now — see [DeliveryApi].
|
|
final baseUrl = DeliveryApi.currentDeliveries;
|
|
|
|
final uri = Uri.parse(baseUrl).replace(
|
|
queryParameters: {
|
|
'userid': userId.toString(),
|
|
'fromdate': today,
|
|
'todate': today,
|
|
't': DateTime.now().millisecondsSinceEpoch.toString(),
|
|
},
|
|
);
|
|
|
|
debugPrint('[CART] Fetching from: $uri');
|
|
|
|
// Fetch deliveries from API directly using http
|
|
// Hardcoded endpoint for cart page: v2/deliveries/getdeliveries
|
|
final httpClient = http.Client();
|
|
List<dynamic> items = [];
|
|
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;
|
|
|
|
items = data is List
|
|
? data
|
|
: (data is Map && data['items'] is List
|
|
? data['items'] as List
|
|
: []);
|
|
} else {
|
|
debugPrint('[CART] API error: ${response.statusCode}');
|
|
}
|
|
} catch (e) {
|
|
debugPrint('[CART] Error fetching from API: $e');
|
|
} finally {
|
|
httpClient.close();
|
|
}
|
|
|
|
debugPrint('[CART] Raw API items count: ${items.length}');
|
|
|
|
// Debug: Print all order statuses to see what we're getting
|
|
for (final item in items) {
|
|
if (item is Map<String, dynamic>) {
|
|
final orderId = (item['orderid'] ?? '').toString();
|
|
final status = (item['orderstatus'] ?? '').toString();
|
|
debugPrint(
|
|
'[CART] Order $orderId has status: "$status" (raw: ${item['orderstatus']})',
|
|
);
|
|
}
|
|
}
|
|
|
|
// Filter for ACTIVE status only
|
|
final activeOrders = items.whereType<Map<String, dynamic>>().where((
|
|
order,
|
|
) {
|
|
final status = (order['orderstatus']?.toString().toLowerCase() ?? '')
|
|
.trim();
|
|
final isActive = status == 'active';
|
|
if (isActive) {
|
|
debugPrint('[CART] ✅ Found active order: ${order['orderid']}');
|
|
} else {
|
|
debugPrint(
|
|
'[CART] ❌ Order ${order['orderid']} has status: "$status" (not active)',
|
|
);
|
|
}
|
|
return isActive;
|
|
}).toList();
|
|
|
|
debugPrint(
|
|
'[CART] Found ${activeOrders.length} active deliveries out of ${items.length} total',
|
|
);
|
|
|
|
// Sort by order ID or step number if available
|
|
activeOrders.sort((a, b) {
|
|
final stepA = (a['step'] ?? a['Step'] ?? 0).toString();
|
|
final stepB = (b['step'] ?? b['Step'] ?? 0).toString();
|
|
final stepAInt = int.tryParse(stepA) ?? 0;
|
|
final stepBInt = int.tryParse(stepB) ?? 0;
|
|
if (stepAInt != stepBInt) return stepAInt.compareTo(stepBInt);
|
|
|
|
final orderIdA = (a['orderid'] ?? '').toString();
|
|
final orderIdB = (b['orderid'] ?? '').toString();
|
|
return orderIdA.compareTo(orderIdB);
|
|
});
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_activeDeliveries = activeOrders;
|
|
});
|
|
}
|
|
|
|
// Get all active order IDs
|
|
final activeOrderIds = activeOrders
|
|
.map((o) => (o['orderid'] ?? '').toString())
|
|
.where((id) => id.isNotEmpty)
|
|
.toSet();
|
|
|
|
// ✅ CRITICAL: Start/restart timers for ALL active deliveries
|
|
// This ensures every active delivery posts logs every 30 seconds
|
|
for (final order in activeOrders) {
|
|
final orderId = (order['orderid'] ?? '').toString();
|
|
if (orderId.isEmpty) {
|
|
debugPrint('[CART] ⚠️ Skipping order with empty orderId');
|
|
continue;
|
|
}
|
|
|
|
// Always restart timer to ensure it's running (handles edge cases)
|
|
if (_deliveryTimers.containsKey(orderId)) {
|
|
debugPrint(
|
|
'[CART] 🔄 Restarting timer for active delivery: $orderId',
|
|
);
|
|
_deliveryTimers[orderId]?.cancel();
|
|
_deliveryTimers.remove(orderId);
|
|
// Also clear base payload to force reload
|
|
_deliveryBasePayload.remove(orderId);
|
|
}
|
|
|
|
debugPrint('[CART] ▶️ Starting timer for active delivery: $orderId');
|
|
await _startDeliveryPosting(order);
|
|
debugPrint('[CART] ✅ Timer started successfully for: $orderId');
|
|
}
|
|
|
|
// ✅ Stop timers for deliveries that are no longer active
|
|
final timersToStop = _deliveryTimers.keys
|
|
.where((id) => !activeOrderIds.contains(id))
|
|
.toList();
|
|
|
|
for (final id in timersToStop) {
|
|
debugPrint('[CART] Stopping timer for orderId: $id (no longer active)');
|
|
_stopDeliveryPosting(id);
|
|
}
|
|
|
|
debugPrint('[CART] Active timers: ${_deliveryTimers.keys.toList()}');
|
|
} catch (e) {
|
|
debugPrint('[CART] Error fetching active deliveries: $e');
|
|
} finally {
|
|
_fetching = false;
|
|
}
|
|
}
|
|
|
|
Future<void> _startDeliveryPosting(Map<String, dynamic> order) async {
|
|
final orderId = (order['orderid'] ?? '').toString();
|
|
if (orderId.isEmpty) {
|
|
debugPrint('[CART][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(
|
|
'[CART][DELIVERYLOG] ⚠️ Timer already exists for $orderId, canceling old one',
|
|
);
|
|
_deliveryTimers[orderId]?.cancel();
|
|
_deliveryTimers.remove(orderId);
|
|
}
|
|
|
|
debugPrint(
|
|
'[CART][DELIVERYLOG] 🚀 Starting 30-second timer for orderId: $orderId',
|
|
);
|
|
|
|
// 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();
|
|
final deliveryId = (order['deliveryid'] ?? 0).toString();
|
|
|
|
// Method 1: Get from SharedPreferences (saved when order became active)
|
|
startTime = prefs.getString('delivery_starttime_$deliveryId') ?? '';
|
|
if (startTime.isNotEmpty) {
|
|
debugPrint(
|
|
'[CART][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(
|
|
'[CART][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(
|
|
'[CART][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(
|
|
'[CART][DELIVERYLOG] ⚠️ Using current time as starttime fallback: $startTime',
|
|
);
|
|
}
|
|
} catch (e) {
|
|
debugPrint('[CART][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(
|
|
'[CART][DELIVERYLOG] ⚠️ Final fallback: starttime was empty, using: $startTime',
|
|
);
|
|
}
|
|
|
|
debugPrint(
|
|
'[CART][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('[CART][DELIVERYLOG] Error saving payload: $e');
|
|
}
|
|
|
|
// Post once immediately (don't await - let it run in background)
|
|
_postDeliveryLog(orderId, order);
|
|
debugPrint(
|
|
'[CART][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(
|
|
'[CART][DELIVERYLOG] ⏰ Timer tick for orderId: $orderId (30 seconds elapsed)',
|
|
);
|
|
_postDeliveryLog(orderId, order);
|
|
});
|
|
|
|
_deliveryTimers[orderId] = timer;
|
|
debugPrint(
|
|
'[CART][DELIVERYLOG] ✅ Timer registered for orderId: $orderId (will post every 30 seconds)',
|
|
);
|
|
}
|
|
|
|
void _postDeliveryLog(String orderId, Map<String, dynamic> order) {
|
|
if (!mounted) {
|
|
debugPrint('[CART][DELIVERYLOG][POST] Widget disposed, skipping');
|
|
return;
|
|
}
|
|
|
|
debugPrint(
|
|
'[CART][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(
|
|
'[CART][DELIVERYLOG][POST] 🔄 Starting _performPost for orderId: $orderId',
|
|
);
|
|
|
|
Map<String, dynamic>? base = _deliveryBasePayload[orderId];
|
|
|
|
if (base == null) {
|
|
debugPrint(
|
|
'[CART][DELIVERYLOG][POST] Base is null, loading from SharedPreferences',
|
|
);
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
if (!mounted) {
|
|
debugPrint(
|
|
'[CART][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('[CART][DELIVERYLOG][POST] Base loaded from prefs: $base');
|
|
} catch (e) {
|
|
debugPrint('[CART][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('[CART][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(
|
|
'[CART][DELIVERYLOG][POST] ❌ Coordinate timeout after retries',
|
|
);
|
|
return null;
|
|
},
|
|
);
|
|
|
|
if (!mounted) {
|
|
debugPrint('[CART][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(
|
|
'[CART][DELIVERYLOG][POST] ❌ SKIPPING POST: Invalid coordinates (lat=${coords?.$1 ?? 'null'}, lng=${coords?.$2 ?? 'null'})',
|
|
);
|
|
debugPrint(
|
|
'[CART][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(
|
|
'[CART][DELIVERYLOG][POST] ❌ SKIPPING POST: Invalid coordinate ranges (lat=$latDouble, lng=$lngDouble)',
|
|
);
|
|
debugPrint(
|
|
'[CART][DELIVERYLOG][POST] ⚠️ Will retry on next timer tick (30 seconds)',
|
|
);
|
|
return; // Skip this post - don't send invalid coordinates
|
|
}
|
|
|
|
debugPrint(
|
|
'[CART][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(
|
|
'[CART][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(
|
|
'[CART][DELIVERYLOG][POST] ⚠️ WARNING: Missing fields in payload: $missingFields',
|
|
);
|
|
}
|
|
|
|
final url = ApiConstants.mainRoute == 'live'
|
|
? ApiConstants.createDeliveryLogLive
|
|
: ApiConstants.createDeliveryLogDev;
|
|
|
|
debugPrint('[CART][DELIVERYLOG][POST] 📤 Sending to API: $url');
|
|
debugPrint('[CART][DELIVERYLOG][POST] 📦 Payload: $payload');
|
|
debugPrint(
|
|
'[CART][DELIVERYLOG][POST] ✅ starttime in payload: "${payload['starttime']}"',
|
|
);
|
|
|
|
await _deliveryLogProvider
|
|
.createDeliveryLog(url, payload)
|
|
.timeout(
|
|
const Duration(seconds: 8),
|
|
onTimeout: () {
|
|
debugPrint(
|
|
'[CART][DELIVERYLOG][POST] ⚠️ API timeout for orderId: $orderId',
|
|
);
|
|
throw TimeoutException('API timeout', const Duration(seconds: 8));
|
|
},
|
|
);
|
|
|
|
debugPrint(
|
|
'[CART][DELIVERYLOG][POST] ✅ SUCCESS for orderId: $orderId at ${DateTime.now()}',
|
|
);
|
|
} catch (e, stackTrace) {
|
|
debugPrint(
|
|
'[CART][DELIVERYLOG][POST] ❌ ERROR for orderId: $orderId - $e',
|
|
);
|
|
debugPrint('[CART][DELIVERYLOG][POST] Stack trace: $stackTrace');
|
|
}
|
|
}
|
|
|
|
Future<(String lat, String lng)?> _getValidCoordinates({
|
|
int retryCount = 0,
|
|
}) async {
|
|
const maxRetries = 3;
|
|
|
|
try {
|
|
final bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
|
if (!serviceEnabled) {
|
|
debugPrint(
|
|
'[CART][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('[CART][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(
|
|
'[CART][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('[CART][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(
|
|
'[CART][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('[CART][COORDS] ✅ Got valid coordinates: $lat, $lng');
|
|
return (lat, lng);
|
|
} else {
|
|
debugPrint('[CART][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('[CART][COORDS] ✅ Using cached coordinates: $lat, $lng');
|
|
return (lat, lng);
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
|
|
// Retry if we haven't exceeded max retries
|
|
if (retryCount < maxRetries) {
|
|
debugPrint(
|
|
'[CART][COORDS] ⚠️ Retry ${retryCount + 1}/$maxRetries to get coordinates',
|
|
);
|
|
await Future.delayed(const Duration(milliseconds: 500));
|
|
return _getValidCoordinates(retryCount: retryCount + 1);
|
|
}
|
|
|
|
debugPrint(
|
|
'[CART][COORDS] ❌ Failed to get valid coordinates after $maxRetries retries',
|
|
);
|
|
return null;
|
|
} catch (e) {
|
|
debugPrint('[CART][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;
|
|
}
|
|
}
|
|
|
|
void _stopDeliveryPosting(String orderId) {
|
|
_deliveryTimers[orderId]?.cancel();
|
|
_deliveryTimers.remove(orderId);
|
|
_deliveryBasePayload.remove(orderId);
|
|
debugPrint('[CART][DELIVERYLOG] Stopped timer for orderId: $orderId');
|
|
}
|
|
|
|
void _stopAllTimers() {
|
|
for (final timer in _deliveryTimers.values) {
|
|
timer.cancel();
|
|
}
|
|
_deliveryTimers.clear();
|
|
_deliveryBasePayload.clear();
|
|
debugPrint('[CART][DELIVERYLOG] Stopped all timers');
|
|
}
|
|
|
|
// Method to stop delivery posting for a specific order (called when order is completed)
|
|
void stopDeliveryPostingForOrder(String orderId) {
|
|
_stopDeliveryPosting(orderId);
|
|
// Refresh the list to remove completed orders
|
|
if (mounted) {
|
|
_fetchActive();
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
String _distanceKmDisplay(Map<String, dynamic> m) {
|
|
// Try API distance first
|
|
final String apiKmsStr = (m['actualkms'] ?? m['distance'] ?? '0')
|
|
.toString();
|
|
final double apiKms = double.tryParse(apiKmsStr) ?? 0.0;
|
|
if (apiKms > 0) {
|
|
return apiKms < 10 ? apiKmsStr : apiKms.toStringAsFixed(0);
|
|
}
|
|
|
|
// Try rider location to delivery location
|
|
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);
|
|
}
|
|
|
|
// Fallback: pickup to delivery
|
|
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';
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
super.build(context);
|
|
|
|
return Scaffold(
|
|
backgroundColor: Colors.grey.shade200,
|
|
appBar: AppBar(
|
|
backgroundColor: Colors.grey.shade200,
|
|
elevation: 0,
|
|
centerTitle: false,
|
|
toolbarHeight: 70,
|
|
title: Padding(
|
|
padding: const EdgeInsets.only(top: 12),
|
|
child: Text(
|
|
"Active Deliveries",
|
|
style: TextStyle(
|
|
fontSize: 26,
|
|
fontWeight: FontWeight.bold,
|
|
fontFamily: FontConstants.fontFamily,
|
|
color: Colors.black,
|
|
),
|
|
),
|
|
),
|
|
bottom: const PreferredSize(
|
|
preferredSize: Size.fromHeight(1),
|
|
child: Divider(height: 1, color: Colors.grey),
|
|
),
|
|
),
|
|
body: SafeArea(
|
|
child: _activeDeliveries.isEmpty
|
|
? Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const SizedBox(height: 80),
|
|
Transform.translate(
|
|
offset: const Offset(0, -11),
|
|
child: Image.asset(
|
|
"assets/images/Doormile Bike.png",
|
|
errorBuilder: (c, e, s) => const Icon(
|
|
Icons.delivery_dining,
|
|
size: 120,
|
|
color: Colors.grey,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Transform.translate(
|
|
offset: const Offset(0, -11),
|
|
child: Text(
|
|
"No Active Deliveries",
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 24,
|
|
fontFamily: FontConstants.fontFamily,
|
|
color: Colors.grey,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
: RefreshIndicator(
|
|
onRefresh: _fetchActive,
|
|
child: ListView.builder(
|
|
padding: const EdgeInsets.only(bottom: 20),
|
|
itemCount: _activeDeliveries.length,
|
|
itemBuilder: (context, index) {
|
|
final item = _activeDeliveries[index];
|
|
final customerName =
|
|
(item['deliverycustomer'] ??
|
|
item['customer'] ??
|
|
'Customer')
|
|
.toString();
|
|
final address =
|
|
(item['deliveryaddress'] ??
|
|
item['address'] ??
|
|
'Address not available')
|
|
.toString();
|
|
final storeName =
|
|
(item['pickupcustomer'] ?? item['store'] ?? 'Store')
|
|
.toString();
|
|
final orderId = (item['orderid'] ?? '').toString();
|
|
final distance = _distanceKmDisplay(item);
|
|
|
|
return Container(
|
|
margin: const EdgeInsets.symmetric(
|
|
horizontal: 14,
|
|
vertical: 8,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(12),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.1),
|
|
blurRadius: 6,
|
|
offset: const Offset(0, 3),
|
|
),
|
|
],
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// -------------------------------------------
|
|
// TOP CUSTOMER DETAILS
|
|
// -------------------------------------------
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Column(
|
|
children: [
|
|
Container(
|
|
width: 12,
|
|
height: 12,
|
|
decoration: const BoxDecoration(
|
|
color: Colors.orange,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
Container(
|
|
width: 2,
|
|
height: 30,
|
|
color: Colors.grey.shade300,
|
|
),
|
|
Container(
|
|
width: 12,
|
|
height: 12,
|
|
decoration: const BoxDecoration(
|
|
color: Colors.green,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment:
|
|
CrossAxisAlignment.start,
|
|
children: [
|
|
Transform.translate(
|
|
offset: const Offset(0, -3),
|
|
child: Text(
|
|
customerName,
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 18,
|
|
color: Colors.black,
|
|
fontFamily:
|
|
FontConstants.fontFamily,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Transform.translate(
|
|
offset: const Offset(0, 8),
|
|
child: Text(
|
|
address,
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
color: Colors.black87,
|
|
fontFamily:
|
|
FontConstants.fontFamily,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Distance: $distance km',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
color: Colors.blueGrey,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
InkWell(
|
|
onTap: () async {
|
|
// Just launch dialer; PiP is handled only from navigation screen
|
|
final phone =
|
|
(item['deliverycontactno'] ?? '')
|
|
.toString();
|
|
final bool success =
|
|
await launchPhoneDialer(
|
|
phone.isNotEmpty
|
|
? phone
|
|
: '9876543210',
|
|
);
|
|
if (!success && context.mounted) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(
|
|
const SnackBar(
|
|
content: Text(
|
|
'Could not launch dialer',
|
|
),
|
|
),
|
|
);
|
|
}
|
|
},
|
|
child: Image.asset(
|
|
'assets/images/phone-call .png',
|
|
height: 27,
|
|
width: 27,
|
|
errorBuilder: (c, e, s) => const Icon(
|
|
Icons.phone,
|
|
size: 27,
|
|
color: Colors.green,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
const Divider(),
|
|
const SizedBox(height: 8),
|
|
// -------------------------------------------
|
|
// STORE DETAILS
|
|
// -------------------------------------------
|
|
Row(
|
|
children: [
|
|
Image.asset(
|
|
'assets/images/shoppingbag.png',
|
|
height: 32,
|
|
width: 32,
|
|
errorBuilder: (c, e, s) => const Icon(
|
|
Icons.shopping_bag,
|
|
size: 32,
|
|
color: Colors.orange,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment:
|
|
CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment:
|
|
MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
storeName,
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 18,
|
|
color: Colors.black,
|
|
fontFamily:
|
|
FontConstants.fontFamily,
|
|
),
|
|
),
|
|
InkWell(
|
|
onTap: () async {
|
|
_showMyOptionsSheet(
|
|
context,
|
|
item,
|
|
null, // No parent state for cart page
|
|
);
|
|
// Refresh cart page after skip (order will no longer be active)
|
|
if (mounted) {
|
|
await Future.delayed(
|
|
const Duration(seconds: 1),
|
|
);
|
|
_fetchActive();
|
|
}
|
|
},
|
|
child: Transform.translate(
|
|
offset: const Offset(0, -5),
|
|
child: Text(
|
|
'Skip>>',
|
|
style: TextStyle(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.bold,
|
|
fontFamily:
|
|
FontConstants.fontFamily,
|
|
color: ColorConstants
|
|
.primaryColor,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
Text(
|
|
"Order ID: #$orderId",
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
color: Colors.black54,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 15),
|
|
// -------------------------------------------
|
|
// SLIDER BUTTON
|
|
// -------------------------------------------
|
|
SliderButton(
|
|
properties: SliderButtonProperties(
|
|
height: 50,
|
|
buttonSize: 45,
|
|
width: MediaQuery.of(context).size.width - 56,
|
|
backgroundColor: ColorConstants.primaryColor,
|
|
dismissThresholds: 0.90,
|
|
action: () async {
|
|
await Future.delayed(
|
|
const Duration(milliseconds: 400),
|
|
);
|
|
if (!context.mounted) return false;
|
|
|
|
// Navigate to delivery map screen (same as deliveries page)
|
|
await Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => _DeliveryMapScreen(
|
|
delivery: item,
|
|
parentState:
|
|
null, // No parent state for cart page
|
|
),
|
|
),
|
|
);
|
|
|
|
// Refresh cart page when returning from map screen
|
|
// (in case delivery was completed/cancelled)
|
|
if (mounted) {
|
|
await Future.delayed(
|
|
const Duration(milliseconds: 500),
|
|
);
|
|
_fetchActive();
|
|
}
|
|
|
|
return false;
|
|
},
|
|
label: const Text(
|
|
'Slide to start Delivery',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w500,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
icon: ClipOval(
|
|
child: Material(
|
|
color: Colors.white,
|
|
child: SizedBox(
|
|
width: 45,
|
|
height: 45,
|
|
child: Center(
|
|
child: Text(
|
|
'${index + 1}',
|
|
style: const TextStyle(
|
|
fontSize: 24,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.black,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|