Skip to content

Both copy text to your clipboard — Build with AI copies a setup prompt to paste into Claude Code, Cursor, Codex or Copilot; Copy page as Markdown copies this page to paste into a chat. How it works

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:

yaml
dependencies:
  mapatlas_geocoder:
    git:
      url: https://github.com/MapMetrics/geocoder-sdk
      path: Flutter

Install

bash
dart pub add mapatlas_geocoder

Quickstart

dart
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

dart
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.

dart
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. GeocodeSession is created once, in the constructor, and reused across every query assignment; select() retrieves and closes it, and the next query assignment 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.suggestions clears synchronously with no request and no session activity.
  • Nothing is emitted after dispose(). states closes with no error; a request already in flight when dispose() is called resolves harmlessly in the background.
  • Errors always land as a typed MapAtlasException in state.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:

dart
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():

dart
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:

ExceptionCause
TokenNotFoundExceptionKey not provisioned.
TokenInactiveExceptionKey exists but is deactivated.
ScopeExceptionKey lacks the scope the endpoint requires.
OriginRequiredExceptionKey is origin-restricted; request had no Origin header.
OriginNotAllowedExceptionKey is origin-restricted; request's Origin isn't on the allow list.
QuotaExceededExceptionOSM-tier quota exhausted; carries selfHostLink.
TierUnsupportedExceptionOperation not supported on the current tier.
NetworkExceptionConnection failure, or a response that didn't match the expected shape.
dart
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:

dart
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: