diff --git a/lib/screens/settings_screen_sections/offline_areas_section.dart b/lib/screens/settings_screen_sections/offline_areas_section.dart index 5e15350..f02feb4 100644 --- a/lib/screens/settings_screen_sections/offline_areas_section.dart +++ b/lib/screens/settings_screen_sections/offline_areas_section.dart @@ -60,7 +60,7 @@ class _OfflineAreasSectionState extends State { } subtitle += '\n${locService.t('offlineAreas.size')}: $diskStr'; if (!area.isPermanent) { - subtitle += '\n${locService.t('offlineAreas.cameras')}: ${area.cameras.length}'; + subtitle += '\n${locService.t('offlineAreas.cameras')}: ${area.nodes.length}'; } return Card( child: ListTile( diff --git a/lib/services/map_data_provider.dart b/lib/services/map_data_provider.dart index c4be440..d7ea4f8 100644 --- a/lib/services/map_data_provider.dart +++ b/lib/services/map_data_provider.dart @@ -9,6 +9,7 @@ import 'map_data_submodules/nodes_from_overpass.dart'; import 'map_data_submodules/tiles_from_remote.dart'; import 'map_data_submodules/nodes_from_local.dart'; import 'map_data_submodules/tiles_from_local.dart'; +import 'network_status.dart'; enum MapSource { local, remote, auto } // For future use @@ -39,6 +40,7 @@ class MapDataProvider { UploadMode uploadMode = UploadMode.production, MapSource source = MapSource.auto, }) async { + try { final offline = AppState.instance.offlineMode; // Explicit remote request: error if offline, else always remote @@ -62,7 +64,7 @@ class MapDataProvider { ); } - // AUTO: default = remote first, fallback to local only if offline + // AUTO: In offline mode, only fetch local. In online mode, fetch both remote and local, then merge. if (offline) { return fetchLocalNodes( bounds: bounds, @@ -70,22 +72,52 @@ class MapDataProvider { maxNodes: AppState.instance.maxCameras, ); } else { - // Try remote, fallback to local ONLY if remote throws (optional, could be removed for stricter behavior) - try { - return await fetchOverpassNodes( - bounds: bounds, - profiles: profiles, - uploadMode: uploadMode, - maxResults: AppState.instance.maxCameras, - ); - } catch (e) { - debugPrint('[MapDataProvider] Remote node fetch failed, error: $e. Falling back to local.'); - return fetchLocalNodes( + // Online mode: fetch both remote and local, then merge with deduplication + final List>> futures = []; + + // Always try to get local nodes (fast, cached) + futures.add(fetchLocalNodes( bounds: bounds, profiles: profiles, maxNodes: AppState.instance.maxCameras, - ); + )); + + // Always try to get remote nodes (slower, fresh data) + futures.add(fetchOverpassNodes( + bounds: bounds, + profiles: profiles, + uploadMode: uploadMode, + maxResults: AppState.instance.maxCameras, + ).catchError((e) { + debugPrint('[MapDataProvider] Remote node fetch failed, error: $e. Continuing with local only.'); + return []; // Return empty list on remote failure + })); + + // Wait for both, then merge with deduplication by node ID + final results = await Future.wait(futures); + final localNodes = results[0]; + final remoteNodes = results[1]; + + // Merge with deduplication - prefer remote data over local for same node ID + final Map mergedNodes = {}; + + // Add local nodes first + for (final node in localNodes) { + mergedNodes[node.id] = node; } + + // Add remote nodes, overwriting any local duplicates + for (final node in remoteNodes) { + mergedNodes[node.id] = node; + } + + // Apply maxCameras limit to the merged result + final finalNodes = mergedNodes.values.take(AppState.instance.maxCameras).toList(); + return finalNodes; + } + } finally { + // Always report node completion, regardless of success or failure + NetworkStatus.instance.reportNodeComplete(); } } @@ -95,7 +127,7 @@ class MapDataProvider { required LatLngBounds bounds, required List profiles, UploadMode uploadMode = UploadMode.production, - int pageSize = 500, + int maxResults = 0, // 0 = no limit for offline downloads int maxTries = 3, }) async { final offline = AppState.instance.offlineMode; @@ -106,7 +138,7 @@ class MapDataProvider { bounds: bounds, profiles: profiles, uploadMode: uploadMode, - maxResults: pageSize, + maxResults: maxResults, // Pass 0 for unlimited ); } diff --git a/lib/services/map_data_submodules/nodes_from_local.dart b/lib/services/map_data_submodules/nodes_from_local.dart index 2d049e5..0273c94 100644 --- a/lib/services/map_data_submodules/nodes_from_local.dart +++ b/lib/services/map_data_submodules/nodes_from_local.dart @@ -1,5 +1,6 @@ import 'dart:io'; import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:latlong2/latlong.dart'; import 'package:flutter_map/flutter_map.dart' show LatLngBounds; import '../../models/osm_camera_node.dart'; @@ -16,38 +17,99 @@ Future> fetchLocalNodes({ final areas = OfflineAreaService().offlineAreas; final Map deduped = {}; + debugPrint('[fetchLocalNodes] Checking ${areas.length} offline areas for nodes'); + debugPrint('[fetchLocalNodes] Requested bounds: ${bounds.southWest.latitude},${bounds.southWest.longitude} to ${bounds.northEast.latitude},${bounds.northEast.longitude}'); + debugPrint('[fetchLocalNodes] Using ${profiles.length} profiles: ${profiles.map((p) => p.name).join(', ')}'); + for (final area in areas) { - if (area.status != OfflineAreaStatus.complete) continue; - if (!area.bounds.isOverlapping(bounds)) continue; + debugPrint('[fetchLocalNodes] Area ${area.name} (${area.id}): status=${area.status}'); + if (area.status != OfflineAreaStatus.complete) { + debugPrint('[fetchLocalNodes] Skipping area ${area.name} - status is ${area.status}'); + continue; + } + debugPrint('[fetchLocalNodes] Area ${area.name} bounds: ${area.bounds.southWest.latitude},${area.bounds.southWest.longitude} to ${area.bounds.northEast.latitude},${area.bounds.northEast.longitude}'); + if (!area.bounds.isOverlapping(bounds)) { + debugPrint('[fetchLocalNodes] Skipping area ${area.name} - bounds do not overlap'); + continue; + } final nodes = await _loadAreaNodes(area); + debugPrint('[fetchLocalNodes] Area ${area.name} loaded ${nodes.length} nodes from storage'); + + int nodesBefore = deduped.length; + int dedupFiltered = 0; + int boundsFiltered = 0; + int profileFiltered = 0; + for (final node in nodes) { // Deduplicate by node ID, preferring the first occurrence - if (deduped.containsKey(node.id)) continue; + if (deduped.containsKey(node.id)) { + dedupFiltered++; + continue; + } // Within view bounds? - if (!_pointInBounds(node.coord, bounds)) continue; + if (!_pointInBounds(node.coord, bounds)) { + boundsFiltered++; + if (boundsFiltered <= 3) { // Log first few for debugging + debugPrint('[fetchLocalNodes] Node ${node.id} at ${node.coord.latitude},${node.coord.longitude} outside bounds ${bounds.southWest.latitude},${bounds.southWest.longitude} to ${bounds.northEast.latitude},${bounds.northEast.longitude}'); + } + continue; + } // Profile filter if used - if (profiles.isNotEmpty && !_matchesAnyProfile(node, profiles)) continue; + if (profiles.isNotEmpty && !_matchesAnyProfile(node, profiles)) { + profileFiltered++; + if (profileFiltered <= 3) { // Log first few for debugging + debugPrint('[fetchLocalNodes] Node ${node.id} tags ${node.tags} don\'t match any of ${profiles.length} profiles'); + } + continue; + } deduped[node.id] = node; } + int nodesAdded = deduped.length - nodesBefore; + debugPrint('[fetchLocalNodes] Area ${area.name}: dedup filtered: $dedupFiltered, bounds filtered: $boundsFiltered, profile filtered: $profileFiltered'); + debugPrint('[fetchLocalNodes] Area ${area.name} contributed ${nodesAdded} nodes after filtering'); } final out = deduped.values.take(maxNodes ?? deduped.length).toList(); + debugPrint('[fetchLocalNodes] Returning ${out.length} nodes total'); return out; } // Try in-memory first, else load from disk Future> _loadAreaNodes(OfflineArea area) async { - if (area.cameras.isNotEmpty) { - return area.cameras; + if (area.nodes.isNotEmpty) { + debugPrint('[_loadAreaNodes] Area ${area.name} has ${area.nodes.length} nodes in memory'); + return area.nodes; } - final file = File('${area.directory}/cameras.json'); - if (await file.exists()) { - final str = await file.readAsString(); + + // Try new nodes.json first, fall back to legacy cameras.json for backward compatibility + final nodeFile = File('${area.directory}/nodes.json'); + final legacyCameraFile = File('${area.directory}/cameras.json'); + + File fileToLoad; + if (await nodeFile.exists()) { + fileToLoad = nodeFile; + debugPrint('[_loadAreaNodes] Found new node file: ${fileToLoad.path}'); + } else if (await legacyCameraFile.exists()) { + fileToLoad = legacyCameraFile; + debugPrint('[_loadAreaNodes] Found legacy camera file: ${fileToLoad.path}'); + } else { + debugPrint('[_loadAreaNodes] No node file exists for area ${area.name}'); + debugPrint('[_loadAreaNodes] Checked: ${nodeFile.path}'); + debugPrint('[_loadAreaNodes] Checked: ${legacyCameraFile.path}'); + return []; + } + + try { + final str = await fileToLoad.readAsString(); final jsonList = jsonDecode(str) as List; - return jsonList.map((e) => OsmCameraNode.fromJson(e)).toList(); + final nodes = jsonList.map((e) => OsmCameraNode.fromJson(e)).toList(); + debugPrint('[_loadAreaNodes] Loaded ${nodes.length} nodes from ${fileToLoad.path}'); + return nodes; + } catch (e) { + debugPrint('[_loadAreaNodes] Error loading nodes from ${fileToLoad.path}: $e'); + return []; } - return []; } bool _pointInBounds(LatLng pt, LatLngBounds bounds) { diff --git a/lib/services/map_data_submodules/nodes_from_overpass.dart b/lib/services/map_data_submodules/nodes_from_overpass.dart index e15c9cd..f6f8ac9 100644 --- a/lib/services/map_data_submodules/nodes_from_overpass.dart +++ b/lib/services/map_data_submodules/nodes_from_overpass.dart @@ -82,11 +82,14 @@ String _buildOverpassQuery(LatLngBounds bounds, List profiles, int return 'node$tagFilters(${bounds.southWest.latitude},${bounds.southWest.longitude},${bounds.northEast.latitude},${bounds.northEast.longitude});'; }).join('\n '); + // Use unlimited output if maxResults is 0 + final outputClause = maxResults > 0 ? 'out body $maxResults;' : 'out body;'; + return ''' [out:json][timeout:25]; ( $nodeClauses ); -out body $maxResults; +$outputClause '''; } \ No newline at end of file diff --git a/lib/services/network_status.dart b/lib/services/network_status.dart index 65e7dec..66c0058 100644 --- a/lib/services/network_status.dart +++ b/lib/services/network_status.dart @@ -6,6 +6,9 @@ import '../app_state.dart'; enum NetworkIssueType { osmTiles, overpassApi, both } enum NetworkStatusType { waiting, issues, timedOut, noData, ready, success } +/// Simple loading state for dual-source async operations (brutalist approach) +enum LoadingState { ready, waiting, success, timeout } + class NetworkStatus extends ChangeNotifier { static final NetworkStatus instance = NetworkStatus._(); NetworkStatus._(); @@ -23,6 +26,13 @@ class NetworkStatus extends ChangeNotifier { Timer? _noDataResetTimer; Timer? _successResetTimer; + // New dual-source loading state (brutalist approach) + LoadingState _tileLoadingState = LoadingState.ready; + LoadingState _nodeLoadingState = LoadingState.ready; + Timer? _tileTimeoutTimer; + Timer? _nodeTimeoutTimer; + Timer? _successDisplayTimer; + // Getters bool get hasAnyIssues => _osmTilesHaveIssues || _overpassHasIssues; bool get osmTilesHaveIssues => _osmTilesHaveIssues; @@ -32,7 +42,22 @@ class NetworkStatus extends ChangeNotifier { bool get hasNoData => _hasNoData; bool get hasSuccess => _hasSuccess; + // New dual-source getters (brutalist approach) + LoadingState get tileLoadingState => _tileLoadingState; + LoadingState get nodeLoadingState => _nodeLoadingState; + + /// Derive overall loading status from dual sources + bool get isDualSourceLoading => _tileLoadingState == LoadingState.waiting || _nodeLoadingState == LoadingState.waiting; + bool get isDualSourceTimeout => _tileLoadingState == LoadingState.timeout || _nodeLoadingState == LoadingState.timeout; + bool get isDualSourceSuccess => _tileLoadingState == LoadingState.success && _nodeLoadingState == LoadingState.success; + NetworkStatusType get currentStatus { + // Check new dual-source states first + if (isDualSourceTimeout) return NetworkStatusType.timedOut; + if (isDualSourceLoading) return NetworkStatusType.waiting; + if (isDualSourceSuccess) return NetworkStatusType.success; + + // Fall back to legacy states for compatibility if (hasAnyIssues) return NetworkStatusType.issues; if (_isWaitingForData) return NetworkStatusType.waiting; if (_isTimedOut) return NetworkStatusType.timedOut; @@ -206,12 +231,91 @@ class NetworkStatus extends ChangeNotifier { }); } + // New dual-source loading methods (brutalist approach) + + /// Start waiting for both tiles and nodes + void setDualSourceWaiting() { + _tileLoadingState = LoadingState.waiting; + _nodeLoadingState = LoadingState.waiting; + + // Set timeout timers for both + _tileTimeoutTimer?.cancel(); + _tileTimeoutTimer = Timer(const Duration(seconds: 8), () { + if (_tileLoadingState == LoadingState.waiting) { + _tileLoadingState = LoadingState.timeout; + debugPrint('[NetworkStatus] Tile loading timed out'); + notifyListeners(); + } + }); + + _nodeTimeoutTimer?.cancel(); + _nodeTimeoutTimer = Timer(const Duration(seconds: 8), () { + if (_nodeLoadingState == LoadingState.waiting) { + _nodeLoadingState = LoadingState.timeout; + debugPrint('[NetworkStatus] Node loading timed out'); + notifyListeners(); + } + }); + + notifyListeners(); + } + + /// Report tile loading completion + void reportTileComplete() { + if (_tileLoadingState == LoadingState.waiting) { + _tileLoadingState = LoadingState.success; + _tileTimeoutTimer?.cancel(); + _checkDualSourceComplete(); + } + } + + /// Report node loading completion + void reportNodeComplete() { + if (_nodeLoadingState == LoadingState.waiting) { + _nodeLoadingState = LoadingState.success; + _nodeTimeoutTimer?.cancel(); + _checkDualSourceComplete(); + } + } + + /// Check if both sources are complete and show success briefly + void _checkDualSourceComplete() { + if (_tileLoadingState == LoadingState.success && _nodeLoadingState == LoadingState.success) { + debugPrint('[NetworkStatus] Both tiles and nodes loaded successfully'); + notifyListeners(); + + // Auto-reset to ready after showing success briefly + _successDisplayTimer?.cancel(); + _successDisplayTimer = Timer(const Duration(seconds: 2), () { + _tileLoadingState = LoadingState.ready; + _nodeLoadingState = LoadingState.ready; + notifyListeners(); + }); + } else { + // Just notify if one completed but not both yet + notifyListeners(); + } + } + + /// Reset dual-source state to ready + void resetDualSourceState() { + _tileLoadingState = LoadingState.ready; + _nodeLoadingState = LoadingState.ready; + _tileTimeoutTimer?.cancel(); + _nodeTimeoutTimer?.cancel(); + _successDisplayTimer?.cancel(); + notifyListeners(); + } + @override void dispose() { _osmRecoveryTimer?.cancel(); _overpassRecoveryTimer?.cancel(); _waitingTimer?.cancel(); _noDataResetTimer?.cancel(); + _tileTimeoutTimer?.cancel(); + _nodeTimeoutTimer?.cancel(); + _successDisplayTimer?.cancel(); super.dispose(); } } \ No newline at end of file diff --git a/lib/services/offline_areas/offline_area_downloader.dart b/lib/services/offline_areas/offline_area_downloader.dart index 27bf41b..e4ba005 100644 --- a/lib/services/offline_areas/offline_area_downloader.dart +++ b/lib/services/offline_areas/offline_area_downloader.dart @@ -45,16 +45,16 @@ class OfflineAreaDownloader { getAreaSizeBytes: getAreaSizeBytes, ); - // Download cameras for non-permanent areas + // Download nodes for non-permanent areas if (!area.isPermanent) { - await _downloadCameras( + await _downloadNodes( area: area, bounds: bounds, minZoom: minZoom, directory: directory, ); } else { - area.cameras = []; + area.nodes = []; } return success; @@ -138,26 +138,29 @@ class OfflineAreaDownloader { return missingTiles; } - /// Download cameras for the area with expanded bounds - static Future _downloadCameras({ + /// Download nodes for the area with modest expansion (one zoom level lower) + static Future _downloadNodes({ required OfflineArea area, required LatLngBounds bounds, required int minZoom, required String directory, }) async { - // Calculate expanded camera bounds that cover the entire tile area at minimum zoom - final cameraBounds = _calculateCameraBounds(bounds, minZoom); - final cameras = await MapDataProvider().getAllNodesForDownload( - bounds: cameraBounds, + // Modest expansion: use tiles at minZoom + 1 instead of minZoom + // This gives a reasonable buffer without capturing entire states + final nodeZoom = (minZoom + 1).clamp(8, 16); // Reasonable bounds for node fetching + final expandedNodeBounds = _calculateNodeBounds(bounds, nodeZoom); + + final nodes = await MapDataProvider().getAllNodesForDownload( + bounds: expandedNodeBounds, profiles: AppState.instance.profiles, // Use ALL profiles, not just enabled ones ); - area.cameras = cameras; - await OfflineAreaDownloader.saveCameras(cameras, directory); - debugPrint('Area ${area.id}: Downloaded ${cameras.length} cameras from expanded bounds (all profiles)'); + area.nodes = nodes; + await OfflineAreaDownloader.saveNodes(nodes, directory); + debugPrint('Area ${area.id}: Downloaded ${nodes.length} nodes from modestly expanded bounds (zoom $nodeZoom vs tile minZoom $minZoom)'); } /// Calculate expanded bounds that cover the entire tile area at minimum zoom - static LatLngBounds _calculateCameraBounds(LatLngBounds visibleBounds, int minZoom) { + static LatLngBounds _calculateNodeBounds(LatLngBounds visibleBounds, int minZoom) { final tiles = computeTileList(visibleBounds, minZoom, minZoom); if (tiles.isEmpty) return visibleBounds; @@ -188,9 +191,9 @@ class OfflineAreaDownloader { await file.writeAsBytes(bytes); } - /// Save cameras to disk as JSON - static Future saveCameras(List cams, String dir) async { - final file = File('$dir/cameras.json'); - await file.writeAsString(jsonEncode(cams.map((c) => c.toJson()).toList())); + /// Save nodes to disk as JSON + static Future saveNodes(List nodes, String dir) async { + final file = File('$dir/nodes.json'); + await file.writeAsString(jsonEncode(nodes.map((n) => n.toJson()).toList())); } } \ No newline at end of file diff --git a/lib/services/offline_areas/offline_area_models.dart b/lib/services/offline_areas/offline_area_models.dart index 76817c5..f6f34c5 100644 --- a/lib/services/offline_areas/offline_area_models.dart +++ b/lib/services/offline_areas/offline_area_models.dart @@ -17,7 +17,7 @@ class OfflineArea { double progress; // 0.0 - 1.0 int tilesDownloaded; int tilesTotal; - List cameras; + List nodes; int sizeBytes; // Disk size in bytes final bool isPermanent; // Not user-deletable if true @@ -38,7 +38,7 @@ class OfflineArea { this.progress = 0, this.tilesDownloaded = 0, this.tilesTotal = 0, - this.cameras = const [], + this.nodes = const [], this.sizeBytes = 0, this.isPermanent = false, this.tileProviderId, @@ -61,7 +61,7 @@ class OfflineArea { 'progress': progress, 'tilesDownloaded': tilesDownloaded, 'tilesTotal': tilesTotal, - 'cameras': cameras.map((c) => c.toJson()).toList(), + 'nodes': nodes.map((n) => n.toJson()).toList(), 'sizeBytes': sizeBytes, 'isPermanent': isPermanent, 'tileProviderId': tileProviderId, @@ -87,7 +87,7 @@ class OfflineArea { progress: (json['progress'] ?? 0).toDouble(), tilesDownloaded: json['tilesDownloaded'] ?? 0, tilesTotal: json['tilesTotal'] ?? 0, - cameras: (json['cameras'] as List? ?? []) + nodes: (json['nodes'] as List? ?? json['cameras'] as List? ?? []) .map((e) => OsmCameraNode.fromJson(e)).toList(), sizeBytes: json['sizeBytes'] ?? 0, isPermanent: json['isPermanent'] ?? false, diff --git a/lib/services/offline_areas/world_area_manager.dart b/lib/services/offline_areas/world_area_manager.dart index a4c798d..349c351 100644 --- a/lib/services/offline_areas/world_area_manager.dart +++ b/lib/services/offline_areas/world_area_manager.dart @@ -71,7 +71,7 @@ class WorldAreaManager { progress: world.progress, tilesDownloaded: world.tilesDownloaded, tilesTotal: world.tilesTotal, - cameras: world.cameras, + nodes: world.nodes, sizeBytes: world.sizeBytes, isPermanent: world.isPermanent, // Add missing provider metadata diff --git a/lib/services/simple_tile_service.dart b/lib/services/simple_tile_service.dart index ef49d77..fab70db 100644 --- a/lib/services/simple_tile_service.dart +++ b/lib/services/simple_tile_service.dart @@ -10,6 +10,9 @@ import 'network_status.dart'; class SimpleTileHttpClient extends http.BaseClient { final http.Client _inner = http.Client(); final MapDataProvider _mapDataProvider = MapDataProvider(); + + // Tile completion tracking (brutalist approach) + int _pendingTileRequests = 0; @override Future send(http.BaseRequest request) async { @@ -48,14 +51,14 @@ class SimpleTileHttpClient extends http.BaseClient { } Future _handleTileRequest(int z, int x, int y) async { + // Increment pending counter (brutalist completion detection) + _pendingTileRequests++; + 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); - // Show success status briefly - NetworkStatus.instance.setSuccess(); - // Serve tile with proper cache headers return http.StreamedResponse( Stream.value(tileBytes), @@ -71,15 +74,18 @@ class SimpleTileHttpClient extends http.BaseClient { } catch (e) { debugPrint('[SimpleTileService] Could not get tile $z/$x/$y: $e'); - // 404 means no tiles available - show "no data" status briefly - NetworkStatus.instance.setNoData(); - // Return 404 and let flutter_map handle it gracefully return http.StreamedResponse( Stream.value([]), 404, reasonPhrase: 'Tile unavailable: $e', ); + } finally { + // Decrement pending counter and report completion when all done + _pendingTileRequests--; + if (_pendingTileRequests == 0) { + NetworkStatus.instance.reportTileComplete(); + } } } diff --git a/lib/widgets/download_area_dialog.dart b/lib/widgets/download_area_dialog.dart index 8e8c9b1..1c499c5 100644 --- a/lib/widgets/download_area_dialog.dart +++ b/lib/widgets/download_area_dialog.dart @@ -55,7 +55,7 @@ class _DownloadAreaDialogState extends State { ); } - final minZoom = kWorldMaxZoom + 1; + final minZoom = 1; // Always start from zoom 1 to show area overview when zoomed out final maxZoom = _zoom.toInt(); // Calculate maximum possible zoom based on tile count limit diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index 2b73292..8c896a9 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -300,8 +300,8 @@ class MapViewState extends State { appState.updateEditSession(target: pos.center); } - // Show waiting indicator when map moves (user is expecting new content) - NetworkStatus.instance.setWaiting(); + // Start dual-source waiting when map moves (user is expecting new tiles AND nodes) + NetworkStatus.instance.setDualSourceWaiting(); // Only clear tile queue on significant ZOOM changes (not panning) final currentZoom = pos.zoom; @@ -323,6 +323,9 @@ class MapViewState extends State { // Request more cameras on any map movement/zoom at valid zoom level (slower debounce) if (pos.zoom >= 10) { _cameraDebounce(_refreshCamerasFromProvider); + } else { + // Skip nodes at low zoom - report immediate completion (brutalist approach) + NetworkStatus.instance.reportNodeComplete(); } }, ), diff --git a/lib/widgets/network_status_indicator.dart b/lib/widgets/network_status_indicator.dart index 66ef9a9..afee6b0 100644 --- a/lib/widgets/network_status_indicator.dart +++ b/lib/widgets/network_status_indicator.dart @@ -35,7 +35,7 @@ class NetworkStatusIndicator extends StatelessWidget { break; case NetworkStatusType.success: - message = 'Tiles loaded'; + message = 'Done'; icon = Icons.check_circle; color = Colors.green; break; @@ -43,7 +43,7 @@ class NetworkStatusIndicator extends StatelessWidget { case NetworkStatusType.issues: switch (networkStatus.currentIssueType) { case NetworkIssueType.osmTiles: - message = 'OSM tiles slow'; + message = 'Tile provider slow'; icon = Icons.map_outlined; color = Colors.orange; break;