From d3974eb4e13ebcfefc446b959611ef5b09c5036d Mon Sep 17 00:00:00 2001 From: uhryniuk Date: Sun, 23 Aug 2026 19:50:16 -0500 Subject: [PATCH] UI-09: switch to CARTO dark map tiles with attribution and cache versioning 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 Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr --- docs/ui-redesign/README.md | 2 +- .../UI-09-monochrome-dark-map-tiles.md | 58 ++++++++++++++++- lib/src/app/providers.dart | 8 ++- lib/src/ui/components/ride_map.dart | 64 +++++++++++++++---- lib/src/ui/routes/route_planner_screen.dart | 16 ++++- test/tile_cache_test.dart | 22 +++++++ 6 files changed, 153 insertions(+), 17 deletions(-) diff --git a/docs/ui-redesign/README.md b/docs/ui-redesign/README.md index 9d3f6ed..68016cd 100644 --- a/docs/ui-redesign/README.md +++ b/docs/ui-redesign/README.md @@ -27,7 +27,7 @@ v3 held to. | [UI-06](UI-06-plan-and-route-planning.md) | Plan & Route Planning redesign | M | UI-01, UI-03 | Not started | | [UI-07](UI-07-rides-history.md) | Rides History redesign | M | UI-01, UI-03 | Not started | | [UI-08](UI-08-theme-modern-professional-dark.md) | Theme migration to Modern Professional Dark | S | — | Done | -| [UI-09](UI-09-monochrome-dark-map-tiles.md) | Monochrome dark map tiles | S | — | Not started | +| [UI-09](UI-09-monochrome-dark-map-tiles.md) | Monochrome dark map tiles | S | — | Done | ## Dependencies diff --git a/docs/ui-redesign/UI-09-monochrome-dark-map-tiles.md b/docs/ui-redesign/UI-09-monochrome-dark-map-tiles.md index 937dd31..5b0e9cb 100644 --- a/docs/ui-redesign/UI-09-monochrome-dark-map-tiles.md +++ b/docs/ui-redesign/UI-09-monochrome-dark-map-tiles.md @@ -1,6 +1,6 @@ # UI-09 — Monochrome dark map tiles -**Depends on** nothing · **Size** S · **Status** Not started +**Depends on** nothing · **Size** S · **Status** Done ## Goal Replace the tan/cream default OpenStreetMap raster style with a monochrome dark map, so @@ -96,3 +96,59 @@ ticket, not a separate one — see Implementation. ## 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). diff --git a/lib/src/app/providers.dart b/lib/src/app/providers.dart index 4818d46..ec268fb 100644 --- a/lib/src/app/providers.dart +++ b/lib/src/app/providers.dart @@ -203,7 +203,13 @@ const int defaultTileCacheMaxBytes = 200 * 1024 * 1024; final tileCacheProvider = FutureProvider((ref) async { final support = await getApplicationSupportDirectory(); final cache = FileTileCache( - directory: Directory('${support.path}/tiles'), + // UI-09: the subdirectory name doubles as the cache version. `TileKey(z, x, y)` + // carries no provider-specific data, so a cache built against OSM's tan tiles and + // one built against CARTO's dark tiles are indistinguishable to `FileTileCache` + // despite being visually incompatible -- switching tile URLs without also changing + // this path would silently serve stale, wrong-looking cached tiles under the new + // dark UI. Bump this suffix again if the tile source ever changes again. + directory: Directory('${support.path}/tiles/carto_dark_v1'), maxBytes: defaultTileCacheMaxBytes, ); ref.onDispose(cache.dispose); diff --git a/lib/src/ui/components/ride_map.dart b/lib/src/ui/components/ride_map.dart index 9f473e9..5c55d61 100644 --- a/lib/src/ui/components/ride_map.dart +++ b/lib/src/ui/components/ride_map.dart @@ -1,17 +1,22 @@ /// Ported from `com.rippr.ui.components.RideMap` (osmdroid → flutter_map). /// -/// Same tile source, same reasoning that chose osmdroid: OpenStreetMap raster tiles, no -/// API key, no billing. +/// UI-09: tiles come from CARTO's "Dark Matter" basemap, not OSM's own tan/cream +/// default -- a purpose-built dark, desaturated style that actually matches the HUD +/// sitting on top of it, rather than a light basemap punched through a dark overlay. +/// Still no API key, no billing; still a third-party tile host to respect the same way +/// OSM's own usage policy was respected before (real user agent, no bulk prefetch, +/// rate-limit-conscious), just a different one, with its own attribution obligation +/// alongside OSM's (CARTO's stylesheet is still built from OSM's underlying data). /// /// ## Four things here are load-bearing /// /// 1. **One polyline per segment**, so a pause leaves a visible gap instead of a /// straight line drawn across town. /// 2. **Decimation is render-only.** `simplify` never touches storage or export. -/// 3. **The zoom clamp.** Fitting a 50 m ride zooms past OpenStreetMap's maximum tile -/// zoom of 19 and renders an empty grid — that shipped in v2.0 and is invisible to -/// any fixture of the wrong size. -/// 4. **A real user agent**, or OSM's tile servers return 403. +/// 3. **The zoom clamp.** Fitting a 50 m ride zooms past this app's zoom ceiling and +/// renders an empty grid — that shipped in v2.0 (against OSM's own tiles, capped at +/// the same 19) and is invisible to any fixture of the wrong size. +/// 4. **A real user agent**, or a tile host's servers return 403. library; import 'package:flutter/material.dart'; @@ -29,13 +34,25 @@ const double simplifyEpsilonM = 5.0; /// fiddly, and flutter_map has no equivalent either. Buckets also read better at a glance. const int speedBucketKmh = 10; -/// OpenStreetMap serves no raster tiles above this. Exceeding it renders a blank grid. +/// This app's own zoom ceiling -- not raised to CARTO's native 20 (see +/// [tileMaxNativeZoom]) without deliberately re-verifying the fit/follow-zoom logic +/// tuned against 19 by earlier tickets. Exceeding it renders an empty grid. const double maxTileZoom = 19.0; +/// CARTO's dark tiles are natively rendered up to this zoom (one past [maxTileZoom]) -- +/// passed to `TileLayer.maxNativeZoom` so a future bump to [maxTileZoom] doesn't also +/// require re-deriving this number. +const int tileMaxNativeZoom = 20; + +/// CARTO's dark basemap. `{s}` is one of [tileSubdomains]; `{r}` is resolved by +/// `TileLayer`'s own `retinaMode` to `@2x` (or empty) based on device pixel ratio. +const String tileUrlTemplate = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'; +const List tileSubdomains = ['a', 'b', 'c', 'd']; + /// What a very short ride falls back to, so streets stay visible. const double shortRideZoom = 17.0; -/// Identifies the app to OSM's servers. Anonymous bulk requests get 403. +/// Identifies the app to the tile host's servers. Anonymous bulk requests get 403. const String tileUserAgent = 'com.rippr.port'; class RideMap extends StatefulWidget { @@ -225,14 +242,18 @@ class _RideMapState extends State with WidgetsBindingObserver { // tile request can fire off-screen. See the lifecycle observer above. if (!_backgrounded) TileLayer( - urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + urlTemplate: tileUrlTemplate, + subdomains: tileSubdomains, + retinaMode: true, userAgentPackageName: tileUserAgent, - maxNativeZoom: maxTileZoom.toInt(), - // Respect OSM's usage policy: render what is looked at, never bulk prefetch. + maxNativeZoom: tileMaxNativeZoom, + // Respect the tile host's usage policy: render what is looked at, never + // bulk prefetch. panBuffer: 0, tileProvider: widget.tileProvider, ), PolylineLayer(polylines: polylines), + const TileAttribution(), ], ), ); @@ -320,3 +341,24 @@ class _RideMapState extends State with WidgetsBindingObserver { return Color.lerp(colors.secondary, colors.primary, t)!; } } + +/// UI-09: CARTO's dark tiles are built from OpenStreetMap's underlying data, so both +/// need crediting, not just CARTO's own styling on top -- OSM-only attribution (what +/// this app carried while it used OSM's raw tiles directly) stopped being sufficient the +/// moment CARTO entered the mix. Shared by every `FlutterMap` in the app rather than +/// duplicated per screen, so there's exactly one place this obligation is discharged. +class TileAttribution extends StatelessWidget { + const TileAttribution({super.key}); + + @override + Widget build(BuildContext context) => const RichAttributionWidget( + alignment: AttributionAlignment.bottomLeft, + // No `onTap` -- opening the license pages would need `url_launcher`, a new + // dependency this ticket has no other reason to add. The obligation is to credit + // both sources visibly, not to make the credit tappable. + attributions: [ + TextSourceAttribution('OpenStreetMap contributors'), + TextSourceAttribution('CARTO'), + ], + ); +} diff --git a/lib/src/ui/routes/route_planner_screen.dart b/lib/src/ui/routes/route_planner_screen.dart index 0e692c1..4a6e3fb 100644 --- a/lib/src/ui/routes/route_planner_screen.dart +++ b/lib/src/ui/routes/route_planner_screen.dart @@ -19,7 +19,14 @@ import '../../geo/geo.dart' as geo; import '../../tiles/tile_cache.dart'; import '../../tiles/tile_downloader.dart'; import '../../tiles/tile_math.dart'; -import '../components/ride_map.dart' show maxTileZoom, tileUserAgent; +import '../components/ride_map.dart' + show + TileAttribution, + maxTileZoom, + tileMaxNativeZoom, + tileSubdomains, + tileUrlTemplate, + tileUserAgent; import '../components/stats.dart' show confirmDialog; import '../format.dart'; @@ -143,9 +150,11 @@ class _RoutePlannerScreenState extends ConsumerState { ), children: [ TileLayer( - urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + urlTemplate: tileUrlTemplate, + subdomains: tileSubdomains, + retinaMode: true, userAgentPackageName: tileUserAgent, - maxNativeZoom: maxTileZoom.toInt(), + maxNativeZoom: tileMaxNativeZoom, panBuffer: 0, tileProvider: ref.watch(cachedTileProviderProvider), ), @@ -197,6 +206,7 @@ class _RoutePlannerScreenState extends ConsumerState { ), ], ), + const TileAttribution(), ], ), ), diff --git a/test/tile_cache_test.dart b/test/tile_cache_test.dart index 03b33df..a524409 100644 --- a/test/tile_cache_test.dart +++ b/test/tile_cache_test.dart @@ -89,4 +89,26 @@ void main() { expect(await reopened.get(const TileKey(5, 10, 10)), isNotNull); expect(await reopened.sizeBytes(), 64); }); + + test('a tile cached under one provider directory is not served from another ' + '(UI-09)', () async { + // `TileKey` carries no provider identity -- (z, x, y) alone can't tell an OSM tan + // tile from a CARTO dark one at the same coordinates. `tileCacheProvider` (see + // app/providers.dart) relies entirely on giving each tile source its own + // subdirectory to prevent a switch from silently serving stale, visually-mismatched + // tiles. This proves that isolation actually holds at the `FileTileCache` level. + final osm = FileTileCache( + directory: Directory('${tempDir.path}/osm'), + maxBytes: 1024 * 1024, + ); + final cartoDark = FileTileCache( + directory: Directory('${tempDir.path}/carto_dark_v1'), + maxBytes: 1024 * 1024, + ); + + await osm.put(const TileKey(5, 10, 10), bytesOfSize(64)); + + expect(await cartoDark.get(const TileKey(5, 10, 10)), isNull, + reason: 'a tile cached under the old provider must not leak into the new one'); + }); }