Flutter Geolocation Tracking App Tutorial: Maps, Routes, and Background Updates

Build a production-ready Flutter geolocation tracker with maps, live routes, distance, and background updates. Permissions, battery, and privacy included.

ASOasis
9 min read
Flutter Geolocation Tracking App Tutorial: Maps, Routes, and Background Updates

Image used for representation purposes only.

Overview

Real‑time geolocation is a cornerstone of delivery, fitness, fleet, and safety apps. In this tutorial, you’ll build a Flutter app that:

  • Streams the user’s location in real time
  • Renders a live marker and polyline on a map
  • Calculates distance and speed
  • Persists points locally
  • Prepares for background tracking on Android and iOS

We’ll favor battle‑tested packages and platform‑correct permission flows, with an eye on battery life, privacy, and store compliance.

What you’ll build

A single‑screen Flutter app that:

  • Requests location permission with rationale
  • Shows your position on a map
  • Starts/stops tracking to record a route
  • Draws a polyline of your path
  • Displays cumulative distance and current speed

You can extend it later with geofences, cloud sync, and trip summaries.

Prerequisites

  • Flutter SDK installed
  • Android Studio or Xcode set up with emulators/simulators
  • A physical device recommended for background testing
  • For Google Maps: a Maps SDK API key (optional; we’ll also show an open‑source map option)

Project setup

Create a new app:

flutter create geo_tracker
cd geo_tracker

Dependencies

Add location, maps, and persistence packages. We’ll use:

  • geolocator: high‑level position APIs (streaming, permissions)
  • permission_handler: granular control and fallbacks (optional but handy)
  • google_maps_flutter or flutter_map: map rendering
  • hive + hive_flutter: lightweight local storage (or use sqflite/drift if you prefer SQL)

Edit pubspec.yaml:

dependencies:
  flutter:
    sdk: flutter
  geolocator: any
  permission_handler: any
  google_maps_flutter: any   # or comment this out and use flutter_map
  flutter_polyline_points: any # optional helper for polylines
  hive: any
  hive_flutter: any

Then get packages:

flutter pub get

Initialize Hive in main() before runApp.

Platform configuration

Android

Open android/app/src/main/AndroidManifest.xml and add permissions inside the tag:

<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- For background tracking; request only if you truly need it -->
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<!-- Foreground service for ongoing background location on Android 10+ -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- Android 13+ needs runtime notification permission if you show a foreground notification -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

If you use a foreground service, declare your service with a location type in the tag:

<service
    android:name=".LocationService"
    android:foregroundServiceType="location"
    android:exported="false" />

Notes:

  • ACCESS_BACKGROUND_LOCATION requires a clear, user‑facing justification and specific Play Console declarations during review.
  • Users can choose approximate (coarse) location; code should adapt gracefully.

iOS

In ios/Runner/Info.plist add purpose strings and background mode:

<key>NSLocationWhenInUseUsageDescription</key>
<string>This app uses your location to track routes while you’re using it.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>This app needs access to your location even when the app is in the background to continue tracking routes.</string>

<!-- Optional: request temporary full accuracy if users selected approximate -->
<key>NSLocationTemporaryUsageDescriptionDictionary</key>
<dict>
  <key>RouteTracking</key>
  <string>We use precise location temporarily to measure your route accurately.</string>
</dict>

<key>UIBackgroundModes</key>
<array>
  <string>location</string>
</array>

Notes:

  • Always request the minimum permission level required for your feature set. Start with “When In Use,” then upgrade to “Always” only if background tracking is explicitly enabled by the user.
  • On iOS, background location requires the UIBackgroundModes/location capability and a persistent blue location indicator.

Core app structure

We’ll design a simple architecture:

  • LocationService: wraps Geolocator for permissions and a Position stream
  • TrackingController: manages start/stop, stores points, computes metrics
  • UI: Map, stats, and controls

Location permissions helper

Create lib/location_service.dart:

import 'dart:async';
import 'package:geolocator/geolocator.dart';

class LocationService {
  final LocationSettings settings;
  StreamSubscription<Position>? _sub;

  LocationService({
    LocationAccuracy accuracy = LocationAccuracy.best,
    int distanceFilter = 5,
  }) : settings = LocationSettings(
          accuracy: accuracy,
          distanceFilter: distanceFilter,
        );

  Future<bool> ensurePermission({bool askForAlways = false}) async {
    bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
    if (!serviceEnabled) return false;

    LocationPermission perm = await Geolocator.checkPermission();
    if (perm == LocationPermission.denied) {
      perm = await Geolocator.requestPermission();
    }

    if (askForAlways && perm == LocationPermission.whileInUse) {
      // On iOS, Geolocator exposes requestPermission only; upgrading to Always
      // may require platform‑specific prompts. Consider guiding users to settings.
    }

    return perm == LocationPermission.always ||
           perm == LocationPermission.whileInUse;
  }

  Stream<Position> positionStream() {
    return Geolocator.getPositionStream(locationSettings: settings);
  }

