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.
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
<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
<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:
- Start a foreground service from a MethodChannel or a plugin to keep the Dart isolate alive.
- In the service, attach to Geolocator.getPositionStream and forward updates to the app (e.g., via event channels or background isolate).
- 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.
Accuracy vs. battery: recommended presets
- 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
Flutter + Google Maps: A Complete Integration Guide
Integrate Google Maps in Flutter: setup keys, Android/iOS config, markers, directions, clustering, styling, and best practices.
Flutter Home Widget Creation Guide: Cross‑Platform Widgets for Android and iOS
Step-by-step Flutter guide to build home screen widgets for Android and iOS using native WidgetKit/AppWidget with Dart-driven updates.
Flutter + TensorFlow Lite: Local AI Integration Guide
A practical guide to integrating TensorFlow Lite models into Flutter for fast, private, offline on-device AI with performance tuning and code examples.