feat(storage): Add local folders persistence and caching mechanism

This commit is contained in:
cogwheel0
2025-11-10 10:44:03 +05:30
parent 5f597a3bb5
commit 122bd0a4b1
5 changed files with 180 additions and 32 deletions

View File

@@ -19,25 +19,38 @@ sealed class Folder with _$Folder {
}) = _Folder; }) = _Folder;
factory Folder.fromJson(Map<String, dynamic> json) { factory Folder.fromJson(Map<String, dynamic> json) {
// Extract conversation IDs from items.chats if available List<String> extractConversationIds(dynamic source) {
final items = json['items'] as Map<String, dynamic>?; if (source is! List) {
final chats = items?['chats'] as List?; return const <String>[];
}
final ids = <String>[];
for (final entry in source) {
String value = '';
if (entry is String) {
value = entry;
} else if (entry is Map<String, dynamic>) {
final id = entry['id'];
if (id is String) {
value = id;
} else if (id != null) {
value = id.toString();
}
} else if (entry != null) {
value = entry.toString();
}
// Handle both string IDs and conversation objects if (value.isNotEmpty) {
final conversationIds = ids.add(value);
chats }
?.map((chat) { }
if (chat is String) { return ids;
return chat; }
} else if (chat is Map<String, dynamic>) {
return chat['id'] as String? ?? ''; final items = json['items'] as Map<String, dynamic>?;
} final chats = items?['chats'];
return ''; final explicitIds = extractConversationIds(json['conversation_ids']);
}) final implicitIds = extractConversationIds(chats);
.where((id) => id.isNotEmpty) final conversationIds = explicitIds.isNotEmpty ? explicitIds : implicitIds;
.toList()
.cast<String>() ??
<String>[];
// Handle Unix timestamp conversion // Handle Unix timestamp conversion
DateTime? parseTimestamp(dynamic timestamp) { DateTime? parseTimestamp(dynamic timestamp) {
@@ -67,3 +80,29 @@ sealed class Folder with _$Folder {
); );
} }
} }
extension FolderJsonExtension on Folder {
Map<String, dynamic> toJson() {
Map<String, dynamic>? normalizedItems;
if (items != null) {
normalizedItems = Map<String, dynamic>.from(items!);
} else if (conversationIds.isNotEmpty) {
normalizedItems = {'chats': List<String>.from(conversationIds)};
}
return {
'id': id,
'name': name,
if (parentId != null) 'parent_id': parentId,
if (userId != null) 'user_id': userId,
if (createdAt != null) 'created_at': createdAt!.toIso8601String(),
if (updatedAt != null) 'updated_at': updatedAt!.toIso8601String(),
'is_expanded': isExpanded,
if (normalizedItems != null) 'items': normalizedItems,
if (meta != null) 'meta': Map<String, dynamic>.from(meta!),
if (data != null) 'data': Map<String, dynamic>.from(data!),
if (conversationIds.isNotEmpty)
'conversation_ids': List<String>.from(conversationIds),
};
}
}

View File

@@ -15,6 +15,7 @@ final class HiveStoreKeys {
// Cache entries // Cache entries
static const String localConversations = 'local_conversations'; static const String localConversations = 'local_conversations';
static const String localFolders = 'local_folders';
static const String attachmentQueueEntries = 'attachment_queue_entries'; static const String attachmentQueueEntries = 'attachment_queue_entries';
static const String taskQueue = 'outbound_task_queue_v1'; static const String taskQueue = 'outbound_task_queue_v1';
} }

View File

