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

@@ -6,16 +6,19 @@
/// instrumented test once wiped a real device's rides.
library;
import 'dart:io';
import 'package:drift/drift.dart' show driftRuntimeOptions;
import 'package:drift_flutter/drift_flutter.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import '../config/config.dart';
import '../crash/crash_reporter.dart';
import '../data/database.dart';
import '../data/route_plan_repository.dart';
import '../data/trip_repository.dart';
import '../domain/models.dart';
import '../crash/crash_reporter.dart';
import '../data/route_plan_repository.dart';
import '../notification/ride_notification_controller.dart';
import '../notification/ride_notification_coordinator.dart';
import '../recording/geolocator_location_source.dart';
@@ -24,6 +27,8 @@ import '../recording/recording_engine.dart';
import '../recording/wakelock_controller.dart';
import '../telemetry/live_telemetry.dart';
import '../telemetry/telemetry_uploader.dart';
import '../tiles/cached_tile_provider.dart';
import '../tiles/tile_cache.dart';
/// The Drift database, opened against app-private storage.
///
@@ -183,3 +188,41 @@ final routePlanProvider = StreamProvider.autoDispose.family<RoutePlan?, int>(
final routeWaypointsProvider = StreamProvider.autoDispose.family<List<Waypoint>, int>(
(ref, routeId) => ref.watch(routePlanRepositoryProvider).watchWaypoints(routeId),
);
// --- Offline tiles (V3-11) ---------------------------------------------------
/// A generous but real cap -- storage growth is explicitly not optional to bound (see
/// the ticket's Risks). Not user-configurable in this pass; only whether to clear it.
const int defaultTileCacheMaxBytes = 200 * 1024 * 1024;
/// A [FutureProvider] rather than a plain [Provider]: the cache directory comes from
/// `path_provider`, which is inherently async. Every reader (the map, the download
/// screen, settings) treats "not loaded yet" as "behave as if there is no cache" rather
/// than blocking on it -- see [RideMap.tileProvider] falling back to plain networked
/// tiles while this resolves.
final tileCacheProvider = FutureProvider<TileCache>((ref) async {
final support = await getApplicationSupportDirectory();
final cache = FileTileCache(
directory: Directory('${support.path}/tiles'),
maxBytes: defaultTileCacheMaxBytes,
);
ref.onDispose(cache.dispose);
return cache;
});
/// Built fresh from [tileCacheProvider] on every read rather than cached itself --
/// cheap to construct, and it must always wrap whichever [TileCache] instance is
/// current.
final cachedTileProviderProvider = Provider<CachedTileProvider?>((ref) {
final cache = ref.watch(tileCacheProvider).valueOrNull;
if (cache == null) return null;
return CachedTileProvider(cache: cache);
});
/// Settings' "Offline tiles" section reads this rather than [tileCacheProvider]
/// directly, so the number on screen updates the moment `Clear cache` invalidates it --
/// see `SettingsScreen`.
final tileCacheSizeProvider = FutureProvider<int>((ref) async {
final cache = await ref.watch(tileCacheProvider.future);
return cache.sizeBytes();
});

View File

@@ -0,0 +1,87 @@
/// Feeds `RideMap`'s `TileLayer` from [TileCache] first, network second -- the same
/// cache a V3-11 pre-download populates, so a rider who downloaded a corridor actually
/// sees it render without a request going out. See V3-11.
library;
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:http/http.dart' as http;
import 'tile_cache.dart';
import 'tile_math.dart';
class CachedTileProvider extends TileProvider {
CachedTileProvider({required this.cache, http.Client? client})
: _client = client ?? http.Client(),
super();
final TileCache cache;
final http.Client _client;
@override
ImageProvider getImage(TileCoordinates coordinates, TileLayer options) =>
_CacheBackedImage(
key: TileKey(coordinates.z, coordinates.x, coordinates.y),
url: getTileUrl(coordinates, options),
headers: headers,
cache: cache,
client: _client,
);
}
class _CacheBackedImage extends ImageProvider<_CacheBackedImage> {
const _CacheBackedImage({
required this.key,
required this.url,
required this.headers,
required this.cache,
required this.client,
});
final TileKey key;
final String url;
final Map<String, String> headers;
final TileCache cache;
final http.Client client;
@override
Future<_CacheBackedImage> obtainKey(ImageConfiguration configuration) =>
SynchronousFuture(this);
@override
ImageStreamCompleter loadImage(_CacheBackedImage key, ImageDecoderCallback decode) =>
MultiFrameImageStreamCompleter(
codec: _load(decode),
scale: 1.0,
debugLabel: url,
);
Future<ui.Codec> _load(ImageDecoderCallback decode) async {
final cached = await cache.get(key);
final bytes = cached ?? await _fetchAndStore();
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
return decode(buffer);
}
Future<Uint8List> _fetchAndStore() async {
final response = await client.get(Uri.parse(url), headers: headers);
if (response.statusCode != 200) {
throw Exception('Tile fetch failed: ${response.statusCode} for $url');
}
final bytes = response.bodyBytes;
// Write-through: viewing a tile online caches it for later, exactly like
// flutter_map's own default caching did -- just capped and evictable now.
await cache.put(key, bytes);
return bytes;
}
@override
bool operator ==(Object other) => other is _CacheBackedImage && other.key == key;
@override
int get hashCode => key.hashCode;
}

View File

@@ -0,0 +1,139 @@
/// A persistent, size-capped tile cache. See V3-11.
///
/// **Storage growth is not optional to cap** (the ticket's own risk section): every
/// [put] that would push the cache over [maxBytes] evicts the least-recently-used tiles
/// first, until it fits, before the new tile is even written.
library;
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'tile_math.dart';
class _Entry {
_Entry({required this.bytes, required this.lastAccess});
final int bytes;
int lastAccess;
}
abstract class TileCache {
Future<void> put(TileKey key, Uint8List bytes);
Future<Uint8List?> get(TileKey key);
Future<int> sizeBytes();
Future<void> clear();
Future<void> dispose();
}
/// Tiles as files on disk, keyed by `z_x_y`, with a JSON manifest tracking size and
/// last-access time for LRU eviction. No database engine for what is, at the end of the
/// day, a directory of small binary blobs with one number (last access) attached to each.
class FileTileCache implements TileCache {
FileTileCache({required Directory directory, required this.maxBytes})
: _dir = directory;
final Directory _dir;
final int maxBytes;
final _manifest = <String, _Entry>{};
bool _loaded = false;
int _clock = 0;
File get _manifestFile => File('${_dir.path}/manifest.json');
File _tileFile(TileKey key) => File('${_dir.path}/${_fileName(key)}');
String _fileName(TileKey key) => '${key.z}_${key.x}_${key.y}.tile';
Future<void> _ensureLoaded() async {
if (_loaded) return;
_loaded = true;
if (!await _dir.exists()) await _dir.create(recursive: true);
if (!await _manifestFile.exists()) return;
final raw = jsonDecode(await _manifestFile.readAsString()) as Map<String, dynamic>;
for (final entry in raw.entries) {
final v = entry.value as Map<String, dynamic>;
_manifest[entry.key] = _Entry(
bytes: v['bytes'] as int,
lastAccess: v['lastAccess'] as int,
);
_clock = _clock > (v['lastAccess'] as int) ? _clock : (v['lastAccess'] as int) + 1;
}
}
Future<void> _saveManifest() => _manifestFile.writeAsString(
jsonEncode({
for (final e in _manifest.entries)
e.key: {'bytes': e.value.bytes, 'lastAccess': e.value.lastAccess},
}),
);
@override
Future<void> put(TileKey key, Uint8List bytes) async {
await _ensureLoaded();
final name = _fileName(key);
// Replacing an existing tile: drop its old size first so the cap check below isn't
// penalised by a tile that's about to be overwritten anyway.
_manifest.remove(name);
if (_totalBytes() + bytes.length > maxBytes) {
await _evictUntilFits(bytes.length);
}
await _tileFile(key).writeAsBytes(bytes);
_manifest[name] = _Entry(bytes: bytes.length, lastAccess: _clock++);
await _saveManifest();
}
Future<void> _evictUntilFits(int incomingBytes) async {
// Oldest-accessed first.
final byAge = _manifest.entries.toList()
..sort((a, b) => a.value.lastAccess.compareTo(b.value.lastAccess));
for (final entry in byAge) {
if (_totalBytes() + incomingBytes <= maxBytes) break;
_manifest.remove(entry.key);
final f = File('${_dir.path}/${entry.key}');
if (await f.exists()) await f.delete();
}
}
int _totalBytes() => _manifest.values.fold(0, (sum, e) => sum + e.bytes);
@override
Future<Uint8List?> get(TileKey key) async {
await _ensureLoaded();
final name = _fileName(key);
final entry = _manifest[name];
if (entry == null) return null;
final file = _tileFile(key);
if (!await file.exists()) {
// Manifest and disk disagree -- treat as a miss and self-heal the manifest rather
// than surfacing an error for what is, from the caller's perspective, just an
// uncached tile.
_manifest.remove(name);
return null;
}
entry.lastAccess = _clock++;
return file.readAsBytes();
}
@override
Future<int> sizeBytes() async {
await _ensureLoaded();
return _totalBytes();
}
@override
Future<void> clear() async {
await _ensureLoaded();
for (final key in _manifest.keys.toList()) {
final f = File('${_dir.path}/$key');
if (await f.exists()) await f.delete();
}
_manifest.clear();
await _saveManifest();
}
@override
Future<void> dispose() async {}
}

View File

@@ -0,0 +1,74 @@
/// Sequential, rate-limited, cancellable tile fetching. See V3-11.
///
/// **Never parallel, never a burst.** OSM's usage policy is what shapes this file --
/// bulk/parallel fetching against the public tile servers is exactly what gets a user
/// agent blocked, which would break the map for everyone, not just this download.
library;
import 'dart:async';
import 'dart:typed_data';
import 'tile_cache.dart';
import 'tile_math.dart';
class DownloadProgress {
const DownloadProgress({
required this.completed,
required this.total,
required this.failed,
});
final int completed;
final int total;
final int failed;
bool get isDone => completed + failed >= total;
}
/// A cooperative cancel flag, checked between tiles -- not `Future.timeout` or a
/// `Stream` subscription cancel, which would leave an in-flight fetch's result
/// discarded rather than the loop simply stopping before starting the next one.
class CancelToken {
bool _cancelled = false;
bool get isCancelled => _cancelled;
void cancel() => _cancelled = true;
}
/// Downloads [tiles] one at a time via [fetchTile], writing each to [cache] as it
/// arrives -- so cancelling mid-download keeps everything already fetched, per the
/// ticket's acceptance criteria, rather than committing only at the end.
///
/// [delay] is the rate limit: a pause after every tile, successful or not, so a large
/// download reads as a slow trickle to the tile server rather than a burst.
Stream<DownloadProgress> downloadTiles({
required List<TileKey> tiles,
required TileCache cache,
required Future<Uint8List> Function(TileKey key) fetchTile,
CancelToken? cancelToken,
Duration delay = const Duration(milliseconds: 250),
}) async* {
var completed = 0;
var failed = 0;
final total = tiles.length;
yield DownloadProgress(completed: 0, total: total, failed: 0);
for (final tile in tiles) {
if (cancelToken?.isCancelled ?? false) break;
try {
final bytes = await fetchTile(tile);
await cache.put(tile, bytes);
completed++;
} on Object {
// One bad tile (a transient network blip, a 404 at the map's edge) must not abort
// tiles that would otherwise succeed -- the download is for a whole corridor, and
// losing one tile in it is a much smaller problem than losing all of them.
failed++;
}
yield DownloadProgress(completed: completed, total: total, failed: failed);
if (cancelToken?.isCancelled ?? false) break;
if (completed + failed < total) await Future<void>.delayed(delay);
}
}

View File

@@ -0,0 +1,126 @@
/// Pure slippy-map tile math for V3-11's offline pre-download. No Flutter, no network --
/// fully unit-testable, the same reasoning as `geo/geo.dart`.
library;
import 'dart:math';
import '../geo/geo.dart' show LatLon;
/// One XYZ tile. Equality/hashCode so a `Set<TileKey>` can dedupe overlapping coverage
/// from adjacent route points -- see [tilesAlongRoute].
class TileKey {
const TileKey(this.z, this.x, this.y);
final int z;
final int x;
final int y;
@override
bool operator ==(Object other) =>
other is TileKey && other.z == z && other.x == x && other.y == y;
@override
int get hashCode => Object.hash(z, x, y);
@override
String toString() => '$z/$x/$y';
}
/// **Respect OSM's tile usage policy** (see the ticket): this is the hard ceiling on any
/// single pre-download, regardless of how the caller arrived at a tile set. Enforced by
/// [TooManyTilesException], not left to a caller to remember.
const int maxTilesPerDownload = 2000;
class TooManyTilesException implements Exception {
const TooManyTilesException(this.requested);
final int requested;
@override
String toString() =>
'TooManyTilesException: $requested tiles requested, cap is $maxTilesPerDownload';
}
int _lonToTileX(double lon, int z) =>
(((lon + 180.0) / 360.0) * (1 << z)).floor().clamp(0, (1 << z) - 1);
int _latToTileY(double lat, int z) {
final latRad = lat * pi / 180.0;
final y =
(1.0 - log(tan(latRad) + 1 / cos(latRad)) / pi) / 2.0 * (1 << z);
return y.floor().clamp(0, (1 << z) - 1);
}
/// Every tile covering a bounding box, across every zoom from [minZoom] to [maxZoom]
/// inclusive. Throws [TooManyTilesException] rather than silently truncating -- a
/// caller must shrink the area or the zoom range, not receive a partial download it
/// doesn't know is partial.
Set<TileKey> tilesForBounds({
required double minLat,
required double maxLat,
required double minLon,
required double maxLon,
required int minZoom,
required int maxZoom,
}) {
final tiles = <TileKey>{};
for (var z = minZoom; z <= maxZoom; z++) {
// Web Mercator y increases southward, so the northern (max) latitude gives the
// smaller tile-y value.
final minX = _lonToTileX(minLon, z);
final maxX = _lonToTileX(maxLon, z);
final minY = _latToTileY(maxLat, z);
final maxY = _latToTileY(minLat, z);
for (var x = minX; x <= maxX; x++) {
for (var y = minY; y <= maxY; y++) {
tiles.add(TileKey(z, x, y));
if (tiles.length > maxTilesPerDownload) {
throw TooManyTilesException(tiles.length);
}
}
}
}
return tiles;
}
/// A tile corridor around a route rather than a rectangle around its bounding box -- "far
/// fewer tiles for the same usefulness," as the ticket puts it. Buffers each point by
/// [bufferMeters] and unions the small per-point tile sets, so a long thin route costs
/// close to its actual length rather than the area of the box that contains it.
Set<TileKey> tilesAlongRoute(
List<LatLon> points, {
required int minZoom,
required int maxZoom,
double bufferMeters = 300,
}) {
final tiles = <TileKey>{};
// Rough conversion good enough for a small buffer: 1 degree of latitude is ~111.32 km
// everywhere; longitude shrinks with cos(latitude), recomputed per point since a long
// route can span enough latitude for that to matter.
const metresPerDegreeLat = 111320.0;
for (final p in points) {
final dLat = bufferMeters / metresPerDegreeLat;
final dLon = bufferMeters / (metresPerDegreeLat * cos(p.lat * pi / 180.0)).abs();
tiles.addAll(
tilesForBounds(
minLat: p.lat - dLat,
maxLat: p.lat + dLat,
minLon: p.lon - dLon,
maxLon: p.lon + dLon,
minZoom: minZoom,
maxZoom: maxZoom,
),
);
if (tiles.length > maxTilesPerDownload) {
throw TooManyTilesException(tiles.length);
}
}
return tiles;
}
/// A conservative estimate shown **before** any request goes out -- the acceptance
/// criterion is a number the rider sees ahead of time, not an accurate one. OSM raster
/// tiles are typically 10-25 KB; 15 KB is a reasonable middle estimate for a mixed
/// urban/rural area.
double estimatedSizeMb(int tileCount, {double avgTileSizeKb = 15}) =>
tileCount * avgTileSizeKb / 1024;

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),