364 lines
13 KiB
Dart
364 lines
13 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'package:miler/helpers/http_overrides.dart';
|
|
import 'dart:isolate';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
|
import 'dart:math' as math;
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:miler/providers/Riderlog/riderlog_provider.dart';
|
|
import 'package:miler/background/backgroundservice.dart';
|
|
import 'package:geolocator/geolocator.dart';
|
|
import 'package:miler/data/device_telemetry.dart';
|
|
import 'package:miler/data/heartbeat.dart';
|
|
import 'package:miler/utils/kalman_filter.dart';
|
|
import 'package:miler/utils/mqtt_service.dart';
|
|
import 'package:miler/views/helpers/constants/mqtt_constants.dart';
|
|
|
|
class _BackgroundRiderLog {
|
|
static MilerKalmanFilter? _kf;
|
|
static DateTime? _lastUpdateTime;
|
|
|
|
static Future<Map<String, String>> _ensureLatLng() 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',
|
|
'status': 'unknown',
|
|
'accuracy': '0',
|
|
};
|
|
try {
|
|
// 1. Check if location services are enabled
|
|
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
|
if (!serviceEnabled) {
|
|
debugPrint('[BG_RIDER_LOG] Location services are disabled.');
|
|
result['status'] = 'disabled';
|
|
return result;
|
|
}
|
|
|
|
// 2. Check permissions
|
|
LocationPermission permission = await Geolocator.checkPermission();
|
|
if (permission == LocationPermission.denied) {
|
|
debugPrint('[BG_RIDER_LOG] Location permission denied.');
|
|
result['status'] = 'denied';
|
|
return result;
|
|
}
|
|
if (permission == LocationPermission.deniedForever) {
|
|
debugPrint('[BG_RIDER_LOG] Location permission denied forever.');
|
|
result['status'] = 'denied_forever';
|
|
return result;
|
|
}
|
|
|
|
result['status'] = 'enabled';
|
|
|
|
// 3. Get position (using non-deprecated LocationSettings + explicit timeout)
|
|
final 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 * (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; // Default background interval
|
|
_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),
|
|
'status': 'enabled',
|
|
'accuracy': pos.accuracy.toStringAsFixed(1),
|
|
};
|
|
} catch (e) {
|
|
debugPrint('[BG_RIDER_LOG] Error getting location: $e');
|
|
return result;
|
|
}
|
|
}
|
|
|
|
static String _two(int n) => n.toString().padLeft(2, '0');
|
|
static 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";
|
|
}
|
|
|
|
static String _formatTime(DateTime dt) {
|
|
final hh = _two(dt.hour);
|
|
final mm = _two(dt.minute);
|
|
final ss = _two(dt.second);
|
|
return "$hh:$mm:$ss";
|
|
}
|
|
|
|
static Future<void> createLoginNow() async {
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final int onduty = prefs.getInt('onduty') ?? 0;
|
|
if (onduty != 1) {
|
|
return;
|
|
}
|
|
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');
|
|
if ((userid ?? 0) == 0) return;
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
// ✅ 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 now = DateTime.now();
|
|
final iso = _formatDateTimeFull(now);
|
|
final loginTime = _formatTime(now);
|
|
final loc = await _ensureLatLng();
|
|
|
|
final int? tenantid = prefs.getInt('tenantid');
|
|
final int? locationid = prefs.getInt('locationid');
|
|
final int? applocationid = prefs.getInt('applocationid');
|
|
final String? userfcmtoken = prefs.getString('userfcmtoken');
|
|
|
|
final int? logid = prefs.getInt('logId') ?? prefs.getInt('logid');
|
|
final String orderId = prefs.getString('current_riding_order_id') ?? '';
|
|
|
|
// ── The handset's own state goes IN the heartbeat ──
|
|
//
|
|
// It used to be gathered further down, after this post had already gone,
|
|
// and published to MQTT alone — so the console reading `/miler/logs` had
|
|
// a position and nothing else, and drew an em dash against Battery,
|
|
// Connection, GPS Accuracy and Location Service on a rider whose phone
|
|
// was reporting all four.
|
|
//
|
|
// Read here, once, and spread into the payload below. The MQTT publish
|
|
// further down reuses the same values rather than asking again, so the
|
|
// two lanes cannot disagree about the same second. See [DeviceTelemetry].
|
|
final telemetry = await DeviceTelemetry.read(
|
|
// The fix lookup already resolved this to get a position at all;
|
|
// asking a second time can disagree with itself if the rider toggles
|
|
// the setting in between.
|
|
locationService: loc['status'],
|
|
);
|
|
|
|
final payload = {
|
|
"logid": logid ?? 0,
|
|
"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 ?? '',
|
|
"username": (username ?? '').trim(),
|
|
"orderid": orderId,
|
|
// What the dispatcher needs when a rider goes quiet: the battery, the
|
|
// signal, whether location is even on, and how good the last fix was.
|
|
...telemetry.toPayload(),
|
|
// Only a measured one — a `0` reads on the console as a perfect fix.
|
|
if ((loc['accuracy'] ?? '0') != '0') "accuracy": loc['accuracy'],
|
|
// This heartbeat is the *background* one — it is what keeps reporting
|
|
// while the app is not on screen, which is exactly the state the
|
|
// console's App State column exists to show.
|
|
"is_background": true,
|
|
};
|
|
|
|
final firstName = prefs.getString('firstname') ?? '';
|
|
final lastName = prefs.getString('lastname') ?? '';
|
|
if (firstName.trim().isNotEmpty) {
|
|
payload['firstname'] = firstName.trim();
|
|
}
|
|
if (lastName.trim().isNotEmpty) {
|
|
payload['lastname'] = lastName.trim();
|
|
}
|
|
|
|
|
|
final provider = CreateRiderLogProvider();
|
|
final resp = await provider.createRiderLog(payload);
|
|
|
|
if (resp == null || resp.isEmpty) return;
|
|
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);
|
|
await prefs.setInt('logid', newLogId);
|
|
|
|
// ✅ MQTT BACKGROUND PUBLISH ( Lane Split )
|
|
final mqttService = MilerMqttService();
|
|
if (!mqttService.isConnected) {
|
|
// Use a slightly different client ID for background to avoid kicking the main one off
|
|
await mqttService.connect();
|
|
}
|
|
|
|
if (mqttService.isConnected) {
|
|
// The same reading the heartbeat above carried — not a second one.
|
|
// Two reads a second apart can report two batteries, and the console
|
|
// then shows one number on the live tile and a different one in the
|
|
// trail it is drawn from.
|
|
final int batteryLevel = telemetry.battery ?? 0;
|
|
final bool isCharging = telemetry.isCharging ?? false;
|
|
final String connType = telemetry.connection ?? 'none';
|
|
|
|
// 1. Direct Telemetry (Feeding the /full API)
|
|
mqttService.publish('battery', '$batteryLevel%');
|
|
mqttService.publish('charging', isCharging ? 'yes' : 'no');
|
|
mqttService.publish('speed', loc['speed'] ?? '0');
|
|
mqttService.publish('connection', connType);
|
|
mqttService.publish('accuracy', loc['accuracy'] ?? '0');
|
|
|
|
// 2. Alert if Location is Off
|
|
final String locStatus = loc['status'] ?? 'unknown';
|
|
if (locStatus != 'enabled') {
|
|
mqttService.publish('alerts', {
|
|
'userid': userid,
|
|
'username': (username ?? '').trim(),
|
|
'event': 'location_turned_off',
|
|
'error_type': locStatus,
|
|
'battery': '$batteryLevel%',
|
|
'is_charging': isCharging,
|
|
'connection': connType,
|
|
'logdate': iso,
|
|
});
|
|
}
|
|
|
|
// 3. Low Battery Alert
|
|
if (batteryLevel < 15 && !isCharging) {
|
|
mqttService.publish('alerts', {
|
|
'userid': userid,
|
|
'username': (username ?? '').trim(),
|
|
'event': 'low_battery_warning',
|
|
'battery': '$batteryLevel%',
|
|
'logdate': iso,
|
|
});
|
|
}
|
|
|
|
// 4. Poor GPS Accuracy Alert
|
|
final double accuracy = double.tryParse(loc['accuracy'] ?? '0') ?? 0;
|
|
if (accuracy > 30) {
|
|
mqttService.publish('alerts', {
|
|
'userid': userid,
|
|
'username': (username ?? '').trim(),
|
|
'event': 'poor_gps_signal',
|
|
'accuracy': '${accuracy.toStringAsFixed(1)}m',
|
|
'logdate': iso,
|
|
});
|
|
}
|
|
|
|
// 5. Lane: Status
|
|
mqttService.updateStatus(
|
|
riderStatus == 'On_Pickup' ? 'Active' : MqttConstants.statusOnline,
|
|
);
|
|
|
|
// 6. Lane: Periodic Log (Comprehensive Snapshot)
|
|
mqttService.publishLog('rider_periodic_log', {
|
|
'userid': userid,
|
|
'username': username,
|
|
'logdate': iso,
|
|
'latitude': loc['lat'] ?? '0',
|
|
'longitude': loc['lng'] ?? '0',
|
|
'speed': loc['speed'] ?? '0',
|
|
'heading': loc['heading'] ?? '0',
|
|
'accuracy': loc['accuracy'] ?? '0',
|
|
'status': riderStatus,
|
|
'orderid': orderId,
|
|
'battery': '$batteryLevel%',
|
|
'is_charging': isCharging,
|
|
'connection': connType,
|
|
'location_service': locStatus,
|
|
'is_background': true,
|
|
});
|
|
}
|
|
} catch (e) {
|
|
// ignore background errors
|
|
}
|
|
}
|
|
}
|
|
|
|
class RiderLogTaskHandler extends TaskHandler {
|
|
Timer? _timer; // not used; plugin provides repeat callback, but keep safety
|
|
|
|
@override
|
|
Future<void> onStart(DateTime timestamp, SendPort? sendPort) async {
|
|
// No-op
|
|
}
|
|
|
|
@override
|
|
Future<void> onRepeatEvent(DateTime timestamp, SendPort? sendPort) async {
|
|
// 1. Rider Log (existing)
|
|
await _BackgroundRiderLog.createLoginNow();
|
|
|
|
// 2. Pickup Log (new)
|
|
await BackgroundCollectionLog.processActivePickups();
|
|
|
|
// 3. Auto Shift End (new)
|
|
await BackgroundCollectionLog.checkShiftEnd();
|
|
}
|
|
|
|
@override
|
|
Future<void> onDestroy(DateTime timestamp, SendPort? sendPort) async {
|
|
_timer?.cancel();
|
|
_timer = null;
|
|
}
|
|
}
|
|
|
|
@pragma('vm:entry-point')
|
|
void riderLogCallback() {
|
|
HttpOverrides.global = MyHttpOverrides();
|
|
FlutterForegroundTask.setTaskHandler(RiderLogTaskHandler());
|
|
}
|