Field service, delivery, logistics, ride-hailing, workforce safety — a large share of the Flutter briefs that reach us have a map on the main screen and a tracking requirement buried in the spec: "we need to see where the driver is while the job is active." Teams budget a week for it. It is almost never a week.
Location is the one Flutter feature where the plugin is the easy part. The hard parts are two divergent permission ladders, an Android process lifecycle that will kill your tracking loop, an iOS suspension model that will not run your Dart code at all unless you ask correctly, a battery budget the user notices, and two app stores that require written justification before they will let you ship.
This tutorial walks the whole path on Flutter 3.4x: choosing a map renderer, the permission ladder, foreground vs. background tracking, geofencing, batching uploads so the device survives an eight-hour shift, and the review paperwork.
Step 1: pick the map renderer before you pick the plugin
There are three realistic options in 2026, and the decision is commercial as much as technical.
| Option | Package | Rendering | Cost model | Good fit |
|---|---|---|---|---|
| Google Maps | google_maps_flutter | Native view, platform-composited | Per-load billing, credits | Android-heavy apps, Places/Directions already in use |
| Apple Maps | apple_maps_flutter / native view | Native view | Free on iOS | iOS-only consumer apps |
| MapLibre / vector tiles | maplibre_gl, flutter_map | GL or Flutter-canvas | Self-host or pay a tile vendor | Cost control, offline tiles, custom cartography |
Two practical notes. First, native map views (Google/Apple) are platform views: they composite outside the Flutter layer tree, which costs you a little performance and makes golden tests useless for map screens — stub the map widget behind an interface so widget tests can render a placeholder. Second, if the app must work offline (it usually must, if it tracks location), vector tiles you can cache on device are a far shorter path than trying to persuade a native SDK to work without a network.
The tracking stack below is renderer-agnostic. Keep it that way; renderers get swapped when someone reads the maps invoice.
Step 2: the permission ladder, in the right order
Android and iOS both split location into foreground and background, and both punish apps that ask for everything at once.
Android
ACCESS_COARSE_LOCATIONand/orACCESS_FINE_LOCATION— requested at runtime, and since Android 12 the user can grant coarse only, even when you asked for fine.ACCESS_BACKGROUND_LOCATION— must be requested separately, in a second prompt, after foreground is already granted. Asking in the same call silently fails.- Android 14+ adds a foreground service type: declare
android:foregroundServiceType="location"and the matchingFOREGROUND_SERVICE_LOCATIONpermission, or the service throws at start.
iOS
NSLocationWhenInUseUsageDescription— the "While Using" prompt.NSLocationAlwaysAndWhenInUseUsageDescription— the escalation to Always. iOS shows it once; if the user declines, you cannot re-prompt, only deep-link to Settings.- Background modes:
locationinUIBackgroundModes. Addprocessingonly if you also run deferred work.
<!-- android/app/src/main/AndroidManifest.xml -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
// lib/location/permissions.dart
import 'package:geolocator/geolocator.dart';
enum LocationAccess { denied, foregroundOnly, always, serviceOff }
Future<LocationAccess> requestForeground() async {
if (!await Geolocator.isLocationServiceEnabled()) return LocationAccess.serviceOff;
var p = await Geolocator.checkPermission();
if (p == LocationPermission.denied) p = await Geolocator.requestPermission();
return switch (p) {
LocationPermission.always => LocationAccess.always,
LocationPermission.whileInUse => LocationAccess.foregroundOnly,
_ => LocationAccess.denied,
};
}
The rule that keeps grant rates healthy: never ask for background location on first launch. Ask for foreground when the user opens the map. Ask for Always only at the moment they start a job or shift, with a one-screen explanation of what it buys them, and an obvious "not now". A pre-permission screen you control is re-showable; the OS prompt is not.
Always design a degraded mode. A meaningful fraction of your users will run foreground-only forever, and the app must still be usable — track while open, tell them plainly what is missing.
Step 3: foreground tracking
For an on-screen map, a plain position stream is enough. Tune the filter rather than the interval: distanceFilter suppresses the stationary jitter that otherwise drains battery and pollutes your trail.
final stream = Geolocator.getPositionStream(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
distanceFilter: 25, // metres
),
);
Drop points that fail a sanity check before they reach the UI or the server: accuracy > 100 metres, timestamps older than the last accepted fix, or an implied speed above what the vehicle can do. Bad fixes are common indoors and they make replayed routes look like the driver teleported.
Step 4: background tracking that actually keeps running
This is where naive implementations fail — usually in QA on a real device, three hours in.
On Android, background location requires a foreground service with a persistent notification. Without it, Doze and standby buckets will freeze your isolate. Vendor skins (Xiaomi, Huawei, Samsung, OnePlus) add their own aggressive killers on top; dontkillmyapp.com is worth bookmarking, and for enterprise fleets the answer is usually a battery-optimisation exemption pushed via MDM.
On iOS, the OS delivers location updates to the app and wakes it as needed, but only if the location background mode is declared and the Always permission is granted. Significant-location-change and region monitoring can relaunch a terminated app; a standard high-accuracy stream cannot.
Use a plugin built for this rather than assembling it yourself. flutter_background_geolocation is the commercially licensed, most battle-tested option; background_locator_2 is a free alternative. Either way, the native side owns the lifecycle and hands you events in a background isolate.
// Background isolate: no BuildContext, no provider graph, no shared singletons.
// Re-open your own DB handle and queue the fix; do not POST from here.
@pragma('vm:entry-point')
Future<void> onLocation(Position p) async {
final db = await openTrackingDb();
await db.enqueueFix(
lat: p.latitude,
lng: p.longitude,
accuracy: p.accuracy,
speed: p.speed,
recordedAt: p.timestamp,
);
}
Two things break here constantly. The background isolate does not share memory with your UI isolate, so anything initialised in main() is absent — re-initialise plugins and the database inside the callback. And the entry point must be a top-level or static function annotated @pragma('vm:entry-point'), or tree-shaking removes it from the release build only, giving you a bug that never reproduces in debug.
Step 5: batch uploads, don't stream them
One HTTP request per fix will flatten a battery and fail constantly in poor coverage. Write fixes to SQLite, then flush in batches — every ~60 seconds, or every 25 fixes, or on a connectivity-restored event, whichever comes first.
Future<void> flush() async {
final batch = await db.pendingFixes(limit: 200);
if (batch.isEmpty) return;
final res = await api.postFixes(batch); // idempotency key per fix id
if (res.ok) await db.markSynced(batch.map((f) => f.id));
}
Give each fix a client-generated UUID and have the server treat replays as idempotent — retries after a flaky upload are certain. Compress the payload, round coordinates to six decimal places (≈11 cm; more is noise), and consider Douglas–Peucker simplification on the device if you are uploading dense trails.
Step 6: geofencing instead of polling
If the requirement is "tell me when the technician arrives on site", do not solve it with a high-accuracy stream and a distance calculation. Register a native geofence: both platforms monitor it in the OS at near-zero battery cost and wake your app on transition. Android caps around 100 active geofences per app and iOS at 20 monitored regions, so for a long route register only the next few stops and re-register as the driver progresses.
Step 7: battery and accuracy budgets
Set a measurable target before the client sets one for you. A reasonable baseline for an eight-hour tracked shift is under 12% of battery attributable to the app. To get there: drop accuracy to balanced when speed is zero, widen distanceFilter when stationary, stop tracking automatically when the job ends (the single biggest win), and never hold a wakelock you did not measure. Test on a mid-range Android device with a two-year-old battery, not on the newest iPhone in the office.
Step 8: the store review paperwork
Both stores gate background location behind human review, and rejections here cost a release cycle.
- Google Play requires a background location declaration in the Play Console plus a short demo video showing the in-app feature that needs it, and the prominent in-app disclosure screen shown before the OS prompt. Feature parity matters: if the app works without it, Play will say so.
- Apple requires the purpose strings to describe the user benefit concretely ("so dispatch can share your ETA with the customer" — not "for app functionality"), a blue location-usage indicator the user can understand, and a privacy nutrition label listing precise location and its linkage to identity.
- Both expect a privacy policy that names location collection, retention period, and third-party recipients.
Build the disclosure screen and record the demo video in the same sprint as the feature. Teams that leave it to the release checklist ship two weeks late.
Testing without walking around the car park
- iOS Simulator: Features → Location → Freeway Drive, or load a custom GPX route.
- Android emulator: extended controls have a route player that accepts GPX/KML.
- Integration tests: inject a fake
PositionStreambehind your repository interface and replay a recorded trail as a list ofPositionobjects — that covers filtering, batching, and geofence logic deterministically in CI. - Manual pass, every release: airplane mode mid-route, force-stop the app, reboot the device, and a battery-saver run. Those four catch most of what escapes automated tests.
What to take away
Location work is not a plugin integration; it is a lifecycle, permission, battery, and compliance project with a map on top. Scope it as three to four weeks for a first implementation, decide the renderer on cost and offline needs, ask for background permission late and with an explanation, keep the background isolate dumb and durable, batch everything, and prepare the store declarations up front.
Planning a tracking, dispatch, or field-service app in Flutter — or debugging one that stops reporting after an hour in the background? Get in touch. Our Flutter consultants have shipped location-critical apps through both store review processes and can join your team as developers or as a development partner.