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