UBHUB Maps SDK

ubhub_maps is the official all-in-one Flutter SDK for UBHUB: native vector maps, place-search widgets, geocoding, reverse geocoding, and routing.

Documentation: developers.ubhub.mn Publisher: developers.ubhub.mn API version: v1 · Package version: 0.8.1 · License: MIT

The default API origin is https://developers.ubhub.mn. Its live OpenAPI contract was verified against the SDK on 2026-10-08. Authenticated live service verification is pending; unit tests use mocked responses. TLS verification is always enabled.

Quickstart, step by step

Full walkthrough with every feature: developers.ubhub.mn/developers/docs/flutter-sdk

Step 1 — Install.

flutter pub add ubhub_maps:^0.8.1

Step 2 — Get an API key in your dashboard. Allow basemap (map), geocode (search) and route (routing). Pass it at build time: flutter run --dart-define=UBHUB_API_KEY=YOUR_KEY. A key shipped in an app is not a secret; use a restricted key.

Step 3 — Show a map. Give it a height (the native map has no intrinsic size).

import 'package:ubhub_maps/ubhub_maps.dart';

UbHubMap(
  options: UbHubMapOptions(apiKey: apiKey),
  height: 480,
);

Step 4 — Add search and a marker. You own the UbHubGeo client; close() it in dispose().

final client = UbHubGeo(apiKey: apiKey);

UbHubPlaceSearch(
  client: client,
  onSelected: (place) async {
    setState(() => _markers = [UbHubMarker(place.location, label: place.name)]);
    await _map?.flyTo(place.location);
  },
);

UbHubMap(
  options: options,
  markers: _markers,
  onReady: (controller) => _map = controller,
);

Step 5 — Add a custom pin. icon loads from an asset or bytes.

UbHubMarker(
  place.location,
  icon: UbHubMarkerIcon.asset('assets/pin.png'),
  label: place.name,
  data: place, // returned on tap
);

Next steps (see the guide for code): clustering (clusterMarkers), shapes (polylines, polygons, circles), taps (onMarkerTap, onLongPress), infoWindowBuilder, and camera (minZoom/maxZoom, cameraBounds, onCameraMove/onCameraIdle).

Installation

flutter pub add ubhub_maps

Import the single supported entry point:

import 'package:ubhub_maps/ubhub_maps.dart';

Authentication

final ubhub = UbHubGeo(apiKey: 'YOUR_API_KEY');

The client adds Authorization: Bearer <API_KEY> automatically. Empty keys and keys containing whitespace are rejected before a request is made. Create keys through the UBHUB developer console. Never commit keys to source control.

Keys embedded in Flutter, mobile, desktop, or browser applications are not secrets: applications can be inspected or reverse engineered. Use restricted keys, endpoint restrictions, quotas, and rate limits. Add application/package restrictions where the backend supports them; this SDK does not implement such restrictions itself. Use your own trusted backend for operations requiring a server-side secret. Environment variables help keep CLI/backend credentials out of source files; they do not make client-distributed credentials secret.

Geocoding

import 'package:ubhub_maps/ubhub_maps.dart';

Future<void> main() async {
  final ubhub = UbHubGeo(apiKey: 'YOUR_API_KEY');
  try {
    final result = await ubhub.geocoding.search('Sukhbaatar Square', limit: 5);
    for (final place in result.features) {
      print('${place.name}: ${place.address}');
      print(place.location);
    }
  } on UbHubException catch (e) {
    print('${e.message} Request: ${e.requestId}');
  } finally {
    ubhub.close();
  }
}

search accepts a 2–200 character query and a limit from 1–20 (default 5). The server performs normalization. Only q and limit are transmitted. results and features refer to the same immutable list. An empty list is a successful no-match response. This is not a GeoJSON FeatureCollection.

Reverse geocoding

import 'package:ubhub_maps/ubhub_maps.dart';

Future<void> main() async {
  final ubhub = UbHubGeo(apiKey: 'YOUR_API_KEY');
  try {
    final result = await ubhub.geocoding.reverse(
      location: const GeoPoint(latitude: 47.9184, longitude: 106.9177),
    );
    for (final place in result.results) {
      print('${place.address ?? place.name}: ${place.distanceMeters} metres');
    }
  } on UbHubException catch (e) {
    print('${e.message} Request: ${e.requestId}');
  } finally {
    ubhub.close();
  }
}

Reverse lookups use GET /v1/geocode with lat and lon. The current API has no /reverse-geocode endpoint. Nullable address/distance fields remain nullable; the SDK does not fabricate a match.

