diff --git a/README.md b/README.md index b46dc67..995019f 100644 --- a/README.md +++ b/README.md @@ -88,11 +88,8 @@ flutter run ## Roadmap ### v1 todo/bug List -- Fix "tiles loaded" indicator accuracy across different providers -- Generic tile provider error messages (not always "OSM tiles slow") - Optional custom icons for camera profiles - Camera deletions -- Clean up cache when submitted changesets appear in Overpass results - Upgrade device marker design (considering nullplate's svg) ### Future Features & Wishlist diff --git a/lib/dev_config.dart b/lib/dev_config.dart index 9b1c74a..4b7e21b 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -34,7 +34,7 @@ const String kClientName = 'DeFlock'; const String kClientVersion = '0.9.11'; // Development/testing features - set to false for production builds -const bool kEnableDevelopmentModes = false; // Set to false to hide sandbox/simulate modes and force production mode +const bool kEnableDevelopmentModes = true; // Set to false to hide sandbox/simulate modes and force production mode // Marker/node interaction const int kCameraMinZoomLevel = 10; // Minimum zoom to show nodes or warning diff --git a/lib/models/pending_upload.dart b/lib/models/pending_upload.dart index 45159d6..2f3dda8 100644 --- a/lib/models/pending_upload.dart +++ b/lib/models/pending_upload.dart @@ -10,6 +10,7 @@ class PendingUpload { final OperatorProfile? operatorProfile; final UploadMode uploadMode; // Capture upload destination when queued final int? originalNodeId; // If this is an edit, the ID of the original OSM node + int? submittedNodeId; // The actual node ID returned by OSM after successful submission int attempts; bool error; bool completing; // True when upload succeeded but item is showing checkmark briefly @@ -21,12 +22,13 @@ class PendingUpload { this.operatorProfile, required this.uploadMode, this.originalNodeId, + this.submittedNodeId, this.attempts = 0, this.error = false, this.completing = false, }); - // True if this is an edit of an existing camera, false if it's a new camera + // True if this is an edit of an existing node, false if it's a new node bool get isEdit => originalNodeId != null; // Get display name for the upload destination @@ -41,11 +43,11 @@ class PendingUpload { } } - // Get combined tags from camera profile and operator profile + // Get combined tags from node profile and operator profile Map getCombinedTags() { final tags = Map.from(profile.tags); - // Add operator profile tags (they override camera profile tags if there are conflicts) + // Add operator profile tags (they override node profile tags if there are conflicts) if (operatorProfile != null) { tags.addAll(operatorProfile!.tags); } @@ -66,6 +68,7 @@ class PendingUpload { 'operatorProfile': operatorProfile?.toJson(), 'uploadMode': uploadMode.index, 'originalNodeId': originalNodeId, + 'submittedNodeId': submittedNodeId, 'attempts': attempts, 'error': error, 'completing': completing, @@ -84,6 +87,7 @@ class PendingUpload { ? UploadMode.values[j['uploadMode']] : UploadMode.production, // Default for legacy entries originalNodeId: j['originalNodeId'], + submittedNodeId: j['submittedNodeId'], attempts: j['attempts'] ?? 0, error: j['error'] ?? false, completing: j['completing'] ?? false, // Default to false for legacy entries diff --git a/lib/services/camera_cache.dart b/lib/services/camera_cache.dart deleted file mode 100644 index 0de43a5..0000000 --- a/lib/services/camera_cache.dart +++ /dev/null @@ -1,56 +0,0 @@ -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) { - final existing = _nodes[node.id]; - if (existing != null) { - // Preserve any tags starting with underscore when updating existing nodes - final mergedTags = Map.from(node.tags); - for (final entry in existing.tags.entries) { - if (entry.key.startsWith('_')) { - mergedTags[entry.key] = entry.value; - } - } - _nodes[node.id] = OsmCameraNode( - id: node.id, - coord: node.coord, - tags: mergedTags, - ); - } else { - _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/services/map_data_submodules/nodes_from_overpass.dart b/lib/services/map_data_submodules/nodes_from_overpass.dart index f6f8ac9..0437090 100644 --- a/lib/services/map_data_submodules/nodes_from_overpass.dart +++ b/lib/services/map_data_submodules/nodes_from_overpass.dart @@ -6,6 +6,7 @@ import 'package:flutter_map/flutter_map.dart'; import '../../models/node_profile.dart'; import '../../models/osm_camera_node.dart'; +import '../../models/pending_upload.dart'; import '../../app_state.dart'; import '../network_status.dart'; @@ -47,7 +48,7 @@ Future> fetchOverpassNodes({ NetworkStatus.instance.reportOverpassSuccess(); - return elements.whereType>().map((element) { + final nodes = elements.whereType>().map((element) { return OsmCameraNode( id: element['id'], coord: LatLng(element['lat'], element['lon']), @@ -55,6 +56,11 @@ Future> fetchOverpassNodes({ ); }).toList(); + // Clean up any pending uploads that now appear in Overpass results + _cleanupCompletedUploads(nodes); + + return nodes; + } catch (e) { debugPrint('[fetchOverpassNodes] Exception: $e'); @@ -92,4 +98,40 @@ String _buildOverpassQuery(LatLngBounds bounds, List profiles, int ); $outputClause '''; +} + +/// Clean up pending uploads that now appear in Overpass results +void _cleanupCompletedUploads(List overpassNodes) { + try { + final appState = AppState.instance; + final pendingUploads = appState.pendingUploads; + + if (pendingUploads.isEmpty) return; + + final overpassNodeIds = overpassNodes.map((n) => n.id).toSet(); + + // Find pending uploads whose submitted node IDs now appear in Overpass results + final uploadsToRemove = []; + + for (final upload in pendingUploads) { + if (upload.submittedNodeId != null && + overpassNodeIds.contains(upload.submittedNodeId!)) { + uploadsToRemove.add(upload); + debugPrint('[OverpassCleanup] Found submitted node ${upload.submittedNodeId} in Overpass results, removing from pending queue'); + } + } + + // Remove the completed uploads from the queue + for (final upload in uploadsToRemove) { + appState.removeFromQueue(upload); + } + + if (uploadsToRemove.isNotEmpty) { + debugPrint('[OverpassCleanup] Cleaned up ${uploadsToRemove.length} completed uploads'); + } + + } catch (e) { + debugPrint('[OverpassCleanup] Error during cleanup: $e'); + // Don't let cleanup errors break the main functionality + } } \ No newline at end of file diff --git a/lib/services/node_cache.dart b/lib/services/node_cache.dart new file mode 100644 index 0000000..3208365 --- /dev/null +++ b/lib/services/node_cache.dart @@ -0,0 +1,103 @@ +import 'package:latlong2/latlong.dart'; +import '../models/osm_camera_node.dart'; +import 'package:flutter_map/flutter_map.dart' show LatLngBounds; + +class NodeCache { + // Singleton instance + static final NodeCache instance = NodeCache._internal(); + factory NodeCache() => instance; + NodeCache._internal(); + + final Map _nodes = {}; + + /// Add or update a batch of nodes in the cache. + void addOrUpdate(List nodes) { + for (var node in nodes) { + final existing = _nodes[node.id]; + if (existing != null) { + // Preserve any tags starting with underscore when updating existing nodes + final mergedTags = Map.from(node.tags); + for (final entry in existing.tags.entries) { + if (entry.key.startsWith('_')) { + mergedTags[entry.key] = entry.value; + } + } + _nodes[node.id] = OsmCameraNode( + id: node.id, + coord: node.coord, + tags: mergedTags, + ); + } else { + _nodes[node.id] = node; + } + } + } + + /// Query for all cached nodes currently within the given LatLngBounds. + List queryByBounds(LatLngBounds bounds) { + return _nodes.values + .where((node) => _inBounds(node.coord, bounds)) + .toList(); + } + + /// Retrieve all cached nodes. + List getAll() => _nodes.values.toList(); + + /// Optionally clear the cache (rarely needed) + void clear() => _nodes.clear(); + + /// Remove the _pending_edit marker from a specific node + void removePendingEditMarker(int nodeId) { + final node = _nodes[nodeId]; + if (node != null && node.tags.containsKey('_pending_edit')) { + final cleanTags = Map.from(node.tags); + cleanTags.remove('_pending_edit'); + + _nodes[nodeId] = OsmCameraNode( + id: node.id, + coord: node.coord, + tags: cleanTags, + ); + } + } + + /// Remove temporary nodes (negative IDs) with _pending_upload marker at the given coordinate + /// This is used when a real node ID is assigned to clean up temp placeholders + void removeTempNodesByCoordinate(LatLng coord, {double tolerance = 0.00001}) { + final nodesToRemove = []; + + for (final entry in _nodes.entries) { + final nodeId = entry.key; + final node = entry.value; + + // Only consider temp nodes (negative IDs) with pending upload marker + if (nodeId < 0 && + node.tags.containsKey('_pending_upload') && + _coordsMatch(node.coord, coord, tolerance)) { + nodesToRemove.add(nodeId); + } + } + + for (final nodeId in nodesToRemove) { + _nodes.remove(nodeId); + } + + if (nodesToRemove.isNotEmpty) { + print('[NodeCache] Removed ${nodesToRemove.length} temp nodes at coordinate ${coord.latitude}, ${coord.longitude}'); + } + } + + /// Check if two coordinates match within tolerance + bool _coordsMatch(LatLng coord1, LatLng coord2, double tolerance) { + return (coord1.latitude - coord2.latitude).abs() < tolerance && + (coord1.longitude - coord2.longitude).abs() < tolerance; + } + + /// 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/services/uploader.dart b/lib/services/uploader.dart index 7beb6c4..fb6f192 100644 --- a/lib/services/uploader.dart +++ b/lib/services/uploader.dart @@ -9,7 +9,7 @@ class Uploader { Uploader(this.accessToken, this.onSuccess, {this.uploadMode = UploadMode.production}); final String accessToken; - final void Function() onSuccess; + final void Function(int nodeId) onSuccess; final UploadMode uploadMode; Future upload(PendingUpload p) async { @@ -99,7 +99,8 @@ class Uploader { print('Uploader: Close response: ${closeResp.statusCode}'); print('Uploader: Upload successful!'); - onSuccess(); + final nodeIdInt = int.parse(nodeId); + onSuccess(nodeIdInt); return true; } catch (e) { print('Uploader: Upload failed with error: $e'); diff --git a/lib/state/upload_queue_state.dart b/lib/state/upload_queue_state.dart index f96dd0b..d35d6d8 100644 --- a/lib/state/upload_queue_state.dart +++ b/lib/state/upload_queue_state.dart @@ -5,7 +5,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../models/pending_upload.dart'; import '../models/osm_camera_node.dart'; -import '../services/camera_cache.dart'; +import '../services/node_cache.dart'; import '../services/uploader.dart'; import '../widgets/camera_provider_with_cache.dart'; import 'settings_state.dart'; @@ -37,7 +37,7 @@ class UploadQueueState extends ChangeNotifier { _queue.add(upload); _saveQueue(); - // Add to camera cache immediately so it shows on the map + // Add to node cache immediately so it shows on the map // Create a temporary node with a negative ID (to distinguish from real OSM nodes) // Using timestamp as negative ID to ensure uniqueness final tempId = -DateTime.now().millisecondsSinceEpoch; @@ -50,8 +50,8 @@ class UploadQueueState extends ChangeNotifier { tags: tags, ); - CameraCache.instance.addOrUpdate([tempNode]); - // Notify camera provider to update the map + NodeCache.instance.addOrUpdate([tempNode]); + // Notify node provider to update the map CameraProviderWithCache.instance.notifyListeners(); notifyListeners(); @@ -73,7 +73,7 @@ class UploadQueueState extends ChangeNotifier { // Create two cache entries: - // 1. Mark the original camera with _pending_edit (grey ring) at original location + // 1. Mark the original node with _pending_edit (grey ring) at original location final originalTags = Map.from(session.originalNode.tags); originalTags['_pending_edit'] = 'true'; // Mark original as having pending edit @@ -83,7 +83,7 @@ class UploadQueueState extends ChangeNotifier { tags: originalTags, ); - // 2. Create new temp node for the edited camera (purple ring) at new location + // 2. Create new temp node for the edited node (purple ring) at new location final tempId = -DateTime.now().millisecondsSinceEpoch; final editedTags = upload.getCombinedTags(); editedTags['_pending_upload'] = 'true'; // Mark as pending upload @@ -95,8 +95,8 @@ class UploadQueueState extends ChangeNotifier { tags: editedTags, ); - CameraCache.instance.addOrUpdate([originalNode, editedNode]); - // Notify camera provider to update the map + NodeCache.instance.addOrUpdate([originalNode, editedNode]); + // Notify node provider to update the map CameraProviderWithCache.instance.notifyListeners(); notifyListeners(); @@ -153,19 +153,16 @@ class UploadQueueState extends ChangeNotifier { debugPrint('[UploadQueue] Simulating upload (no real API call)'); await Future.delayed(const Duration(seconds: 1)); // Simulate network delay ok = true; + // Simulate a node ID for simulate mode + _markAsCompleting(item, simulatedNodeId: DateTime.now().millisecondsSinceEpoch); } else { // Real upload -- use the upload mode that was saved when this item was queued debugPrint('[UploadQueue] Real upload to: ${item.uploadMode}'); - final up = Uploader(access, () { - _markAsCompleting(item); + final up = Uploader(access, (nodeId) { + _markAsCompleting(item, submittedNodeId: nodeId); }, uploadMode: item.uploadMode); ok = await up.upload(item); } - - if (ok && item.uploadMode == UploadMode.simulate) { - // Mark as completing for simulate mode too - _markAsCompleting(item); - } if (!ok) { item.attempts++; if (item.attempts >= 3) { @@ -186,8 +183,22 @@ class UploadQueueState extends ChangeNotifier { } // Mark an item as completing (shows checkmark) and schedule removal after 1 second - void _markAsCompleting(PendingUpload item) { + void _markAsCompleting(PendingUpload item, {int? submittedNodeId, int? simulatedNodeId}) { item.completing = true; + + // Store the submitted node ID for cleanup purposes + if (submittedNodeId != null) { + item.submittedNodeId = submittedNodeId; + debugPrint('[UploadQueue] Upload successful, OSM assigned node ID: $submittedNodeId'); + + // Update cache with real node ID instead of temp ID + _updateCacheWithRealNodeId(item, submittedNodeId); + } else if (simulatedNodeId != null && item.uploadMode == UploadMode.simulate) { + // For simulate mode, use a fake but positive ID + item.submittedNodeId = simulatedNodeId; + debugPrint('[UploadQueue] Simulated upload, fake node ID: $simulatedNodeId'); + } + _saveQueue(); notifyListeners(); @@ -198,6 +209,34 @@ class UploadQueueState extends ChangeNotifier { notifyListeners(); }); } + + // Update the cache to use the real OSM node ID instead of temporary ID + void _updateCacheWithRealNodeId(PendingUpload item, int realNodeId) { + // Create the node with real ID and clean tags (remove temp markers) + final tags = item.getCombinedTags(); + + final realNode = OsmCameraNode( + id: realNodeId, + coord: item.coord, + tags: tags, // Clean tags without _pending_upload markers + ); + + // Add/update the cache with the real node + NodeCache.instance.addOrUpdate([realNode]); + + // Clean up any temp nodes at the same coordinate + NodeCache.instance.removeTempNodesByCoordinate(item.coord); + + // For edits, also clean up the original node's _pending_edit marker + if (item.isEdit && item.originalNodeId != null) { + // Remove the _pending_edit marker from the original node in cache + // The next Overpass fetch will provide the authoritative data anyway + NodeCache.instance.removePendingEditMarker(item.originalNodeId!); + } + + // Notify node provider to update the map + CameraProviderWithCache.instance.notifyListeners(); + } // ---------- Queue persistence ---------- Future _saveQueue() async { diff --git a/lib/widgets/camera_provider_with_cache.dart b/lib/widgets/camera_provider_with_cache.dart index b733556..6e3ce12 100644 --- a/lib/widgets/camera_provider_with_cache.dart +++ b/lib/widgets/camera_provider_with_cache.dart @@ -4,13 +4,13 @@ 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 '../services/node_cache.dart'; import '../services/network_status.dart'; import '../models/node_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 +/// Provides surveillance nodes 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(); @@ -21,16 +21,16 @@ class CameraProviderWithCache extends ChangeNotifier { /// Call this to get (quickly) all cached overlays for the given view. /// Filters by currently enabled profiles. - List getCachedCamerasForBounds(LatLngBounds bounds) { - final allCameras = CameraCache.instance.queryByBounds(bounds); + List getCachedNodesForBounds(LatLngBounds bounds) { + final allNodes = NodeCache.instance.queryByBounds(bounds); final enabledProfiles = AppState.instance.enabledProfiles; - // If no profiles are enabled, show no cameras + // If no profiles are enabled, show no nodes if (enabledProfiles.isEmpty) return []; - // Filter cameras to only show those matching enabled profiles - return allCameras.where((camera) { - return _matchesAnyProfile(camera, enabledProfiles); + // Filter nodes to only show those matching enabled profiles + return allNodes.where((node) { + return _matchesAnyProfile(node, enabledProfiles); }).toList(); } @@ -55,13 +55,13 @@ class CameraProviderWithCache extends ChangeNotifier { source: MapSource.auto, ); if (fresh.isNotEmpty) { - CameraCache.instance.addOrUpdate(fresh); - // Clear waiting status when camera data arrives + NodeCache.instance.addOrUpdate(fresh); + // Clear waiting status when node data arrives NetworkStatus.instance.clearWaiting(); notifyListeners(); } } catch (e) { - debugPrint('[CameraProviderWithCache] Camera fetch failed: $e'); + debugPrint('[CameraProviderWithCache] Node fetch failed: $e'); // Cache already holds whatever is available for the view } }); @@ -69,7 +69,7 @@ class CameraProviderWithCache extends ChangeNotifier { /// Optionally: clear the cache (could be used for testing/dev) void clearCache() { - CameraCache.instance.clear(); + NodeCache.instance.clear(); notifyListeners(); } @@ -78,18 +78,18 @@ class CameraProviderWithCache extends ChangeNotifier { notifyListeners(); } - /// Check if a camera matches any of the provided profiles - bool _matchesAnyProfile(OsmCameraNode camera, List profiles) { + /// Check if a node matches any of the provided profiles + bool _matchesAnyProfile(OsmCameraNode node, List profiles) { for (final profile in profiles) { - if (_cameraMatchesProfile(camera, profile)) return true; + if (_nodeMatchesProfile(node, profile)) return true; } return false; } - /// Check if a camera matches a specific profile (all profile tags must match) - bool _cameraMatchesProfile(OsmCameraNode camera, NodeProfile profile) { + /// Check if a node matches a specific profile (all profile tags must match) + bool _nodeMatchesProfile(OsmCameraNode node, NodeProfile profile) { for (final entry in profile.tags.entries) { - if (camera.tags[entry.key] != entry.value) return false; + if (node.tags[entry.key] != entry.value) return false; } return true; } diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index 8c896a9..b2b911d 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -228,7 +228,7 @@ class MapViewState extends State { mapBounds = null; } final cameras = (mapBounds != null) - ? cameraProvider.getCachedCamerasForBounds(mapBounds) + ? cameraProvider.getCachedNodesForBounds(mapBounds) : []; final markers = CameraMarkersBuilder.buildCameraMarkers(