939 lines
33 KiB
Dart
939 lines
33 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_tts/flutter_tts.dart';
|
|
import 'package:geolocator/geolocator.dart';
|
|
import 'package:miler/data/api_config.dart';
|
|
import 'package:miler/data/miler_api.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:audioplayers/audioplayers.dart';
|
|
import 'dart:math' as math;
|
|
|
|
import 'package:miler/xpress/providers/deliverylog/deliverylog_provider.dart';
|
|
import 'package:miler/xpress/providers/notifications/notificationservce.dart';
|
|
import 'package:miler/xpress/views/helpers/constants/apiconstants.dart';
|
|
import 'package:miler/xpress/utils/kalman_filter.dart';
|
|
|
|
/// Background service for managing active delivery logs
|
|
class BackgroundDeliveryLog {
|
|
static DoormileKalmanFilter? _kf;
|
|
static DateTime? _lastUpdateTime;
|
|
|
|
static final CreateDeliveryLogProvider _logProvider =
|
|
CreateDeliveryLogProvider();
|
|
static final FlutterTts _tts = FlutterTts();
|
|
static final AudioPlayer _proximityPlayer = AudioPlayer();
|
|
|
|
static const double _idleThresholdMeters = 10; // ~5-10m tolerance
|
|
static const double _proximityThresholdMeters =
|
|
50.0; // 50 meters for arrival alert
|
|
static const String _activeDeliveriesKey = 'active_deliveries';
|
|
static const String _payloadKeyPrefix = 'delivery_payload_';
|
|
static const String _offlineLogKey = 'offline_delivery_logs';
|
|
static const String _proximityAlertKeyPrefix = 'delivery_proximity_alerted_';
|
|
|
|
static bool _isPosting = false;
|
|
static bool _ttsReady = false;
|
|
static bool _audioReady = false;
|
|
|
|
/// Process active deliveries: fetch from API, filter active, and post logs
|
|
static Future<void> processActiveDeliveries() async {
|
|
if (_isPosting) {
|
|
debugPrint('[ACTIVE_DELIVERY_LOG][BG] Already posting, skipping...');
|
|
return;
|
|
}
|
|
_isPosting = true;
|
|
|
|
try {
|
|
// Get userid from SharedPreferences
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final userId = prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0;
|
|
|
|
debugPrint('[ACTIVE_DELIVERY_LOG][BG] Checking for user: $userId');
|
|
|
|
if (userId == 0) {
|
|
debugPrint('[ACTIVE_DELIVERY_LOG][BG] ❌ No user ID found in prefs');
|
|
return;
|
|
}
|
|
|
|
// Get current date dynamically (YYYY-MM-DD format)
|
|
final now = DateTime.now();
|
|
final today = '${now.year}-${_pad(now.month)}-${_pad(now.day)}';
|
|
|
|
// ── The delivery twin of the pickup fetch, with the same two faults ──
|
|
//
|
|
// It built a `deliveries/getdeliveries` URL by hand and fetched it with a
|
|
// bare `_httpClient.get` — **no bearer token** — and put `userid` in the
|
|
// query string. The URL itself already resolved to a v1 route through the
|
|
// [DeliveryApi] facade, so the call could only ever be answered 401.
|
|
//
|
|
// And the filter looked for `orderstatus == 'active'`, which is not a
|
|
// value this app's vocabulary contains: [ApiConfig.legacyStatusFromNew]
|
|
// maps the v1 statuses to `assigned` / `accepted` / `arrived` / `picked`,
|
|
// never to `active`. So it returned nothing on every tick and fell
|
|
// through to the shift-end check, which is what the device log shows.
|
|
//
|
|
// `GET /miler/bookings` through [MilerApi], asked with the session, about
|
|
// the rider the session names.
|
|
final res = await MilerApi.bookings(date: today);
|
|
final deliveries = res.list;
|
|
|
|
debugPrint(
|
|
'[ACTIVE_DELIVERY_LOG][BG] /miler/bookings -> ${res.status}, '
|
|
'${deliveries.length} items',
|
|
);
|
|
|
|
const inProgress = {'accepted', 'arrived', 'picked'};
|
|
final activeOrders = deliveries.whereType<Map<String, dynamic>>().where((
|
|
order,
|
|
) {
|
|
final status = ApiConfig.legacyStatusFromNew(
|
|
(order['status'] ?? order['orderstatus'])?.toString(),
|
|
);
|
|
return inProgress.contains(status);
|
|
}).toList();
|
|
|
|
if (activeOrders.isEmpty) {
|
|
debugPrint('[ACTIVE_DELIVERY_LOG][BG] No active orders found');
|
|
// ✅ Still check shift end even if no active deliveries
|
|
debugPrint('[ACTIVE_DELIVERY_LOG][BG] 🔍 Checking shift end time...');
|
|
await checkShiftEnd();
|
|
return;
|
|
}
|
|
|
|
debugPrint(
|
|
'[ACTIVE_DELIVERY_LOG][BG] Found ${activeOrders.length} active deliveries',
|
|
);
|
|
|
|
// Post logs for each active delivery
|
|
for (final order in activeOrders) {
|
|
final orderId = (order['orderid'] ?? '').toString();
|
|
if (orderId.isEmpty) continue;
|
|
|
|
// Get current coordinates once per order to reuse for proximity + log
|
|
final Map<String, String>? coords = await _getCoordinatesWithFallback();
|
|
if (coords != null) {
|
|
await _checkProximityAlert(order, coords);
|
|
}
|
|
|
|
// Get payload from SharedPreferences or create new one
|
|
Map<String, dynamic>? payload = await _loadPayload(orderId);
|
|
if (payload == null) {
|
|
// Create new payload from order data with userId from SharedPreferences
|
|
payload = _createPayload(order, userId);
|
|
await _persistPayload(orderId, payload);
|
|
await _markAsActive(orderId);
|
|
} else {
|
|
// Ensure userid is set correctly in existing payload
|
|
payload['userid'] = userId;
|
|
}
|
|
|
|
// Post the log
|
|
await _postDeliveryLog(orderId, payload, currentCoords: coords);
|
|
}
|
|
|
|
// ✅ CRITICAL: Check if shift end time has passed (backup to alarm)
|
|
await checkShiftEnd();
|
|
|
|
// Attempt to flush offline logs
|
|
await _flushOfflineLogs();
|
|
} catch (e) {
|
|
debugPrint('[ACTIVE_DELIVERY_LOG][BG] Error processing: $e');
|
|
} finally {
|
|
_isPosting = false;
|
|
}
|
|
}
|
|
|
|
/// Post delivery log for a specific order
|
|
static Future<void> _postDeliveryLog(
|
|
String orderId,
|
|
Map<String, dynamic> payload, {
|
|
Map<String, String>? currentCoords,
|
|
}) async {
|
|
Map<String, dynamic>? payloadWithCoords;
|
|
try {
|
|
// Get current coordinates
|
|
final coords = currentCoords ?? await _getCoordinatesWithFallback();
|
|
if (coords == null) {
|
|
return;
|
|
}
|
|
|
|
// Distance calculation is now fully handled by LiveTrackingService in the background.
|
|
// 03-14: Removed redundant 60-second background calculations to avoid logic conflicts.
|
|
final deliveryId = payload['deliveryid']?.toString() ?? '';
|
|
|
|
// Create log date timestamp
|
|
final now = DateTime.now();
|
|
final logDate =
|
|
'${now.year}-${_pad(now.month)}-${_pad(now.day)} ${_pad(now.hour)}:${_pad(now.minute)}:${_pad(now.second)}';
|
|
|
|
// Retrieve final cumulative KM to send
|
|
double riderKmsToSend = 0.0;
|
|
if (deliveryId.isNotEmpty && deliveryId != '0') {
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs
|
|
.reload(); // Force reload so we get the latest value written by the main isolate
|
|
riderKmsToSend =
|
|
double.tryParse(
|
|
prefs.getString(
|
|
'delivery_tracking_${deliveryId}_cumulativeKm',
|
|
) ??
|
|
'0',
|
|
) ??
|
|
0.0;
|
|
} catch (_) {}
|
|
}
|
|
|
|
// Build payload with coordinates and timestamp
|
|
payloadWithCoords = <String, dynamic>{
|
|
...payload,
|
|
'logdate': logDate,
|
|
'latitude': coords['lat'] ?? '0',
|
|
'longitude': coords['lng'] ?? '0',
|
|
'raw_latitude': coords['raw_lat'] ?? '0',
|
|
'raw_longitude': coords['raw_lng'] ?? '0',
|
|
'velocity_lat': coords['velocity_lat'] ?? '0',
|
|
'velocity_lng': coords['velocity_lng'] ?? '0',
|
|
'speed': coords['speed'] ?? '0',
|
|
'heading': coords['heading'] ?? '0',
|
|
'riderkms': riderKmsToSend,
|
|
'logstatus': await _resolveLogStatus(orderId, coords),
|
|
};
|
|
|
|
// v1: `/miler/consignments/logs`, via the DeliveryApi facade.
|
|
final url = ApiConstants.createDeliveryLogLive;
|
|
// Post the log
|
|
debugPrint('[ACTIVE_DELIVERY_LOG][BG] Posting log for $orderId');
|
|
|
|
final result = await _logProvider
|
|
.createDeliveryLog(url, payloadWithCoords)
|
|
.timeout(
|
|
const Duration(seconds: 8),
|
|
onTimeout: () => throw TimeoutException(
|
|
'Delivery log post timeout for $orderId',
|
|
),
|
|
);
|
|
|
|
if (result != null) {
|
|
debugPrint('[ACTIVE_DELIVERY_LOG][BG] Success for $orderId');
|
|
await _persistLastLogLocation(orderId, coords);
|
|
} else {
|
|
debugPrint(
|
|
'[ACTIVE_DELIVERY_LOG][BG] Warning: No response for $orderId',
|
|
);
|
|
}
|
|
} catch (e) {
|
|
debugPrint(
|
|
'[ACTIVE_DELIVERY_LOG][BG] Failed to post log for $orderId: $e',
|
|
);
|
|
// Offline fallback
|
|
if (payloadWithCoords != null) {
|
|
await _saveToOfflineQueue(orderId, payloadWithCoords);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Create payload from delivery order data
|
|
static Map<String, dynamic> _createPayload(
|
|
Map<String, dynamic> delivery,
|
|
int userId,
|
|
) {
|
|
return <String, dynamic>{
|
|
'logid': 0,
|
|
'tenantid': delivery['tenantid'] ?? 0,
|
|
'partnerid': delivery['partnerid'] ?? 0,
|
|
'locationid': delivery['locationid'] ?? 0,
|
|
'orderheaderid': delivery['orderheaderid'] ?? 0,
|
|
'deliveryid': delivery['deliveryid'] ?? 0,
|
|
'userid': userId,
|
|
'orderid': (delivery['orderid'] ?? '').toString(),
|
|
'orderstatus': 'active',
|
|
};
|
|
}
|
|
|
|
/// Get coordinates with fallback (current position -> last known -> cached)
|
|
static Future<Map<String, String>?> _getCoordinatesWithFallback() async {
|
|
Map<String, String> result = {
|
|
'lat': '0',
|
|
'lng': '0',
|
|
'raw_lat': '0',
|
|
'raw_lng': '0',
|
|
'speed': '0',
|
|
'heading': '0',
|
|
'velocity_lat': '0',
|
|
'velocity_lng': '0',
|
|
};
|
|
|
|
try {
|
|
// 1. Check if location services are enabled
|
|
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
|
if (!serviceEnabled) {
|
|
debugPrint('[ACTIVE_DELIVERY_LOG][BG] Location services disabled');
|
|
return await _getCachedCoordinatesAsMap();
|
|
}
|
|
|
|
// 2. Check permissions
|
|
final permission = await Geolocator.checkPermission();
|
|
if (permission == LocationPermission.denied ||
|
|
permission == LocationPermission.deniedForever) {
|
|
debugPrint('[ACTIVE_DELIVERY_LOG][BG] Location permission denied');
|
|
return await _getCachedCoordinatesAsMap();
|
|
}
|
|
|
|
Position? position;
|
|
try {
|
|
position = await Geolocator.getCurrentPosition(
|
|
locationSettings: const LocationSettings(
|
|
accuracy: LocationAccuracy.high,
|
|
),
|
|
).timeout(const Duration(seconds: 5));
|
|
} catch (e) {
|
|
debugPrint('[ACTIVE_DELIVERY_LOG][BG] Failed to get current pos: $e');
|
|
position = await Geolocator.getLastKnownPosition();
|
|
}
|
|
|
|
if (position != null) {
|
|
// Reject mocked GPS (anti-cheat)
|
|
if (position.isMocked) {
|
|
debugPrint(
|
|
'[ACTIVE_DELIVERY_LOG][BG] Mocked position — using cached',
|
|
);
|
|
return await _getCachedCoordinatesAsMap();
|
|
}
|
|
// Reject very poor accuracy to avoid phantom movements in delivery logs
|
|
if (position.accuracy > 50.0) {
|
|
debugPrint(
|
|
'[ACTIVE_DELIVERY_LOG][BG] Low-accuracy position (${position.accuracy.toStringAsFixed(0)}m) — using cached',
|
|
);
|
|
return await _getCachedCoordinatesAsMap();
|
|
}
|
|
|
|
final now = DateTime.now();
|
|
double outLat = position.latitude;
|
|
double outLng = position.longitude;
|
|
double speed = position.speed;
|
|
double heading = position.heading;
|
|
|
|
// Decompose velocity for Kalman
|
|
final double headingRadians = heading * (math.pi / 180.0);
|
|
final double velocityLng = speed * math.sin(headingRadians);
|
|
final double velocityLat = speed * math.cos(headingRadians);
|
|
|
|
if (_kf == null) {
|
|
_kf = DoormileKalmanFilter(lat: outLat, lng: outLng);
|
|
} else {
|
|
final double dt = _lastUpdateTime != null
|
|
? now.difference(_lastUpdateTime!).inMilliseconds / 1000.0
|
|
: 30.0;
|
|
|
|
_kf!.predict(dt);
|
|
_kf!.update(outLat, outLng);
|
|
outLat = _kf!.x[0];
|
|
outLng = _kf!.x[1];
|
|
}
|
|
_lastUpdateTime = now;
|
|
|
|
result = {
|
|
'lat': outLat.toString(),
|
|
'lng': outLng.toString(),
|
|
'raw_lat': position.latitude.toString(),
|
|
'raw_lng': position.longitude.toString(),
|
|
'speed': speed.toStringAsFixed(2),
|
|
'heading': heading.toStringAsFixed(2),
|
|
'velocity_lat': velocityLat.toStringAsFixed(4),
|
|
'velocity_lng': velocityLng.toStringAsFixed(4),
|
|
};
|
|
|
|
await _cacheCoordinates(result['lat']!, result['lng']!);
|
|
return result;
|
|
}
|
|
|
|
return await _getCachedCoordinatesAsMap();
|
|
} catch (e) {
|
|
debugPrint('[ACTIVE_DELIVERY_LOG][BG] Error getting coords: $e');
|
|
return await _getCachedCoordinatesAsMap();
|
|
}
|
|
}
|
|
|
|
static Future<Map<String, String>?> _getCachedCoordinatesAsMap() async {
|
|
final coords = await _getCachedCoordinates();
|
|
if (coords == null) return null;
|
|
return {
|
|
'lat': coords.$1,
|
|
'lng': coords.$2,
|
|
'raw_lat': coords.$1,
|
|
'raw_lng': coords.$2,
|
|
'speed': '0',
|
|
'heading': '0',
|
|
'velocity_lat': '0',
|
|
'velocity_lng': '0',
|
|
};
|
|
}
|
|
|
|
/// Cache coordinates to SharedPreferences
|
|
static Future<void> _cacheCoordinates(String lat, String lng) async {
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString('last_lat', lat);
|
|
await prefs.setString('last_lng', lng);
|
|
} catch (_) {}
|
|
}
|
|
|
|
/// Get cached coordinates from SharedPreferences
|
|
static Future<(String, String)?> _getCachedCoordinates() async {
|
|
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) {
|
|
return (lat, lng);
|
|
}
|
|
} catch (_) {}
|
|
return null;
|
|
}
|
|
|
|
/// Load payload from SharedPreferences
|
|
static Future<Map<String, dynamic>?> _loadPayload(String orderId) async {
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final jsonStr = prefs.getString('$_payloadKeyPrefix$orderId');
|
|
if (jsonStr == null || jsonStr.isEmpty) return null;
|
|
final decoded = jsonDecode(jsonStr);
|
|
if (decoded is Map<String, dynamic>) return decoded;
|
|
if (decoded is Map) return decoded.cast<String, dynamic>();
|
|
} catch (_) {}
|
|
return null;
|
|
}
|
|
|
|
/// Persist payload to SharedPreferences
|
|
static Future<void> _persistPayload(
|
|
String orderId,
|
|
Map<String, dynamic> payload,
|
|
) async {
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString('$_payloadKeyPrefix$orderId', jsonEncode(payload));
|
|
} catch (_) {}
|
|
}
|
|
|
|
/// Mark order as active in SharedPreferences
|
|
static Future<void> _markAsActive(String orderId) async {
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final list = prefs.getStringList(_activeDeliveriesKey) ?? <String>[];
|
|
if (!list.contains(orderId)) {
|
|
list.add(orderId);
|
|
await prefs.setStringList(_activeDeliveriesKey, list);
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
/// Fetch deliveries from API using v1 endpoint
|
|
|
|
/// Helper to pad numbers with leading zero
|
|
static String _pad(int n) => n.toString().padLeft(2, '0');
|
|
|
|
static Future<int> _resolveLogStatus(
|
|
String orderId,
|
|
Map<String, String> coords,
|
|
) async {
|
|
try {
|
|
final last = await _loadLastLogLocation(orderId);
|
|
if (last != null) {
|
|
final lastLat = last.$1;
|
|
final lastLng = last.$2;
|
|
final currentLat = double.tryParse(coords['lat'] ?? '0') ?? 0;
|
|
final currentLng = double.tryParse(coords['lng'] ?? '0') ?? 0;
|
|
if (currentLat != 0 &&
|
|
currentLng != 0 &&
|
|
lastLat != 0 &&
|
|
lastLng != 0) {
|
|
final distance = Geolocator.distanceBetween(
|
|
lastLat,
|
|
lastLng,
|
|
currentLat,
|
|
currentLng,
|
|
);
|
|
if (distance <= _idleThresholdMeters) {
|
|
return 1; // idle
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
debugPrint('[ACTIVE_DELIVERY_LOG][BG] logstatus error: $e');
|
|
}
|
|
return 0; // moving
|
|
}
|
|
|
|
static Future<(double, double)?> _loadLastLogLocation(String orderId) async {
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final jsonStr = prefs.getString('last_log_loc_$orderId');
|
|
if (jsonStr == null || jsonStr.isEmpty) return null;
|
|
final decoded = jsonDecode(jsonStr);
|
|
if (decoded is Map<String, dynamic>) {
|
|
final lat = double.tryParse('${decoded['lat']}') ?? 0;
|
|
final lng = double.tryParse('${decoded['lng']}') ?? 0;
|
|
if (lat != 0 && lng != 0) return (lat, lng);
|
|
}
|
|
} catch (_) {}
|
|
return null;
|
|
}
|
|
|
|
static Future<void> _persistLastLogLocation(
|
|
String orderId,
|
|
Map<String, String> coords,
|
|
) async {
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString(
|
|
'last_log_loc_$orderId',
|
|
jsonEncode({'lat': coords['lat'], 'lng': coords['lng']}),
|
|
);
|
|
} catch (_) {}
|
|
}
|
|
|
|
// ---------------- Offline Queue Logic ----------------
|
|
|
|
static Future<void> _flushOfflineLogs() async {
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final List<String> queue = prefs.getStringList(_offlineLogKey) ?? [];
|
|
if (queue.isEmpty) return;
|
|
|
|
debugPrint(
|
|
'[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Flushing ${queue.length} offline logs...',
|
|
);
|
|
|
|
final List<String> remaining = [];
|
|
bool anySuccess = false;
|
|
|
|
for (final itemStr in queue) {
|
|
try {
|
|
final Map<String, dynamic> item = jsonDecode(itemStr);
|
|
final String orderId = item['orderId'] ?? '';
|
|
final Map<String, dynamic> payload = Map<String, dynamic>.from(
|
|
item['payload'] ?? {},
|
|
);
|
|
|
|
if (payload.isEmpty) continue;
|
|
|
|
// v1: `/miler/consignments/logs`, via the DeliveryApi facade.
|
|
final url = ApiConstants.createDeliveryLogLive;
|
|
debugPrint(
|
|
'[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Retrying for orderId: $orderId',
|
|
);
|
|
|
|
final result = await _logProvider
|
|
.createDeliveryLog(url, payload)
|
|
.timeout(const Duration(seconds: 8));
|
|
|
|
if (result != null) {
|
|
debugPrint(
|
|
'[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Success for orderId: $orderId',
|
|
);
|
|
anySuccess = true;
|
|
} else {
|
|
remaining.add(itemStr);
|
|
}
|
|
} catch (e) {
|
|
remaining.add(itemStr);
|
|
}
|
|
}
|
|
|
|
if (anySuccess || remaining.length != queue.length) {
|
|
await prefs.setStringList(_offlineLogKey, remaining);
|
|
}
|
|
} catch (e) {
|
|
debugPrint('[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Flush error: $e');
|
|
}
|
|
}
|
|
|
|
static Future<void> _saveToOfflineQueue(
|
|
String orderId,
|
|
Map<String, dynamic> payload,
|
|
) async {
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final List<String> queue = prefs.getStringList(_offlineLogKey) ?? [];
|
|
|
|
final item = jsonEncode({
|
|
'orderId': orderId,
|
|
'payload': payload,
|
|
'timestamp': DateTime.now().millisecondsSinceEpoch,
|
|
});
|
|
|
|
queue.add(item);
|
|
await prefs.setStringList(_offlineLogKey, queue);
|
|
debugPrint(
|
|
'[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Saved to queue. Total: ${queue.length}',
|
|
);
|
|
} catch (e) {
|
|
debugPrint(
|
|
'[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Error saving to queue: $e',
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------- Auto Shift End Logic ----------------
|
|
|
|
/// Check if shift has ended and trigger auto-break if needed
|
|
static Future<void> checkShiftEnd() async {
|
|
try {
|
|
debugPrint('[AUTO_SHIFT_END] 🔍 Starting shift end check...');
|
|
final prefs = await SharedPreferences.getInstance();
|
|
|
|
// 1. Check if currently On Duty
|
|
final int onduty = prefs.getInt('onduty') ?? 0;
|
|
debugPrint('[AUTO_SHIFT_END] 📊 Current onduty status: $onduty');
|
|
if (onduty != 1) {
|
|
debugPrint('[AUTO_SHIFT_END] ⏭️ Rider not on duty, skipping check');
|
|
return; // Already offline
|
|
}
|
|
|
|
// 2. Get Shift Timings
|
|
final String startTimeStr = prefs.getString('starttime') ?? '';
|
|
final String endTimeStr = prefs.getString('endtime') ?? '';
|
|
debugPrint(
|
|
'[AUTO_SHIFT_END] ⏰ Shift times - Start: "$startTimeStr", End: "$endTimeStr"',
|
|
);
|
|
|
|
if (endTimeStr.isEmpty) {
|
|
debugPrint(
|
|
'[AUTO_SHIFT_END] ⚠️ No endtime found, cannot check shift end',
|
|
);
|
|
return;
|
|
}
|
|
|
|
// 3. Parse Times (Assumed format HH:mm:ss)
|
|
final now = DateTime.now();
|
|
|
|
final endParts = endTimeStr.split(':');
|
|
if (endParts.length < 2) return;
|
|
|
|
final int endHour = int.tryParse(endParts[0]) ?? 0;
|
|
final int endMinute = int.tryParse(endParts[1]) ?? 0;
|
|
final int endSecond = endParts.length > 2
|
|
? (int.tryParse(endParts[2]) ?? 0)
|
|
: 0;
|
|
|
|
// Create DateTime for end time on TODAY
|
|
final DateTime endToday = DateTime(
|
|
now.year,
|
|
now.month,
|
|
now.day,
|
|
endHour,
|
|
endMinute,
|
|
endSecond,
|
|
);
|
|
|
|
bool isShiftOver = false;
|
|
|
|
if (startTimeStr.isNotEmpty) {
|
|
final startParts = startTimeStr.split(':');
|
|
if (startParts.length >= 2) {
|
|
final int startHour = int.tryParse(startParts[0]) ?? 0;
|
|
final int startMinute = int.tryParse(startParts[1]) ?? 0;
|
|
|
|
// Create DateTime for start time on TODAY
|
|
final DateTime startToday = DateTime(
|
|
now.year,
|
|
now.month,
|
|
now.day,
|
|
startHour,
|
|
startMinute,
|
|
);
|
|
|
|
// Check for overnight shift (Start > End in 24-hour format, e.g. 22:00 to 06:00)
|
|
// This means shift crosses midnight
|
|
final double startVal = startHour + (startMinute / 60.0);
|
|
final double endVal = endHour + (endMinute / 60.0);
|
|
|
|
if (startVal > endVal) {
|
|
// ✅ OVERNIGHT SHIFT (crosses midnight, e.g. 22:00 to 06:00)
|
|
// Shift is over if current time is AFTER end time today AND BEFORE start time today
|
|
// Example: End 06:00, Start 22:00
|
|
// - Now 23:00 -> After 22:00 (start) -> Still in shift (ACTIVE)
|
|
// - Now 05:00 -> Before 06:00 (end) -> Still in shift from yesterday (ACTIVE)
|
|
// - Now 10:00 -> After 06:00 (end) AND Before 22:00 (start) -> Shift OVER
|
|
|
|
debugPrint(
|
|
'[AUTO_SHIFT_END] 🌙 Overnight shift detected (Start: ${_pad(startHour)}:${_pad(startMinute)}, End: ${_pad(endHour)}:${_pad(endMinute)})',
|
|
);
|
|
debugPrint(
|
|
'[AUTO_SHIFT_END] 📅 Current time: ${_pad(now.hour)}:${_pad(now.minute)}',
|
|
);
|
|
|
|
if (now.isAfter(endToday) && now.isBefore(startToday)) {
|
|
// We're in the gap period between end and start -> shift is OVER
|
|
isShiftOver = true;
|
|
debugPrint(
|
|
'[AUTO_SHIFT_END] ✅ Overnight shift: In gap period -> SHIFT OVER',
|
|
);
|
|
} else {
|
|
// We're either before end (still in shift from yesterday) or after start (still in shift today)
|
|
isShiftOver = false;
|
|
debugPrint('[AUTO_SHIFT_END] ✅ Overnight shift: Still active');
|
|
}
|
|
} else {
|
|
// ✅ NORMAL DAY SHIFT (e.g. 09:00 to 17:00, doesn't cross midnight)
|
|
// Shift is over if current time is AFTER end time
|
|
debugPrint(
|
|
'[AUTO_SHIFT_END] ☀️ Day shift detected (Start: ${_pad(startHour)}:${_pad(startMinute)}, End: ${_pad(endHour)}:${_pad(endMinute)})',
|
|
);
|
|
debugPrint(
|
|
'[AUTO_SHIFT_END] 📅 Current time: ${_pad(now.hour)}:${_pad(now.minute)}, End time: ${_pad(endHour)}:${_pad(endMinute)}',
|
|
);
|
|
|
|
if (now.isAfter(endToday) || now.isAtSameMomentAs(endToday)) {
|
|
isShiftOver = true;
|
|
debugPrint('[AUTO_SHIFT_END] ✅ Day shift: Shift ended');
|
|
} else {
|
|
isShiftOver = false;
|
|
debugPrint('[AUTO_SHIFT_END] ✅ Day shift: Still active');
|
|
}
|
|
}
|
|
} else {
|
|
// Fallback if start time parse fails: assume day shift
|
|
debugPrint(
|
|
'[AUTO_SHIFT_END] ⚠️ Could not parse start time, assuming day shift',
|
|
);
|
|
if (now.isAfter(endToday) || now.isAtSameMomentAs(endToday)) {
|
|
isShiftOver = true;
|
|
}
|
|
}
|
|
} else {
|
|
// Fallback if no start time: assume day shift
|
|
debugPrint(
|
|
'[AUTO_SHIFT_END] ⚠️ No start time provided, assuming day shift',
|
|
);
|
|
if (now.isAfter(endToday) || now.isAtSameMomentAs(endToday)) {
|
|
isShiftOver = true;
|
|
}
|
|
}
|
|
|
|
// 4. Trigger Auto End if Shift is Over
|
|
debugPrint(
|
|
'[AUTO_SHIFT_END] 📅 Time check result - isShiftOver: $isShiftOver, Current: ${_pad(now.hour)}:${_pad(now.minute)}, End: ${_pad(endHour)}:${_pad(endMinute)}',
|
|
);
|
|
if (isShiftOver) {
|
|
debugPrint(
|
|
'[AUTO_SHIFT_END] ⏰ Shift ended (Start: $startTimeStr, End: $endTimeStr). Current: ${_pad(now.hour)}:${_pad(now.minute)}',
|
|
);
|
|
await _autoEndShift(prefs);
|
|
} else {
|
|
debugPrint('[AUTO_SHIFT_END] ✅ Shift not ended yet, continuing...');
|
|
}
|
|
} catch (e) {
|
|
debugPrint('[AUTO_SHIFT_END] ❌ Error checking shift end: $e');
|
|
debugPrint('[AUTO_SHIFT_END] Stack trace: ${StackTrace.current}');
|
|
}
|
|
}
|
|
|
|
static Future<void> _autoEndShift(SharedPreferences prefs) async {
|
|
try {
|
|
debugPrint(
|
|
'[AUTO_SHIFT_END] 🚀 Initiating auto-break and offline sequence...',
|
|
);
|
|
|
|
// ── The four ids and the fix are no longer this function's business ──
|
|
//
|
|
// `userid`, `partnerid`, `shiftid`, `logid` and a position were assembled
|
|
// here to fill a legacy break-log body. On v1 the break and the duty end
|
|
// are both keyed on the **token**: `POST /miler/breaks/start` opens a
|
|
// break for whoever the session names, and `PUT /miler/duty/end` closes
|
|
// whichever duty log is open for them. Sending an id would not make it
|
|
// more correct; it is what let the old call be made unauthenticated.
|
|
//
|
|
// `userid` is still read, as the one guard worth keeping: no signed-in
|
|
// rider, nothing to end.
|
|
final int userid = prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0;
|
|
if (userid == 0) {
|
|
debugPrint(
|
|
'[AUTO_SHIFT_END] ❌ Missing userid, cannot create break log',
|
|
);
|
|
return;
|
|
}
|
|
|
|
final now = DateTime.now();
|
|
final int localBreakId =
|
|
(DateTime.now().millisecondsSinceEpoch % 900) + 100; // Random-ish ID
|
|
final String breakstart =
|
|
'${_pad(now.hour)}:${_pad(now.minute)}:${_pad(now.second)}';
|
|
|
|
// 4. Close the shift with a break, on v1.
|
|
//
|
|
// This hand-posted the retired backend's `partners/createbreaklog`
|
|
// with a bare `_httpClient.post` and no bearer
|
|
// token — an unauthenticated write keyed on a `userid` in the
|
|
// body. `POST /miler/breaks/start` is the v1 endpoint, and
|
|
// [MilerApi] attaches the session.
|
|
debugPrint('[AUTO_SHIFT_END] Creating break log (v1)');
|
|
|
|
try {
|
|
final res = await MilerApi.startBreak('Shift_End');
|
|
|
|
if (res.ok) {
|
|
debugPrint('[AUTO_SHIFT_END] ✅ Break log created successfully');
|
|
|
|
final serverBreakId = res.map['breaklogid'] ?? res.map['breakid'];
|
|
|
|
if (serverBreakId != null) {
|
|
await prefs.setInt(
|
|
'breakId',
|
|
int.tryParse('$serverBreakId') ?? localBreakId,
|
|
);
|
|
}
|
|
await prefs.setString('breakStart', breakstart);
|
|
await prefs.setInt('break_start_epoch', now.millisecondsSinceEpoch);
|
|
} else {
|
|
debugPrint(
|
|
'[AUTO_SHIFT_END] ⚠️ Failed to create break log: ${res.status}',
|
|
);
|
|
}
|
|
} catch (e) {
|
|
debugPrint('[AUTO_SHIFT_END] ❌ API Error: $e');
|
|
// Even if API fails, we MUST go offline locally to prevent further issues
|
|
}
|
|
|
|
// 5. Set Offline Locally
|
|
await prefs.setInt('onduty', 0);
|
|
await prefs.setBool('online', false);
|
|
|
|
// 6. End duty, on v1.
|
|
//
|
|
// Was an unauthenticated POST of `{userid, onduty: 0}` to the
|
|
// legacy `updateriderlog`. `PUT /miler/duty/end` closes whichever
|
|
// duty log is open for the token's rider, which is the same fact
|
|
// without the id or the open door.
|
|
|
|
try {
|
|
await MilerApi.endDuty();
|
|
debugPrint('[AUTO_SHIFT_END] ✅ Rider status updated to Offline');
|
|
} catch (_) {}
|
|
|
|
debugPrint('[AUTO_SHIFT_END] 🏁 Auto-shift end sequence complete.');
|
|
} catch (e) {
|
|
debugPrint('[AUTO_SHIFT_END] Critical error in _autoEndShift: $e');
|
|
}
|
|
}
|
|
|
|
/// Trigger proximity alert once per delivery when within threshold
|
|
static Future<void> _checkProximityAlert(
|
|
Map<String, dynamic> order,
|
|
Map<String, String> coords,
|
|
) async {
|
|
try {
|
|
final deliveryId = (order['deliveryid'] ?? order['orderid'] ?? '')
|
|
.toString();
|
|
if (deliveryId.isEmpty) return;
|
|
|
|
final dropLatRaw =
|
|
(order['droplat'] ?? order['DropLat'] ?? order['deliverylat'] ?? '')
|
|
.toString();
|
|
final dropLngRaw =
|
|
(order['droplon'] ??
|
|
order['droplong'] ??
|
|
order['DropLon'] ??
|
|
order['DropLong'] ??
|
|
order['deliverylong'] ??
|
|
'')
|
|
.toString();
|
|
final dropLat = double.tryParse(dropLatRaw) ?? 0.0;
|
|
final dropLng = double.tryParse(dropLngRaw) ?? 0.0;
|
|
if (dropLat == 0 ||
|
|
dropLng == 0 ||
|
|
dropLat.abs() > 90 ||
|
|
dropLng.abs() > 180) {
|
|
return;
|
|
}
|
|
|
|
final riderLat = double.tryParse(coords['lat'] ?? '0') ?? 0.0;
|
|
final riderLng = double.tryParse(coords['lng'] ?? '0') ?? 0.0;
|
|
if (riderLat == 0 ||
|
|
riderLng == 0 ||
|
|
riderLat.abs() > 90 ||
|
|
riderLng.abs() > 180) {
|
|
return;
|
|
}
|
|
|
|
final distanceMeters = Geolocator.distanceBetween(
|
|
dropLat,
|
|
dropLng,
|
|
riderLat,
|
|
riderLng,
|
|
);
|
|
|
|
if (distanceMeters <= _proximityThresholdMeters) {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final alreadyAlerted =
|
|
prefs.getBool('$_proximityAlertKeyPrefix$deliveryId') ?? false;
|
|
if (alreadyAlerted) {
|
|
debugPrint(
|
|
'[PROXIMITY] Already alerted for deliveryId=$deliveryId, skipping',
|
|
);
|
|
return;
|
|
}
|
|
|
|
await NotificationServce.showLocalNotification(
|
|
title: 'Near delivery location',
|
|
body: 'You\'ve reached your destination. Please update the status.',
|
|
);
|
|
|
|
final played = await _playProximityAudio();
|
|
await prefs.setBool('$_proximityAlertKeyPrefix$deliveryId', true);
|
|
|
|
debugPrint(
|
|
'[PROXIMITY] Alerted deliveryId=$deliveryId at ${distanceMeters.toStringAsFixed(1)}m '
|
|
'target=($dropLat,$dropLng) rider=($riderLat,$riderLng) played=$played',
|
|
);
|
|
} else {
|
|
debugPrint(
|
|
'[PROXIMITY] Skipped alert for deliveryId=$deliveryId | distance=${distanceMeters.toStringAsFixed(1)}m',
|
|
);
|
|
}
|
|
} catch (e) {
|
|
debugPrint('[PROXIMITY] Error sending alert: $e');
|
|
}
|
|
}
|
|
|
|
static Future<bool> _playProximityAudio() async {
|
|
try {
|
|
if (!_audioReady) {
|
|
await _proximityPlayer.setReleaseMode(ReleaseMode.stop);
|
|
await _proximityPlayer.setVolume(1.0);
|
|
_audioReady = true;
|
|
}
|
|
// Attempt to play bundled destination audio
|
|
await _proximityPlayer.stop();
|
|
await _proximityPlayer.play(AssetSource('audio/destination.mp3'));
|
|
debugPrint('[PROXIMITY][AUDIO] Playing destination.mp3');
|
|
return true;
|
|
} catch (e) {
|
|
debugPrint('[PROXIMITY][AUDIO] Error playing destination clip: $e');
|
|
}
|
|
|
|
// Fallback to TTS if audio fails
|
|
try {
|
|
// Initialize once
|
|
if (!_ttsReady) {
|
|
await _tts.setLanguage('en-US');
|
|
await _tts.setSpeechRate(0.9);
|
|
await _tts.setVolume(1.0);
|
|
await _tts.setPitch(1.0);
|
|
_ttsReady = true;
|
|
}
|
|
// Speak without awaiting completion to avoid blocking
|
|
await _tts.speak(
|
|
'You have reached your destination. Please update the delivery status.',
|
|
);
|
|
debugPrint('[PROXIMITY][TTS] Spoke destination prompt');
|
|
return true;
|
|
} catch (e) {
|
|
debugPrint('[PROXIMITY][TTS] Error speaking prompt: $e');
|
|
}
|
|
return false;
|
|
}
|
|
}
|