Routing

import 'package:ubhub_maps/ubhub_maps.dart';

Future<void> main() async {
  final ubhub = UbHubGeo(apiKey: 'YOUR_API_KEY');
  try {
    final result = await ubhub.routing.route(
      origin: const GeoPoint(latitude: 47.9184, longitude: 106.9177),
      destination: const GeoPoint(latitude: 47.9220, longitude: 106.9057),
      mode: TravelMode.driving,
    );
    print('${result.route.distanceMeters} metres');
    print('${result.route.durationSeconds} seconds');
    print(result.route.geometry.toJson());
    print(result.waypoints.first.location);
  } on UbHubException catch (e) {
    print('${e.message} Request: ${e.requestId}');
  } finally {
    ubhub.close();
  }
}

Modes are TravelMode.driving (default) and TravelMode.walking. Geometry is a GeoJSON LineString; waypoints preserve submitted and snapped coordinates. There are no invented alternatives, instructions, profile, avoid, or language options. Flutter consumers can hide this package's Route type if they also import Flutter's navigation Route: import 'package:ubhub_maps/ubhub_maps.dart' hide Route;.

Coordinate order and models

GeoPoint uses explicit latitude and longitude names. GeoJSON arrays and route wire parameters use longitude, latitude. Latitude must be finite and within ±90; longitude within ±180. Constant points are checked at service boundaries, or explicitly with point.validate(). Invalid values are never clamped or silently swapped.

The route backend accepts at most 15 fractional decimal places and no exponent notation. The SDK expands representable small values and rejects values that would require rounding. Model fromJson methods throw a safe FormatException for malformed data; requests convert these into ResponseException with HTTP context. toJson retains the public wire field names and omits HTTP metadata. New unknown response fields are ignored. No raw infrastructure data is exposed.

Error handling

import 'package:ubhub_maps/ubhub_maps.dart';

Future<void> lookup(UbHubGeo ubhub) async {
  try {
    await ubhub.geocoding.search('Ulaanbaatar');
  } on RateLimitException catch (e) {
    print('Retry after ${e.retryAfter}; remaining ${e.remaining}');
    print(e.requestId);
  } on UbHubException catch (e) {
    print('${e.message} HTTP ${e.statusCode}, code ${e.code}');
    print(e.requestId);
  }
}
Failure Exception
400, 422 ValidationException
401 AuthenticationException
402 PaymentRequiredException
403 AuthorizationException
404 NotFoundException
408 or client deadline TimeoutException
429 RateLimitException
500–599, including 504 ServerException
Other non-200 status, including redirects HttpException
Transport failure NetworkException
Malformed/oversized success response ResponseException

Invalid local configuration/arguments throw ArgumentError; using a closed client throws StateError. SDK messages are safe local descriptions; raw server messages, details, bodies, headers, and transport exceptions are deliberately not included. Known public API error codes are preserved. Request IDs come from X-Request-ID, falling back to the error envelope. Invalid or credential- containing IDs are discarded. Nothing retries automatically, including 429.

Successful results expose .context.requestId, .context.rateLimit, .context.rateLimitRemaining, .context.rateLimitReset (Unix seconds), and .context.retryAfter. Rate-limit exceptions expose .retryAfter, .limit, .remaining, and .reset. Retry-After accepts integer seconds and IMF-fixdate HTTP dates. Missing or malformed headers remain null. No pagination or attribution schema exists in the current geocoding/routing contract.

Configuration

final ubhub = UbHubGeo(
  apiKey: 'YOUR_API_KEY',
  baseUrl: 'https://developers.ubhub.mn',
  timeout: const Duration(seconds: 10),
  coalesceRequests: true, // Share identical requests while they are in flight.
  debug: true,
  logger: (message) { /* Forward safe diagnostics to your application logger. */ },
);
  • The default endpoint is centralized in ApiClientConfig.defaultBaseUrl.
  • Base URL overrides are advanced configuration. Only trusted HTTPS origins are accepted, optionally with a port and trailing slashes. Paths, credentials, queries, and fragments are rejected. Your API key will be sent to that origin; an override is not a proxy target or a way to bypass backend access policies.
  • Version v1 is centralized and appended automatically. Do not add /v1 to baseUrl.
  • The positive timeout (default 10 seconds) covers sending and response body reading. Timeouts abort supported transports without closing other requests.
  • Responses are buffered up to 8 MiB, then rejected and aborted.
  • Debug logging requires both debug: true and a callback. It includes only method, fixed endpoint path, status, and elapsed time—no queries, keys, response bodies, or authorization headers. Nothing prints by default.
  • Redirects are rejected, never followed. TLS validation cannot be disabled.
  • Reuse the client and call synchronous, idempotent close() when finished.
  • You may inject an http.Client using httpClient: for tests/custom transport. It remains caller-owned. A custom transport must honor redirect/TLS/abort semantics; keep it trusted. Closing the SDK cancels its own pending requests and prevents new requests, but does not close an injected client.

