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
v1is centralized and appended automatically. Do not add/v1tobaseUrl. - 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: trueand 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.ClientusinghttpClient: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.
Performance and smooth search
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.assetor.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, useonMarkerTapto show your own sheet or dialog.UbHubMapControlleralso hasshowInfoWindow(id),hideInfoWindow(),zoomIn()andzoomOut(). onTapstill reports taps on the basemap (not on a marker or shape).- Circles —
UbHubCircle(center, radiusMeters: 500)draws a meter-accurate circle (scales with zoom);onCircleTapreports taps. - Camera and gestures —
minZoom/maxZoom(0–16),cameraBounds(UbHubBounds),compassEnabled,rotateGesturesEnabled,scrollGesturesEnabled,zoomGesturesEnabled,tiltGesturesEnabled(2D by default),doubleClickZoomEnabled, andgestureRecognizers. - Long press and camera events —
onLongPress,onCameraMoveandonCameraIdle(each aUbHubCameraPosition); read the current camera withUbHubMapController.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.
noneleaves 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. Keepopacityat 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. Chooseflatorextrudedto control them. - Detailed 3D buildings and trees.
UbHubBuildings.detaileddraws the detailed 3D buildings as parts (wings, towers, roof steps), each raised to its own height, andUbHubTrees.extrudeddraws 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 toextrudedandpoints. 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.