Replaces OSM's tan/cream default raster style with CARTO's "Dark Matter" basemap (both RideMap and the route planner's independent TileLayer), so the map itself looks like the dark mockups rather than a light basemap under a dark overlay. Shares one urlTemplate/subdomains/maxNativeZoom across both call sites instead of two independently-drifting copies. Re-points the V3-11 tile cache at a versioned directory (tiles/carto_dark_v1) since TileKey(z, x, y) carries no provider identity and would otherwise silently serve stale tan tiles cached under the old scheme. Added a test proving cache isolation holds across provider directories. Adds a shared TileAttribution widget crediting both OpenStreetMap (CARTO's style is still built from OSM data) and CARTO, composed into every map rather than duplicated per screen -- OSM-only credit stopped being sufficient once a second tile host entered the mix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
9.6 KiB
UI-09 — Monochrome dark map tiles
Depends on nothing · Size S · Status Done
Goal
Replace the tan/cream default OpenStreetMap raster style with a monochrome dark map, so the map actually looks like it belongs in a dark-mode HUD instead of a light basemap punched through a dark overlay.
Context
RideMap, the Plan/Route Planning canvas, and every Stitch mockup all assume a dark or
near-monochrome map underneath the HUD. Today's TileLayer (ride_map.dart) points at
tile.openstreetmap.org, OSM's standard light/tan default style — the actual on-device
result is nothing like the mockups, regardless of how dark the chrome on top of it is.
This affects every screen that shows a map (Map HUD, Plan, Route Planning, and UI-01's dimmed background on every other tab), so it's worth settling once, early, rather than each screen ticket discovering the same mismatch independently.
Design
Two real options, not one obvious answer:
Option A — switch tile provider to CARTO's "Dark Matter" basemap.
https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png (subdomains a-d, retina
{r} variant available, max native zoom 20). Purpose-built dark, desaturated/monochrome
cartography — closest to the mockups with the least engineering. Free for reasonable
usage same as OSM's own tiles, but it's a different third party with its own usage
policy and attribution requirement ("© OpenStreetMap contributors © CARTO", not just
OSM's own attribution) — a real dependency to add, not a style tweak.
Option B — keep OSM tiles, apply a color filter client-side. Wrap TileLayer in a
ColorFiltered widget using a ColorFilter.matrix that desaturates and inverts/darkens
the tan basemap into a monochrome dark look. No new third party, no new attribution, and
V3-11's offline tile cache keeps working against the exact tiles it already knows about
— but the result is a filtered light map, not tiles actually designed for dark
presentation, and will read as slightly "off" next to CARTO's purpose-built version
(road/building contrast tuned for light backgrounds doesn't always invert cleanly).
Recommendation: Option A. The mockups' whole aesthetic depends on the map itself being dark, not just tinted dark, and CARTO's dark tiles are a well-established, free, no-API-key option already widely used in the Flutter/Leaflet ecosystem for exactly this. The added attribution line and a second host to trust are a small, one-time cost.
V3-11 interaction: the offline tile cache (FileTileCache/CachedTileProvider) is
already keyed by TileKey(z, x, y) with no provider-specific data baked in — but that
means a cache built against OSM tiles and a cache built against CARTO tiles are
indistinguishable to the cache despite being visually different. Switching providers
without a plan here would silently serve stale, wrong-looking cached OSM tiles once
CARTO's URL is live. This needs a cache-busting or provider-tagging fix as part of this
ticket, not a separate one — see Implementation.
Implementation
- Add a
provider-qualified cache key (or a cache-version bump that invalidates existing V3-11 cache contents wholesale) so switching tile sources can't silently mix cached tiles from two visually different sources. - Update
RideMap'sTileLayer(and the Route Planner's separateTileLayerinstance — seeroute_planner_screen.dart) to CARTO's dark tile URL template, correctsubdomains, andmaxNativeZoom: 20. - Update the attribution requirement — check whether
flutter_map'sRichAttributionWidget/AttributionWidgetis in use anywhere already, or add one; OSM-only attribution is no longer sufficient once CARTO tiles are in the mix. - Update
tileUserAgent/any OSM-specific comments inride_map.dartthat assumed OSM's tile servers specifically (the "respect OSM's usage policy" reasoning still applies in spirit to CARTO's own policy, but the specific server being addressed changes). - Re-verify V3-11's tile math/download flow still works end-to-end against the new URL template (tile enumeration and caching are provider-agnostic already; only the fetch URL construction needs updating).
Acceptance criteria
- The map renders in a dark, desaturated/monochrome style matching the Stitch mockups, not OSM's default tan basemap
- Correct attribution for both OpenStreetMap and CARTO is shown wherever the map is shown
- Switching tile providers does not silently serve stale, visually-mismatched cached tiles from before the switch (V3-11's cache is invalidated or provider-tagged)
- Both
RideMapand the Route Planner's independentTileLayerare updated consistently — one dark style everywhere, not just on the screens that happened to get touched first
Tests
- Existing
ride_map/route-planner widget tests updated for the new tile URL and still passing - Unit: cache-key/versioning change is exercised the same way V3-11's eviction tests are — a cache built under the old scheme does not get served for the new provider
Risks
- A second third-party tile host is a real dependency, not a free color swap — its own usage policy, its own risk of being blocked if misused, and its own attribution obligation. Treat it with the same care V3-11 already applies to OSM (rate-limit respecting, no bulk prefetch, real user agent).
- If CARTO's free tier ever proves insufficient or is discontinued, Option B (a color filter over OSM tiles) is the fallback with no new third-party dependency — worth keeping in mind as a documented Plan B, not re-deriving from scratch later.
Out of scope
A fully custom/self-hosted vector tile style — much larger scope, and not needed to hit "looks like the mockups" today.
Outcome
Took the recommended Option A. ride_map.dart now defines tileUrlTemplate
(https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png), tileSubdomains
(a-d), and tileMaxNativeZoom (20, kept separate from the app's own maxTileZoom
clamp of 19 — raising the app's overall zoom ceiling to match CARTO's native maximum was
deliberately left alone rather than folded into this ticket, since it would mean
re-verifying the fit/follow-zoom logic V3-04/V3-05 already tuned against 19). Both
RideMap and route_planner_screen.dart's independent TileLayer now import and use
these same three constants plus retinaMode: true, so there is exactly one dark style
and one set of tile-request parameters across the app, not two independently-drifting
copies.
Cache versioning (the ticket's named risk): tileCacheProvider in
app/providers.dart now points FileTileCache at tiles/carto_dark_v1 instead of the
old bare tiles directory. TileKey(z, x, y) still carries no provider identity, so
the directory segment is the actual version tag — any tiles cached under the old OSM-tan
scheme are simply orphaned in a directory the app no longer reads from, rather than
being silently served under the new dark UI. Added a unit test
(test/tile_cache_test.dart, "a tile cached under one provider directory is not served
from another") proving this isolation holds at the FileTileCache level, the same way
V3-11's own eviction tests exercise cache behavior directly rather than through the UI.
Attribution: added a shared TileAttribution widget (ride_map.dart) wrapping
flutter_map's RichAttributionWidget with TextSourceAttributions for both
"OpenStreetMap contributors" (CARTO's dark style is still built from OSM's underlying
data) and "CARTO" itself — OSM-only credit, which is what the app carried before, stopped
being sufficient the moment a second tile host entered the mix. Composed into both
RideMap and the route planner's FlutterMap, so it's one component discharging the
obligation everywhere a map renders, not a copy-pasted attribution block per screen. Did
not wire up onTap license-page links — that would need the url_launcher package, a
new dependency this ticket has no other reason to add; the obligation is to visibly
credit both sources, not to make the credit tappable.
Tests: flutter analyze clean. flutter test green at 316 tests (315 + the new
cache-isolation test) — no existing test hardcoded the old OSM URL string, so nothing
else needed updating.
Android emulator verification (Medium_Phone_API_35): confirmed CARTO's dark,
desaturated basemap renders on-device (a genuinely dark map, not a tan basemap under a
dark overlay) — a clear visual match for the Stitch mockups' aesthetic, on the Map tab at
full opacity and dimmed correctly on Rides/Plan/Settings via UI-01's existing scrim. The
attribution icon is visibly present in the bottom-left corner on every tab (same shared
map instance). Its tap-to-expand interaction could not be confirmed via adb input tap
in this session — taps at its on-screen coordinates didn't visibly toggle the popup, and
a genuine Android mock-location watermark icon (left over from this session's earlier
adb emu geo fix calls, and confirmed present at the same screen position across
unrelated tabs, which a page-level widget couldn't be) sits immediately next to it,
making the exact tap target ambiguous to hit blindly. This is a dev-tooling
verification gap, not a known defect — the widget itself renders without error and
matches flutter_map's standard, widely-shipped attribution pattern (the same
small-icon-that-expands convention Google Maps and Mapbox both use). Re-verify the
popup's tap behavior with a real touchscreen or Flutter Inspector if it becomes load-
bearing later (e.g., if legal review specifically requires confirming the expand
interaction, not just the icon's presence).