From d0f92f6dafce313402c3baacac185eb37c6849d2 Mon Sep 17 00:00:00 2001 From: stopflock Date: Tue, 12 Aug 2025 19:49:03 -0500 Subject: [PATCH 1/4] offline mode fix for camera upload queue --- lib/app_state.dart | 29 +++++++++++--- lib/models/pending_upload.dart | 6 ++- .../queue_section.dart | 40 ++++++++++++++----- 3 files changed, 58 insertions(+), 17 deletions(-) diff --git a/lib/app_state.dart b/lib/app_state.dart index c12c987..294c42c 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -43,6 +43,7 @@ class AppState extends ChangeNotifier { if (wasOffline && !enabled) { // Transitioning from offline to online: clear tile cache! TileProviderWithCache.clearCache(); + _startUploader(); // Resume upload queue processing as we leave offline mode } notifyListeners(); } @@ -387,17 +388,23 @@ class AppState extends ChangeNotifier { void _startUploader() { _uploadTimer?.cancel(); - // No uploads without auth or queue. - if (_queue.isEmpty) return; + // No uploads without auth or queue, or if offline mode is enabled. + if (_queue.isEmpty || _offlineMode) return; _uploadTimer = Timer.periodic(const Duration(seconds: 10), (t) async { - if (_queue.isEmpty) return; + if (_queue.isEmpty || _offlineMode) { + _uploadTimer?.cancel(); + return; + } + + // Find the first queue item that is NOT in error state and act on that + final item = _queue.where((pu) => !pu.error).cast().firstOrNull; + if (item == null) return; // Retrieve access after every tick (accounts for re-login) final access = await _auth.getAccessToken(); if (access == null) return; // not logged in - final item = _queue.first; bool ok; if (_uploadMode == UploadMode.simulate) { // Simulate successful upload without calling real API @@ -424,7 +431,10 @@ class AppState extends ChangeNotifier { if (!ok) { item.attempts++; if (item.attempts >= 3) { - // give up until next launch + // Mark as error and stop the uploader. User can manually retry. + item.error = true; + _saveQueue(); + notifyListeners(); _uploadTimer?.cancel(); } else { await Future.delayed(const Duration(seconds: 20)); @@ -451,4 +461,13 @@ class AppState extends ChangeNotifier { _saveQueue(); notifyListeners(); } + + // Retry a failed upload (clear error and attempts, then try uploading again) + void retryUpload(PendingUpload upload) { + upload.error = false; + upload.attempts = 0; + _saveQueue(); + notifyListeners(); + _startUploader(); // resume uploader if not busy + } } diff --git a/lib/models/pending_upload.dart b/lib/models/pending_upload.dart index 498a5ce..ae47afc 100644 --- a/lib/models/pending_upload.dart +++ b/lib/models/pending_upload.dart @@ -6,12 +6,14 @@ class PendingUpload { final double direction; final CameraProfile profile; int attempts; + bool error; PendingUpload({ required this.coord, required this.direction, required this.profile, this.attempts = 0, + this.error = false, }); Map toJson() => { @@ -20,6 +22,7 @@ class PendingUpload { 'dir': direction, 'profile': profile.toJson(), 'attempts': attempts, + 'error': error, }; factory PendingUpload.fromJson(Map j) => PendingUpload( @@ -27,8 +30,9 @@ class PendingUpload { direction: j['dir'], profile: j['profile'] is Map ? CameraProfile.fromJson(j['profile']) - : CameraProfile.alpr(), // fallback for legacy, more logic can be added + : CameraProfile.alpr(), attempts: j['attempts'] ?? 0, + error: j['error'] ?? false, ); } diff --git a/lib/screens/settings_screen_sections/queue_section.dart b/lib/screens/settings_screen_sections/queue_section.dart index 6d82405..393e405 100644 --- a/lib/screens/settings_screen_sections/queue_section.dart +++ b/lib/screens/settings_screen_sections/queue_section.dart @@ -71,22 +71,40 @@ class QueueSection extends StatelessWidget { itemBuilder: (context, index) { final upload = appState.pendingUploads[index]; return ListTile( - leading: const Icon(Icons.camera_alt), - title: Text('Camera ${index + 1}'), + leading: Icon( + upload.error ? Icons.error : Icons.camera_alt, + color: upload.error ? Colors.red : null, + ), + title: Text('Camera ${index + 1}${upload.error ? " (Error)" : ""}'), subtitle: Text( 'Lat: ${upload.coord.latitude.toStringAsFixed(6)}\n' 'Lon: ${upload.coord.longitude.toStringAsFixed(6)}\n' 'Direction: ${upload.direction.round()}°\n' - 'Attempts: ${upload.attempts}' + 'Attempts: ${upload.attempts}' + + (upload.error ? "\nUpload failed. Tap retry to try again." : "") ), - trailing: IconButton( - icon: const Icon(Icons.delete), - onPressed: () { - appState.removeFromQueue(upload); - if (appState.pendingCount == 0) { - Navigator.pop(context); - } - }, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (upload.error) + IconButton( + icon: const Icon(Icons.refresh), + color: Colors.orange, + tooltip: 'Retry upload', + onPressed: () { + appState.retryUpload(upload); + }, + ), + IconButton( + icon: const Icon(Icons.delete), + onPressed: () { + appState.removeFromQueue(upload); + if (appState.pendingCount == 0) { + Navigator.pop(context); + } + }, + ), + ], ), ); }, From 6331f68b2b248bce2de6c55e16ea883f684e8669 Mon Sep 17 00:00:00 2001 From: stopflock Date: Tue, 12 Aug 2025 21:20:57 -0500 Subject: [PATCH 2/4] builds, has camera cache --- lib/services/camera_cache.dart | 40 ++++++++ lib/widgets/camera_provider_with_cache.dart | 64 ++++++++++++ lib/widgets/map_view.dart | 105 +++++++++++++------- 3 files changed, 172 insertions(+), 37 deletions(-) create mode 100644 lib/services/camera_cache.dart create mode 100644 lib/widgets/camera_provider_with_cache.dart 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) From 5d6c5f7e108b2f625d35712ab0f0411ba1f65841 Mon Sep 17 00:00:00 2001 From: stopflock Date: Tue, 12 Aug 2025 21:29:51 -0500 Subject: [PATCH 3/4] fix repeated calls to overpass --- lib/widgets/map_view.dart | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index b618957..1d6dbd5 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -226,8 +226,8 @@ class _MapViewState extends State { final appState = context.watch(); final session = appState.session; - // Always update cameras on profile/mode change and map move - _refreshCamerasFromProvider(); + // Only update cameras when map moves or profiles/mode actually change (not every build!) + // _refreshCamerasFromProvider() is now only called from map movement and relevant change handlers. // Seed add‑mode target once, after first controller center is available. if (session != null && session.target == null) { @@ -295,7 +295,10 @@ class _MapViewState extends State { if (session != null) { appState.updateSession(target: pos.center); } - _debounce(_refreshCameras); + // Only request more cameras if the user navigated the map (and at valid zoom) + if (gesture && pos.zoom >= 10) { + _debounce(_refreshCamerasFromProvider); + } }, ), children: [ From 55b25a39c155030a36d73b91348f887a76e810eb Mon Sep 17 00:00:00 2001 From: stopflock Date: Tue, 12 Aug 2025 21:51:27 -0500 Subject: [PATCH 4/4] fix paging - overpass doesnt do that - just fetch all when we are fetching all --- .../cameras_from_overpass.dart | 38 ++----------------- 1 file changed, 4 insertions(+), 34 deletions(-) diff --git a/lib/services/map_data_submodules/cameras_from_overpass.dart b/lib/services/map_data_submodules/cameras_from_overpass.dart index d283398..489ef2c 100644 --- a/lib/services/map_data_submodules/cameras_from_overpass.dart +++ b/lib/services/map_data_submodules/cameras_from_overpass.dart @@ -30,12 +30,13 @@ Future> camerasFromOverpass({ // Helper for one Overpass chunk fetch Future> fetchChunk() async { + final outLine = fetchAllPages ? 'out body;' : 'out body $pageSize;'; final query = ''' [out:json][timeout:25]; ( $nodeClauses ); - out body $pageSize; + $outLine '''; try { print('[camerasFromOverpass] Querying Overpass...'); @@ -62,37 +63,6 @@ Future> camerasFromOverpass({ } } - if (!fetchAllPages) { - // Just one page - return await fetchChunk(); - } else { - // Fetch all possible data, paging with deduplication and backoff - final seenIds = {}; - final allCameras = []; - int page = 0; - while (true) { - page++; - List pageCameras = []; - int tries = 0; - while (tries < maxTries) { - try { - final cams = await fetchChunk(); - pageCameras = cams.where((c) => !seenIds.contains(c.id)).toList(); - break; - } catch (e) { - tries++; - final delayMs = 400 * (1 << tries); - print('[camerasFromOverpass][paged] Error on page $page try $tries: $e. Retrying in ${delayMs}ms.'); - await Future.delayed(Duration(milliseconds: delayMs)); - } - } - if (pageCameras.isEmpty) break; - print('[camerasFromOverpass][paged] Page $page: got ${pageCameras.length} new cameras.'); - allCameras.addAll(pageCameras); - seenIds.addAll(pageCameras.map((c) => c.id)); - if (pageCameras.length < pageSize) break; - } - print('[camerasFromOverpass][paged] DONE. Found ${allCameras.length} cameras for download.'); - return allCameras; - } + // All paths just use a single fetch now; paging logic no longer required. + return await fetchChunk(); }