Files
ingtrakapp/lib/services/storage_service.dart
T
2026-09-08 12:58:32 +02:00

346 lines
10 KiB
Dart

/// 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<String, TxMeta> byId;
final Map<String, List<String>> labelsByMerchant;
final Map<String, String> notesByMerchant;
final Map<String, TxExactTemplate> exactTemplates;
final List<TxLabel> 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<BankTransaction> transactions;
Map<String, dynamic> 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<String, dynamic> json) {
final statusRaw = json['status'];
final status = statusRaw is Map
? ConnectStatus.fromJson(Map<String, dynamic>.from(statusRaw))
: ConnectStatus.disconnected;
final balanceRaw = json['balance'];
final balance = balanceRaw is Map
? BankBalance.fromJson(Map<String, dynamic>.from(balanceRaw))
: null;
final txsRaw = json['transactions'];
final transactions = txsRaw is List
? txsRaw
.whereType<Map>()
.map((e) => BankTransaction.fromJson(Map<String, dynamic>.from(e)))
.toList(growable: false)
: const <BankTransaction>[];
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<void> 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<void> setThemeModeName(String value) {
return _prefs.setString(StorageKeys.themeMode, value);
}
bool get useRemoteData => _prefs.getBool(StorageKeys.useRemoteData) ?? false;
Future<void> 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<String, dynamic>.from(decoded));
} catch (_) {
return null;
}
}
Future<void> saveBankCache(BankCache cache) {
return _prefs.setString(StorageKeys.bankCache, jsonEncode(cache.toJson()));
}
Future<void> clearBankCache() {
return _prefs.remove(StorageKeys.bankCache);
}
List<BankTransaction> 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>()
.map((e) => BankTransaction.fromJson(Map<String, dynamic>.from(e)))
.toList(growable: false);
} catch (_) {
return const [];
}
}
Future<void> saveImportedTransactions(List<BankTransaction> txs) {
final encoded = [
for (final tx in txs)
{
'date': tx.date,
'description': tx.description,
'amount': tx.amount,
'currency': tx.currency,
'pending': tx.pending,
},
];
return _prefs.setString(
StorageKeys.importedTransactions,
jsonEncode(encoded),
);
}
Future<void> clearImportedTransactions() {
return _prefs.remove(StorageKeys.importedTransactions);
}
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 = <String, TxMeta>{};
final labelsByMerchant = <String, List<String>>{};
decoded.forEach((key, value) {
if (key is! String || value is! Map) return;
final meta = TxMeta.fromJson(Map<String, dynamic>.from(value));
byId[key] = meta;
if (meta.hasLabel) {
final desc = _descriptionFromLegacyId(key);
if (desc != null) {
labelsByMerchant[merchantKeyFromDescription(desc)] =
List<String>.of(meta.labelIds);
}
}
});
return TxMetaStoreData(
byId: byId,
labelsByMerchant: labelsByMerchant,
);
} catch (_) {
return const TxMetaStoreData();
}
}
Future<void> 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<String, dynamic>.from(decoded);
final byId = <String, TxMeta>{};
final byIdRaw = root['byId'];
if (byIdRaw is Map) {
byIdRaw.forEach((key, value) {
if (key is String && value is Map) {
byId[key] = TxMeta.fromJson(Map<String, dynamic>.from(value));
}
});
}
final labelsByMerchant = <String, List<String>>{};
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 = <String, String>{};
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 = <String, TxExactTemplate>{};
final templates = root['exactTemplates'];
if (templates is Map) {
templates.forEach((key, value) {
if (key is String && value is Map) {
exactTemplates[key] =
TxExactTemplate.fromJson(Map<String, dynamic>.from(value));
}
});
}
final customLabels = <TxLabel>[];
final customs = root['customLabels'];
if (customs is List) {
for (final item in customs) {
if (item is Map) {
customLabels.add(
TxLabel.fromJson(Map<String, dynamic>.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+'), ' ');
}
}