  Future<Position> current() => Geolocator.getCurrentPosition();
}

Tracking controller

Create lib/tracking_controller.dart:

import 'dart:async';
import 'package:geolocator/geolocator.dart';

class TrackPoint {
  final double lat;
  final double lng;
  final DateTime ts;
  final double? speedMps; // nullable if unknown
  TrackPoint(this.lat, this.lng, this.ts, {this.speedMps});
}

class TrackingController {
  final Stream<Position> stream;
  StreamSubscription<Position>? _sub;
  final List<TrackPoint> points = [];
  double totalMeters = 0.0;

  TrackingController(this.stream);

  bool get isTracking => _sub != null;

  Future<void> start() async {
    if (isTracking) return;
    _sub = stream.listen(_onPosition, onError: (e) {
      // handle errors, e.g., permissions changed
    });
  }

  Future<void> stop() async {
    await _sub?.cancel();
    _sub = null;
  }

  void clear() {
    points.clear();
    totalMeters = 0.0;
  }

  void _onPosition(Position p) {
    final now = DateTime.now();
    final point = TrackPoint(p.latitude, p.longitude, now, speedMps: p.speed);

    if (points.isNotEmpty) {
      final last = points.last;
      final d = Geolocator.distanceBetween(
        last.lat, last.lng, p.latitude, p.longitude,
      );
      if (d >= 0) totalMeters += d;
    }

    points.add(point);
  }

  double get speedKmh => points.isNotEmpty && points.last.speedMps != null
      ? (points.last.speedMps! * 3.6)
      : 0.0;

  double get distanceKm => totalMeters / 1000.0;
}

UI with Google Maps (option A)

Create lib/main.dart:

import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'location_service.dart';
import 'tracking_controller.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(const GeoApp());
}

class GeoApp extends StatelessWidget {
  const GeoApp({super.key});
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Geo Tracker',
      theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.indigo),
      home: const HomePage(),
    );
  }
}

class HomePage extends StatefulWidget {
  const HomePage({super.key});
  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  late final LocationService _loc;
  late final TrackingController _tracker;
  GoogleMapController? _map;
  LatLng? _center;

  @override
  void initState() {
    super.initState();
    _loc = LocationService(distanceFilter: 5);
    _tracker = TrackingController(_loc.positionStream());
    _init();
  }

  Future<void> _init() async {
    final ok = await _loc.ensurePermission();
    if (!ok && mounted) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Location permission required')),
      );
      return;
    }
    final p = await _loc.current();
    setState(() => _center = LatLng(p.latitude, p.longitude));
  }

  Set<Marker> get _markers {
    if (_tracker.points.isEmpty) return {};
    final last = _tracker.points.last;
    return {
      Marker(
        markerId: const MarkerId('me'),
        position: LatLng(last.lat, last.lng),
      )
    };
  }

  Set<Polyline> get _polyline {
    final coords = _tracker.points
        .map((e) => LatLng(e.lat, e.lng))
        .toList(growable: false);
    if (coords.length < 2) return {};
    return {
      Polyline(
        polylineId: const PolylineId('route'),
        color: Colors.indigo,
        width: 5,
        points: coords,
      )
    };
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Geo Tracker')),
      body: _center == null
          ? const Center(child: CircularProgressIndicator())
          : Column(
              children: [
                Expanded(
                  child: GoogleMap(
                    initialCameraPosition: CameraPosition(target: _center!, zoom: 16),
                    myLocationEnabled: true,
                    myLocationButtonEnabled: true,
                    polylines: _polyline,
                    markers: _markers,
                    onMapCreated: (c) => _map = c,
                  ),
                ),
                _StatsBar(tracker: _tracker, onUpdateUI: () => setState(() {})),
              ],
            ),
    );
  }
}

class _StatsBar extends StatefulWidget {
  final TrackingController tracker;
  final VoidCallback onUpdateUI;
  const _StatsBar({required this.tracker, required this.onUpdateUI});

  @override
  State<_StatsBar> createState() => _StatsBarState();
}

class _StatsBarState extends State<_StatsBar> {
  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(12),
      color: Theme.of(context).colorScheme.surface,
      child: Row(
        children: [
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text('Distance: ${widget.tracker.distanceKm.toStringAsFixed(2)} km'),
                Text('Speed: ${widget.tracker.speedKmh.toStringAsFixed(1)} km/h'),
              ],
            ),
          ),
          FilledButton.icon(
            onPressed: () async {
              if (widget.tracker.isTracking) {
                await widget.tracker.stop();
              } else {
                await widget.tracker.start();
              }
              widget.onUpdateUI();
            },
            icon: Icon(widget.tracker.isTracking ? Icons.stop : Icons.play_arrow),
            label: Text(widget.tracker.isTracking ? 'Stop' : 'Start'),
          ),
          const SizedBox(width: 8),
          OutlinedButton(
            onPressed: () {
              widget.tracker.clear();
              widget.onUpdateUI();
            },
            child: const Text('Clear'),
          )
        ],
      ),
    );
  }
}

