diff --git a/lib/app_state.dart b/lib/app_state.dart index aa72884..6586f20 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:latlong2/latlong.dart'; import 'models/camera_profile.dart'; diff --git a/lib/services/map_data_provider.dart b/lib/services/map_data_provider.dart index 6dea030..a6ac431 100644 --- a/lib/services/map_data_provider.dart +++ b/lib/services/map_data_provider.dart @@ -1,5 +1,6 @@ import 'package:latlong2/latlong.dart'; import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter/foundation.dart'; import '../models/camera_profile.dart'; import '../models/osm_camera_node.dart'; @@ -125,7 +126,7 @@ class MapDataProvider { if (offline) { throw OfflineModeException("Cannot fetch remote tiles in offline mode."); } - return fetchOSMTile(z: z, x: x, y: y); + return _fetchRemoteTileFromCurrentProvider(z, x, y); } // Explicitly local @@ -138,13 +139,29 @@ class MapDataProvider { return await fetchLocalTile(z: z, x: x, y: y); } catch (_) { if (!offline) { - return fetchOSMTile(z: z, x: x, y: y); + return _fetchRemoteTileFromCurrentProvider(z, x, y); } else { throw OfflineModeException("Tile $z/$x/$y not found in offline areas and offline mode is enabled."); } } } + /// Fetch remote tile using current provider from AppState + Future> _fetchRemoteTileFromCurrentProvider(int z, int x, int y) async { + final appState = AppState.instance; + final selectedTileType = appState.selectedTileType; + final selectedProvider = appState.selectedTileProvider; + + if (selectedTileType != null && selectedProvider != null) { + // Use current provider + final tileUrl = selectedTileType.getTileUrl(z, x, y, apiKey: selectedProvider.apiKey); + return fetchRemoteTile(z: z, x: x, y: y, url: tileUrl); + } else { + // Fallback to OSM if no provider selected + return fetchOSMTile(z: z, x: x, y: y); + } + } + /// Clear any queued tile requests (call when map view changes significantly) void clearTileQueue() { clearRemoteTileQueue(); diff --git a/lib/services/map_data_submodules/tiles_from_remote.dart b/lib/services/map_data_submodules/tiles_from_remote.dart index c742846..e22b14d 100644 --- a/lib/services/map_data_submodules/tiles_from_remote.dart +++ b/lib/services/map_data_submodules/tiles_from_remote.dart @@ -41,23 +41,23 @@ Future> fetchRemoteTile({ while (true) { await _tileFetchSemaphore.acquire(); try { - print('[fetchRemoteTile] FETCH $z/$x/$y from $hostInfo'); + // Only log on first attempt or errors + if (attempt == 1) { + debugPrint('[fetchRemoteTile] Fetching $z/$x/$y from $hostInfo'); + } attempt++; final resp = await http.get(Uri.parse(url)); - print('[fetchRemoteTile] HTTP ${resp.statusCode} for $z/$x/$y from $hostInfo, length=${resp.bodyBytes.length}'); if (resp.statusCode == 200 && resp.bodyBytes.isNotEmpty) { - print('[fetchRemoteTile] SUCCESS $z/$x/$y from $hostInfo'); + // Success - no logging for normal operation NetworkStatus.instance.reportOsmTileSuccess(); // Still use OSM reporting for now return resp.bodyBytes; } else { - print('[fetchRemoteTile] FAIL $z/$x/$y from $hostInfo: code=${resp.statusCode}, bytes=${resp.bodyBytes.length}'); + debugPrint('[fetchRemoteTile] FAIL $z/$x/$y from $hostInfo: code=${resp.statusCode}, bytes=${resp.bodyBytes.length}'); NetworkStatus.instance.reportOsmTileIssue(); // Still use OSM reporting for now throw HttpException('Failed to fetch tile $z/$x/$y from $hostInfo: status ${resp.statusCode}'); } } catch (e) { - print('[fetchRemoteTile] Exception $z/$x/$y from $hostInfo: $e'); - // Report network issues on connection errors if (e.toString().contains('Connection refused') || e.toString().contains('Connection timed out') || @@ -66,12 +66,14 @@ Future> fetchRemoteTile({ } if (attempt >= maxAttempts) { - print("[fetchRemoteTile] Failed for $z/$x/$y from $hostInfo after $attempt attempts: $e"); + debugPrint("[fetchRemoteTile] Failed for $z/$x/$y from $hostInfo after $attempt attempts: $e"); rethrow; } final delay = delays[attempt - 1].clamp(0, 60000); - print("[fetchRemoteTile] Attempt $attempt for $z/$x/$y from $hostInfo failed: $e. Retrying in ${delay}ms."); + if (attempt == 1) { + debugPrint("[fetchRemoteTile] Attempt $attempt for $z/$x/$y from $hostInfo failed: $e. Retrying in ${delay}ms."); + } await Future.delayed(Duration(milliseconds: delay)); } finally { _tileFetchSemaphore.release(); diff --git a/lib/services/offline_areas/offline_area_downloader.dart b/lib/services/offline_areas/offline_area_downloader.dart index 074895d..19330e6 100644 --- a/lib/services/offline_areas/offline_area_downloader.dart +++ b/lib/services/offline_areas/offline_area_downloader.dart @@ -4,13 +4,10 @@ import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:latlong2/latlong.dart'; import 'package:flutter_map/flutter_map.dart' show LatLngBounds; -import 'package:collection/collection.dart'; import '../../app_state.dart'; import '../../models/osm_camera_node.dart'; -import '../../models/tile_provider.dart'; import '../map_data_provider.dart'; -import '../map_data_submodules/tiles_from_remote.dart'; import 'offline_area_models.dart'; import 'offline_tile_utils.dart'; import 'package:flock_map_app/dev_config.dart'; @@ -30,32 +27,6 @@ class OfflineAreaDownloader { required Future Function() saveAreasToDisk, required Future Function(OfflineArea) getAreaSizeBytes, }) async { - // Get tile provider info from the area metadata or current AppState - TileProvider? tileProvider; - TileType? tileType; - - final appState = AppState.instance; - - if (area.tileProviderId != null && area.tileTypeId != null) { - // Use the provider info stored with the area (for refreshing existing areas) - try { - tileProvider = appState.tileProviders.firstWhere( - (p) => p.id == area.tileProviderId, - ); - tileType = tileProvider.tileTypes.firstWhere( - (t) => t.id == area.tileTypeId, - ); - } catch (e) { - // Fallback if stored provider/type not found - tileProvider = appState.selectedTileProvider ?? appState.tileProviders.firstOrNull; - tileType = appState.selectedTileType ?? tileProvider?.tileTypes.firstOrNull; - } - } else { - // New area - use currently selected provider - tileProvider = appState.selectedTileProvider ?? appState.tileProviders.firstOrNull; - tileType = appState.selectedTileType ?? tileProvider?.tileTypes.firstOrNull; - } - // Calculate tiles to download Set> allTiles; if (area.isPermanent) { allTiles = computeTileList(globalWorldBounds(), kWorldMinZoom, kWorldMaxZoom); @@ -72,8 +43,6 @@ class OfflineAreaDownloader { onProgress: onProgress, saveAreasToDisk: saveAreasToDisk, getAreaSizeBytes: getAreaSizeBytes, - tileProvider: tileProvider, - tileType: tileType, ); // Download cameras for non-permanent areas @@ -99,8 +68,6 @@ class OfflineAreaDownloader { void Function(double progress)? onProgress, required Future Function() saveAreasToDisk, required Future Function(OfflineArea) getAreaSizeBytes, - TileProvider? tileProvider, - TileType? tileType, }) async { int pass = 0; Set> tilesToFetch = allTiles; @@ -113,7 +80,7 @@ class OfflineAreaDownloader { for (final tile in tilesToFetch) { if (area.status == OfflineAreaStatus.cancelled) break; - if (await _downloadSingleTile(tile, directory, area, tileProvider, tileType)) { + if (await _downloadSingleTile(tile, directory, area)) { totalDone++; area.tilesDownloaded = totalDone; area.progress = area.tilesTotal == 0 ? 0.0 : (totalDone / area.tilesTotal); @@ -134,25 +101,20 @@ class OfflineAreaDownloader { return false; // Failed after max retries } - /// Download a single tile + /// Download a single tile using the unified MapDataProvider path static Future _downloadSingleTile( List tile, String directory, OfflineArea area, - TileProvider? tileProvider, - TileType? tileType, ) async { try { - List bytes; - - if (tileType != null && tileProvider != null) { - // Use the same path as live tiles: build URL and fetch directly - final tileUrl = tileType.getTileUrl(tile[0], tile[1], tile[2], apiKey: tileProvider.apiKey); - bytes = await fetchRemoteTile(z: tile[0], x: tile[1], y: tile[2], url: tileUrl); - } else { - // Fallback to OSM for legacy areas or when no provider info - bytes = await fetchOSMTile(z: tile[0], x: tile[1], y: tile[2]); - } + // Use the same unified path as live tiles: always go through MapDataProvider + final bytes = await MapDataProvider().getTile( + z: tile[0], + x: tile[1], + y: tile[2], + source: MapSource.remote, // Force remote fetch for downloads + ); if (bytes.isNotEmpty) { await OfflineAreaDownloader.saveTileBytes(tile[0], tile[1], tile[2], directory, bytes); return true; diff --git a/lib/services/simple_tile_service.dart b/lib/services/simple_tile_service.dart index 6939622..0389021 100644 --- a/lib/services/simple_tile_service.dart +++ b/lib/services/simple_tile_service.dart @@ -13,77 +13,50 @@ class SimpleTileHttpClient extends http.BaseClient { @override Future send(http.BaseRequest request) async { - // Try to parse as a tile request from any provider - final tileInfo = _parseTileRequest(request.url); - if (tileInfo != null) { - return _handleTileRequest(request, tileInfo); + // Extract tile coordinates from the URL using our standard pattern + final tileCoords = _extractTileCoords(request.url); + if (tileCoords != null) { + final z = tileCoords['z']!; // We know these are not null from _extractTileCoords + final x = tileCoords['x']!; + final y = tileCoords['y']!; + return _handleTileRequest(z, x, y); } // Pass through non-tile requests return _inner.send(request); } - /// Parse URL to extract tile coordinates if it looks like a tile request - Map? _parseTileRequest(Uri url) { + /// Extract z/x/y coordinates from our standard tile URL pattern + 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; - // Common patterns for tile URLs: - // OSM: /z/x/y.png - // Google: /vt/lyrs=y&x=x&y=y&z=z (query params) - // Mapbox: /styles/v1/mapbox/streets-v12/tiles/z/x/y - // ArcGIS: /tile/z/y/x.png - - // Try query parameters first (Google style) - final query = url.queryParameters; - if (query.containsKey('x') && query.containsKey('y') && query.containsKey('z')) { - final x = int.tryParse(query['x']!); - final y = int.tryParse(query['y']!); - final z = int.tryParse(query['z']!); - if (x != null && y != null && z != null) { - return {'z': z, 'x': x, 'y': y, 'originalUrl': url.toString()}; - } - } - - // Try path-based patterns - if (pathSegments.length >= 3) { - // Try z/x/y pattern (OSM style) - can be at different positions - for (int i = 0; i <= pathSegments.length - 3; i++) { - final z = int.tryParse(pathSegments[i]); - final x = int.tryParse(pathSegments[i + 1]); - final yWithExt = pathSegments[i + 2]; - final y = int.tryParse(yWithExt.replaceAll(RegExp(r'\.[^.]*$'), '')); // Remove file extension - - if (z != null && x != null && y != null) { - return {'z': z, 'x': x, 'y': y, 'originalUrl': url.toString()}; - } - } - } - - return null; // Not a recognizable tile request - } - - Future _handleTileRequest(http.BaseRequest request, Map tileInfo) async { - final z = tileInfo['z'] as int; - final x = tileInfo['x'] as int; - final y = tileInfo['y'] as int; - final originalUrl = tileInfo['originalUrl'] as String; - - return _getTile(z, x, y, originalUrl, request.url.host); - } - - Future _getTile(int z, int x, int y, String originalUrl, String providerHost) async { - try { - // First try to get tile from offline storage - final localTileBytes = await _mapDataProvider.getTile(z: z, x: x, y: y, source: MapSource.local); + 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 - debugPrint('[SimpleTileService] Serving tile $z/$x/$y from offline storage'); + if (z != null && x != null && y != null) { + return {'z': z, 'x': x, 'y': y}; + } + } + + return null; + } + + Future _handleTileRequest(int z, int x, int y) async { + try { + // Always go through MapDataProvider - it handles offline/online routing + final tileBytes = await _mapDataProvider.getTile(z: z, x: x, y: y, source: MapSource.auto); // Clear waiting status - we got data NetworkStatus.instance.clearWaiting(); - // Serve offline tile with proper cache headers + // Serve tile with proper cache headers return http.StreamedResponse( - Stream.value(localTileBytes), + Stream.value(tileBytes), 200, headers: { 'Content-Type': 'image/png', @@ -94,39 +67,15 @@ class SimpleTileHttpClient extends http.BaseClient { ); } catch (e) { - // No offline tile available - debugPrint('[SimpleTileService] No offline tile for $z/$x/$y'); + debugPrint('[SimpleTileService] Could not get tile $z/$x/$y: $e'); - // Check if we're in offline mode - if (AppState.instance.offlineMode) { - debugPrint('[SimpleTileService] Offline mode - not attempting $providerHost fetch for $z/$x/$y'); - // Report that we couldn't serve this tile offline - NetworkStatus.instance.reportOfflineMiss(); - return http.StreamedResponse( - Stream.value([]), - 404, - reasonPhrase: 'Tile not available offline', - ); - } - - // We're online - try the original provider with proper error handling - debugPrint('[SimpleTileService] Online mode - trying $providerHost for $z/$x/$y'); - try { - final response = await _inner.send(http.Request('GET', Uri.parse(originalUrl))); - // Clear waiting status on successful network tile - if (response.statusCode == 200) { - NetworkStatus.instance.clearWaiting(); - } - return response; - } catch (networkError) { - debugPrint('[SimpleTileService] $providerHost request failed for $z/$x/$y: $networkError'); - // Return 404 instead of throwing - let flutter_map handle gracefully - return http.StreamedResponse( - Stream.value([]), - 404, - reasonPhrase: 'Network tile unavailable: $networkError', - ); - } + // Let MapDataProvider handle offline mode logic + // Just return 404 and let flutter_map handle it gracefully + return http.StreamedResponse( + Stream.value([]), + 404, + reasonPhrase: 'Tile unavailable: $e', + ); } } diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index ccfbd34..41615cc 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -54,6 +54,9 @@ class MapViewState extends State { // Track zoom to clear queue on zoom changes double? _lastZoom; + + // Track tile type changes to clear cache + String? _lastTileTypeId; @override void initState() { @@ -171,56 +174,16 @@ class MapViewState extends State { return ids1.length == ids2.length && ids1.containsAll(ids2); } - /// Build tile layer based on selected tile provider + /// Build tile layer - uses standard URL that SimpleTileHttpClient can parse Widget _buildTileLayer(AppState appState) { - final selectedTileType = appState.selectedTileType; - final selectedProvider = appState.selectedTileProvider; - - // Fallback to first available tile type if none selected - if (selectedTileType == null || selectedProvider == null) { - final allTypes = []; - for (final provider in appState.tileProviders) { - allTypes.addAll(provider.availableTileTypes); - } - - final fallback = allTypes.firstOrNull; - if (fallback != null) { - return TileLayer( - urlTemplate: fallback.urlTemplate, - userAgentPackageName: 'com.stopflock.flock_map_app', - tileProvider: NetworkTileProvider( - httpClient: _tileHttpClient, - ), - ); - } - - // Ultimate fallback - hardcoded OSM - return TileLayer( - urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - userAgentPackageName: 'com.stopflock.flock_map_app', - tileProvider: NetworkTileProvider( - httpClient: _tileHttpClient, - ), - ); - } - - // Get the URL template with API key if needed - String urlTemplate = selectedTileType.urlTemplate; - if (selectedTileType.requiresApiKey && selectedProvider.apiKey != null) { - urlTemplate = urlTemplate.replaceAll('{api_key}', selectedProvider.apiKey!); - } - - // For now, use our custom HTTP client for all tile requests - // This will enable offline support for all providers + // Use a generic URL template that SimpleTileHttpClient recognizes + // The actual provider URL will be built by MapDataProvider using current AppState return TileLayer( - urlTemplate: urlTemplate, + urlTemplate: 'https://tiles.local/{z}/{x}/{y}.png', userAgentPackageName: 'com.stopflock.flock_map_app', tileProvider: NetworkTileProvider( httpClient: _tileHttpClient, ), - additionalOptions: { - 'attribution': selectedTileType.attribution, - }, ); } @@ -245,6 +208,17 @@ class MapViewState extends State { }); } + // Check if tile type changed and clear cache if needed + final currentTileTypeId = appState.selectedTileType?.id; + if (_lastTileTypeId != null && _lastTileTypeId != currentTileTypeId) { + WidgetsBinding.instance.addPostFrameCallback((_) { + // Clear our tile request queue + _tileHttpClient.clearTileQueue(); + // Note: The ValueKey on FlutterMap will cause flutter_map to rebuild and clear its cache + }); + } + _lastTileTypeId = currentTileTypeId; + // Seed add‑mode target once, after first controller center is available. if (session != null && session.target == null) { try {