finalize code paths for offline areas, caching, in light of multiple tile providers

This commit is contained in:
stopflock
2025-08-26 17:52:14 -05:00
parent 17c9ee0c5c
commit a3edcfc2de
6 changed files with 69 additions and 49 deletions

View File

@@ -3,9 +3,14 @@ import 'package:latlong2/latlong.dart';
import '../offline_area_service.dart';
import '../offline_areas/offline_area_models.dart';
import '../offline_areas/offline_tile_utils.dart';
import '../../app_state.dart';
/// Fetch a tile from the newest offline area that plausibly contains it, or throw if not found.
/// Fetch a tile from the newest offline area that matches the current provider, or throw if not found.
Future<List<int>> fetchLocalTile({required int z, required int x, required int y}) async {
final appState = AppState.instance;
final currentProvider = appState.selectedTileProvider;
final currentTileType = appState.selectedTileType;
final offlineService = OfflineAreaService();
await offlineService.ensureInitialized();
final areas = offlineService.offlineAreas;
@@ -14,6 +19,9 @@ Future<List<int>> fetchLocalTile({required int z, required int x, required int y
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 != currentProvider?.id || area.tileTypeId != currentTileType?.id) continue;
// Get tile coverage for area at this zoom only
final coveredTiles = computeTileList(area.bounds, z, z);
@@ -28,7 +36,7 @@ Future<List<int>> fetchLocalTile({required int z, required int x, required int y
}
}
if (candidates.isEmpty) {
throw Exception('Tile $z/$x/$y not found in any offline area');
throw Exception('Tile $z/$x/$y from current provider ${currentProvider?.id}/${currentTileType?.id} not found in any offline area');
}
candidates.sort((a, b) => b.modified.compareTo(a.modified)); // newest first
return await candidates.first.file.readAsBytes();

View File

@@ -60,6 +60,7 @@ class OfflineAreaService {
await _loadAreasFromDisk();
await WorldAreaManager.ensureWorldArea(_areas, getOfflineAreaDir, downloadArea);
await saveAreasToDisk(); // Save any world area updates
_initialized = true;
}

View File

@@ -109,6 +109,7 @@ class OfflineAreaDownloader {
) async {
try {
// Use the same unified path as live tiles: always go through MapDataProvider
// MapDataProvider will use current AppState provider for downloads
final bytes = await MapDataProvider().getTile(
z: tile[0],
x: tile[1],

View File

@@ -38,7 +38,7 @@ class WorldAreaManager {
}
}
// Create world area if it doesn't exist
// Create world area if it doesn't exist, or update existing area without provider info
if (world == null) {
final appDocDir = await getOfflineAreaDir();
final dir = "${appDocDir.path}/$_worldAreaId";
@@ -51,8 +51,38 @@ class WorldAreaManager {
directory: dir,
status: OfflineAreaStatus.downloading,
isPermanent: true,
// World area always uses OpenStreetMap
tileProviderId: 'openstreetmap',
tileProviderName: 'OpenStreetMap',
tileTypeId: 'osm_street',
tileTypeName: 'Street Map',
);
areas.insert(0, world);
} else if (world.tileProviderId == null || world.tileTypeId == null) {
// Update existing world area that lacks provider metadata
final updatedWorld = OfflineArea(
id: world.id,
name: world.name,
bounds: world.bounds,
minZoom: world.minZoom,
maxZoom: world.maxZoom,
directory: world.directory,
status: world.status,
progress: world.progress,
tilesDownloaded: world.tilesDownloaded,
tilesTotal: world.tilesTotal,
cameras: world.cameras,
sizeBytes: world.sizeBytes,
isPermanent: world.isPermanent,
// Add missing provider metadata
tileProviderId: 'openstreetmap',
tileProviderName: 'OpenStreetMap',
tileTypeId: 'osm_street',
tileTypeName: 'Street Map',
);
final index = areas.indexOf(world);
areas[index] = updatedWorld;
world = updatedWorld;
}
// Check world area status and start download if needed

View File

@@ -13,10 +13,10 @@ class SimpleTileHttpClient extends http.BaseClient {
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
// Extract tile coordinates from the URL using our standard pattern
// Extract tile coordinates from our custom URL scheme
final tileCoords = _extractTileCoords(request.url);
if (tileCoords != null) {
final z = tileCoords['z']!; // We know these are not null from _extractTileCoords
final z = tileCoords['z']!;
final x = tileCoords['x']!;
final y = tileCoords['y']!;
return _handleTileRequest(z, x, y);
@@ -26,21 +26,22 @@ class SimpleTileHttpClient extends http.BaseClient {
return _inner.send(request);
}
/// Extract z/x/y coordinates from our standard tile URL pattern
/// Extract z/x/y coordinates from our fake domain: https://tiles.local/provider/type/z/x/y
/// We ignore the provider/type in the URL since we use current AppState for actual fetching
Map<String, int>? _extractTileCoords(Uri url) {
// We'll use a simple standard pattern: /{z}/{x}/{y}.png
// This will be the format we use in map_view.dart
final pathSegments = url.pathSegments;
if (url.host != 'tiles.local') return null;
if (pathSegments.length == 3) {
final z = int.tryParse(pathSegments[0]);
final x = int.tryParse(pathSegments[1]);
final yWithExt = pathSegments[2];
final y = int.tryParse(yWithExt.replaceAll(RegExp(r'\.[^.]*$'), '')); // Remove .png
if (z != null && x != null && y != null) {
return {'z': z, 'x': x, 'y': y};
}
final pathSegments = url.pathSegments;
if (pathSegments.length != 5) return null;
// pathSegments[0] = providerId (for cache separation only)
// pathSegments[1] = tileTypeId (for cache separation only)
final z = int.tryParse(pathSegments[2]);
final x = int.tryParse(pathSegments[3]);
final y = int.tryParse(pathSegments[4]);
if (z != null && x != null && y != null) {
return {'z': z, 'x': x, 'y': y};
}
return null;
@@ -49,6 +50,7 @@ class SimpleTileHttpClient extends http.BaseClient {
Future<http.StreamedResponse> _handleTileRequest(int z, int x, int y) async {
try {
// Always go through MapDataProvider - it handles offline/online routing
// MapDataProvider will get current provider from AppState
final tileBytes = await _mapDataProvider.getTile(z: z, x: x, y: y, source: MapSource.auto);
// Clear waiting status - we got data

View File

@@ -59,7 +59,6 @@ class MapViewState extends State<MapView> {
String? _lastTileTypeId;
bool? _lastOfflineMode;
int _mapRebuildKey = 0;
bool _shouldClearCache = false;
@override
void initState() {
@@ -181,40 +180,21 @@ class MapViewState extends State<MapView> {
return ids1.length == ids2.length && ids1.containsAll(ids2);
}
/// Build tile layer - uses standard URL that SimpleTileHttpClient can parse
/// Build tile layer - uses fake domain that SimpleTileHttpClient can parse
Widget _buildTileLayer(AppState appState) {
final selectedTileType = appState.selectedTileType;
final selectedProvider = appState.selectedTileProvider;
final offlineMode = appState.offlineMode;
// Create a unique cache key that includes provider, tile type, and offline mode
// This ensures different providers/modes have separate cache entries
String generateTileKey(String url) {
final providerKey = selectedProvider?.id ?? 'unknown';
final typeKey = selectedTileType?.id ?? 'unknown';
final modeKey = offlineMode ? 'offline' : 'online';
return '$providerKey-$typeKey-$modeKey-$url';
}
// Use a generic URL template that SimpleTileHttpClient recognizes
// The actual provider URL will be built by MapDataProvider using current AppState
// Create a completely fresh HTTP client when providers change
// This should bypass any caching at the HTTP client level
final httpClient = _shouldClearCache
? SimpleTileHttpClient() // Fresh instance
: _tileHttpClient; // Reuse existing
if (_shouldClearCache) {
debugPrint('[MapView] Creating fresh HTTP client to bypass cache');
}
// Use fake domain with standard HTTPS scheme: https://tiles.local/provider/type/z/x/y
// This naturally separates cache entries by provider and type while being HTTP-compatible
final urlTemplate = 'https://tiles.local/${selectedProvider?.id ?? 'unknown'}/${selectedTileType?.id ?? 'unknown'}/{z}/{x}/{y}';
return TileLayer(
urlTemplate: 'https://tiles.local/{z}/{x}/{y}.png?provider=${selectedProvider?.id}&type=${selectedTileType?.id}&mode=${offlineMode ? 'offline' : 'online'}',
urlTemplate: urlTemplate,
userAgentPackageName: 'com.stopflock.flock_map_app',
tileProvider: NetworkTileProvider(
httpClient: httpClient,
// Also disable flutter_map caching
cachingProvider: const DisabledMapCachingProvider(),
httpClient: _tileHttpClient,
// Enable flutter_map caching - cache busting handled by URL changes and FlutterMap key
),
);
}
@@ -246,17 +226,15 @@ class MapViewState extends State<MapView> {
if ((_lastTileTypeId != null && _lastTileTypeId != currentTileTypeId) ||
(_lastOfflineMode != null && _lastOfflineMode != currentOfflineMode)) {
// Force map rebuild with new key and destroy cache
// Force map rebuild with new key to bust flutter_map cache
_mapRebuildKey++;
_shouldClearCache = true;
final reason = _lastTileTypeId != currentTileTypeId
? 'tile type ($currentTileTypeId)'
: 'offline mode ($currentOfflineMode)';
debugPrint('[MapView] *** CACHE CLEAR *** $reason changed - destroying cache $_mapRebuildKey');
debugPrint('[MapView] *** CACHE CLEAR *** $reason changed - rebuilding map $_mapRebuildKey');
WidgetsBinding.instance.addPostFrameCallback((_) {
debugPrint('[MapView] Post-frame: Clearing tile request queue');
_tileHttpClient.clearTileQueue();
_shouldClearCache = false;
});
}