Deletions!

This commit is contained in:
stopflock
2025-09-28 21:44:28 -05:00
parent c8a8d4c81f
commit a05abd8bd8
13 changed files with 272 additions and 63 deletions
+5
View File
@@ -216,6 +216,11 @@ class AppState extends ChangeNotifier {
}
}
void deleteNode(OsmCameraNode node) {
_uploadQueueState.addFromNodeDeletion(node, uploadMode: uploadMode);
_startUploader();
}
// ---------- Settings Methods ----------
Future<void> setOfflineMode(bool enabled) async {
await _settingsState.setOfflineMode(enabled);
+1
View File
@@ -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
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+18 -3
View File
@@ -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,
+7
View File
@@ -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
+104 -44
View File
@@ -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 = '''
<osm>
<changeset>
@@ -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) =>
'<tag k="${e.key}" v="${e.value}"/>').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 = '''
<osm>
<node changeset="$csId" id="${p.originalNodeId}" version="$currentVersion" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
$tagsXml
</node>
</osm>''';
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) =>
'<tag k="${e.key}" v="${e.value}"/>').join('\n ');
final nodeXml = '''
<osm>
<node changeset="$csId" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
$tagsXml
</node>
</osm>''';
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) =>
'<tag k="${e.key}" v="${e.value}"/>').join('\n ');
final nodeXml = '''
<osm>
<node changeset="$csId" id="${p.originalNodeId}" version="$currentVersion" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
$tagsXml
</node>
</osm>''';
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 = '''
<osm>
<node changeset="$csId" id="${p.originalNodeId}" version="$currentVersion" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
</node>
</osm>''';
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<http.Response> _delete(String path, String body) => http.delete(
Uri.https(_host, path),
headers: _headers,
body: body,
);
Map<String, String> get _headers => {
'Authorization': 'Bearer $accessToken',
'Content-Type': 'text/xml',
};
}
extension StringExtension on String {
String capitalize() {
return "${this[0].toUpperCase()}${substring(1)}";
}
}
+59 -4
View File
@@ -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<String, String>.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<void> _saveQueue() async {
final prefs = await SharedPreferences.getInstance();
+8 -5
View File
@@ -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;
}
}
+5 -1
View File
@@ -51,9 +51,13 @@ class _CameraMapMarkerState extends State<CameraMapMarker> {
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;
+45 -2
View File
@@ -17,17 +17,50 @@ class NodeTagSheet extends StatelessWidget {
final appState = context.watch<AppState>();
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<bool>(
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(