520 lines
16 KiB
Dart
520 lines
16 KiB
Dart
/// 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+'), ' ');
|
||
}
|
||
}
|