Files
doormile_milderapp/lib/background/backgroundservice.dart
2026-08-28 18:16:28 +05:30

921 lines
32 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:shared_preferences/shared_preferences.dart';
import 'package:audioplayers/audioplayers.dart';
import 'dart:math' as math;
import 'package:miler/providers/pickuplog/pickuplog_provider.dart';
import 'package:miler/data/api_config.dart';
import 'package:miler/data/miler_api.dart';
import 'package:miler/data/device_telemetry.dart';
import 'package:miler/providers/notifications/notificationservce.dart';
import 'package:miler/utils/kalman_filter.dart';
/// Background service for managing active pickup logs
class BackgroundCollectionLog {
static MilerKalmanFilter? _kf;
static DateTime? _lastUpdateTime;
static final CreatePickupLogProvider _logProvider = CreatePickupLogProvider();
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 _activePickupsKey = 'active_pickup';
static const String _payloadKeyPrefix = 'pickup_payload_';
static const String _offlineLogKey = 'offline_pickup_logs';
static const String _proximityAlertKeyPrefix = 'pickup_proximity_alerted_';
static bool _isPosting = false;
static bool _ttsReady = false;
static bool _audioReady = false;
/// Process active Pickups: fetch from API, filter active, and post logs
static Future<void> processActivePickups() async {
if (_isPosting) {
debugPrint('[ACTIVE_COLLECTION_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_COLLECTION_LOG][BG] Checking for user: $userId');
if (userId == 0) {
debugPrint('[ACTIVE_COLLECTION_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)}';
// ── This asked the pre-v1 backend, with the wrong question ──
//
// It built a `pickups/getpickups` URL on the retired backend by hand
// and fetched it with a bare `_httpClient.get` — **no bearer token**, and
// `userid` in the query string, so the rider's day was readable by id
// from an unauthenticated call.
//
// It could never have worked anyway. The filter below looked for
// `orderstatus == 'active'`, and `active` is not a value this app's own
// vocabulary contains: [ApiConfig.legacyStatusFromNew] maps the v1
// statuses to `assigned` / `accepted` / `arrived` / `picked`, and never to
// `active`. So on every tick this returned 0 items and fell through to
// the shift-end check — which is exactly what the device log shows.
//
// `GET /miler/bookings` is the v1 question, asked with the token, about
// the rider the token names. The filter is the real vocabulary: work the
// rider has taken on and not yet collected.
final res = await MilerApi.bookings(date: today);
final pickups = res.list;
debugPrint(
'[ACTIVE_COLLECTION_LOG][BG] /miler/bookings -> ${res.status}, '
'${pickups.length} items',
);
const inProgress = {'accepted', 'arrived'};
final activeOrders = pickups.whereType<Map<String, dynamic>>().where((
Booking,
) {
final status = ApiConfig.legacyStatusFromNew(
(Booking['status'] ?? Booking['orderstatus'])?.toString(),
);
return inProgress.contains(status);
}).toList();
if (activeOrders.isEmpty) {
debugPrint('[ACTIVE_COLLECTION_LOG][BG] No active bookings found');
// ✅ Still check shift end even if no active pickup
debugPrint('[ACTIVE_COLLECTION_LOG][BG] 🔍 Checking shift end time...');
await checkShiftEnd();
return;
}
debugPrint(
'[ACTIVE_COLLECTION_LOG][BG] Found ${activeOrders.length} active Pickup',
);
// Post logs for each active pickup
for (final Booking in activeOrders) {
final orderId = (Booking['orderid'] ?? '').toString();
if (orderId.isEmpty) continue;
// Get current coordinates once per Booking to reuse for proximity + log
final Map<String, String>? coords = await _getCoordinatesWithFallback();
if (coords != null) {
await _checkProximityAlert(Booking, coords);
}
// Get payload from SharedPreferences or create new one
Map<String, dynamic>? payload = await _loadPayload(orderId);
if (payload == null) {
// Create new payload from Booking data with userId from SharedPreferences
payload = _createPayload(Booking, userId);
await _persistPayload(orderId, payload);
await _markAsActive(orderId);
} else {
// Ensure userid is set correctly in existing payload
payload['userid'] = userId;
}
// Post the log
await _postPickupLog(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_COLLECTION_LOG][BG] Error processing: $e');
} finally {
_isPosting = false;
}
}
/// Post pickup log for a specific Booking
static Future<void> _postPickupLog(
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 pickupId = payload['pickupid']?.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 (pickupId.isNotEmpty) {
try {
final prefs = await SharedPreferences.getInstance();
riderKmsToSend =
double.tryParse(
prefs.getString('pickup_tracking_${pickupId}_cumulativeKm') ??
'0',
) ??
0.0;
} catch (_) {}
}
// ── The handset's state rides with the breadcrumb ──
//
// `POST /miler/consignments/logs` carries `battery` and `is_background`,
// and the provider forwards both — but this payload never put them in,
// so every per-stop breadcrumb reached the console with a position and
// nothing to explain a gap in the trail. Read from the same place the
// rider heartbeat reads it. See [DeviceTelemetry].
final telemetry = await DeviceTelemetry.read(
locationService: coords['status'],
);
// Build payload with coordinates and timestamp
payloadWithCoords = <String, dynamic>{
...payload,
...telemetry.toPayload(),
// This posts from the background isolate, which is the whole point of
// it — it is what keeps the trail alive while the app is off screen.
'is_background': true,
'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),
};
// Post the log
debugPrint('[ACTIVE_COLLECTION_LOG][BG] Posting pickup log for $orderId');
final result = await _logProvider
.createPickupLog(payloadWithCoords)
.timeout(
const Duration(seconds: 8),
onTimeout: () =>
throw TimeoutException('Pickup log post timeout for $orderId'),
);
if (result != null) {
debugPrint('[ACTIVE_COLLECTION_LOG][BG] Success for $orderId');
await _persistLastLogLocation(orderId, coords);
} else {
debugPrint(
'[ACTIVE_COLLECTION_LOG][BG] Warning: No response for $orderId',
);
}
} catch (e) {
debugPrint(
'[ACTIVE_COLLECTION_LOG][BG] Failed to post log for $orderId: $e',
);
// Offline fallback
if (payloadWithCoords != null) {
await _saveToOfflineQueue(orderId, payloadWithCoords);
}
}
}
/// Create payload from pickup Booking data
static Map<String, dynamic> _createPayload(
Map<String, dynamic> pickup,
int userId,
) {
return <String, dynamic>{
'logid': 0,
'tenantid': pickup['tenantid'] ?? 0,
'partnerid': pickup['partnerid'] ?? 0,
'locationid': pickup['locationid'] ?? 0,
'orderheaderid': pickup['orderheaderid'] ?? 0,
'pickupid': pickup['pickupid'] ?? 0,
'userid': userId,
'orderid': (pickup['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_COLLECTION_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_COLLECTION_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_COLLECTION_LOG][BG] Failed to get current pos: $e');
position = await Geolocator.getLastKnownPosition();
}
if (position != null) {
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 = MilerKalmanFilter(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_COLLECTION_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 Booking as active in SharedPreferences
static Future<void> _markAsActive(String orderId) async {
try {
final prefs = await SharedPreferences.getInstance();
final list = prefs.getStringList(_activePickupsKey) ?? <String>[];
if (!list.contains(orderId)) {
list.add(orderId);
await prefs.setStringList(_activePickupsKey, list);
}
} catch (_) {}
}
/// Fetch pickup 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_COLLECTION_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_COLLECTION_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;
debugPrint(
'[ACTIVE_COLLECTION_LOG][BG][OFFLINE] Retrying for orderId: $orderId',
);
final result = await _logProvider
.createPickupLog(payload)
.timeout(const Duration(seconds: 8));
if (result != null) {
debugPrint(
'[ACTIVE_COLLECTION_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_COLLECTION_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_PICKUP_LOG][BG][OFFLINE] Saved to queue. Total: ${queue.length}',
);
} catch (e) {
debugPrint('[ACTIVE_PICKUP_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 pickup when within threshold
static Future<void> _checkProximityAlert(
Map<String, dynamic> Booking,
Map<String, String> coords,
) async {
try {
final pickupId = (Booking['pickupid'] ?? Booking['orderid'] ?? '')
.toString();
if (pickupId.isEmpty) return;
final dropLatRaw =
(Booking['droplat'] ??
Booking['DropLat'] ??
Booking['pickuplat'] ??
'')
.toString();
final dropLngRaw =
(Booking['droplon'] ??
Booking['droplong'] ??
Booking['DropLon'] ??
Booking['DropLong'] ??
Booking['pickuplong'] ??
'')
.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) {
await NotificationServce.showLocalNotification(
title: 'Near pickup location',
body: 'You’ve reached your destination. Please update the status.',
);
final played = await _playProximityAudio();
debugPrint(
'[PROXIMITY] Alerted pickupId=$pickupId at ${distanceMeters.toStringAsFixed(1)}m '
'target=($dropLat,$dropLng) rider=($riderLat,$riderLng) played=$played',
);
} else {
debugPrint(
'[PROXIMITY] Skipped alert for pickupId=$pickupId | 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 pickup status.',
);
debugPrint('[PROXIMITY][TTS] Spoke destination prompt');
return true;
} catch (e) {
debugPrint('[PROXIMITY][TTS] Error speaking prompt: $e');
}
return false;
}
}