feat(storage): Add local folders persistence and caching mechanism
This commit is contained in:
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user