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:
Tizian 「ティツィアーン」 2026-10-29 10:28:36 +01:00
commit a2997e87af
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
18 changed files with 1611 additions and 65 deletions

File diff suppressed because one or more lines are too long

View file

@ -405,6 +405,14 @@
"routeAscent": "{metres} m bergauf",
"routeDescent": "{metres} m bergab",
"routeSummary": "{distance} · {ascent}",
"routeOffline": "Karte offline behalten",
"routeOfflineOff": "Die Karte entlang des Wegs, 2 km zu beiden Seiten, auf diesem Gerät für unterwegs ohne Netz",
"routeOfflineLoading": "Karte wird gespeichert: {done} von {total} Kacheln",
"routeOfflineKept": "Auf diesem Gerät: {size} MB",
"routeOfflineMissing": "{missing} von {total} Kacheln kamen nicht an: sie werden beim nächsten Öffnen mit Netz erneut geladen",
"routeOfflineNoMap": "Die Karte ist gerade nicht erreichbar: versuch es mit Netz noch einmal",
"routeOfflineSaveFirst": "Speichere die Route, um ihre Karte offline zu behalten",
"routeKeptOffline": "Karte offline gespeichert",
"routeSurfaces": "Untergrund",
"surfacePaved": "Befestigt",
"surfaceCompacted": "Verdichtet",

View file

@ -513,6 +513,18 @@
"@routeDescent": {"placeholders": {"metres": {"type": "int"}}},
"routeSummary": "{distance} · {ascent}",
"@routeSummary": {"description": "A route in the list: its distance and climb.", "placeholders": {"distance": {"type": "String", "example": "12.3 km"}, "ascent": {"type": "String", "example": "120 m up"}}},
"routeOffline": "Keep the map offline",
"@routeOffline": {"description": "A switch on a saved route: its map kept on this device (ADR 0030, D45)."},
"routeOfflineOff": "Its map along the way, 2 km either side, on this device for when there's no network",
"routeOfflineLoading": "Keeping the map: {done} of {total} tiles",
"@routeOfflineLoading": {"placeholders": {"done": {"type": "int"}, "total": {"type": "int"}}},
"routeOfflineKept": "Kept on this device: {size} MB",
"@routeOfflineKept": {"placeholders": {"size": {"type": "String", "example": "12.4"}}},
"routeOfflineMissing": "{missing} of {total} tiles didn't come: asked for again when the app next opens online",
"@routeOfflineMissing": {"placeholders": {"missing": {"type": "int"}, "total": {"type": "int"}}},
"routeOfflineNoMap": "The map can't be reached now: try again online",
"routeOfflineSaveFirst": "Save the route to keep its map offline",
"routeKeptOffline": "Map kept offline",
"routeSurfaces": "Surfaces",
"surfacePaved": "Paved",
"surfaceCompacted": "Compacted",

View file

