Maps: fespalier_maps
fespalier_maps (since 0.13.0) gives an app a pin picker that returns a place: the pin is fixed at the centre, the map moves under it, a search field sits at the bottom, and a name for the point under the pin is shown as a guess. Confirming pops a PickedPlace to the page that asked for it. The map is MapLibre through maplibre_gl, the device position is geolocator, and the geocoder is yours. The same package downloads offline region packs (a rectangle, a style and a zoom range stored in MapLibre's database) with progress, pause, resume, deletion and a storage report, and file packs: one PMTiles file per pack, downloaded over HTTP with Range requests so that a download resumes after the app was closed.
fespalier itself has no map feature: no file kind, no fespalier: key, no fsp command, and app.g.dart is the same bytes. The package is a companion, installed like fespalier_flags. It cannot add a route (fsp scans your lib/app/): you write the page, and the page's body is PinPicker.
A taste first, then the details.
// lib/app/orders/new/page.dart: ask for a place, get it back
final place = await PickPlaceRoute().push<PickedPlace>(context);
if (place != null) {
save(place.point, label: place.guess?.label); // the point is the truth, the label a courtesy
}Contents: Install, Picking a place, Search and guesses, Where the device is, The map, Offline packs, Downloading a region, File packs, Storage, Telemetry, Testing, Rules and what it costs, Not built yet.
Install
Add the package next to fespalier with the same url and the same ref: pub resolves the two to one package only then. The tag must be a release that contains the package (0.13.0 or later). The install block, with the version release-please keeps current, is in the package's README.
The package depends on maplibre_gl (>=0.27.1 <0.28.0), geolocator (>=14.0.0 <15.0.0) and, for the file packs, fespalier_transport (since 0.21.0; the HttpTransport the download goes through, which replaces http, >=1.5.0 <2.0.0, as a dependency) and crypto (>=3.0.6 <4.0.0), two pure Dart packages with no platform side, and on fespalier_download, which does the file packs' transfer (list it next to fespalier_maps with the same ref). An app that lists the package links the two plugins: each is imported by one library only (maplibre.dart, geolocator.dart), but a platform plugin is linked whether or not it is imported. Both ranges resolve on Flutter 3.35, fespalier's floor, with the lowest versions they allow (CI's floor job runs flutter pub downgrade, flutter analyze and flutter test on the package). Two things are not checked by any CI job here and need a device or a build of your app: that the map draws, and that your Android toolchain builds maplibre_gl (its 0.26.0 changelog lists a Gradle, Kotlin and Android Gradle Plugin update, and 0.27.1 needs Flutter 3.29, which the 3.35 floor satisfies).
The platform setup is the plugins' own: geolocator needs the location permission strings in Info.plist (NSLocationWhenInUseUsageDescription) and the manifest (ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION), and MapLibre needs a style (a URL or its JSON) that you are allowed to use. See each plugin's README.
Picking a place
Three parts: a route you write, a body that is PinPicker, and three small widgets that are yours (the guess card, the search field, the confirm button), so the picker looks like the rest of your app.
// lib/app/pick-place/page.dart -> /pick-place, and PickPlaceRoute
class PickPlacePage extends StatelessWidget {
const PickPlacePage({super.key});
@override
Widget build(BuildContext context) => Scaffold(
body: PinPicker(
map: _map,
geocoder: _geocoder,
position: const GeolocatorPositionSource(),
guess: (context, g) => GuessCard(g),
searchField: (context, search) => PlaceSearchField(search),
confirm: (context, confirm) => FilledButton(onPressed: confirm, child: const Text('Use this place')),
),
);
}How it behaves, each point pinned by a test:
- The pin is fixed and the map moves under it. The tip of the default marker is the point, at the map's centre. A custom
pin:widget is placed with its bottom centre there. The pin never moves while the map does. - Seeded once. Without an
initial:camera, the picker asks thePositionSourceonce and moves the map to the fix (zoom 16 by default,focusZoom). With aninitial:(editing a saved place) it starts there and does not ask until the person taps "use my location". The map's first rest, on the whole world, is not a choice: the pin has no point (PinGuess.centeris null, andconfirmis null) until a fix lands, a result is picked, or the person moves the map. A pan that comes to rest while the fix is still awaited is the person's choice: it is adopted at once and the fix, when it arrives, is dropped. Picking a result does the same. A rest is the person's choice only after the map reported that its camera started to move (MapBinding.move(), the map's camera-move event): a rest with no move before it is ignored while nothing has been chosen, so a map or a test that only reportsidlenever gives the pin a point. While the fix is awaited the rest must also be away from where the map started (about 0.01 degree, see Where the device is). - Reverse geocoding runs when the map comes to rest, on the map's idle event, never while it moves, and there is no debounce timer. At most one reverse request is in flight: the newest rest waits (replacing an older one that was waiting) until the answer on its way returns, and that answer, made for a point the pin has left, is dropped. A rest at the point already being asked about sends nothing, and a point that already has a name (a picked result) is not asked again. So a person who pans a lot costs the geocoder one request at a time, not one per rest.
- Confirming returns
PickedPlace(point, guess:)throughGoRouter.pop, soawait ...push<PickedPlace>(context)completes with it. Theconfirmcallback is null until the pin has a point and the map is at rest. The guess comes with the place only when it was made for exactly that point, never a stale one.onPicked:replaces the pop. A page opened withgo(or by a link) has nothing to pop to: the picker does not throw, and does nothing unless you passonPicked:. - Nothing opens over the page: no dialog, no menu, no sheet, no snack bar.
test/no_timers_test.dartgrepslib/for them. The location permission prompt is the platform's own.
PinPicker is a body, not a page: put it where the thumb is. The three builders are laid out at its bottom edge (guess, search field with its results, confirm), so a Scaffold body that resizes for the keyboard puts the search field on the keyboard.
Search and guesses
A place name is a guess. A geocoder answers with the nearest address or place it knows; a person standing at one point can be told another street, and a typed "Douala" can match the wrong town. So the picker keeps two things apart: the point is the truth, and a PlaceGuess is a courtesy label. PinGuess.guess is where you show it, with its caveat (the sample card says "Best guess"). Store the point; keep the label only to show.
PinSearch is what the search field is told: query, results, busy, failed, and the actions submit(query), pick(place), useMyLocation() and clear(). submit is for the keyboard's search action, not for every keystroke (a public geocoder's terms usually forbid search-as-you-type, and the package has no debounce timer to make it polite). The query is biased towards the pin (near:) and sent with the locale: you give the picker. pick moves the map to the result and shows the result's label as the guess for it.
The geocoder is an interface, and the network and its terms of use are yours:
abstract interface class Geocoder {
Future<List<PlaceGuess>> search(String query, {GeoPoint? near, String? locale});
Future<PlaceGuess?> reverse(GeoPoint point, {String? locale});
}Throw on failure: the picker shows "no answer" (PinSearch.failed, PinGuess.failed) and never keeps or shows the error's text. The skill's geocoders.md has compiled recipes for Nominatim, Photon and your own backend. The Nominatim one implements its public server's policy that you must not skip: requests at least a second apart, an LRU cache of answers, an identifying User-Agent or Referer, and no autocomplete. The picker itself sends one reverse request at a time but does no spacing or caching: that is the geocoder's.
Where the device is
PositionSource.current() completes with a PositionFix, never an exception, so you render each case: Fixed(point, accuracyMeters), ServiceOff, Denied(permanent) and Unavailable. package:fespalier_maps/geolocator.dart has GeolocatorPositionSource, which checks that the service is on, asks for the permission with the platform's own prompt when it is denied, and takes one fix (LocationAccuracy.high, a 15 second limit that is geolocator's own). The picker keeps the last answer in PinGuess.fix: a refusal leaves the map where it is and lets you show a hint with a button to useMyLocation again, or to open the system settings when it is Denied(permanent: true).
The fix moves the map once, when it arrives. If the person has panned in the meantime it does not: their rest was adopted (a pan still going when the answer arrives is adopted at its rest). A rest counts as a choice only when it is away from where the map started (about a kilometre, 0.01 degree): iOS reports a camera move for any region change and maplibre_gl does not say whether a gesture caused it, so a move and a rest at the start are the map's own and the fix still lands. What needs a device check is a map's own movement that ends somewhere else (a minimum-zoom clamp that recentres, say), which would be taken for a choice. "Use my location" is explicit and always moves the map: a pan made after tapping it, before the answer, is overridden by the fix.
The map
MapSurface is the map under the pin: build(context, camera, binding). The MapBinding is the picker's own link to the map built for it: the map reports its camera to the binding (move(), idle(center)), and the picker moves the map through it (binding.moveTo), so a move can only reach the map it was made for. MapLibreSurface in package:fespalier_maps/maplibre.dart is the one that draws:
const _map = MapLibreSurface(styleString: 'https://tiles.example.com/style.json');A surface is configuration, with value equality, and holds no state about a picker: share it freely (a const, a top-level final, or one built inside build), among pickers one after the other or stacked, a pickup picker that pushes a drop-off picker. Each map it builds has its own controller and receives only the moves of its own picker's binding. A move made before the platform view exists is parked and applied without animation when it does; when the map is disposed the binding is detached. maplibre_gl keeps the camera callbacks of the build that created the platform view, which is why they call the binding, and why the picker keeps one model, and one binding, for its whole life: a new geocoder, locale or map: never makes a new picker. A surface that differs only in its options (a theme switch changes the style) is applied to the same map in place, so the pin, the guess, the search and the position survive; a surface of another type replaces the map, which opens where the pin is, and the old map detaching afterwards does not detach the new one. It rotates and tilts nothing by default (rotateGesturesEnabled: false, tiltGesturesEnabled: false, no compass), and it listens to nothing: the camera events are the widget's own callbacks. The default style is MapLibre's demo style, for trying things out; an app uses its own tiles and follows their attribution.
A map is a platform view: it cannot render in a widget test, and no CI job of this repository draws one. A page that holds a PinPicker is tested with FakeMapSurface (below), and a device run is what shows that the map is right.
Offline packs
A pack is a MapLibre region: the tiles, glyphs and sprites a style needs for a rectangle and a zoom range, stored in MapLibre's offline database so the map draws with no network (since 0.13.0). Three pieces, and only the last imports MapLibre:
RegionPackRequest: thekeyyou name the pack by, theGeoBounds, thestyleUrl,minZoomandmaxZoom.estimatedTilesis the number of Web Mercator tiles under it (tileCount), to show the size of a choice before anything is fetched;isValidis false for an empty key or style, a zoom range out of order or above 22, an inverted rectangle and one that crosses the antimeridian (split it in two).TilePacks, the notifier behind thetilePacksprovider:start,pause,resume,remove,refresh,storage. Its state is aMap<String, PackStatus>;tilePackStatus(key)is one pack's,Absentwhile there is none.OfflineTiles, the port over the database.offlineTilesis the provider that holds it and has no default: the app overrides it withMapLibreOfflineTiles()frompackage:fespalier_maps/maplibre.dart, and a test withFakeOfflineTiles.
// the app's ProviderScope
ProviderScope(
overrides: [offlineTiles.overrideWithValue(const MapLibreOfflineTiles())],
child: const MyApp(),
)The PackStatus values are Absent, Downloading (progress 0 to 1, resource counts, bytes), Paused, Complete (bytes), Interrupted and Failed (a PackFailure: unsupported, invalidRegion, limitExceeded, duplicateRegion, replaced, other; never the platform's text). MapLibre counts resources (tiles, glyph ranges, sprites, the style), not tiles, and the numbers here are its own. Offline downloads exist on Android and iOS only: on the web a download ends in Failed(unsupported).
A MapLibre download does not resume across an app restart. While the app stays alive, pause and resume continue the same download. If the app is closed while a region downloads, MapLibre keeps what it stored but the download is gone: the next session finds the region in the database, and refresh() reports it as Interrupted. resume of an Interrupted (or Failed) pack starts the same definition again; whether MapLibre reuses the resources already stored or fetches them again was not checked on a device. On iOS the plugin's sources suggest an unfinished pack can go on by itself after a restart, silently, since there is no event channel to Dart for it: refresh() then shows it Interrupted or Complete depending on when you ask, and a resume of an Interrupted one restarts a download that may already be running (also not verified on a device). Android's native side also answers "Region is no longer actively tracked" to a resume it has lost, which TilePacks turns into Interrupted as well. A download that resumes from a byte offset needs a file the package controls: the PMTiles file packs are that (HTTP Range).
Downloading a region
final packs = ref.read(tilePacks.notifier);
await packs.refresh(); // once, when the page opens: learns the packs of earlier sessions
await packs.start(doualaPack); // returns when MapLibre has the region; the end is Complete or Failed in the state
final status = ref.watch(tilePackStatus('douala'));- Nothing is read at startup.
refresh()lists the database: a complete region becomesComplete, an unfinished oneInterrupted, one deleted behind your back leaves the state, and a region another tool made (without this package's key in its metadata) is ignored. A pack that downloads in this session is never overwritten by it. - Progress is the state. MapLibre reports a download through a callback that
TilePackshands the port, and the app watches the provider: no stream, no subscription and no timer in the package.startof a key that isDownloadingorPauseddoes nothing; of aCompleteone downloads it again (see the next point). pause,resumeandremovenever leave the state lying. Apausethat MapLibre refuses leavesDownloading;resumeof aPausedpack whose download was lost makes itInterrupted;removestops a running download (its late events are dropped), frees what only that pack needed, and, if the database refuses, marks the packFailedand throws.- The key is yours and stays in the database. It is stored in the region's metadata under
fespalier_maps.key, and never sent to telemetry. - MapLibre treats two regions with the same style, rectangle and zoom range as one (
maplibre_gl0.27). Downloading a definition deletes the region that already has it, at the start of the download, and on iOS the new region takes the old one's id. So a re-download of aCompletepack (or aresumeof anInterruptedorFailedone) replaces the old region at once, and a re-download that then fails has lost the pack;TilePacksshows it asFailed, not as a staleComplete. It also never deletes the region a download just made, whatever the id. And two keys cannot share a definition: starting the second ends inFailed(duplicateRegion)and reaches nothing (starting it would delete the first's region). Give each pack its own rectangle, zoom range or style. refresh()keeps the complete region of a key whatever the ids (iOS ids are not ordered) and deletes its other regions; with none complete it keeps the highest id, and aresumereplaces them.
File packs
A file pack is one file, a PMTiles archive (any single file works), fetched over HTTP into a directory your app owns (since 0.13.0). Since 0.15.0 the transfer itself is fespalier_download's (HttpTransfer and its file port, the foreground backend): FilePacks keeps its API and its states, and a refusal for credentials (401, 403) is still rejected. Where a MapLibre region cannot continue after a restart, a file pack does: the bytes arrive in <destination>.part, a transfer that stops leaves them, and the next attempt asks the server for the rest with Range: bytes=<size of the partial file>-. Pieces:
FilePackRequest(key:, url:, destination:, sha256:, bytes:, headers:).destinationis a path (aString, so the library compiles for the web) in a directory the app got itself, typically its application-support directory: the package depends on nopath_provider.bytesandsha256are the integrity checks; give them whenever the server's release notes or a manifest say them.headerscarries anAuthorizationheader if the server wants one.isValidis false for an empty key, a URL that is nothttporhttps, a size of zero or less and a hash that is not 64 hexadecimal digits.FilePacks, the notifier behind thefilePacksprovider:start,pause,resume,remove,refresh(requests),storage. Its state is the sameMap<String, PackStatus>as region packs, withfilePackStatus(key)for one pack, so a page that shows both kinds reads one vocabulary. ThePackFailures a file pack ends in arenetwork,rejected,sizeMismatch,hashMismatch,storage,invalidRequestandunsupported.packTransport, the provider of theHttpTransportthe download uses (since 0.21.0; it waspackHttpClient, a provider of anhttp.Client, until 0.20.0). It has no default: override it with the app's transport chain (its timeouts, proxy, certificates and session then apply; HTTP transport), and a test withpackage:fespalier_transport/testing.dart'sFakeTransport. The package never closes it.packFileStoreis the provider of the files (dart:io;FakePackFilesin tests).
ProviderScope(
overrides: [packTransport.overrideWithValue(HttpClientTransport(http.Client()))],
child: const MyApp(),
)
final pack = FilePackRequest(
key: 'douala',
url: Uri.parse('https://tiles.example.com/douala-2026-10.pmtiles'),
destination: '${supportDirectory.path}/packs/douala.pmtiles',
bytes: 48213377,
sha256: 'ab12cd34ef56ab12cd34ef56ab12cd34ef56ab12cd34ef56ab12cd34ef56ab12',
);
final packs = ref.read(filePacks.notifier);
await packs.refresh([pack]); // once, when the page opens: disk tells what an earlier session left
unawaited(packs.start(pack)); // returns when the transfer stops; the state is Complete, Failed or Paused
final status = ref.watch(filePackStatus('douala'));How a download goes:
- The file at the destination is always whole and checked. The bytes are written to
<destination>.part; when the transfer ends, the size is compared withbytes, the SHA-256 withsha256(computed from the file, after the last byte, in a background isolate: a large file takes a moment, not frames), and only then is the file renamed into place. A size or a hash that does not match deletes the partial file (Failed(sizeMismatch)orFailed(hashMismatch)): it is not the file the app expects, and appending to it would not help. Withoutbytesandsha256the pack is trusted as the server sent it. - Resuming is a new request with
Range.pauseaborts the request (so a connection that sends nothing cannot hold it; this needs apackTransportthat honours the request'sabortTrigger:HttpClientTransportandDioTransportdo, over a client that does (IOClientinhttp1.5 does, a wrapper such asRetryClientmust forward the request'sabortTrigger), and so must a transport of your own) and keeps the partial file (Paused);resumeasks for the bytes from its size on. The same call after an error (Failed(network)), and after a restart (refreshfound the.partfile and reportsInterrupted(bytes:, progress:)), continues from the last byte on disk, so the label is "Resume", unlike a region pack's "Download again". - The server may not cooperate, and the package copes. One that ignores
Rangeanswers 200 with the whole body: the transfer starts again from the first byte (the partial file is emptied, never appended to). The package sendsIf-Rangewith theETag(or elseLast-Modified) the first response had, kept in<destination>.part.etag, so a file replaced on the server is fetched whole instead of being glued onto old bytes. A CDN may ignoreIf-Range, so a 206 whose validator is not the stored one is dropped and the transfer restarts from zero; and a partial file with no stored validator (and nosha256to catch a splice at the end) is not continued blindly: the transfer starts from the first byte. A 416 to a partial file the server's copy is shorter than gets one restart from zero (a 416 that says the partial file is the whole file finishes it); a 206 that starts at another offset than asked does the same; a second failure isFailed(rejected). Any other status (a 404, a 403, a 5xx) isFailed(rejected)and the partial file stays. The package asks forAccept-Encoding: identity, because offsets count the bytes on the wire (a transport over a client built on the platform's own stack, such ascupertino_httporcronet_http, may decompress anyway: use the defaultdart:ioclient, or a server that does not compress archives). Bodies the transfer does not read (an error page, a restart) are cancelled. startandresumereturn when the transfer stops, not when it begins (unlikeTilePacks.start):unawaited(...)them in a handler, orawaitthem in a test. They never throw for a failure of the transfer, only theProviderException(Riverpod wraps theStateErrorthat explains it) whenpackTransportis not overridden.startof a key that isDownloadingorPauseddoes nothing; of one whose file is already at the destination (with the size the request names) makes itCompletewith no request (a file of another size stays until the new one is whole, and the move replaces it), so to publish a newer archive give it another URL and destination, orremovethe pack first.removeaborts the request and waits for the transfer to close its file, then deletes the finished file, the partial file and the validator. If a file cannot be deleted the pack becomesFailed(other), as a region pack does, and the error is thrown. Astartof the same key while a remove runs takes the key over. A pack of an earlier session is known toremoveandresumeonly afterrefreshhas been given its request: the registry of which packs exist is the app's.- Nothing is read at startup, and disk is the state of record.
refresh(requests)reads each destination and.partfile:Complete,Interruptedor nothing. A pack that is downloading in this session is left alone. - On the web a file pack ends in
Failed(unsupported)before any request (there are no files); thedart:iopart is behind a conditional import. - No timer, no listener, no microtask. The response body is read with
await for, which ends when the transfer does; a pause, a removal and the end of the provider abort the request (theabortTriggerof theTransportRequest, which the app's transport must honour), and the transfer closes its file.
Drawing from the file. A finished PMTiles archive is read directly by MapLibre: a style's vector source whose url is pmtilesSourceUrl(path) (pmtiles://file:///data/.../douala.pmtiles). In the style JSON you pass to MapLibreSurface(styleString: ...):
{
"version": 8,
"sources": {
"basemap": {
"type": "vector",
"url": "pmtiles://file:///data/user/0/app/files/packs/douala.pmtiles"
}
},
"layers": [
{
"id": "water",
"type": "fill",
"source": "basemap",
"source-layer": "water"
}
]
}maplibre_gl 0.27.1 lists "GeoJSON, vector, raster & PMTiles sources" for Android, iOS and the web, and bundles MapLibre Native Android 13.5 and iOS 6.28 (its changelog says PMTiles support came in with Native Android 11.9 and iOS 6.14). What was not checked here is that a pmtiles://file:// source draws on a device: no CI job renders a map. Two caveats are certain: an archive holds tiles only, so the style's glyphs and sprite must be reachable offline too (bundled with the app, or inside a region pack) or labels do not draw; and the web has no local files, so on the web the archive stays a remote pmtiles://https://... source, which is not a pack.
Storage
await ref.read(tilePacks.notifier).storage() answers a StorageUse: perPack (the bytes each pack's resources take, from the statuses), packSum, and onDisk (the size of the database file). The per-pack bytes overlap: a glyph range or an edge tile that two packs share is counted in each, so packSum can be more than the file, and the file also holds MapLibre's ambient cache (the tiles the map cached while it was browsed). Show each row's bytes and onDisk as the total; do not add the rows up as if they were the file.
onDisk is the size of the file getOfflineDatabasePath names (the file only, not the journal beside it); it is null on the web and wherever the plugin does not know the path. For file packs FilePacks.storage() answers the same StorageUse, but the rows add up: perPack is each pack's bytes (finished or partial) and onDisk the sum of the files on disk of the packs this session knows; null where there are no files. The dart:io part sits behind a conditional import, so a web build does not compile it.
Telemetry
With a sink installed, the picker and the packs report four TelemetryOp.custom operations (since 0.13.0). Their names are this package's own contract, pinned by test/telemetry_test.dart: a name is added, never renamed.
| Operation | Attributes | Ends with |
|---|---|---|
fespalier.maps.geocode | fespalier.maps.direction: search or reverse | fespalier.maps.result: found, empty, error, stale |
fespalier.maps.locate | none | fespalier.maps.result: fixed, off, denied, error, stale |
fespalier.maps.pick | fespalier.maps.guessed: whether a guess came with the confirmed place | (starts and ends in the same call) |
fespalier.maps.download | fespalier.maps.kind: region or file | fespalier.maps.result: complete, failed, cancelled |
A dropped answer ends with outcome superseded and result stale. A download is one operation from start (or the restart that resume makes) to its end: a pause leaves it open, remove or the end of the provider's life ends it as cancelled (outcome superseded), and a failure ends it with outcome error. A file pack's operation stays open across pause and resume. It never carries the pack's key, bounds, style, URL, path, hash or progress. A value is never a coordinate, a label, a query, an address or an error's text: the attributes are constants and a boolean, and the test asserts that none of the strings it feeds the picker appears in what a sink hears. With no sink installed, nothing is started.
Testing
package:fespalier_maps/testing.dart has a fake of each seam, so a test needs no map, no network and no platform channel:
FakeMapSurface({idleOnMove}): a box withfakeMapKey;startMove()andidleAt(point)are what a real map reports,movesis what the pickers asked for,shownthe camera it was built with. Each mounted map is aFakeMapMountinmounts(its ownmoves, andstartMove()andidleAtfor that picker), so a test can stack two pickers on one surface and see that a move reaches only its own map.FakeGeocoder({places, reverseAnswer, hold}): answers from tables; withhold: trueevery call waits insearchCallsorreverseCallsuntil the test completes or fails it, which is how to order two answers and see the stale one dropped;errormakes every call throw.FakePositionSource(fix, {hold}): answers withanswer, countscalls, and withholdwaits forrelease().FakePackFiles({files, texts}): the files of a file pack in memory (overridepackFileStorewith it), withbytesOf,textOf,putBytesandputTextto seed what an earlier session left and read what a transfer wrote,failWrites,failRenameandfailDelete,unsupported(the web) andrenames. The server is aFakeTransport(package:fespalier_transport/testing.dart) behindpackTransport: answerRangewith a 206 and aContent-Range, or ignore it with a 200, to play both servers.FakeOfflineTiles({holdDownloads, databaseSize}): the offline database in memory; overrideofflineTileswith it. The test plays MapLibre withprogress(key, fraction, ...),finish(key)andfail(key, reason);seed(request, complete:)puts a region there as an earlier session left it,restart()plays the app being reopened (old downloads are no longer tracked, soresumeof one throws as MapLibre's does),holdDownloadswithreleaseDownloads()puts events before the download call's answer, andpauseError,resumeError,deleteError,downloadErrorandregionsErrormake a call throw.pauses,resumes,deletedanddownloadssay what the packs asked for.
// the page that asked: a real push<PickedPlace> on pumpRouter, the map played by the test
final result = router.push<PickedPlace>('/pick-place');
await tester.pumpAndSettle();
map.idleAt(const GeoPoint(4.05, 9.7));
await tester.pump();
await tester.tap(find.text('Use this place'));
await tester.pumpAndSettle();
expect((await result)?.point, const GeoPoint(4.05, 9.7));The picker's state is also a plain class, PinPickerModel, which a test (or an app that writes its own widget) can drive without a widget at all: seed(), onMove(), onIdle(point), search.submit, pick, confirm(). The pure tile arithmetic, tileCount(bounds, minZoom:, maxZoom:), is tested against values worked out by hand.
Rules and what it costs
- No timer, no polling, no microtask, no listener of its own. The map reports through callbacks the widget hands it, the model is a
ChangeNotifierthatuseListenablewatches, and a request's staleness is a number.test/no_timers_test.dartgrepslib/for timers, delays,.listen(,addListener(, frame callbacks andDateTime.now(). - A geocoder error, a query, a label and a coordinate never leave the picker through telemetry, and an error's text is never kept.
- Downloads add no timer, no listener and no stream of their own (a file pack reads its response with
await for). MapLibre's download callback is the only source of progress,TilePackswrites it into provider state, and a stale event (of a pack removed or started again) is dropped by a generation number. - HTTP only in file packs, and only through the client you provide; no
path_provider, no secret. The package takes no API key and no CDN secret: a style or archive URL with a key in it is public once it is in a build, and the URL, the paths and the headers of a file pack never reach telemetry. - It links two plugins into every app that depends on it, and a MapLibre native library with them. An app that wants a map but not this picker can use
maplibre_gldirectly; an app that wants this picker's model on another map writes aMapSurface.
Not built yet
Markers, routes and other overlays, clustering, search-as-you-type, a geocoder of the package's own, a download that continues in the background when the app is closed (a file pack resumes when the app is opened and starts it again; the background transfer is fespalier_download's to grow, and file packs do not use it yet), a retry policy with backoff (it would need a timer: the app decides when to call resume), several files for one pack, and a web-specific surface (maplibre_gl has a web implementation, but nothing here has run on it, and it has no offline regions and no local files).