From a05abd8bd8f996f7f75cff269513babe1e48f473 Mon Sep 17 00:00:00 2001 From: stopflock Date: Sun, 28 Sep 2025 21:44:28 -0500 Subject: [PATCH] Deletions! --- lib/app_state.dart | 5 + lib/dev_config.dart | 1 + lib/localizations/de.json | 6 +- lib/localizations/en.json | 6 +- lib/localizations/es.json | 6 +- lib/localizations/fr.json | 6 +- lib/models/pending_upload.dart | 21 +++- lib/services/node_cache.dart | 7 ++ lib/services/uploader.dart | 148 +++++++++++++++++++--------- lib/state/upload_queue_state.dart | 63 +++++++++++- lib/widgets/camera_icon.dart | 13 ++- lib/widgets/map/camera_markers.dart | 6 +- lib/widgets/node_tag_sheet.dart | 47 ++++++++- 13 files changed, 272 insertions(+), 63 deletions(-) diff --git a/lib/app_state.dart b/lib/app_state.dart index 5649c03..cec1b47 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -216,6 +216,11 @@ class AppState extends ChangeNotifier { } } + void deleteNode(OsmCameraNode node) { + _uploadQueueState.addFromNodeDeletion(node, uploadMode: uploadMode); + _startUploader(); + } + // ---------- Settings Methods ---------- Future setOfflineMode(bool enabled) async { await _settingsState.setOfflineMode(enabled); diff --git a/lib/dev_config.dart b/lib/dev_config.dart index 9b1c74a..16fd4df 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -76,3 +76,4 @@ const Color kCameraRingColorMock = Color(0xC4FFFFFF); // Add node mock point - w const Color kCameraRingColorPending = Color(0xC49C27B0); // Submitted/pending nodes - purple const Color kCameraRingColorEditing = Color(0xC4FF9800); // Node being edited - orange const Color kCameraRingColorPendingEdit = Color(0xC4757575); // Original node with pending edit - grey +const Color kCameraRingColorPendingDeletion = Color(0xA4F44336); // Node pending deletion - red, slightly transparent diff --git a/lib/localizations/de.json b/lib/localizations/de.json index e005ad3..4a39dfd 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -10,6 +10,7 @@ "download": "Herunterladen", "settings": "Einstellungen", "edit": "Bearbeiten", + "delete": "Löschen", "cancel": "Abbrechen", "ok": "OK", "close": "Schließen", @@ -41,7 +42,10 @@ "title": "Knoten #{}", "tagSheetTitle": "Gerät-Tags", "queuedForUpload": "Knoten zum Upload eingereiht", - "editQueuedForUpload": "Knotenbearbeitung zum Upload eingereiht" + "editQueuedForUpload": "Knotenbearbeitung zum Upload eingereiht", + "deleteQueuedForUpload": "Knoten-Löschung zum Upload eingereiht", + "confirmDeleteTitle": "Knoten löschen", + "confirmDeleteMessage": "Sind Sie sicher, dass Sie Knoten #{} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden." }, "addNode": { "profile": "Profil", diff --git a/lib/localizations/en.json b/lib/localizations/en.json index 4b18296..3f0409f 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -10,6 +10,7 @@ "download": "Download", "settings": "Settings", "edit": "Edit", + "delete": "Delete", "cancel": "Cancel", "ok": "OK", "close": "Close", @@ -41,7 +42,10 @@ "title": "Node #{}", "tagSheetTitle": "Surveillance Device Tags", "queuedForUpload": "Node queued for upload", - "editQueuedForUpload": "Node edit queued for upload" + "editQueuedForUpload": "Node edit queued for upload", + "deleteQueuedForUpload": "Node deletion queued for upload", + "confirmDeleteTitle": "Delete Node", + "confirmDeleteMessage": "Are you sure you want to delete node #{}? This action cannot be undone." }, "addNode": { "profile": "Profile", diff --git a/lib/localizations/es.json b/lib/localizations/es.json index 0cb29e8..38d2eae 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -10,6 +10,7 @@ "download": "Descargar", "settings": "Configuración", "edit": "Editar", + "delete": "Eliminar", "cancel": "Cancelar", "ok": "Aceptar", "close": "Cerrar", @@ -41,7 +42,10 @@ "title": "Nodo #{}", "tagSheetTitle": "Etiquetas del Dispositivo", "queuedForUpload": "Nodo en cola para subir", - "editQueuedForUpload": "Edición de nodo en cola para subir" + "editQueuedForUpload": "Edición de nodo en cola para subir", + "deleteQueuedForUpload": "Eliminación de nodo en cola para subir", + "confirmDeleteTitle": "Eliminar Nodo", + "confirmDeleteMessage": "¿Estás seguro de que quieres eliminar el nodo #{}? Esta acción no se puede deshacer." }, "addNode": { "profile": "Perfil", diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index 4016921..209c921 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -10,6 +10,7 @@ "download": "Télécharger", "settings": "Paramètres", "edit": "Modifier", + "delete": "Supprimer", "cancel": "Annuler", "ok": "OK", "close": "Fermer", @@ -41,7 +42,10 @@ "title": "Nœud #{}", "tagSheetTitle": "Balises du Dispositif", "queuedForUpload": "Nœud mis en file pour envoi", - "editQueuedForUpload": "Modification de nœud mise en file pour envoi" + "editQueuedForUpload": "Modification de nœud mise en file pour envoi", + "deleteQueuedForUpload": "Suppression de nœud mise en file pour envoi", + "confirmDeleteTitle": "Supprimer le Nœud", + "confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer le nœud #{} ? Cette action ne peut pas être annulée." }, "addNode": { "profile": "Profil", diff --git a/lib/models/pending_upload.dart b/lib/models/pending_upload.dart index 2f3dda8..776f05e 100644 --- a/lib/models/pending_upload.dart +++ b/lib/models/pending_upload.dart @@ -3,13 +3,16 @@ import 'node_profile.dart'; import 'operator_profile.dart'; import '../state/settings_state.dart'; +enum UploadOperation { create, modify, delete } + class PendingUpload { final LatLng coord; final double direction; final NodeProfile profile; 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 + final UploadOperation operation; // Type of operation: create, modify, or delete + final int? originalNodeId; // If this is modify/delete, the ID of the original OSM node int? submittedNodeId; // The actual node ID returned by OSM after successful submission int attempts; bool error; @@ -21,15 +24,23 @@ class PendingUpload { required this.profile, this.operatorProfile, required this.uploadMode, + required this.operation, this.originalNodeId, this.submittedNodeId, this.attempts = 0, this.error = false, this.completing = false, - }); + }) : assert( + (operation == UploadOperation.create && originalNodeId == null) || + (operation != UploadOperation.create && originalNodeId != null), + 'originalNodeId must be null for create operations and non-null for modify/delete operations' + ); // True if this is an edit of an existing node, false if it's a new node - bool get isEdit => originalNodeId != null; + bool get isEdit => operation == UploadOperation.modify; + + // True if this is a deletion of an existing node + bool get isDeletion => operation == UploadOperation.delete; // Get display name for the upload destination String get uploadModeDisplayName { @@ -67,6 +78,7 @@ class PendingUpload { 'profile': profile.toJson(), 'operatorProfile': operatorProfile?.toJson(), 'uploadMode': uploadMode.index, + 'operation': operation.index, 'originalNodeId': originalNodeId, 'submittedNodeId': submittedNodeId, 'attempts': attempts, @@ -86,6 +98,9 @@ class PendingUpload { uploadMode: j['uploadMode'] != null ? UploadMode.values[j['uploadMode']] : UploadMode.production, // Default for legacy entries + operation: j['operation'] != null + ? UploadOperation.values[j['operation']] + : (j['originalNodeId'] != null ? UploadOperation.modify : UploadOperation.create), // Legacy compatibility originalNodeId: j['originalNodeId'], submittedNodeId: j['submittedNodeId'], attempts: j['attempts'] ?? 0, diff --git a/lib/services/node_cache.dart b/lib/services/node_cache.dart index 3208365..5aae931 100644 --- a/lib/services/node_cache.dart +++ b/lib/services/node_cache.dart @@ -60,6 +60,13 @@ class NodeCache { ); } } + + /// Remove a node by ID from the cache (used for successful deletions) + void removeNodeById(int nodeId) { + if (_nodes.remove(nodeId) != null) { + print('[NodeCache] Removed node $nodeId from cache (successful deletion)'); + } + } /// 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 diff --git a/lib/services/uploader.dart b/lib/services/uploader.dart index fb6f192..59f61da 100644 --- a/lib/services/uploader.dart +++ b/lib/services/uploader.dart @@ -17,7 +17,18 @@ class Uploader { print('Uploader: Starting upload for node at ${p.coord.latitude}, ${p.coord.longitude}'); // 1. open changeset - final action = p.isEdit ? 'Update' : 'Add'; + String action; + switch (p.operation) { + case UploadOperation.create: + action = 'Add'; + break; + case UploadOperation.modify: + action = 'Update'; + break; + case UploadOperation.delete: + action = 'Delete'; + break; + } final csXml = ''' @@ -35,63 +46,100 @@ class Uploader { final csId = csResp.body.trim(); print('Uploader: Created changeset ID: $csId'); - // 2. create or update node - final mergedTags = p.getCombinedTags(); - final tagsXml = mergedTags.entries.map((e) => - '').join('\n '); - + // 2. create, update, or delete node final http.Response nodeResp; final String nodeId; - if (p.isEdit) { - // First, fetch the current node to get its version - print('Uploader: Fetching current node ${p.originalNodeId} to get version...'); - final currentNodeResp = await _get('/api/0.6/node/${p.originalNodeId}'); - print('Uploader: Current node response: ${currentNodeResp.statusCode}'); - if (currentNodeResp.statusCode != 200) { - print('Uploader: Failed to fetch current node'); - return false; - } - - // Parse version from the response XML - final currentNodeXml = currentNodeResp.body; - final versionMatch = RegExp(r'version="(\d+)"').firstMatch(currentNodeXml); - if (versionMatch == null) { - print('Uploader: Could not parse version from current node XML'); - return false; - } - final currentVersion = versionMatch.group(1)!; - print('Uploader: Current node version: $currentVersion'); - - // Update existing node with version - final nodeXml = ''' - - - $tagsXml - - '''; - print('Uploader: Updating node ${p.originalNodeId}...'); - nodeResp = await _put('/api/0.6/node/${p.originalNodeId}', nodeXml); - nodeId = p.originalNodeId.toString(); - } else { - // Create new node - final nodeXml = ''' + switch (p.operation) { + case UploadOperation.create: + // Create new node + final mergedTags = p.getCombinedTags(); + final tagsXml = mergedTags.entries.map((e) => + '').join('\n '); + final nodeXml = ''' $tagsXml '''; - print('Uploader: Creating new node...'); - nodeResp = await _put('/api/0.6/node/create', nodeXml); - nodeId = nodeResp.body.trim(); + print('Uploader: Creating new node...'); + nodeResp = await _put('/api/0.6/node/create', nodeXml); + nodeId = nodeResp.body.trim(); + break; + + case UploadOperation.modify: + // First, fetch the current node to get its version + print('Uploader: Fetching current node ${p.originalNodeId} to get version...'); + final currentNodeResp = await _get('/api/0.6/node/${p.originalNodeId}'); + print('Uploader: Current node response: ${currentNodeResp.statusCode}'); + if (currentNodeResp.statusCode != 200) { + print('Uploader: Failed to fetch current node'); + return false; + } + + // Parse version from the response XML + final currentNodeXml = currentNodeResp.body; + final versionMatch = RegExp(r'version="(\d+)"').firstMatch(currentNodeXml); + if (versionMatch == null) { + print('Uploader: Could not parse version from current node XML'); + return false; + } + final currentVersion = versionMatch.group(1)!; + print('Uploader: Current node version: $currentVersion'); + + // Update existing node with version + final mergedTags = p.getCombinedTags(); + final tagsXml = mergedTags.entries.map((e) => + '').join('\n '); + final nodeXml = ''' + + + $tagsXml + + '''; + print('Uploader: Updating node ${p.originalNodeId}...'); + nodeResp = await _put('/api/0.6/node/${p.originalNodeId}', nodeXml); + nodeId = p.originalNodeId.toString(); + break; + + case UploadOperation.delete: + // First, fetch the current node to get its version and coordinates + print('Uploader: Fetching current node ${p.originalNodeId} for deletion...'); + final currentNodeResp = await _get('/api/0.6/node/${p.originalNodeId}'); + print('Uploader: Current node response: ${currentNodeResp.statusCode}'); + if (currentNodeResp.statusCode != 200) { + print('Uploader: Failed to fetch current node'); + return false; + } + + // Parse version and tags from the response XML + final currentNodeXml = currentNodeResp.body; + final versionMatch = RegExp(r'version="(\d+)"').firstMatch(currentNodeXml); + if (versionMatch == null) { + print('Uploader: Could not parse version from current node XML'); + return false; + } + final currentVersion = versionMatch.group(1)!; + print('Uploader: Current node version: $currentVersion'); + + // Delete node - OSM requires current tags and coordinates + final nodeXml = ''' + + + + '''; + print('Uploader: Deleting node ${p.originalNodeId}...'); + nodeResp = await _delete('/api/0.6/node/${p.originalNodeId}', nodeXml); + nodeId = p.originalNodeId.toString(); + break; } print('Uploader: Node response: ${nodeResp.statusCode} - ${nodeResp.body}'); if (nodeResp.statusCode != 200) { - print('Uploader: Failed to ${p.isEdit ? "update" : "create"} node'); + print('Uploader: Failed to ${p.operation.name} node'); return false; } - print('Uploader: ${p.isEdit ? "Updated" : "Created"} node ID: $nodeId'); + print('Uploader: ${p.operation.name.capitalize()} node ID: $nodeId'); // 3. close changeset print('Uploader: Closing changeset...'); @@ -135,9 +183,21 @@ class Uploader { body: body, ); + Future _delete(String path, String body) => http.delete( + Uri.https(_host, path), + headers: _headers, + body: body, + ); + Map get _headers => { 'Authorization': 'Bearer $accessToken', 'Content-Type': 'text/xml', }; } +extension StringExtension on String { + String capitalize() { + return "${this[0].toUpperCase()}${substring(1)}"; + } +} + diff --git a/lib/state/upload_queue_state.dart b/lib/state/upload_queue_state.dart index d35d6d8..9700488 100644 --- a/lib/state/upload_queue_state.dart +++ b/lib/state/upload_queue_state.dart @@ -5,6 +5,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../models/pending_upload.dart'; import '../models/osm_camera_node.dart'; +import '../models/node_profile.dart'; import '../services/node_cache.dart'; import '../services/uploader.dart'; import '../widgets/camera_provider_with_cache.dart'; @@ -32,6 +33,7 @@ class UploadQueueState extends ChangeNotifier { profile: session.profile, operatorProfile: session.operatorProfile, uploadMode: uploadMode, + operation: UploadOperation.create, ); _queue.add(upload); @@ -65,6 +67,7 @@ class UploadQueueState extends ChangeNotifier { profile: session.profile, operatorProfile: session.operatorProfile, uploadMode: uploadMode, + operation: UploadOperation.modify, originalNodeId: session.originalNode.id, // Track which node we're editing ); @@ -102,6 +105,37 @@ class UploadQueueState extends ChangeNotifier { notifyListeners(); } + // Add a node deletion to the upload queue + void addFromNodeDeletion(OsmCameraNode node, {required UploadMode uploadMode}) { + final upload = PendingUpload( + coord: node.coord, + direction: node.directionDeg ?? 0, // Use existing direction or default to 0 + profile: NodeProfile.genericAlpr(), // Dummy profile - not used for deletions + uploadMode: uploadMode, + operation: UploadOperation.delete, + originalNodeId: node.id, + ); + + _queue.add(upload); + _saveQueue(); + + // Mark the original node as pending deletion in the cache + final deletionTags = Map.from(node.tags); + deletionTags['_pending_deletion'] = 'true'; + + final nodeWithDeletionTag = OsmCameraNode( + id: node.id, + coord: node.coord, + tags: deletionTags, + ); + + NodeCache.instance.addOrUpdate([nodeWithDeletionTag]); + // Notify node provider to update the map + CameraProviderWithCache.instance.notifyListeners(); + + notifyListeners(); + } + void clearQueue() { _queue.clear(); _saveQueue(); @@ -189,14 +223,24 @@ class UploadQueueState extends ChangeNotifier { // 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); + if (item.isDeletion) { + debugPrint('[UploadQueue] Deletion successful, removing node ID: $submittedNodeId from cache'); + _handleSuccessfulDeletion(item); + } else { + 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'); + if (item.isDeletion) { + debugPrint('[UploadQueue] Simulated deletion, removing fake node ID: $simulatedNodeId from cache'); + _handleSuccessfulDeletion(item); + } else { + debugPrint('[UploadQueue] Simulated upload, fake node ID: $simulatedNodeId'); + } } _saveQueue(); @@ -238,6 +282,17 @@ class UploadQueueState extends ChangeNotifier { CameraProviderWithCache.instance.notifyListeners(); } + // Handle successful deletion by removing the node from cache + void _handleSuccessfulDeletion(PendingUpload item) { + if (item.originalNodeId != null) { + // Remove the node from cache entirely + NodeCache.instance.removeNodeById(item.originalNodeId!); + + // Notify node provider to update the map + CameraProviderWithCache.instance.notifyListeners(); + } + } + // ---------- Queue persistence ---------- Future _saveQueue() async { final prefs = await SharedPreferences.getInstance(); diff --git a/lib/widgets/camera_icon.dart b/lib/widgets/camera_icon.dart index d3c4d64..33b425b 100644 --- a/lib/widgets/camera_icon.dart +++ b/lib/widgets/camera_icon.dart @@ -2,11 +2,12 @@ import 'package:flutter/material.dart'; import '../dev_config.dart'; enum CameraIconType { - real, // Blue ring - real cameras from OSM - mock, // White ring - add camera mock point - pending, // Purple ring - submitted/pending cameras - editing, // Orange ring - camera being edited - pendingEdit, // Grey ring - original camera with pending edit + real, // Blue ring - real cameras from OSM + mock, // White ring - add camera mock point + pending, // Purple ring - submitted/pending cameras + editing, // Orange ring - camera being edited + pendingEdit, // Grey ring - original camera with pending edit + pendingDeletion, // Red ring - camera pending deletion } /// Simple camera icon with grey dot and colored ring @@ -27,6 +28,8 @@ class CameraIcon extends StatelessWidget { return kCameraRingColorEditing; case CameraIconType.pendingEdit: return kCameraRingColorPendingEdit; + case CameraIconType.pendingDeletion: + return kCameraRingColorPendingDeletion; } } diff --git a/lib/widgets/map/camera_markers.dart b/lib/widgets/map/camera_markers.dart index 48eefab..8de43fa 100644 --- a/lib/widgets/map/camera_markers.dart +++ b/lib/widgets/map/camera_markers.dart @@ -51,9 +51,13 @@ class _CameraMapMarkerState extends State { widget.node.tags['_pending_upload'] == 'true'; final isPendingEdit = widget.node.tags.containsKey('_pending_edit') && widget.node.tags['_pending_edit'] == 'true'; + final isPendingDeletion = widget.node.tags.containsKey('_pending_deletion') && + widget.node.tags['_pending_deletion'] == 'true'; CameraIconType iconType; - if (isPendingUpload) { + if (isPendingDeletion) { + iconType = CameraIconType.pendingDeletion; + } else if (isPendingUpload) { iconType = CameraIconType.pending; } else if (isPendingEdit) { iconType = CameraIconType.pendingEdit; diff --git a/lib/widgets/node_tag_sheet.dart b/lib/widgets/node_tag_sheet.dart index b4f163b..32a0cd5 100644 --- a/lib/widgets/node_tag_sheet.dart +++ b/lib/widgets/node_tag_sheet.dart @@ -17,17 +17,50 @@ class NodeTagSheet extends StatelessWidget { final appState = context.watch(); final locService = LocalizationService.instance; - // Check if this device is editable (not a pending upload or pending edit) + // Check if this device is editable (not a pending upload, pending edit, or pending deletion) final isEditable = (!node.tags.containsKey('_pending_upload') || node.tags['_pending_upload'] != 'true') && (!node.tags.containsKey('_pending_edit') || - node.tags['_pending_edit'] != 'true'); + node.tags['_pending_edit'] != 'true') && + (!node.tags.containsKey('_pending_deletion') || + node.tags['_pending_deletion'] != 'true'); void _openEditSheet() { Navigator.pop(context); // Close this sheet first appState.startEditSession(node); // HomeScreen will auto-show the edit sheet } + void _deleteNode() async { + final shouldDelete = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text(locService.t('node.confirmDeleteTitle')), + content: Text(locService.t('node.confirmDeleteMessage', params: [node.id.toString()])), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(locService.cancel), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(locService.t('actions.delete')), + ), + ], + ); + }, + ); + + if (shouldDelete == true && context.mounted) { + Navigator.pop(context); // Close this sheet first + appState.deleteNode(node); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(locService.t('node.deleteQueuedForUpload'))), + ); + } + } + return SafeArea( child: Padding( padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20), @@ -81,6 +114,16 @@ class NodeTagSheet extends StatelessWidget { minimumSize: const Size(0, 36), ), ), + const SizedBox(width: 8), + ElevatedButton.icon( + onPressed: _deleteNode, + icon: const Icon(Icons.delete, size: 18), + label: Text(locService.t('actions.delete')), + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 36), + foregroundColor: Colors.red, + ), + ), const SizedBox(width: 12), ], TextButton(