Files
ingtrakapp/lib/services/storage_service.dart
T
2026-09-10 23:59:00 +02:00

520 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 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<String, TxMeta> byId;
final Map<String, List<String>> labelsByMerchant;
final Map<String, String> notesByMerchant;
final Map<String, TxExactTemplate> exactTemplates;
final Map<String, RecurringTemplate> recurringTemplates;
final List<TxLabel> customLabels;
/// childLabelId → parentLabelId (roots omitted).
final Map<String, String> 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<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) tx.toJson(),
],
};
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);
}
/// 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<void> setLookAheadMaxYears(int years) {
return _prefs.setInt(StorageKeys.lookAheadMaxYears, years.clamp(1, 30));
}
List<OverviewFavourite> 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<String, dynamic>.from(item)),
];
} catch (_) {
return const [];
}
}
Future<void> saveOverviewFavourites(List<OverviewFavourite> favourites) {
final encoded = jsonEncode([
for (final fav in favourites) fav.toJson(),
]);
return _prefs.setString(StorageKeys.overviewFavourites, encoded);
}
List<HomeFilterWidget> 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<String, dynamic>.from(item)),
];
list.sort((a, b) => a.sortOrder.compareTo(b.sortOrder));
return list;
} catch (_) {
return const [];
}
}
Future<void> saveHomeFilterWidgets(List<HomeFilterWidget> 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<String, dynamic>.from(decoded),
);
} catch (_) {
return HomeBuiltInSections.defaults;
}
}
Future<void> saveHomeBuiltInSections(HomeBuiltInSections sections) {
return _prefs.setString(
StorageKeys.homeBuiltInSections,
jsonEncode(sections.toJson()),
);
}
bool get useRemoteData => _prefs.getBool(StorageKeys.useRemoteData) ?? false;
Future<void> setUseRemoteData(bool value) {
return _prefs.setBool(StorageKeys.useRemoteData, value);
}
String? get pocketBaseAuth => _prefs.getString(StorageKeys.pocketBaseAuth);
Future<void> setPocketBaseAuth(String value) {
return _prefs.setString(StorageKeys.pocketBaseAuth, value);
}
Future<void> 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<String, dynamic>.from(decoded));
} catch (_) {
return null;
}
}
Future<void> saveBankCache(BankCache cache) async {
final json = await compute(jsonEncode, cache.toJson());
await _prefs.setString(StorageKeys.bankCache, json);
}
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) async {
final encoded = [for (final tx in txs) tx.toJson()];
final json = await compute(jsonEncode, encoded);
await _prefs.setString(StorageKeys.importedTransactions, json);
}
Future<void> 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<BankTransaction> 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 = <BankTransaction>[];
for (final e in decoded) {
if (e is Map) {
out.add(BankTransaction.fromJson(Map<String, dynamic>.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<void> savePendingTxDeletes(List<BankTransaction> 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 = <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) 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<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 recurringTemplates = <String, RecurringTemplate>{};
final recurringRaw = root['recurringTemplates'];
if (recurringRaw is Map) {
recurringRaw.forEach((key, value) {
if (key is String && value is Map) {
final tpl =
RecurringTemplate.fromJson(Map<String, dynamic>.from(value));
if (tpl.id.isEmpty) return;
recurringTemplates[tpl.id.isNotEmpty ? tpl.id : key] = tpl;
}
});
}
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)),
);
}
}
}
final labelParents = <String, String>{};
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+'), ' ');
}
}