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 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> 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 _refreshSessionId() async { _sessionId = await AttendanceStorage.getActiveSession(); return _sessionId; } @override Future onDestroy(DateTime timestamp, bool isTimeout) async {} double _totalKm(List> 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 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 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 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> 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> 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? 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 maybeAskBatteryExemption(BuildContext context) async { try { final bool ignoring = await FlutterForegroundTask.isIgnoringBatteryOptimizations; if (ignoring || !context.mounted) return; final bool? go = await showDialog( 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 (_) {} } }