look ahead
This commit is contained in:
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user