feat(train): the instance's own map tiles, drawn by Train's style (ADR 0030, D50)

Protomaps' basemap cut for Europe and served by pmtiles beside Train's server
(compose.maps.yaml), drawn by Train's own style, light and dark, which the
server serves. Only people signed in get tiles: map.source hands a key that
Caddy has the server check before each tile; nothing logs a tile.

Also fixes .dockerignore, which left apps/train/ out of the build context, so
Train's server's and BRouter's images couldn't have been built.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-29 16:04:05 +01:00
commit 190c3872f4
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 3726 additions and 97 deletions

View file

@ -84,6 +84,7 @@ void main() {
RecordingDevice? recording,
WorkoutRepository? workouts,
bool mapReachable = true,
MapSource? mapSource,
HealthConnect? health,
ProfileRepository? profile,
DayRepository? days,
@ -208,7 +209,9 @@ void main() {
health: health,
loop: loop,
maps: MapSourceRepository(
_FakeMapSourceGateway(mapReachable ? openFreeMap : null),
_FakeMapSourceGateway(
mapReachable ? mapSource ?? openFreeMap : null,
),
),
// No tiles: widget tests reach no network.
mapTiles: (source) => const SizedBox.shrink(),
@ -2604,6 +2607,35 @@ void main() {
expect(find.text('Pace'), findsOneWidget);
});
testWidgets("drawn from the instance's own tiles, the page says the "
'instance keeps nothing of the area shown (D50)', (tester) async {
await saveRun(DateTime(2026, 10, 27, 7, 30));
await start(
tester,
usedBefore: true,
workouts: workouts,
mapSource: MapSource(
name: 'train.example.org',
styleUrl: 'https://train.example.org/map/style/light.json',
darkStyleUrl: 'https://train.example.org/map/style/dark.json',
attribution: '© OpenStreetMap contributors · Protomaps',
tileKey: 'm1.20000.mac',
),
);
await tester.tap(find.text('Morning run'));
await tester.pumpAndSettle();
expect(
find.text(
'Map by your instance, train.example.org, from OpenStreetMap: it '
'keeps nothing of the area shown.',
),
findsOneWidget,
);
expect(find.textContaining('sees your device'), findsNothing);
});
testWidgets("a workout with a strap shows the heart's average and highest "
'rate, by split too, and its chart', (tester) async {
await saveRun(DateTime(2026, 10, 27, 7, 30), strap: true);

View file

@ -0,0 +1,70 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart' show MapSource;
import 'package:halkyon_train_flutter/src/feature/map/data/map_style.dart';
import 'package:vector_tile_renderer/vector_tile_renderer.dart';
/// Train's style for the instance's own tiles (ADR 0030, D50), as its
/// server makes it (the fixtures its tests keep), read by the renderer
/// without a warning; and which style and key a source's requests take.
void main() {
for (final name in ['train_style_light_de.json', 'train_style_dark.json']) {
test(
'$name: vector_tile_renderer reads each layer, warning of nothing',
() {
final style = jsonDecode(
File('test/fixtures/map/$name').readAsStringSync(),
) as Map<String, dynamic>;
final warnings = <String>[];
final theme = ThemeReader(logger: _Warnings(warnings)).read(style);
expect(warnings, isEmpty);
expect(theme.layers, hasLength((style['layers'] as List).length));
},
);
}
final own = MapSource(
name: 'train.example.org',
styleUrl: 'https://train.example.org/map/style/light.json',
darkStyleUrl: 'https://train.example.org/map/style/dark.json',
attribution: '© OpenStreetMap contributors · Protomaps',
tileKey: 'm1.20000.mac',
);
final provider = MapSource(
name: 'MapTiler',
styleUrl: 'https://api.maptiler.com/maps/outdoor-v2/style.json?key=k',
attribution: '© MapTiler',
);
test("the instance's own: dark in a dark theme, in the app's language, "
'its tiles asked for with its key', () {
expect(
styleUrlOf(own, dark: true, language: 'de'),
'https://train.example.org/map/style/dark.json?lang=de',
);
expect(styleUrlOf(own), own.styleUrl);
expect(tileHeadersOf(own), {'authorization': 'Bearer m1.20000.mac'});
});
test(
"a provider's: its one style as given, and no key of the instance's",
() {
expect(
styleUrlOf(provider, dark: true, language: 'de'),
provider.styleUrl,
);
expect(tileHeadersOf(provider), isNull);
},
);
}
class _Warnings(final List<String> _warnings) implements Logger {
@override
void log(MessageFunction message) {}
@override
void warn(MessageFunction message) => _warnings.add(message());
}

View file

@ -217,6 +217,49 @@ void main() {
},
);
test("the instance's own tiles are kept with its key; another source's "
'with none (D50)', () async {
final own = MapSource(
name: 'tiles.example',
styleUrl: 'https://tiles.example/map/style/light.json',
darkStyleUrl: 'https://tiles.example/map/style/dark.json',
attribution: '© OpenStreetMap',
tileKey: 'm1.20000.mac',
);
await maps().keep('route-1', north, own);
expect(server.keys, isNotEmpty);
expect(server.keys.toSet(), {'Bearer m1.20000.mac'});
server.keys.clear();
await maps().keep('route-2', east, source);
expect(server.keys.toSet(), {null});
});
test("each style is kept apart: light and dark, in the app's language "
"for the instance's own (D50)", () async {
final own = MapSource(
name: 'tiles.example',
styleUrl: 'https://tiles.example/map/style/light.json',
darkStyleUrl: 'https://tiles.example/map/style/dark.json',
attribution: '© OpenStreetMap',
tileKey: 'm1.20000.mac',
);
await maps().style(own, language: 'de');
await maps().style(own, dark: true, language: 'de');
expect(server.styles, [
'https://tiles.example/map/style/light.json?lang=de',
'https://tiles.example/map/style/dark.json?lang=de',
]);
server.offline = true;
expect((await maps().style(own, language: 'de'))!.json['name'], 'Liberty');
expect(
(await maps().style(own, dark: true, language: 'de'))!.json['name'],
'Dark',
);
expect(await maps().style(own, language: 'en'), isNull);
});
test('the style read once draws offline, with its sprites; another '
"source's does not", () async {
final online = await maps().style(source);
@ -249,6 +292,12 @@ void main() {
/// tiles asked for listed.
class _Tiles() {
final tiles = <String>[];
/// The `Authorization` header each tile came with, null for none.
final keys = <String?>[];
/// The styles asked for, with their query.
final styles = <String>[];
bool failing = false;
bool empty = false;
bool offline = false;
@ -259,19 +308,24 @@ class _Tiles() {
}
final path = request.url.path;
return switch (path) {
'/styles/liberty' => http.Response(
jsonEncode({
'version': 8,
'name': 'Liberty',
'sources': {
'planet': {'type': 'vector', 'url': '/planet'},
'shade': {'type': 'raster-dem', 'url': '/dem'},
},
'sprite': 'https://tiles.example/sprites/liberty',
'layers': <Object?>[],
}),
200,
),
'/styles/liberty' ||
'/map/style/light.json' ||
'/map/style/dark.json' => () {
styles.add(request.url.toString());
return http.Response(
jsonEncode({
'version': 8,
'name': path.contains('dark') ? 'Dark' : 'Liberty',
'sources': {
'planet': {'type': 'vector', 'url': '/planet'},
'shade': {'type': 'raster-dem', 'url': '/dem'},
},
'sprite': 'https://tiles.example/sprites/liberty',
'layers': <Object?>[],
}),
200,
);
}(),
'/planet' => http.Response(
jsonEncode({
'tiles': ['https://tiles.example/planet/{z}/{x}/{y}.pbf'],
@ -292,6 +346,7 @@ class _Tiles() {
),
_ when path.startsWith('/planet/') => () {
tiles.add(path.substring(1).replaceAll('.pbf', ''));
keys.add(request.headers['authorization']);
if (failing) {
return http.Response('', 503);
}

View file

@ -0,0 +1,958 @@
{
"version": 8,
"name": "Halkyon Train, dark",
"sources": {
"protomaps": {
"type": "vector",
"tiles": [
"https://train.example.org/tiles/europe/{z}/{x}/{y}.mvt"
],
"minzoom": 0,
"maxzoom": 15,
"attribution": "<a href=\"https://www.openstreetmap.org/copyright\">© OpenStreetMap contributors</a> · <a href=\"https://protomaps.com\">Protomaps</a>"
}
},
"layers": [
{
"id": "background",
"type": "background",
"paint": {
"background-color": "#1b3042"
}
},
{
"id": "earth",
"type": "fill",
"source": "protomaps",
"source-layer": "earth",
"paint": {
"fill-color": "#1d2024"
}
},
{
"id": "landcover",
"type": "fill",
"source": "protomaps",
"source-layer": "landcover",
"paint": {
"fill-color": [
"match",
[
"get",
"kind"
],
[
"forest",
"scrub"
],
"#22302a",
[
"grassland",
"farmland"
],
"#222a24",
"glacier",
"#2b3036",
"barren",
"#2e2c25",
"#212428"
]
}
},
{
"id": "landuse-wood",
"type": "fill",
"source": "protomaps",
"source-layer": "landuse",
"filter": [
"match",
[
"get",
"kind"
],
[
"forest",
"wood",
"orchard",
"scrub"
],
true,
false
],
"paint": {
"fill-color": "#22302a"
}
},
{
"id": "landuse-park",
"type": "fill",
"source": "protomaps",
"source-layer": "landuse",
"filter": [
"match",
[
"get",
"kind"
],
[
"park",
"garden",
"grass",
"meadow",
"recreation_ground",
"playground",
"dog_park",
"golf_course",
"nature_reserve",
"national_park",
"protected_area",
"cemetery",
"camp_site"
],
true,
false
],
"paint": {
"fill-color": "#233127"
}
},
{
"id": "landuse-pitch",
"type": "fill",
"source": "protomaps",
"source-layer": "landuse",
"filter": [
"match",
[
"get",
"kind"
],
[
"pitch",
"stadium"
],
true,
false
],
"paint": {
"fill-color": "#253427"
}
},
{
"id": "landuse-sand",
"type": "fill",
"source": "protomaps",
"source-layer": "landuse",
"filter": [
"match",
[
"get",
"kind"
],
[
"beach",
"sand",
"bare_rock"
],
true,
false
],
"paint": {
"fill-color": "#2e2c25"
}
},
{
"id": "landuse-wetland",
"type": "fill",
"source": "protomaps",
"source-layer": "landuse",
"filter": [
"match",
[
"get",
"kind"
],
[
"wetland"
],
true,
false
],
"paint": {
"fill-color": "#20302d"
}
},
{
"id": "landuse-built",
"type": "fill",
"source": "protomaps",
"source-layer": "landuse",
"filter": [
"match",
[
"get",
"kind"
],
[
"industrial",
"commercial",
"railway",
"military",
"aerodrome",
"hospital",
"school",
"college",
"university",
"kindergarten"
],
true,
false
],
"paint": {
"fill-color": "#25272b"
}
},
{
"id": "landuse-runway",
"type": "fill",
"source": "protomaps",
"source-layer": "landuse",
"filter": [
"match",
[
"get",
"kind"
],
[
"runway",
"taxiway"
],
true,
false
],
"paint": {
"fill-color": "#2c2e32"
}
},
{
"id": "water",
"type": "fill",
"source": "protomaps",
"source-layer": "water",
"filter": [
"==",
[
"geometry-type"
],
"Polygon"
],
"paint": {
"fill-color": "#1b3042"
}
},
{
"id": "water-lines",
"type": "line",
"source": "protomaps",
"source-layer": "water",
"filter": [
"==",
[
"geometry-type"
],
"LineString"
],
"minzoom": 9,
"layout": {
"line-cap": "round",
"line-join": "round"
},
"paint": {
"line-color": "#1b3042",
"line-width": [
"match",
[
"get",
"kind_detail"
],
[
"river",
"canal"
],
[
"interpolate",
[
"exponential",
1.6
],
[
"zoom"
],
12,
1.5,
18,
12
],
[
"interpolate",
[
"exponential",
1.6
],
[
"zoom"
],
12,
0.5,
18,
3
]
]
}
},
{
"id": "buildings",
"type": "fill",
"source": "protomaps",
"source-layer": "buildings",
"filter": [
"match",
[
"get",
"kind"
],
[
"building",
"building_part"
],
true,
false
],
"minzoom": 14,
"paint": {
"fill-color": "#2a2c30"
}
},
{
"id": "boundaries",
"type": "line",
"source": "protomaps",
"source-layer": "boundaries",
"filter": [
"match",
[
"get",
"kind"
],
[
"country",
"region"
],
true,
false
],
"paint": {
"line-color": "#7d7290",
"line-width": [
"match",
[
"get",
"kind"
],
"country",
1.5,
0.8
],
"line-dasharray": [
3,
2
]
}
},
{
"id": "rail",
"type": "line",
"source": "protomaps",
"source-layer": "roads",
"filter": [
"match",
[
"get",
"kind"
],
[
"rail"
],
true,
false
],
"minzoom": 10,
"paint": {
"line-color": "#55575b",
"line-width": [
"interpolate",
[
"exponential",
1.6
],
[
"zoom"
],
12,
0.8,
18,
3
]
}
},
{
"id": "roads-major-casing",
"type": "line",
"source": "protomaps",
"source-layer": "roads",
"filter": [
"match",
[
"get",
"kind"
],
[
"highway",
"major_road"
],
true,
false
],
"minzoom": 7,
"layout": {
"line-cap": "round",
"line-join": "round"
},
"paint": {
"line-color": "#15171a",
"line-width": [
"interpolate",
[
"exponential",
1.6
],
[
"zoom"
],
12,
3,
18,
22
]
}
},
{
"id": "roads-minor-casing",
"type": "line",
"source": "protomaps",
"source-layer": "roads",
"filter": [
"match",
[
"get",
"kind"
],
[
"minor_road"
],
true,
false
],
"minzoom": 13,
"layout": {
"line-cap": "round",
"line-join": "round"
},
"paint": {
"line-color": "#15171a",
"line-width": [
"interpolate",
[
"exponential",
1.6
],
[
"zoom"
],
12,
1.5,
18,
14
]
}
},
{
"id": "roads-minor",
"type": "line",
"source": "protomaps",
"source-layer": "roads",
"filter": [
"match",
[
"get",
"kind"
],
[
"minor_road"
],
true,
false
],
"minzoom": 11,
"layout": {
"line-cap": "round",
"line-join": "round"
},
"paint": {
"line-color": "#3a3d42",
"line-width": [
"interpolate",
[
"exponential",
1.6
],
[
"zoom"
],
12,
0.8,
18,
11
]
}
},
{
"id": "roads-major",
"type": "line",
"source": "protomaps",
"source-layer": "roads",
"filter": [
"match",
[
"get",
"kind"
],
[
"highway",
"major_road"
],
true,
false
],
"minzoom": 5,
"layout": {
"line-cap": "round",
"line-join": "round"
},
"paint": {
"line-color": [
"match",
[
"get",
"kind"
],
"highway",
"#6a573a",
"#4b4639"
],
"line-width": [
"interpolate",
[
"exponential",
1.6
],
[
"zoom"
],
12,
2,
18,
18
]
}
},
{
"id": "tracks",
"type": "line",
"source": "protomaps",
"source-layer": "roads",
"filter": [
"all",
[
"match",
[
"get",
"kind"
],
[
"path",
"minor_road"
],
true,
false
],
[
"match",
[
"get",
"kind_detail"
],
[
"track"
],
true,
false
]
],
"minzoom": 12,
"layout": {
"line-cap": "round",
"line-join": "round"
},
"paint": {
"line-color": "#b88a5a",
"line-width": [
"interpolate",
[
"exponential",
1.6
],
[
"zoom"
],
12,
1,
18,
4
],
"line-dasharray": [
4,
1.5
]
}
},
{
"id": "footpaths",
"type": "line",
"source": "protomaps",
"source-layer": "roads",
"filter": [
"all",
[
"match",
[
"get",
"kind"
],
[
"path"
],
true,
false
],
[
"match",
[
"get",
"kind_detail"
],
[
"footway",
"path",
"pedestrian",
"steps",
"bridleway"
],
true,
false
]
],
"minzoom": 13,
"paint": {
"line-color": "#d07a5c",
"line-width": [
"interpolate",
[
"exponential",
1.6
],
[
"zoom"
],
12,
1,
18,
3.5
],
"line-dasharray": [
1.5,
1.5
]
}
},
{
"id": "cycleways",
"type": "line",
"source": "protomaps",
"source-layer": "roads",
"filter": [
"all",
[
"match",
[
"get",
"kind"
],
[
"path"
],
true,
false
],
[
"match",
[
"get",
"kind_detail"
],
[
"cycleway"
],
true,
false
]
],
"minzoom": 12,
"layout": {
"line-cap": "round",
"line-join": "round"
},
"paint": {
"line-color": "#5fb5ae",
"line-width": [
"interpolate",
[
"exponential",
1.6
],
[
"zoom"
],
12,
1,
18,
4
]
}
},
{
"id": "water-names",
"type": "symbol",
"source": "protomaps",
"source-layer": "water",
"filter": [
"has",
"name"
],
"minzoom": 12,
"layout": {
"symbol-placement": "line",
"text-field": [
"get",
"name"
],
"text-size": 12
},
"paint": {
"text-color": "#8fb2cb",
"text-halo-color": "#1d2024",
"text-halo-width": 1.5
}
},
{
"id": "road-names",
"type": "symbol",
"source": "protomaps",
"source-layer": "roads",
"filter": [
"all",
[
"has",
"name"
],
[
"match",
[
"get",
"kind"
],
[
"highway",
"major_road",
"minor_road",
"path"
],
true,
false
]
],
"minzoom": 14,
"layout": {
"symbol-placement": "line",
"text-field": [
"get",
"name"
],
"text-size": 12
},
"paint": {
"text-color": "#e2ded8",
"text-halo-color": "#1d2024",
"text-halo-width": 1.5
}
},
{
"id": "places-local",
"type": "symbol",
"source": "protomaps",
"source-layer": "places",
"filter": [
"match",
[
"get",
"kind"
],
[
"neighbourhood",
"macrohood"
],
true,
false
],
"minzoom": 13,
"layout": {
"text-field": [
"get",
"name"
],
"text-size": 12,
"text-transform": "uppercase",
"text-letter-spacing": 0.08
},
"paint": {
"text-color": "#a29b92",
"text-halo-color": "#1d2024",
"text-halo-width": 1.5
}
},
{
"id": "places-locality",
"type": "symbol",
"source": "protomaps",
"source-layer": "places",
"filter": [
"match",
[
"get",
"kind"
],
[
"locality"
],
true,
false
],
"layout": {
"text-field": [
"get",
"name"
],
"text-size": [
"interpolate",
[
"linear"
],
[
"get",
"population_rank"
],
1,
11,
15,
18
]
},
"paint": {
"text-color": "#e2ded8",
"text-halo-color": "#1d2024",
"text-halo-width": 1.5
}
},
{
"id": "places-region",
"type": "symbol",
"source": "protomaps",
"source-layer": "places",
"filter": [
"match",
[
"get",
"kind"
],
[
"region"
],
true,
false
],
"layout": {
"text-field": [
"get",
"name"
],
"text-size": 12,
"text-transform": "uppercase"
},
"paint": {
"text-color": "#a29b92",
"text-halo-color": "#1d2024",
"text-halo-width": 1.5
}
},
{
"id": "places-country",
"type": "symbol",
"source": "protomaps",
"source-layer": "places",
"filter": [
"match",
[
"get",
"kind"
],
[
"country"
],
true,
false
],
"layout": {
"text-field": [
"get",
"name"
],
"text-size": 15
},
"paint": {
"text-color": "#e2ded8",
"text-halo-color": "#1d2024",
"text-halo-width": 1.5
}
}
]
}

File diff suppressed because it is too large Load diff