/// Thin wrapper around [SharedPreferences] for local key-value persistence. library; import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/transaction_meta.dart'; import '../utils/constants.dart'; /// Persisted annotation store for transactions. class TxMetaStoreData { const TxMetaStoreData({ this.byId = const {}, this.categoryByDescription = const {}, this.exactTemplates = const {}, }); final Map byId; final Map categoryByDescription; final Map exactTemplates; } /// Loads and stores user preferences. Call [init] once at startup. class StorageService { StorageService({this._preferences}); SharedPreferences? _preferences; Future init() async { _preferences ??= await SharedPreferences.getInstance(); } SharedPreferences get _prefs { final prefs = _preferences; if (prefs == null) { throw StateError('StorageService.init() must be called before use.'); } return prefs; } String get themeModeName => _prefs.getString(StorageKeys.themeMode) ?? 'system'; Future setThemeModeName(String value) { return _prefs.setString(StorageKeys.themeMode, value); } bool get useRemoteData => _prefs.getBool(StorageKeys.useRemoteData) ?? false; Future setUseRemoteData(bool value) { return _prefs.setBool(StorageKeys.useRemoteData, value); } TxMetaStoreData loadTxMetaStore() { final raw = _prefs.getString(StorageKeys.txMeta); if (raw != null && raw.isNotEmpty) { return _decodeStore(raw); } // Migrate flat v1 map → v2 store. final legacy = _prefs.getString(StorageKeys.txMetaLegacy); if (legacy == null || legacy.isEmpty) return const TxMetaStoreData(); try { final decoded = jsonDecode(legacy); if (decoded is! Map) return const TxMetaStoreData(); final byId = {}; final categoryByDescription = {}; decoded.forEach((key, value) { if (key is! String || value is! Map) return; final meta = TxMeta.fromJson(Map.from(value)); byId[key] = meta; if (meta.hasCategory) { final desc = _descriptionFromLegacyId(key); if (desc != null) { categoryByDescription[desc] = meta.category; } } }); return TxMetaStoreData( byId: byId, categoryByDescription: categoryByDescription, ); } catch (_) { return const TxMetaStoreData(); } } Future saveTxMetaStore(TxMetaStoreData store) { final encoded = { 'byId': { for (final e in store.byId.entries) e.key: e.value.toJson(), }, 'categoryByDescription': { for (final e in store.categoryByDescription.entries) e.key: e.value.name, }, 'exactTemplates': { for (final e in store.exactTemplates.entries) e.key: e.value.toJson(), }, }; return _prefs.setString(StorageKeys.txMeta, jsonEncode(encoded)); } /// Deprecated shim used by older call sites during transition. Map loadTxMeta() => loadTxMetaStore().byId; Future saveTxMeta(Map meta) { final current = loadTxMetaStore(); return saveTxMetaStore( TxMetaStoreData( byId: meta, categoryByDescription: current.categoryByDescription, exactTemplates: current.exactTemplates, ), ); } TxMetaStoreData _decodeStore(String raw) { try { final decoded = jsonDecode(raw); if (decoded is! Map) return const TxMetaStoreData(); final root = Map.from(decoded); final byId = {}; final byIdRaw = root['byId']; if (byIdRaw is Map) { byIdRaw.forEach((key, value) { if (key is String && value is Map) { byId[key] = TxMeta.fromJson(Map.from(value)); } }); } final categoryByDescription = {}; final cats = root['categoryByDescription']; if (cats is Map) { cats.forEach((key, value) { if (key is String && value is String) { categoryByDescription[key] = TxCategoryX.fromName(value); } }); } final exactTemplates = {}; final templates = root['exactTemplates']; if (templates is Map) { templates.forEach((key, value) { if (key is String && value is Map) { exactTemplates[key] = TxExactTemplate.fromJson(Map.from(value)); } }); } return TxMetaStoreData( byId: byId, categoryByDescription: categoryByDescription, exactTemplates: exactTemplates, ); } catch (_) { return const TxMetaStoreData(); } } /// Legacy id format: `date|currency|amount|description`. String? _descriptionFromLegacyId(String id) { final parts = id.split('|'); if (parts.length < 4) return null; final description = parts.sublist(3).join('|'); return description.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' '); } }