look ahead

This commit is contained in:
davmar
2026-09-10 00:35:54 +02:00
parent 91d6dc9e54
commit 5b22fc8694
20 changed files with 2942 additions and 799 deletions
+28 -2
View File
@@ -47,15 +47,30 @@ bool isSameTransaction(BankTransaction a, BankTransaction b) {
int _amountCents(double amount) => (amount * 100).round();
/// Coarse bucket so fuzzy [isSameTransaction] only compares likely peers.
String _dedupeBucket(BankTransaction tx) =>
'${tx.date}|${tx.currency.toUpperCase()}|${_amountCents(tx.amount)}';
/// Merge [extra] into [existing], skipping duplicates. Newest date first.
List<BankTransaction> mergeTransactions(
List<BankTransaction> existing,
List<BankTransaction> extra,
) {
if (extra.isEmpty) return existing;
final buckets = <String, List<BankTransaction>>{};
for (final tx in existing) {
buckets.putIfAbsent(_dedupeBucket(tx), () => []).add(tx);
}
final out = List<BankTransaction>.of(existing);
for (final tx in extra) {
if (out.any((e) => isSameTransaction(e, tx))) continue;
final key = _dedupeBucket(tx);
final peers = buckets[key];
if (peers != null && peers.any((e) => isSameTransaction(e, tx))) {
continue;
}
out.add(tx);
buckets.putIfAbsent(key, () => []).add(tx);
}
out.sort((a, b) {
final byDate = b.date.compareTo(a.date);
@@ -70,9 +85,20 @@ List<BankTransaction> filterNewTransactions(
List<BankTransaction> existing,
List<BankTransaction> candidates,
) {
if (candidates.isEmpty) return const [];
if (existing.isEmpty) return List<BankTransaction>.of(candidates);
final buckets = <String, List<BankTransaction>>{};
for (final tx in existing) {
buckets.putIfAbsent(_dedupeBucket(tx), () => []).add(tx);
}
return [
for (final tx in candidates)
if (!existing.any((e) => isSameTransaction(e, tx))) tx,
if (!(buckets[_dedupeBucket(tx)]
?.any((e) => isSameTransaction(e, tx)) ??
false))
tx,
];
}
+157 -5
View File
@@ -52,6 +52,13 @@ TxMetaStoreData mergeTxMetaStores(TxMetaStoreData a, TxMetaStoreData b) {
existing == null ? e.value : _mergeExact(existing, e.value);
}
final recurringTemplates = <String, RecurringTemplate>{
...a.recurringTemplates,
};
for (final e in b.recurringTemplates.entries) {
recurringTemplates.putIfAbsent(e.key, () => e.value);
}
final labelsByClientId = <String, TxLabel>{
for (final label in a.customLabels) label.id: label,
};
@@ -66,6 +73,7 @@ TxMetaStoreData mergeTxMetaStores(TxMetaStoreData a, TxMetaStoreData b) {
labelsByMerchant: labelsByMerchant,
notesByMerchant: notesByMerchant,
exactTemplates: exactTemplates,
recurringTemplates: recurringTemplates,
customLabels: labelsByClientId.values.toList(growable: false),
labelParents: labelParents,
);
@@ -170,6 +178,7 @@ class PbSyncService {
}) async {
final out = <RecordModel>[];
var page = 1;
var lastReported = -1;
while (true) {
final result = await _withTimeout(
_pb.collection(collection).getList(
@@ -181,8 +190,13 @@ class PbSyncService {
label: '$collection list page $page',
);
out.addAll(result.items);
onPage?.call(out.length);
if (result.items.length < _listPageSize) break;
final finished = result.items.length < _listPageSize;
if (onPage != null &&
(finished || out.length - lastReported >= 400)) {
lastReported = out.length;
onPage(out.length);
}
if (finished) break;
page++;
}
return out;
@@ -213,7 +227,17 @@ class PbSyncService {
report(0.36);
onStatus?.call('Pulling templates…');
final exactRecords = await _fullList('exact_templates', filter);
report(0.45);
report(0.42);
onStatus?.call('Pulling recurring templates…');
List<RecordModel> recurringRecords = const [];
try {
recurringRecords = await _fullList('recurring_templates', filter);
} catch (error) {
debugPrint(
'PbSyncService pull: recurring_templates unavailable ($error)',
);
}
report(0.48);
final customLabels = <TxLabel>[];
final labelParents = <String, String>{};
@@ -269,10 +293,35 @@ class PbSyncService {
);
}
final recurringTemplates = <String, RecurringTemplate>{};
for (final r in recurringRecords) {
final clientId = r.getStringValue('client_id');
if (clientId.isEmpty) continue;
final sourceTx = r.getStringValue('source_tx_id');
final labelId = r.getStringValue('label_id');
recurringTemplates[clientId] = RecurringTemplate(
id: clientId,
name: r.getStringValue('name').isEmpty
? 'Recurring'
: r.getStringValue('name'),
dayOfMonth: r.getIntValue('day_of_month', 1).clamp(1, 28),
currency: r.getStringValue('currency').isEmpty
? 'EUR'
: r.getStringValue('currency'),
source: _parseTemplateSource(r.getStringValue('source')),
amountRule: _parseAmountRule(r.getStringValue('amount_rule')),
merchantKey: r.getStringValue('merchant_key'),
sourceTxId: sourceTx.isEmpty ? null : sourceTx,
labelId: labelId.isEmpty ? null : labelId,
avgCount: r.getIntValue('avg_count', 5).clamp(1, 36),
autoFromRecurring: r.getBoolValue('auto_from_recurring'),
);
}
debugPrint(
'PbSyncService pull: labels=${labelRecords.length} '
'tx_meta=${metaRecords.length} merchants=${merchantRecords.length} '
'exact=${exactRecords.length}',
'exact=${exactRecords.length} recurring=${recurringRecords.length}',
);
return TxMetaStoreData(
@@ -280,6 +329,7 @@ class PbSyncService {
labelsByMerchant: labelsByMerchant,
notesByMerchant: notesByMerchant,
exactTemplates: exactTemplates,
recurringTemplates: recurringTemplates,
customLabels: customLabels,
labelParents: labelParents,
);
@@ -347,6 +397,15 @@ class PbSyncService {
report(0.96);
onStatus?.call('Syncing templates…');
await _syncExactTemplates(userId, filter, store);
report(0.98);
onStatus?.call('Syncing recurring templates…');
try {
await _syncRecurringTemplates(userId, filter, store);
} catch (error) {
debugPrint(
'PbSyncService push: recurring_templates unavailable ($error)',
);
}
report(1.0);
return txSummary;
}
@@ -563,7 +622,11 @@ class PbSyncService {
]);
done = end;
onProgress?.call(done / total);
onStatus?.call('Syncing $collection ($done/$total)…');
// Status less often than progress — SyncController already throttles,
// but fewer string allocs/churn still help.
if (done == total || done == end && (done ~/ _writeConcurrency) % 4 == 0) {
onStatus?.call('Syncing $collection ($done/$total)…');
}
}
if (errors.isNotEmpty) {
@@ -603,6 +666,12 @@ class PbSyncService {
filter =
'user="$userId" && exact_key="${key.replaceAll('"', '\\"')}"';
}
case 'recurring_templates':
final key = body['client_id'];
if (userId != null && key is String) {
filter =
'user="$userId" && client_id="${key.replaceAll('"', '\\"')}"';
}
case 'transactions':
final key = body['tx_id'];
if (userId != null && key is String) {
@@ -853,6 +922,61 @@ class PbSyncService {
await _runWriteOps('exact_templates', ops);
}
Future<void> _syncRecurringTemplates(
String userId,
String filter,
TxMetaStoreData store,
) async {
final existing = {
for (final r in await _fullList('recurring_templates', filter))
r.getStringValue('client_id'): r,
};
final keep = <String>{};
final ops = <_BatchOp>[];
for (final e in store.recurringTemplates.entries) {
final t = e.value;
final clientId = _clip(t.id.isNotEmpty ? t.id : e.key, 120);
if (clientId.isEmpty) continue;
keep.add(clientId);
final body = <String, dynamic>{
'user': userId,
'client_id': clientId,
'name': _clip(t.name.isEmpty ? 'Recurring' : t.name, 400),
'day_of_month': t.dayOfMonth.clamp(1, 28),
'currency': _clip(
t.currency.isEmpty ? 'EUR' : t.currency.toUpperCase(),
8,
),
'source': t.source.name,
'amount_rule': t.amountRule.name,
'merchant_key': _clip(t.merchantKey, 500),
'source_tx_id': _clip(t.sourceTxId ?? '', 2000),
'label_id': _clip(t.labelId ?? '', 80),
'avg_count': t.avgCount.clamp(1, 36),
'auto_from_recurring': t.autoFromRecurring,
};
final remote = existing[clientId];
if (remote == null) {
ops.add(_BatchOp.create(body));
} else if (!_recurringUnchanged(remote, t)) {
ops.add(_BatchOp.update(remote.id, body));
}
}
for (final e in existing.entries) {
if (!keep.contains(e.key)) {
ops.add(_BatchOp.delete(e.value.id));
}
}
debugPrint(
'PbSyncService recurring_templates: remote=${existing.length} '
'ops=${ops.length}',
);
await _runWriteOps('recurring_templates', ops);
}
bool _labelUnchanged(RecordModel remote, Map<String, dynamic> body) {
return remote.getStringValue('name') == body['name'] &&
remote.getIntValue('color') == body['color'] &&
@@ -882,6 +1006,34 @@ class PbSyncService {
remote.getBoolValue('recurring') == t.recurring &&
remote.getBoolValue('internal_transfer') == t.internalTransfer;
}
bool _recurringUnchanged(RecordModel remote, RecurringTemplate t) {
return remote.getStringValue('name') == t.name &&
remote.getIntValue('day_of_month') == t.dayOfMonth &&
remote.getStringValue('currency') == t.currency &&
remote.getStringValue('source') == t.source.name &&
remote.getStringValue('amount_rule') == t.amountRule.name &&
remote.getStringValue('merchant_key') == t.merchantKey &&
remote.getStringValue('source_tx_id') == (t.sourceTxId ?? '') &&
remote.getStringValue('label_id') == (t.labelId ?? '') &&
remote.getIntValue('avg_count', 5) == t.avgCount &&
remote.getBoolValue('auto_from_recurring') == t.autoFromRecurring;
}
}
RecurringTemplateSource _parseTemplateSource(String raw) {
return switch (raw) {
'label' => RecurringTemplateSource.label,
_ => RecurringTemplateSource.transaction,
};
}
RecurringAmountRule _parseAmountRule(String raw) {
return switch (raw) {
'avgLastTxs' => RecurringAmountRule.avgLastTxs,
'labelAvgMonths' => RecurringAmountRule.labelAvgMonths,
_ => RecurringAmountRule.lastValue,
};
}
List<String> _jsonStringList(dynamic raw) {
+31 -9
View File
@@ -3,6 +3,7 @@ library;
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/bank.dart';
@@ -16,6 +17,7 @@ class TxMetaStoreData {
this.labelsByMerchant = const {},
this.notesByMerchant = const {},
this.exactTemplates = const {},
this.recurringTemplates = const {},
this.customLabels = const [],
this.labelParents = const {},
});
@@ -24,6 +26,7 @@ class TxMetaStoreData {
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).
@@ -147,8 +150,9 @@ class StorageService {
}
}
Future<void> saveBankCache(BankCache cache) {
return _prefs.setString(StorageKeys.bankCache, jsonEncode(cache.toJson()));
Future<void> saveBankCache(BankCache cache) async {
final json = await compute(jsonEncode, cache.toJson());
await _prefs.setString(StorageKeys.bankCache, json);
}
Future<void> clearBankCache() {
@@ -170,7 +174,7 @@ class StorageService {
}
}
Future<void> saveImportedTransactions(List<BankTransaction> txs) {
Future<void> saveImportedTransactions(List<BankTransaction> txs) async {
final encoded = [
for (final tx in txs)
{
@@ -181,10 +185,8 @@ class StorageService {
'pending': tx.pending,
},
];
return _prefs.setString(
StorageKeys.importedTransactions,
jsonEncode(encoded),
);
final json = await compute(jsonEncode, encoded);
await _prefs.setString(StorageKeys.importedTransactions, json);
}
Future<void> clearImportedTransactions() {
@@ -225,7 +227,7 @@ class StorageService {
}
}
Future<void> saveTxMetaStore(TxMetaStoreData store) {
Future<void> saveTxMetaStore(TxMetaStoreData store) async {
final encoded = {
'byId': {
for (final e in store.byId.entries) e.key: e.value.toJson(),
@@ -247,12 +249,18 @@ class StorageService {
'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,
};
return _prefs.setString(StorageKeys.txMeta, jsonEncode(encoded));
// 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) {
@@ -326,6 +334,19 @@ class StorageService {
});
}
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) {
@@ -365,6 +386,7 @@ class StorageService {
labelsByMerchant: labelsByMerchant,
notesByMerchant: notesByMerchant,
exactTemplates: exactTemplates,
recurringTemplates: recurringTemplates,
customLabels: customLabels,
labelParents: labelParents,
);