Keep one UbHubGeo client for a feature or application lifetime to reuse connections. Identical concurrent operations on that client automatically share one HTTP request and one immutable result. They share the original deadline, request ID, and rate-limit snapshot. Debug logging runs once per HTTP request. Different queries, limits, modes, or client instances stay independent. Set coalesceRequests: false when each caller needs its own HTTP request.

There is no completed-response cache: once a request finishes or fails, a later call goes back to the API. There is no cross-client credential or result sharing. Closing a client aborts requests shared by its callers.

For a search-as-you-type UI, debounce input in the app and ignore responses for outdated text. Different queries are not cancelled or debounced by the SDK. Run independent operations concurrently with Future.wait when appropriate, but keep concurrency within your plan's rate limits. Client optimizations cannot remove server processing time or network latency.

Response buffering uses typed bytes and fused UTF-8/JSON decoding. JSON/model parsing is still synchronous in the calling isolate; very large responses can still affect a UI frame. See PERFORMANCE.md in the repository for the local benchmark, measurement limits, and reproduction commands.

Supported platforms and migration

Version 0.3.x requires Flutter >=3.29 and Dart >=3.7. Native map rendering supports Android and iOS; search and API services can be used in Flutter apps on other platforms. Web requests remain subject to the API's CORS policy. Desktop/web map widgets show an unsupported-platform message.

Breaking change: this package now requires Flutter. Plain Dart CLI/backend applications must stay on ubhub_maps 0.2.1. Existing API service names remain unchanged. If migrating from ubhub_maps_flutter, remove that dependency and replace its imports with package:ubhub_maps/ubhub_maps.dart. Do not install both plugins: they register the same native transport channel.

Example and tests

The complete map/search example is example/main.dart; native host projects are included in the repository. From example/, run:

flutter pub get
flutter run --dart-define=UBHUB_API_KEY=YOUR_RESTRICTED_KEY

From the repository root:

flutter pub get
dart format .
flutter analyze
flutter test
flutter pub publish --dry-run

Unit tests use mocked HTTP and require no production access. Authenticated physical-device verification and performance measurements remain pending.

License

MIT, copyright 2026 UBHUB. See LICENSE.

Map and search widgets

One dependency and one public import provide both the API client and widgets:

import 'package:ubhub_maps/ubhub_maps.dart';

final client = UbHubGeo(apiKey: apiKey);
// Keep options stable, for example in State.initState.
final options = UbHubMapOptions(apiKey: apiKey);

// The map has no intrinsic size. Give it a height with the `height` parameter,
// or an external constraint (an `Expanded`, or a `SizedBox`).
UbHubMap(
  options: options,
  height: 480,
  markers: const [UbHubMarker(GeoPoint(latitude: 47.9184, longitude: 106.9177))],
  onReady: (controller) { /* controller.flyTo(place.location); */ },
);
UbHubPlaceSearch(client: client, onSelected: (place) { /* Select place. */ });

The caller closes client. The map owns its native transport and closes it on disposal. Size the map with width/height, or let it fill a bounded parent such as an Expanded or SizedBox. Supply route: routeResult.route.geometry.coordinates to draw a route. Replace options to rotate the map credential or trusted API origin.

Markers, clustering, shapes and info windows

Markers are UbHubMarkers. A bare UbHubMarker(point) draws the default dot; add an icon and/or label for a Google-Maps-style pin. Set clusterMarkers: true to group nearby markers into count bubbles that zoom in when tapped.

