ex_background_geolocation 1.0.0-beta.2
ex_background_geolocation: ^1.0.0-beta.2 copied to clipboard
Background location tracking for Android and iOS: motion detection that turns GPS off when parked, geofencing and an offline-safe HTTP upload queue.
example/lib/main.dart
import 'dart:async';
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show PlatformException;
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:ex_background_geolocation/ex_background_geolocation.dart';
/// The Upload URL field is remembered across launches: ready() runs at every
/// launch with the field's value, so an empty field would clear the saved url.
const _urlPrefKey = 'upload_url';
/// License keys for this app's Android application id and iOS bundle id.
/// Debug builds don't need them; release and profile builds do. Get a key
/// from support@exlinelabs.com.
const _androidLicenseKey = '';
const _iosLicenseKey = '';
void main() {
runApp(const MyApp());
}
/// Shows a local notification — usable from both the main isolate and the
/// background headless isolate (each initialises its own plugin instance).
Future<void> showLocationNotification(String title, String body) async {
final plugin = FlutterLocalNotificationsPlugin();
await plugin.initialize(const InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
iOS: DarwinInitializationSettings(),
));
await plugin.show(
DateTime.now().millisecondsSinceEpoch ~/ 1000 % 100000,
title,
body,
const NotificationDetails(
android: AndroidNotificationDetails(
'ex_geo_events', 'Location events',
importance: Importance.high, priority: Priority.high,
),
iOS: DarwinNotificationDetails(
presentAlert: true, presentBanner: true, presentSound: true,
),
),
);
}
/// Background headless task — runs in its own isolate when the app is
/// terminated (Android). MUST be top-level + `@pragma('vm:entry-point')`.
@pragma('vm:entry-point')
void headlessTask(HeadlessEvent event) {
final loc = event.location;
final where = loc == null
? 'no location'
: '${loc.coords.latitude.toStringAsFixed(4)}, '
'${loc.coords.longitude.toStringAsFixed(4)}';
showLocationNotification('Headless: ${event.name}', where);
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
final List<String> _log = [];
final _urlController = TextEditingController();
StreamSubscription<Location>? _locSub;
StreamSubscription<HttpEvent>? _httpSub;
StreamSubscription<Location>? _motionSub;
StreamSubscription<ActivityChangeEvent>? _activitySub;
StreamSubscription<GeofenceEvent>? _geofenceSub;
StreamSubscription<bool>? _providerSub;
Timer? _countTimer;
int _geofenceSeq = 0;
bool _tracking = false;
bool _isMoving = true;
String _activity = '-';
String _status = 'idle';
int _queued = 0;
@override
void initState() {
super.initState();
_init();
}
Future<void> _init() async {
final prefs = await SharedPreferences.getInstance();
_urlController.text = prefs.getString(_urlPrefKey) ?? '';
// Ask for notification permission up front (iOS popup + Android 13+).
await _initNotifications();
// Register the background headless task (Android: fires when terminated).
await ExBackgroundGeolocation.registerHeadlessTask(headlessTask);
_locSub = ExBackgroundGeolocation.onLocation(
(location) {
_append(location.toString());
// Notify in foreground/relaunch too so we can see the callback firing.
showLocationNotification(
'Location',
'${location.coords.latitude.toStringAsFixed(4)}, '
'${location.coords.longitude.toStringAsFixed(4)}',
);
},
(error) => _append('ERROR: $error'),
);
_httpSub = ExBackgroundGeolocation.onHttp(
(e) => _append('HTTP $e'),
);
_motionSub = ExBackgroundGeolocation.onMotionChange((loc) {
setState(() => _isMoving = loc.isMoving);
_append('MOTIONCHANGE -> ${loc.isMoving ? "MOVING" : "STATIONARY"}');
});
_activitySub = ExBackgroundGeolocation.onActivityChange((a) {
setState(() => _activity = a.activity);
_append('ACTIVITY -> ${a.activity}');
});
_geofenceSub = ExBackgroundGeolocation.onGeofence((g) {
_append('GEOFENCE ${g.action} -> ${g.identifier}');
});
_providerSub = ExBackgroundGeolocation.onProviderChange((enabled) {
_append('LOCATION SERVICES -> ${enabled ? "ON" : "OFF"}');
});
final permission = await ExBackgroundGeolocation.requestPermission();
_setStatus('permission: $permission');
// Permission alone is not enough: with the device Location switch off the
// service runs but never gets a fix.
if (!await ExBackgroundGeolocation.isLocationServicesEnabled()) {
_append('WARNING: device Location is OFF - no fixes until it is enabled');
}
// ready() at every launch, like a real host app: on Android it also resumes
// tracking that was on but whose service was killed and not restarted.
final version = await ExBackgroundGeolocation.ready(_config());
_append('ready on $version');
// Reflect tracking that may already be running (e.g. resumed after reboot).
// Motion events fired while the UI was gone are not replayed, so read the
// current state instead of assuming MOVING.
final alreadyTracking = await ExBackgroundGeolocation.isTracking();
if (mounted && alreadyTracking) {
final stats = await ExBackgroundGeolocation.getTripStats();
setState(() {
_tracking = true;
_isMoving = stats.isMoving;
_status = 'tracking (resumed)';
});
}
// Poll the queue depth so we can watch it fill/drain.
_countTimer = Timer.periodic(const Duration(seconds: 2), (_) async {
final n = await ExBackgroundGeolocation.getCount();
if (mounted) setState(() => _queued = n);
});
}
Config _config() => Config(
desiredAccuracy: LocationAccuracy.high,
distanceFilter: 10, // report every 10m - lighter than reporting every tick
stationaryRadius: 25,
stopTimeout: 30, // short so stationary triggers quickly while testing
url: _urlController.text.trim().isEmpty
? null
: _urlController.text.trim(),
autoSync: true,
licenseKey: Platform.isIOS ? _iosLicenseKey : _androidLicenseKey,
// Demonstrate custom-field trimming: only these go in the POST body.
locationFields: const ['latitude', 'longitude', 'timestamp'],
stopOnTerminate: false,
startOnBoot: true,
notificationTitle: 'ex_background_geolocation',
notificationText: 'Tracking test harness',
);
void _append(String line) {
final now = DateTime.now();
String two(int n) => n.toString().padLeft(2, '0');
final ts = '${two(now.hour)}:${two(now.minute)}:${two(now.second)}';
setState(() => _log.insert(0, '[$ts] $line'));
}
void _setStatus(String s) => setState(() => _status = s);
/// Initialise notifications and request permission at startup, so the iOS
/// permission prompt appears (and the app registers under Settings).
Future<void> _initNotifications() async {
final plugin = FlutterLocalNotificationsPlugin();
await plugin.initialize(const InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
iOS: DarwinInitializationSettings(),
));
await plugin
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin>()
?.requestPermissions(alert: true, badge: true, sound: true);
await plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.requestNotificationsPermission();
}
Future<void> _start() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_urlPrefKey, _urlController.text.trim());
final cfg = _config();
final version = await ExBackgroundGeolocation.ready(cfg);
_append('ready on $version, url=${_urlController.text.trim().isEmpty ? "(none)" : _urlController.text.trim()}');
_append('POST body fields: ${cfg.locationFields?.join(", ") ?? "all"}');
try {
await ExBackgroundGeolocation.start();
} on PlatformException catch (e) {
// LICENSE_INVALID: a release build without a valid licenseKey.
_append('start failed: ${e.code} ${e.message ?? ""}');
_setStatus('not started (${e.code})');
return;
}
setState(() => _tracking = true);
_setStatus('tracking');
}
Future<void> _stop() async {
await ExBackgroundGeolocation.stop();
setState(() => _tracking = false);
_setStatus('stopped');
}
Future<void> _syncNow() async {
final ok = await ExBackgroundGeolocation.sync();
_append('manual sync -> $ok');
}
Future<void> _clearQueue() async {
await ExBackgroundGeolocation.destroyLocations();
_append('queue cleared');
}
Future<void> _forcePace(bool moving) async {
final applied = await ExBackgroundGeolocation.changePace(moving);
_append('changePace(${moving ? "moving" : "stationary"}) '
'applied=$applied');
}
Future<void> _addGeofenceHere() async {
try {
final loc = await ExBackgroundGeolocation.getCurrentPosition();
final id = 'gf_${++_geofenceSeq}';
await ExBackgroundGeolocation.addGeofence(Geofence(
identifier: id,
latitude: loc.coords.latitude,
longitude: loc.coords.longitude,
radius: 150,
));
_append('added geofence $id @ '
'${loc.coords.latitude.toStringAsFixed(4)},'
'${loc.coords.longitude.toStringAsFixed(4)} r=150m');
} catch (e) {
_append('addGeofence failed: $e');
}
}
Future<void> _listGeofences() async {
final list = await ExBackgroundGeolocation.getGeofences();
_append('geofences (${list.length}): '
'${list.map((g) => g.identifier).join(", ")}');
}
Future<void> _clearGeofences() async {
await ExBackgroundGeolocation.removeGeofences();
_append('removed all geofences');
}
@override
void dispose() {
_locSub?.cancel();
_httpSub?.cancel();
_motionSub?.cancel();
_activitySub?.cancel();
_geofenceSub?.cancel();
_providerSub?.cancel();
_countTimer?.cancel();
_urlController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('ex_background_geolocation')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _urlController,
decoration: const InputDecoration(
labelText: 'Upload URL (e.g. https://webhook.site/...)',
border: OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(child: Text('Status: $_status')),
Text('Queued: $_queued'),
],
),
const SizedBox(height: 4),
Row(
children: [
Chip(
label: Text(_isMoving ? 'MOVING' : 'STATIONARY'),
backgroundColor: _isMoving
? Colors.green.shade100
: Colors.orange.shade100,
),
const SizedBox(width: 8),
Text('Activity: $_activity'),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
ElevatedButton(
onPressed: _tracking ? null : _start,
child: const Text('Start'),
),
ElevatedButton(
onPressed: _tracking ? _stop : null,
child: const Text('Stop'),
),
OutlinedButton(
onPressed: _syncNow, child: const Text('Sync')),
OutlinedButton(
onPressed: _clearQueue,
child: const Text('Clear queue')),
OutlinedButton(
onPressed: _tracking ? () => _forcePace(false) : null,
child: const Text('Force stationary')),
OutlinedButton(
onPressed: _tracking ? () => _forcePace(true) : null,
child: const Text('Force moving')),
OutlinedButton(
onPressed: _addGeofenceHere,
child: const Text('Geofence here')),
OutlinedButton(
onPressed: _listGeofences,
child: const Text('List geofences')),
OutlinedButton(
onPressed: _clearGeofences,
child: const Text('Clear geofences')),
OutlinedButton(
onPressed: ExBackgroundGeolocation.openSettings,
child: const Text('Open settings')),
],
),
],
),
),
const Divider(height: 1),
Expanded(
child: _log.isEmpty
? const Center(child: Text('Waiting for locations…'))
: ListView.builder(
itemCount: _log.length,
itemBuilder: (_, i) => Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 4),
child: Text(
_log[i],
style: const TextStyle(
fontFamily: 'monospace', fontSize: 12),
),
),
),
),
],
),
),
);
}
}