@ -2452,6 +2452,54 @@ abstract class AppLocalizations {
/// **'{distance} · {ascent}'**
String routeSummary(String distance, String ascent);
/// A switch on a saved route: its map kept on this device (ADR 0030, D45).
///
/// In en, this message translates to:
/// **'Keep the map offline'**
String get routeOffline;
/// No description provided for @routeOfflineOff.
///
/// In en, this message translates to:
/// **'Its map along the way, 2 km either side, on this device for when there\'s no network'**
String get routeOfflineOff;
/// No description provided for @routeOfflineLoading.
///
/// In en, this message translates to:
/// **'Keeping the map: {done} of {total} tiles'**
String routeOfflineLoading(int done, int total);
/// No description provided for @routeOfflineKept.
///
/// In en, this message translates to:
/// **'Kept on this device: {size} MB'**
String routeOfflineKept(String size);
/// No description provided for @routeOfflineMissing.
///
/// In en, this message translates to:
/// **'{missing} of {total} tiles didn\'t come: asked for again when the app next opens online'**
String routeOfflineMissing(int missing, int total);
/// No description provided for @routeOfflineNoMap.
///
/// In en, this message translates to:
/// **'The map can\'t be reached now: try again online'**
String get routeOfflineNoMap;
/// No description provided for @routeOfflineSaveFirst.
///
/// In en, this message translates to:
/// **'Save the route to keep its map offline'**
String get routeOfflineSaveFirst;
/// No description provided for @routeKeptOffline.
///
/// In en, this message translates to:
/// **'Map kept offline'**
String get routeKeptOffline;
/// No description provided for @routeSurfaces.
///
/// In en, this message translates to:

View file

@ -1464,6 +1464,39 @@ class AppLocalizationsDe extends AppLocalizations {
return '$distance · $ascent';
}
@override
String get routeOffline => 'Karte offline behalten';
@override
String get routeOfflineOff =>
'Die Karte entlang des Wegs, 2 km zu beiden Seiten, auf diesem Gerät für unterwegs ohne Netz';
@override
String routeOfflineLoading(int done, int total) {
return 'Karte wird gespeichert: $done von $total Kacheln';
}
@override
String routeOfflineKept(String size) {
return 'Auf diesem Gerät: $size MB';
}
@override
String routeOfflineMissing(int missing, int total) {
return '$missing von $total Kacheln kamen nicht an: sie werden beim nächsten Öffnen mit Netz erneut geladen';
}
@override
String get routeOfflineNoMap =>
'Die Karte ist gerade nicht erreichbar: versuch es mit Netz noch einmal';
@override
String get routeOfflineSaveFirst =>
'Speichere die Route, um ihre Karte offline zu behalten';
@override
String get routeKeptOffline => 'Karte offline gespeichert';
@override
String get routeSurfaces => 'Untergrund';

View file

@ -1456,6 +1456,38 @@ class AppLocalizationsEn extends AppLocalizations {
return '$distance · $ascent';
}
@override
String get routeOffline => 'Keep the map offline';
@override
String get routeOfflineOff =>
'Its map along the way, 2 km either side, on this device for when there\'s no network';
@override
String routeOfflineLoading(int done, int total) {
return 'Keeping the map: $done of $total tiles';
}
@override
String routeOfflineKept(String size) {
return 'Kept on this device: $size MB';
}
@override
String routeOfflineMissing(int missing, int total) {
return '$missing of $total tiles didn\'t come: asked for again when the app next opens online';
}
@override
String get routeOfflineNoMap =>
'The map can\'t be reached now: try again online';
@override
String get routeOfflineSaveFirst => 'Save the route to keep its map offline';
@override
String get routeKeptOffline => 'Map kept offline';
@override
String get routeSurfaces => 'Surfaces';

View file

@ -18,6 +18,7 @@ import 'package:halkyon_train_flutter/src/feature/loop/data/firmware_relay_gatew
import 'package:halkyon_train_flutter/src/feature/loop/data/loop_store.dart';
import 'package:halkyon_train_flutter/src/feature/loop/data/polar_loop.dart';
import 'package:halkyon_train_flutter/src/feature/map/data/map_source_repository.dart';
import 'package:halkyon_train_flutter/src/feature/map/data/offline_maps.dart';
import 'package:halkyon_train_flutter/src/feature/map/widget/map_tiles.dart';
import 'package:halkyon_train_flutter/src/feature/plan/data/plan_repository.dart';
import 'package:halkyon_train_flutter/src/feature/plan/data/sync_plan_gateway.dart';
@ -79,9 +80,14 @@ class TrainPerson(
/// The instance's map source (D22).
required final MapSourceRepository maps,
/// The map's tiles for that source: [vectorTiles] but in widget tests.
/// The map's tiles for that source: [keptVectorTiles] in the app,
/// [vectorTiles] by default, none in widget tests.
final MapTiles mapTiles = vectorTiles,
/// The routes' maps this device keeps offline (D45); null where none can
/// be (widget tests but those of it).
final OfflineMaps? offlineMaps,
/// Where this device records; null where it doesn't (iOS until 5d, the
/// desktop, which shows and plans, D17).
final RecordingDevice? recording,
@ -114,6 +120,7 @@ class TrainPerson(
await deletions.dispose();
await planDeletions.dispose();
await routeDeletions.dispose();
await offlineMaps?.dispose();
await health?.dispose();
await loop?.dispose();
await _onDispose?.call();
@ -357,6 +364,15 @@ Future<TrainPerson> _openPersonData(
);
final plans = PlanRepository(SyncPlanGateway(db, sync), keys);
final routes = RouteRepository(SyncRouteGateway(db, sync), keys);
final maps = MapSourceRepository(
ServerpodMapSourceGateway(client),
kept: File(p.join(folder.path, 'map_source.json')),
);
// Follows the kept routes as they change (D45).
final offlineMaps = OfflineMaps(
Directory(p.join(folder.path, 'offline_maps')),
);
unawaited(offlineMaps.open(routes, maps));
// Writes what changed while the app was closed, as it opens (D31).
final health = Platform.isAndroid
? (HealthConnect(
@ -386,7 +402,9 @@ Future<TrainPerson> _openPersonData(
plans: plans,
routes: routes,
routing: ServerpodRoutingGateway(client),
maps: MapSourceRepository(ServerpodMapSourceGateway(client)),
maps: maps,
mapTiles: keptVectorTiles(offlineMaps),
offlineMaps: offlineMaps,
recording: recorder != null
? (
platform: recorder,

View file

@ -0,0 +1,77 @@
import 'dart:math';
import 'package:latlong2/latlong.dart';
/// A map tile, as slippy maps number them: zoom, column, row.
typedef TileKey = ({int z, int x, int y});
/// How far either side of a route its map is kept offline (ADR 0030, D45).
const corridorMetres = 2000.0;
/// The lowest zoom a route's map is kept at offline (D45), a town's
/// streets, and the highest, a path's turns.
const corridorMinZoom = 10;
const corridorMaxZoom = 16;
/// The tiles within [metres] of the line through [points] at the zooms
/// [minZoom] to [maxZoom]: for each zoom, the tiles under a square of
/// [metres] each way around every point of the line, taken every 250 m, which
/// holds a band at least [metres] wide either side.
Set<TileKey> corridorTiles(
List<LatLng> points, {
double metres = corridorMetres,
int minZoom = corridorMinZoom,
int maxZoom = corridorMaxZoom,
}) {
final along = <LatLng>[];
const distance = Distance();
for (final (index, point) in points.indexed) {
if (index == 0) {
along.add(point);
continue;
}
final from = points[index - 1];
final length = distance.as(LengthUnit.Meter, from, point);
final steps = max(1, (length / 250).ceil());
for (var step = 1; step <= steps; step++) {
final t = step / steps;
along.add(
LatLng(
from.latitude + (point.latitude - from.latitude) * t,
from.longitude + (point.longitude - from.longitude) * t,
),
);
}
}
final tiles = <TileKey>{};
for (final point in along) {
final lat = metres / 111195;
final lon = metres / (111195 * cos(point.latitude * pi / 180));
for (var z = minZoom; z <= maxZoom; z++) {
final west = _column(point.longitude - lon, z);
final east = _column(point.longitude + lon, z);
final north = _row(point.latitude + lat, z);
final south = _row(point.latitude - lat, z);
for (var x = west; x <= east; x++) {
for (var y = north; y <= south; y++) {
tiles.add((z: z, x: x, y: y));
}
}
}
}
return tiles;
}
int _column(double lon, int z) {
final n = 1 << z;
return ((lon + 180) / 360 * n).floor().clamp(0, n - 1);
}
int _row(double lat, int z) {
final n = 1 << z;
final phi = lat.clamp(-85.0511, 85.0511) * pi / 180;
return ((1 - log(tan(phi) + 1 / cos(phi)) / pi) / 2 * n).floor().clamp(
0,
n - 1,
);
}

View file

@ -1,4 +1,6 @@
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'package:halkyon_train_client/halkyon_train_client.dart'
show Client, MapSource;
@ -16,20 +18,45 @@ class ServerpodMapSourceGateway(final Client _client)
}
/// The instance's map source, asked once it is needed and kept while the
/// person's app runs; asked again after a failure (offline, say).
class MapSourceRepository(final MapSourceGateway _gateway) {
/// person's app runs; asked again after a failure (offline, say), and then
/// the one last asked where it is remembered in [_kept] (D45: a route's
/// map offline needs its source).
class MapSourceRepository(
final MapSourceGateway _gateway, {
final File? _kept,
}) {
MapSource? _source;
/// The map source; null where the instance can't be asked now.
/// The map source; null where the instance can't be asked now and none is
/// remembered.
Future<MapSource?> source() async {
if (_source case final source?) {
return source;
}
try {
return _source = await _gateway.source();
final source = _source = await _gateway.source();
try {
await _kept?.writeAsString(jsonEncode(source.toJson()));
} on FileSystemException catch (error) {
log('The map source not kept: ${error.message}', name: 'Map');
}
return source;
} on Object catch (error) {
log('The map source not asked: ${error.runtimeType}', name: 'Map');
return null;
return await _remembered();
}
}
Future<MapSource?> _remembered() async {
try {
if (_kept case final file? when file.existsSync()) {
return MapSource.fromJson(
jsonDecode(await file.readAsString()) as Map<String, dynamic>,
);
}
} on Object catch (error) {
log('No map source remembered: ${error.runtimeType}', name: 'Map');
}
return null;
}
}

View file

@ -0,0 +1,574 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.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/map_source_repository.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/route_repository.dart';
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart';
import 'package:path/path.dart' as p;
/// Where a route's map stands on this device (ADR 0030, D45): its tiles
/// [done] of [total], [missing] of them not come, [loading] while it keeps
/// them; [bytes] on the device.
final class const KeptMap({
required final int total,
final int done = 0,
final int missing = 0,
final int bytes = 0,
final bool loading = false,
}) with Equatable {
@override
List<Object?> get props => [total, done, missing, bytes, loading];
}
/// One of a style's tile sources as its TileJSON says: its type (`vector`,
/// `raster`), the URL of its tiles with `{z}`, `{x}`, `{y}` and the
/// zooms it has.
typedef StyleSource = ({String type, String tiles, int minZoom, int maxZoom});
/// A map style with all it needs to draw: its JSON, its sources' TileJSON
/// read, its sprites where it has them.
final class const OfflineStyle({
required final Map<String, Object?> json,
required final Map<String, StyleSource> sources,
final Map<String, Object?>? spriteIndex,
final Uint8List? spriteImage,
});
/// The maps this device keeps offline for the person's routes (D45): a
/// route's corridor, [corridorMetres] either side at [corridorMinZoom] to [corridorMaxZoom], kept
/// on request ([keep]) until let go ([release]), the tiles shared between
/// routes; the instance's style kept as it was last read, so that the map
/// draws without a network. In the person's own folder, never synced.
///
/// The folder holds `style/` (the style, its sources, its sprites),
/// `tiles/<source>/<z>/<x>/<y>` and `routes/<id>.json`, each kept route's
/// tiles and what they were made for.
class OfflineMaps(
final Directory _folder, {
final http.Client? client,
final int parallel = 4,
}) {
late final http.Client _http = client ?? http.Client();
/// Each kept route's map by its id.
final kept = ValueNotifier<Map<String, KeptMap>>(const {});
/// A route being kept counts up; letting it go, or keeping it again, stops
/// what came before.
final _runs = <String, int>{};
StreamSubscription<void>? _changes;
var _closed = false;
Directory get _style => Directory(p.join(_folder.path, 'style'));
Directory get _routes => Directory(p.join(_folder.path, 'routes'));
/// Reads what is kept ([read]), keeps going for routes that changed or
/// lack tiles, and follows the person's [routes] from now on: one changed
/// is kept again, one deleted let go.
Future<void> open(RouteRepository routes, MapSourceRepository maps) async {
await read();
Future<void> follow() async {
if (kept.value.isEmpty) {
return;
}
try {
await this.follow(await routes.all(), await maps.source());
} on Object catch (error) {
log('Kept maps not followed: ${error.runtimeType}', name: 'Map');
}
}
if (_closed) {
return;
}
_changes = routes.changes.listen((_) => unawaited(follow()));
unawaited(follow());
}
/// Reads into [kept] what the folder keeps.
Future<void> read() async {
final manifests = await _manifests();
if (_closed) {
return;
}
kept.value = {
for (final MapEntry(:key, :value) in manifests.entries)
key: _keptOf(value),
};
}
/// Keeps [routes]' maps as they are now: one deleted, or left without a
/// way, let go; one whose way or map source changed, or still lacking
/// tiles, kept again with [source]; one that doesn't open here kept as it
/// is.
Future<void> follow(List<StoredRoute> routes, MapSource? source) async {
final byId = {for (final route in routes) route.id: route.content};
final manifests = await _manifests();
for (final MapEntry(key: id, value: manifest) in manifests.entries) {
if (_runs.containsKey(id) && kept.value[id]?.loading == true) {
continue;
}
final route = byId[id];
if (route?.planned == null) {
if (!byId.containsKey(id) || route != null) {
await release(id);
}
continue;
}
if (source != null &&
(manifest.fingerprint != _fingerprint(route!, source) ||
manifest.tiles.length < manifest.total)) {
await keep(id, route, source);
}
}
}
/// Keeps the map along [route] on this device, from [source]; what was
/// kept already stays, what didn't come is asked for again next time.
/// False where nothing could be kept: no way, or the style can't be had.
Future<bool> keep(String id, RouteContent route, MapSource source) async {
final planned = route.planned;
if (planned == null || !_safe(id) || _closed) {
return false;
}
final run = (_runs[id] ?? 0) + 1;
_runs[id] = run;
bool current() => _runs[id] == run;
final before = (await _manifests())[id];
_set(id, KeptMap(total: before?.total ?? 0, loading: true));
final style = await this.style(source);
if (style == null) {
if (current()) {
_set(id, before == null ? null : _keptOf(before));
}
return false;
}
final wanted = <String>[];
for (final MapEntry(key: name, value: from) in style.sources.entries) {
final top = from.maxZoom < corridorMaxZoom
? from.maxZoom
: corridorMaxZoom;
final bottom = from.minZoom > corridorMinZoom
? from.minZoom
: corridorMinZoom;
if (top < bottom) {
continue;
}
for (final tile in corridorTiles(
planned.points,
minZoom: bottom,
maxZoom: top,
)) {
wanted.add('$name/${tile.z}/${tile.x}/${tile.y}');
}
}
final have = <String>[];
var bytes = 0;
var missing = 0;
var next = 0;
var lastShown = DateTime.now();
Future<void> worker() async {
while (next < wanted.length && current()) {
final key = wanted[next++];
final file = _tileFile(key);
// Null where it didn't come; -1 where there is none to keep.
int? size;
if (file.existsSync()) {
size = file.lengthSync();
} else {
final [name, z, x, y] = key.split('/');
final url = style.sources[name]!.tiles
.replaceAll('{z}', z)
.replaceAll('{x}', x)
.replaceAll('{y}', y);
try {
final response = await _http.get(Uri.parse(url));
if (response.statusCode == 200) {
await _write(file, response.bodyBytes);
size = response.bodyBytes.length;
} else if (response.statusCode == 204 ||
response.statusCode == 404) {
// No tile there (the sea, say): nothing to keep, nothing
// missing.
size = -1;
}
} on Object catch (error) {
log('A tile not kept: ${error.runtimeType}', name: 'Map');
}
}
switch (size) {
case null:
missing++;
case final size:
bytes += size > 0 ? size : 0;
have.add(key);
}
final now = DateTime.now();
if (current() && now.difference(lastShown).inMilliseconds > 250) {
lastShown = now;
_set(
id,
KeptMap(
total: wanted.length,
done: have.length,
missing: missing,
bytes: bytes,
loading: true,
),
);
}
}
}
await Future.wait([for (var i = 0; i < parallel; i++) worker()]);
if (!current()) {
// Let go meanwhile: what came for it alone goes too.
if (!_closed && !kept.value.containsKey(id)) {
await _deleteUnused(have.toSet());
}
return true;
}
await _writeManifest(
id,
_Manifest(
fingerprint: _fingerprint(route, source),
total: wanted.length,
tiles: have,
bytes: bytes,
),
);
if (before != null) {
await _deleteUnused(before.tiles.toSet().difference(have.toSet()));
}
_set(
id,
KeptMap(
total: wanted.length,
done: have.length,
missing: missing,
bytes: bytes,
),
);
return true;
}
/// Lets the map of route [id] go: its tiles deleted, but for those
/// another kept route needs.
Future<void> release(String id) async {
if (!_safe(id)) {
return;
}
_runs[id] = (_runs[id] ?? 0) + 1;
final manifest = (await _manifests())[id];
final file = _manifestFile(id);
if (file.existsSync()) {
await file.delete();
}
if (manifest != null) {
await _deleteUnused(manifest.tiles.toSet());
}
_set(id, null);
}
/// A kept tile of the style's source [name], or null where none is kept.
Future<Uint8List?> tile(String name, int z, int x, int y) async {
final file = _tileFile('$name/$z/$x/$y');
try {
return await file.readAsBytes();
} on FileSystemException {
return null;
}
}
/// [source]'s style read from it and kept, or as it was last kept where
/// it can't be read now; null where neither can be had.
Future<OfflineStyle?> style(MapSource source) async {
try {
final style = await _read(source.styleUrl);
await _keepStyle(source.styleUrl, style);
return style;
} on Object catch (error) {
log('The style not read: ${error.runtimeType}', name: 'Map');
return await _keptStyle(source.styleUrl);
}
}
/// Stops what runs; what was kept stays.
Future<void> dispose() async {
if (_closed) {
return;
}
_closed = true;
_runs.updateAll((_, run) => run + 1);
await _changes?.cancel();
kept.dispose();
}
void _set(String id, KeptMap? map) {
if (_closed) {
return;
}
kept.value = map == null
? ({...kept.value}..remove(id))
: {...kept.value, id: map};
}
Future<OfflineStyle> _read(String url) async {
final json = _object(await _get(url));
final sources = <String, StyleSource>{};
for (final MapEntry(:key, :value) in _object(json['sources']).entries) {
// A source's name becomes a folder of its tiles.
if (!_safe(key)) {
continue;
}
final source = _object(value);
final type = source['type'];
if (type is! String || (type != 'vector' && type != 'raster')) {
continue;
}
final tileJson = switch (source['url']) {
final String at => _object(await _get(_resolve(at, url: url))),
_ => source,
};
if (tileJson['tiles'] case [final String tiles, ...]) {
sources[key] = (
type: type,
tiles: tiles,
minZoom: (tileJson['minzoom'] as num?)?.toInt() ?? 0,
maxZoom: (tileJson['maxzoom'] as num?)?.toInt() ?? 14,
);
}
}
if (sources.isEmpty) {
throw const FormatException('A style without tiles');
}
Map<String, Object?>? index;
Uint8List? image;
if (json['sprite'] case final String at when at.isNotEmpty) {
final sprite = _resolve(at, url: url);
for (final ratio in ['@2x', '']) {
try {
index = _object(await _get(_sprite(sprite, ratio, '.json')));
image = (await _http.get(
Uri.parse(_sprite(sprite, ratio, '.png')),
)).bodyBytes;
break;
} on Object {
index = null;
image = null;
}
}
}
return OfflineStyle(
json: json,
sources: sources,
spriteIndex: index,
spriteImage: image,
);
}
Future<void> _keepStyle(String url, OfflineStyle style) async {
await _write(
File(p.join(_style.path, 'style.json')),
utf8.encode(
jsonEncode({
'url': url,
'style': style.json,
'sources': {
for (final MapEntry(:key, :value) in style.sources.entries)
key: {
'type': value.type,
'tiles': value.tiles,
'minzoom': value.minZoom,
'maxzoom': value.maxZoom,
},
},
'sprite': style.spriteIndex,
}),
),
);
final image = File(p.join(_style.path, 'sprite.png'));
if (style.spriteImage case final bytes?) {
await _write(image, bytes);
} else if (image.existsSync()) {
await image.delete();
}
}
Future<OfflineStyle?> _keptStyle(String url) async {
try {
final kept = _object(
jsonDecode(
await File(p.join(_style.path, 'style.json')).readAsString(),
),
);
if (kept['url'] != url) {
return null;
}
final image = File(p.join(_style.path, 'sprite.png'));
return OfflineStyle(
json: _object(kept['style']),
sources: {
for (final MapEntry(:key, :value) in _object(kept['sources']).entries)
key: switch (_object(value)) {
{
'type': final String type,
'tiles': final String tiles,
'minzoom': final int minZoom,
'maxzoom': final int maxZoom,
} =>
(type: type, tiles: tiles, minZoom: minZoom, maxZoom: maxZoom),
_ => throw const FormatException('A kept source unread'),
},
},
spriteIndex: switch (kept['sprite']) {
final Map<String, Object?> index => index,
_ => null,
},
spriteImage: image.existsSync() ? await image.readAsBytes() : null,
);
} on Object catch (error) {
log('No style kept: ${error.runtimeType}', name: 'Map');
return null;
}
}
Future<Object?> _get(String url) async {
final response = await _http.get(Uri.parse(url));
if (response.statusCode != 200) {
throw HttpException('HTTP ${response.statusCode}', uri: Uri.parse(url));
}
return jsonDecode(response.body);
}
Future<Map<String, _Manifest>> _manifests() async {
if (!_routes.existsSync()) {
return const {};
}
final manifests = <String, _Manifest>{};
for (final file in _routes.listSync().whereType<File>()) {
if (p.extension(file.path) != '.json') {
continue;
}
try {
manifests[p.basenameWithoutExtension(file.path)] = _Manifest.fromJson(
_object(jsonDecode(await file.readAsString())),
);
} on Object catch (error) {
log('A kept map unread: ${error.runtimeType}', name: 'Map');
}
}
return manifests;
}
Future<void> _writeManifest(String id, _Manifest manifest) =>
_write(_manifestFile(id), utf8.encode(jsonEncode(manifest.toJson())));
/// Deletes those of [tiles] no kept route needs.
Future<void> _deleteUnused(Set<String> tiles) async {
if (tiles.isEmpty) {
return;
}
final needed = {
for (final manifest in (await _manifests()).values) ...manifest.tiles,
};
for (final key in tiles.difference(needed)) {
final file = _tileFile(key);
if (file.existsSync()) {
await file.delete();
}
}
}
File _manifestFile(String id) => File(p.join(_routes.path, '$id.json'));
File _tileFile(String key) =>
File(p.joinAll([_folder.path, 'tiles', ...key.split('/')]));
/// Written whole or not at all.
static Future<void> _write(File file, List<int> bytes) async {
await file.parent.create(recursive: true);
final part = File('${file.path}.part');
await part.writeAsBytes(bytes, flush: true);
await part.rename(file.path);
}
/// A name safe as a file's or a folder's: what the app makes ids of, and
/// what styles name their sources.
static bool _safe(String id) => RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(id);
}
KeptMap _keptOf(_Manifest manifest) => KeptMap(
total: manifest.total,
done: manifest.tiles.length,
missing: manifest.total - manifest.tiles.length,
bytes: manifest.bytes,
);
/// What a route's map was kept for: its way and the style's URL.
String _fingerprint(RouteContent route, MapSource source) => sha256
.convert(
utf8.encode(
[
source.styleUrl,
for (final point in route.planned?.points ?? const <LatLng>[])
'${point.latitude},${point.longitude}',
].join(';'),
),
)
.toString();
String _resolve(String reference, {required String url}) =>
Uri.parse(url).resolve(reference).toString();
/// A sprite's file: its base URL with the ratio and extension before any
/// query.
String _sprite(String base, String ratio, String extension) {
final uri = Uri.parse(base);
return uri.replace(path: '${uri.path}$ratio$extension').toString();
}
Map<String, Object?> _object(Object? json) => switch (json) {
final Map<String, Object?> map => map,
_ => throw const FormatException('Not a JSON object'),
};
/// A kept route's map as its file holds it.
final class const _Manifest({
required final String fingerprint,
required final int total,
required final List<String> tiles,
required final int bytes,
}) {
factory fromJson(Map<String, Object?> json) => switch (json) {
{
'fingerprint': final String fingerprint,
'total': final int total,
'tiles': final List<Object?> tiles,
'bytes': final int bytes,
} =>
_Manifest(
fingerprint: fingerprint,
total: total,
tiles: tiles.whereType<String>().toList(),
bytes: bytes,
),
_ => throw const FormatException('A kept map unread'),
};
Map<String, Object?> toJson() => {
'fingerprint': fingerprint,
'total': total,
'tiles': tiles,
'bytes': bytes,
};
}

View file

@ -1,6 +1,11 @@
import 'dart:typed_data';
import 'package:flutter/widgets.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart' show MapSource;
import 'package:halkyon_train_flutter/src/feature/map/data/offline_maps.dart';
import 'package:vector_map_tiles/vector_map_tiles.dart';
import 'package:vector_tile_renderer/vector_tile_renderer.dart'
show SpriteIndexReader, ThemeReader;
/// Builds the map's tile layer for a source: [vectorTiles] in the app, none
/// in widget tests, which reach no network.
@ -8,18 +13,78 @@ typedef MapTiles = Widget Function(MapSource source);
/// The source's vector tiles, drawn by its MapLibre style (ADR 0030, D11,
/// D23); nothing while the style loads, nor where it can't be read.
Widget vectorTiles(MapSource source) => _VectorTiles(source: source);
Widget vectorTiles(MapSource source) =>
_VectorTiles(style: () => StyleReader(uri: source.styleUrl).read());
class const _VectorTiles({required final MapSource source})
/// [vectorTiles] with what [maps] keeps offline (D45): the style as it was
/// last read where it can't be read now, and a kept route's tiles from the
/// device before the network.
MapTiles keptVectorTiles(OfflineMaps maps) =>
(source) => _VectorTiles(style: () => _keptStyle(maps, source));
Future<Style> _keptStyle(OfflineMaps maps, MapSource source) async {
final style = await maps.style(source);
if (style == null) {
throw StateError('No style for ${source.name}');
}
final sprite = style.spriteIndex;
final image = style.spriteImage;
return Style(
theme: ThemeReader().read(style.json),
providers: TileProviders({
for (final MapEntry(:key, :value) in style.sources.entries)
key: _KeptFirst(maps, key, value),
}),
sprites: sprite != null && image != null
? SpriteStyle(
atlasProvider: () async => image,
index: SpriteIndexReader().read(sprite),
)
: null,
);
}
/// A source's tiles from what [_maps] keeps, else from the network.
class _KeptFirst(
final OfflineMaps _maps,
final String _name,
final StyleSource _source,
) extends VectorTileProvider {
late final _network = NetworkVectorTileProvider(
urlTemplate: _source.tiles,
type: type,
maximumZoom: maximumZoom,
minimumZoom: minimumZoom,
);
@override
Future<Uint8List> provide(TileIdentity tile) async =>
await _maps.tile(_name, tile.z, tile.x, tile.y) ??
await _network.provide(tile);
@override
int get maximumZoom => _source.maxZoom;
@override
int get minimumZoom => _source.minZoom;
@override
TileOffset get tileOffset => TileOffset.DEFAULT;
@override
TileProviderType get type => _source.type == 'raster'
? TileProviderType.raster
: TileProviderType.vector;
}
class const _VectorTiles({required final Future<Style> Function() style})
extends StatefulWidget {
@override
State<_VectorTiles> createState() => _VectorTilesState();
}
class _VectorTilesState() extends State<_VectorTiles> {
late final Future<Style> _style = StyleReader(
uri: widget.source.styleUrl,
).read();
late final Future<Style> _style = widget.style();
@override
Widget build(BuildContext context) => FutureBuilder(

View file

@ -4,6 +4,7 @@ import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart' show MapSource;
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/feature/map/data/offline_maps.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart';
import 'package:halkyon_train_flutter/src/feature/route/bloc/route_editor_cubit.dart';
@ -17,6 +18,7 @@ import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/workout_chart.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/workout_texts.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:intl/intl.dart';
import 'package:latlong2/latlong.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -478,6 +480,7 @@ class const _Details() extends StatelessWidget {
],
if (planned != null && planned.stretches.isNotEmpty)
_Surfaces(planned),
if (planned != null) _KeepOffline(id: state.id),
],
),
),
@ -487,6 +490,69 @@ class const _Details() extends StatelessWidget {
}
}
/// Whether this device keeps the route's map offline (D45), and how far it
/// got; for a saved route alone, as the map follows the route saved.
class const _KeepOffline({required final String? id}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toString();
final train = PersonScope.of<TrainPerson>(context);
final offline = train.offlineMaps;
if (offline == null) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(top: 16),
child: ValueListenableBuilder(
valueListenable: offline.kept,
builder: (context, kept, _) {
final map = id == null ? null : kept[id];
return SwitchListTile(
contentPadding: EdgeInsets.zero,
secondary: const Icon(LucideIcons.hardDriveDownload),
title: Text(l10n.routeOffline),
subtitle: Text(switch (map) {
_ when id == null => l10n.routeOfflineSaveFirst,
null => l10n.routeOfflineOff,
KeptMap(loading: true, :final done, :final total) =>
l10n.routeOfflineLoading(done, total),
KeptMap(:final missing, :final total) when missing > 0 =>
l10n.routeOfflineMissing(missing, total),
KeptMap(:final bytes) => l10n.routeOfflineKept(
NumberFormat('0.0', locale).format(bytes / 1000000),
),
}),
value: map != null,
onChanged: switch (id) {
final id? =>
(on) => on
? _keep(context, offline, train, id)
: offline.release(id),
null => null,
},
);
},
),
);
}
Future<void> _keep(
BuildContext context,
OfflineMaps offline,
TrainPerson train,
String id,
) async {
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final route = context.read<RouteEditorCubit>().state.route;
final source = await train.maps.source();
if (source == null || !await offline.keep(id, route, source)) {
messenger.showSnackBar(SnackBar(content: Text(l10n.routeOfflineNoMap)));
}
}
}
/// How much of the way runs on each kind of surface, the most first.
class const _Surfaces(final RouteGeometry _route) extends StatelessWidget {
@override

View file

@ -1,5 +1,8 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/feature/map/data/offline_maps.dart';
import 'package:halkyon_train_flutter/src/feature/route/bloc/routes_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
import 'package:halkyon_train_flutter/src/feature/route/widget/route_screen.dart';
@ -15,63 +18,73 @@ class const RoutesList({super.key}) extends StatelessWidget {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toString();
return BlocBuilder<RoutesCubit, RoutesState>(
builder: (context, state) {
if (!state.loaded) {
return const Center(child: AppLoadingIndicator());
}
final routes = state.visible;
return ListView(
padding: const EdgeInsets.only(bottom: 88),
children: [
for (final row in [
ListTile(
leading: const Icon(LucideIcons.plus),
title: Text(l10n.routesNew),
onTap: () => openRouteScreen(
context,
const RouteContent(
name: '',
profile: RouteProfile.run,
waypoints: [],
final offline = PersonScope.of<TrainPerson>(context).offlineMaps?.kept;
return ValueListenableBuilder(
valueListenable: offline ?? ValueNotifier(const <String, KeptMap>{}),
builder: (context, kept, _) => BlocBuilder<RoutesCubit, RoutesState>(
builder: (context, state) {
if (!state.loaded) {
return const Center(child: AppLoadingIndicator());
}
final routes = state.visible;
return ListView(
padding: const EdgeInsets.only(bottom: 88),
children: [
for (final row in [
ListTile(
leading: const Icon(LucideIcons.plus),
title: Text(l10n.routesNew),
onTap: () => openRouteScreen(
context,
const RouteContent(
name: '',
profile: RouteProfile.run,
waypoints: [],
),
),
),
),
if (routes.isEmpty)
Padding(
padding: const EdgeInsets.only(top: 48),
child: EmptyListMessage(
icon: LucideIcons.route,
title: l10n.routesEmptyTitle,
hint: l10n.routesEmptyHint,
if (routes.isEmpty)
Padding(
padding: const EdgeInsets.only(top: 48),
child: EmptyListMessage(
icon: LucideIcons.route,
title: l10n.routesEmptyTitle,
hint: l10n.routesEmptyHint,
),
),
for (final route in routes)
switch (route.content) {
final content? => ListTile(
leading: const Icon(LucideIcons.route),
title: Text(content.name),
subtitle: Text(routeSummary(l10n, locale, content)),
trailing: kept.containsKey(route.id)
? Icon(
LucideIcons.hardDriveDownload,
semanticLabel: l10n.routeKeptOffline,
)
: null,
onTap: () =>
openRouteScreen(context, content, id: route.id),
),
null => ListTile(
leading: const Icon(LucideIcons.lock),
title: Text(l10n.routeLocked),
),
},
])
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: readableContentMaxWidth,
),
child: row,
),
),
for (final route in routes)
switch (route.content) {
final content? => ListTile(
leading: const Icon(LucideIcons.route),
title: Text(content.name),
subtitle: Text(routeSummary(l10n, locale, content)),
onTap: () =>
openRouteScreen(context, content, id: route.id),
),
null => ListTile(
leading: const Icon(LucideIcons.lock),
title: Text(l10n.routeLocked),
),
},
])
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: readableContentMaxWidth,
),
child: row,
),
),
],
);
},
],
);
},
),
);
}
}

View file

@ -14,6 +14,8 @@ resolution: workspace
dependencies:
clock: ^1.1.3
# What a route's map kept offline was made for (ADR 0030, D45).
crypto: ^3.0.7
equatable: ^3.0.0
flutter:
sdk: flutter
@ -42,6 +44,9 @@ dependencies:
# Noto Sans and the widgets every screen is built of.
halkyon_ui:
path: ../../../packages/halkyon_ui
# A route's map kept offline (D45): its style, sprites and tiles asked
# for directly, as vector_map_tiles asks for them.
http: ^1.6.0
# gen-l10n's output imports it.
intl: ^0.20.3
latlong2: ^0.10.1
@ -57,6 +62,9 @@ dependencies:
# Pinned: the 9 line is beta, the only one for flutter_map 8 (D23); moves to
# 9.0.0 once it is released.
vector_map_tiles: 9.0.0-beta.13
# Reads a style kept offline (D45), which vector_map_tiles reads only from
# the network; the version vector_map_tiles takes.
vector_tile_renderer: ^6.1.0
dev_dependencies:
flutter_driver:

View file

@ -20,6 +20,7 @@ import 'package:halkyon_train_flutter/src/feature/loop/data/loop_store.dart';
import 'package:halkyon_train_flutter/src/feature/loop/data/polar_loop.dart';
import 'package:halkyon_train_flutter/src/feature/loop/model/loop_figures.dart';
import 'package:halkyon_train_flutter/src/feature/map/data/map_source_repository.dart';
import 'package:halkyon_train_flutter/src/feature/map/data/offline_maps.dart';
import 'package:halkyon_train_flutter/src/feature/plan/data/plan_repository.dart';
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
import 'package:halkyon_train_flutter/src/feature/plan/model/stored_plan.dart';
@ -36,6 +37,8 @@ import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:latlong2/latlong.dart';
import 'package:material_ui/material_ui.dart';
@ -84,6 +87,7 @@ void main() {
PlanRepository? plans,
RouteRepository? routes,
RoutingGateway? routing,
OfflineMaps? offlineMaps,
}) async {
tester.view.physicalSize = const Size(412, 900);
tester.view.devicePixelRatio = 1;
@ -203,6 +207,7 @@ void main() {
),
// No tiles: widget tests reach no network.
mapTiles: (source) => const SizedBox.shrink(),
offlineMaps: offlineMaps,
);
},
),
@ -1939,12 +1944,14 @@ void main() {
Future<void> openRoutes(
WidgetTester tester, {
({double lat, double lon})? at,
OfflineMaps? offlineMaps,
}) async {
await start(
tester,
usedBefore: true,
routes: routes,
routing: routing,
offlineMaps: offlineMaps,
recording: at == null
? null
: (
@ -2104,6 +2111,128 @@ void main() {
expect(find.text('Isar'), findsOneWidget);
expect((await tester.runAsync(routes.all))!.single.id, id);
});
testWidgets("a saved route's map is kept offline on request, its tiles "
'counted, marked in the list, and let go again (D45)', (tester) async {
final id = await routes.save(
RouteContent(
name: 'Isar',
profile: RouteProfile.run,
waypoints: const [LatLng(48.137, 11.575), LatLng(48.13, 11.575)],
planned: straightRoute(const [
LatLng(48.137, 11.575),
LatLng(48.13, 11.575),
]),
),
);
final asked = <String>[];
final offline = OfflineMaps(
Directory('${folder.path}/offline_maps'),
client: MockClient((request) async {
asked.add(request.url.path);
return request.url.path.startsWith('/styles/')
? http.Response(
jsonEncode({
'version': 8,
'sources': {
'planet': {
'type': 'vector',
'tiles': ['https://tiles.example/{z}/{x}/{y}.pbf'],
'maxzoom': 14,
},
},
'layers': <Object?>[],
}),
200,
)
: http.Response.bytes(List.filled(1000, 1), 200);
}),
);
addTearDown(offline.dispose);
// The files and the fake server in turn with the test's own time, until
// [done].
Future<void> until(bool Function() done) async {
for (var i = 0; i < 200 && !done(); i++) {
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 10)),
);
await tester.pump();
}
}
final keptMark = find.byWidgetPredicate(
(widget) =>
widget is Icon && widget.semanticLabel == 'Map kept offline',
);
await openRoutes(tester, offlineMaps: offline);
expect(keptMark, findsNothing);
await tester.tap(find.text('Isar'));
await tester.pumpAndSettle();
final keep = find.widgetWithText(SwitchListTile, 'Keep the map offline');
await tester.scrollUntilVisible(
keep,
100,
scrollable: find
.descendant(
of: find.byType(ListView),
matching: find.byType(Scrollable),
)
.first,
);
expect(
find.text(
'Its map along the way, 2 km either side, on this device for '
"when there's no network",
),
findsOneWidget,
);
await tester.ensureVisible(keep);
await tester.pumpAndSettle();
await tester.tap(keep);
await until(() => !(offline.kept.value[id]?.loading ?? true));
await tester.pumpAndSettle();
final tiles = asked.where((path) => !path.startsWith('/styles/'));
expect(tiles, isNotEmpty);
expect(tiles.any((path) => path.startsWith('/15/')), isFalse);
expect(
find.text(
'Kept on this device: '
'${(tiles.length * 1000 / 1000000).toStringAsFixed(1)} MB',
),
findsOneWidget,
);
expect(tester.widget<SwitchListTile>(keep).value, isTrue);
await tester.pageBack();
await tester.pumpAndSettle();
expect(keptMark, findsOneWidget);
await tester.tap(find.text('Isar'));
await tester.pumpAndSettle();
await tester.scrollUntilVisible(
keep,
100,
scrollable: find
.descendant(
of: find.byType(ListView),
matching: find.byType(Scrollable),
)
.first,
);
await tester.ensureVisible(keep);
await tester.pumpAndSettle();
await tester.tap(keep);
await until(() => !offline.kept.value.containsKey(id));
await tester.pumpAndSettle();
expect(tester.widget<SwitchListTile>(keep).value, isFalse);
expect(
Directory('${folder.path}/offline_maps/tiles')
.listSync(recursive: true)
.whereType<File>(),
isEmpty,
);
});
});
group('workouts (ADR 0030, D24)', () {

View file

@ -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)));
});
}

View file

@ -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'));
}

View file

@ -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),
};
});
}