UbHubMap(
  options: options,
  height: 480,
  clusterMarkers: true,
  markers: [
    const UbHubMarker(GeoPoint(latitude: 47.9184, longitude: 106.9177)), // dot
    UbHubMarker(
      place.location,
      id: place.id,
      icon: UbHubMarkerIcon.asset('assets/pin.png'), // register from assets
      label: place.name,
      data: place, // opaque; returned on tap and to the info window
    ),
  ],
  polylines: [UbHubPolyline(route, color: const Color(0xFF2563EB), width: 5)],
  polygons: [
    UbHubPolygon([ring], strokeColor: const Color(0xFF2563EB), strokeWidth: 2),
  ],
  onMarkerTap: (marker) => debugPrint('Tapped ${marker.id}'),
  onPolylineTap: (line) {},
  onPolygonTap: (polygon) {},
  infoWindowBuilder: (context, marker) => Card(
    child: Padding(padding: const EdgeInsets.all(8), child: Text(marker.label ?? '')),
  ),
);
  • Clustering respects the basemap's maximum zoom of 16; tapping a cluster animates to the cluster's expansion zoom (capped at 16).
  • Icons come from your app via UbHubMarkerIcon.asset or .bytes; they are registered with the map and never touch the basemap style.
  • Info windows are your own widgets, anchored above the tapped marker and kept in place as the camera moves. Without infoWindowBuilder, use onMarkerTap to show your own sheet or dialog. UbHubMapController also has showInfoWindow(id), hideInfoWindow(), zoomIn() and zoomOut().
  • onTap still reports taps on the basemap (not on a marker or shape).
  • Circles — UbHubCircle(center, radiusMeters: 500) draws a meter-accurate circle (scales with zoom); onCircleTap reports taps.
  • Camera and gestures — minZoom/maxZoom (0–16), cameraBounds (UbHubBounds), compassEnabled, rotateGesturesEnabled, scrollGesturesEnabled, zoomGesturesEnabled, tiltGesturesEnabled (2D by default), doubleClickZoomEnabled, and gestureRecognizers.
  • Long press and camera events — onLongPress, onCameraMove and onCameraIdle (each a UbHubCameraPosition); read the current camera with UbHubMapController.queryCamera().

Camera control

UbHubMapController (from onReady) moves the camera and converts between map and screen positions:

// Frame a route, or any markers, with padding in logical pixels.
await controller.fitBounds(
  UbHubBounds.fromPoints(result.route.geometry.coordinates),
  padding: const EdgeInsets.all(48),
);

// Animate with bearing and tilt, or jump without animation.
await controller.animateCamera(
  UbHubCameraPosition(target: place.location, zoom: 15, bearing: 30),
  duration: const Duration(milliseconds: 600),
);
await controller.moveCamera(UbHubCameraPosition(target: place.location, zoom: 13));
await controller.flyTo(place.location, duration: const Duration(seconds: 1));

// Screen positions are logical pixels from the map's top-left corner.
final Offset onScreen = await controller.toScreenLocation(place.location);
final GeoPoint underFinger = await controller.fromScreenLocation(const Offset(100, 200));

final camera = await controller.queryCamera();

fitBounds respects the map's maxZoom and resets bearing and tilt to 0. The synchronous controller.camera getter stays current only while onCameraMove or onCameraIdle is set; queryCamera() always asks the native map.

Migrating from List<GeoPoint> markers (0.3.x → 0.4.0): wrap each point — markers: points.map(UbHubMarker.new).toList(). A bare UbHubMarker renders the same dot as before.

Basemaps

Two basemaps are available with the same key, scope, limits and billing:

UbHubBasemap Style Notes
standard (default) /v1/basemap/style.json The API's default basemap
nova /v1/basemap/nova/style.json The Nova basemap
final options = UbHubMapOptions(apiKey: apiKey, basemap: UbHubBasemap.nova);

To switch basemaps, create new options; the widget then rebuilds the native map. Each map only accepts resources from the basemap it was created with.

3D buildings and trees

Buildings and trees are separate layers. Choose how they are drawn in the options, then restyle or hide them on the widget at any time:

final options = UbHubMapOptions(
  apiKey: apiKey,
  buildings: UbHubBuildings.detailed, // none, flat (default), extruded or detailed
  trees: UbHubTrees.extruded,         // none (default), points or extruded
  initialZoom: 16,
  initialTilt: 55,                    // 0–60 degrees
);

