ing connected, makign some features
This commit is contained in:
@@ -5,6 +5,7 @@ import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/bank.dart';
|
||||
import '../models/transaction_meta.dart';
|
||||
import '../utils/constants.dart';
|
||||
|
||||
@@ -12,13 +13,81 @@ import '../utils/constants.dart';
|
||||
class TxMetaStoreData {
|
||||
const TxMetaStoreData({
|
||||
this.byId = const {},
|
||||
this.categoryByDescription = const {},
|
||||
this.labelsByMerchant = const {},
|
||||
this.notesByMerchant = const {},
|
||||
this.exactTemplates = const {},
|
||||
this.customLabels = const [],
|
||||
});
|
||||
|
||||
final Map<String, TxMeta> byId;
|
||||
final Map<String, TxCategory> categoryByDescription;
|
||||
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.
|
||||
@@ -52,34 +121,54 @@ class StorageService {
|
||||
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);
|
||||
}
|
||||
|
||||
TxMetaStoreData loadTxMetaStore() {
|
||||
final raw = _prefs.getString(StorageKeys.txMeta);
|
||||
if (raw != null && raw.isNotEmpty) {
|
||||
return _decodeStore(raw);
|
||||
}
|
||||
|
||||
// Migrate flat v1 map → v2 store.
|
||||
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 categoryByDescription = <String, TxCategory>{};
|
||||
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.hasCategory) {
|
||||
if (meta.hasLabel) {
|
||||
final desc = _descriptionFromLegacyId(key);
|
||||
if (desc != null) {
|
||||
categoryByDescription[desc] = meta.category;
|
||||
labelsByMerchant[merchantKeyFromDescription(desc)] =
|
||||
List<String>.of(meta.labelIds);
|
||||
}
|
||||
}
|
||||
});
|
||||
return TxMetaStoreData(
|
||||
byId: byId,
|
||||
categoryByDescription: categoryByDescription,
|
||||
labelsByMerchant: labelsByMerchant,
|
||||
);
|
||||
} catch (_) {
|
||||
return const TxMetaStoreData();
|
||||
@@ -91,31 +180,30 @@ class StorageService {
|
||||
'byId': {
|
||||
for (final e in store.byId.entries) e.key: e.value.toJson(),
|
||||
},
|
||||
'categoryByDescription': {
|
||||
for (final e in store.categoryByDescription.entries)
|
||||
e.key: e.value.name,
|
||||
'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));
|
||||
}
|
||||
|
||||
/// Deprecated shim used by older call sites during transition.
|
||||
Map<String, TxMeta> loadTxMeta() => loadTxMetaStore().byId;
|
||||
|
||||
Future<void> saveTxMeta(Map<String, TxMeta> meta) {
|
||||
final current = loadTxMetaStore();
|
||||
return saveTxMetaStore(
|
||||
TxMetaStoreData(
|
||||
byId: meta,
|
||||
categoryByDescription: current.categoryByDescription,
|
||||
exactTemplates: current.exactTemplates,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TxMetaStoreData _decodeStore(String raw) {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
@@ -132,12 +220,46 @@ class StorageService {
|
||||
});
|
||||
}
|
||||
|
||||
final categoryByDescription = <String, TxCategory>{};
|
||||
final cats = root['categoryByDescription'];
|
||||
if (cats is Map) {
|
||||
cats.forEach((key, value) {
|
||||
if (key is String && value is String) {
|
||||
categoryByDescription[key] = TxCategoryX.fromName(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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -153,10 +275,24 @@ class StorageService {
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
categoryByDescription: categoryByDescription,
|
||||
labelsByMerchant: labelsByMerchant,
|
||||
notesByMerchant: notesByMerchant,
|
||||
exactTemplates: exactTemplates,
|
||||
customLabels: customLabels,
|
||||
);
|
||||
} catch (_) {
|
||||
return const TxMetaStoreData();
|
||||
|
||||
Reference in New Issue
Block a user