V3-11: offline tile pre-download along a route corridor

Three pure modules: tile_math.dart (tilesForBounds/tilesAlongRoute, hard cap enforced by throwing), tile_cache.dart (FileTileCache with LRU eviction before any write that would exceed the cap), tile_downloader.dart (sequential, rate-limited, cooperatively cancellable, one bad tile doesn't abort the rest). CachedTileProvider wires the cache into flutter_map via a custom ImageProvider and now backs RideMap and RoutePlannerScreen's tile layers, so ordinary viewing write-throughs into the same capped cache.

RoutePlannerScreen gained a route-corridor download action (no rectangle-selection UI -- the ticket names the corridor as strictly better and V3-07 already exists to hang it off of), with a count/size confirmation before any request and a cancellable progress dialog. Fixed a real bug before shipping: a StatefulBuilder-based progress dialog would have started a new overlapping download subscription on every single progress tick; moved to a dedicated StatefulWidget that subscribes once in initState.

Marked partially done: aeroplane-mode verification on a real device is the ticket's own acceptance criterion and needs a phone this environment doesn't have.
This commit is contained in:
2026-08-17 19:45:40 -05:00
parent be50448917
commit e4ffafe9e0
16 changed files with 1089 additions and 5 deletions

View File

@@ -44,12 +44,18 @@ class RideMap extends StatefulWidget {
required this.segments,
this.height = 320,
this.follow = false,
this.tileProvider,
});
final List<TrackPoint> points;
final List<Segment> segments;
final double height;
/// V3-11: when supplied, tiles are read from (and written through to) the offline
/// tile cache instead of flutter_map's own uncapped default. Null falls back to
/// ordinary networked tiles -- used whenever the cache isn't ready yet, or in tests.
final TileProvider? tileProvider;
/// V3-04: keep the latest point centred while recording. A manual pan/pinch turns
/// this off until the widget is rebuilt fresh (e.g. a new ride) — chasing the rider
/// back to centre after they deliberately looked elsewhere would be worse than not
@@ -184,6 +190,7 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
maxNativeZoom: maxTileZoom.toInt(),
// Respect OSM's usage policy: render what is looked at, never bulk prefetch.
panBuffer: 0,
tileProvider: widget.tileProvider,
),
PolylineLayer(polylines: polylines),
],

View File

@@ -326,7 +326,13 @@ class _Body extends StatelessWidget {
),
const SizedBox(height: 24),
if (detail.mapEnabled)
RideMap(points: detail.points, segments: detail.segments),
Consumer(
builder: (context, ref, _) => RideMap(
points: detail.points,
segments: detail.segments,
tileProvider: ref.watch(cachedTileProviderProvider),
),
),
if (detail.mapEnabled) const SizedBox(height: 24),
Card(
child: Padding(

View File

@@ -369,6 +369,7 @@ class _LiveMap extends ConsumerWidget {
segments: segments,
height: 220,
follow: true,
tileProvider: ref.watch(cachedTileProviderProvider),
);
}
}

View File

