Background location tracking

This commit is contained in:
2026-03-09 22:33:35 +08:00
parent 1e1c7d9552
commit ccc1c62262
10 changed files with 371 additions and 127 deletions
+1 -1
View File
@@ -276,7 +276,7 @@ Future<void> main() async {
return;
}
// Initialize background location service (Workmanager)
// Initialize background location service (flutter_background_service)
if (!kIsWeb) {
try {
await initBackgroundLocationService().timeout(
+5 -3
View File
@@ -64,10 +64,12 @@ class WhereaboutsController {
if (userId == null) throw Exception('Not authenticated');
if (allow) {
// Ensure location permission before enabling
final granted = await ensureLocationPermission();
// Ensure foreground + background location permission before enabling
final granted = await ensureBackgroundLocationPermission();
if (!granted) {
throw Exception('Location permission is required for tracking');
throw Exception(
'Background location permission is required for tracking',
);
}
}
+68 -4
View File
@@ -24,6 +24,7 @@ import '../../providers/workforce_provider.dart';
import '../../theme/m3_motion.dart';
import '../../utils/app_time.dart';
import '../../utils/location_permission.dart';
import 'package:permission_handler/permission_handler.dart';
import '../../widgets/face_verification_overlay.dart';
import '../../utils/snackbar.dart';
import '../../widgets/gemini_animated_text_field.dart';
@@ -42,6 +43,10 @@ class _AttendanceScreenState extends ConsumerState<AttendanceScreen>
late TabController _tabController;
bool _fabMenuOpen = false;
// (moved into _CheckInTabState) local tracking state for optimistic UI updates
// bool _trackingLocal = false;
// bool _trackingSaving = false;
@override
void initState() {
super.initState();
@@ -261,6 +266,9 @@ class _CheckInTab extends ConsumerStatefulWidget {
class _CheckInTabState extends ConsumerState<_CheckInTab> {
bool _loading = false;
// local tracking state for optimistic UI updates (optimistic toggle in UI)
bool _trackingLocal = false;
bool _trackingSaving = false;
final _justCheckedIn = <String>{};
final _checkInLogIds = <String, String>{};
String? _overtimeLogId;
@@ -352,6 +360,17 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
final schedulesAsync = ref.watch(dutySchedulesProvider);
final logsAsync = ref.watch(attendanceLogsProvider);
final allowTracking = profile?.allowTracking ?? false;
// local state for optimistic switch update and to avoid flicker while the
// profile stream lags. Once the network request completes we keep the
// local value until the provider returns the same value.
bool effectiveTracking;
if (_trackingSaving) {
effectiveTracking = _trackingLocal;
} else if (_trackingLocal != allowTracking) {
effectiveTracking = _trackingLocal;
} else {
effectiveTracking = allowTracking;
}
if (profile == null) {
return const Center(child: CircularProgressIndicator());
@@ -409,11 +428,14 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
),
),
Switch(
value: allowTracking,
value: effectiveTracking,
onChanged: (v) async {
if (_trackingSaving) return; // ignore while pending
if (v) {
final granted = await ensureLocationPermission();
if (!granted) {
// first ensure foreground & background location perms
final grantedFg = await ensureLocationPermission();
if (!grantedFg) {
if (context.mounted) {
showWarningSnackBar(
context,
@@ -422,8 +444,50 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
}
return;
}
final grantedBg =
await ensureBackgroundLocationPermission();
if (!grantedBg) {
// if permanently denied, open settings so the user can
// manually grant the permission; otherwise just warn.
if (await Permission
.locationAlways
.isPermanentlyDenied ||
await Permission.location.isPermanentlyDenied) {
openAppSettings();
}
if (context.mounted) {
showWarningSnackBar(
context,
'Background location permission is required.',
);
}
return;
}
}
// optimistically flip
setState(() {
_trackingLocal = v;
_trackingSaving = true;
});
try {
await ref
.read(whereaboutsControllerProvider)
.setTracking(v);
} catch (e) {
if (context.mounted) {
showWarningSnackBar(context, e.toString());
}
// revert to actual stored value
setState(() {
_trackingLocal = allowTracking;
});
} finally {
setState(() {
_trackingSaving = false;
});
}
ref.read(whereaboutsControllerProvider).setTracking(v);
},
),
],
+171 -61
View File
@@ -1,78 +1,188 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_background_service/flutter_background_service.dart';
import 'package:geolocator/geolocator.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:workmanager/workmanager.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
/// Unique task name for the background location update.
const _taskName = 'com.tasq.backgroundLocationUpdate';
/// Interval between background location updates.
const _updateInterval = Duration(minutes: 15);
/// Top-level callback required by Workmanager. Must be a top-level or static
/// function.
@pragma('vm:entry-point')
void callbackDispatcher() {
Workmanager().executeTask((task, inputData) async {
/// Whether the Supabase client has been initialised inside the background
/// isolate. We guard against double-init which would throw.
bool _bgIsolateInitialised = false;
/// Initialise Supabase inside the background isolate so we can call RPCs.
/// Safe to call multiple times only the first call performs real work.
Future<SupabaseClient?> _ensureBgSupabase() async {
if (_bgIsolateInitialised) {
try {
// Re-initialize Supabase in the isolate
await dotenv.load();
final url = dotenv.env['SUPABASE_URL'] ?? '';
final anonKey = dotenv.env['SUPABASE_ANON_KEY'] ?? '';
if (url.isEmpty || anonKey.isEmpty) return Future.value(true);
await Supabase.initialize(url: url, anonKey: anonKey);
final client = Supabase.instance.client;
// Must have an active session
final session = client.auth.currentSession;
if (session == null) return Future.value(true);
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) return Future.value(true);
final permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
return Future.value(true);
}
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
),
);
await client.rpc(
'update_live_position',
params: {'p_lat': position.latitude, 'p_lng': position.longitude},
);
} catch (e) {
debugPrint('Background location update error: $e');
return Supabase.instance.client;
} catch (_) {
_bgIsolateInitialised = false;
}
return Future.value(true);
}
try {
await dotenv.load();
} catch (_) {
// .env may already be loaded
}
final url = dotenv.env['SUPABASE_URL'] ?? '';
final anonKey = dotenv.env['SUPABASE_ANON_KEY'] ?? '';
if (url.isEmpty || anonKey.isEmpty) return null;
try {
await Supabase.initialize(url: url, anonKey: anonKey);
} catch (_) {
// Already initialised that's fine
}
_bgIsolateInitialised = true;
return Supabase.instance.client;
}
/// Send location update to Supabase via the `update_live_position` RPC.
Future<void> _sendLocationUpdate() async {
try {
final client = await _ensureBgSupabase();
if (client == null) {
debugPrint('[BgLoc] Supabase client unavailable');
return;
}
final session = client.auth.currentSession;
if (session == null) {
debugPrint('[BgLoc] No active session');
return;
}
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
debugPrint('[BgLoc] Location service disabled');
return;
}
final permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
debugPrint('[BgLoc] Location permission denied');
return;
}
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(accuracy: LocationAccuracy.high),
);
await client.rpc(
'update_live_position',
params: {'p_lat': position.latitude, 'p_lng': position.longitude},
);
debugPrint(
'[BgLoc] Position updated: ${position.latitude}, ${position.longitude}',
);
} catch (e) {
debugPrint('[BgLoc] Error: $e');
}
}
// ---------------------------------------------------------------------------
// Background service entry point (runs in a separate isolate on Android).
// MUST be a PUBLIC top-level function — PluginUtilities.getCallbackHandle()
// returns null for private (underscore-prefixed) functions.
// ---------------------------------------------------------------------------
/// Top-level entry point invoked by flutter_background_service in a new isolate.
@pragma('vm:entry-point')
Future<void> onBackgroundServiceStart(ServiceInstance service) async {
// Required for platform channel access (Geolocator, Supabase) in an isolate.
WidgetsFlutterBinding.ensureInitialized();
debugPrint('[BgLoc] Background service started');
// Send initial location immediately on service start.
await _sendLocationUpdate();
// Periodic 15-minute timer for location updates.
Timer.periodic(_updateInterval, (_) async {
debugPrint('[BgLoc] Periodic update triggered');
await _sendLocationUpdate();
});
// Listen for stop command from the foreground.
service.on('stop').listen((_) {
debugPrint('[BgLoc] Stop command received');
service.stopSelf();
});
}
/// Initialize Workmanager and register periodic background location task.
Future<void> initBackgroundLocationService() async {
if (kIsWeb) return; // Workmanager is not supported on web.
await Workmanager().initialize(callbackDispatcher);
/// iOS background entry point — must be a PUBLIC top-level function.
@pragma('vm:entry-point')
Future<bool> onIosBackground(ServiceInstance service) async {
WidgetsFlutterBinding.ensureInitialized();
await _sendLocationUpdate();
return true;
}
/// Register a periodic task to report location every ~15 minutes
/// (Android minimum for periodic Workmanager tasks).
Future<void> startBackgroundLocationUpdates() async {
if (kIsWeb) return; // Workmanager is not supported on web.
await Workmanager().registerPeriodicTask(
_taskName,
_taskName,
frequency: const Duration(minutes: 15),
constraints: Constraints(networkType: NetworkType.connected),
existingWorkPolicy: ExistingPeriodicWorkPolicy.keep,
// ---------------------------------------------------------------------------
// Public API same function signatures as the previous implementation.
// ---------------------------------------------------------------------------
/// Initialise the background service plugin. Call once at app startup.
Future<void> initBackgroundLocationService() async {
if (kIsWeb) return;
final service = FlutterBackgroundService();
await service.configure(
iosConfiguration: IosConfiguration(
autoStart: false,
onForeground: onBackgroundServiceStart,
onBackground: onIosBackground,
),
androidConfiguration: AndroidConfiguration(
onStart: onBackgroundServiceStart,
isForegroundMode: true,
autoStart: false,
autoStartOnBoot: true,
// Leave notificationChannelId null so the plugin will create the
// default "FOREGROUND_DEFAULT" channel for us. Passing a non-null
// value requires us to create the channel ourselves first, otherwise
// the service crashes on API&nbsp;>=26 when startForeground is called.
notificationChannelId: null,
initialNotificationTitle: 'TasQ',
initialNotificationContent: 'Location tracking is active',
foregroundServiceNotificationId: 7749,
foregroundServiceTypes: [AndroidForegroundType.location],
),
);
}
/// Cancel the periodic background location task.
Future<void> stopBackgroundLocationUpdates() async {
if (kIsWeb) return; // Workmanager is not supported on web.
await Workmanager().cancelByUniqueName(_taskName);
/// Start the background location service.
Future<void> startBackgroundLocationUpdates() async {
if (kIsWeb) return;
final service = FlutterBackgroundService();
final running = await service.isRunning();
if (running) return;
await service.startService();
}
/// Stop the background location service.
Future<void> stopBackgroundLocationUpdates() async {
if (kIsWeb) return;
final service = FlutterBackgroundService();
final running = await service.isRunning();
if (!running) return;
service.invoke('stop');
}
/// Check whether the background location service is currently running.
Future<bool> isBackgroundLocationRunning() async {
if (kIsWeb) return false;
return FlutterBackgroundService().isRunning();
}
+34 -1
View File
@@ -27,6 +27,39 @@ Future<bool> ensureLocationPermission() async {
final newStatus = await requestLocationPermission();
return newStatus.isGranted;
}
// permanently denied user must open settings
// permanently denied send user to settings so they can enable it
await openAppSettings();
return false;
}
/// Ensures "Allow all the time" (background) location permission is granted.
///
/// This is required for `background_locator_2` to track location when the app
/// is backgrounded or killed. On Android 10+ this triggers the system prompt
/// for `ACCESS_BACKGROUND_LOCATION`. Must be called **after** the foreground
/// location permission (`Permission.location`) has been granted.
Future<bool> ensureBackgroundLocationPermission() async {
if (kIsWeb) return true;
// Foreground location must be granted first; this helper already opens
// settings if the user permanently denied the fine/coarse prompt.
final fgGranted = await ensureLocationPermission();
if (!fgGranted) return false;
final bgStatus = await Permission.locationAlways.status;
if (bgStatus.isGranted) return true;
if (bgStatus.isDenied || bgStatus.isRestricted || bgStatus.isLimited) {
final newStatus = await Permission.locationAlways.request();
if (newStatus.isGranted) return true;
if (newStatus.isPermanentlyDenied) {
// user chose "Don't ask again" send them to settings
await openAppSettings();
}
return false;
}
// Permanently denied user must open settings
await openAppSettings();
return false;
}