baaba_location
Foreground location for Flutter that never throws. One call runs the whole flow — permission, location services, precision, the fix, a fallback to the last known location, and the address — and returns one typed result.
final result = await BaabaLocation.instance.get(withAddress: true);
if (result.isSuccess) {
print(result.address?.formatted ?? result.position);
} else if (result.failure!.resolution != LocationResolution.none) {
await BaabaLocation.instance.resolve(result.failure!); // one button: "Turn on location"
}
- The package does the work.
get()asks for permission (rationale, prompt, Settings with steps, viabaaba_permission_handler), turns location on, upgrades to precise when the profile needs it, reuses a recent fix, falls back to the last known location, and looks up the address.result.stepssays what it did. - Two dialogs never stack. Several
get()calls at once — two widgets on one screen — share one flow. - A last known location that is almost always there. The package keeps its own copy, and picks the newer of it and the OS's.
- Filters drop bad accuracy, impossible jumps, mocked fixes, 0,0 and duplicates before your code sees them.
- Geocoding with the hard parts handled: a queue, back-off when throttled, a cache, locales (
ur-PKaddresses), cancellation, and pluggable providers with fallbacks. - A setup doctor.
diagnose()prints exactly what to add to your manifest orInfo.plist. - Nothing throws. Every failure is a typed
LocationFailurewith aresolution, reported once to your observer. - A test double (
FakeBaabaLocation) that runs the real flow without a device.
Built on geolocator and geocoding. Android and iOS; on web and desktop every call returns notApplicable.
Contents
- 60-second setup
- Platform setup per feature
- Choosing a profile
- Getting a location
- Streams
- Background tracking (opt-in)
- Last known location
- Geocoding
- Permissions
- Setup doctor
- Error handling
- Troubleshooting
- Gotchas
- Testing
- Configuration reference
60-second setup
1. Add the package.
dependencies:
baaba_location: ^0.1.0
2. Android — android/app/src/main/AndroidManifest.xml, inside <manifest>:
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
and in android/app/build.gradle(.kts): compileSdk = 37 (needed by permission_handler, through baaba_permission_handler).
3. iOS — ios/Runner/Info.plist, inside <dict>:
<key>NSLocationWhenInUseUsageDescription</key>
<string>Your location is used to show where you are.</string>
4. Call it.
final result = await BaabaLocation.instance.get(withAddress: true);
No init needed. If something is missing, the result is a misconfigured failure whose details['fix'] is the exact snippet to add — and BaabaLocation.instance.diagnose() prints a checklist.
Platform setup per feature
baaba_location declares no permissions of its own. Your app declares exactly what it uses:
| Feature | Android (AndroidManifest.xml) |
iOS (Info.plist) |
|---|---|---|
Any location (get, streams, last known) |
ACCESS_FINE_LOCATION + ACCESS_COARSE_LOCATION |
NSLocationWhenInUseUsageDescription |
| Approximate only (no precise) | ACCESS_COARSE_LOCATION alone |
— |
Precise upgrade on iOS 14+ (navigation, fitness, or PrecisionNeed) |
— | NSLocationTemporaryUsageDescriptionDictionary with a key; set LocationConfig.iosPrecisePurposeKey to it |
| Geocoding (platform geocoder) | nothing | nothing |
Background tracking (BackgroundLocation) |
FOREGROUND_SERVICE + FOREGROUND_SERVICE_LOCATION; POST_NOTIFICATIONS to show the notification on 13+ |
UIBackgroundModes → location |
| Your own HTTP geocoding provider | INTERNET |
— |
What merges into your app from the plugins: geolocator's GeolocatorLocationService element (a location foreground service, started only by BackgroundLocation) and the Play services meta-data. No permissions — tool/check_merged_manifest.sh builds the example and fails if ACCESS_BACKGROUND_LOCATION or a library-added foreground-service permission appears.
ACCESS_BACKGROUND_LOCATION and "Always" permission are never needed.
Choosing a profile
Profiles set accuracy, interval, distance filter, filters and precision together. Accuracy is always your choice: override it per call (get(accuracy: LocationAccuracy.high)) or copy a profile (LocationProfile.delivery.copyWith(interval: Duration(seconds: 10))).
| Profile | For | Accuracy | Interval / distance | Reuses a fix up to | Filters | Precise |
|---|---|---|---|---|---|---|
checkIn (default) |
one-off "where am I", attendance | balanced (≈ 100 m) | — | 30 s | — | asked once |
nearby |
store finder, address fill | low (≈ 1–10 km) | 30 s / 100 m | 5 min | — | never |
navigation |
live map, turn-by-turn | best | 1 s / 5 m | 5 s | ≤ 50 m, ≤ 70 m/s | required |
delivery |
courier, patrol | high | 5 s / 15 m | 15 s | ≤ 50 m, ≤ 50 m/s | asked once |
fitness |
runs, walks | high | 2 s / 5 m | 5 s | ≤ 30 m, ≤ 15 m/s | required |
batterySaver |
long sessions on old phones | balanced | 60 s / 100 m | 2 min | — | never |
LocationAccuracy |
Android | iOS | Typical |
|---|---|---|---|
passive |
lowest | 3 km | only fixes other apps asked for |
low |
low power | 1 km | city block to city |
balanced |
balanced | 100 m | Wi-Fi and cell, ≈ 100 m |
high |
high accuracy | best | GPS, ≈ 5–20 m |
best |
high accuracy | best for navigation | GPS + sensor fusion on iOS |
For attendance with a tight radius, use LocationProfile.checkIn.copyWith(accuracy: LocationAccuracy.high).
Getting a location
final r = await BaabaLocation.instance.get(
profile: LocationProfile.checkIn,
withAddress: true,
locale: const Locale('ur', 'PK'), // address language
context: context, // optional: where dialogs appear
);
The flow, in order — each step only when needed:
- Configuration — a missing manifest /
Info.plistentry fails withmisconfiguredbefore anything is asked (on iOS that would otherwise be a silent denial). - Permission — the
baaba_permission_handlerflow.handlePermission: falseskips it;LocationConfig.permissionStepreplaces it with your own UI. - Services — Android's in-app "turn on location" dialog (with Play services), otherwise a dialog that opens location settings and waits for the return. Switching location on from the notification shade also counts.
handleServices: falseskips it. - Precision — if the profile wants precise and only approximate is allowed, it asks once; if the user keeps approximate, the result is still a success with
isApproximate: true(orapproximateOnlyforPrecisionNeed.required). - Fix — a fix younger than the profile's
maxAgeis reused without waking the GPS (source: cached), otherwise one is requested. - Fallback — no fix in time? The last known location up to
maxFallbackAge(30 min) is returned as a success,source: lastKnownOsorlastKnownStored, with itsage. - Address — with
withAddress. A geocoding failure never fails the result:addressisnullandaddressFailuresays why.
LocationResult carries position, address, addressFailure, source, age, isApproximate, isMocked, and steps. Read position only after isSuccess (or use positionOrNull).
The widget for the common case:
LocationBuilder(
withAddress: true,
builder: (context, result) =>
Text(result.address?.formatted ?? result.position.toString()),
// loadingBuilder / failureBuilder are optional; the default failure view
// shows the message and a button that runs resolve(failure).
)
Streams
// High level: setup once, then results (with the address if asked).
BaabaLocation.instance.watch(profile: LocationProfile.delivery, withAddress: true);
// Low level: fixes only, no UI.
BaabaLocation.instance.positionStream(
accuracy: LocationAccuracy.high,
distanceFilter: 20,
interval: const Duration(seconds: 5),
);
- Every listener shares one platform subscription that uses the most demanding settings; each listener still gets its own distance filter, interval and filters. The last cancel stops it.
- Streams never error and never end because of state. When location services go off, permission changes or the app goes to the background, they pause;
statusStreamsays why; they resume by themselves. watch(withAddress: true)looks up the address again only after moving 50 m or after 10 minutes (GeocodingConfig.watchRegeocodeMeters/watchRegeocodeTtl), never per fix.
Background tracking (opt-in)
By default streams pause in the background. Pass BackgroundLocation to keep one running — a delivery trip, a patrol, a run:
BaabaLocation.instance.positionStream(
accuracy: LocationAccuracy.high,
distanceFilter: 15,
background: const BackgroundLocation(
notificationTitle: 'Trip in progress',
notificationText: 'Your location is shared until you end the trip.',
notificationIcon: 'drawable/ic_tracking',
),
);
- Android runs a
locationforeground service with that notification. Start the stream while the app is visible. DeclareFOREGROUND_SERVICEandFOREGROUND_SERVICE_LOCATION; Google Play then asks for a foreground-service declaration in the Console. - iOS keeps updating in the background with the blue indicator. Needs
UIBackgroundModes→location; without it the stream stays foreground-only andpositionStreamreportsmisconfigured. - While-in-use permission is enough.
BackgroundLocation also takes notificationChannelName ('Location'), notificationIcon ('mipmap/ic_launcher', as type/name in res/), notificationColor, ongoing (true), wakeLock / wifiLock (false; Android) and showIosIndicator (true).
What it does not do: fixes live only while the process lives. If the OS or the user kills the app, tracking stops, nothing is buffered on disk, and it does not restart after a reboot. Apps that need that use a dedicated background-location package.
Last known location
final r = await BaabaLocation.instance.lastKnownPosition(maxAge: const Duration(hours: 1));
// r.source: lastKnownOs or lastKnownStored; r.age; r.address (if one was looked up)
The OS's last known location is often missing (new device, after a reboot, after location was off) or old, so the package keeps its own copy of the latest accepted fix (and its address), and returns the newer of the two — the more accurate one when they are within 30 s.
- Stored in the app's cache directory, which neither Android Auto Backup nor iCloud backs up: a location is never restored onto another device. The OS may clear it when storage runs low.
- Written at most every 25 m or 10 s (
storeWriteMinDistance/storeWriteMinInterval), and when the app pauses. - Only fixes that pass
LocationConfig.filtersare stored — withrejectMocked, a spoofed fix is never kept. - On logout and account switch, call
clearStoredLocation(). It deletes the stored copy and the address cache. Turn storing off withpersistLastKnown: false. Nothing stored is ever sent anywhere.
Geocoding
final address = await BaabaLocation.instance.addressFrom(position);
final address2 = await BaabaLocation.instance.addressFrom(null, lat: 31.52, lng: 74.35);
final places = await BaabaLocation.instance.positionsFrom('Mall Road, Lahore');
Address has formatted (one display line, built from the parts when the provider has none), name, street, houseNumber, subLocality, locality, subAdministrativeArea, administrativeArea, postalCode, country, countryCode, raw. Missing parts are null, never '', and nothing is invented — a city-only or plus-code-only answer still gives a usable formatted.
Handled for you:
- Queue and throttling — one request at a time per provider; on
rateLimitedit backs off (1 s, doubling, up tomaxBackoff) instead of failing every call. - Cache — reverse results by a ~20 m grid cell + locale, forward results by the normalised query + locale, with a TTL and a size cap. A stream drifting a few metres never triggers a lookup.
- Cancellation — pass
consumer:; a newer call with the same consumer completes the older one at once withsuperseded(a search box typing quickly sends only what's needed). - Locale — per call, then
GeocodingConfig.locale, then the device's. Part of the cache key. - Fallback providers — on
notSupported,networkorrateLimitedthe next provider is tried; never onnoResult.
The default provider is the platform geocoder (Android Geocoder, iOS CLGeocoder: free, no key). Plug in your own backend, Google or Mapbox:
class MyBackendGeocoder extends GeocodingProvider {
@override
String get name => 'backend';
@override
Future<AddressResult> reverse(double lat, double lng, {Locale? locale}) async { ... }
@override
Future<PositionsResult> forward(String query, {Locale? locale}) async { ... }
}
BaabaLocation.init(LocationConfig(
geocoding: GeocodingConfig(fallbackProviders: [MyBackendGeocoder()]),
));
The example app has a complete OpenStreetMap Nominatim provider.
Permissions
| Call | Shows UI |
|---|---|
get(), watch(), LocationBuilder, ensurePermission(), resolve() |
yes — the baaba_permission_handler flow, with your BaabaPermissions.init wording and theme |
currentPosition(), lastKnownPosition(), positionStream(), checkPermission() |
never — a missing permission is permissionDenied at once |
LocationAccess reports state and precise. Approximate-only access (Android 12+ "Approximate", iOS 14+ Precise off) is its own state: access.approximateOnly. requestPrecise() asks for the upgrade (Android re-request; iOS temporary full accuracy, with iosPrecisePurposeKey).
Setup doctor
final report = await BaabaLocation.instance.diagnose();
print(report.toChecklist());
In debug builds it also prints the checklist. It reports: missing manifest / Info.plist entries with the snippet to add, the iOS purpose key and background mode, permission and precision, location services, a short test fix (no UI), whether the geocoder is present and answering, the stored last known location, device model and OS version, and battery-optimisation state on Android.
With baaba_app_config, drop it into the debug panel in one line:
DebugSection.info('Location', () => report.toMap())
Error handling
No public method throws. Each returns a result, and every failure:
- has a
reasonfrom one closed enum,LocationFailureReason; - has a
messagefor developers (not end users); - has a
resolution—requestPermission,requestPrecise,openAppSettings,openLocationSettings,updatePlayServices,fixConfiguration,retryLaterornone— thatresolve(failure)performs; - carries
details(the missing key, the snippet, the provider) anderror/stackTracewhere relevant; - reaches
LocationObserver.onError(operation, failure, stack)exactly once, with a stable operation name:get,get.address,watch,watch.address,currentPosition,lastKnownPosition,positionStream,addressFrom,positionsFrom,serviceEnabled,checkPermission,ensurePermission,requestPrecise,openLocationSettings,openAppSettings,resolve,diagnose,storage.corrupt,storage.write,callback.permissionStep.
Nothing hangs: every platform call has a timeout, and a whole get() flow is bounded by flowTimeout (3 min). State changes — services off, permission downgraded — arrive on statusStream, not as errors.
Troubleshooting
| Reason | What it means | What diagnose() shows |
Fix |
|---|---|---|---|
misconfigured |
A manifest / Info.plist entry is missing |
✗ Missing … with the snippet | Add the snippet |
permissionDenied |
Not allowed yet | Permission not granted yet | resolve(failure) |
permissionPermanentlyDenied |
Refused for good | permanently denied | resolve(failure) opens Settings with steps |
permissionRestricted |
Parental controls / MDM (iOS) | restricted | Nothing the app can do |
approximateOnly |
The profile needs precise | Only approximate location is allowed | resolve(failure); on iOS set iosPrecisePurposeKey |
serviceDisabled |
Location is off | Location services are off | resolve(failure) |
timeout |
No fix in time, and no last known within maxFallbackAge |
No fix: timeout | Indoors? Try balanced, or a longer timeout |
noFix |
The platform gave up, nothing stored | No fix: noFix | As above |
notSupported |
No location hardware, or no platform geocoder | No platform geocoder | Add a fallback GeocodingProvider |
network / rateLimited / providerError |
Geocoding failed | The geocoder did not answer | Retry later; add a fallback provider |
noResult |
The geocoder has nothing there (sea, empty area) | — | Show coordinates instead |
superseded |
A newer geocoding call with the same consumer replaced this one |
— | Nothing: expected while typing |
invalidInput |
An empty query, or coordinates out of range | — | Check the arguments |
platformError |
An unexpected plugin error; see error |
— | Report it; a "not registered" message means stop and rerun the app |
notApplicable |
Web or desktop | unsupported platform | — |
playServicesMissing, playServicesOutdated, startNotAllowed, storageFull, limitReached and cancelledByUser are reserved and never returned today; a switch over the enum still has to cover them.
Gotchas
positionthrows on a failure. CheckisSuccessfirst, or usepositionOrNull.balancedis about 100 m. Attendance with a tight radius needshigh.- Geolocator allows one position stream per app. If your own code also calls
Geolocator.getPositionStream(), the two share it — useBaabaLocation.instance.positionStream()instead. - iOS doesn't open location settings directly. The services dialog opens the app's Settings page; the user goes back to Privacy → Location Services from there.
- Ages use the device clock (geolocator has no monotonic time). A clock moved backwards marks a stored value's age as unreliable instead of guessing.
- A permission dialog still on screen when
flowTimeoutpasses stays there until the user answers; the flow has already returnedtimeoutand ignores the late answer. - Background tracking stops if the app is killed. See Background tracking.
Testing
import 'package:baaba_location/testing.dart';
final location = FakeBaabaLocation(permission: LocationAccess.denied)
..simulateUserResponse(FakeUserResponse.grantPrecise)
..setCurrentFix(Position(latitude: 31.52, longitude: 74.35, timestamp: DateTime.now()));
final r = await location.get();
expect(r.isSuccess, isTrue);
expect(r.steps, contains(LocationStep.requestedPermission));
expect(location.callsTo('get'), hasLength(1));
FakeBaabaLocation is the real implementation on a simulated device, so the flow, filters, fallback, cache and geocoding queue behave as in production. Script it with emit, playRoute, setServiceEnabled, setPermission, setLastKnown / setOsLastKnown / setStoredLastKnown, failNext(reason), timeoutNext, setMisconfigured, setBackground, stubAddress, stubPositions, geocodingFails, setOnline and simulateUserResponse. Every call is recorded in calls. Use it in widget tests with LocationBuilder(location: fake, …) or BaabaLocation.instance = fake.
Configuration reference
LocationConfig
| Field | Default | |
|---|---|---|
observer |
null |
LocationObserver: onPosition, onServiceStatus, onPermission, onFiltered, onResult, onError |
logging |
debug builds | [baaba_location] log lines |
logPrint |
debugPrint |
Where log lines go |
defaultProfile |
checkIn |
For get() / watch() without a profile |
filters |
null island + duplicates | Applied to every fix and to what is stored |
persistLastKnown |
true |
Keep the stored last known location |
storeWriteMinDistance / storeWriteMinInterval |
25 m / 10 s | Write throttling |
maxFallbackAge |
30 min | Oldest last known location get() falls back to |
flowTimeout |
3 min | Bound on a whole get() flow |
platformTimeout |
10 s | Bound on platform calls without their own timeout |
permissionStep |
null |
Replace the permission step with your own UI |
permissions |
BaabaPermissions.instance |
The permission handler to use |
permissionOptions |
null |
Per-call PermissionRequestOptions |
iosPrecisePurposeKey |
null |
Key in NSLocationTemporaryUsageDescriptionDictionary |
geocoding |
GeocodingConfig() |
See below |
GeocodingConfig
| Field | Default | |
|---|---|---|
provider |
platform geocoder | First provider |
fallbackProviders |
[] |
Tried on notSupported, network, rateLimited |
locale |
device | Address language |
reverseCachePrecisionMeters |
20 | Cache grid cell size, in metres (above zero) |
cacheTtl / cacheSize |
24 h / 200 | Per direction |
offlineCacheRangeMeters |
200 | When every provider fails with network, a cached address this close is returned |
watchRegeocodeMeters / watchRegeocodeTtl |
50 m / 10 min | When watch looks up again |
timeout |
10 s | Per call, queueing and back-off included |
maxBackoff |
30 s | Back-off ceiling |
PositionFilters
| Field | Default | |
|---|---|---|
maxAccuracyMeters |
off | Drop fixes less accurate than this |
maxSpeedMps |
off | Drop jumps faster than this (allowing for both fixes' accuracy; resets after 3 rejections or a 2-minute gap) |
rejectMocked |
false |
Drop mocked fixes; they are reported to onFiltered |
rejectNullIsland |
true |
Drop exactly 0,0 |
dropDuplicates |
true |
Drop duplicates and out-of-order fixes |
jumpResetCount / jumpResetAfter |
3 / 2 min | When the jump filter gives up and accepts the new place |
PositionFilters.standard is the default; PositionFilters.none turns every filter off.
Helpers
BaabaLocation.distanceBetween(a, b) (metres) and BaabaLocation.bearingBetween(a, b) (degrees from north) take two LatLngs and work on every platform.
License
MIT
Libraries
- baaba_location
- Foreground location for Flutter that never throws.
- testing
- Test doubles for consumers of this package.