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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
This commit is contained in:
2026-08-23 19:50:16 -05:00
parent 13017b8ee8
commit d3974eb4e1
6 changed files with 153 additions and 17 deletions

View File

@@ -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-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-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-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 ## Dependencies

View File

@@ -1,6 +1,6 @@
# UI-09 — Monochrome dark map tiles # UI-09 — Monochrome dark map tiles
**Depends on** nothing · **Size** S · **Status** Not started **Depends on** nothing · **Size** S · **Status** Done
## Goal ## Goal
Replace the tan/cream default OpenStreetMap raster style with a monochrome dark map, so 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 ## Out of scope
A fully custom/self-hosted vector tile style — much larger scope, and not needed to hit A fully custom/self-hosted vector tile style — much larger scope, and not needed to hit
"looks like the mockups" today. "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).

View File

@@ -203,7 +203,13 @@ const int defaultTileCacheMaxBytes = 200 * 1024 * 1024;
final tileCacheProvider = FutureProvider<TileCache>((ref) async { final tileCacheProvider = FutureProvider<TileCache>((ref) async {
final support = await getApplicationSupportDirectory(); final support = await getApplicationSupportDirectory();
final cache = FileTileCache( 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, maxBytes: defaultTileCacheMaxBytes,
); );
ref.onDispose(cache.dispose); ref.onDispose(cache.dispose);

View File

@@ -1,17 +1,22 @@
/// Ported from `com.rippr.ui.components.RideMap` (osmdroid → flutter_map). /// Ported from `com.rippr.ui.components.RideMap` (osmdroid → flutter_map).
/// ///
/// Same tile source, same reasoning that chose osmdroid: OpenStreetMap raster tiles, no /// UI-09: tiles come from CARTO's "Dark Matter" basemap, not OSM's own tan/cream
/// API key, no billing. /// 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 /// ## Four things here are load-bearing
/// ///
/// 1. **One polyline per segment**, so a pause leaves a visible gap instead of a /// 1. **One polyline per segment**, so a pause leaves a visible gap instead of a
/// straight line drawn across town. /// straight line drawn across town.
/// 2. **Decimation is render-only.** `simplify` never touches storage or export. /// 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 /// 3. **The zoom clamp.** Fitting a 50 m ride zooms past this app's zoom ceiling and
/// zoom of 19 and renders an empty grid — that shipped in v2.0 and is invisible to /// renders an empty grid — that shipped in v2.0 (against OSM's own tiles, capped at
/// any fixture of the wrong size. /// the same 19) and is invisible to any fixture of the wrong size.
/// 4. **A real user agent**, or OSM's tile servers return 403. /// 4. **A real user agent**, or a tile host's servers return 403.
library; library;
import 'package:flutter/material.dart'; 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. /// fiddly, and flutter_map has no equivalent either. Buckets also read better at a glance.
const int speedBucketKmh = 10; 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; 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<String> tileSubdomains = ['a', 'b', 'c', 'd'];
/// What a very short ride falls back to, so streets stay visible. /// What a very short ride falls back to, so streets stay visible.
const double shortRideZoom = 17.0; 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'; const String tileUserAgent = 'com.rippr.port';
class RideMap extends StatefulWidget { class RideMap extends StatefulWidget {
@@ -225,14 +242,18 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
// tile request can fire off-screen. See the lifecycle observer above. // tile request can fire off-screen. See the lifecycle observer above.
if (!_backgrounded) if (!_backgrounded)
TileLayer( TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', urlTemplate: tileUrlTemplate,
subdomains: tileSubdomains,
retinaMode: true,
userAgentPackageName: tileUserAgent, userAgentPackageName: tileUserAgent,
maxNativeZoom: maxTileZoom.toInt(), maxNativeZoom: tileMaxNativeZoom,
// Respect OSM's usage policy: render what is looked at, never bulk prefetch. // Respect the tile host's usage policy: render what is looked at, never
// bulk prefetch.
panBuffer: 0, panBuffer: 0,
tileProvider: widget.tileProvider, tileProvider: widget.tileProvider,
), ),
PolylineLayer(polylines: polylines), PolylineLayer(polylines: polylines),
const TileAttribution(),
], ],
), ),
); );
@@ -320,3 +341,24 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
return Color.lerp(colors.secondary, colors.primary, t)!; 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'),
],
);
}

View File

@@ -19,7 +19,14 @@ import '../../geo/geo.dart' as geo;
import '../../tiles/tile_cache.dart'; import '../../tiles/tile_cache.dart';
import '../../tiles/tile_downloader.dart'; import '../../tiles/tile_downloader.dart';
import '../../tiles/tile_math.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 '../components/stats.dart' show confirmDialog;
import '../format.dart'; import '../format.dart';
@@ -143,9 +150,11 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
), ),
children: [ children: [
TileLayer( TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', urlTemplate: tileUrlTemplate,
subdomains: tileSubdomains,
retinaMode: true,
userAgentPackageName: tileUserAgent, userAgentPackageName: tileUserAgent,
maxNativeZoom: maxTileZoom.toInt(), maxNativeZoom: tileMaxNativeZoom,
panBuffer: 0, panBuffer: 0,
tileProvider: ref.watch(cachedTileProviderProvider), tileProvider: ref.watch(cachedTileProviderProvider),
), ),
@@ -197,6 +206,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
), ),
], ],
), ),
const TileAttribution(),
], ],
), ),
), ),

View File

@@ -89,4 +89,26 @@ void main() {
expect(await reopened.get(const TileKey(5, 10, 10)), isNotNull); expect(await reopened.get(const TileKey(5, 10, 10)), isNotNull);
expect(await reopened.sizeBytes(), 64); 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');
});
} }