From a3edcfc2de969c6921488dc7e701df9dce005cb9 Mon Sep 17 00:00:00 2001 From: stopflock Date: Tue, 26 Aug 2025 17:52:14 -0500 Subject: [PATCH] finalize code paths for offline areas, caching, in light of multiple tile providers --- .../map_data_submodules/tiles_from_local.dart | 12 +++++- lib/services/offline_area_service.dart | 1 + .../offline_area_downloader.dart | 1 + .../offline_areas/world_area_manager.dart | 32 ++++++++++++++- lib/services/simple_tile_service.dart | 32 ++++++++------- lib/widgets/map_view.dart | 40 +++++-------------- 6 files changed, 69 insertions(+), 49 deletions(-) diff --git a/lib/services/map_data_submodules/tiles_from_local.dart b/lib/services/map_data_submodules/tiles_from_local.dart index 9432b18..69b732c 100644 --- a/lib/services/map_data_submodules/tiles_from_local.dart +++ b/lib/services/map_data_submodules/tiles_from_local.dart @@ -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> 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> 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> 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(); diff --git a/lib/services/offline_area_service.dart b/lib/services/offline_area_service.dart index 6394b9b..3b52d39 100644 --- a/lib/services/offline_area_service.dart +++ b/lib/services/offline_area_service.dart @@ -60,6 +60,7 @@ class OfflineAreaService { await _loadAreasFromDisk(); await WorldAreaManager.ensureWorldArea(_areas, getOfflineAreaDir, downloadArea); + await saveAreasToDisk(); // Save any world area updates _initialized = true; } diff --git a/lib/services/offline_areas/offline_area_downloader.dart b/lib/services/offline_areas/offline_area_downloader.dart index 19330e6..0480358 100644 --- a/lib/services/offline_areas/offline_area_downloader.dart +++ b/lib/services/offline_areas/offline_area_downloader.dart @@ -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], diff --git a/lib/services/offline_areas/world_area_manager.dart b/lib/services/offline_areas/world_area_manager.dart index 879ecb3..8a4905a 100644 --- a/lib/services/offline_areas/world_area_manager.dart +++ b/lib/services/offline_areas/world_area_manager.dart @@ -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 diff --git a/lib/services/simple_tile_service.dart b/lib/services/simple_tile_service.dart index 0389021..222f537 100644 --- a/lib/services/simple_tile_service.dart +++ b/lib/services/simple_tile_service.dart @@ -13,10 +13,10 @@ class SimpleTileHttpClient extends http.BaseClient { @override Future 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? _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 _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 diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index 9acc440..a852d3e 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -59,7 +59,6 @@ class MapViewState extends State { String? _lastTileTypeId; bool? _lastOfflineMode; int _mapRebuildKey = 0; - bool _shouldClearCache = false; @override void initState() { @@ -181,40 +180,21 @@ class MapViewState extends State { 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 { 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; }); }