@@ -4,16 +4,23 @@
/// polyline drawn here is derived from waypoints on every build, never persisted.
library;
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart' as ll;
import '../../app/providers.dart';
import '../../data/route_plan_repository.dart';
import '../../domain/models.dart';
import '../../geo/geo.dart' as geo;
import '../../tiles/tile_cache.dart';
import '../../tiles/tile_downloader.dart';
import '../../tiles/tile_math.dart';
import '../components/ride_map.dart' show maxTileZoom, tileUserAgent;
import '../components/stats.dart' show confirmDialog;
import '../format.dart';
class RoutePlannerScreen extends ConsumerStatefulWidget {
@@ -86,6 +93,16 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
),
title: Text(route.name),
actions: [
IconButton(
key: const Key('download-tiles'),
icon: const Icon(Icons.download_for_offline_outlined),
tooltip: waypoints.isEmpty
? 'Add pins first'
: 'Download offline tiles along this route',
onPressed: waypoints.isEmpty
? null
: () => _downloadOfflineTiles(context, waypoints),
),
IconButton(
key: const Key('rename-route'),
icon: const Icon(Icons.edit_outlined),
@@ -130,6 +147,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
userAgentPackageName: tileUserAgent,
maxNativeZoom: maxTileZoom.toInt(),
panBuffer: 0,
tileProvider: ref.watch(cachedTileProviderProvider),
),
if (waypoints.length >= 2)
PolylineLayer(
@@ -232,6 +250,146 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
if (name == null || name.isEmpty) return;
await repo.renameRoutePlan(widget.routeId, name);
}
/// V3-11: pre-downloads a corridor along the route, not a rectangle around it -- "far
/// fewer tiles for the same usefulness," as the ticket puts it. Zoom range is fixed
/// (city-street level through the map's own max) rather than picked by the rider --
/// keeping the choice small is part of what keeps this within OSM's usage policy.
static const _downloadMinZoom = 13;
Future<void> _downloadOfflineTiles(
BuildContext context,
List<Waypoint> waypoints,
) async {
final Set<TileKey> tiles;
try {
tiles = tilesAlongRoute(
[for (final w in waypoints) geo.LatLon(w.latitude, w.longitude)],
minZoom: _downloadMinZoom,
maxZoom: maxTileZoom.toInt(),
);
} on TooManyTilesException catch (e) {
if (!context.mounted) return;
await showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Too many tiles'),
content: Text(
'This route would need ${e.requested} tiles, more than this app will '
'request in one go. Try a shorter route.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
),
);
return;
}
final sizeMb = estimatedSizeMb(tiles.length);
if (!context.mounted) return;
final confirmed = await confirmDialog(
context,
title: 'Download ${tiles.length} tiles?',
message: 'Roughly ${sizeMb.toStringAsFixed(1)} MB, fetched slowly and one at a '
'time so the map stays available to everyone else using it too.',
confirmLabel: 'Download',
);
if (!confirmed) return;
final cache = await ref.read(tileCacheProvider.future);
if (!context.mounted) return;
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (context) => _DownloadDialog(tiles: tiles.toList(), cache: cache),
);
}
}
/// Owns the download's stream subscription for exactly the lifetime of the dialog --
/// pulled out of a plain `showDialog` builder because that builder re-runs on every
/// `setState`, which would otherwise start a brand new overlapping download on every
/// single progress update.
class _DownloadDialog extends StatefulWidget {
const _DownloadDialog({required this.tiles, required this.cache});
final List<TileKey> tiles;
final TileCache cache;
@override
State<_DownloadDialog> createState() => _DownloadDialogState();
}
class _DownloadDialogState extends State<_DownloadDialog> {
final _token = CancelToken();
final _client = http.Client();
StreamSubscription<DownloadProgress>? _sub;
DownloadProgress _progress = const DownloadProgress(completed: 0, total: 0, failed: 0);
@override
void initState() {
super.initState();
_sub = downloadTiles(
tiles: widget.tiles,
cache: widget.cache,
cancelToken: _token,
fetchTile: (key) async {
final url = 'https://tile.openstreetmap.org/${key.z}/${key.x}/${key.y}.png';
final response = await _client.get(
Uri.parse(url),
headers: {'User-Agent': tileUserAgent},
);
if (response.statusCode != 200) {
throw Exception('HTTP ${response.statusCode}');
}
return response.bodyBytes;
},
).listen((p) {
if (!mounted) return;
setState(() => _progress = p);
if (p.isDone) Navigator.of(context).pop();
});
}
@override
void dispose() {
_sub?.cancel();
_client.close();
super.dispose();
}
@override
Widget build(BuildContext context) => AlertDialog(
key: const Key('download-progress-dialog'),
title: const Text('Downloading tiles'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
LinearProgressIndicator(
value: _progress.total == 0
? 0
: (_progress.completed + _progress.failed) / _progress.total,
),
const SizedBox(height: 12),
Text('${_progress.completed} of ${_progress.total} '
'(${_progress.failed} failed)'),
],
),
actions: [
TextButton(
key: const Key('cancel-download'),
onPressed: () {
_token.cancel();
Navigator.of(context).pop();
},
child: const Text('Cancel'),
),
],
);
}
class _WaypointPin extends StatelessWidget {

View File

@@ -157,6 +157,33 @@ class _SettingsBodyState extends ConsumerState<_SettingsBody> {
},
),
const Divider(),
const _SectionHeader('Offline tiles'),
ListTile(
title: const Text('Cache size'),
subtitle: Text(
switch (ref.watch(tileCacheSizeProvider)) {
AsyncValue(hasValue: true, value: final bytes?) =>
'${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB of '
'${defaultTileCacheMaxBytes ~/ (1024 * 1024)} MB',
_ => 'Calculating...',
},
),
trailing: TextButton(
key: const Key('clear-tile-cache'),
onPressed: () async {
final cache = await ref.read(tileCacheProvider.future);
await cache.clear();
ref.invalidate(tileCacheSizeProvider);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Offline tiles cleared')),
);
}
},
child: const Text('Clear'),
),
),
const Divider(),
const _SectionHeader('Sync'),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),