Map with OpenStreetMap (option B)

If you prefer to avoid API keys, use flutter_map with OpenStreetMap:

dependencies:
  flutter_map: any
  latlong2: any

In your UI, replace GoogleMap with FlutterMap and a TileLayer from OSM; draw a PolylineLayer using your points.

Persisting routes with Hive (optional)

Create a box to store TrackPoint lists as maps:

import 'package:hive_flutter/hive_flutter.dart';

class TrackStore {
  static const boxName = 'tracks';
  static Future init() async => Hive.initFlutter();
  static Future<Box> open() => Hive.openBox(boxName);

  static Future<int> saveRoute(List<TrackPoint> points) async {
    final box = await open();
    final data = points.map((p) => {
      'lat': p.lat,
      'lng': p.lng,
      'ts': p.ts.toIso8601String(),
      'v': p.speedMps,
    }).toList();
    return box.add({'createdAt': DateTime.now().toIso8601String(), 'pts': data});
  }
}

Call TrackStore.saveRoute(controller.points) when the user stops tracking.

Background tracking fundamentals

Background location is a power‑sensitive, policy‑gated feature. Follow these principles:

  • Ask for background access only when a user enables a “Track in background” switch and explain why.
  • On Android, run a Foreground Service with a persistent notification while gathering location in the background.
  • On iOS, enable the location background mode; iOS will show a blue status bar indicator.
  • Reduce update frequency/accuracy when the app is backgrounded via distanceFilter and accuracy.

Android: simple foreground service pattern

There are multiple approaches (dedicated plugins exist). Conceptually:

  1. Start a foreground service from a MethodChannel or a plugin to keep the Dart isolate alive.
  2. In the service, attach to Geolocator.getPositionStream and forward updates to the app (e.g., via event channels or background isolate).
  3. Show an ongoing notification with actions to stop.

Pseudo‑Dart for switching to a low‑power config in background:

// When app transitions to background
await _sub?.cancel();
_sub = Geolocator.getPositionStream(
  locationSettings: const LocationSettings(
    accuracy: LocationAccuracy.low,
    distanceFilter: 25, // meters
  ),
).listen(_onPosition);

iOS: background modes and accuracy

  • With UIBackgroundModes/location, iOS will continue delivering updates when the app is backgrounded, but may throttle based on motion and power.
  • Consider significant location change or visits APIs when turn‑by‑turn precision isn’t required.
  • If users opted for approximate location, request temporary full accuracy only while recording a route and only with a clear purpose string.
  • Live workout: accuracy = best, distanceFilter = 5–10 m
  • City navigation: accuracy = high, distanceFilter = 10–20 m
  • Background/long trips: accuracy = balanced/low, distanceFilter = 25–50 m

Tip: dynamically adapt accuracy based on speed (e.g., higher filter at highway speeds).

Handling permissions UX

  • Explain the benefit before the system prompt (a pre‑permission dialog).
  • If denied, show a Settings deep link with clear guidance.
  • Respect “Allow Once” on iOS; resume gracefully next launch.
  • Handle Android 13+ notification permission before starting a foreground service.

Example settings link:

import 'package:geolocator/geolocator.dart';

Future<void> openSettingsIfDenied() async {
  final perm = await Geolocator.checkPermission();
  if (perm == LocationPermission.deniedForever) {
    await Geolocator.openAppSettings();
  }
}

Testing and debugging

  • Use a physical device; emulators often throttle sensors.
  • Simulate routes: Xcode > Features > Location; Android Studio > Extended Controls > Location > GPX.
  • Log Position fields (accuracy, speed, speedAccuracy) to spot GPS noise.
  • Validate background: lock the screen for 10–15 minutes and verify points continue arriving within your configured cadence.

Privacy and compliance checklist

  • Provide an in‑app Privacy Policy and link it in the store listing.
  • Collect only the data you need; offer “while in use” tracking by default.
  • Let users pause/delete routes; purge raw points after summarizing.
  • On Android, complete the Play Console sensitive permissions declarations for background location.
  • On iOS, ensure your purpose strings clearly reflect actual use.

Common pitfalls

  • Requesting background access up front: more likely to be rejected or denied.
  • Overly aggressive accuracy: drains battery, creates heat, and triggers OS throttling.
  • Missing POST_NOTIFICATIONS on Android 13+: foreground service won’t show, leading to termination.
  • Not handling approximate location: expect reduced accuracy; don’t crash.

Where to go next

  • Geofencing (enter/exit regions)
  • Offline map tiles and map matching
  • Cloud sync with batched uploads and exponential backoff
  • Trip segmentation and stay‑point detection
  • Battery‑aware policies using Activity Recognition

Wrap‑up

You’ve built a production‑ready foundation for a Flutter geolocation tracker: permission‑smart, map‑rendered, distance‑aware, and background‑capable. From here, you can specialize for fitness, delivery, fleet, or safety—always balancing accuracy, power, and privacy. Happy shipping!

Related Posts