diff --git a/assets/changelog.json b/assets/changelog.json index 426a4fd..f6d2837 100644 --- a/assets/changelog.json +++ b/assets/changelog.json @@ -4,6 +4,8 @@ "• 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: Max nodes setting now correctly limits rendering only (not data fetching) to prevent UI lag", + "• FIXED: New node limit indicator shows when not all devices are displayed due to rendering limit", "• 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" diff --git a/lib/app_state.dart b/lib/app_state.dart index 3192cb3..fa488f5 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -16,7 +16,6 @@ import 'services/tile_preview_service.dart'; import 'services/changelog_service.dart'; import 'services/operator_profile_service.dart'; import 'services/profile_service.dart'; -import 'widgets/camera_provider_with_cache.dart'; import 'widgets/proximity_warning_dialog.dart'; import 'dev_config.dart'; import 'state/auth_state.dart'; @@ -437,7 +436,6 @@ class AppState extends ChangeNotifier { Future setUploadMode(UploadMode mode) async { // Clear node cache when switching upload modes to prevent mixing production/sandbox data NodeCache.instance.clear(); - CameraProviderWithCache.instance.notifyListeners(); debugPrint('[AppState] Cleared node cache due to upload mode change'); await _settingsState.setUploadMode(mode); diff --git a/lib/localizations/de.json b/lib/localizations/de.json index 3cbbd76..59cdbf2 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -382,9 +382,11 @@ "timedOut": "Anfrage Zeitüberschreitung", "noData": "Keine Offline-Daten", "success": "Überwachungsdaten geladen", - "nodeLimitReached": "Limit erreicht - in Einstellungen erhöhen", "nodeDataSlow": "Überwachungsdaten langsam" }, + "nodeLimitIndicator": { + "message": "Zeige {rendered} von {total} Geräten" + }, "about": { "title": "DeFlock - Überwachungs-Transparenz", "description": "DeFlock ist eine datenschutzorientierte mobile App zur Kartierung öffentlicher Überwachungsinfrastruktür mit OpenStreetMap. Dokumentieren Sie Kameras, ALPRs, Schussdetektoren und andere Überwachungsgeräte in Ihrer Gemeinde, um diese Infrastruktur sichtbar und durchsuchbar zu machen.", diff --git a/lib/localizations/en.json b/lib/localizations/en.json index 39d913d..7fc98e2 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -414,9 +414,11 @@ "timedOut": "Request timed out", "noData": "No offline data", "success": "Surveillance data loaded", - "nodeLimitReached": "Showing limit - increase in settings", "nodeDataSlow": "Surveillance data slow" }, + "nodeLimitIndicator": { + "message": "Showing {rendered} of {total} devices" + }, "navigation": { "searchLocation": "Search Location", "searchPlaceholder": "Search places or coordinates...", diff --git a/lib/localizations/es.json b/lib/localizations/es.json index 107e0e0..52e1f5d 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -414,9 +414,11 @@ "timedOut": "Solicitud agotada", "noData": "Sin datos sin conexión", "success": "Datos de vigilancia cargados", - "nodeLimitReached": "Mostrando límite - aumentar en ajustes", "nodeDataSlow": "Datos de vigilancia lentos" }, + "nodeLimitIndicator": { + "message": "Mostrando {rendered} de {total} dispositivos" + }, "navigation": { "searchLocation": "Buscar ubicación", "searchPlaceholder": "Buscar lugares o coordenadas...", diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index e78de9c..6827806 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -414,9 +414,11 @@ "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", "nodeDataSlow": "Données de surveillance lentes" }, + "nodeLimitIndicator": { + "message": "Affichage de {rendered} sur {total} appareils" + }, "navigation": { "searchLocation": "Rechercher lieu", "searchPlaceholder": "Rechercher lieux ou coordonnées...", diff --git a/lib/localizations/it.json b/lib/localizations/it.json index 77670ca..dea60b7 100644 --- a/lib/localizations/it.json +++ b/lib/localizations/it.json @@ -414,9 +414,11 @@ "timedOut": "Richiesta scaduta", "noData": "Nessun dato offline", "success": "Dati di sorveglianza caricati", - "nodeLimitReached": "Limite visualizzato - aumentare nelle impostazioni", "nodeDataSlow": "Dati di sorveglianza lenti" }, + "nodeLimitIndicator": { + "message": "Mostra {rendered} di {total} dispositivi" + }, "navigation": { "searchLocation": "Cerca posizione", "searchPlaceholder": "Cerca luoghi o coordinate...", diff --git a/lib/localizations/pt.json b/lib/localizations/pt.json index cabf14f..e410c47 100644 --- a/lib/localizations/pt.json +++ b/lib/localizations/pt.json @@ -414,9 +414,11 @@ "timedOut": "Solicitação expirada", "noData": "Nenhum dado offline", "success": "Dados de vigilância carregados", - "nodeLimitReached": "Limite exibido - aumentar nas configurações", "nodeDataSlow": "Dados de vigilância lentos" }, + "nodeLimitIndicator": { + "message": "Mostrando {rendered} de {total} dispositivos" + }, "navigation": { "searchLocation": "Buscar localização", "searchPlaceholder": "Buscar locais ou coordenadas...", diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json index 8192169..17a5d4b 100644 --- a/lib/localizations/zh.json +++ b/lib/localizations/zh.json @@ -414,9 +414,11 @@ "timedOut": "请求超时", "noData": "无离线数据", "success": "监控数据已加载", - "nodeLimitReached": "显示限制 - 在设置中增加", "nodeDataSlow": "监控数据缓慢" }, + "nodeLimitIndicator": { + "message": "显示 {rendered} / {total} 设备" + }, "navigation": { "searchLocation": "搜索位置", "searchPlaceholder": "搜索地点或坐标...", diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 27741fd..05f79f3 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -13,7 +13,6 @@ import '../services/localization_service.dart'; import '../widgets/add_node_sheet.dart'; import '../widgets/edit_node_sheet.dart'; import '../widgets/node_tag_sheet.dart'; -import '../widgets/camera_provider_with_cache.dart'; import '../widgets/download_area_dialog.dart'; import '../widgets/measured_sheet.dart'; import '../widgets/navigation_sheet.dart'; @@ -49,6 +48,9 @@ class _HomeScreenState extends State with TickerProviderStateMixin { // Flag to prevent map bounce when transitioning from tag sheet to edit sheet bool _transitioningToEdit = false; + // Track node limit state for button disabling + bool _isNodeLimitActive = false; + // Track selected node for highlighting int? _selectedNodeId; @@ -546,6 +548,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin { }, child: NodeTagSheet( node: node, + isNodeLimitActive: _isNodeLimitActive, onEditPressed: () { // Check minimum zoom level before starting edit session final currentZoom = _mapController.mapController.camera.zoom; @@ -666,13 +669,9 @@ class _HomeScreenState extends State with TickerProviderStateMixin { ? _navigationSheetHeight : _tagSheetHeight)); - return MultiProvider( - providers: [ - ChangeNotifierProvider(create: (_) => CameraProviderWithCache()), - ], - child: MediaQuery( - data: MediaQuery.of(context).copyWith(viewInsets: EdgeInsets.zero), - child: Scaffold( + return MediaQuery( + data: MediaQuery.of(context).copyWith(viewInsets: EdgeInsets.zero), + child: Scaffold( key: _scaffoldKey, appBar: AppBar( automaticallyImplyLeading: false, // Disable automatic back button @@ -717,6 +716,11 @@ class _HomeScreenState extends State with TickerProviderStateMixin { onNodeTap: openNodeTagSheet, onSuspectedLocationTap: openSuspectedLocationSheet, onSearchPressed: _onNavigationButtonPressed, + onNodeLimitChanged: (isLimited) { + setState(() { + _isNodeLimitActive = isLimited; + }); + }, onUserGesture: () { if (appState.followMeMode != FollowMeMode.off) { appState.setFollowMeMode(FollowMeMode.off); @@ -771,7 +775,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin { builder: (context, child) => ElevatedButton.icon( icon: Icon(Icons.add_location_alt), label: Text(LocalizationService.instance.tagNode), - onPressed: _openAddNodeSheet, + onPressed: _isNodeLimitActive ? null : _openAddNodeSheet, style: ElevatedButton.styleFrom( minimumSize: Size(0, 48), textStyle: TextStyle(fontSize: 16), @@ -828,7 +832,6 @@ class _HomeScreenState extends State with TickerProviderStateMixin { ], ), ), - ), ); } } diff --git a/lib/services/deflock_tile_provider.dart b/lib/services/deflock_tile_provider.dart index fe7715f..e45315b 100644 --- a/lib/services/deflock_tile_provider.dart +++ b/lib/services/deflock_tile_provider.dart @@ -97,19 +97,14 @@ class DeflockTileImageProvider extends ImageProvider { 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'); + // Don't log routine offline misses to avoid console spam + if (!e.toString().contains('offline mode is enabled')) { + 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); + // Re-throw the exception and let FlutterMap handle missing tiles gracefully + // This is better than trying to provide fallback images + throw e; } } diff --git a/lib/services/map_data_provider.dart b/lib/services/map_data_provider.dart index cc31361..95a7445 100644 --- a/lib/services/map_data_provider.dart +++ b/lib/services/map_data_provider.dart @@ -53,7 +53,7 @@ class MapDataProvider { bounds: bounds, profiles: profiles, uploadMode: uploadMode, - maxResults: AppState.instance.maxCameras, + maxResults: 0, // No limit - fetch all available data ); } @@ -76,7 +76,7 @@ class MapDataProvider { return fetchLocalNodes( bounds: bounds, profiles: profiles, - maxNodes: AppState.instance.maxCameras, + maxNodes: 0, // No limit - get all available data ); } } else if (uploadMode == UploadMode.sandbox) { @@ -86,7 +86,7 @@ class MapDataProvider { bounds: bounds, profiles: profiles, uploadMode: uploadMode, - maxResults: AppState.instance.maxCameras, + maxResults: 0, // No limit - fetch all available data ); } else { // Production mode: use pre-fetch service for efficient area loading @@ -116,13 +116,9 @@ class MapDataProvider { debugPrint('[MapDataProvider] Using existing fresh pre-fetched area cache'); } - // Apply rendering limit and warn if nodes are being excluded - final maxNodes = AppState.instance.maxCameras; - if (localNodes.length > maxNodes) { - NetworkStatus.instance.reportNodeLimitReached(localNodes.length, maxNodes); - } - - return localNodes.take(maxNodes).toList(); + // Return all local nodes without any rendering limit + // Rendering limits are applied at the UI layer + return localNodes; } } diff --git a/lib/services/network_status.dart b/lib/services/network_status.dart index 76f9784..fd7db81 100644 --- a/lib/services/network_status.dart +++ b/lib/services/network_status.dart @@ -4,7 +4,7 @@ import 'dart:async'; import '../app_state.dart'; enum NetworkIssueType { overpassApi } -enum NetworkStatusType { waiting, issues, timedOut, noData, ready, success, nodeLimitReached } +enum NetworkStatusType { waiting, issues, timedOut, noData, ready, success } @@ -22,9 +22,6 @@ class NetworkStatus extends ChangeNotifier { Timer? _waitingTimer; Timer? _noDataResetTimer; Timer? _successResetTimer; - bool _nodeLimitReached = false; - Timer? _nodeLimitResetTimer; - // Getters bool get hasAnyIssues => _overpassHasIssues; bool get overpassHasIssues => _overpassHasIssues; @@ -32,7 +29,6 @@ class NetworkStatus extends ChangeNotifier { bool get isTimedOut => _isTimedOut; bool get hasNoData => _hasNoData; bool get hasSuccess => _hasSuccess; - bool get nodeLimitReached => _nodeLimitReached; NetworkStatusType get currentStatus { // Simple single-path status logic @@ -41,7 +37,6 @@ class NetworkStatus extends ChangeNotifier { if (_isTimedOut) return NetworkStatusType.timedOut; if (_hasNoData) return NetworkStatusType.noData; if (_hasSuccess) return NetworkStatusType.success; - if (_nodeLimitReached) return NetworkStatusType.nodeLimitReached; return NetworkStatusType.ready; } @@ -144,17 +139,15 @@ class NetworkStatus extends ChangeNotifier { /// Clear waiting/timeout/no-data status (legacy method for compatibility) void clearWaiting() { - if (_isWaitingForData || _isTimedOut || _hasNoData || _hasSuccess || _nodeLimitReached) { + if (_isWaitingForData || _isTimedOut || _hasNoData || _hasSuccess) { _isWaitingForData = false; _isTimedOut = false; _hasNoData = false; _hasSuccess = false; - _nodeLimitReached = false; _recentOfflineMisses = 0; _waitingTimer?.cancel(); _noDataResetTimer?.cancel(); _successResetTimer?.cancel(); - _nodeLimitResetTimer?.cancel(); notifyListeners(); } } @@ -195,22 +188,6 @@ class NetworkStatus extends ChangeNotifier { debugPrint('[NetworkStatus] Network error occurred'); } - /// Show notification that node display limit was reached - void reportNodeLimitReached(int totalNodes, int maxNodes) { - _nodeLimitReached = true; - notifyListeners(); - debugPrint('[NetworkStatus] Node display limit reached: $totalNodes found, showing $maxNodes'); - - // Auto-clear after 8 seconds - _nodeLimitResetTimer?.cancel(); - _nodeLimitResetTimer = Timer(const Duration(seconds: 8), () { - if (_nodeLimitReached) { - _nodeLimitReached = false; - notifyListeners(); - } - }); - } - /// Report that a tile was not available offline @@ -243,7 +220,6 @@ class NetworkStatus extends ChangeNotifier { _waitingTimer?.cancel(); _noDataResetTimer?.cancel(); _successResetTimer?.cancel(); - _nodeLimitResetTimer?.cancel(); super.dispose(); } } \ No newline at end of file diff --git a/lib/services/simple_tile_service.dart b/lib/services/simple_tile_service.dart deleted file mode 100644 index 9d926fa..0000000 --- a/lib/services/simple_tile_service.dart +++ /dev/null @@ -1,130 +0,0 @@ -import 'package:http/http.dart' as http; -import 'package:flutter/foundation.dart'; -import 'package:latlong2/latlong.dart'; -import 'package:flutter_map/flutter_map.dart'; - -import '../app_state.dart'; -import 'map_data_provider.dart'; -import 'network_status.dart'; - -/// Simple HTTP client that routes tile requests through the centralized MapDataProvider. -/// This ensures all tile fetching (offline/online routing, retries, etc.) is in one place. -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 { - // Extract tile coordinates from our custom URL scheme - final tileCoords = _extractTileCoords(request.url); - if (tileCoords != null) { - final z = tileCoords['z']!; - final x = tileCoords['x']!; - final y = tileCoords['y']!; - return _handleTileRequest(z, x, y); - } - - // Pass through non-tile requests - return _inner.send(request); - } - - /// 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) { - if (url.host != 'tiles.local') return null; - - 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; - } - - 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); - - // Serve tile with proper cache headers - return http.StreamedResponse( - Stream.value(tileBytes), - 200, - headers: { - 'Content-Type': 'image/png', - 'Cache-Control': 'public, max-age=604800', - 'Expires': _httpDateFormat(DateTime.now().add(Duration(days: 7))), - 'Last-Modified': _httpDateFormat(DateTime.now().subtract(Duration(hours: 1))), - }, - ); - - } catch (e) { - debugPrint('[SimpleTileService] Could not get tile $z/$x/$y: $e'); - - // 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) { - // Only report tile completion if we were in loading state (user-initiated) - if (NetworkStatus.instance.currentStatus == NetworkStatusType.waiting) { - NetworkStatus.instance.setSuccess(); - } - } - } - } - - /// Clear any queued tile requests when map view changes - void clearTileQueue() { - _mapDataProvider.clearTileQueue(); - } - - /// Clear only tile requests that are no longer visible in the current bounds - void clearStaleRequests(LatLngBounds currentBounds) { - _mapDataProvider.clearTileQueueSelective(currentBounds); - } - - /// Format date for HTTP headers (RFC 7231) - String _httpDateFormat(DateTime date) { - final utc = date.toUtc(); - final weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; - final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', - 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - - final weekday = weekdays[utc.weekday - 1]; - final day = utc.day.toString().padLeft(2, '0'); - final month = months[utc.month - 1]; - final year = utc.year; - final hour = utc.hour.toString().padLeft(2, '0'); - final minute = utc.minute.toString().padLeft(2, '0'); - final second = utc.second.toString().padLeft(2, '0'); - - return '$weekday, $day $month $year $hour:$minute:$second GMT'; - } - - @override - void close() { - _inner.close(); - super.close(); - } -} \ No newline at end of file diff --git a/lib/widgets/camera_provider_with_cache.dart b/lib/widgets/camera_provider_with_cache.dart index 2e81666..f733cb4 100644 --- a/lib/widgets/camera_provider_with_cache.dart +++ b/lib/widgets/camera_provider_with_cache.dart @@ -20,7 +20,7 @@ class CameraProviderWithCache extends ChangeNotifier { Timer? _debounceTimer; /// Call this to get (quickly) all cached overlays for the given view. - /// Filters by currently enabled profiles. + /// Filters by currently enabled profiles only. Limiting is handled by MapView. List getCachedNodesForBounds(LatLngBounds bounds) { final allNodes = NodeCache.instance.queryByBounds(bounds); final enabledProfiles = AppState.instance.enabledProfiles; diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index 57a665d..5ad3c89 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -25,6 +25,7 @@ import 'map/camera_refresh_controller.dart'; import 'map/gps_controller.dart'; import 'map/suspected_location_markers.dart'; import 'network_status_indicator.dart'; +import 'node_limit_indicator.dart'; import 'provisional_pin.dart'; import 'proximity_alert_banner.dart'; import '../dev_config.dart'; @@ -44,6 +45,7 @@ class MapView extends StatefulWidget { this.onNodeTap, this.onSuspectedLocationTap, this.onSearchPressed, + this.onNodeLimitChanged, }); final FollowMeMode followMeMode; @@ -53,6 +55,7 @@ class MapView extends StatefulWidget { final void Function(OsmNode)? onNodeTap; final void Function(SuspectedLocation)? onSuspectedLocationTap; final VoidCallback? onSearchPressed; + final void Function(bool isLimited)? onNodeLimitChanged; @override State createState() => MapViewState(); @@ -76,7 +79,8 @@ class MapViewState extends State { // Track map center to clear queue on significant panning LatLng? _lastCenter; - + // Track node limit state for parent notification + bool _lastNodeLimitState = false; // State for proximity alert banner bool _showProximityBanner = false; @@ -156,7 +160,6 @@ class MapViewState extends State { getNearbyNodes: () { if (mounted) { try { - final cameraProvider = context.read(); LatLngBounds? mapBounds; try { mapBounds = _controller.mapController.camera.visibleBounds; @@ -164,7 +167,7 @@ class MapViewState extends State { return []; } return mapBounds != null - ? cameraProvider.getCachedNodesForBounds(mapBounds) + ? CameraProviderWithCache.instance.getCachedNodesForBounds(mapBounds) : []; } catch (e) { debugPrint('[MapView] Could not get nearby nodes: $e'); @@ -395,38 +398,70 @@ class MapViewState extends State { // Edit sessions don't need to center - we're already centered from the node tap // SheetAwareMap handles the visual positioning - // Fetch cached cameras for current map bounds (using Consumer so overlays redraw instantly) - Widget cameraLayers = Consumer( - builder: (context, cameraProvider, child) { - // Get current zoom level and map bounds (shared by all logic) - double currentZoom = 15.0; // fallback - LatLngBounds? mapBounds; - try { - currentZoom = _controller.mapController.camera.zoom; - mapBounds = _controller.mapController.camera.visibleBounds; - } catch (_) { - // Controller not ready yet, use fallback values - mapBounds = null; - } - - final minZoom = _getMinZoomForNodes(context); - List nodes; - - if (currentZoom >= minZoom) { - // Above minimum zoom - get cached nodes - nodes = (mapBounds != null) - ? cameraProvider.getCachedNodesForBounds(mapBounds) - : []; - } else { - // Below minimum zoom - don't render any nodes - nodes = []; - } + // Get current zoom level and map bounds (shared by all logic) + double currentZoom = 15.0; // fallback + LatLngBounds? mapBounds; + try { + currentZoom = _controller.mapController.camera.zoom; + mapBounds = _controller.mapController.camera.visibleBounds; + } catch (_) { + // Controller not ready yet, use fallback values + mapBounds = null; + } + + final minZoom = _getMinZoomForNodes(context); + List allNodes; + List nodesToRender; + bool isLimitActive = false; + + if (currentZoom >= minZoom) { + // Above minimum zoom - get cached nodes directly (no Provider needed) + allNodes = (mapBounds != null) + ? CameraProviderWithCache.instance.getCachedNodesForBounds(mapBounds) + : []; + + // Filter out invalid coordinates before applying limit + final validNodes = allNodes.where((node) { + return (node.coord.latitude != 0 || node.coord.longitude != 0) && + node.coord.latitude.abs() <= 90 && + node.coord.longitude.abs() <= 180; + }).toList(); + + // Apply rendering limit to prevent UI lag + final maxNodes = appState.maxCameras; + if (validNodes.length > maxNodes) { + nodesToRender = validNodes.take(maxNodes).toList(); + isLimitActive = true; + debugPrint('[MapView] Node limit active: rendering ${nodesToRender.length} of ${validNodes.length} devices'); + } else { + nodesToRender = validNodes; + isLimitActive = false; + } + } else { + // Below minimum zoom - don't render any nodes + allNodes = []; + nodesToRender = []; + isLimitActive = false; + } + + // Notify parent if limit state changed (for button disabling) + if (isLimitActive != _lastNodeLimitState) { + _lastNodeLimitState = isLimitActive; + // Schedule callback after build completes to avoid setState during build + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.onNodeLimitChanged?.call(isLimitActive); + }); + } + + // Build camera layers using the limited nodes + Widget cameraLayers = LayoutBuilder( + builder: (context, constraints) { // Determine if we should dim node markers (when suspected location is selected) final shouldDimNodes = appState.selectedSuspectedLocation != null; final markers = CameraMarkersBuilder.buildCameraMarkers( - cameras: nodes, + cameras: nodesToRender, mapController: _controller.mapController, userLocation: _gpsController.currentLocation, selectedNodeId: widget.selectedNodeId, @@ -451,7 +486,7 @@ class MapViewState extends State { // Filter out suspected locations that are too close to real nodes final filteredSuspectedLocations = _filterSuspectedLocationsByProximity( suspectedLocations: limitedSuspectedLocations, - realNodes: nodes, + realNodes: nodesToRender, minDistance: appState.suspectedLocationMinDistance, ); @@ -473,7 +508,7 @@ class MapViewState extends State { } final overlays = DirectionConesBuilder.buildDirectionCones( - cameras: nodes, + cameras: nodesToRender, zoom: currentZoom, session: session, editSession: editSession, @@ -496,7 +531,7 @@ class MapViewState extends State { } // Build edit lines connecting original nodes to their edited positions - final editLines = _buildEditLines(nodes); + final editLines = _buildEditLines(nodesToRender); // Build center marker for add/edit sessions final centerMarkers = []; @@ -573,9 +608,20 @@ class MapViewState extends State { if (editLines.isNotEmpty) PolylineLayer(polylines: editLines), if (routeLines.isNotEmpty) PolylineLayer(polylines: routeLines), MarkerLayer(markers: [...suspectedLocationMarkers, ...markers, ...centerMarkers]), + + // Node limit indicator (top-left) - shown when limit is active + NodeLimitIndicator( + isActive: isLimitActive, + renderedCount: nodesToRender.length, + totalCount: isLimitActive ? allNodes.where((node) { + return (node.coord.latitude != 0 || node.coord.longitude != 0) && + node.coord.latitude.abs() <= 90 && + node.coord.longitude.abs() <= 180; + }).length : 0, + ), ], ); - } + }, ); return Stack( diff --git a/lib/widgets/network_status_indicator.dart b/lib/widgets/network_status_indicator.dart index b9eb941..0e17995 100644 --- a/lib/widgets/network_status_indicator.dart +++ b/lib/widgets/network_status_indicator.dart @@ -44,12 +44,6 @@ class NetworkStatusIndicator extends StatelessWidget { color = Colors.green; break; - case NetworkStatusType.nodeLimitReached: - message = locService.t('networkStatus.nodeLimitReached'); - icon = Icons.visibility_off; - color = Colors.amber; - break; - case NetworkStatusType.issues: switch (networkStatus.currentIssueType) { case NetworkIssueType.overpassApi: @@ -67,7 +61,7 @@ class NetworkStatusIndicator extends StatelessWidget { } return Positioned( - top: 8, // Position relative to the map area (not the screen) + top: 56, // Position below node limit indicator when present left: 8, child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), diff --git a/lib/widgets/node_limit_indicator.dart b/lib/widgets/node_limit_indicator.dart new file mode 100644 index 0000000..24df5f9 --- /dev/null +++ b/lib/widgets/node_limit_indicator.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import '../services/localization_service.dart'; + +class NodeLimitIndicator extends StatelessWidget { + final bool isActive; + final int renderedCount; + final int totalCount; + + const NodeLimitIndicator({ + super.key, + required this.isActive, + required this.renderedCount, + required this.totalCount, + }); + + @override + Widget build(BuildContext context) { + if (!isActive) { + return const SizedBox.shrink(); + } + + final locService = LocalizationService.instance; + final message = locService.t('nodeLimitIndicator.message') + .replaceAll('{rendered}', renderedCount.toString()) + .replaceAll('{total}', totalCount.toString()); + + return Positioned( + top: 8, // Position at top-left of map area + left: 8, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.amber, width: 1), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.visibility_off, + size: 16, + color: Colors.amber, + ), + const SizedBox(width: 4), + Text( + message, + style: const TextStyle( + color: Colors.amber, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/node_tag_sheet.dart b/lib/widgets/node_tag_sheet.dart index 375d945..cc736c3 100644 --- a/lib/widgets/node_tag_sheet.dart +++ b/lib/widgets/node_tag_sheet.dart @@ -11,8 +11,14 @@ import 'advanced_edit_options_sheet.dart'; class NodeTagSheet extends StatelessWidget { final OsmNode node; final VoidCallback? onEditPressed; + final bool isNodeLimitActive; - const NodeTagSheet({super.key, required this.node, this.onEditPressed}); + const NodeTagSheet({ + super.key, + required this.node, + this.onEditPressed, + this.isNodeLimitActive = false, + }); @override Widget build(BuildContext context) { @@ -200,7 +206,7 @@ class NodeTagSheet extends StatelessWidget { children: [ if (isEditable) ...[ ElevatedButton.icon( - onPressed: _openEditSheet, + onPressed: isNodeLimitActive ? null : _openEditSheet, icon: const Icon(Icons.edit, size: 18), label: Text(locService.edit), style: ElevatedButton.styleFrom(