Background location tracking

This commit is contained in:
Marc Rejohn Castillano 2026-03-09 22:33:35 +08:00
parent 1e1c7d9552
commit ccc1c62262
10 changed files with 371 additions and 127 deletions

View File

@ -1,6 +1,12 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.INTERNET" />
<!-- Required on Android 13+ to post notifications. Without this the system
will automatically block notifications and the user cannot enable them
@ -57,6 +63,12 @@
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="tasq_custom_sound_channel_3" />
<!-- Override the plugin's service to add foregroundServiceType (required Android 14+) -->
<service
android:name="id.flutter.flutter_background_service.BackgroundService"
android:foregroundServiceType="location"
tools:replace="android:foregroundServiceType" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
android:src="@mipmap/ic_launcher"
android:gravity="center" />

View File

@ -2,6 +2,7 @@ import com.android.build.gradle.LibraryExtension
import org.gradle.api.tasks.Delete
import com.android.build.gradle.BaseExtension
import org.gradle.api.JavaVersion
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
allprojects {
repositories {
@ -20,6 +21,46 @@ subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
// Force compileSdk, Java 17, and Kotlin JVM target 17 on all library subprojects.
// Must use the `subprojects {}` block (not `subprojects.forEach`) so the
// afterEvaluate callback is registered *before* evaluationDependsOn triggers.
subprojects {
afterEvaluate {
plugins.withId("com.android.library") {
val androidExt = extensions.findByName("android") as? BaseExtension
if (androidExt != null) {
try {
androidExt.compileOptions.sourceCompatibility = JavaVersion.VERSION_17
androidExt.compileOptions.targetCompatibility = JavaVersion.VERSION_17
} catch (_: Throwable) {}
try {
val sdkNum = androidExt.compileSdkVersion?.removePrefix("android-")?.toIntOrNull() ?: 0
if (sdkNum < 36) {
androidExt.compileSdkVersion("android-36")
}
} catch (_: Throwable) {}
}
// Align Kotlin JVM target with Java 17 to avoid mismatch errors
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
compilerOptions.jvmTarget.set(JvmTarget.JVM_17)
}
}
plugins.withId("com.android.application") {
val androidExt = extensions.findByName("android") as? BaseExtension
if (androidExt != null) {
try {
androidExt.compileOptions.sourceCompatibility = JavaVersion.VERSION_17
androidExt.compileOptions.targetCompatibility = JavaVersion.VERSION_17
} catch (_: Throwable) {}
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
compilerOptions.jvmTarget.set(JvmTarget.JVM_17)
}
}
}
}
subprojects {
project.evaluationDependsOn(":app")
}
@ -46,29 +87,6 @@ subprojects.forEach { project ->
}
// Ensure Kotlin JVM target matches Java compatibility to avoid mismatches in third-party modules.
// Align Java compile options to Java 17 for Android modules so Kotlin JVM target mismatch is avoided.
subprojects.forEach { project ->
project.plugins.withId("com.android.library") {
val androidExt = project.extensions.findByName("android") as? BaseExtension
if (androidExt != null) {
try {
androidExt.compileOptions.sourceCompatibility = JavaVersion.VERSION_17
androidExt.compileOptions.targetCompatibility = JavaVersion.VERSION_17
} catch (_: Throwable) {}
}
}
project.plugins.withId("com.android.application") {
val androidExt = project.extensions.findByName("android") as? BaseExtension
if (androidExt != null) {
try {
androidExt.compileOptions.sourceCompatibility = JavaVersion.VERSION_17
androidExt.compileOptions.targetCompatibility = JavaVersion.VERSION_17
} catch (_: Throwable) {}
}
}
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

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(

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',
);
}
}

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);
},
),
],

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();
}

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;
}

View File

@ -502,6 +502,38 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_background_service:
dependency: "direct main"
description:
name: flutter_background_service
sha256: "70a1c185b1fa1a44f8f14ecd6c86f6e50366e3562f00b2fa5a54df39b3324d3d"
url: "https://pub.dev"
source: hosted
version: "5.1.0"
flutter_background_service_android:
dependency: "direct main"
description:
name: flutter_background_service_android
sha256: ca0793d4cd19f1e194a130918401a3d0b1076c81236f7273458ae96987944a87
url: "https://pub.dev"
source: hosted
version: "6.3.1"
flutter_background_service_ios:
dependency: transitive
description:
name: flutter_background_service_ios
sha256: "6037ffd45c4d019dab0975c7feb1d31012dd697e25edc05505a4a9b0c7dc9fba"
url: "https://pub.dev"
source: hosted
version: "5.0.3"
flutter_background_service_platform_interface:
dependency: transitive
description:
name: flutter_background_service_platform_interface
sha256: ca74aa95789a8304f4d3f57f07ba404faa86bed6e415f83e8edea6ad8b904a41
url: "https://pub.dev"
source: hosted
version: "5.1.2"
flutter_colorpicker:
dependency: transitive
description:
@ -1834,38 +1866,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.0"
workmanager:
dependency: "direct main"
description:
name: workmanager
sha256: "065673b2a465865183093806925419d311a9a5e0995aa74ccf8920fd695e2d10"
url: "https://pub.dev"
source: hosted
version: "0.9.0+3"
workmanager_android:
dependency: transitive
description:
name: workmanager_android
sha256: "9ae744db4ef891f5fcd2fb8671fccc712f4f96489a487a1411e0c8675e5e8cb7"
url: "https://pub.dev"
source: hosted
version: "0.9.0+2"
workmanager_apple:
dependency: transitive
description:
name: workmanager_apple
sha256: "1cc12ae3cbf5535e72f7ba4fde0c12dd11b757caf493a28e22d684052701f2ca"
url: "https://pub.dev"
source: hosted
version: "0.9.1+2"
workmanager_platform_interface:
dependency: transitive
description:
name: workmanager_platform_interface
sha256: f40422f10b970c67abb84230b44da22b075147637532ac501729256fcea10a47
url: "https://pub.dev"
source: hosted
version: "0.9.1+1"
xdg_directories:
dependency: transitive
description:

View File

@ -40,7 +40,8 @@ dependencies:
fl_chart: ^0.70.2
google_generative_ai: ^0.4.0
http: ^1.2.0
workmanager: ^0.9.0
flutter_background_service: ^5.0.12
flutter_background_service_android: ^6.2.7
intl: ^0.20.2
image_picker: ^1.1.2
flutter_liveness_check: ^1.0.3