diff --git a/lib/services/camera_cache.dart b/lib/services/camera_cache.dart new file mode 100644 index 0000000..777b6de --- /dev/null +++ b/lib/services/camera_cache.dart @@ -0,0 +1,40 @@ +import 'package:latlong2/latlong.dart'; +import '../models/osm_camera_node.dart'; +import 'package:flutter_map/flutter_map.dart' show LatLngBounds; + +class CameraCache { + // Singleton instance + static final CameraCache instance = CameraCache._internal(); + factory CameraCache() => instance; + CameraCache._internal(); + + final Map _nodes = {}; + + /// Add or update a batch of camera nodes in the cache. + void addOrUpdate(List nodes) { + for (var node in nodes) { + _nodes[node.id] = node; + } + } + + /// Query for all cached cameras currently within the given LatLngBounds. + List queryByBounds(LatLngBounds bounds) { + return _nodes.values + .where((node) => _inBounds(node.coord, bounds)) + .toList(); + } + + /// Retrieve all cached cameras. + List getAll() => _nodes.values.toList(); + + /// Optionally clear the cache (rarely needed) + void clear() => _nodes.clear(); + + /// Utility: point-in-bounds for coordinates + bool _inBounds(LatLng coord, LatLngBounds bounds) { + return coord.latitude >= bounds.southWest.latitude && + coord.latitude <= bounds.northEast.latitude && + coord.longitude >= bounds.southWest.longitude && + coord.longitude <= bounds.northEast.longitude; + } +} diff --git a/lib/widgets/camera_provider_with_cache.dart b/lib/widgets/camera_provider_with_cache.dart new file mode 100644 index 0000000..95ee58d --- /dev/null +++ b/lib/widgets/camera_provider_with_cache.dart @@ -0,0 +1,64 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:flutter_map/flutter_map.dart' show LatLngBounds; + +import '../services/map_data_provider.dart'; +import '../services/camera_cache.dart'; +import '../models/camera_profile.dart'; +import '../models/osm_camera_node.dart'; +import '../app_state.dart'; + +/// Provides cameras for a map view, using an in-memory cache and optionally +/// merging in new results from Overpass via MapDataProvider when not offline. +class CameraProviderWithCache extends ChangeNotifier { + static final CameraProviderWithCache instance = CameraProviderWithCache._internal(); + factory CameraProviderWithCache() => instance; + CameraProviderWithCache._internal(); + + Timer? _debounceTimer; + + /// Call this to get (quickly) all cached overlays for the given view. + List getCachedCamerasForBounds(LatLngBounds bounds) { + return CameraCache.instance.queryByBounds(bounds); + } + + /// Call this when the map view changes (bounds/profiles), triggers async fetch + /// and notifies listeners/UI when new data is available. + void fetchAndUpdate({ + required LatLngBounds bounds, + required List profiles, + UploadMode uploadMode = UploadMode.production, + }) { + // Fast: serve cached immediately + notifyListeners(); + // Debounce rapid panning/zooming + _debounceTimer?.cancel(); + _debounceTimer = Timer(const Duration(milliseconds: 400), () async { + final isOffline = AppState.instance.offlineMode; + if (!isOffline) { + try { + final fresh = await MapDataProvider().getCameras( + bounds: bounds, + profiles: profiles, + uploadMode: uploadMode, + source: MapSource.remote, + ); + if (fresh.isNotEmpty) { + CameraCache.instance.addOrUpdate(fresh); + notifyListeners(); + } + } catch (e) { + debugPrint('[CameraProviderWithCache] Overpass fetch failed: $e'); + // Cache already holds whatever is available for the view + } + } // else, only cache is used + }); + } + + /// Optionally: clear the cache (could be used for testing/dev) + void clearCache() { + CameraCache.instance.clear(); + notifyListeners(); + } +} diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index 5b9bb37..b618957 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -20,6 +20,7 @@ import '../models/osm_camera_node.dart'; import 'debouncer.dart'; import 'camera_tag_sheet.dart'; import 'tile_provider_with_cache.dart'; +import 'camera_provider_with_cache.dart'; import 'package:flock_map_app/dev_config.dart'; // --- Smart marker widget for camera with single/double tap distinction @@ -93,42 +94,63 @@ class _MapViewState extends State { StreamSubscription? _positionSub; LatLng? _currentLatLng; - List _cameras = []; - List _lastProfileIds = []; - UploadMode? _lastUploadMode; - - void _maybeRefreshCameras() { - final appState = context.read(); - final currProfileIds = appState.enabledProfiles.map((p) => p.id).toList(); - final currMode = appState.uploadMode; - if (_lastProfileIds.isEmpty || - currProfileIds.length != _lastProfileIds.length || - !_lastProfileIds.asMap().entries.every((entry) => currProfileIds[entry.key] == entry.value) || - _lastUploadMode != currMode) { - // If this is first load, or list/ids/mode changed, refetch - _debounce(_refreshCameras); - _lastProfileIds = List.from(currProfileIds); - _lastUploadMode = currMode; - } - } + late final CameraProviderWithCache _cameraProvider; @override void initState() { super.initState(); _debounceTileLayerUpdate = Debouncer(kDebounceTileLayerUpdate); - // Kick off offline area loading as soon as map loads OfflineAreaService(); _controller = widget.controller; _initLocation(); + + // Set up camera overlay caching + _cameraProvider = CameraProviderWithCache.instance; + _cameraProvider.addListener(_onCamerasUpdated); } @override void dispose() { _positionSub?.cancel(); _debounce.dispose(); + _cameraProvider.removeListener(_onCamerasUpdated); super.dispose(); } + void _onCamerasUpdated() { + if (mounted) setState(() {}); + } + + void _refreshCamerasFromProvider() { + final appState = context.read(); + LatLngBounds? bounds; + try { + bounds = _controller.camera.visibleBounds; + } catch (_) { + return; + } + final zoom = _controller.camera.zoom; + if (zoom < 10) { + // Show a snackbar-style bubble, if desired + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Cameras not drawn below zoom level 10'), + duration: Duration(seconds: 2), + ), + ); + } + return; + } + _cameraProvider.fetchAndUpdate( + bounds: bounds, + profiles: appState.enabledProfiles, + uploadMode: appState.uploadMode, + ); + } + +// Duplicate dispose in _MapViewState removed. Only one dispose() remains with all proper teardown. + @override void didUpdateWidget(covariant MapView oldWidget) { super.didUpdateWidget(oldWidget); @@ -147,7 +169,15 @@ class _MapViewState extends State { final latLng = LatLng(position.latitude, position.longitude); setState(() => _currentLatLng = latLng); if (widget.followMe) { - _controller.move(latLng, _controller.camera.zoom); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + try { + _controller.move(latLng, _controller.camera.zoom); + } catch (e) { + debugPrint('MapController not ready yet: $e'); + } + } + }); } }); } @@ -163,7 +193,7 @@ class _MapViewState extends State { // If too zoomed out, do NOT fetch cameras; show info final zoom = _controller.camera.zoom; if (zoom < 10) { - if (mounted) setState(() => _cameras = []); + // No-op: camera overlays handled via provider and cache, no local _cameras assignment needed. // Show a snackbar-style bubble, if desired if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -176,16 +206,10 @@ class _MapViewState extends State { return; } try { - final cams = await _mapDataProvider.getCameras( - bounds: bounds, - profiles: appState.enabledProfiles, - uploadMode: appState.uploadMode, - // MapSource.auto (default) will prefer Overpass for now - ); - if (mounted) setState(() => _cameras = cams); + // (Legacy _cameras assignment removed—now handled via provider and cache updates) } on OfflineModeException catch (_) { // Swallow the error in offline mode - if (mounted) setState(() => _cameras = []); + // (Legacy _cameras assignment removed—handled via provider) } } @@ -202,10 +226,8 @@ class _MapViewState extends State { final appState = context.watch(); final session = appState.session; - // Refetch only if profiles or mode changed - // This avoids repeated fetches on every build - // We track last seen values (local to the State class) - _maybeRefreshCameras(); + // Always update cameras on profile/mode change and map move + _refreshCamerasFromProvider(); // Seed add‑mode target once, after first controller center is available. if (session != null && session.target == null) { @@ -218,10 +240,19 @@ class _MapViewState extends State { } final zoom = _safeZoom(); - + // Fetch cached cameras for current map bounds, but only if controller is ready + LatLngBounds? mapBounds; + try { + mapBounds = _controller.camera.visibleBounds; + } catch (_) { + mapBounds = null; + } + final cameras = (mapBounds != null) + ? _cameraProvider.getCachedCamerasForBounds(mapBounds) + : []; // Camera markers first, then GPS dot, so blue dot is always on top - final markers = [ - ..._cameras + final markers = [ + ...cameras .where((n) => n.coord.latitude != 0 || n.coord.longitude != 0) .where((n) => n.coord.latitude.abs() <= 90 && n.coord.longitude.abs() <= 180) .map((n) => Marker( @@ -242,7 +273,7 @@ class _MapViewState extends State { final overlays = [ if (session != null && session.target != null) _buildCone(session.target!, session.directionDegrees, zoom), - ..._cameras + ...cameras .where((n) => n.hasDirection && n.directionDeg != null) .where((n) => n.coord.latitude != 0 || n.coord.longitude != 0) .where((n) => n.coord.latitude.abs() <= 90 && n.coord.longitude.abs() <= 180)