Background location tracking
This commit is contained in:
parent
1e1c7d9552
commit
ccc1c62262
|
|
@ -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_FINE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_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" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<!-- Required on Android 13+ to post notifications. Without this the system
|
<!-- Required on Android 13+ to post notifications. Without this the system
|
||||||
will automatically block notifications and the user cannot enable them
|
will automatically block notifications and the user cannot enable them
|
||||||
|
|
@ -57,6 +63,12 @@
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||||
android:value="tasq_custom_sound_channel_3" />
|
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>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility and
|
https://developer.android.com/training/package-visibility and
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
@ -2,6 +2,7 @@ import com.android.build.gradle.LibraryExtension
|
||||||
import org.gradle.api.tasks.Delete
|
import org.gradle.api.tasks.Delete
|
||||||
import com.android.build.gradle.BaseExtension
|
import com.android.build.gradle.BaseExtension
|
||||||
import org.gradle.api.JavaVersion
|
import org.gradle.api.JavaVersion
|
||||||
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
allprojects {
|
allprojects {
|
||||||
repositories {
|
repositories {
|
||||||
|
|
@ -20,6 +21,46 @@ subprojects {
|
||||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
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 {
|
subprojects {
|
||||||
project.evaluationDependsOn(":app")
|
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") {
|
tasks.register<Delete>("clean") {
|
||||||
delete(rootProject.layout.buildDirectory)
|
delete(rootProject.layout.buildDirectory)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -276,7 +276,7 @@ Future<void> main() async {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize background location service (Workmanager)
|
// Initialize background location service (flutter_background_service)
|
||||||
if (!kIsWeb) {
|
if (!kIsWeb) {
|
||||||
try {
|
try {
|
||||||
await initBackgroundLocationService().timeout(
|
await initBackgroundLocationService().timeout(
|
||||||
|
|
|
||||||
|
|
@ -64,10 +64,12 @@ class WhereaboutsController {
|
||||||
if (userId == null) throw Exception('Not authenticated');
|
if (userId == null) throw Exception('Not authenticated');
|
||||||
|
|
||||||
if (allow) {
|
if (allow) {
|
||||||
// Ensure location permission before enabling
|
// Ensure foreground + background location permission before enabling
|
||||||
final granted = await ensureLocationPermission();
|
final granted = await ensureBackgroundLocationPermission();
|
||||||
if (!granted) {
|
if (!granted) {
|
||||||
throw Exception('Location permission is required for tracking');
|
throw Exception(
|
||||||
|
'Background location permission is required for tracking',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import '../../providers/workforce_provider.dart';
|
||||||
import '../../theme/m3_motion.dart';
|
import '../../theme/m3_motion.dart';
|
||||||
import '../../utils/app_time.dart';
|
import '../../utils/app_time.dart';
|
||||||
import '../../utils/location_permission.dart';
|
import '../../utils/location_permission.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import '../../widgets/face_verification_overlay.dart';
|
import '../../widgets/face_verification_overlay.dart';
|
||||||
import '../../utils/snackbar.dart';
|
import '../../utils/snackbar.dart';
|
||||||
import '../../widgets/gemini_animated_text_field.dart';
|
import '../../widgets/gemini_animated_text_field.dart';
|
||||||
|
|
@ -42,6 +43,10 @@ class _AttendanceScreenState extends ConsumerState<AttendanceScreen>
|
||||||
late TabController _tabController;
|
late TabController _tabController;
|
||||||
bool _fabMenuOpen = false;
|
bool _fabMenuOpen = false;
|
||||||
|
|
||||||
|
// (moved into _CheckInTabState) local tracking state for optimistic UI updates
|
||||||
|
// bool _trackingLocal = false;
|
||||||
|
// bool _trackingSaving = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
@ -261,6 +266,9 @@ class _CheckInTab extends ConsumerStatefulWidget {
|
||||||
|
|
||||||
class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
|
// local tracking state for optimistic UI updates (optimistic toggle in UI)
|
||||||
|
bool _trackingLocal = false;
|
||||||
|
bool _trackingSaving = false;
|
||||||
final _justCheckedIn = <String>{};
|
final _justCheckedIn = <String>{};
|
||||||
final _checkInLogIds = <String, String>{};
|
final _checkInLogIds = <String, String>{};
|
||||||
String? _overtimeLogId;
|
String? _overtimeLogId;
|
||||||
|
|
@ -352,6 +360,17 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||||
final schedulesAsync = ref.watch(dutySchedulesProvider);
|
final schedulesAsync = ref.watch(dutySchedulesProvider);
|
||||||
final logsAsync = ref.watch(attendanceLogsProvider);
|
final logsAsync = ref.watch(attendanceLogsProvider);
|
||||||
final allowTracking = profile?.allowTracking ?? false;
|
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) {
|
if (profile == null) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
|
@ -409,11 +428,14 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Switch(
|
Switch(
|
||||||
value: allowTracking,
|
value: effectiveTracking,
|
||||||
onChanged: (v) async {
|
onChanged: (v) async {
|
||||||
|
if (_trackingSaving) return; // ignore while pending
|
||||||
|
|
||||||
if (v) {
|
if (v) {
|
||||||
final granted = await ensureLocationPermission();
|
// first ensure foreground & background location perms
|
||||||
if (!granted) {
|
final grantedFg = await ensureLocationPermission();
|
||||||
|
if (!grantedFg) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
showWarningSnackBar(
|
showWarningSnackBar(
|
||||||
context,
|
context,
|
||||||
|
|
@ -422,8 +444,50 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||||
}
|
}
|
||||||
return;
|
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);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,78 +1,188 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
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:geolocator/geolocator.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
import 'package:workmanager/workmanager.dart';
|
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
|
|
||||||
/// Unique task name for the background location update.
|
/// Interval between background location updates.
|
||||||
const _taskName = 'com.tasq.backgroundLocationUpdate';
|
const _updateInterval = Duration(minutes: 15);
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
return Supabase.instance.client;
|
||||||
|
} catch (_) {
|
||||||
|
_bgIsolateInitialised = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 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 {
|
|
||||||
try {
|
try {
|
||||||
// Re-initialize Supabase in the isolate
|
|
||||||
await dotenv.load();
|
await dotenv.load();
|
||||||
|
} catch (_) {
|
||||||
|
// .env may already be loaded
|
||||||
|
}
|
||||||
|
|
||||||
final url = dotenv.env['SUPABASE_URL'] ?? '';
|
final url = dotenv.env['SUPABASE_URL'] ?? '';
|
||||||
final anonKey = dotenv.env['SUPABASE_ANON_KEY'] ?? '';
|
final anonKey = dotenv.env['SUPABASE_ANON_KEY'] ?? '';
|
||||||
if (url.isEmpty || anonKey.isEmpty) return Future.value(true);
|
if (url.isEmpty || anonKey.isEmpty) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
await Supabase.initialize(url: url, anonKey: anonKey);
|
await Supabase.initialize(url: url, anonKey: anonKey);
|
||||||
final client = Supabase.instance.client;
|
} 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;
|
||||||
|
}
|
||||||
|
|
||||||
// Must have an active session
|
|
||||||
final session = client.auth.currentSession;
|
final session = client.auth.currentSession;
|
||||||
if (session == null) return Future.value(true);
|
if (session == null) {
|
||||||
|
debugPrint('[BgLoc] No active session');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||||
if (!serviceEnabled) return Future.value(true);
|
if (!serviceEnabled) {
|
||||||
|
debugPrint('[BgLoc] Location service disabled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final permission = await Geolocator.checkPermission();
|
final permission = await Geolocator.checkPermission();
|
||||||
if (permission == LocationPermission.denied ||
|
if (permission == LocationPermission.denied ||
|
||||||
permission == LocationPermission.deniedForever) {
|
permission == LocationPermission.deniedForever) {
|
||||||
return Future.value(true);
|
debugPrint('[BgLoc] Location permission denied');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final position = await Geolocator.getCurrentPosition(
|
final position = await Geolocator.getCurrentPosition(
|
||||||
locationSettings: const LocationSettings(
|
locationSettings: const LocationSettings(accuracy: LocationAccuracy.high),
|
||||||
accuracy: LocationAccuracy.high,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await client.rpc(
|
await client.rpc(
|
||||||
'update_live_position',
|
'update_live_position',
|
||||||
params: {'p_lat': position.latitude, 'p_lng': position.longitude},
|
params: {'p_lat': position.latitude, 'p_lng': position.longitude},
|
||||||
);
|
);
|
||||||
|
debugPrint(
|
||||||
|
'[BgLoc] Position updated: ${position.latitude}, ${position.longitude}',
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Background location update error: $e');
|
debugPrint('[BgLoc] Error: $e');
|
||||||
}
|
}
|
||||||
return Future.value(true);
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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.
|
/// iOS background entry point — must be a PUBLIC top-level function.
|
||||||
Future<void> initBackgroundLocationService() async {
|
@pragma('vm:entry-point')
|
||||||
if (kIsWeb) return; // Workmanager is not supported on web.
|
Future<bool> onIosBackground(ServiceInstance service) async {
|
||||||
await Workmanager().initialize(callbackDispatcher);
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
await _sendLocationUpdate();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register a periodic task to report location every ~15 minutes
|
// ---------------------------------------------------------------------------
|
||||||
/// (Android minimum for periodic Workmanager tasks).
|
// Public API – same function signatures as the previous implementation.
|
||||||
Future<void> startBackgroundLocationUpdates() async {
|
// ---------------------------------------------------------------------------
|
||||||
if (kIsWeb) return; // Workmanager is not supported on web.
|
|
||||||
await Workmanager().registerPeriodicTask(
|
/// Initialise the background service plugin. Call once at app startup.
|
||||||
_taskName,
|
Future<void> initBackgroundLocationService() async {
|
||||||
_taskName,
|
if (kIsWeb) return;
|
||||||
frequency: const Duration(minutes: 15),
|
|
||||||
constraints: Constraints(networkType: NetworkType.connected),
|
final service = FlutterBackgroundService();
|
||||||
existingWorkPolicy: ExistingPeriodicWorkPolicy.keep,
|
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 >=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.
|
/// Start the background location service.
|
||||||
Future<void> stopBackgroundLocationUpdates() async {
|
Future<void> startBackgroundLocationUpdates() async {
|
||||||
if (kIsWeb) return; // Workmanager is not supported on web.
|
if (kIsWeb) return;
|
||||||
await Workmanager().cancelByUniqueName(_taskName);
|
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,39 @@ Future<bool> ensureLocationPermission() async {
|
||||||
final newStatus = await requestLocationPermission();
|
final newStatus = await requestLocationPermission();
|
||||||
return newStatus.isGranted;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
64
pubspec.lock
64
pubspec.lock
|
|
@ -502,6 +502,38 @@ packages:
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
flutter_colorpicker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1834,38 +1866,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
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:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,8 @@ dependencies:
|
||||||
fl_chart: ^0.70.2
|
fl_chart: ^0.70.2
|
||||||
google_generative_ai: ^0.4.0
|
google_generative_ai: ^0.4.0
|
||||||
http: ^1.2.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
|
intl: ^0.20.2
|
||||||
image_picker: ^1.1.2
|
image_picker: ^1.1.2
|
||||||
flutter_liveness_check: ^1.0.3
|
flutter_liveness_check: ^1.0.3
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user