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

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)', () {