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

1330 lines
48 KiB
Dart

import 'package:get/get.dart';
import 'package:flutter/foundation.dart';
import 'dart:convert';
import 'package:miler/Models/riders/riders_models.dart';
import 'package:miler/providers/Riderlog/riderlog_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:async';
import 'package:geolocator/geolocator.dart';
import 'dart:math';
import 'dart:io' show Platform;
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
import 'package:miler/helpers/shift_end_alarm.dart';
import 'package:miler/controllers/logcontroller.dart';
import 'package:miler/background/foreground_service.dart' as fg;
import 'package:miler/utils/kalman_filter.dart';
import 'package:miler/utils/mqtt_service.dart';
import 'package:miler/data/device_telemetry.dart';
import 'package:miler/data/heartbeat.dart';
import 'package:miler/views/helpers/constants/mqtt_constants.dart';
import 'package:battery_plus/battery_plus.dart';
import 'package:miler/controllers/connectivity_mixin.dart';
class RiderLogController extends GetxController
with ConnectivityControllerMixin {
final _createProvider = CreateRiderLogProvider();
final _updateProvider = UpdateRiderLogProvider();
final _getProvider = GetRiderLogProvider();
final _breakProvider = BreakRiderLogProvider();
final RxBool isLoading = false.obs;
final RxList<RiderLog> riderLogs = <RiderLog>[].obs;
final RxInt riderCount = 0.obs;
bool _isEnsuringSession = false;
bool _startBreakInFlight = false;
bool _endBreakInFlight = false;
StreamSubscription<void>? _autoLoginSubscription;
StreamSubscription<BatteryState>? _batterySubscription;
bool _lowBatteryAlertSent = false;
MilerKalmanFilter? _kf;
DateTime? _lastUpdateTime;
Future<void> fetchRiderLogs({required String userId}) async {
isLoading.value = true;
try {
final response = await _getProvider.getRiderLog();
// Handle either {details: {...}} or {data: [...]} shapes
if (response == null) {
riderLogs.clear();
} else if (response['details'] is Map<String, dynamic>) {
final Map<String, dynamic> details =
response['details'] as Map<String, dynamic>;
// Only accept if non-zero logid or userid present
final int logIdVal = int.tryParse('${details['logid'] ?? 0}') ?? 0;
if (logIdVal > 0) {
riderLogs.value = [RiderLog.fromJson(details)];
} else {
riderLogs.clear();
}
} else if (response['data'] is List) {
final List<dynamic> dataList = response['data'] as List<dynamic>;
riderLogs.value = dataList
.whereType<Map<String, dynamic>>()
.map((e) => RiderLog.fromJson(e))
.toList();
} else {
riderLogs.clear();
}
// Persist key identifiers for later API calls (only if valid)
try {
if (riderLogs.isNotEmpty) {
final RiderLog current = riderLogs.first;
final prefs = await SharedPreferences.getInstance();
if ((current.userid ?? 0) > 0) {
await prefs.setInt('userid', current.userid!);
}
if ((current.partnerid ?? 0) > 0) {
await prefs.setInt('partnerid', current.partnerid!);
await prefs.setInt('partnerId', current.partnerid!);
}
if ((current.shiftid ?? 0) > 0) {
await prefs.setInt('shiftid', current.shiftid!);
await prefs.setInt('shiftId', current.shiftid!);
}
if ((current.logid ?? 0) > 0) {
await prefs.setInt('logid', current.logid!);
await prefs.setInt('logId', current.logid!);
}
if (current.onduty != null) {
await prefs.setInt('onduty', current.onduty!);
}
if ((current.tenantid ?? 0) > 0) {
await prefs.setInt('tenantid', current.tenantid!);
}
if ((current.locationid ?? 0) > 0) {
await prefs.setInt('locationid', current.locationid!);
}
if ((current.applocationid ?? 0) > 0) {
await prefs.setInt('applocationid', current.applocationid!);
}
if (current.userfcmtoken != null &&
current.userfcmtoken!.isNotEmpty) {
await prefs.setString('userfcmtoken', current.userfcmtoken!);
}
// Persist full rider log template from server for future rider log payloads.
// Latitude/longitude will always be overridden with real GPS values when sending.
try {
await prefs.setString(
'riderlog_template',
jsonEncode(current.toJson()),
);
} catch (_) {}
}
} catch (_) {}
} finally {
isLoading.value = false;
}
}
Future<void> fetchRiderCount({required String userId}) async {
final response = await _getProvider.getRiderCount();
final dynamic cnt = response != null ? response['count'] : null;
riderCount.value = cnt is num
? cnt.toInt()
: int.tryParse('${cnt ?? 0}') ?? 0;
}
Future<bool> createLogin(RiderLogin login) async {
final ok = await _createProvider.createRiderLog(
_CreateRiderLogRequestCompat.fromRiderLogin(login),
);
return ok != null && ok.isNotEmpty;
}
Future<bool> updateLog(RiderUpdate update) async {
final ok = await _updateProvider.updateRiderLog(
_UpdateRiderLogRequestCompat.fromRiderUpdate(update),
);
return ok != null && ok.isNotEmpty;
}
Future<bool> createBreak(RiderBreak brk) async {
final ok = await _breakProvider.createBreakRiderLog(
_BreakLogRequestCompat.fromRiderBreak(brk),
);
return ok != null && ok.isNotEmpty;
}
/// The break's own payload is no longer sent: `PUT /miler/breaks/end` takes
/// no body and closes whichever break is open, which the server knows and the
/// app was only guessing at.
Future<bool> updateBreak(RiderBreak brk) async {
final ok = await _breakProvider.updateBreakRiderLog();
return ok != null && ok.isNotEmpty;
}
Future<bool> updateBreakCustom({
required int breakid,
required int logid,
required String breakdate,
required int userid,
required int partnerid,
required int shiftid,
required String breakstart,
required String breakend,
required double breakhours,
required String latitude,
required String longitude,
}) async {
final payload = {
"breakid": breakid,
"logid": logid,
"breakdate": breakdate,
"userid": userid,
"partnerid": partnerid,
"shiftid": shiftid,
"breakstart": breakstart,
"breakend": breakend,
"breakhours": breakhours,
"latitude": latitude,
"longitude": longitude,
};
try {
// Debug: show exact URL and JSON body
debugPrint('[BREAK][UPDATE] Body: $payload');
final response = await _breakProvider.updateBreakRiderLog();
// Debug: show raw response
debugPrint(
'[BREAK][UPDATE] Response: ${response == null ? 'null' : response.toString()}',
);
return response != null && response.isNotEmpty;
} catch (e) {
return false;
}
}
// Public helper: create login immediately after successful auth
Future<bool> createLoginNowV2() async {
try {
final prefs = await SharedPreferences.getInstance();
final int onduty = prefs.getInt('onduty') ?? 0;
if (onduty != 1) {
debugPrint(
'[RIDERLOG][CREATE LOGIN NOW] Skipped because onduty=$onduty',
);
return false;
}
final int? userid = prefs.getInt('userId') ?? prefs.getInt('userid');
final int? partnerid =
prefs.getInt('partnerId') ?? prefs.getInt('partnerid');
final int? shiftid = prefs.getInt('shiftId') ?? prefs.getInt('shiftid');
final int? tenantid = prefs.getInt('tenantid');
final int? locationid = prefs.getInt('locationid');
final int? applocationid = prefs.getInt('applocationid');
final String? userfcmtoken = prefs.getString('userfcmtoken');
// Prefer explicit username, then fallback to stored full name or first/last
String? username = prefs.getString('username');
username ??= prefs.getString('user_name');
if (username == null || username.trim().isEmpty) {
final first = prefs.getString('firstname') ?? '';
final last = prefs.getString('lastname') ?? '';
final combined = ('$first $last').trim();
if (combined.isNotEmpty) {
username = combined;
}
}
if ((userid ?? 0) == 0) return false;
// ✅ Check if there are active pickup to set status
final bool hasActivePickups = prefs.getBool('has_live_pickup') ?? false;
final String riderStatus = heartbeatStatus(hasActiveWork: hasActivePickups);
final String orderId = prefs.getString('current_riding_order_id') ?? '';
debugPrint(
'[RIDERLOG][CREATE LOGIN NOW] Active pickup: $hasActivePickups -> status: $riderStatus',
);
final now = DateTime.now();
final iso = _formatDateTimeFull(now); // e.g. 2025-10-30 15:00:00
final loginTime = _formatTime(now); // e.g. 12:00:00
final loc = await _ensureLatLng('0', '0');
// Start from rider log template stored after login, then override
// dynamic fields like date/time and GPS coordinates.
Map<String, dynamic> baseTemplate = {};
try {
final rawTemplate = prefs.getString('riderlog_template');
if (rawTemplate != null && rawTemplate.isNotEmpty) {
final decoded = jsonDecode(rawTemplate);
if (decoded is Map<String, dynamic>) {
baseTemplate = decoded;
} else if (decoded is Map) {
baseTemplate = decoded.cast<String, dynamic>();
}
}
} catch (_) {}
// The handset's own state — see [DeviceTelemetry] for why this is read
// here and not published to MQTT alone. The console's Battery,
// Connection, GPS Accuracy and Location Service columns are read off the
// rider log, and until now nothing put them in it.
final telemetry = await DeviceTelemetry.read(
locationService: loc['status'],
);
final Map<String, dynamic> payload = {
// Base from server template
...baseTemplate,
// Always ensure required identifiers are correct
'logid': baseTemplate['logid'], // let server decide new logid
'userid': userid,
'partnerid': partnerid,
'shiftid': shiftid,
// Always override with current time and real GPS
'logdate': iso,
'login': loginTime,
'latitude': loc['lat'] ?? '0',
'longitude': loc['lng'] ?? '0',
'raw_latitude': loc['raw_lat'] ?? '0',
'raw_longitude': loc['raw_lng'] ?? '0',
'velocity_lat': loc['velocity_lat'] ?? '0',
'velocity_lng': loc['velocity_lng'] ?? '0',
'speed': loc['speed'] ?? '0',
'heading': loc['heading'] ?? '0',
'onduty': 1,
'status': riderStatus,
'contactno': prefs.getString('contactno') ?? '',
'tenantid': tenantid ?? 0,
'locationid': locationid ?? 0,
'applocationid': applocationid ?? 0,
'userfcmtoken': userfcmtoken ?? '',
'orderid': orderId,
...telemetry.toPayload(),
// Only a measured one. A `0` here would draw on the console as a
// perfect fix rather than as no fix at all.
if ((loc['accuracy'] ?? '').isNotEmpty) 'accuracy': loc['accuracy'],
// This is the *foreground* controller — the rider has the app open.
// The background heartbeat sends true, and the difference between the
// two is what the console's App State column reports.
'is_background': false,
};
// --- MQTT LOGIC ( Lane Split ) ---
final mqttService = MilerMqttService();
if (!mqttService.isConnected) {
await mqttService.connect();
}
// 1. Lane: Status
mqttService.updateStatus(
riderStatus == 'On_Pickup' ? 'Active' : MqttConstants.statusOnline,
);
// 2. Lane: Profile (Send only if significantly changed or first time)
// For simplicity, we send it here once when duty starts or during logs,
// but in a separate topic so it doesn't clutter the GPS stream.
mqttService.publishProfile({
'userid': userid,
'username': (username ?? '').trim(),
'firstname': prefs.getString('firstname') ?? '',
'lastname': prefs.getString('lastname') ?? '',
'contactno': prefs.getString('contactno') ?? '',
'userfcmtoken': userfcmtoken ?? '',
'app_version': prefs.getString('CurrentVersion') ?? '',
'device_info': Platform.operatingSystem,
});
// 3. Lane: Periodic Log (Lightweight)
mqttService.publishLog('rider_periodic_log', {
'userid': userid,
'logdate': iso,
'latitude': loc['lat'] ?? '0',
'longitude': loc['lng'] ?? '0',
'speed': loc['speed'] ?? '0',
'heading': loc['heading'] ?? '0',
'status': riderStatus,
'orderid': orderId,
// The same reading the log above carries, so the live lane and the
// trail cannot report two different batteries a second apart.
...telemetry.toPayload(),
if ((loc['accuracy'] ?? '').isNotEmpty) 'accuracy': loc['accuracy'],
});
// Ensure rider identity fields are clean:
// - always send username key (even if empty) so backend sees it
payload['username'] = (username ?? '').trim();
final String cNo = prefs.getString('contactno') ?? '';
debugPrint('[RIDERLOG] Contact No from Prefs: "$cNo"');
payload['contactno'] = cNo;
final firstName = prefs.getString('firstname') ?? '';
final lastName = prefs.getString('lastname') ?? '';
if (firstName.trim().isNotEmpty) {
payload['firstname'] = firstName.trim();
} else {
payload.remove('firstname');
}
if (lastName.trim().isNotEmpty) {
payload['lastname'] = lastName.trim();
} else {
payload.remove('lastname');
}
debugPrint('[RIDERLOG][CREATE LOGIN NOW] duty/location');
debugPrint('[RIDERLOG][CREATE LOGIN NOW] Payload: $payload');
final resp = await _createProvider.createRiderLog(payload);
debugPrint(
'[RIDERLOG][CREATE LOGIN NOW] Response: ${resp == null ? 'null' : resp.toString()}',
);
if (resp == null || resp.isEmpty) {
// Offline fallback
debugPrint(
'[RIDERLOG][CREATE LOGIN NOW] Failed, saving to offline queue',
);
await _saveToOfflineQueue('/miler/logs', payload);
return false;
}
final det = (resp['details'] is Map<String, dynamic>)
? (resp['details'] as Map<String, dynamic>)
: resp;
final newLogId =
int.tryParse('${det['logid'] ?? 0}') ?? (det['logid'] as int? ?? 0);
if (newLogId > 0) {
await prefs.setInt('logId', newLogId);
await prefs.setInt('logid', newLogId);
}
// Attempt to flush any other pending logs since we have a success
flushOfflineLogs();
return true;
} catch (_) {
// Offline fallback on exception
try {
final prefs = await SharedPreferences.getInstance();
final int? userid = prefs.getInt('userId') ?? prefs.getInt('userid');
final int? partnerid =
prefs.getInt('partnerId') ?? prefs.getInt('partnerid');
final int? shiftid = prefs.getInt('shiftId') ?? prefs.getInt('shiftid');
final int? tenantid = prefs.getInt('tenantid');
final int? locationid = prefs.getInt('locationid');
final int? applocationid = prefs.getInt('applocationid');
final String? userfcmtoken = prefs.getString('userfcmtoken');
// Prefer explicit username, then fallback to stored full name or first/last
String? username = prefs.getString('username');
username ??= prefs.getString('user_name');
if (username == null || username.trim().isEmpty) {
final first = prefs.getString('firstname') ?? '';
final last = prefs.getString('lastname') ?? '';
final combined = ('$first $last').trim();
if (combined.isNotEmpty) {
username = combined;
}
}
if ((userid ?? 0) != 0) {
final now = DateTime.now();
final iso = _formatDateTimeFull(now);
final loginTime = _formatTime(now);
final loc = await _ensureLatLng('0', '0');
// Offline fallback also uses stored template plus real GPS/time.
Map<String, dynamic> baseTemplate = {};
try {
final rawTemplate = prefs.getString('riderlog_template');
if (rawTemplate != null && rawTemplate.isNotEmpty) {
final decoded = jsonDecode(rawTemplate);
if (decoded is Map<String, dynamic>) {
baseTemplate = decoded;
} else if (decoded is Map) {
baseTemplate = decoded.cast<String, dynamic>();
}
}
} catch (_) {}
// Check status for offline fallback too
final bool hasActivePickups =
prefs.getBool('has_live_pickup') ?? false;
final String riderStatus = heartbeatStatus(hasActiveWork: hasActivePickups);
final Map<String, dynamic> payload = {
...baseTemplate,
'logid': baseTemplate['logid'],
'userid': userid,
'partnerid': partnerid,
'shiftid': shiftid,
'logdate': iso,
'login': loginTime,
'latitude': loc['lat'] ?? '0',
'longitude': loc['lng'] ?? '0',
'raw_latitude': loc['raw_lat'] ?? '0',
'raw_longitude': loc['raw_lng'] ?? '0',
'velocity_lat': loc['velocity_lat'] ?? '0',
'velocity_lng': loc['velocity_lng'] ?? '0',
'speed': loc['speed'] ?? '0',
'heading': loc['heading'] ?? '0',
'onduty': 1,
'status': riderStatus,
'contactno': prefs.getString('contactno') ?? '',
'tenantid': tenantid ?? 0,
'locationid': locationid ?? 0,
'applocationid': applocationid ?? 0,
'userfcmtoken': userfcmtoken ?? '',
};
// Ensure rider identity fields are clean in offline payload too
// Always send username key (even if empty) for parity with online payload
payload['username'] = (username ?? '').trim();
final firstName = prefs.getString('firstname') ?? '';
final lastName = prefs.getString('lastname') ?? '';
if (firstName.trim().isNotEmpty) {
payload['firstname'] = firstName.trim();
} else {
payload.remove('firstname');
}
if (lastName.trim().isNotEmpty) {
payload['lastname'] = lastName.trim();
} else {
payload.remove('lastname');
}
await _saveToOfflineQueue('/miler/logs', payload);
}
} catch (e) {
debugPrint('[RIDERLOG] Error saving offline log: $e');
}
return false;
}
}
// Toggle onduty via updateLog
Future<bool> setOnDuty(bool on) async {
try {
final prefs = await SharedPreferences.getInstance();
final int? userid = prefs.getInt('userId') ?? prefs.getInt('userid');
if ((userid ?? 0) == 0) return false;
final loc = await _ensureLatLng('0', '0');
final payload = RiderUpdate(
userid: userid,
onduty: on ? 1 : 0,
latitude: loc['lat'] ?? '0',
longitude: loc['lng'] ?? '0',
);
debugPrint('[RIDERLOG][SET ONDUTY] -> ${payload.toJson()}');
final ok = await updateLog(payload);
if (ok) {
await prefs.setInt('onduty', on ? 1 : 0);
if (on) {
await createLoginNowV2();
// ── Going on duty always starts the heartbeat ──
//
// This was gated on `interval > 0`, reading a stored zero as a
// decision not to beat. The v1 login has no `logseconds` to store, so
// the gate was shut on every rider: duty went on, one log was posted
// by the call above, and nothing followed it — no telemetry trail and
// no location writes for the rest of the shift.
//
// There is no cadence at which a rider on duty should not report, so
// there is no branch here any more. [resolveLogSeconds] supplies the
// floor when the hub has not set one.
final int interval = resolveLogSeconds(prefs.getInt('logseconds'));
startAutoCreateLoginLoop(seconds: interval);
// Ensure foreground logging notification is started when going on-duty
try {
if (Get.isRegistered<LogController>()) {
await Get.find<LogController>().startLogging();
}
} catch (_) {}
// ✅ Schedule shift end alarm (works even when app is killed)
try {
final String endTime = prefs.getString('endtime') ?? '';
final String startTime = prefs.getString('starttime') ?? '';
debugPrint(
'[RIDERLOG][SET ONDUTY] 📅 Checking shift times - endTime: "$endTime", startTime: "$startTime"',
);
if (endTime.isNotEmpty) {
debugPrint(
'[RIDERLOG][SET ONDUTY] 📅 Scheduling shift end alarm for: $endTime',
);
final scheduled = await ShiftEndAlarm.scheduleAlarm(
endTime: endTime,
startTime: startTime,
);
if (scheduled) {
debugPrint(
'[RIDERLOG][SET ONDUTY] ✅ Successfully scheduled shift end alarm for: $endTime',
);
} else {
debugPrint(
'[RIDERLOG][SET ONDUTY] ⚠️ Failed to schedule shift end alarm for: $endTime',
);
}
} else {
debugPrint(
'[RIDERLOG][SET ONDUTY] ⚠️ endTime is empty - cannot schedule alarm',
);
}
} catch (e) {
debugPrint('[RIDERLOG][SET ONDUTY] ❌ Error scheduling alarm: $e');
}
_startBatteryMonitoring();
// Force MQTT connection on duty start
MilerMqttService().connect();
} else {
await stopAutoCreateLoginLoop();
_stopBatteryMonitoring();
// ✅ Cancel shift end alarm when going offline
try {
await ShiftEndAlarm.cancelAlarm();
debugPrint('[RIDERLOG][SET ONDUTY] ✅ Cancelled shift end alarm');
} catch (e) {
debugPrint('[RIDERLOG][SET ONDUTY] Error cancelling alarm: $e');
}
// --- MQTT LOGIC ---
MilerMqttService().updateStatus(MqttConstants.statusOffline);
}
}
return ok;
} catch (_) {
return false;
}
}
/// Whether the periodic heartbeat is currently running on the stream path.
///
/// ── Why a test needs to be able to see this ──
///
/// The `logseconds` bug was invisible from outside the class. Duty went on,
/// the API accepted it, one log was posted by the immediate call — and the
/// loop that was supposed to follow it simply never started. Nothing threw,
/// nothing logged an error, and the only symptom was a console panel that had
/// been blank for so long it read as a backend problem.
///
/// Android normally runs the heartbeat as a foreground service instead, and
/// leaves this null; the test host is never Android, so the stream path is
/// the one under test. See [startAutoCreateLoginLoop].
@visibleForTesting
bool get autoLoopRunning => _autoLoginSubscription != null;
// Start periodic createRiderLog calls based on seconds (or prefs 'logseconds')
// ✅ CRITICAL: When there are active pickup, use 30 seconds (same as pickup logs)
// Otherwise, use the configured logseconds interval
/// `Future<void>`, not `void`. It was fire-and-forget, which meant no caller
/// could wait for the loop to actually be up — and the one place that most
/// wanted to, [setOnDuty], had no way to tell whether the heartbeat it had
/// just asked for existed. Every existing call site ignores the future and is
/// unchanged by this.
Future<void> startAutoCreateLoginLoop({int? seconds}) async {
final prefs = await SharedPreferences.getInstance();
// ✅ Check if there are active pickup - if yes, use 30 seconds (same as pickup logs)
final bool hasActivePickups = prefs.getBool('has_live_pickup') ?? false;
// ── The configured cadence, or the floor under it ──
//
// `?? 0` here was the second half of the same bug as the one in
// [setOnDuty]: a caller that passes no `seconds` — the app-resume path in
// `main.dart` is one — fell through to a stored zero and then returned at
// the `interval <= 0` guard below, so the loop the resume was trying to
// revive silently did not start. See [resolveLogSeconds].
final int baseInterval = resolveLogSeconds(
seconds ?? prefs.getInt('logseconds'),
);
// When there are active pickup, post rider logs every 30 seconds (matching pickup logs)
// Otherwise, use the configured interval
final int interval = hasActivePickups ? 30 : baseInterval;
if (hasActivePickups && interval != 30) {
debugPrint(
'[RIDERLOG][AUTO LOOP] Active pickup detected - using 30 second interval (matching pickup logs)',
);
}
// Stop before start, which is what keeps this method safe to call from the
// three places that do — going on duty, resuming the app, and a pickup
// going live. Whichever arrives last wins, and there is never a second
// loop: the subscription is cancelled and the foreground service stopped
// before either is created again.
await stopAutoCreateLoginLoop();
// Attempt to flush offline logs on loop start
flushOfflineLogs();
// No `interval <= 0` guard. [resolveLogSeconds] cannot return one, and the
// guard that used to be here is what turned a missing `logseconds` into a
// rider who never reported. Duty state decides whether the loop runs; the
// cadence only decides how often.
final int onduty = prefs.getInt('onduty') ?? 0;
if (onduty != 1) {
debugPrint('[RIDERLOG][AUTO LOOP] Not starting - onduty=$onduty');
return;
}
if (Platform.isAndroid) {
try {
// Check if we can use foreground service (Android 14+ restrictions)
// Initialize and start Android foreground service for reliable background ticks
FlutterForegroundTask.init(
androidNotificationOptions: AndroidNotificationOptions(
channelId: 'doormile_bg_service',
channelName: 'Background Service',
channelDescription:
'Keeps Miler online updates running in background.',
channelImportance: NotificationChannelImportance.LOW,
priority: NotificationPriority.LOW,
),
iosNotificationOptions: const IOSNotificationOptions(
showNotification: true,
playSound: false,
),
foregroundTaskOptions: ForegroundTaskOptions(
interval: Duration(seconds: interval).inMilliseconds,
isOnceEvent: false,
autoRunOnBoot: false,
allowWakeLock: true,
allowWifiLock: true,
),
);
// Check permissions before starting service to prevent Android 14 crash
final permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
debugPrint(
'[RIDERLOG] Location permission missing, cannot start foreground service',
);
_startStreamBasedLoop(interval);
return;
}
// Try to start the service with error handling
ServiceRequestResult? started;
try {
started = await FlutterForegroundTask.startService(
notificationTitle: 'Miler is running',
notificationText: 'Auto rider log active',
callback: fg.riderLogCallback,
);
} catch (error) {
// If foreground service fails, fall back to stream-based approach
debugPrint(
'[RIDERLOG] Foreground service failed, using stream fallback: $error',
);
}
// If foreground service failed to start, use stream fallback.
// ServiceRequestResult is a class with a `success` bool — compare that,
// not the value against the `success()` factory (which is always true).
if (started?.success != true) {
debugPrint(
'[RIDERLOG] Using stream-based periodic updates instead of foreground service',
);
_startStreamBasedLoop(interval);
return;
}
} catch (e) {
// If any error occurs, fall back to stream-based approach
debugPrint(
'[RIDERLOG] Error starting foreground service, using stream fallback: $e',
);
_startStreamBasedLoop(interval);
return;
}
} else {
_startStreamBasedLoop(interval);
}
}
// Stream-based periodic loop (fallback when foreground service is unavailable)
void _startStreamBasedLoop(int interval) {
_autoLoginSubscription?.cancel();
_autoLoginSubscription =
Stream.periodic(Duration(seconds: interval), (_) {})
.asyncMap((_) async {
await createLoginNowV2();
})
.listen(
(_) {}, // Success handler
onError: (error) {
// Handle errors gracefully without crashing
debugPrint('[RIDERLOG][STREAM ERROR] $error');
},
cancelOnError: false, // Continue even on errors
);
}
Future<void> stopAutoCreateLoginLoop() async {
if (Platform.isAndroid) {
try {
await FlutterForegroundTask.stopService();
} catch (e) {
debugPrint('[RIDERLOG] Error stopping foreground service: $e');
}
}
await _autoLoginSubscription?.cancel();
_autoLoginSubscription = null;
}
void _startBatteryMonitoring() {
_batterySubscription?.cancel();
final battery = Battery();
_batterySubscription = battery.onBatteryStateChanged.listen((
BatteryState state,
) async {
final level = await battery.batteryLevel;
if (level <= 20 && !_lowBatteryAlertSent) {
MilerMqttService().publishLog('critical_battery', {
'level': level,
'state': state.toString(),
'rider_id': riderLogs.isNotEmpty ? riderLogs.first.userid : 'unknown',
'timestamp': DateTime.now().toIso8601String(),
});
_lowBatteryAlertSent = true;
debugPrint('[MQTT] ⚠️ Critical Battery Alert Sent: $level%');
} else if (level > 25) {
_lowBatteryAlertSent = false; // Reset if they started charging
}
});
}
void _stopBatteryMonitoring() {
_batterySubscription?.cancel();
_batterySubscription = null;
}
@override
void onClose() {
stopAutoCreateLoginLoop();
_stopBatteryMonitoring();
super.onClose();
}
// ---------------- Break flow parity with xpressrider ----------------
String _two(int n) => n.toString().padLeft(2, '0');
String _formatDateTimeFull(DateTime dt) {
final y = dt.year.toString();
final m = _two(dt.month);
final d = _two(dt.day);
final hh = _two(dt.hour);
final mm = _two(dt.minute);
final ss = _two(dt.second);
return "$y-$m-$d $hh:$mm:$ss";
}
String _formatTime(DateTime dt) {
final hh = _two(dt.hour);
final mm = _two(dt.minute);
final ss = _two(dt.second);
return "$hh:$mm:$ss";
}
// ignore: unused_element
String _durationToHourDotMinute(Duration d) {
final hours = d.inHours;
final minutes = d.inMinutes.remainder(60);
return "$hours.${_two(minutes)}"; // e.g. 1.30
}
Future<Map<String, String>> _ensureLatLng(String lat, String lng) async {
Map<String, String> result = {
'lat': lat,
'lng': lng,
'raw_lat': lat,
'raw_lng': lng,
'speed': '0',
'heading': '0',
'velocity_lat': '0',
'velocity_lng': '0',
// ── Two fields the background lookup has always returned and this one
// did not ──
//
// `accuracy` is how good the fix is and `status` is whether location is
// switched on at all — the two the console draws under GPS Accuracy and
// Location Service. Absent here, the foreground heartbeat had nothing to
// send and would have had to invent a `0`, which reads as a *perfect*
// fix rather than as no fix.
//
// Empty, not zero: an unknown accuracy is dropped from the payload by
// the caller and drawn as an em dash, which is the truth.
'accuracy': '',
'status': 'unknown',
};
try {
final needsFetch =
(lat == '0' || lat.isEmpty || lng == '0' || lng.isEmpty);
if (!needsFetch) return result;
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
result['status'] = 'disabled';
return result;
}
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
result['status'] = permission == LocationPermission.deniedForever
? 'denied_forever'
: 'denied';
return result;
}
result['status'] = 'enabled';
final Position pos = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
),
);
final now = DateTime.now();
double outLat = pos.latitude;
double outLng = pos.longitude;
double speed = pos.speed;
double heading = pos.heading;
// Decompose velocity for Kalman
final double headingRadians = heading * (pi / 180.0);
final double velocityLng = speed * sin(headingRadians);
final double velocityLat = speed * 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;
return {
'lat': outLat.toStringAsFixed(6),
'lng': outLng.toStringAsFixed(6),
'raw_lat': pos.latitude.toStringAsFixed(6),
'raw_lng': pos.longitude.toStringAsFixed(6),
'speed': speed.toStringAsFixed(2),
'heading': heading.toStringAsFixed(2),
'velocity_lat': velocityLat.toStringAsFixed(4),
'velocity_lng': velocityLng.toStringAsFixed(4),
'accuracy': pos.accuracy.toStringAsFixed(1),
'status': 'enabled',
};
} catch (_) {
return result;
}
}
Future<bool> startBreakAuto({
String latitude = '0',
String longitude = '0',
}) async {
try {
if (_startBreakInFlight) return false;
_startBreakInFlight = true;
// Resolve location if not provided
final ll = await _ensureLatLng(latitude, longitude);
latitude = ll['lat'] ?? latitude;
longitude = ll['lng'] ?? longitude;
final prefs = await SharedPreferences.getInstance();
int? userid = prefs.getInt('userId') ?? prefs.getInt('userid');
int? partnerid = prefs.getInt('partnerId') ?? prefs.getInt('partnerid');
int? shiftid = prefs.getInt('shiftId') ?? prefs.getInt('shiftid');
int? logid = prefs.getInt('logId') ?? prefs.getInt('logid');
// Ensure a rider log session exists (no GET calls)
if ((userid ?? 0) == 0 ||
(partnerid ?? 0) == 0 ||
(shiftid ?? 0) == 0 ||
(logid ?? 0) == 0) {
final ok = await _ensureLogSession();
if (ok) {
userid = prefs.getInt('userId') ?? prefs.getInt('userid');
partnerid = prefs.getInt('partnerId') ?? prefs.getInt('partnerid');
shiftid = prefs.getInt('shiftId') ?? prefs.getInt('shiftid');
logid = prefs.getInt('logId') ?? prefs.getInt('logid');
}
}
// Default missing optional IDs to 0, require at least userid and a logid
partnerid = partnerid ?? 0;
shiftid = shiftid ?? 0;
// If still missing, fall back to zeros to allow call and see server response
userid = userid ?? 0;
logid = logid ?? 0;
final now = DateTime.now();
final int localBreakId =
Random().nextInt(900) + 100; // 3-digit ID: 100-999
final breakdate = _formatDateTimeFull(now); // e.g. 2025-10-16 16:36:16
final breakstart = _formatTime(now); // e.g. 16:36:16
// Build payload with all required fields in the exact format the API expects
final payload = <String, dynamic>{
"breakid": localBreakId,
"logid": logid,
"breakdate": breakdate, // YYYY-MM-DD HH:MM:SS
"userid": userid,
"partnerid": partnerid,
"shiftid": shiftid,
"breakstart": breakstart, // HH:MM:SS
"breakend": "",
"breakhours": 0.0,
"latitude": latitude,
"longitude": longitude,
};
// Debug logs for terminal visibility
debugPrint('[BREAK][CREATE] Payload: $payload');
// Persist local break id immediately to ensure update can reference it
await prefs.setInt('breakId', localBreakId);
final resp = await _breakProvider.createBreakRiderLog(payload);
debugPrint(
'[BREAK][CREATE] Response: ${resp == null ? 'null' : resp.toString()}',
);
if (resp == null || resp.isEmpty) return false;
final det = (resp['details'] is Map<String, dynamic>)
? (resp['details'] as Map<String, dynamic>)
: resp;
await prefs.setString(
'breakStart',
(det['breakstart'] ?? breakstart).toString(),
);
final dynamic rawId = det['breakid'] ?? det['message_id'] ?? 0;
final int serverBreakId = int.tryParse('$rawId') ?? 0;
if (serverBreakId > 0) {
await prefs.setInt('breakId', serverBreakId);
}
final int persistedLogId =
int.tryParse('${det['logid'] ?? 0}') ?? (det['logid'] as int? ?? 0);
await prefs.setInt('logId', persistedLogId);
await prefs.setInt('break_start_epoch', now.millisecondsSinceEpoch);
return true;
} catch (_) {
return false;
} finally {
_startBreakInFlight = false;
}
}
Future<bool> endBreakAuto({
String latitude = '0',
String longitude = '0',
}) async {
try {
if (_endBreakInFlight) return false;
_endBreakInFlight = true;
// Resolve location if not provided
final ll = await _ensureLatLng(latitude, longitude);
latitude = ll['lat'] ?? latitude;
longitude = ll['lng'] ?? longitude;
final prefs = await SharedPreferences.getInstance();
int? userid = prefs.getInt('userId') ?? prefs.getInt('userid');
int? partnerid = prefs.getInt('partnerId') ?? prefs.getInt('partnerid');
int? shiftid = prefs.getInt('shiftId') ?? prefs.getInt('shiftid');
int? logid = prefs.getInt('logId') ?? prefs.getInt('logid');
int? breakid = prefs.getInt('breakId');
final startEpoch = prefs.getInt('break_start_epoch');
final savedBreakStart = prefs.getString('breakStart') ?? '';
// Ensure a rider log session exists (no GET calls)
if ((userid ?? 0) == 0 ||
(partnerid ?? 0) == 0 ||
(shiftid ?? 0) == 0 ||
(logid ?? 0) == 0) {
final ok = await _ensureLogSession();
if (ok) {
userid = prefs.getInt('userId') ?? prefs.getInt('userid');
partnerid = prefs.getInt('partnerId') ?? prefs.getInt('partnerid');
shiftid = prefs.getInt('shiftId') ?? prefs.getInt('shiftid');
logid = prefs.getInt('logId') ?? prefs.getInt('logid');
}
}
// Default missing optional IDs to 0; require userid, logid, breakid, start time
partnerid = partnerid ?? 0;
shiftid = shiftid ?? 0;
// If missing, default to zeros so we still hit API and get response for visibility
userid = userid ?? 0;
logid = logid ?? 0;
breakid = breakid ?? 0;
if (breakid == 0) {
// No break in progress; nothing to update.
// Remove any stale tracking data and exit gracefully.
await prefs.remove('break_start_epoch');
await prefs.remove('breakId');
debugPrint(
'[BREAK][UPDATE] Skipped endBreakAuto because breakId is 0/missing',
);
return true;
}
final now = DateTime.now();
final breakdate = _formatDateTimeFull(now);
final breakend = _formatTime(now);
final int startEpochVal =
startEpoch ?? DateTime.now().millisecondsSinceEpoch;
final startTime = DateTime.fromMillisecondsSinceEpoch(startEpochVal);
final duration = now.difference(startTime);
final breakhoursDouble = duration.inSeconds / 3600.0;
// Debug logs for terminal visibility
debugPrint(
'[BREAK][UPDATE] Fields: breakid=$breakid, logid=$logid, userid=$userid, partnerid=$partnerid, shiftid=$shiftid, breakend=$breakend, breakhours=$breakhoursDouble, lat=$latitude, lng=$longitude',
);
return await updateBreakCustom(
breakid: breakid,
logid: logid,
breakdate: breakdate,
userid: userid,
partnerid: partnerid,
shiftid: shiftid,
breakstart: savedBreakStart,
breakend: breakend,
breakhours: breakhoursDouble,
latitude: latitude,
longitude: longitude,
);
} catch (_) {
return false;
} finally {
_endBreakInFlight = false;
}
}
// Ensure rider log session by creating login if missing; persists logid/ids.
Future<bool> _ensureLogSession() async {
try {
if (_isEnsuringSession) return false;
_isEnsuringSession = true;
final prefs = await SharedPreferences.getInstance();
final int onduty = prefs.getInt('onduty') ?? 0;
if (onduty != 1) {
debugPrint('[RIDERLOG][ENSURE SESSION] Skipped because onduty=$onduty');
return false;
}
int? userid = prefs.getInt('userId') ?? prefs.getInt('userid');
int? partnerid = prefs.getInt('partnerId') ?? prefs.getInt('partnerid');
int? shiftid = prefs.getInt('shiftId') ?? prefs.getInt('shiftid');
int? logid = prefs.getInt('logId') ?? prefs.getInt('logid');
final int? tenantid = prefs.getInt('tenantid');
final int? locationid = prefs.getInt('locationid');
final int? applocationid = prefs.getInt('applocationid');
final String? userfcmtoken = prefs.getString('userfcmtoken');
if ((userid ?? 0) == 0) return false;
if ((partnerid ?? 0) == 0) return false;
if ((shiftid ?? 0) == 0) return false;
if ((logid ?? 0) > 0) return true;
final now = DateTime.now();
final iso = _formatDateTimeFull(now);
final loginTime = _formatTime(now);
// ✅ Check if there are active pickup to set status
final bool hasActivePickups = prefs.getBool('has_live_pickup') ?? false;
final String riderStatus = heartbeatStatus(hasActiveWork: hasActivePickups);
// Resolve rider display name (username) from prefs to include in log
String? username = prefs.getString('username');
username ??= prefs.getString('user_name');
if (username == null || username.trim().isEmpty) {
final first = prefs.getString('firstname') ?? '';
final last = prefs.getString('lastname') ?? '';
final combined = ('$first $last').trim();
if (combined.isNotEmpty) {
username = combined;
}
}
// Resolve current location for accurate login lat/lng
final loc = await _ensureLatLng('0', '0');
final payload = RiderLogin(
userid: userid,
partnerid: partnerid,
shiftid: shiftid,
logdate: iso.substring(0, 10),
login: loginTime,
latitude: loc['lat'] ?? '0',
longitude: loc['lng'] ?? '0',
onduty: 1,
status: riderStatus, // ✅ Add status field: "active" or "idle"
username: username,
tenantid: tenantid ?? 0,
locationid: locationid ?? 0,
applocationid: applocationid ?? 0,
userfcmtoken: userfcmtoken ?? '',
).toJson();
debugPrint('[RIDERLOG][CREATE LOGIN] Payload: $payload');
final resp = await _createProvider.createRiderLog(payload);
debugPrint(
'[RIDERLOG][CREATE LOGIN] Response: ${resp == null ? 'null' : resp.toString()}',
);
if (resp == null || resp.isEmpty) return false;
final det = (resp['details'] is Map<String, dynamic>)
? (resp['details'] as Map<String, dynamic>)
: resp;
final newLogId =
int.tryParse('${det['logid'] ?? 0}') ?? (det['logid'] as int? ?? 0);
if (newLogId > 0) {
await prefs.setInt('logId', newLogId);
await prefs.setInt('logid', newLogId);
return true;
}
return false;
} catch (_) {
return false;
} finally {
_isEnsuringSession = false;
}
}
// ---------------- Offline Queue Logic ----------------
static const String _offlineLogKey = 'offline_rider_logs';
bool _isFlushing = false;
/// Call this on app start or network restoration
Future<void> flushOfflineLogs() async {
if (_isFlushing) return;
_isFlushing = true;
try {
final prefs = await SharedPreferences.getInstance();
final List<String> queue = prefs.getStringList(_offlineLogKey) ?? [];
if (queue.isEmpty) return;
debugPrint(
'[RIDERLOG][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 url = item['url'] ?? '';
final Map<String, dynamic> payload = Map<String, dynamic>.from(
item['payload'] ?? {},
);
if (url.isEmpty || payload.isEmpty) continue;
// Determine type of log based on URL or payload structure if needed
// For now, we assume these are mostly createRiderLog calls from createLoginNow
// We can use _createProvider generic call
debugPrint('[RIDERLOG][OFFLINE] Retrying: $payload');
final resp = await _createProvider.createRiderLog(payload);
if (resp != null && resp.isNotEmpty) {
debugPrint('[RIDERLOG][OFFLINE] Success!');
anySuccess = true;
} else {
// Keep in queue if failed
remaining.add(itemStr);
}
} catch (e) {
debugPrint('[RIDERLOG][OFFLINE] Error processing item: $e');
remaining.add(itemStr); // Keep on error
}
}
if (anySuccess || remaining.length != queue.length) {
await prefs.setStringList(_offlineLogKey, remaining);
debugPrint(
'[RIDERLOG][OFFLINE] Flush complete. Remaining: ${remaining.length}',
);
}
} catch (e) {
debugPrint('[RIDERLOG][OFFLINE] Flush error: $e');
} finally {
_isFlushing = false;
}
}
Future<void> _saveToOfflineQueue(
String url,
Map<String, dynamic> payload,
) async {
try {
final prefs = await SharedPreferences.getInstance();
List<String> queue = prefs.getStringList(_offlineLogKey) ?? [];
final item = jsonEncode({
'url': url,
'payload': payload,
'timestamp': DateTime.now().millisecondsSinceEpoch,
});
queue.add(item);
// Cap the queue at 50 entries — keep the newest ones.
// Prevents unbounded growth on devices where the endpoint is unreachable.
const maxQueueSize = 50;
if (queue.length > maxQueueSize) {
queue = queue.sublist(queue.length - maxQueueSize);
debugPrint(
'[RIDERLOG][OFFLINE] Queue capped at $maxQueueSize entries.',
);
}
await prefs.setStringList(_offlineLogKey, queue);
debugPrint('[RIDERLOG][OFFLINE] Saved to queue. Total: ${queue.length}');
} catch (e) {
debugPrint('[RIDERLOG][OFFLINE] Error saving to queue: $e');
}
}
}
// 🔹 Helper Adapters
class _CreateRiderLogRequestCompat {
static dynamic fromRiderLogin(RiderLogin login) {
return login.toJson();
}
}
class _UpdateRiderLogRequestCompat {
static dynamic fromRiderUpdate(RiderUpdate update) {
return update.toJson();
}
}
class _BreakLogRequestCompat {
static dynamic fromRiderBreak(RiderBreak brk) {
return brk.toJson();
}
}