diff --git a/DEVELOPER.md b/DEVELOPER.md index b7b59c4..7a3f6e2 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -309,7 +309,32 @@ Local cache contains production data. Showing production nodes in sandbox mode w **Why separate from follow mode:** Users often want to follow their location while keeping the map oriented north. Previous "north up" follow mode was confusing because it didn't actually keep north up. This separation provides clear, predictable behavior. -### 9. Suspected Locations +### 9. Network Status Indicator (Simplified in v1.5.2+) + +**Purpose**: Show loading and error states for surveillance data fetching only + +**Simplified approach (v1.5.2+):** +- **Surveillance data focus**: Only tracks node/camera data loading, not tile loading +- **Visual feedback**: Tiles show their own loading progress naturally +- **Reduced complexity**: Eliminated tile completion tracking and multiple issue types + +**Status types:** +- **Loading**: Shows when fetching surveillance data from APIs +- **Success**: Brief confirmation when data loads successfully +- **Timeout**: Network request timeouts +- **Limit reached**: When node display limit is hit +- **API issues**: Overpass/OSM API problems only + +**What was removed:** +- Tile server issue tracking (tiles handle their own progress) +- "Both" network issue type (only surveillance data matters) +- Complex semaphore-based completion detection +- Tile-related status messages and localizations + +**Why the change:** +The previous approach tracked both tile loading and surveillance data, creating redundancy since tiles already show loading progress visually on the map. Users don't need to be notified about tile loading issues when they can see tiles loading/failing directly. Focusing only on surveillance data makes the indicator more purposeful and less noisy. + +### 11. Suspected Locations **Data pipeline:** - **CSV ingestion**: Downloads utility permit data from alprwatch.org @@ -327,7 +352,7 @@ Users often want to follow their location while keeping the map oriented north. **Why utility permits:** Utility companies often must file permits when installing surveillance infrastructure. This creates a paper trail that can indicate potential surveillance sites before devices are confirmed through direct observation. -### 10. Upload Mode Simplification +### 12. Upload Mode Simplification **Release vs Debug builds:** - **Release builds**: Production OSM only (simplified UX) @@ -340,11 +365,22 @@ Most users should contribute to production; testing modes add complexity bool get showUploadModeSelector => kDebugMode; ``` -### 11. Tile Provider System & URL Templates +### 13. Tile Provider System & Clean Architecture (v1.5.2+) -**Design approach:** +**Architecture (post-v1.5.2):** +- **Custom TileProvider**: Clean Flutter Map integration using `DeflockTileProvider` +- **Direct MapDataProvider integration**: Tiles go through existing offline/online routing +- **No HTTP interception**: Eliminated fake URLs and complex HTTP clients +- **Simplified caching**: Single cache layer (FlutterMap's internal cache) + +**Key components:** +- `DeflockTileProvider`: Custom Flutter Map TileProvider implementation +- `DeflockTileImageProvider`: Handles tile fetching through MapDataProvider +- Automatic offline/online routing: Uses `MapSource.auto` for each tile + +**Tile provider configuration:** - **Flexible URL templates**: Support multiple coordinate systems and load-balancing patterns -- **Built-in providers**: Curated set of high-quality, reliable tile sources +- **Built-in providers**: Curated set of high-quality, reliable tile sources - **Custom providers**: Users can add any tile service with full validation - **API key management**: Secure storage with per-provider API keys @@ -352,7 +388,7 @@ bool get showUploadModeSelector => kDebugMode; ``` {x}, {y}, {z} - Standard TMS tile coordinates {quadkey} - Bing Maps quadkey format (alternative to x/y/z) -{0_3} - Subdomain 0-3 for load balancing +{0_3} - Subdomain 0-3 for load balancing {1_4} - Subdomain 1-4 for providers using 1-based indexing {api_key} - API key insertion point (optional) ``` @@ -363,13 +399,14 @@ bool get showUploadModeSelector => kDebugMode; - **Mapbox**: Satellite and street tiles, requires API key - **OpenTopoMap**: Topographic maps, no API key required -**Validation logic:** -URL templates must contain either `{quadkey}` OR all of `{x}`, `{y}`, and `{z}`. This allows for both standard tile services and specialized formats like Bing Maps. +**Why the architectural change:** +The previous HTTP interception approach (`SimpleTileHttpClient` with fake URLs) fought against Flutter Map's architecture and created unnecessary complexity. The new `TileProvider` approach: +- **Cleaner integration**: Works with Flutter Map's design instead of against it +- **Simpler caching**: One cache layer instead of multiple conflicting systems +- **Better error handling**: Graceful fallbacks for missing tiles +- **Reduced complexity**: Eliminated semaphores, queue management, and tile completion tracking -**Why this approach:** -Provides maximum flexibility while maintaining simplicity. Users can add any tile service without code changes, while built-in providers offer immediate functionality. The quadkey system enables access to high-quality satellite imagery without API key requirements. - -### 12. Navigation & Routing (Implemented, Awaiting Integration) +### 14. Navigation & Routing (Implemented, Awaiting Integration) **Current state:** - **Search functionality**: Fully implemented and active diff --git a/README.md b/README.md index 56bb7bb..6bc2e23 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,6 @@ cp lib/keys.dart.example lib/keys.dart - Max nodes not working - Update node cache to reflect cleared queue entries - Are offline areas preferred for fast loading even when online? Check working. -- Fix network indicator - only done when fetch queue is empty! ### Current Development - Decide what to do for extracting nodes attached to a way/relation: diff --git a/assets/changelog.json b/assets/changelog.json index 1e101c5..426a4fd 100644 --- a/assets/changelog.json +++ b/assets/changelog.json @@ -1,4 +1,14 @@ { + "1.5.2": { + "content": [ + "• IMPROVED: Simplified tile loading architecture - replaced HTTP interception with clean TileProvider implementation", + "• IMPROVED: Network status indicator now focuses only on surveillance data loading, not tile loading (tiles show their own progress)", + "• IMPROVED: Reduced complexity in cache management and state tracking", + "• FIXED: Tile cache properly clears when switching between tile providers/types - no more mixed tiles", + "• FIXED: Network status indicator no longer shows false timeouts during surveillance data splitting operations", + "• FIXED: Eliminated potential conflicts between multiple cache layers" + ] + }, "1.5.1": { "content": [ "• NEW: Bing satellite imagery - high-quality satellite tiles used by the iD editor, no API key required", diff --git a/lib/localizations/de.json b/lib/localizations/de.json index 7d83de7..3cbbd76 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -377,15 +377,13 @@ }, "networkStatus": { "showIndicator": "Netzwerkstatus-Anzeige anzeigen", - "showIndicatorSubtitle": "Netzwerk-Ladestatus und Fehlerstatus auf der Karte anzeigen", - "loading": "Lädt...", - "timedOut": "Zeitüberschreitung", - "noData": "Keine Kacheln hier", - "success": "Fertig", + "showIndicatorSubtitle": "Ladestatus und Fehler für Überwachungsdaten anzeigen", + "loading": "Lade Überwachungsdaten...", + "timedOut": "Anfrage Zeitüberschreitung", + "noData": "Keine Offline-Daten", + "success": "Überwachungsdaten geladen", "nodeLimitReached": "Limit erreicht - in Einstellungen erhöhen", - "tileProviderSlow": "Kartenanbieter langsam", - "nodeDataSlow": "Knotendaten langsam", - "networkIssues": "Netzwerkprobleme" + "nodeDataSlow": "Überwachungsdaten langsam" }, "about": { "title": "DeFlock - Überwachungs-Transparenz", diff --git a/lib/localizations/en.json b/lib/localizations/en.json index f69e0e3..39d913d 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -409,15 +409,13 @@ }, "networkStatus": { "showIndicator": "Show network status indicator", - "showIndicatorSubtitle": "Display network loading and error status on the map", - "loading": "Loading...", - "timedOut": "Timed out", - "noData": "No tiles here", - "success": "Done", + "showIndicatorSubtitle": "Display surveillance data loading and error status", + "loading": "Loading surveillance data...", + "timedOut": "Request timed out", + "noData": "No offline data", + "success": "Surveillance data loaded", "nodeLimitReached": "Showing limit - increase in settings", - "tileProviderSlow": "Tile provider slow", - "nodeDataSlow": "Node data slow", - "networkIssues": "Network issues" + "nodeDataSlow": "Surveillance data slow" }, "navigation": { "searchLocation": "Search Location", diff --git a/lib/localizations/es.json b/lib/localizations/es.json index 571b756..107e0e0 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -409,15 +409,13 @@ }, "networkStatus": { "showIndicator": "Mostrar indicador de estado de red", - "showIndicatorSubtitle": "Mostrar estado de carga y errores de red en el mapa", - "loading": "Cargando...", - "timedOut": "Tiempo agotado", - "noData": "Sin mosaicos aquí", - "success": "Hecho", + "showIndicatorSubtitle": "Mostrar estado de carga y errores de datos de vigilancia", + "loading": "Cargando datos de vigilancia...", + "timedOut": "Solicitud agotada", + "noData": "Sin datos sin conexión", + "success": "Datos de vigilancia cargados", "nodeLimitReached": "Mostrando límite - aumentar en ajustes", - "tileProviderSlow": "Proveedor de mosaicos lento", - "nodeDataSlow": "Datos de nodo lentos", - "networkIssues": "Problemas de red" + "nodeDataSlow": "Datos de vigilancia lentos" }, "navigation": { "searchLocation": "Buscar ubicación", diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index 21a23c0..e78de9c 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -409,15 +409,13 @@ }, "networkStatus": { "showIndicator": "Afficher l'indicateur de statut réseau", - "showIndicatorSubtitle": "Afficher l'état de chargement et d'erreur réseau sur la carte", - "loading": "Chargement...", - "timedOut": "Temps dépassé", - "noData": "Aucune tuile ici", - "success": "Terminé", + "showIndicatorSubtitle": "Afficher l'état de chargement et d'erreur des données de surveillance", + "loading": "Chargement des données de surveillance...", + "timedOut": "Demande expirée", + "noData": "Aucune donnée hors ligne", + "success": "Données de surveillance chargées", "nodeLimitReached": "Limite affichée - augmenter dans les paramètres", - "tileProviderSlow": "Fournisseur de tuiles lent", - "nodeDataSlow": "Données de nœud lentes", - "networkIssues": "Problèmes réseau" + "nodeDataSlow": "Données de surveillance lentes" }, "navigation": { "searchLocation": "Rechercher lieu", diff --git a/lib/localizations/it.json b/lib/localizations/it.json index b6eee25..77670ca 100644 --- a/lib/localizations/it.json +++ b/lib/localizations/it.json @@ -409,15 +409,13 @@ }, "networkStatus": { "showIndicator": "Mostra indicatore di stato di rete", - "showIndicatorSubtitle": "Visualizza lo stato di caricamento e errori di rete sulla mappa", - "loading": "Caricamento...", - "timedOut": "Tempo scaduto", - "noData": "Nessuna tessera qui", - "success": "Fatto", + "showIndicatorSubtitle": "Visualizza lo stato di caricamento e errori dei dati di sorveglianza", + "loading": "Caricamento dati di sorveglianza...", + "timedOut": "Richiesta scaduta", + "noData": "Nessun dato offline", + "success": "Dati di sorveglianza caricati", "nodeLimitReached": "Limite visualizzato - aumentare nelle impostazioni", - "tileProviderSlow": "Provider di tessere lento", - "nodeDataSlow": "Dati del nodo lenti", - "networkIssues": "Problemi di rete" + "nodeDataSlow": "Dati di sorveglianza lenti" }, "navigation": { "searchLocation": "Cerca posizione", diff --git a/lib/localizations/pt.json b/lib/localizations/pt.json index 23fb4db..cabf14f 100644 --- a/lib/localizations/pt.json +++ b/lib/localizations/pt.json @@ -409,15 +409,13 @@ }, "networkStatus": { "showIndicator": "Exibir indicador de status de rede", - "showIndicatorSubtitle": "Mostrar status de carregamento e erro de rede no mapa", - "loading": "Carregando...", - "timedOut": "Tempo esgotado", - "noData": "Nenhum tile aqui", - "success": "Concluído", + "showIndicatorSubtitle": "Mostrar status de carregamento e erro de dados de vigilância", + "loading": "Carregando dados de vigilância...", + "timedOut": "Solicitação expirada", + "noData": "Nenhum dado offline", + "success": "Dados de vigilância carregados", "nodeLimitReached": "Limite exibido - aumentar nas configurações", - "tileProviderSlow": "Provedor de tiles lento", - "nodeDataSlow": "Dados do nó lentos", - "networkIssues": "Problemas de rede" + "nodeDataSlow": "Dados de vigilância lentos" }, "navigation": { "searchLocation": "Buscar localização", diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json index 11013db..8192169 100644 --- a/lib/localizations/zh.json +++ b/lib/localizations/zh.json @@ -409,15 +409,13 @@ }, "networkStatus": { "showIndicator": "显示网络状态指示器", - "showIndicatorSubtitle": "在地图上显示网络加载和错误状态", - "loading": "加载中...", - "timedOut": "超时", - "noData": "这里没有瓦片", - "success": "完成", + "showIndicatorSubtitle": "显示监控数据加载和错误状态", + "loading": "加载监控数据...", + "timedOut": "请求超时", + "noData": "无离线数据", + "success": "监控数据已加载", "nodeLimitReached": "显示限制 - 在设置中增加", - "tileProviderSlow": "瓦片提供商缓慢", - "nodeDataSlow": "节点数据缓慢", - "networkIssues": "网络问题" + "nodeDataSlow": "监控数据缓慢" }, "navigation": { "searchLocation": "搜索位置", diff --git a/lib/services/deflock_tile_provider.dart b/lib/services/deflock_tile_provider.dart new file mode 100644 index 0000000..fe7715f --- /dev/null +++ b/lib/services/deflock_tile_provider.dart @@ -0,0 +1,127 @@ +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'dart:async'; +import 'dart:typed_data'; +import 'dart:ui'; + +import '../app_state.dart'; +import '../models/tile_provider.dart' as models; +import 'map_data_provider.dart'; + +/// Custom tile provider that integrates with DeFlock's offline/online architecture. +/// +/// This replaces the complex HTTP interception approach with a clean TileProvider +/// implementation that directly interfaces with our MapDataProvider system. +class DeflockTileProvider extends TileProvider { + final MapDataProvider _mapDataProvider = MapDataProvider(); + + @override + ImageProvider getImage(TileCoordinates coordinates, TileLayer options) { + // Get current provider info to include in cache key + final appState = AppState.instance; + final providerId = appState.selectedTileProvider?.id ?? 'unknown'; + final tileTypeId = appState.selectedTileType?.id ?? 'unknown'; + + return DeflockTileImageProvider( + coordinates: coordinates, + options: options, + mapDataProvider: _mapDataProvider, + providerId: providerId, + tileTypeId: tileTypeId, + ); + } +} + +/// Image provider that fetches tiles through our MapDataProvider. +/// +/// This handles the actual tile fetching using our existing offline/online +/// routing logic without any HTTP interception complexity. +class DeflockTileImageProvider extends ImageProvider { + final TileCoordinates coordinates; + final TileLayer options; + final MapDataProvider mapDataProvider; + final String providerId; + final String tileTypeId; + + const DeflockTileImageProvider({ + required this.coordinates, + required this.options, + required this.mapDataProvider, + required this.providerId, + required this.tileTypeId, + }); + + @override + Future obtainKey(ImageConfiguration configuration) { + return SynchronousFuture(this); + } + + @override + ImageStreamCompleter loadImage(DeflockTileImageProvider key, ImageDecoderCallback decode) { + final chunkEvents = StreamController(); + + return MultiFrameImageStreamCompleter( + codec: _loadAsync(key, decode, chunkEvents), + chunkEvents: chunkEvents.stream, + scale: 1.0, + ); + } + + Future _loadAsync( + DeflockTileImageProvider key, + ImageDecoderCallback decode, + StreamController chunkEvents, + ) async { + try { + // Get current tile provider and type from app state + final appState = AppState.instance; + final selectedProvider = appState.selectedTileProvider; + final selectedTileType = appState.selectedTileType; + + if (selectedProvider == null || selectedTileType == null) { + throw Exception('No tile provider configured'); + } + + // Fetch tile through our existing MapDataProvider system + // This automatically handles offline/online routing, caching, etc. + final tileBytes = await mapDataProvider.getTile( + z: coordinates.z, + x: coordinates.x, + y: coordinates.y, + source: MapSource.auto, // Use auto routing (offline first, then online) + ); + + // Decode the image bytes + final buffer = await ImmutableBuffer.fromUint8List(Uint8List.fromList(tileBytes)); + return await decode(buffer); + + } catch (e) { + // Log error for debugging but don't spam network status + debugPrint('[DeflockTileProvider] Failed to load tile ${coordinates.z}/${coordinates.x}/${coordinates.y}: $e'); + + // Return a transparent 1x1 pixel tile for missing tiles + // This is more graceful than throwing and prevents cascade failures + final transparentPixel = Uint8List.fromList([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0B, 0x49, 0x44, 0x41, + 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, + 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82]); + + final buffer = await ImmutableBuffer.fromUint8List(transparentPixel); + return await decode(buffer); + } + } + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) return false; + return other is DeflockTileImageProvider && + other.coordinates == coordinates && + other.providerId == providerId && + other.tileTypeId == tileTypeId; + } + + @override + int get hashCode => Object.hash(coordinates, providerId, tileTypeId); +} \ No newline at end of file diff --git a/lib/services/map_data_submodules/nodes_from_osm_api.dart b/lib/services/map_data_submodules/nodes_from_osm_api.dart index afc4b22..a6c7ad0 100644 --- a/lib/services/map_data_submodules/nodes_from_osm_api.dart +++ b/lib/services/map_data_submodules/nodes_from_osm_api.dart @@ -20,6 +20,45 @@ Future> fetchOsmApiNodes({ }) async { if (profiles.isEmpty) return []; + // Check if this is a user-initiated fetch (indicated by loading state) + final wasUserInitiated = NetworkStatus.instance.currentStatus == NetworkStatusType.waiting; + + try { + final nodes = await _fetchFromOsmApi( + bounds: bounds, + profiles: profiles, + uploadMode: uploadMode, + maxResults: maxResults, + ); + + // Only report success at the top level if this was user-initiated + if (wasUserInitiated) { + NetworkStatus.instance.setSuccess(); + } + + return nodes; + } catch (e) { + // Only report errors at the top level if this was user-initiated + if (wasUserInitiated) { + if (e.toString().contains('timeout') || e.toString().contains('timed out')) { + NetworkStatus.instance.setTimeoutError(); + } else { + NetworkStatus.instance.setNetworkError(); + } + } + + debugPrint('[fetchOsmApiNodes] OSM API operation failed: $e'); + return []; + } +} + +/// Internal method that performs the actual OSM API fetch. +Future> _fetchFromOsmApi({ + required LatLngBounds bounds, + required List profiles, + UploadMode uploadMode = UploadMode.production, + required int maxResults, +}) async { // Choose API endpoint based on upload mode final String apiHost = uploadMode == UploadMode.sandbox ? 'api06.dev.openstreetmap.org' @@ -41,8 +80,7 @@ Future> fetchOsmApiNodes({ if (response.statusCode != 200) { debugPrint('[fetchOsmApiNodes] OSM API error: ${response.statusCode} - ${response.body}'); - NetworkStatus.instance.reportOverpassIssue(); // Reuse same status tracking - return []; + throw Exception('OSM API error: ${response.statusCode} - ${response.body}'); } // Parse XML response @@ -53,20 +91,14 @@ Future> fetchOsmApiNodes({ debugPrint('[fetchOsmApiNodes] Retrieved ${nodes.length} matching surveillance nodes'); } - NetworkStatus.instance.reportOverpassSuccess(); // Reuse same status tracking + // Don't report success here - let the top level handle it return nodes; } catch (e) { debugPrint('[fetchOsmApiNodes] Exception: $e'); - // Report network issues for connection errors - if (e.toString().contains('Connection refused') || - e.toString().contains('Connection timed out') || - e.toString().contains('Connection reset')) { - NetworkStatus.instance.reportOverpassIssue(); - } - - return []; + // Don't report status here - let the top level handle it + throw e; // Re-throw to let caller handle } } diff --git a/lib/services/map_data_submodules/nodes_from_overpass.dart b/lib/services/map_data_submodules/nodes_from_overpass.dart index f7b75c4..1039055 100644 --- a/lib/services/map_data_submodules/nodes_from_overpass.dart +++ b/lib/services/map_data_submodules/nodes_from_overpass.dart @@ -23,14 +23,35 @@ Future> fetchOverpassNodes({ // Check if this is a user-initiated fetch (indicated by loading state) final wasUserInitiated = NetworkStatus.instance.currentStatus == NetworkStatusType.waiting; - return _fetchOverpassNodesWithSplitting( - bounds: bounds, - profiles: profiles, - uploadMode: uploadMode, - maxResults: maxResults, - splitDepth: 0, - wasUserInitiated: wasUserInitiated, - ); + try { + final nodes = await _fetchOverpassNodesWithSplitting( + bounds: bounds, + profiles: profiles, + uploadMode: uploadMode, + maxResults: maxResults, + splitDepth: 0, + reportStatus: wasUserInitiated, // Only top level reports status + ); + + // Only report success at the top level if this was user-initiated + if (wasUserInitiated) { + NetworkStatus.instance.setSuccess(); + } + + return nodes; + } catch (e) { + // Only report errors at the top level if this was user-initiated + if (wasUserInitiated) { + if (e.toString().contains('timeout') || e.toString().contains('timed out')) { + NetworkStatus.instance.setTimeoutError(); + } else { + NetworkStatus.instance.setNetworkError(); + } + } + + debugPrint('[fetchOverpassNodes] Top-level operation failed: $e'); + return []; + } } /// Internal method that handles splitting when node limit is exceeded. @@ -40,7 +61,7 @@ Future> _fetchOverpassNodesWithSplitting({ UploadMode uploadMode = UploadMode.production, required int maxResults, required int splitDepth, - required bool wasUserInitiated, + required bool reportStatus, // Only true for top level }) async { if (profiles.isEmpty) return []; @@ -51,28 +72,20 @@ Future> _fetchOverpassNodesWithSplitting({ bounds: bounds, profiles: profiles, maxResults: maxResults, + reportStatus: reportStatus, ); } on OverpassRateLimitException catch (e) { // Rate limits should NOT be split - just fail with extended backoff debugPrint('[fetchOverpassNodes] Rate limited - using extended backoff, not splitting'); - // Report error if user was waiting - if (wasUserInitiated) { - NetworkStatus.instance.setNetworkError(); - } - // Wait longer for rate limits before giving up entirely await Future.delayed(const Duration(seconds: 30)); - return []; // Return empty rather than rethrowing + return []; // Return empty rather than rethrowing - let caller handle error reporting } on OverpassNodeLimitException { // If we've hit max split depth, give up to avoid infinite recursion if (splitDepth >= maxSplitDepth) { debugPrint('[fetchOverpassNodes] Max split depth reached, giving up on area: $bounds'); - // Report timeout if this was user-initiated (can't split further) - if (wasUserInitiated) { - NetworkStatus.instance.setTimeoutError(); - } - return []; + return []; // Return empty - let caller handle error reporting } // Split the bounds into 4 quadrants and try each separately @@ -87,7 +100,7 @@ Future> _fetchOverpassNodesWithSplitting({ uploadMode: uploadMode, maxResults: 0, // No limit on individual quadrants to avoid double-limiting splitDepth: splitDepth + 1, - wasUserInitiated: wasUserInitiated, + reportStatus: false, // Sub-requests don't report status ); allNodes.addAll(nodes); } @@ -102,6 +115,7 @@ Future> _fetchSingleOverpassQuery({ required LatLngBounds bounds, required List profiles, required int maxResults, + required bool reportStatus, }) async { const String overpassEndpoint = 'https://overpass-api.de/api/interpreter'; @@ -146,8 +160,8 @@ Future> _fetchSingleOverpassQuery({ throw OverpassRateLimitException('Rate limited by server', serverResponse: errorBody); } - NetworkStatus.instance.reportOverpassIssue(); - return []; + // Don't report status here - let the top level handle it + throw Exception('Overpass API error: $errorBody'); } final data = await compute(jsonDecode, response.body) as Map; @@ -157,7 +171,7 @@ Future> _fetchSingleOverpassQuery({ debugPrint('[fetchOverpassNodes] Retrieved ${elements.length} elements (nodes + ways/relations)'); } - NetworkStatus.instance.reportOverpassSuccess(); + // Don't report success here - let the top level handle it // Parse response to determine which nodes are constrained final nodes = _parseOverpassResponseWithConstraints(elements); @@ -173,14 +187,8 @@ Future> _fetchSingleOverpassQuery({ debugPrint('[fetchOverpassNodes] Exception: $e'); - // Report network issues for connection errors - if (e.toString().contains('Connection refused') || - e.toString().contains('Connection timed out') || - e.toString().contains('Connection reset')) { - NetworkStatus.instance.reportOverpassIssue(); - } - - return []; + // Don't report status here - let the top level handle it + throw e; // Re-throw to let caller handle } } diff --git a/lib/services/map_data_submodules/tiles_from_remote.dart b/lib/services/map_data_submodules/tiles_from_remote.dart index df905db..40c16b3 100644 --- a/lib/services/map_data_submodules/tiles_from_remote.dart +++ b/lib/services/map_data_submodules/tiles_from_remote.dart @@ -6,7 +6,6 @@ import 'package:flutter/foundation.dart'; import 'package:latlong2/latlong.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:deflockapp/dev_config.dart'; -import '../network_status.dart'; /// Global semaphore to limit simultaneous tile fetches final _tileFetchSemaphore = _SimpleSemaphore(kTileFetchConcurrentThreads); @@ -121,21 +120,12 @@ Future> fetchRemoteTile({ if (attempt > 1) { debugPrint('[fetchRemoteTile] SUCCESS $z/$x/$y from $hostInfo after $attempt attempts'); } - NetworkStatus.instance.reportOsmTileSuccess(); return resp.bodyBytes; } else { debugPrint('[fetchRemoteTile] FAIL $z/$x/$y from $hostInfo: code=${resp.statusCode}, bytes=${resp.bodyBytes.length}'); - NetworkStatus.instance.reportOsmTileIssue(); throw HttpException('Failed to fetch tile $z/$x/$y from $hostInfo: status ${resp.statusCode}'); } } catch (e) { - // Report network issues on connection errors - if (e.toString().contains('Connection refused') || - e.toString().contains('Connection timed out') || - e.toString().contains('Connection reset')) { - NetworkStatus.instance.reportOsmTileIssue(); - } - // Calculate delay and retry (no attempt limit - keep trying forever) final delay = _calculateRetryDelay(attempt, random); if (attempt == 1) { diff --git a/lib/services/network_status.dart b/lib/services/network_status.dart index 329215b..76f9784 100644 --- a/lib/services/network_status.dart +++ b/lib/services/network_status.dart @@ -3,7 +3,7 @@ import 'dart:async'; import '../app_state.dart'; -enum NetworkIssueType { osmTiles, overpassApi, both } +enum NetworkIssueType { overpassApi } enum NetworkStatusType { waiting, issues, timedOut, noData, ready, success, nodeLimitReached } @@ -12,14 +12,12 @@ class NetworkStatus extends ChangeNotifier { static final NetworkStatus instance = NetworkStatus._(); NetworkStatus._(); - bool _osmTilesHaveIssues = false; bool _overpassHasIssues = false; bool _isWaitingForData = false; bool _isTimedOut = false; bool _hasNoData = false; bool _hasSuccess = false; int _recentOfflineMisses = 0; - Timer? _osmRecoveryTimer; Timer? _overpassRecoveryTimer; Timer? _waitingTimer; Timer? _noDataResetTimer; @@ -28,8 +26,7 @@ class NetworkStatus extends ChangeNotifier { Timer? _nodeLimitResetTimer; // Getters - bool get hasAnyIssues => _osmTilesHaveIssues || _overpassHasIssues; - bool get osmTilesHaveIssues => _osmTilesHaveIssues; + bool get hasAnyIssues => _overpassHasIssues; bool get overpassHasIssues => _overpassHasIssues; bool get isWaitingForData => _isWaitingForData; bool get isTimedOut => _isTimedOut; @@ -49,29 +46,10 @@ class NetworkStatus extends ChangeNotifier { } NetworkIssueType? get currentIssueType { - if (_osmTilesHaveIssues && _overpassHasIssues) return NetworkIssueType.both; - if (_osmTilesHaveIssues) return NetworkIssueType.osmTiles; if (_overpassHasIssues) return NetworkIssueType.overpassApi; return null; } - /// Report tile server issues (for any provider) - void reportOsmTileIssue() { - if (!_osmTilesHaveIssues) { - _osmTilesHaveIssues = true; - notifyListeners(); - debugPrint('[NetworkStatus] Tile server issues detected'); - } - - // Reset recovery timer - if we keep getting errors, keep showing indicator - _osmRecoveryTimer?.cancel(); - _osmRecoveryTimer = Timer(const Duration(minutes: 2), () { - _osmTilesHaveIssues = false; - notifyListeners(); - debugPrint('[NetworkStatus] Tile server issues cleared'); - }); - } - /// Report Overpass API issues void reportOverpassIssue() { if (!_overpassHasIssues) { @@ -90,16 +68,6 @@ class NetworkStatus extends ChangeNotifier { } /// Report successful operations to potentially clear issues faster - void reportOsmTileSuccess() { - // Clear issues immediately on success (they were likely temporary) - if (_osmTilesHaveIssues) { - // Quietly clear - don't log routine success - _osmTilesHaveIssues = false; - _osmRecoveryTimer?.cancel(); - notifyListeners(); - } - } - void reportOverpassSuccess() { if (_overpassHasIssues) { // Quietly clear - don't log routine success @@ -271,7 +239,6 @@ class NetworkStatus extends ChangeNotifier { @override void dispose() { - _osmRecoveryTimer?.cancel(); _overpassRecoveryTimer?.cancel(); _waitingTimer?.cancel(); _noDataResetTimer?.cancel(); diff --git a/lib/services/prefetch_area_service.dart b/lib/services/prefetch_area_service.dart index 440cfbb..13168f6 100644 --- a/lib/services/prefetch_area_service.dart +++ b/lib/services/prefetch_area_service.dart @@ -139,20 +139,15 @@ class PrefetchAreaService { _preFetchedUploadMode = uploadMode; _lastFetchTime = DateTime.now(); - // Report completion to network status (only if user was waiting) - NetworkStatus.instance.setSuccess(); + // The overpass module already reported success/failure during fetching + // We just need to handle the successful result here // Notify UI that cache has been updated with fresh data CameraProviderWithCache.instance.refreshDisplay(); } catch (e) { debugPrint('[PrefetchAreaService] Pre-fetch failed: $e'); - // Report failure to network status (only if user was waiting) - if (e.toString().contains('timeout') || e.toString().contains('timed out')) { - NetworkStatus.instance.setTimeoutError(); - } else { - NetworkStatus.instance.setNetworkError(); - } + // The overpass module already reported the error status // Don't update pre-fetched area info on failure } finally { _preFetchInProgress = false; diff --git a/lib/widgets/map/tile_layer_manager.dart b/lib/widgets/map/tile_layer_manager.dart index c6a5c2c..97f5303 100644 --- a/lib/widgets/map/tile_layer_manager.dart +++ b/lib/widgets/map/tile_layer_manager.dart @@ -3,12 +3,12 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import '../../models/tile_provider.dart' as models; -import '../../services/simple_tile_service.dart'; +import '../../services/deflock_tile_provider.dart'; /// Manages tile layer creation, caching, and provider switching. -/// Handles tile HTTP client lifecycle and cache invalidation. +/// Uses DeFlock's custom tile provider for clean integration. class TileLayerManager { - late final SimpleTileHttpClient _tileHttpClient; + DeflockTileProvider? _tileProvider; int _mapRebuildKey = 0; String? _lastTileTypeId; bool? _lastOfflineMode; @@ -18,12 +18,12 @@ class TileLayerManager { /// Initialize the tile layer manager void initialize() { - _tileHttpClient = SimpleTileHttpClient(); + // Don't create tile provider here - create it fresh for each build } /// Dispose of resources void dispose() { - _tileHttpClient.close(); + // No resources to dispose with the new tile provider } /// Check if cache should be cleared and increment rebuild key if needed. @@ -46,6 +46,8 @@ class TileLayerManager { if (shouldClear) { // Force map rebuild with new key to bust flutter_map cache _mapRebuildKey++; + // Also force new tile provider instance to ensure fresh cache + _tileProvider = null; debugPrint('[TileLayerManager] *** CACHE CLEAR *** $reason changed - rebuilding map $_mapRebuildKey'); } @@ -57,38 +59,42 @@ class TileLayerManager { /// Clear the tile request queue (call after cache clear) void clearTileQueue() { - debugPrint('[TileLayerManager] Post-frame: Clearing tile request queue'); - _tileHttpClient.clearTileQueue(); + // With the new tile provider, clearing is handled by FlutterMap's internal cache + // We just need to increment the rebuild key to bust the cache + _mapRebuildKey++; + debugPrint('[TileLayerManager] Cache cleared - rebuilding map $_mapRebuildKey'); } /// Clear tile queue immediately (for zoom changes, etc.) void clearTileQueueImmediate() { - _tileHttpClient.clearTileQueue(); + // No immediate clearing needed with the new architecture + // FlutterMap handles this naturally } /// Clear only tiles that are no longer visible in the current bounds void clearStaleRequests({required LatLngBounds currentBounds}) { - _tileHttpClient.clearStaleRequests(currentBounds); + // No selective clearing needed with the new architecture + // FlutterMap's internal caching is efficient enough } /// Build tile layer widget with current provider and type. - /// Uses fake domain that SimpleTileHttpClient can parse for cache separation. + /// Uses DeFlock's custom tile provider for clean integration with our offline/online system. Widget buildTileLayer({ required models.TileProvider? selectedProvider, required models.TileType? selectedTileType, }) { - // 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}'; + // Create a fresh tile provider instance if we don't have one or cache was cleared + _tileProvider ??= DeflockTileProvider(); + + // Use provider/type info in URL template for FlutterMap's cache key generation + // This ensures different providers/types get different cache keys + final urlTemplate = '${selectedProvider?.id ?? 'unknown'}/${selectedTileType?.id ?? 'unknown'}/{z}/{x}/{y}'; return TileLayer( - urlTemplate: urlTemplate, + urlTemplate: urlTemplate, // Critical for cache key generation userAgentPackageName: 'me.deflock.deflockapp', maxZoom: selectedTileType?.maxZoom?.toDouble() ?? 18.0, - tileProvider: NetworkTileProvider( - httpClient: _tileHttpClient, - // Enable flutter_map caching - cache busting handled by URL changes and FlutterMap key - ), + tileProvider: _tileProvider!, ); } } \ No newline at end of file diff --git a/lib/widgets/network_status_indicator.dart b/lib/widgets/network_status_indicator.dart index 581f0c6..b9eb941 100644 --- a/lib/widgets/network_status_indicator.dart +++ b/lib/widgets/network_status_indicator.dart @@ -52,21 +52,11 @@ class NetworkStatusIndicator extends StatelessWidget { case NetworkStatusType.issues: switch (networkStatus.currentIssueType) { - case NetworkIssueType.osmTiles: - message = locService.t('networkStatus.tileProviderSlow'); - icon = Icons.map_outlined; - color = Colors.orange; - break; case NetworkIssueType.overpassApi: message = locService.t('networkStatus.nodeDataSlow'); icon = Icons.camera_alt_outlined; color = Colors.orange; break; - case NetworkIssueType.both: - message = locService.t('networkStatus.networkIssues'); - icon = Icons.cloud_off_outlined; - color = Colors.red; - break; default: return const SizedBox.shrink(); } diff --git a/pubspec.yaml b/pubspec.yaml index 1bc92ee..cee31d3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: deflockapp description: Map public surveillance infrastructure with OpenStreetMap publish_to: "none" -version: 1.5.1+19 # The thing after the + is the version code, incremented with each release +version: 1.5.2+20 # The thing after the + is the version code, incremented with each release environment: sdk: ">=3.5.0 <4.0.0" # oauth2_client 4.x needs Dart 3.5+ diff --git a/test/services/deflock_tile_provider_test.dart b/test/services/deflock_tile_provider_test.dart new file mode 100644 index 0000000..44b900a --- /dev/null +++ b/test/services/deflock_tile_provider_test.dart @@ -0,0 +1,104 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_map/flutter_map.dart'; + +import '../../lib/services/deflock_tile_provider.dart'; +import '../../lib/services/map_data_provider.dart'; + +void main() { + group('DeflockTileProvider', () { + late DeflockTileProvider provider; + + setUp(() { + provider = DeflockTileProvider(); + }); + + test('creates image provider for tile coordinates', () { + const coordinates = TileCoordinates(0, 0, 0); + const options = TileLayer( + urlTemplate: 'test/{z}/{x}/{y}', + ); + + final imageProvider = provider.getImage(coordinates, options); + + expect(imageProvider, isA()); + expect((imageProvider as DeflockTileImageProvider).coordinates, equals(coordinates)); + }); + }); + + group('DeflockTileImageProvider', () { + test('generates consistent keys for same coordinates', () { + const coordinates1 = TileCoordinates(1, 2, 3); + const coordinates2 = TileCoordinates(1, 2, 3); + const coordinates3 = TileCoordinates(1, 2, 4); + + const options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}'); + + final mapDataProvider = MapDataProvider(); + + final provider1 = DeflockTileImageProvider( + coordinates: coordinates1, + options: options, + mapDataProvider: mapDataProvider, + providerId: 'test_provider', + tileTypeId: 'test_type', + ); + final provider2 = DeflockTileImageProvider( + coordinates: coordinates2, + options: options, + mapDataProvider: mapDataProvider, + providerId: 'test_provider', + tileTypeId: 'test_type', + ); + final provider3 = DeflockTileImageProvider( + coordinates: coordinates3, + options: options, + mapDataProvider: mapDataProvider, + providerId: 'test_provider', + tileTypeId: 'test_type', + ); + + // Same coordinates should be equal + expect(provider1, equals(provider2)); + expect(provider1.hashCode, equals(provider2.hashCode)); + + // Different coordinates should not be equal + expect(provider1, isNot(equals(provider3))); + }); + + test('generates different keys for different providers/types', () { + const coordinates = TileCoordinates(1, 2, 3); + const options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}'); + final mapDataProvider = MapDataProvider(); + + final provider1 = DeflockTileImageProvider( + coordinates: coordinates, + options: options, + mapDataProvider: mapDataProvider, + providerId: 'provider_a', + tileTypeId: 'type_1', + ); + final provider2 = DeflockTileImageProvider( + coordinates: coordinates, + options: options, + mapDataProvider: mapDataProvider, + providerId: 'provider_b', + tileTypeId: 'type_1', + ); + final provider3 = DeflockTileImageProvider( + coordinates: coordinates, + options: options, + mapDataProvider: mapDataProvider, + providerId: 'provider_a', + tileTypeId: 'type_2', + ); + + // Different providers should not be equal (even with same coordinates) + expect(provider1, isNot(equals(provider2))); + expect(provider1.hashCode, isNot(equals(provider2.hashCode))); + + // Different tile types should not be equal (even with same coordinates and provider) + expect(provider1, isNot(equals(provider3))); + expect(provider1.hashCode, isNot(equals(provider3.hashCode))); + }); + }); +} \ No newline at end of file