feat(storage): Add local folders persistence and caching mechanism
This commit is contained in:
@@ -19,25 +19,38 @@ sealed class Folder with _$Folder {
|
||||
}) = _Folder;
|
||||
|
||||
factory Folder.fromJson(Map<String, dynamic> json) {
|
||||
// Extract conversation IDs from items.chats if available
|
||||
final items = json['items'] as Map<String, dynamic>?;
|
||||
final chats = items?['chats'] as List?;
|
||||
|
||||
// Handle both string IDs and conversation objects
|
||||
final conversationIds =
|
||||
chats
|
||||
?.map((chat) {
|
||||
if (chat is String) {
|
||||
return chat;
|
||||
} else if (chat is Map<String, dynamic>) {
|
||||
return chat['id'] as String? ?? '';
|
||||
List<String> extractConversationIds(dynamic source) {
|
||||
if (source is! List) {
|
||||
return const <String>[];
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.where((id) => id.isNotEmpty)
|
||||
.toList()
|
||||
.cast<String>() ??
|
||||
<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();
|
||||
}
|
||||
|
||||
if (value.isNotEmpty) {
|
||||
ids.add(value);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
final items = json['items'] as Map<String, dynamic>?;
|
||||
final chats = items?['chats'];
|
||||
final explicitIds = extractConversationIds(json['conversation_ids']);
|
||||
final implicitIds = extractConversationIds(chats);
|
||||
final conversationIds = explicitIds.isNotEmpty ? explicitIds : implicitIds;
|
||||
|
||||
// Handle Unix timestamp conversion
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ final class HiveStoreKeys {
|
||||
|
||||
// Cache entries
|
||||
static const String localConversations = 'local_conversations';
|
||||
static const String localFolders = 'local_folders';
|
||||
static const String attachmentQueueEntries = 'attachment_queue_entries';
|
||||
static const String taskQueue = 'outbound_task_queue_v1';
|
||||
}
|
||||
|
||||
@@ -107,7 +107,24 @@ class PersistenceMigrator {
|
||||
}
|
||||
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
@@ -118,11 +135,11 @@ class PersistenceMigrator {
|
||||
final list = decoded
|
||||
.map((entry) => Map<String, dynamic>.from(entry as Map))
|
||||
.toList(growable: false);
|
||||
await _boxes.caches.put(HiveStoreKeys.localConversations, list);
|
||||
await _boxes.caches.put(key, list);
|
||||
}
|
||||
} catch (error, stack) {
|
||||
DebugLogger.error(
|
||||
'Failed to migrate local conversations',
|
||||
'Failed to migrate $logLabel',
|
||||
scope: 'persistence/migration',
|
||||
error: error,
|
||||
stackTrace: stack,
|
||||
@@ -206,6 +223,7 @@ class PersistenceMigrator {
|
||||
PreferenceKeys.onboardingSeen,
|
||||
PreferenceKeys.reviewerMode,
|
||||
HiveStoreKeys.localConversations,
|
||||
HiveStoreKeys.localFolders,
|
||||
HiveStoreKeys.attachmentQueueEntries,
|
||||
LegacyPreferenceKeys.attachmentUploadQueue,
|
||||
LegacyPreferenceKeys.taskQueue,
|
||||
|
||||
@@ -1801,24 +1801,53 @@ class Folders extends _$Folders {
|
||||
Future<List<Folder>> build() async {
|
||||
if (!ref.watch(isAuthenticatedProvider2)) {
|
||||
DebugLogger.log('skip-unauthed', scope: 'folders');
|
||||
_persistFoldersAsync(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);
|
||||
if (api == null) {
|
||||
DebugLogger.warning('api-missing', scope: 'folders');
|
||||
return const [];
|
||||
}
|
||||
return _load(api);
|
||||
final fresh = await _load(api);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
if (!ref.read(isAuthenticatedProvider2)) {
|
||||
state = const AsyncData<List<Folder>>([]);
|
||||
_persistFoldersAsync(const []);
|
||||
return;
|
||||
}
|
||||
final api = ref.read(apiServiceProvider);
|
||||
if (api == null) {
|
||||
state = const AsyncData<List<Folder>>([]);
|
||||
_persistFoldersAsync(const []);
|
||||
return;
|
||||
}
|
||||
final result = await AsyncValue.guard(() => _load(api));
|
||||
@@ -1835,7 +1864,9 @@ class Folders extends _$Folders {
|
||||
} else {
|
||||
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) {
|
||||
@@ -1845,7 +1876,9 @@ class Folders extends _$Folders {
|
||||
if (index < 0) return;
|
||||
final updated = <Folder>[...current];
|
||||
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) {
|
||||
@@ -1854,7 +1887,9 @@ class Folders extends _$Folders {
|
||||
final updated = current
|
||||
.where((folder) => folder.id != id)
|
||||
.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 {
|
||||
@@ -1868,7 +1903,9 @@ class Folders extends _$Folders {
|
||||
scope: 'folders',
|
||||
data: {'count': folders.length},
|
||||
);
|
||||
return _sort(folders);
|
||||
final sorted = _sort(folders);
|
||||
_persistFoldersAsync(sorted);
|
||||
return sorted;
|
||||
} catch (e, stackTrace) {
|
||||
DebugLogger.error(
|
||||
'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) {
|
||||
final sorted = [...input];
|
||||
sorted.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:hive_ce/hive.dart';
|
||||
|
||||
import '../models/conversation.dart';
|
||||
import '../models/folder.dart';
|
||||
import '../models/server_config.dart';
|
||||
import '../persistence/hive_boxes.dart';
|
||||
import '../persistence/persistence_keys.dart';
|
||||
@@ -40,6 +41,7 @@ class OptimizedStorageService {
|
||||
static const String _themePaletteKey = PreferenceKeys.themePalette;
|
||||
static const String _localeCodeKey = PreferenceKeys.localeCode;
|
||||
static const String _localConversationsKey = HiveStoreKeys.localConversations;
|
||||
static const String _localFoldersKey = HiveStoreKeys.localFolders;
|
||||
static const String _onboardingSeenKey = PreferenceKeys.onboardingSeen;
|
||||
static const String _reviewerModeKey = PreferenceKeys.reviewerMode;
|
||||
|
||||
@@ -304,7 +306,7 @@ class OptimizedStorageService {
|
||||
}
|
||||
final parsed = await _workerManager
|
||||
.schedule<Map<String, dynamic>, List<Map<String, dynamic>>>(
|
||||
_decodeStoredConversationsWorker,
|
||||
_decodeStoredJsonListWorker,
|
||||
{'stored': stored},
|
||||
debugLabel: 'decode_local_conversations',
|
||||
);
|
||||
@@ -326,8 +328,8 @@ class OptimizedStorageService {
|
||||
.map((conversation) => conversation.toJson())
|
||||
.toList();
|
||||
final serialized = await _workerManager
|
||||
.schedule<Map<String, dynamic>, String>(_encodeConversationsWorker, {
|
||||
'conversations': jsonReady,
|
||||
.schedule<Map<String, dynamic>, String>(_encodeJsonListWorker, {
|
||||
'items': jsonReady,
|
||||
}, debugLabel: 'encode_local_conversations');
|
||||
await _cachesBox.put(_localConversationsKey, serialized);
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -458,7 +506,7 @@ class OptimizedStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _decodeStoredConversationsWorker(
|
||||
List<Map<String, dynamic>> _decodeStoredJsonListWorker(
|
||||
Map<String, dynamic> payload,
|
||||
) {
|
||||
final stored = payload['stored'];
|
||||
@@ -483,8 +531,8 @@ List<Map<String, dynamic>> _decodeStoredConversationsWorker(
|
||||
return <Map<String, dynamic>>[];
|
||||
}
|
||||
|
||||
String _encodeConversationsWorker(Map<String, dynamic> payload) {
|
||||
final raw = payload['conversations'];
|
||||
String _encodeJsonListWorker(Map<String, dynamic> payload) {
|
||||
final raw = payload['items'] ?? payload['conversations'];
|
||||
if (raw is List) {
|
||||
return jsonEncode(raw);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user