Repopulate node cache from pending

This commit is contained in:
stopflock
2025-12-02 19:16:33 -06:00
parent d902495312
commit 5043ef3e34
17 changed files with 335 additions and 54 deletions
+6
View File
@@ -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();
+6 -2
View File
@@ -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",
+6 -2
View File
@@ -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",
+6 -2
View File
@@ -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",
+6 -2
View File
@@ -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",
+6 -2
View File
@@ -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",
+6 -2
View File
@@ -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",
+6 -2
View File
@@ -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": "瓦片提供商",
+4
View File
@@ -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
@@ -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
)
);
}
},
),
],
),
),
],
+43
View File
@@ -93,6 +93,9 @@ class UploadQueueScreen extends StatelessWidget {
final locService = LocalizationService.instance;
final appState = context.watch<AppState>();
// 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,
+21 -5
View File
@@ -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<OsmNode> findNodesWithinDistance(LatLng coord, double distanceMeters, {int? excludeNodeId}) {
final nearbyNodes = <OsmNode>[];
@@ -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;
}
+125 -9
View File
@@ -21,9 +21,100 @@ class UploadQueueState extends ChangeNotifier {
int get pendingCount => _queue.length;
List<PendingUpload> get pendingUploads => List.unmodifiable(_queue);
// Initialize by loading queue from storage
// Initialize by loading queue from storage and repopulate cache with pending nodes
Future<void> 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 = <OsmNode>[];
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<String, String>.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<String, String>.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();
+22 -1
View File
@@ -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();