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

@@ -203,7 +203,13 @@ const int defaultTileCacheMaxBytes = 200 * 1024 * 1024;
final tileCacheProvider = FutureProvider<TileCache>((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);

View File

@@ -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<String> 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<RideMap> 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<RideMap> 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'),
],
);
}

View File

@@ -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<RoutePlannerScreen> {
),
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<RoutePlannerScreen> {
),
],
),
const TileAttribution(),
],
),
),