Skip to main content

ex_background_geolocation 1.0.0-beta.2 copy "ex_background_geolocation: ^1.0.0-beta.2" to clipboard
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),
                        ),
                      ),
                    ),
            ),
          ],
        ),
      ),
    );
  }
}
0
likes
150
points
0
downloads

Documentation

Documentation
API reference

Publisher

verified publisherexlinelabs.com

Weekly Downloads

Background location tracking for Android and iOS: motion detection that turns GPS off when parked, geofencing and an offline-safe HTTP upload queue.

Homepage

License

unknown (license)

Dependencies

flutter

More

Packages that depend on ex_background_geolocation

Packages that implement ex_background_geolocation