Files
authsec_flutter/lib/screens/Attendance/attendance_service.dart
T
2026-09-30 09:13:45 +05:30

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 (_) {}
}
}