285 lines
9.5 KiB
Dart
285 lines
9.5 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
|
import 'package:geolocator/geolocator.dart';
|
|
import 'package:permission_handler/permission_handler.dart' as ph;
|
|
|
|
import 'attendance_math.dart';
|
|
import 'attendance_storage.dart';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Background attendance tracking (wake every 5 min -> one GPS fix -> sleep).
|
|
//
|
|
// The task handler runs in a background isolate. It reads the open session id
|
|
// that the UI saved, takes one fix, applies the accuracy/drift filters and
|
|
// stores accepted dots in sqflite. It also pings the UI with live totals.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Entry point for the background isolate. Must stay top-level.
|
|
@pragma('vm:entry-point')
|
|
void attendanceCallback() {
|
|
FlutterForegroundTask.setTaskHandler(AttendanceTaskHandler());
|
|
}
|
|
|
|
class AttendanceTaskHandler extends TaskHandler {
|
|
String? _sessionId;
|
|
|
|
@override
|
|
Future<void> onStart(DateTime timestamp, TaskStarter starter) async {
|
|
try {
|
|
_sessionId =
|
|
await FlutterForegroundTask.getData(key: 'attendance_session_id')
|
|
as String?;
|
|
} catch (_) {
|
|
_sessionId = null;
|
|
}
|
|
// Fallback: SharedPreferences is visible from this isolate too.
|
|
_sessionId ??= await AttendanceStorage.getActiveSession();
|
|
}
|
|
|
|
@override
|
|
void onRepeatEvent(DateTime timestamp) async {
|
|
final String? sessionId = _sessionId ?? await _refreshSessionId();
|
|
if (sessionId == null) return; // punched out already, nothing to do
|
|
|
|
try {
|
|
final Position pos = await Geolocator.getCurrentPosition(
|
|
locationSettings: LocationSettings(
|
|
accuracy: LocationAccuracy.high,
|
|
timeLimit: kFixTimeout,
|
|
),
|
|
);
|
|
|
|
final List<Map<String, dynamic>> existing =
|
|
await AttendanceStorage.getPoints(sessionId);
|
|
final bool isFirst = existing.isEmpty;
|
|
double distanceFromLast = 0;
|
|
if (!isFirst) {
|
|
final last = existing.last;
|
|
distanceFromLast = haversineMeters(
|
|
(last['lat'] as num).toDouble(),
|
|
(last['lng'] as num).toDouble(),
|
|
pos.latitude,
|
|
pos.longitude,
|
|
);
|
|
}
|
|
|
|
final bool keep = shouldStorePoint(
|
|
accuracy: pos.accuracy,
|
|
distanceFromLastMeters: distanceFromLast,
|
|
isFirstPoint: isFirst,
|
|
);
|
|
|
|
if (keep) {
|
|
await AttendanceStorage.insertPoint(
|
|
sessionId: sessionId,
|
|
lat: pos.latitude,
|
|
lng: pos.longitude,
|
|
accuracy: pos.accuracy,
|
|
isMocked: pos.isMocked,
|
|
timestamp: pos.timestamp,
|
|
);
|
|
}
|
|
|
|
// Live totals for the open punch page (harmless if nobody listens).
|
|
final updated = await AttendanceStorage.getPoints(sessionId);
|
|
final double km = _totalKm(updated);
|
|
FlutterForegroundTask.sendDataToMain({
|
|
'sessionId': sessionId,
|
|
'points': updated.length,
|
|
'km': km,
|
|
});
|
|
try {
|
|
await FlutterForegroundTask.updateService(
|
|
notificationTitle: 'Duty tracking ON',
|
|
notificationText:
|
|
'${updated.length} points • ${km.toStringAsFixed(2)} km',
|
|
);
|
|
} catch (_) {}
|
|
} catch (_) {
|
|
// GPS off / timeout / no fix this round - just wait for next 5-min tick.
|
|
}
|
|
}
|
|
|
|
Future<String?> _refreshSessionId() async {
|
|
_sessionId = await AttendanceStorage.getActiveSession();
|
|
return _sessionId;
|
|
}
|
|
|
|
@override
|
|
Future<void> onDestroy(DateTime timestamp, bool isTimeout) async {}
|
|
|
|
double _totalKm(List<Map<String, dynamic>> points) {
|
|
double meters = 0;
|
|
for (int i = 1; i < points.length; i++) {
|
|
meters += haversineMeters(
|
|
(points[i - 1]['lat'] as num).toDouble(),
|
|
(points[i - 1]['lng'] as num).toDouble(),
|
|
(points[i]['lat'] as num).toDouble(),
|
|
(points[i]['lng'] as num).toDouble(),
|
|
);
|
|
}
|
|
return meters / 1000.0;
|
|
}
|
|
}
|
|
|
|
/// Small result for the permission flow so the UI can explain, not just fail.
|
|
class PermissionResult {
|
|
final bool ok;
|
|
final String message;
|
|
PermissionResult(this.ok, this.message);
|
|
}
|
|
|
|
class AttendanceService {
|
|
static bool _initialized = false;
|
|
|
|
/// Call once from main() before runApp().
|
|
static void initService() {
|
|
if (_initialized) return;
|
|
FlutterForegroundTask.init(
|
|
androidNotificationOptions: AndroidNotificationOptions(
|
|
channelId: 'attendance_tracking',
|
|
channelName: 'Duty tracking',
|
|
channelDescription:
|
|
'Shows while your duty location is being recorded.',
|
|
),
|
|
iosNotificationOptions: const IOSNotificationOptions(
|
|
showNotification: true,
|
|
),
|
|
foregroundTaskOptions: ForegroundTaskOptions(
|
|
eventAction: ForegroundTaskEventAction.repeat(
|
|
kFixInterval.inMilliseconds,
|
|
),
|
|
allowWakeLock: true,
|
|
allowWifiLock: false,
|
|
autoRunOnBoot: false,
|
|
// Swiping the app away from recents must NOT stop duty tracking.
|
|
stopWithTask: false,
|
|
),
|
|
);
|
|
_initialized = true;
|
|
}
|
|
|
|
/// Asks (in order): location -> always/background -> notifications + GPS on.
|
|
static Future<PermissionResult> ensurePermissions() async {
|
|
final bool serviceOn = await Geolocator.isLocationServiceEnabled();
|
|
if (!serviceOn) {
|
|
return PermissionResult(
|
|
false, 'Please switch ON location (GPS) and try again.');
|
|
}
|
|
|
|
ph.PermissionStatus loc = await ph.Permission.location.request();
|
|
if (!loc.isGranted) {
|
|
return PermissionResult(
|
|
false, 'Location permission is needed for punch in.');
|
|
}
|
|
|
|
// Android 10+: background needs its own grant (may open Settings).
|
|
ph.PermissionStatus always = await ph.Permission.locationAlways.request();
|
|
if (!always.isGranted) {
|
|
return PermissionResult(true,
|
|
'Background not granted - tracking works only while the app is open.');
|
|
}
|
|
|
|
await ph.Permission.notification.request(); // for the tracking notice
|
|
return PermissionResult(true, ' granted');
|
|
}
|
|
|
|
static Future<bool> get isPunchedIn async {
|
|
if (!await FlutterForegroundTask.isRunningService) {
|
|
// Service dead but prefs say open (reboot/crash) -> still show open.
|
|
return await AttendanceStorage.getActiveSession() != null;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/// Starts a duty session + background tracking. Returns the session id.
|
|
static Future<String> punchIn() async {
|
|
final String sessionId =
|
|
'SES-${DateTime.now().millisecondsSinceEpoch}';
|
|
final DateTime now = DateTime.now();
|
|
await AttendanceStorage.createSession(sessionId, now);
|
|
await AttendanceStorage.setActiveSession(sessionId);
|
|
await FlutterForegroundTask.saveData(
|
|
key: 'attendance_session_id', value: sessionId);
|
|
|
|
await FlutterForegroundTask.startService(
|
|
serviceId: 1001,
|
|
serviceTypes: [ForegroundServiceTypes.location],
|
|
notificationTitle: 'Duty tracking ON',
|
|
notificationText: 'Punched in at ${_hhmm(now)} • recording location',
|
|
callback: attendanceCallback,
|
|
);
|
|
return sessionId;
|
|
}
|
|
|
|
/// Stops tracking and closes the session. Returns a summary for the UI.
|
|
static Future<Map<String, dynamic>> punchOut() async {
|
|
final String? sessionId = await AttendanceStorage.getActiveSession();
|
|
try {
|
|
await FlutterForegroundTask.stopService();
|
|
} catch (_) {}
|
|
await FlutterForegroundTask.clearAllData();
|
|
await AttendanceStorage.clearActiveSession();
|
|
|
|
if (sessionId == null) {
|
|
return {'points': 0, 'km': 0.0};
|
|
}
|
|
final List<Map<String, dynamic>> points =
|
|
await AttendanceStorage.getPoints(sessionId);
|
|
double meters = 0;
|
|
for (int i = 1; i < points.length; i++) {
|
|
meters += haversineMeters(
|
|
(points[i - 1]['lat'] as num).toDouble(),
|
|
(points[i - 1]['lng'] as num).toDouble(),
|
|
(points[i]['lat'] as num).toDouble(),
|
|
(points[i]['lng'] as num).toDouble(),
|
|
);
|
|
}
|
|
final double km = meters / 1000.0;
|
|
final Map<String, dynamic>? session =
|
|
await AttendanceStorage.getSession(sessionId);
|
|
await AttendanceStorage.closeSession(sessionId, DateTime.now(), km);
|
|
return {
|
|
'sessionId': sessionId,
|
|
'punchIn': session?['punch_in'],
|
|
'points': points.length,
|
|
'km': km,
|
|
};
|
|
}
|
|
|
|
static String _hhmm(DateTime t) =>
|
|
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
|
|
|
|
/// Nudge to exempt the app from battery optimization (OEMs kill trackers).
|
|
static Future<void> maybeAskBatteryExemption(BuildContext context) async {
|
|
try {
|
|
final bool ignoring =
|
|
await FlutterForegroundTask.isIgnoringBatteryOptimizations;
|
|
if (ignoring || !context.mounted) return;
|
|
final bool? go = await showDialog<bool>(
|
|
context: context,
|
|
builder: (c) => AlertDialog(
|
|
title: const Text('Keep tracking running'),
|
|
content: const Text(
|
|
'Xiaomi, Oppo, Vivo and Samsung phones stop background tracking '
|
|
'to save battery. Allow "Unrestricted" battery use for this app '
|
|
'so duty tracking keeps working when the screen is off?'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(c).pop(false),
|
|
child: const Text('Later')),
|
|
TextButton(
|
|
onPressed: () => Navigator.of(c).pop(true),
|
|
child: const Text('Open settings')),
|
|
],
|
|
),
|
|
);
|
|
if (go == true) {
|
|
await FlutterForegroundTask.requestIgnoreBatteryOptimization();
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
}
|