UbHubMap(
  options: options,
  tiltGesturesEnabled: true,
  buildingStyle: const UbHubBuildingStyle(
    color: Color(0xff1d4ed8),
    opacity: 1,
    heightScale: 1.5, // 0–10; exaggerate or flatten heights
  ),
  treeStyle: const UbHubTreeStyle(color: Color(0xff22c55e)),
  showBuildings: showBuildings, // toggle without reloading the map
  showTrees: showTrees,
)
Setting Where Changing it
buildings, trees UbHubMapOptions New options: loads a new style (one map load)
buildingStyle, treeStyle UbHubMap Restyles the layer in place
showBuildings, showTrees UbHubMap Hides or shows the layer in place
  • Default look. Null style values keep the basemap's own building colors (Nova draws navy buildings, the standard basemap light ones). Trees default to one green per kind of tree. A color's alpha is ignored; use opacity.
  • Performance. none leaves the layer and its tiles out of the map entirely: the lightest option. A hidden layer (showBuildings: false) downloads no new tiles while hidden and comes back instantly. Extruded buildings start at zoom 13 and trees at zoom 14. Keep opacity at 1 for extruded buildings: translucent extrusions cost the renderer an extra pass.
  • Nova. With buildings: none, Nova keeps its own flat buildings, which the SDK cannot restyle. Choose flat or extruded to control them.
  • Detailed 3D buildings and trees. UbHubBuildings.detailed draws the detailed 3D buildings as parts (wings, towers, roof steps), each raised to its own height, and UbHubTrees.extruded draws each tree as a trunk and a crown (tiered for conifers). Both render natively with MapLibre on Android and iOS; sloped and curved roofs become steps. Where the API has no detailed buildings or tree shapes yet, they fall back to extruded and points. The website's exact meshes and tree models (3D Tiles) need a 3D Tiles renderer, which MapLibre Native does not have.

Android and iOS setup

The renderer currently requires Android compile SDK 36 and Java 21, and iOS 13 or later. Use an app toolchain compatible with maplibre_gl 0.27.1. Android INTERNET permission is declared by this plugin; no cleartext exception is needed. The iOS integration uses CocoaPods, with MapLibre 6.28.0. Swift Package Manager integration is not provided in this first version; use CocoaPods for the host app.

The repository includes Android and iOS example host projects. Run from its example directory:

flutter run --dart-define=UBHUB_API_KEY=YOUR_RESTRICTED_KEY

The recognized pub.flutter-io.cn example is example/lib/main.dart.

Authentication and resource isolation

Use a restricted key with basemap scope; search additionally needs geocode. Mobile keys are extractable and are not secrets. Restrict endpoints and supported application identities, configure quotas, and rotate keys when necessary. The default trusted API is https://developers.ubhub.mn. Advanced origin overrides receive your key; only configure your own trusted UBHUB API deployment.

The widget validates the style before constructing a renderer. Only vector sources and public basemap, building and tree tile paths from that exact origin are accepted. Native resources are rewritten to a unique .ubhub-map.invalid hostname: an opaque, local routing handle, never an API key or actual network destination. Native adapters translate it into a fixed public basemap request using that map's credential session. Keys never enter URLs or native error messages. Redirects, cookies, ambient credentials, unknown sessions and non-basemap paths are blocked. Normal TLS validation stays enabled. Style fetches are limited to 2 MiB; native resources to 8 MiB, with finite deadlines. Closing a session cancels its requests.

Do not call MapLibre's global/custom header or global HTTP-client configuration APIs in the same app: they replace its process-wide transport hook. Doing so causes UBHUB resources to fail closed, not switch to another map's credentials. MapLibre's hook is global, but each UBHUB map owns its key and upstream connections. Unrelated map requests receive no UBHUB credentials. Do not load untrusted map styles alongside authenticated maps; a local session handle is not an app sandbox.

Native asset disk caching is disabled in this release to avoid credential reuse through a shared renderer cache. Already rendered tiles remain managed by MapLibre. Backend caches and lossless compression still apply. Each style request counts as one map load; tiles, sprites and fonts are not charged but are rate limited. We make no FPS or device-memory claims until real Android/iOS measurements are available.

Search behavior

Search waits 350 ms after typing and requires 2–200 characters. New queries, clearing the field, disabling it and disposal invalidate earlier results. Superseded HTTP requests may already have consumed quota; they are not cached. Loading, no-results and safe error states are displayed. Result lists scroll, and submitting the field selects the first loaded result.

Text is Mongolian when the app locale is mn and English otherwise. Pass strings to choose a language or supply your own translation, and decoration to replace the field decoration entirely:

UbHubPlaceSearch(
  client: client,
  strings: UbHubPlaceSearchStrings.mongolian, // or a custom UbHubPlaceSearchStrings
  // Custom rows for results and suggestions; onTap is null while disabled.
  resultBuilder: (context, place, onTap) => ListTile(
    leading: const Icon(Icons.place),
    title: Text(place.name),
    onTap: onTap,
  ),
  // Shown while the field is focused and empty. The widget does not store
  // anything; keep recent selections yourself in onSelected.
  suggestions: recentPlaces,
  onSelected: (place) => setState(() => recentPlaces = [place, ...recentPlaces.take(4)]),
);

Libraries

ubhub_maps
UBHUB maps, search widgets, and typed public Geospatial API client.