From 5043ef3e34abdcd71861290c0ce105ae24da6629 Mon Sep 17 00:00:00 2001 From: stopflock Date: Tue, 2 Dec 2025 19:16:33 -0600 Subject: [PATCH] Repopulate node cache from pending --- DEVELOPER.md | 6 + README.md | 1 - assets/changelog.json | 8 +- lib/app_state.dart | 6 + lib/localizations/de.json | 8 +- lib/localizations/en.json | 8 +- lib/localizations/es.json | 8 +- lib/localizations/fr.json | 8 +- lib/localizations/it.json | 8 +- lib/localizations/pt.json | 8 +- lib/localizations/zh.json | 8 +- lib/models/pending_upload.dart | 4 + .../sections/upload_mode_section.dart | 82 ++++++++--- lib/screens/upload_queue_screen.dart | 43 ++++++ lib/services/node_cache.dart | 26 +++- lib/state/upload_queue_state.dart | 134 ++++++++++++++++-- lib/widgets/camera_provider_with_cache.dart | 23 ++- 17 files changed, 335 insertions(+), 54 deletions(-) diff --git a/DEVELOPER.md b/DEVELOPER.md index b513c25..f631e58 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -227,6 +227,12 @@ The previous implementation conflated changeset creation + node operation as one **Why immediate visual feedback:** Users expect instant response to their actions. By immediately updating the cache with temporary markers (e.g., `_pending_deletion`), the UI stays responsive while the actual API calls happen in background. +**Queue persistence & cache synchronization (v1.5.4+):** +- **Startup repopulation**: Queue initialization now repopulates cache with pending nodes, ensuring visual continuity after app restarts +- **Specific node cleanup**: Each upload stores a `tempNodeId` for precise removal, preventing accidental cleanup of other pending nodes at the same location +- **Proximity awareness**: Proximity warnings now consider pending nodes to prevent duplicate submissions at the same location +- **Processing status UI**: Upload queue screen shows clear indicators when processing is paused due to offline mode or user settings + ### 4. Cache & Visual States **Node visual states:** diff --git a/README.md b/README.md index 259f41b..3f1768b 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,6 @@ cp lib/keys.dart.example lib/keys.dart - Manual cleanup (cognitive load for users) - Delete the old one (also wrong answer unless user chooses intentionally) - Give multiple of these options?? -- Persistent cache for MY submissions: assume submissions worked, cache,clean up when we see that node appear in overpass/OSM results or when older than 24h - Dropdown on "refine tags" page to select acceptable options for camera:mount= (is this a boolean property of a profile?) - Option to pull in profiles from NSI (man_made=surveillance only?) diff --git a/assets/changelog.json b/assets/changelog.json index 45edb12..5e5ec64 100644 --- a/assets/changelog.json +++ b/assets/changelog.json @@ -2,9 +2,13 @@ "1.5.4": { "content": [ "• OSM message notifications - dot appears on Settings button and OSM Account section when you have unread messages on OpenStreetMap", - "• Download area max zoom level is now limited to the currently selected tile provider's maximum zoom level", + "• Download area max zoom level is now limited to the currently selected tile provider's maximum zoom level", "• Navigation route planning now prevents selecting start and end locations that are too close together", - "• Cleaned up internal 'maxCameras' references to use 'maxNodes' terminology consistently" + "• Cleaned up internal 'maxCameras' references to use 'maxNodes' terminology consistently", + "• FIXED: Proximity warnings now consider pending nodes - prevents submitting multiple nodes at the same location without warning", + "• FIXED: Deleting queue items now only removes that specific item, not all pending nodes at the same location", + "• FIXED: Pending nodes now reappear on the map after app restart - queue items repopulate the visual cache on startup", + "• NEW: Upload queue screen shows when processing is paused (offline mode or manually paused)" ] }, "1.5.3": { diff --git a/lib/app_state.dart b/lib/app_state.dart index c45dce7..8b8d11f 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -18,6 +18,7 @@ import 'services/node_cache.dart'; import 'services/tile_preview_service.dart'; import 'services/changelog_service.dart'; import 'services/operator_profile_service.dart'; +import 'widgets/camera_provider_with_cache.dart'; import 'services/profile_service.dart'; import 'widgets/proximity_warning_dialog.dart'; import 'widgets/reauth_messages_dialog.dart'; @@ -212,6 +213,11 @@ class AppState extends ChangeNotifier { await _uploadQueueState.init(); await _authState.init(_settingsState.uploadMode); + // Set up callback to repopulate pending nodes after cache clears + CameraProviderWithCache.instance.setOnCacheClearedCallback(() { + _uploadQueueState.repopulateCacheFromQueue(); + }); + // Check for messages on app launch if user is already logged in if (isLoggedIn) { checkMessages(); diff --git a/lib/localizations/de.json b/lib/localizations/de.json index 4c7a328..ef5ed0c 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -152,7 +152,8 @@ "simulate": "Simulieren", "productionDescription": "Hochladen in die Live-OSM-Datenbank (für alle Benutzer sichtbar)", "sandboxDescription": "Uploads gehen an die OSM Sandbox (sicher zum Testen, wird regelmäßig zurückgesetzt).", - "simulateDescription": "Uploads simulieren (kontaktiert OSM-Server nicht)" + "simulateDescription": "Uploads simulieren (kontaktiert OSM-Server nicht)", + "cannotChangeWithQueue": "Upload-Ziel kann nicht geändert werden, während {} Elemente in der Warteschlange sind. Warteschlange zuerst leeren." }, "auth": { "osmAccountTitle": "OpenStreetMap-Konto", @@ -220,7 +221,10 @@ "errorDetails": "Fehlerdetails", "creatingChangeset": " (Changeset erstellen...)", "uploading": " (Uploading...)", - "closingChangeset": " (Changeset schließen...)" + "closingChangeset": " (Changeset schließen...)", + "processingPaused": "Warteschlangenverarbeitung pausiert", + "pausedDueToOffline": "Upload-Verarbeitung ist pausiert, da der Offline-Modus aktiviert ist.", + "pausedByUser": "Upload-Verarbeitung ist manuell pausiert." }, "tileProviders": { "title": "Kachel-Anbieter", diff --git a/lib/localizations/en.json b/lib/localizations/en.json index ae559e8..794227a 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -184,7 +184,8 @@ "simulate": "Simulate", "productionDescription": "Upload to the live OSM database (visible to all users)", "sandboxDescription": "Uploads go to the OSM Sandbox (safe for testing, resets regularly).", - "simulateDescription": "Simulate uploads (does not contact OSM servers)" + "simulateDescription": "Simulate uploads (does not contact OSM servers)", + "cannotChangeWithQueue": "Cannot change upload destination while {} items are in queue. Clear queue first." }, "auth": { "osmAccountTitle": "OpenStreetMap Account", @@ -252,7 +253,10 @@ "errorDetails": "Error Details", "creatingChangeset": " (Creating changeset...)", "uploading": " (Uploading...)", - "closingChangeset": " (Closing changeset...)" + "closingChangeset": " (Closing changeset...)", + "processingPaused": "Queue Processing Paused", + "pausedDueToOffline": "Upload processing is paused because offline mode is enabled.", + "pausedByUser": "Upload processing is manually paused." }, "tileProviders": { "title": "Tile Providers", diff --git a/lib/localizations/es.json b/lib/localizations/es.json index 5f26008..56c7be0 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -184,7 +184,8 @@ "simulate": "Simular", "productionDescription": "Subir a la base de datos OSM en vivo (visible para todos los usuarios)", "sandboxDescription": "Las subidas van al Sandbox de OSM (seguro para pruebas, se reinicia regularmente).", - "simulateDescription": "Simular subidas (no contacta servidores OSM)" + "simulateDescription": "Simular subidas (no contacta servidores OSM)", + "cannotChangeWithQueue": "No se puede cambiar el destino de subida mientras hay {} elementos en cola. Limpie la cola primero." }, "auth": { "osmAccountTitle": "Cuenta de OpenStreetMap", @@ -252,7 +253,10 @@ "errorDetails": "Detalles del Error", "creatingChangeset": " (Creando changeset...)", "uploading": " (Subiendo...)", - "closingChangeset": " (Cerrando changeset...)" + "closingChangeset": " (Cerrando changeset...)", + "processingPaused": "Procesamiento de Cola Pausado", + "pausedDueToOffline": "El procesamiento de subida está pausado porque el modo sin conexión está habilitado.", + "pausedByUser": "El procesamiento de subida está pausado manualmente." }, "tileProviders": { "title": "Proveedores de Tiles", diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index bc77643..1439eee 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -184,7 +184,8 @@ "simulate": "Simuler", "productionDescription": "Télécharger vers la base de données OSM en direct (visible pour tous les utilisateurs)", "sandboxDescription": "Les téléchargements vont vers le Sandbox OSM (sûr pour les tests, réinitialisé régulièrement).", - "simulateDescription": "Simuler les téléchargements (ne contacte pas les serveurs OSM)" + "simulateDescription": "Simuler les téléchargements (ne contacte pas les serveurs OSM)", + "cannotChangeWithQueue": "Impossible de changer la destination de téléversement tant que {} éléments sont en file d'attente. Videz d'abord la file d'attente." }, "auth": { "osmAccountTitle": "Compte OpenStreetMap", @@ -252,7 +253,10 @@ "errorDetails": "Détails de l'Erreur", "creatingChangeset": " (Création du changeset...)", "uploading": " (Téléchargement...)", - "closingChangeset": " (Fermeture du changeset...)" + "closingChangeset": " (Fermeture du changeset...)", + "processingPaused": "Traitement de la File d'Attente Interrompu", + "pausedDueToOffline": "Le traitement des téléversements est interrompu car le mode hors ligne est activé.", + "pausedByUser": "Le traitement des téléversements est interrompu manuellement." }, "tileProviders": { "title": "Fournisseurs de Tuiles", diff --git a/lib/localizations/it.json b/lib/localizations/it.json index 017ff97..cb7c154 100644 --- a/lib/localizations/it.json +++ b/lib/localizations/it.json @@ -184,7 +184,8 @@ "simulate": "Simula", "productionDescription": "Carica nel database OSM dal vivo (visibile a tutti gli utenti)", "sandboxDescription": "Gli upload vanno alla Sandbox OSM (sicuro per i test, si resetta regolarmente).", - "simulateDescription": "Simula upload (non contatta i server OSM)" + "simulateDescription": "Simula upload (non contatta i server OSM)", + "cannotChangeWithQueue": "Impossibile cambiare la destinazione di upload mentre ci sono {} elementi in coda. Svuota prima la coda." }, "auth": { "osmAccountTitle": "Account OpenStreetMap", @@ -252,7 +253,10 @@ "errorDetails": "Dettagli dell'Errore", "creatingChangeset": " (Creazione changeset...)", "uploading": " (Caricamento...)", - "closingChangeset": " (Chiusura changeset...)" + "closingChangeset": " (Chiusura changeset...)", + "processingPaused": "Elaborazione Coda Sospesa", + "pausedDueToOffline": "L'elaborazione dei caricamenti è sospesa perché la modalità offline è abilitata.", + "pausedByUser": "L'elaborazione dei caricamenti è sospesa manualmente." }, "tileProviders": { "title": "Fornitori di Tile", diff --git a/lib/localizations/pt.json b/lib/localizations/pt.json index cea6692..76e5765 100644 --- a/lib/localizations/pt.json +++ b/lib/localizations/pt.json @@ -184,7 +184,8 @@ "simulate": "Simular", "productionDescription": "Enviar para o banco de dados OSM ao vivo (visível para todos os usuários)", "sandboxDescription": "Uploads vão para o Sandbox OSM (seguro para testes, redefine regularmente).", - "simulateDescription": "Simular uploads (não contacta servidores OSM)" + "simulateDescription": "Simular uploads (não contacta servidores OSM)", + "cannotChangeWithQueue": "Não é possível alterar o destino de upload enquanto {} itens estão na fila. Limpe a fila primeiro." }, "auth": { "osmAccountTitle": "Conta OpenStreetMap", @@ -252,7 +253,10 @@ "errorDetails": "Detalhes do Erro", "creatingChangeset": " (Criando changeset...)", "uploading": " (Enviando...)", - "closingChangeset": " (Fechando changeset...)" + "closingChangeset": " (Fechando changeset...)", + "processingPaused": "Processamento da Fila Pausado", + "pausedDueToOffline": "O processamento de upload está pausado porque o modo offline está habilitado.", + "pausedByUser": "O processamento de upload está pausado manualmente." }, "tileProviders": { "title": "Provedores de Tiles", diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json index d9b1ddc..aa0a3c5 100644 --- a/lib/localizations/zh.json +++ b/lib/localizations/zh.json @@ -184,7 +184,8 @@ "simulate": "模拟", "productionDescription": "上传到实时 OSM 数据库(对所有用户可见)", "sandboxDescription": "上传到 OSM 沙盒(测试安全,定期重置)。", - "simulateDescription": "模拟上传(不联系 OSM 服务器)" + "simulateDescription": "模拟上传(不联系 OSM 服务器)", + "cannotChangeWithQueue": "队列中有 {} 个项目时无法更改上传目标。请先清空队列。" }, "auth": { "osmAccountTitle": "OpenStreetMap 账户", @@ -252,7 +253,10 @@ "errorDetails": "错误详情", "creatingChangeset": " (创建变更集...)", "uploading": " (上传中...)", - "closingChangeset": " (关闭变更集...)" + "closingChangeset": " (关闭变更集...)", + "processingPaused": "队列处理已暂停", + "pausedDueToOffline": "因为离线模式已启用,上传处理已暂停。", + "pausedByUser": "上传处理已手动暂停。" }, "tileProviders": { "title": "瓦片提供商", diff --git a/lib/models/pending_upload.dart b/lib/models/pending_upload.dart index 50b0f90..d25307f 100644 --- a/lib/models/pending_upload.dart +++ b/lib/models/pending_upload.dart @@ -25,6 +25,7 @@ class PendingUpload { 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? tempNodeId; // ID of temporary node created in cache (for specific cleanup) int attempts; bool error; // DEPRECATED: Use uploadState instead String? errorMessage; // Detailed error message for debugging @@ -46,6 +47,7 @@ class PendingUpload { required this.operation, this.originalNodeId, this.submittedNodeId, + this.tempNodeId, this.attempts = 0, this.error = false, this.errorMessage, @@ -255,6 +257,7 @@ class PendingUpload { 'operation': operation.index, 'originalNodeId': originalNodeId, 'submittedNodeId': submittedNodeId, + 'tempNodeId': tempNodeId, 'attempts': attempts, 'error': error, 'errorMessage': errorMessage, @@ -285,6 +288,7 @@ class PendingUpload { : (j['originalNodeId'] != null ? UploadOperation.modify : UploadOperation.create), // Legacy compatibility originalNodeId: j['originalNodeId'], submittedNodeId: j['submittedNodeId'], + tempNodeId: j['tempNodeId'], attempts: j['attempts'] ?? 0, error: j['error'] ?? false, errorMessage: j['errorMessage'], // Can be null for legacy entries diff --git a/lib/screens/settings/sections/upload_mode_section.dart b/lib/screens/settings/sections/upload_mode_section.dart index ae4c192..6875596 100644 --- a/lib/screens/settings/sections/upload_mode_section.dart +++ b/lib/screens/settings/sections/upload_mode_section.dart @@ -37,7 +37,7 @@ class UploadModeSection extends StatelessWidget { child: Text(locService.t('uploadMode.simulate')), ), ], - onChanged: (mode) { + onChanged: appState.pendingCount > 0 ? null : (mode) { if (mode != null) { appState.setUploadMode(mode); // Check if re-authentication is needed after mode change @@ -50,27 +50,65 @@ class UploadModeSection extends StatelessWidget { ), Padding( padding: const EdgeInsets.only(left: 56, top: 2, right: 16, bottom: 12), - child: Builder( - builder: (context) { - switch (appState.uploadMode) { - case UploadMode.production: - return Text( - locService.t('uploadMode.productionDescription'), - style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7)) - ); - case UploadMode.sandbox: - return Text( - locService.t('uploadMode.sandboxDescription'), - style: const TextStyle(fontSize: 12, color: Colors.orange), - ); - case UploadMode.simulate: - default: - return Text( - locService.t('uploadMode.simulateDescription'), - style: const TextStyle(fontSize: 12, color: Colors.deepPurple) - ); - } - }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Upload mode restriction message when queue has items + if (appState.pendingCount > 0) ...[ + Row( + children: [ + const Icon(Icons.info_outline, color: Colors.orange, size: 16), + const SizedBox(width: 6), + Expanded( + child: Text( + locService.t('uploadMode.cannotChangeWithQueue', params: [appState.pendingCount.toString()]), + style: const TextStyle(fontSize: 12, color: Colors.orange), + ), + ), + ], + ), + const SizedBox(height: 6), + ], + + // Normal upload mode description + Builder( + builder: (context) { + switch (appState.uploadMode) { + case UploadMode.production: + return Text( + locService.t('uploadMode.productionDescription'), + style: TextStyle( + fontSize: 12, + color: appState.pendingCount > 0 + ? Theme.of(context).disabledColor + : Theme.of(context).colorScheme.onSurface.withOpacity(0.7) + ) + ); + case UploadMode.sandbox: + return Text( + locService.t('uploadMode.sandboxDescription'), + style: TextStyle( + fontSize: 12, + color: appState.pendingCount > 0 + ? Theme.of(context).disabledColor + : Colors.orange + ), + ); + case UploadMode.simulate: + default: + return Text( + locService.t('uploadMode.simulateDescription'), + style: TextStyle( + fontSize: 12, + color: appState.pendingCount > 0 + ? Theme.of(context).disabledColor + : Colors.deepPurple + ) + ); + } + }, + ), + ], ), ), ], diff --git a/lib/screens/upload_queue_screen.dart b/lib/screens/upload_queue_screen.dart index 986894f..2433d46 100644 --- a/lib/screens/upload_queue_screen.dart +++ b/lib/screens/upload_queue_screen.dart @@ -93,6 +93,9 @@ class UploadQueueScreen extends StatelessWidget { final locService = LocalizationService.instance; final appState = context.watch(); + // Check if queue processing is paused + final isQueuePaused = appState.offlineMode || appState.pauseQueueProcessing; + return Scaffold( appBar: AppBar( title: Text(locService.t('queue.title')), @@ -105,6 +108,46 @@ class UploadQueueScreen extends StatelessWidget { 16 + MediaQuery.of(context).padding.bottom, ), children: [ + // Queue processing status indicator + if (isQueuePaused && appState.pendingCount > 0) + Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.orange.withOpacity(0.1), + border: Border.all(color: Colors.orange.withOpacity(0.3)), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon(Icons.pause_circle_outline, color: Colors.orange), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + locService.t('queue.processingPaused'), + style: const TextStyle( + fontWeight: FontWeight.bold, + color: Colors.orange, + ), + ), + Text( + appState.offlineMode + ? locService.t('queue.pausedDueToOffline') + : locService.t('queue.pausedByUser'), + style: TextStyle( + fontSize: 12, + color: Colors.orange.shade700, + ), + ), + ], + ), + ), + ], + ), + ), // Clear Upload Queue button - always visible SizedBox( width: double.infinity, diff --git a/lib/services/node_cache.dart b/lib/services/node_cache.dart index 334287e..2f72cfe 100644 --- a/lib/services/node_cache.dart +++ b/lib/services/node_cache.dart @@ -114,6 +114,23 @@ class NodeCache { print('[NodeCache] Removed ${nodesToRemove.length} temp nodes at coordinate ${coord.latitude}, ${coord.longitude}'); } } + + /// Remove a specific temporary node by its ID (for queue item-specific cleanup) + void removeTempNodeById(int tempNodeId) { + if (tempNodeId >= 0) { + print('[NodeCache] Warning: Attempted to remove non-temp node ID $tempNodeId'); + return; + } + + if (_nodes.remove(tempNodeId) != null) { + print('[NodeCache] Removed specific temp node $tempNodeId from cache'); + } + } + + /// Get a specific node by ID (returns null if not found) + OsmNode? getNodeById(int nodeId) { + return _nodes[nodeId]; + } /// Check if two coordinates match within tolerance bool _coordsMatch(LatLng coord1, LatLng coord2, double tolerance) { @@ -123,6 +140,7 @@ class NodeCache { /// Find nodes within the specified distance (in meters) of the given coordinate /// Excludes nodes with the excludeNodeId (useful when checking proximity for edited nodes) + /// Includes pending nodes to warn about potential duplicates List findNodesWithinDistance(LatLng coord, double distanceMeters, {int? excludeNodeId}) { final nearbyNodes = []; @@ -132,11 +150,9 @@ class NodeCache { continue; } - // Skip temporary nodes (negative IDs) with pending upload/edit/deletion markers - if (node.id < 0 && ( - node.tags.containsKey('_pending_upload') || - node.tags.containsKey('_pending_edit') || - node.tags.containsKey('_pending_deletion'))) { + // Include all nodes (real and pending) to catch potential duplicates + // Only skip nodes marked for deletion since they won't actually exist after processing + if (node.tags.containsKey('_pending_deletion')) { continue; } diff --git a/lib/state/upload_queue_state.dart b/lib/state/upload_queue_state.dart index 5ae823e..541bd16 100644 --- a/lib/state/upload_queue_state.dart +++ b/lib/state/upload_queue_state.dart @@ -21,9 +21,100 @@ class UploadQueueState extends ChangeNotifier { int get pendingCount => _queue.length; List get pendingUploads => List.unmodifiable(_queue); - // Initialize by loading queue from storage + // Initialize by loading queue from storage and repopulate cache with pending nodes Future init() async { await _loadQueue(); + print('[UploadQueue] Loaded ${_queue.length} items from storage'); + _repopulateCacheFromQueue(); + } + + // Repopulate the cache with pending nodes from the queue on startup + void _repopulateCacheFromQueue() { + print('[UploadQueue] Repopulating cache from ${_queue.length} queue items'); + final nodesToAdd = []; + + for (final upload in _queue) { + // Skip completed uploads - they should already be in OSM and will be fetched normally + if (upload.isComplete) { + print('[UploadQueue] Skipping completed upload at ${upload.coord}'); + continue; + } + + print('[UploadQueue] Processing ${upload.operation} upload at ${upload.coord}'); + + if (upload.isDeletion) { + // For deletions: mark the original node as pending deletion if it exists in cache + if (upload.originalNodeId != null) { + final existingNode = NodeCache.instance.getNodeById(upload.originalNodeId!); + if (existingNode != null) { + final deletionTags = Map.from(existingNode.tags); + deletionTags['_pending_deletion'] = 'true'; + + final nodeWithDeletionTag = OsmNode( + id: upload.originalNodeId!, + coord: existingNode.coord, + tags: deletionTags, + ); + nodesToAdd.add(nodeWithDeletionTag); + } + } + } else { + // For creates, edits, and extracts: recreate temp node if needed + // Generate new temp ID if not already stored (for backward compatibility) + final tempId = upload.tempNodeId ?? -DateTime.now().millisecondsSinceEpoch - _queue.indexOf(upload); + + final tags = upload.getCombinedTags(); + tags['_pending_upload'] = 'true'; + tags['_temp_id'] = tempId.toString(); + + // Store temp ID for future cleanup if not already set + if (upload.tempNodeId == null) { + upload.tempNodeId = tempId; + } + + if (upload.isEdit) { + // For edits: also mark original with _pending_edit if it exists + if (upload.originalNodeId != null) { + final existingOriginal = NodeCache.instance.getNodeById(upload.originalNodeId!); + if (existingOriginal != null) { + final originalTags = Map.from(existingOriginal.tags); + originalTags['_pending_edit'] = 'true'; + + final originalWithEdit = OsmNode( + id: upload.originalNodeId!, + coord: existingOriginal.coord, + tags: originalTags, + ); + nodesToAdd.add(originalWithEdit); + } + } + + // Add connection line marker + tags['_original_node_id'] = upload.originalNodeId.toString(); + } else if (upload.operation == UploadOperation.extract) { + // For extracts: add connection line marker + tags['_original_node_id'] = upload.originalNodeId.toString(); + } + + final tempNode = OsmNode( + id: tempId, + coord: upload.coord, + tags: tags, + ); + nodesToAdd.add(tempNode); + } + } + + if (nodesToAdd.isNotEmpty) { + NodeCache.instance.addOrUpdate(nodesToAdd); + print('[UploadQueue] Repopulated cache with ${nodesToAdd.length} pending nodes from queue'); + + // Save queue if we updated any temp IDs for backward compatibility + _saveQueue(); + + // Notify node provider to update the map + CameraProviderWithCache.instance.notifyListeners(); + } } // Add a completed session to the upload queue @@ -46,6 +137,10 @@ class UploadQueueState extends ChangeNotifier { final tempId = -DateTime.now().millisecondsSinceEpoch; final tags = upload.getCombinedTags(); tags['_pending_upload'] = 'true'; // Mark as pending for potential UI distinction + tags['_temp_id'] = tempId.toString(); // Store temp ID for specific removal + + // Store the temp ID in the upload for cleanup purposes + upload.tempNodeId = tempId; final tempNode = OsmNode( id: tempId, @@ -100,6 +195,10 @@ class UploadQueueState extends ChangeNotifier { final extractedTags = upload.getCombinedTags(); extractedTags['_pending_upload'] = 'true'; // Mark as pending upload extractedTags['_original_node_id'] = session.originalNode.id.toString(); // Track original for line drawing + extractedTags['_temp_id'] = tempId.toString(); // Store temp ID for specific removal + + // Store the temp ID in the upload for cleanup purposes + upload.tempNodeId = tempId; final extractedNode = OsmNode( id: tempId, @@ -125,6 +224,10 @@ class UploadQueueState extends ChangeNotifier { final editedTags = upload.getCombinedTags(); editedTags['_pending_upload'] = 'true'; // Mark as pending upload editedTags['_original_node_id'] = session.originalNode.id.toString(); // Track original for line drawing + editedTags['_temp_id'] = tempId.toString(); // Store temp ID for specific removal + + // Store the temp ID in the upload for cleanup purposes + upload.tempNodeId = tempId; final editedNode = OsmNode( id: tempId, @@ -548,8 +651,10 @@ class UploadQueueState extends ChangeNotifier { // 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); + // Clean up the specific temp node for this upload + if (item.tempNodeId != null) { + NodeCache.instance.removeTempNodeById(item.tempNodeId!); + } // For modify operations, clean up the original node's _pending_edit marker // For extract operations, we don't modify the original node so leave it unchanged @@ -609,17 +714,23 @@ class UploadQueueState extends ChangeNotifier { NodeCache.instance.removePendingDeletionMarker(upload.originalNodeId!); } } else if (upload.isEdit) { - // For edits: remove both the temp node and the _pending_edit marker from original - NodeCache.instance.removeTempNodesByCoordinate(upload.coord); + // For edits: remove the specific temp node and the _pending_edit marker from original + if (upload.tempNodeId != null) { + NodeCache.instance.removeTempNodeById(upload.tempNodeId!); + } if (upload.originalNodeId != null) { NodeCache.instance.removePendingEditMarker(upload.originalNodeId!); } } else if (upload.operation == UploadOperation.extract) { - // For extracts: remove the temp node (leave original unchanged) - NodeCache.instance.removeTempNodesByCoordinate(upload.coord); + // For extracts: remove the specific temp node (leave original unchanged) + if (upload.tempNodeId != null) { + NodeCache.instance.removeTempNodeById(upload.tempNodeId!); + } } else { - // For creates: remove the temp node - NodeCache.instance.removeTempNodesByCoordinate(upload.coord); + // For creates: remove the specific temp node + if (upload.tempNodeId != null) { + NodeCache.instance.removeTempNodeById(upload.tempNodeId!); + } } } @@ -646,6 +757,11 @@ class UploadQueueState extends ChangeNotifier { notifyListeners(); } + // Public method to manually trigger cache repopulation (useful for debugging or after cache clears) + void repopulateCacheFromQueue() { + _repopulateCacheFromQueue(); + } + @override void dispose() { _uploadTimer?.cancel(); diff --git a/lib/widgets/camera_provider_with_cache.dart b/lib/widgets/camera_provider_with_cache.dart index f733cb4..07bcef1 100644 --- a/lib/widgets/camera_provider_with_cache.dart +++ b/lib/widgets/camera_provider_with_cache.dart @@ -67,12 +67,33 @@ class CameraProviderWithCache extends ChangeNotifier { }); } - /// Optionally: clear the cache (could be used for testing/dev) + /// Clear the cache and repopulate with pending nodes from upload queue void clearCache() { NodeCache.instance.clear(); + // Repopulate with pending nodes from upload queue if available + _repopulatePendingNodesAfterClear(); notifyListeners(); } + /// Repopulate pending nodes after cache clear + void _repopulatePendingNodesAfterClear() { + // We need access to the upload queue state, but we don't have direct access here + // Instead, we'll trigger a callback that the app state can handle + // For now, let's use a more direct approach through a global service access + // This could be refactored to use proper dependency injection later + Future.microtask(() { + // This will be called from app state when cache clears happen + _onCacheCleared?.call(); + }); + } + + VoidCallback? _onCacheCleared; + + /// Set callback for when cache is cleared (used by app state to repopulate pending nodes) + void setOnCacheClearedCallback(VoidCallback? callback) { + _onCacheCleared = callback; + } + /// Force refresh the display (useful when filters change but cache doesn't) void refreshDisplay() { notifyListeners();