@@ -107,7 +107,24 @@ class PersistenceMigrator {
} }
Future<void> _migrateCaches(SharedPreferences prefs) async { Future<void> _migrateCaches(SharedPreferences prefs) async {
final jsonString = prefs.getString(HiveStoreKeys.localConversations); await _migrateJsonListCache(
prefs,
HiveStoreKeys.localConversations,
logLabel: 'local conversations',
);
await _migrateJsonListCache(
prefs,
HiveStoreKeys.localFolders,
logLabel: 'local folders',
);
}
Future<void> _migrateJsonListCache(
SharedPreferences prefs,
String key, {
required String logLabel,
}) async {
final jsonString = prefs.getString(key);
if (jsonString == null || jsonString.isEmpty) { if (jsonString == null || jsonString.isEmpty) {
return; return;
} }
@@ -118,11 +135,11 @@ class PersistenceMigrator {
final list = decoded final list = decoded
.map((entry) => Map<String, dynamic>.from(entry as Map)) .map((entry) => Map<String, dynamic>.from(entry as Map))
.toList(growable: false); .toList(growable: false);
await _boxes.caches.put(HiveStoreKeys.localConversations, list); await _boxes.caches.put(key, list);
} }
} catch (error, stack) { } catch (error, stack) {
DebugLogger.error( DebugLogger.error(
'Failed to migrate local conversations', 'Failed to migrate $logLabel',
scope: 'persistence/migration', scope: 'persistence/migration',
error: error, error: error,
stackTrace: stack, stackTrace: stack,
@@ -206,6 +223,7 @@ class PersistenceMigrator {
PreferenceKeys.onboardingSeen, PreferenceKeys.onboardingSeen,
PreferenceKeys.reviewerMode, PreferenceKeys.reviewerMode,
HiveStoreKeys.localConversations, HiveStoreKeys.localConversations,
HiveStoreKeys.localFolders,
HiveStoreKeys.attachmentQueueEntries, HiveStoreKeys.attachmentQueueEntries,
LegacyPreferenceKeys.attachmentUploadQueue, LegacyPreferenceKeys.attachmentUploadQueue,
LegacyPreferenceKeys.taskQueue, LegacyPreferenceKeys.taskQueue,

View File

@@ -1801,24 +1801,53 @@ class Folders extends _$Folders {
Future<List<Folder>> build() async { Future<List<Folder>> build() async {
if (!ref.watch(isAuthenticatedProvider2)) { if (!ref.watch(isAuthenticatedProvider2)) {
DebugLogger.log('skip-unauthed', scope: 'folders'); DebugLogger.log('skip-unauthed', scope: 'folders');
_persistFoldersAsync(const []);
return const []; return const [];
} }
final storage = ref.watch(optimizedStorageServiceProvider);
final cached = await storage.getLocalFolders();
if (cached.isNotEmpty) {
DebugLogger.log(
'cache-restored',
scope: 'folders/cache',
data: {'count': cached.length},
);
Future.microtask(() async {
try {
await refresh();
} catch (error, stackTrace) {
DebugLogger.error(
'warm-refresh-failed',
scope: 'folders/cache',
error: error,
stackTrace: stackTrace,
);
}
});
return _sort(cached);
}
DebugLogger.log('cache-empty', scope: 'folders/cache');
final api = ref.watch(apiServiceProvider); final api = ref.watch(apiServiceProvider);
if (api == null) { if (api == null) {
DebugLogger.warning('api-missing', scope: 'folders'); DebugLogger.warning('api-missing', scope: 'folders');
return const []; return const [];
} }
return _load(api); final fresh = await _load(api);
return fresh;
} }
Future<void> refresh() async { Future<void> refresh() async {
if (!ref.read(isAuthenticatedProvider2)) { if (!ref.read(isAuthenticatedProvider2)) {
state = const AsyncData<List<Folder>>([]); state = const AsyncData<List<Folder>>([]);
_persistFoldersAsync(const []);
return; return;
} }
final api = ref.read(apiServiceProvider); final api = ref.read(apiServiceProvider);
if (api == null) { if (api == null) {
state = const AsyncData<List<Folder>>([]); state = const AsyncData<List<Folder>>([]);
_persistFoldersAsync(const []);
return; return;
} }
final result = await AsyncValue.guard(() => _load(api)); final result = await AsyncValue.guard(() => _load(api));
@@ -1835,7 +1864,9 @@ class Folders extends _$Folders {
} else { } else {
updated.add(folder); updated.add(folder);
} }
state = AsyncData<List<Folder>>(_sort(updated)); final sorted = _sort(updated);
state = AsyncData<List<Folder>>(sorted);
_persistFoldersAsync(sorted);
} }
void updateFolder(String id, Folder Function(Folder folder) transform) { void updateFolder(String id, Folder Function(Folder folder) transform) {
@@ -1845,7 +1876,9 @@ class Folders extends _$Folders {
if (index < 0) return; if (index < 0) return;
final updated = <Folder>[...current]; final updated = <Folder>[...current];
updated[index] = transform(updated[index]); updated[index] = transform(updated[index]);
state = AsyncData<List<Folder>>(_sort(updated)); final sorted = _sort(updated);
state = AsyncData<List<Folder>>(sorted);
_persistFoldersAsync(sorted);
} }
void removeFolder(String id) { void removeFolder(String id) {
@@ -1854,7 +1887,9 @@ class Folders extends _$Folders {
final updated = current final updated = current
.where((folder) => folder.id != id) .where((folder) => folder.id != id)
.toList(growable: true); .toList(growable: true);
state = AsyncData<List<Folder>>(_sort(updated)); final sorted = _sort(updated);
state = AsyncData<List<Folder>>(sorted);
_persistFoldersAsync(sorted);
} }
Future<List<Folder>> _load(ApiService api) async { Future<List<Folder>> _load(ApiService api) async {
@@ -1868,7 +1903,9 @@ class Folders extends _$Folders {
scope: 'folders', scope: 'folders',
data: {'count': folders.length}, data: {'count': folders.length},
); );
return _sort(folders); final sorted = _sort(folders);
_persistFoldersAsync(sorted);
return sorted;
} catch (e, stackTrace) { } catch (e, stackTrace) {
DebugLogger.error( DebugLogger.error(
'fetch-failed', 'fetch-failed',
@@ -1880,6 +1917,11 @@ class Folders extends _$Folders {
} }
} }
void _persistFoldersAsync(List<Folder> folders) {
final storage = ref.read(optimizedStorageServiceProvider);
unawaited(storage.saveLocalFolders(folders));
}
List<Folder> _sort(List<Folder> input) { List<Folder> _sort(List<Folder> input) {
final sorted = [...input]; final sorted = [...input];
sorted.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); sorted.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));

View File

@@ -4,6 +4,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:hive_ce/hive.dart'; import 'package:hive_ce/hive.dart';
import '../models/conversation.dart'; import '../models/conversation.dart';
import '../models/folder.dart';
import '../models/server_config.dart'; import '../models/server_config.dart';
import '../persistence/hive_boxes.dart'; import '../persistence/hive_boxes.dart';
import '../persistence/persistence_keys.dart'; import '../persistence/persistence_keys.dart';
@@ -40,6 +41,7 @@ class OptimizedStorageService {
static const String _themePaletteKey = PreferenceKeys.themePalette; static const String _themePaletteKey = PreferenceKeys.themePalette;
static const String _localeCodeKey = PreferenceKeys.localeCode; static const String _localeCodeKey = PreferenceKeys.localeCode;
static const String _localConversationsKey = HiveStoreKeys.localConversations; static const String _localConversationsKey = HiveStoreKeys.localConversations;
static const String _localFoldersKey = HiveStoreKeys.localFolders;
static const String _onboardingSeenKey = PreferenceKeys.onboardingSeen; static const String _onboardingSeenKey = PreferenceKeys.onboardingSeen;
static const String _reviewerModeKey = PreferenceKeys.reviewerMode; static const String _reviewerModeKey = PreferenceKeys.reviewerMode;
@@ -304,7 +306,7 @@ class OptimizedStorageService {
} }
final parsed = await _workerManager final parsed = await _workerManager
.schedule<Map<String, dynamic>, List<Map<String, dynamic>>>( .schedule<Map<String, dynamic>, List<Map<String, dynamic>>>(
_decodeStoredConversationsWorker, _decodeStoredJsonListWorker,
{'stored': stored}, {'stored': stored},
debugLabel: 'decode_local_conversations', debugLabel: 'decode_local_conversations',
); );
@@ -326,8 +328,8 @@ class OptimizedStorageService {
.map((conversation) => conversation.toJson()) .map((conversation) => conversation.toJson())
.toList(); .toList();
final serialized = await _workerManager final serialized = await _workerManager
.schedule<Map<String, dynamic>, String>(_encodeConversationsWorker, { .schedule<Map<String, dynamic>, String>(_encodeJsonListWorker, {
'conversations': jsonReady, 'items': jsonReady,
}, debugLabel: 'encode_local_conversations'); }, debugLabel: 'encode_local_conversations');
await _cachesBox.put(_localConversationsKey, serialized); await _cachesBox.put(_localConversationsKey, serialized);
DebugLogger.log( DebugLogger.log(
@@ -344,6 +346,52 @@ class OptimizedStorageService {
} }
} }
Future<List<Folder>> getLocalFolders() async {
try {
final stored = _cachesBox.get(_localFoldersKey);
if (stored == null) {
return const [];
}
final parsed = await _workerManager
.schedule<Map<String, dynamic>, List<Map<String, dynamic>>>(
_decodeStoredJsonListWorker,
{'stored': stored},
debugLabel: 'decode_local_folders',
);
return parsed.map(Folder.fromJson).toList(growable: false);
} catch (error, stack) {
DebugLogger.error(
'Failed to retrieve local folders',
scope: 'storage/optimized',
error: error,
stackTrace: stack,
);
return const [];
}
}
Future<void> saveLocalFolders(List<Folder> folders) async {
try {
final jsonReady = folders.map((folder) => folder.toJson()).toList();
final serialized = await _workerManager
.schedule<Map<String, dynamic>, String>(_encodeJsonListWorker, {
'items': jsonReady,
}, debugLabel: 'encode_local_folders');
await _cachesBox.put(_localFoldersKey, serialized);
DebugLogger.log(
'Saved ${folders.length} local folders',
scope: 'storage/optimized',
);
} catch (error, stack) {
DebugLogger.error(
'Failed to save local folders',
scope: 'storage/optimized',
error: error,
stackTrace: stack,
);
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Batch operations // Batch operations
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -458,7 +506,7 @@ class OptimizedStorageService {
} }
} }
List<Map<String, dynamic>> _decodeStoredConversationsWorker( List<Map<String, dynamic>> _decodeStoredJsonListWorker(
Map<String, dynamic> payload, Map<String, dynamic> payload,
) { ) {
final stored = payload['stored']; final stored = payload['stored'];
@@ -483,8 +531,8 @@ List<Map<String, dynamic>> _decodeStoredConversationsWorker(
return <Map<String, dynamic>>[]; return <Map<String, dynamic>>[];
} }
String _encodeConversationsWorker(Map<String, dynamic> payload) { String _encodeJsonListWorker(Map<String, dynamic> payload) {
final raw = payload['conversations']; final raw = payload['items'] ?? payload['conversations'];
if (raw is List) { if (raw is List) {
return jsonEncode(raw); return jsonEncode(raw);
} }