# 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 1. 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. 2. Update `RideMap`'s `TileLayer` (and the Route Planner's separate `TileLayer` instance — see `route_planner_screen.dart`) to CARTO's dark tile URL template, correct `subdomains`, and `maxNativeZoom: 20`. 3. Update the attribution requirement — check whether `flutter_map`'s `RichAttributionWidget`/`AttributionWidget` is in use anywhere already, or add one; OSM-only attribution is no longer sufficient once CARTO tiles are in the mix. 4. Update `tileUserAgent`/any OSM-specific comments in `ride_map.dart` that 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). 5. 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 `RideMap` and the Route Planner's independent `TileLayer` are 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 `TextSourceAttribution`s 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).