Background location tracking
This commit is contained in:
+1
-1
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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 >=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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user