/// Thin wrapper around [SharedPreferences] for local key-value persistence. library; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/bank.dart'; import '../models/home_built_in_sections.dart'; import '../models/home_filter_widget.dart'; import '../models/overview_favourite.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.recurringTemplates = const {}, this.customLabels = const [], this.labelParents = const {}, }); final Map byId; final Map> labelsByMerchant; final Map notesByMerchant; final Map exactTemplates; final Map recurringTemplates; final List customLabels; /// childLabelId → parentLabelId (roots omitted). final Map labelParents; } /// 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) tx.toJson(), ], }; 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); } /// Look ahead navigation horizon in years (clamped 1–30, default 5). int get lookAheadMaxYears { final raw = _prefs.getInt(StorageKeys.lookAheadMaxYears); if (raw == null) return 5; return raw.clamp(1, 30); } Future setLookAheadMaxYears(int years) { return _prefs.setInt(StorageKeys.lookAheadMaxYears, years.clamp(1, 30)); } List loadOverviewFavourites() { final raw = _prefs.getString(StorageKeys.overviewFavourites); if (raw == null || raw.isEmpty) return const []; try { final decoded = jsonDecode(raw); if (decoded is! List) return const []; return [ for (final item in decoded) if (item is Map) OverviewFavourite.fromJson(Map.from(item)), ]; } catch (_) { return const []; } } Future saveOverviewFavourites(List favourites) { final encoded = jsonEncode([ for (final fav in favourites) fav.toJson(), ]); return _prefs.setString(StorageKeys.overviewFavourites, encoded); } List loadHomeFilterWidgets() { final raw = _prefs.getString(StorageKeys.homeFilterWidgets); if (raw == null || raw.isEmpty) return const []; try { final decoded = jsonDecode(raw); if (decoded is! List) return const []; final list = [ for (final item in decoded) if (item is Map) HomeFilterWidget.fromJson(Map.from(item)), ]; list.sort((a, b) => a.sortOrder.compareTo(b.sortOrder)); return list; } catch (_) { return const []; } } Future saveHomeFilterWidgets(List widgets) { final encoded = jsonEncode([ for (final w in widgets) w.toJson(), ]); return _prefs.setString(StorageKeys.homeFilterWidgets, encoded); } HomeBuiltInSections loadHomeBuiltInSections() { final raw = _prefs.getString(StorageKeys.homeBuiltInSections); if (raw == null || raw.isEmpty) return HomeBuiltInSections.defaults; try { final decoded = jsonDecode(raw); if (decoded is! Map) return HomeBuiltInSections.defaults; return HomeBuiltInSections.fromJson( Map.from(decoded), ); } catch (_) { return HomeBuiltInSections.defaults; } } Future saveHomeBuiltInSections(HomeBuiltInSections sections) { return _prefs.setString( StorageKeys.homeBuiltInSections, jsonEncode(sections.toJson()), ); } bool get useRemoteData => _prefs.getBool(StorageKeys.useRemoteData) ?? false; Future setUseRemoteData(bool value) { return _prefs.setBool(StorageKeys.useRemoteData, value); } String? get pocketBaseAuth => _prefs.getString(StorageKeys.pocketBaseAuth); Future setPocketBaseAuth(String value) { return _prefs.setString(StorageKeys.pocketBaseAuth, value); } Future clearPocketBaseAuth() { return _prefs.remove(StorageKeys.pocketBaseAuth); } 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) async { final json = await compute(jsonEncode, cache.toJson()); await _prefs.setString(StorageKeys.bankCache, json); } Future clearBankCache() { return _prefs.remove(StorageKeys.bankCache); } List loadImportedTransactions() { final raw = _prefs.getString(StorageKeys.importedTransactions); if (raw == null || raw.isEmpty) return const []; try { final decoded = jsonDecode(raw); if (decoded is! List) return const []; return decoded .whereType() .map((e) => BankTransaction.fromJson(Map.from(e))) .toList(growable: false); } catch (_) { return const []; } } Future saveImportedTransactions(List txs) async { final encoded = [for (final tx in txs) tx.toJson()]; final json = await compute(jsonEncode, encoded); await _prefs.setString(StorageKeys.importedTransactions, json); } Future clearImportedTransactions() { return _prefs.remove(StorageKeys.importedTransactions); } /// Tombstones for locally deleted bank txs awaiting remote removal. /// /// Supports legacy string-id lists and full transaction JSON objects. List loadPendingTxDeletes() { final raw = _prefs.getString(StorageKeys.pendingTxDeletes); if (raw == null || raw.isEmpty) return const []; try { final decoded = jsonDecode(raw); if (decoded is! List) return const []; final out = []; for (final e in decoded) { if (e is Map) { out.add(BankTransaction.fromJson(Map.from(e))); } else if (e is String && e.isNotEmpty) { // Legacy id-only entry — match by id string only. final parts = e.split('|'); if (parts.length >= 4) { final amount = double.tryParse(parts[2]) ?? 0; out.add( BankTransaction( date: parts[0], currency: parts[1], amount: amount, description: parts.sublist(3).join('|'), pending: false, isManual: true, ), ); } } } return out; } catch (_) { return const []; } } Future savePendingTxDeletes(List txs) { if (txs.isEmpty) { return _prefs.remove(StorageKeys.pendingTxDeletes); } return _prefs.setString( StorageKeys.pendingTxDeletes, jsonEncode([for (final tx in txs) tx.toJson()]), ); } 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) async { 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(), }, 'recurringTemplates': { for (final e in store.recurringTemplates.entries) e.key: e.value.toJson(), }, 'customLabels': [ for (final label in store.customLabels) label.toJson(), ], 'labelParents': store.labelParents, }; // jsonEncode of large annotation maps is CPU-heavy — keep it off the UI isolate. final json = await compute(jsonEncode, encoded); await _prefs.setString(StorageKeys.txMeta, json); } 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 recurringTemplates = {}; final recurringRaw = root['recurringTemplates']; if (recurringRaw is Map) { recurringRaw.forEach((key, value) { if (key is String && value is Map) { final tpl = RecurringTemplate.fromJson(Map.from(value)); if (tpl.id.isEmpty) return; recurringTemplates[tpl.id.isNotEmpty ? tpl.id : key] = tpl; } }); } 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)), ); } } } final labelParents = {}; final parentsRaw = root['labelParents']; if (parentsRaw is Map) { parentsRaw.forEach((key, value) { if (key is String && value is String && key.isNotEmpty && value.isNotEmpty) { labelParents[key] = value; } }); } // Prefer explicit map; fall back to parentId embedded on custom labels. for (final label in customLabels) { final parent = label.parentId; if (parent != null && parent.isNotEmpty && !labelParents.containsKey(label.id)) { labelParents[label.id] = parent; } } return TxMetaStoreData( byId: byId, labelsByMerchant: labelsByMerchant, notesByMerchant: notesByMerchant, exactTemplates: exactTemplates, recurringTemplates: recurringTemplates, customLabels: customLabels, labelParents: labelParents, ); } 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+'), ' '); } }