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