Flutter / Dart SDK
mapatlas_geocoder is a pure-Dart client (no Flutter dependency, sound null safety, zero runtime dependencies) for the v2 geocoding API — session-based autocomplete, place retrieval, forward/reverse geocoding, Valhalla-backed routing, and isochrones. Because the core package is plain Dart, it works server-side too, not just from a Flutter app.
Not published yet
dart pub add mapatlas_geocoder will work once the package is on pub.dev. Until then, add it as a git dependency in pubspec.yaml:
dependencies:
mapatlas_geocoder:
git:
url: https://github.com/MapMetrics/geocoder-sdk
path: FlutterInstall
dart pub add mapatlas_geocoderQuickstart
import 'package:mapatlas_geocoder/mapatlas_geocoder.dart';
Future<void> main() async {
final mapatlas = MapAtlas(token: 'YOUR_API_KEY');
// One session covers a whole search — every keystroke, then the pick.
final session = mapatlas.geocoding.createSession();
// As the user types. Debounced for you; suggestions carry no coordinates.
final results = await session.suggest('Nieuwezijds');
// When they pick one, hand back the Suggestion — not an id.
final place = await session.retrieve(results.first);
print('${place.center.lat}, ${place.center.lon}');
// Resolving several at once is both fewer round trips and cheaper billing —
// see Sessions & Billing.
final places = await session.retrieveBatch(results.take(3).toList());
// One-shot lookups, when there's no user typing to debounce. Both return GeoJSON.
await mapatlas.geocoding.search('Nieuwezijds Voorburgwal 147', country: 'nl');
await mapatlas.geocoding.reverse(lat: 52.37, lon: 4.89);
mapatlas.close(); // releases the underlying HTTP client
}Constructing a client
MapAtlas(
token: 'YOUR_API_KEY', // XOR getToken — exactly one is required
// getToken: () async => await fetchRotatingToken(),
baseUrl: 'https://gateway.mapmetrics-atlas.net', // default
tier: MapAtlasTier.v2, // default; or MapAtlasTier.osm
debounce: Duration(milliseconds: 150), // default; Duration.zero disables
);Use getToken instead of token for a short-lived or asynchronously fetched key (secure storage, or a backend-issued rotating credential) — it's called fresh before every request.
The reactive layer: GeocodeSearchController
GeocodeSearchController wires a search box's whole lifecycle — debounced suggest() calls, one session per search, out-of-order response handling, typed errors — into a single object you drive with controller.query = ... and observe with a StreamBuilder. It's the Dart/Flutter analogue of the useAutocomplete React hook in the sibling @mapmetrics/geocoder package.
It's built on dart:async's Stream, not ValueListenable/ ChangeNotifier — those live in package:flutter/foundation.dart, which this package can't import without depending on Flutter. StreamBuilder consumes a Stream natively, so nothing extra is needed on the Flutter side.
import 'package:flutter/material.dart';
import 'package:mapatlas_geocoder/mapatlas_geocoder.dart';
class AddressField extends StatefulWidget {
const AddressField({super.key, required this.mapatlas});
final MapAtlas mapatlas;
@override
State<AddressField> createState() => _AddressFieldState();
}
class _AddressFieldState extends State<AddressField> {
late final GeocodeSearchController _controller = GeocodeSearchController(
client: widget.mapatlas,
country: 'nl',
);
final _textController = TextEditingController();
@override
void dispose() {
_controller.dispose(); // cancels pending work; safe even mid-request
_textController.dispose();
super.dispose();
}
Future<void> _onSelect(Suggestion suggestion) async {
try {
final place = await _controller.select(suggestion);
_textController.text = suggestion.placeName ?? suggestion.text ?? '';
debugPrint('Selected ${place.center.lat}, ${place.center.lon}');
} on MapAtlasException {
// Already reflected in state.error via the StreamBuilder below.
}
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _textController,
onChanged: (value) => _controller.query = value,
decoration: const InputDecoration(hintText: 'Search an address…'),
),
StreamBuilder<GeocodeSearchState>(
stream: _controller.states,
initialData: _controller.state,
builder: (context, snapshot) {
final state = snapshot.data!;
if (state.isLoading) {
return const LinearProgressIndicator();
}
if (state.error != null) {
return Text(
state.error!.message,
style: TextStyle(color: Theme.of(context).colorScheme.error),
);
}
return ListView(
shrinkWrap: true,
children: [
for (final suggestion in state.suggestions)
ListTile(
title: Text(suggestion.placeName ?? suggestion.text ?? ''),
subtitle: Text(suggestion.layer),
onTap: () => _onSelect(suggestion),
),
],
);
},
),
],
);
}
}Behavior:
- One session per search.
GeocodeSessionis created once, in the constructor, and reused across everyqueryassignment;select()retrieves and closes it, and the nextqueryassignment transparently opens a fresh one. - Out-of-order responses never win. Every request carries a monotonically increasing sequence number; a slow response for an earlier keystroke arriving after a later one's is discarded.
minLength(default 2) short-circuits locally — below it,state.suggestionsclears synchronously with no request and no session activity.- Nothing is emitted after
dispose().statescloses with no error; a request already in flight whendispose()is called resolves harmlessly in the background. - Errors always land as a typed
MapAtlasExceptioninstate.error, never as an unhandled async error.
If you'd rather work with ValueListenable (e.g. to reuse existing ValueListenableBuilder widgets), wrap the stream in three lines of app code — this needs package:flutter/foundation.dart, which is why it isn't built into the package itself:
final notifier = ValueNotifier<GeocodeSearchState>(controller.state);
final sub = controller.states.listen((s) => notifier.value = s);
// Later: sub.cancel(); notifier.dispose();Routing & isochrones
mapatlas.routing covers directions()/directionsSimple(), matrix(), mapMatching(), and optimization() (the path is /optimization/ — /optimize/ 404s); mapatlas.isochrone() returns reachability contours. Costing and ShapeMatch are enums, not raw strings, so a typo like 'motorscooter' is a compile error rather than a silent gateway rejection. Response models are deliberately permissive (a parsed top-level structure plus raw, the full decoded body) since Valhalla's field set is large and version-dependent. See example/routing_example.dart and example/isochrone_example.dart in the package, or the README for full request/response shapes.
The OSM tier
MapAtlasTier.osm routes search()/reverse()/autocomplete() to /osm-geocode///osm-reverse///osm-autocomplete/, using the osm-geocode scope. There's no session or retrieve concept on this tier — createSession() throws TierUnsupportedException, and autocomplete() results already carry coordinates. Rate-limited to 10,000 requests/key/day plus a global monthly cap; QuotaExceededException carries a self-host link once you hit it.
Self-hosting
The free tier's engine is open source: MapMetrics/atlas-osm-geocoder, deployable on Cloudflare Workers with a prebuilt planet bundle. Point this package at your own instance with MapAtlas.selfHostedOsm():
final mapatlas = MapAtlas.selfHostedOsm(baseUrl: 'https://my-worker.workers.dev');
await mapatlas.geocoding.search('Nieuwezijds Voorburgwal 147');
await mapatlas.geocoding.autocomplete('Nieuwezijds');
await mapatlas.geocoding.reverse(lat: 52.37, lon: 4.89);A self-hosted instance takes no token and uses different paths (/search, /reverse, /autocomplete, no token parameter); this package never sends a credential to it, even if you pass token/getToken by mistake — it throws instead. createSession() (and GeocodeSession.retrieve() / retrieveBatch()) throw TierUnsupportedException, exactly as on MapAtlasTier.osm — this engine has no session concept at all.
Choosing a key for a Flutter app
Origin restriction is a browser-only feature — it checks the Origin header a browser sends, and native mobile/desktop apps never send one. Use an unrestricted key in a Flutter app; an origin-restricted key raises OriginRequiredException on every request, by design. See API Keys & Security for the full model.
Errors
Every failure is a MapAtlasException subtype:
| Exception | Cause |
|---|---|
TokenNotFoundException | Key not provisioned. |
TokenInactiveException | Key exists but is deactivated. |
ScopeException | Key lacks the scope the endpoint requires. |
OriginRequiredException | Key is origin-restricted; request had no Origin header. |
OriginNotAllowedException | Key is origin-restricted; request's Origin isn't on the allow list. |
QuotaExceededException | OSM-tier quota exhausted; carries selfHostLink. |
TierUnsupportedException | Operation not supported on the current tier. |
NetworkException | Connection failure, or a response that didn't match the expected shape. |
try {
await mapatlas.geocoding.search('Nieuwezijds Voorburgwal 147', country: 'nl');
} on OriginRequiredException {
// ...
} on MapAtlasException catch (e) {
// catches every other documented failure mode
}Suggestions without an ord
Suggestion carries no coordinates — only retrieve()/retrieveBatch() return them. ord is nullable: check suggestion.isRetrievable (or the nullability of ord itself) before calling retrieve() on a row you're not sure about. Calling retrieve() on a non-retrievable suggestion throws a typed exception explaining why, rather than a silent 404 or a null crash; retrieveBatch() rejects the whole call if any item in the list is non-retrievable.
Testing your own code against this package
HttpTransport is exported so you can inject a fake in your own tests instead of hitting the network:
final mapatlas = MapAtlas(token: 't', transport: myFakeTransport);Endpoint reference
This SDK is a typed wrapper — for the full parameter list, response shape, and gateway-level gotchas each call is protecting you from, see: