/// Thin wrapper around [SharedPreferences] for local key-value persistence. library; import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/bank.dart'; import '../models/transaction_meta.dart'; import '../utils/constants.dart'; /// Persisted annotation store for transactions. class TxMetaStoreData { const TxMetaStoreData({ this.byId = const {}, this.labelsByMerchant = const {}, this.notesByMerchant = const {}, this.exactTemplates = const {}, this.customLabels = const [], }); final Map byId; final Map> labelsByMerchant; final Map notesByMerchant; final Map exactTemplates; final List customLabels; } /// Cached bank payload so the app stays usable across restarts / brief outages. class BankCache { const BankCache({ required this.status, this.balance, this.transactions = const [], }); final ConnectStatus status; final BankBalance? balance; final List transactions; Map toJson() => { 'status': { 'status': status.status, 'linked': status.linked, 'accountId': status.accountId, 'sessionId': status.sessionId, 'state': status.state, 'validUntil': status.validUntil, }, 'balance': balance == null ? null : { 'amount': balance!.amount, 'currency': balance!.currency, 'balanceType': balance!.balanceType, }, 'transactions': [ for (final tx in transactions) { 'date': tx.date, 'description': tx.description, 'amount': tx.amount, 'currency': tx.currency, 'pending': tx.pending, }, ], }; factory BankCache.fromJson(Map json) { final statusRaw = json['status']; final status = statusRaw is Map ? ConnectStatus.fromJson(Map.from(statusRaw)) : ConnectStatus.disconnected; final balanceRaw = json['balance']; final balance = balanceRaw is Map ? BankBalance.fromJson(Map.from(balanceRaw)) : null; final txsRaw = json['transactions']; final transactions = txsRaw is List ? txsRaw .whereType() .map((e) => BankTransaction.fromJson(Map.from(e))) .toList(growable: false) : const []; return BankCache( status: status, balance: balance, transactions: transactions, ); } } /// 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); } BankCache? loadBankCache() { final raw = _prefs.getString(StorageKeys.bankCache); if (raw == null || raw.isEmpty) return null; try { final decoded = jsonDecode(raw); if (decoded is! Map) return null; return BankCache.fromJson(Map.from(decoded)); } catch (_) { return null; } } Future saveBankCache(BankCache cache) { return _prefs.setString(StorageKeys.bankCache, jsonEncode(cache.toJson())); } Future clearBankCache() { return _prefs.remove(StorageKeys.bankCache); } TxMetaStoreData loadTxMetaStore() { final raw = _prefs.getString(StorageKeys.txMeta); if (raw != null && raw.isNotEmpty) { return _decodeStore(raw); } 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 labelsByMerchant = >{}; decoded.forEach((key, value) { if (key is! String || value is! Map) return; final meta = TxMeta.fromJson(Map.from(value)); byId[key] = meta; if (meta.hasLabel) { final desc = _descriptionFromLegacyId(key); if (desc != null) { labelsByMerchant[merchantKeyFromDescription(desc)] = List.of(meta.labelIds); } } }); return TxMetaStoreData( byId: byId, labelsByMerchant: labelsByMerchant, ); } catch (_) { return const TxMetaStoreData(); } } Future saveTxMetaStore(TxMetaStoreData store) { final encoded = { 'byId': { for (final e in store.byId.entries) e.key: e.value.toJson(), }, 'labelsByMerchant': { for (final e in store.labelsByMerchant.entries) e.key: e.value, }, // Legacy single-label map for older builds. 'labelByMerchant': { for (final e in store.labelsByMerchant.entries) if (e.value.isNotEmpty) e.key: e.value.first, }, 'categoryByDescription': { for (final e in store.labelsByMerchant.entries) if (e.value.isNotEmpty) e.key: e.value.first, }, 'notesByMerchant': store.notesByMerchant, 'notesByDescription': store.notesByMerchant, 'exactTemplates': { for (final e in store.exactTemplates.entries) e.key: e.value.toJson(), }, 'customLabels': [ for (final label in store.customLabels) label.toJson(), ], }; return _prefs.setString(StorageKeys.txMeta, jsonEncode(encoded)); } 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 labelsByMerchant = >{}; final multi = root['labelsByMerchant']; if (multi is Map) { multi.forEach((key, value) { if (key is! String) return; if (value is List) { final ids = [ for (final item in value) if (item is String && item.isNotEmpty && item != 'uncategorized' && item != 'other') item, ]; if (ids.isNotEmpty) labelsByMerchant[key] = ids; } else if (value is String && value.isNotEmpty && value != 'uncategorized' && value != 'other') { labelsByMerchant[key] = [value]; } }); } else { final singles = root['labelByMerchant'] ?? root['categoryByDescription']; if (singles is Map) { singles.forEach((key, value) { if (key is! String || value is! String) return; if (value == 'uncategorized' || value == 'other') return; labelsByMerchant[key] = [value]; }); } } final notesByMerchant = {}; final notesRaw = root['notesByMerchant'] ?? root['notesByDescription']; if (notesRaw is Map) { notesRaw.forEach((key, value) { if (key is String && value is String && value.trim().isNotEmpty) { notesByMerchant[key] = 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)); } }); } final customLabels = []; final customs = root['customLabels']; if (customs is List) { for (final item in customs) { if (item is Map) { customLabels.add( TxLabel.fromJson(Map.from(item)), ); } } } return TxMetaStoreData( byId: byId, labelsByMerchant: labelsByMerchant, notesByMerchant: notesByMerchant, exactTemplates: exactTemplates, customLabels: customLabels, ); } 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+'), ' '); } }