feat(train): a route's map kept offline on request (ADR 0030, D45)
A saved route's page has Keep the map offline: the tiles within 2 km of its way at zooms 10 to 16 (never above the source's own), shared between routes, kept again as the route changes and let go with it. The style, its sprites and the instance's map source are kept as last read, and every map reads a kept tile before the network, so a map draws with no network at all. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
b0c0eeb3d5
commit
a2997e87af
3 changed files with 1611 additions and 65 deletions
|
|
@ -0,0 +1,58 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/map/data/corridor.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
/// The tiles a route's map is kept offline with (ADR 0030, D45): 2 km
|
||||
/// either side of its way, at zooms 10 to 16.
|
||||
void main() {
|
||||
TileKey tileOf(LatLng point, int z) {
|
||||
final n = 1 << z;
|
||||
final phi = point.latitude * pi / 180;
|
||||
return (
|
||||
z: z,
|
||||
x: ((point.longitude + 180) / 360 * n).floor(),
|
||||
y: ((1 - log(tan(phi) + 1 / cos(phi)) / pi) / 2 * n).floor(),
|
||||
);
|
||||
}
|
||||
|
||||
const distance = Distance();
|
||||
// About 11 km north along 10° E.
|
||||
const way = [LatLng(50, 10), LatLng(50.1, 10)];
|
||||
final tiles = corridorTiles(way);
|
||||
|
||||
test('every zoom from 10 to 16, none other', () {
|
||||
expect({for (final tile in tiles) tile.z}, {10, 11, 12, 13, 14, 15, 16});
|
||||
});
|
||||
|
||||
test('what lies 1.9 km either side of the way is kept, at every zoom, '
|
||||
'midway between its points too', () {
|
||||
const middle = LatLng(50.05, 10);
|
||||
for (final bearing in [90.0, 270.0, 45.0, 225.0]) {
|
||||
final aside = distance.offset(middle, 1900, bearing);
|
||||
for (var z = 10; z <= 16; z++) {
|
||||
expect(tiles, contains(tileOf(aside, z)), reason: '$bearing° at $z');
|
||||
}
|
||||
}
|
||||
// And around its ends.
|
||||
expect(tiles, contains(tileOf(distance.offset(way.last, 1900, 0), 16)));
|
||||
});
|
||||
|
||||
test('what lies 3 km or more off the way is not, at the highest zoom', () {
|
||||
for (final bearing in [90.0, 270.0]) {
|
||||
final far = distance.offset(const LatLng(50.05, 10), 3000, bearing);
|
||||
expect(tiles, isNot(contains(tileOf(far, 16))));
|
||||
}
|
||||
expect(
|
||||
tiles,
|
||||
isNot(contains(tileOf(distance.offset(way.last, 3000, 0), 16))),
|
||||
);
|
||||
});
|
||||
|
||||
test('zooms are kept to what the source has', () {
|
||||
final some = corridorTiles(way, minZoom: 12, maxZoom: 14);
|
||||
expect({for (final tile in some) tile.z}, {12, 13, 14});
|
||||
expect(some, everyElement(isIn(tiles)));
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
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_source_repository.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// The instance's map source, remembered for a map drawn offline (ADR 0030,
|
||||
/// D22, D45).
|
||||
void main() {
|
||||
late Directory folder;
|
||||
final source = MapSource(
|
||||
name: 'OpenFreeMap',
|
||||
styleUrl: 'https://tiles.openfreemap.org/styles/liberty',
|
||||
attribution: 'OpenFreeMap © OpenMapTiles Data from OpenStreetMap',
|
||||
);
|
||||
|
||||
setUp(() => folder = Directory.systemTemp.createTempSync('map_source'));
|
||||
tearDown(() => folder.deleteSync(recursive: true));
|
||||
|
||||
test('the source asked once online is the source offline, the app '
|
||||
'opened again', () async {
|
||||
final kept = File(p.join(folder.path, 'map_source.json'));
|
||||
expect(
|
||||
await MapSourceRepository(_Gateway(source), kept: kept).source(),
|
||||
source,
|
||||
);
|
||||
// The generated model has no equality of its own.
|
||||
expect(
|
||||
(await MapSourceRepository(
|
||||
_Gateway(null),
|
||||
kept: kept,
|
||||
).source())?.toJson(),
|
||||
source.toJson(),
|
||||
);
|
||||
});
|
||||
|
||||
test('none offline where none was ever asked', () async {
|
||||
final kept = File(p.join(folder.path, 'map_source.json'));
|
||||
expect(
|
||||
await MapSourceRepository(_Gateway(null), kept: kept).source(),
|
||||
null,
|
||||
);
|
||||
expect(await MapSourceRepository(_Gateway(null)).source(), null);
|
||||
});
|
||||
}
|
||||
|
||||
class _Gateway(final MapSource? _source) implements MapSourceGateway {
|
||||
@override
|
||||
Future<MapSource> source() async => _source ?? (throw StateError('offline'));
|
||||
}
|
||||
|
|
@ -0,0 +1,306 @@
|
|||
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/corridor.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/map/data/offline_maps.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// A route's map kept on the device (ADR 0030, D45): its corridor's tiles
|
||||
/// up to what the source has, shared between routes, let go on request or
|
||||
/// with the route, kept again as the route changes; the style kept to draw
|
||||
/// without a network.
|
||||
void main() {
|
||||
late Directory folder;
|
||||
late _Tiles server;
|
||||
|
||||
final source = MapSource(
|
||||
name: 'Tiles',
|
||||
styleUrl: 'https://tiles.example/styles/liberty',
|
||||
attribution: '© OpenStreetMap',
|
||||
);
|
||||
|
||||
RouteContent route(List<LatLng> points) => RouteContent(
|
||||
name: 'Park',
|
||||
profile: RouteProfile.run,
|
||||
waypoints: [points.first, points.last],
|
||||
planned: RouteGeometry(points: points, distanceMetres: 1000),
|
||||
);
|
||||
|
||||
// About a kilometre north, and one about 20 km east of it.
|
||||
final north = route(const [LatLng(50, 10), LatLng(50.009, 10)]);
|
||||
final east = route(const [LatLng(50, 10.3), LatLng(50.009, 10.3)]);
|
||||
// The source has zooms 0 to 14: none above is asked for.
|
||||
Set<String> tilesOf(RouteContent route) => {
|
||||
for (final tile in corridorTiles(route.planned!.points, maxZoom: 14))
|
||||
'planet/${tile.z}/${tile.x}/${tile.y}',
|
||||
};
|
||||
|
||||
OfflineMaps maps() => OfflineMaps(folder, client: server.client);
|
||||
|
||||
Future<bool> kept(String key) async {
|
||||
final [name, z, x, y] = key.split('/');
|
||||
return await maps().tile(name, int.parse(z), int.parse(x), int.parse(y)) !=
|
||||
null;
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
folder = Directory.systemTemp.createTempSync('offline_maps');
|
||||
server = _Tiles();
|
||||
});
|
||||
tearDown(() => folder.deleteSync(recursive: true));
|
||||
|
||||
test("a route kept has its corridor's tiles on the device, each asked "
|
||||
'for once, to the zoom the source has', () async {
|
||||
final offline = maps();
|
||||
await offline.keep('route-1', north, source);
|
||||
|
||||
final wanted = tilesOf(north);
|
||||
expect(server.tiles.toSet(), wanted);
|
||||
expect(server.tiles, hasLength(wanted.length));
|
||||
for (final key in wanted) {
|
||||
expect(await kept(key), isTrue, reason: key);
|
||||
}
|
||||
expect(server.tiles.any((key) => key.contains('/15/')), isFalse);
|
||||
final map = offline.kept.value['route-1']!;
|
||||
expect(map.total, wanted.length);
|
||||
expect(map.done, wanted.length);
|
||||
expect(map.missing, 0);
|
||||
expect(map.loading, isFalse);
|
||||
expect(map.bytes, greaterThan(0));
|
||||
|
||||
// Kept again: nothing asked for anew.
|
||||
server.tiles.clear();
|
||||
await offline.keep('route-1', north, source);
|
||||
expect(server.tiles, isEmpty);
|
||||
});
|
||||
|
||||
test('a tile outside every kept corridor is not on the device', () async {
|
||||
await maps().keep('route-1', north, source);
|
||||
expect(await kept(tilesOf(east).first), isFalse);
|
||||
});
|
||||
|
||||
test("let go, a route's tiles are deleted, but those another kept route "
|
||||
'needs', () async {
|
||||
final offline = maps();
|
||||
final near = route(const [LatLng(50.005, 10), LatLng(50.014, 10)]);
|
||||
await offline.keep('route-1', north, source);
|
||||
await offline.keep('route-2', near, source);
|
||||
final shared = tilesOf(north).intersection(tilesOf(near));
|
||||
expect(shared, isNotEmpty);
|
||||
|
||||
await offline.release('route-1');
|
||||
expect(offline.kept.value.keys, ['route-2']);
|
||||
for (final key in tilesOf(north)) {
|
||||
expect(await kept(key), tilesOf(near).contains(key), reason: key);
|
||||
}
|
||||
await offline.release('route-2');
|
||||
expect(offline.kept.value, isEmpty);
|
||||
expect(
|
||||
Directory(p.join(folder.path, 'tiles'))
|
||||
.listSync(recursive: true)
|
||||
.whereType<File>(),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
test("the tiles that didn't come are counted, and asked for again the "
|
||||
'next time', () async {
|
||||
server.failing = true;
|
||||
final offline = maps();
|
||||
await offline.keep('route-1', north, source);
|
||||
final failed = offline.kept.value['route-1']!;
|
||||
expect(failed.missing, failed.total);
|
||||
expect(failed.done, 0);
|
||||
|
||||
server.failing = false;
|
||||
await offline.follow([StoredRoute('route-1', north)], source);
|
||||
expect(offline.kept.value['route-1']!.missing, 0);
|
||||
expect(offline.kept.value['route-1']!.done, tilesOf(north).length);
|
||||
});
|
||||
|
||||
test(
|
||||
'with the style out of reach nothing is kept, and that is said',
|
||||
() async {
|
||||
server.offline = true;
|
||||
final offline = maps();
|
||||
expect(await offline.keep('route-1', north, source), isFalse);
|
||||
expect(offline.kept.value, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('let go while it keeps, nothing of it stays', () async {
|
||||
final offline = maps();
|
||||
final keeping = offline.keep('route-1', north, source);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
await offline.release('route-1');
|
||||
await keeping;
|
||||
expect(offline.kept.value, isEmpty);
|
||||
final tiles = Directory(p.join(folder.path, 'tiles'));
|
||||
expect(
|
||||
tiles.existsSync()
|
||||
? tiles.listSync(recursive: true).whereType<File>()
|
||||
: const <File>[],
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
test('none there (the sea) is nothing missing', () async {
|
||||
server.empty = true;
|
||||
final offline = maps();
|
||||
await offline.keep('route-1', north, source);
|
||||
expect(offline.kept.value['route-1']!.missing, 0);
|
||||
});
|
||||
|
||||
test('what is kept reads again as the app opens; the route deleted '
|
||||
'meanwhile is let go', () async {
|
||||
await maps().keep('route-1', north, source);
|
||||
final again = maps();
|
||||
await again.read();
|
||||
expect(again.kept.value['route-1']!.done, tilesOf(north).length);
|
||||
await again.follow(const [], null);
|
||||
// Followed with the route deleted: let go.
|
||||
expect(again.kept.value, isEmpty);
|
||||
expect(await kept(tilesOf(north).first), isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
"a route changed is kept along its new way, the old way's tiles "
|
||||
"deleted; one deleted is let go; one that doesn't open here stays",
|
||||
() async {
|
||||
final offline = maps();
|
||||
await offline.keep('route-1', north, source);
|
||||
await offline.keep('route-2', north, source);
|
||||
|
||||
await offline.follow([
|
||||
StoredRoute('route-1', east),
|
||||
const StoredRoute('route-3', null),
|
||||
], source);
|
||||
expect(offline.kept.value.keys, ['route-1']);
|
||||
for (final key in tilesOf(east)) {
|
||||
expect(await kept(key), isTrue, reason: key);
|
||||
}
|
||||
for (final key in tilesOf(north).difference(tilesOf(east))) {
|
||||
expect(await kept(key), isFalse, reason: key);
|
||||
}
|
||||
|
||||
await offline.follow([const StoredRoute('route-1', null)], source);
|
||||
expect(offline.kept.value.keys, ['route-1']);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"a route without a way, or an id that isn't one, keeps nothing",
|
||||
() async {
|
||||
final offline = maps();
|
||||
await offline.keep(
|
||||
'route-1',
|
||||
const RouteContent(
|
||||
name: 'Park',
|
||||
profile: RouteProfile.run,
|
||||
waypoints: [],
|
||||
),
|
||||
source,
|
||||
);
|
||||
await offline.keep('../outside', north, source);
|
||||
expect(offline.kept.value, isEmpty);
|
||||
expect(server.tiles, isEmpty);
|
||||
expect(
|
||||
File(p.join(folder.parent.path, 'outside.json')).existsSync(),
|
||||
isFalse,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('the style read once draws offline, with its sprites; another '
|
||||
"source's does not", () async {
|
||||
final online = await maps().style(source);
|
||||
expect(
|
||||
online!.sources['planet']!.tiles,
|
||||
'https://tiles.example/planet/{z}/{x}/{y}.pbf',
|
||||
);
|
||||
expect(online.spriteIndex, contains('park'));
|
||||
|
||||
server.offline = true;
|
||||
final offline = await maps().style(source);
|
||||
expect(offline!.json['name'], 'Liberty');
|
||||
expect(offline.sources['planet']!.maxZoom, 14);
|
||||
expect(offline.spriteIndex, contains('park'));
|
||||
expect(offline.spriteImage, utf8.encode('png@2x'));
|
||||
expect(
|
||||
await maps().style(
|
||||
MapSource(
|
||||
name: 'Other',
|
||||
styleUrl: 'https://other.example/style.json',
|
||||
attribution: '',
|
||||
),
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// A tile server: a style, its source's TileJSON, sprites and tiles; the
|
||||
/// tiles asked for listed.
|
||||
class _Tiles() {
|
||||
final tiles = <String>[];
|
||||
bool failing = false;
|
||||
bool empty = false;
|
||||
bool offline = false;
|
||||
|
||||
late final http.Client client = MockClient((request) async {
|
||||
if (offline) {
|
||||
throw const SocketException('offline');
|
||||
}
|
||||
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,
|
||||
),
|
||||
'/planet' => http.Response(
|
||||
jsonEncode({
|
||||
'tiles': ['https://tiles.example/planet/{z}/{x}/{y}.pbf'],
|
||||
'minzoom': 0,
|
||||
'maxzoom': 14,
|
||||
}),
|
||||
200,
|
||||
),
|
||||
'/sprites/liberty@2x.json' => http.Response(
|
||||
jsonEncode({
|
||||
'park': {'x': 0, 'y': 0, 'width': 16, 'height': 16, 'pixelRatio': 2},
|
||||
}),
|
||||
200,
|
||||
),
|
||||
'/sprites/liberty@2x.png' => http.Response.bytes(
|
||||
utf8.encode('png@2x'),
|
||||
200,
|
||||
),
|
||||
_ when path.startsWith('/planet/') => () {
|
||||
tiles.add(path.substring(1).replaceAll('.pbf', ''));
|
||||
if (failing) {
|
||||
return http.Response('', 503);
|
||||
}
|
||||
if (empty) {
|
||||
return http.Response('', 204);
|
||||
}
|
||||
return http.Response.bytes(utf8.encode(path), 200);
|
||||
}(),
|
||||
_ => http.Response('', 404),
|
||||
};
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue