Files
deflock-app/lib/services/map_data_submodules/tiles_from_local.dart
T
Doug Borg 2d92214bed Add offline-first tile system with per-provider caching and error retry
- Add ServicePolicy framework with OSM-specific rate limiting and TTL
- Add per-provider disk tile cache (ProviderTileCacheStore) with O(1)
  lookup, oldest-modified eviction, and ETag/304 revalidation
- Rewrite DeflockTileProvider with two paths: common (NetworkTileProvider)
  and offline-first (disk cache -> local tiles -> network with caching)
- Add zoom-aware offline routing so tiles outside offline area zoom ranges
  use the efficient common path instead of the overhead-heavy offline path
- Fix HTTP client lifecycle: dispose() is now a no-op for flutter_map
  widget recycling; shutdown() handles permanent teardown
- Add TileLayerManager with exponential backoff retry (2s->60s cap),
  provider switch detection, and backoff reset
- Guard null provider/tileType in download dialog with localized error
- Fix Nominatim cache key to use normalized viewbox values
- Comprehensive test coverage (1800+ lines across 6 test files)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:34:01 -07:00

96 lines
3.5 KiB
Dart

import 'dart:io';
import 'dart:math';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import 'package:flutter/foundation.dart' show visibleForTesting;
import '../offline_area_service.dart';
import '../offline_areas/offline_area_models.dart';
import '../../app_state.dart';
/// Fetch a tile from the newest offline area that matches the given provider, or throw if not found.
///
/// When [providerId] and [tileTypeId] are supplied the lookup is pinned to
/// those values (avoids a race when the user switches provider mid-flight).
/// Otherwise falls back to the current AppState selection.
Future<List<int>> fetchLocalTile({
required int z,
required int x,
required int y,
String? providerId,
String? tileTypeId,
}) async {
final appState = AppState.instance;
final currentProviderId = providerId ?? appState.selectedTileProvider?.id;
final currentTileTypeId = tileTypeId ?? appState.selectedTileType?.id;
final offlineService = OfflineAreaService();
await offlineService.ensureInitialized();
final areas = offlineService.offlineAreas;
final List<_AreaTileMatch> candidates = [];
for (final area in areas) {
if (area.status != OfflineAreaStatus.complete) continue;
if (z < area.minZoom || z > area.maxZoom) continue;
// Only consider areas that match the current provider/type
if (area.tileProviderId != currentProviderId || area.tileTypeId != currentTileTypeId) continue;
// O(1) bounds check instead of enumerating all tiles at this zoom level
if (!tileInBounds(area.bounds, z, x, y)) continue;
final tilePath = _tilePath(area.directory, z, x, y);
final file = File(tilePath);
try {
final stat = await file.stat();
if (stat.type == FileSystemEntityType.notFound) continue;
candidates.add(_AreaTileMatch(area: area, file: file, modified: stat.modified));
} on FileSystemException {
continue;
}
}
if (candidates.isEmpty) {
throw Exception('Tile $z/$x/$y from provider $currentProviderId/$currentTileTypeId not found in any offline area');
}
candidates.sort((a, b) => b.modified.compareTo(a.modified)); // newest first
return await candidates.first.file.readAsBytes();
}
/// O(1) check whether tile (z, x, y) falls within the given lat/lng bounds.
///
/// Uses the same Mercator projection math as [latLonToTile] in
/// offline_tile_utils.dart, but only computes the bounding tile range
/// instead of enumerating every tile at that zoom level.
///
/// Note: Y axis is inverted in tile coordinates — north = lower Y.
@visibleForTesting
bool tileInBounds(LatLngBounds bounds, int z, int x, int y) {
final n = pow(2.0, z);
final west = bounds.west;
final east = bounds.east;
final north = bounds.north;
final south = bounds.south;
final minX = ((west + 180.0) / 360.0 * n).floor();
final maxX = ((east + 180.0) / 360.0 * n).floor();
// North → lower Y (Mercator projection inverts latitude)
final minY = ((1.0 - log(tan(north * pi / 180.0) +
1.0 / cos(north * pi / 180.0)) /
pi) / 2.0 * n).floor();
final maxY = ((1.0 - log(tan(south * pi / 180.0) +
1.0 / cos(south * pi / 180.0)) /
pi) / 2.0 * n).floor();
return x >= minX && x <= maxX && y >= minY && y <= maxY;
}
String _tilePath(String areaDir, int z, int x, int y) =>
'$areaDir/tiles/$z/$x/$y.png';
class _AreaTileMatch {
final OfflineArea area;
final File file;
final DateTime modified;
_AreaTileMatch({required this.area, required this.file, required this.modified});
}