mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-06-05 06:28:07 +02:00
2d92214bed
- 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>
96 lines
3.5 KiB
Dart
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});
|
|
}
|