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:
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user