look ahead
This commit is contained in:
@@ -325,6 +325,126 @@ class TxExactTemplate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How a [RecurringTemplate] resolves its projected amount.
|
||||||
|
enum RecurringAmountRule {
|
||||||
|
lastValue,
|
||||||
|
avgLastTxs,
|
||||||
|
labelAvgMonths,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a recurring template is anchored to.
|
||||||
|
enum RecurringTemplateSource {
|
||||||
|
transaction,
|
||||||
|
label,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First-class look-ahead recurring template (manual or auto from marked txs).
|
||||||
|
class RecurringTemplate {
|
||||||
|
const RecurringTemplate({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.dayOfMonth,
|
||||||
|
required this.currency,
|
||||||
|
required this.source,
|
||||||
|
required this.amountRule,
|
||||||
|
this.merchantKey = '',
|
||||||
|
this.sourceTxId,
|
||||||
|
this.labelId,
|
||||||
|
this.avgCount = 5,
|
||||||
|
this.autoFromRecurring = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final int dayOfMonth;
|
||||||
|
final String currency;
|
||||||
|
final RecurringTemplateSource source;
|
||||||
|
final RecurringAmountRule amountRule;
|
||||||
|
final String merchantKey;
|
||||||
|
final String? sourceTxId;
|
||||||
|
final String? labelId;
|
||||||
|
final int avgCount;
|
||||||
|
final bool autoFromRecurring;
|
||||||
|
|
||||||
|
RecurringTemplate copyWith({
|
||||||
|
String? id,
|
||||||
|
String? name,
|
||||||
|
int? dayOfMonth,
|
||||||
|
String? currency,
|
||||||
|
RecurringTemplateSource? source,
|
||||||
|
RecurringAmountRule? amountRule,
|
||||||
|
String? merchantKey,
|
||||||
|
String? sourceTxId,
|
||||||
|
bool clearSourceTxId = false,
|
||||||
|
String? labelId,
|
||||||
|
bool clearLabelId = false,
|
||||||
|
int? avgCount,
|
||||||
|
bool? autoFromRecurring,
|
||||||
|
}) {
|
||||||
|
return RecurringTemplate(
|
||||||
|
id: id ?? this.id,
|
||||||
|
name: name ?? this.name,
|
||||||
|
dayOfMonth: dayOfMonth ?? this.dayOfMonth,
|
||||||
|
currency: currency ?? this.currency,
|
||||||
|
source: source ?? this.source,
|
||||||
|
amountRule: amountRule ?? this.amountRule,
|
||||||
|
merchantKey: merchantKey ?? this.merchantKey,
|
||||||
|
sourceTxId: clearSourceTxId ? null : (sourceTxId ?? this.sourceTxId),
|
||||||
|
labelId: clearLabelId ? null : (labelId ?? this.labelId),
|
||||||
|
avgCount: avgCount ?? this.avgCount,
|
||||||
|
autoFromRecurring: autoFromRecurring ?? this.autoFromRecurring,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'dayOfMonth': dayOfMonth,
|
||||||
|
'currency': currency,
|
||||||
|
'source': source.name,
|
||||||
|
'amountRule': amountRule.name,
|
||||||
|
'merchantKey': merchantKey,
|
||||||
|
'sourceTxId': sourceTxId,
|
||||||
|
'labelId': labelId,
|
||||||
|
'avgCount': avgCount,
|
||||||
|
'autoFromRecurring': autoFromRecurring,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory RecurringTemplate.fromJson(Map<String, dynamic> json) {
|
||||||
|
return RecurringTemplate(
|
||||||
|
id: json['id'] as String? ?? '',
|
||||||
|
name: json['name'] as String? ?? 'Recurring',
|
||||||
|
dayOfMonth: (json['dayOfMonth'] as num?)?.toInt().clamp(1, 28) ?? 1,
|
||||||
|
currency: json['currency'] as String? ?? 'EUR',
|
||||||
|
source: _parseTemplateSource(json['source'] as String?),
|
||||||
|
amountRule: _parseAmountRule(json['amountRule'] as String?),
|
||||||
|
merchantKey: json['merchantKey'] as String? ?? '',
|
||||||
|
sourceTxId: json['sourceTxId'] as String?,
|
||||||
|
labelId: json['labelId'] as String?,
|
||||||
|
avgCount: (json['avgCount'] as num?)?.toInt().clamp(1, 36) ?? 5,
|
||||||
|
autoFromRecurring: json['autoFromRecurring'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable id for auto templates linked to a bank transaction.
|
||||||
|
static String autoIdForTx(String txId) => 'auto_${txId.hashCode.toRadixString(16)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// Strip bank noise so supermarket / POS lines group by merchant stem.
|
/// Strip bank noise so supermarket / POS lines group by merchant stem.
|
||||||
///
|
///
|
||||||
/// Example: `BCK*AH CAMPUS DIEMEN ZUID NLD Pasvolgnr:…` → `ah campus`
|
/// Example: `BCK*AH CAMPUS DIEMEN ZUID NLD Pasvolgnr:…` → `ah campus`
|
||||||
|
|||||||
@@ -158,7 +158,10 @@ class BankController extends ChangeNotifier {
|
|||||||
/// Union cloud txs into the local bank cache without dropping either side.
|
/// Union cloud txs into the local bank cache without dropping either side.
|
||||||
///
|
///
|
||||||
/// Rows land in the API cache; CSV imports that match are deduped away.
|
/// Rows land in the API cache; CSV imports that match are deduped away.
|
||||||
Future<void> applySyncedTransactions(List<BankTransaction> fromCloud) async {
|
Future<void> applySyncedTransactions(
|
||||||
|
List<BankTransaction> fromCloud, {
|
||||||
|
bool notify = true,
|
||||||
|
}) async {
|
||||||
if (fromCloud.isEmpty) return;
|
if (fromCloud.isEmpty) return;
|
||||||
|
|
||||||
_remoteTransactions = mergeTransactions(_remoteTransactions, fromCloud);
|
_remoteTransactions = mergeTransactions(_remoteTransactions, fromCloud);
|
||||||
@@ -181,7 +184,7 @@ class BankController extends ChangeNotifier {
|
|||||||
|
|
||||||
await _persistCache();
|
await _persistCache();
|
||||||
await _persistImported();
|
await _persistImported();
|
||||||
notifyListeners();
|
if (notify) notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load status; if linked, also load balance + transactions.
|
/// Load status; if linked, also load balance + transactions.
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ class SyncController extends ChangeNotifier {
|
|||||||
bool _queuedFullSync = false;
|
bool _queuedFullSync = false;
|
||||||
Timer? _pushDebounce;
|
Timer? _pushDebounce;
|
||||||
Timer? _progressHold;
|
Timer? _progressHold;
|
||||||
|
Timer? _uiNotifyThrottle;
|
||||||
|
var _uiNotifyQueued = false;
|
||||||
String? _lastError;
|
String? _lastError;
|
||||||
DateTime? _lastSyncedAt;
|
DateTime? _lastSyncedAt;
|
||||||
String _lastSyncSummary = '';
|
String _lastSyncSummary = '';
|
||||||
@@ -64,6 +66,7 @@ class SyncController extends ChangeNotifier {
|
|||||||
double _syncProgress = 0;
|
double _syncProgress = 0;
|
||||||
bool _showProgressRing = false;
|
bool _showProgressRing = false;
|
||||||
bool _progressSuccess = false;
|
bool _progressSuccess = false;
|
||||||
|
int _lastNotifiedProgressPct = -1;
|
||||||
|
|
||||||
bool get isSyncing => _syncing;
|
bool get isSyncing => _syncing;
|
||||||
String? get lastError => _lastError;
|
String? get lastError => _lastError;
|
||||||
@@ -86,6 +89,25 @@ class SyncController extends ChangeNotifier {
|
|||||||
/// Ring turns green when this is true (sync finished successfully).
|
/// Ring turns green when this is true (sync finished successfully).
|
||||||
bool get progressSuccess => _progressSuccess;
|
bool get progressSuccess => _progressSuccess;
|
||||||
|
|
||||||
|
void _notifyUiNow() {
|
||||||
|
_uiNotifyThrottle?.cancel();
|
||||||
|
_uiNotifyQueued = false;
|
||||||
|
_lastNotifiedProgressPct = (_syncProgress * 100).round();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coalesce high-frequency progress/status into ~10 Hz UI updates.
|
||||||
|
void _notifyUiThrottled() {
|
||||||
|
if (_uiNotifyQueued) return;
|
||||||
|
_uiNotifyQueued = true;
|
||||||
|
_uiNotifyThrottle?.cancel();
|
||||||
|
_uiNotifyThrottle = Timer(const Duration(milliseconds: 100), () {
|
||||||
|
_uiNotifyQueued = false;
|
||||||
|
_lastNotifiedProgressPct = (_syncProgress * 100).round();
|
||||||
|
notifyListeners();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
void _announce(String message, {bool isProgress = false, bool isError = false}) {
|
void _announce(String message, {bool isProgress = false, bool isError = false}) {
|
||||||
_feedbackEpoch++;
|
_feedbackEpoch++;
|
||||||
_feedback = SyncFeedback(
|
_feedback = SyncFeedback(
|
||||||
@@ -94,24 +116,38 @@ class SyncController extends ChangeNotifier {
|
|||||||
isProgress: isProgress,
|
isProgress: isProgress,
|
||||||
isError: isError,
|
isError: isError,
|
||||||
);
|
);
|
||||||
notifyListeners();
|
_notifyUiNow();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _setProgress(double value, {bool success = false}) {
|
void _setProgress(double value, {bool success = false}) {
|
||||||
_syncProgress = value.clamp(0.0, 1.0);
|
final next = value.clamp(0.0, 1.0);
|
||||||
|
final pct = (next * 100).round();
|
||||||
|
_syncProgress = next;
|
||||||
_showProgressRing = true;
|
_showProgressRing = true;
|
||||||
_progressSuccess = success;
|
_progressSuccess = success;
|
||||||
notifyListeners();
|
// Skip tiny progress moves — avatar only shows whole percents.
|
||||||
|
if (!success &&
|
||||||
|
pct == _lastNotifiedProgressPct &&
|
||||||
|
_showProgressRing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (success) {
|
||||||
|
_notifyUiNow();
|
||||||
|
} else {
|
||||||
|
_notifyUiThrottled();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _setStatus(String message) {
|
void _setStatus(String message) {
|
||||||
if (_statusDetail == message) return;
|
if (_statusDetail == message) return;
|
||||||
_statusDetail = message;
|
_statusDetail = message;
|
||||||
notifyListeners();
|
_notifyUiThrottled();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _finishProgress({required bool success}) {
|
void _finishProgress({required bool success}) {
|
||||||
_progressHold?.cancel();
|
_progressHold?.cancel();
|
||||||
|
_uiNotifyThrottle?.cancel();
|
||||||
|
_uiNotifyQueued = false;
|
||||||
_statusDetail = '';
|
_statusDetail = '';
|
||||||
if (success) {
|
if (success) {
|
||||||
_setProgress(1, success: true);
|
_setProgress(1, success: true);
|
||||||
@@ -119,13 +155,15 @@ class SyncController extends ChangeNotifier {
|
|||||||
_showProgressRing = false;
|
_showProgressRing = false;
|
||||||
_progressSuccess = false;
|
_progressSuccess = false;
|
||||||
_syncProgress = 0;
|
_syncProgress = 0;
|
||||||
notifyListeners();
|
_lastNotifiedProgressPct = -1;
|
||||||
|
_notifyUiNow();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
_showProgressRing = false;
|
_showProgressRing = false;
|
||||||
_progressSuccess = false;
|
_progressSuccess = false;
|
||||||
_syncProgress = 0;
|
_syncProgress = 0;
|
||||||
notifyListeners();
|
_lastNotifiedProgressPct = -1;
|
||||||
|
_notifyUiNow();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,12 +176,15 @@ class SyncController extends ChangeNotifier {
|
|||||||
if (!loggedIn) {
|
if (!loggedIn) {
|
||||||
_pushDebounce?.cancel();
|
_pushDebounce?.cancel();
|
||||||
_progressHold?.cancel();
|
_progressHold?.cancel();
|
||||||
|
_uiNotifyThrottle?.cancel();
|
||||||
|
_uiNotifyQueued = false;
|
||||||
_lastError = null;
|
_lastError = null;
|
||||||
_statusDetail = '';
|
_statusDetail = '';
|
||||||
_showProgressRing = false;
|
_showProgressRing = false;
|
||||||
_progressSuccess = false;
|
_progressSuccess = false;
|
||||||
_syncProgress = 0;
|
_syncProgress = 0;
|
||||||
notifyListeners();
|
_lastNotifiedProgressPct = -1;
|
||||||
|
_notifyUiNow();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,8 +232,11 @@ class SyncController extends ChangeNotifier {
|
|||||||
_setProgress(0.5);
|
_setProgress(0.5);
|
||||||
_setStatus('Merging…');
|
_setStatus('Merging…');
|
||||||
final mergedMeta = mergeTxMetaStores(_meta.exportStore(), remoteMeta);
|
final mergedMeta = mergeTxMetaStores(_meta.exportStore(), remoteMeta);
|
||||||
await _meta.replaceStore(mergedMeta);
|
// Apply without notifying — one rebuild after both stores are ready.
|
||||||
await _bank.applySyncedTransactions(remoteTxs);
|
await _meta.replaceStore(mergedMeta, notify: false);
|
||||||
|
await _bank.applySyncedTransactions(remoteTxs, notify: false);
|
||||||
|
_meta.notifyListeners();
|
||||||
|
_bank.notifyListeners();
|
||||||
_setProgress(0.55);
|
_setProgress(0.55);
|
||||||
_setStatus('Pushing…');
|
_setStatus('Pushing…');
|
||||||
final metaSummary = await service.push(
|
final metaSummary = await service.push(
|
||||||
@@ -283,6 +327,7 @@ class SyncController extends ChangeNotifier {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_pushDebounce?.cancel();
|
_pushDebounce?.cancel();
|
||||||
_progressHold?.cancel();
|
_progressHold?.cancel();
|
||||||
|
_uiNotifyThrottle?.cancel();
|
||||||
_auth.removeListener(_onAuthChanged);
|
_auth.removeListener(_onAuthChanged);
|
||||||
_meta.onPersisted = null;
|
_meta.onPersisted = null;
|
||||||
super.dispose();
|
super.dispose();
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class TxMetaController extends ChangeNotifier {
|
|||||||
};
|
};
|
||||||
_notesByMerchant = Map.of(loaded.notesByMerchant);
|
_notesByMerchant = Map.of(loaded.notesByMerchant);
|
||||||
_exactTemplates = Map.of(loaded.exactTemplates);
|
_exactTemplates = Map.of(loaded.exactTemplates);
|
||||||
|
_recurringTemplates = Map.of(loaded.recurringTemplates);
|
||||||
_customLabels = List.of(loaded.customLabels);
|
_customLabels = List.of(loaded.customLabels);
|
||||||
_labelParents = Map.of(loaded.labelParents);
|
_labelParents = Map.of(loaded.labelParents);
|
||||||
}
|
}
|
||||||
@@ -32,6 +33,7 @@ class TxMetaController extends ChangeNotifier {
|
|||||||
Map<String, List<String>> _labelsByMerchant = {};
|
Map<String, List<String>> _labelsByMerchant = {};
|
||||||
Map<String, String> _notesByMerchant = {};
|
Map<String, String> _notesByMerchant = {};
|
||||||
Map<String, TxExactTemplate> _exactTemplates = {};
|
Map<String, TxExactTemplate> _exactTemplates = {};
|
||||||
|
Map<String, RecurringTemplate> _recurringTemplates = {};
|
||||||
List<TxLabel> _customLabels = [];
|
List<TxLabel> _customLabels = [];
|
||||||
Map<String, String> _labelParents = {};
|
Map<String, String> _labelParents = {};
|
||||||
|
|
||||||
@@ -45,6 +47,7 @@ class TxMetaController extends ChangeNotifier {
|
|||||||
},
|
},
|
||||||
notesByMerchant: Map.of(_notesByMerchant),
|
notesByMerchant: Map.of(_notesByMerchant),
|
||||||
exactTemplates: Map.of(_exactTemplates),
|
exactTemplates: Map.of(_exactTemplates),
|
||||||
|
recurringTemplates: Map.of(_recurringTemplates),
|
||||||
customLabels: List.of(_customLabels),
|
customLabels: List.of(_customLabels),
|
||||||
labelParents: Map.of(_labelParents),
|
labelParents: Map.of(_labelParents),
|
||||||
);
|
);
|
||||||
@@ -75,7 +78,10 @@ class TxMetaController extends ChangeNotifier {
|
|||||||
int get customLabelCount => _customLabels.length;
|
int get customLabelCount => _customLabels.length;
|
||||||
|
|
||||||
/// Replace local store (e.g. after merge) and persist without re-triggering push.
|
/// Replace local store (e.g. after merge) and persist without re-triggering push.
|
||||||
Future<void> replaceStore(TxMetaStoreData store) async {
|
Future<void> replaceStore(
|
||||||
|
TxMetaStoreData store, {
|
||||||
|
bool notify = true,
|
||||||
|
}) async {
|
||||||
_byId = Map.of(store.byId);
|
_byId = Map.of(store.byId);
|
||||||
_labelsByMerchant = {
|
_labelsByMerchant = {
|
||||||
for (final e in store.labelsByMerchant.entries)
|
for (final e in store.labelsByMerchant.entries)
|
||||||
@@ -83,10 +89,12 @@ class TxMetaController extends ChangeNotifier {
|
|||||||
};
|
};
|
||||||
_notesByMerchant = Map.of(store.notesByMerchant);
|
_notesByMerchant = Map.of(store.notesByMerchant);
|
||||||
_exactTemplates = Map.of(store.exactTemplates);
|
_exactTemplates = Map.of(store.exactTemplates);
|
||||||
|
_recurringTemplates = Map.of(store.recurringTemplates);
|
||||||
_customLabels = List.of(store.customLabels);
|
_customLabels = List.of(store.customLabels);
|
||||||
_labelParents = Map.of(store.labelParents);
|
_labelParents = Map.of(store.labelParents);
|
||||||
_invalidateLabelCache();
|
_invalidateLabelCache();
|
||||||
notifyListeners();
|
_storeRevision++;
|
||||||
|
if (notify) notifyListeners();
|
||||||
await _writeStore(notifySync: false);
|
await _writeStore(notifySync: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,12 +404,16 @@ class TxMetaController extends ChangeNotifier {
|
|||||||
String? pattern,
|
String? pattern,
|
||||||
SimilarMatchCriteria? criteria,
|
SimilarMatchCriteria? criteria,
|
||||||
}) {
|
}) {
|
||||||
return similarTo(
|
final query = criteria ??
|
||||||
prototype,
|
SimilarMatchCriteria(
|
||||||
all,
|
pattern: (pattern ?? prototype.merchantKey).trim(),
|
||||||
pattern: pattern,
|
);
|
||||||
criteria: criteria,
|
if (query.pattern.trim().isEmpty) return 1;
|
||||||
).length;
|
var n = 0;
|
||||||
|
for (final tx in all) {
|
||||||
|
if (txMatchesSimilarCriteria(tx, query)) n++;
|
||||||
|
}
|
||||||
|
return n == 0 ? 1 : n;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<BankTransaction> similarTo(
|
List<BankTransaction> similarTo(
|
||||||
@@ -630,23 +642,113 @@ class TxMetaController extends ChangeNotifier {
|
|||||||
_schedulePersist();
|
_schedulePersist();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Recurring look-ahead templates (manual + auto from marked txs).
|
||||||
|
List<RecurringTemplate> get recurringTemplates {
|
||||||
|
final list = _recurringTemplates.values.toList(growable: false);
|
||||||
|
list.sort((a, b) {
|
||||||
|
final day = a.dayOfMonth.compareTo(b.dayOfMonth);
|
||||||
|
if (day != 0) return day;
|
||||||
|
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
RecurringTemplate? recurringTemplateById(String id) =>
|
||||||
|
_recurringTemplates[id];
|
||||||
|
|
||||||
|
void upsertRecurringTemplate(RecurringTemplate template) {
|
||||||
|
if (template.id.isEmpty) return;
|
||||||
|
_recurringTemplates[template.id] = template;
|
||||||
|
_schedulePersist(invalidateLabels: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeRecurringTemplate(String id) {
|
||||||
|
if (!_recurringTemplates.containsKey(id)) return;
|
||||||
|
_recurringTemplates.remove(id);
|
||||||
|
_schedulePersist(invalidateLabels: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Backfill auto templates for txs already marked recurring (upgrade path).
|
||||||
|
void ensureAutoRecurringTemplates(List<BankTransaction> transactions) {
|
||||||
|
var changed = false;
|
||||||
|
final recurringIds = <String>{};
|
||||||
|
for (final tx in transactions) {
|
||||||
|
if (!metaFor(tx).recurring) continue;
|
||||||
|
recurringIds.add(tx.id);
|
||||||
|
final id = RecurringTemplate.autoIdForTx(tx.id);
|
||||||
|
if (_recurringTemplates.containsKey(id)) continue;
|
||||||
|
_recurringTemplates[id] = RecurringTemplate(
|
||||||
|
id: id,
|
||||||
|
name: tx.description,
|
||||||
|
dayOfMonth: (DateTime.tryParse(tx.date)?.day ?? 1).clamp(1, 28),
|
||||||
|
currency: tx.currency,
|
||||||
|
source: RecurringTemplateSource.transaction,
|
||||||
|
amountRule: RecurringAmountRule.lastValue,
|
||||||
|
merchantKey: tx.merchantKey,
|
||||||
|
sourceTxId: tx.id,
|
||||||
|
avgCount: 5,
|
||||||
|
autoFromRecurring: true,
|
||||||
|
);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
// Drop orphaned auto templates whose source is no longer recurring.
|
||||||
|
final orphanIds = [
|
||||||
|
for (final e in _recurringTemplates.entries)
|
||||||
|
if (e.value.autoFromRecurring &&
|
||||||
|
(e.value.sourceTxId == null ||
|
||||||
|
!recurringIds.contains(e.value.sourceTxId)))
|
||||||
|
e.key,
|
||||||
|
];
|
||||||
|
for (final id in orphanIds) {
|
||||||
|
_recurringTemplates.remove(id);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (changed) _schedulePersist(invalidateLabels: false);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> setRecurring(BankTransaction tx, bool recurring) async {
|
Future<void> setRecurring(BankTransaction tx, bool recurring) async {
|
||||||
final current = metaFor(tx);
|
final current = metaFor(tx);
|
||||||
_byId = {
|
_byId[tx.id] = current.copyWith(recurring: recurring);
|
||||||
..._byId,
|
|
||||||
tx.id: current.copyWith(recurring: recurring),
|
|
||||||
};
|
|
||||||
final existing = _exactTemplates[tx.exactKey] ?? const TxExactTemplate();
|
final existing = _exactTemplates[tx.exactKey] ?? const TxExactTemplate();
|
||||||
_exactTemplates = {
|
_exactTemplates[tx.exactKey] = TxExactTemplate(
|
||||||
..._exactTemplates,
|
|
||||||
tx.exactKey: TxExactTemplate(
|
|
||||||
notes: existing.notes.isNotEmpty ? existing.notes : current.notes,
|
notes: existing.notes.isNotEmpty ? existing.notes : current.notes,
|
||||||
recurring: recurring,
|
recurring: recurring,
|
||||||
internalTransfer:
|
internalTransfer: existing.internalTransfer || current.internalTransfer,
|
||||||
existing.internalTransfer || current.internalTransfer,
|
);
|
||||||
),
|
if (recurring) {
|
||||||
};
|
final id = RecurringTemplate.autoIdForTx(tx.id);
|
||||||
_schedulePersist();
|
final prev = _recurringTemplates[id];
|
||||||
|
_recurringTemplates[id] = RecurringTemplate(
|
||||||
|
id: id,
|
||||||
|
name: prev?.name ?? tx.description,
|
||||||
|
dayOfMonth: prev?.dayOfMonth ??
|
||||||
|
(DateTime.tryParse(tx.date)?.day ?? 1).clamp(1, 28),
|
||||||
|
currency: prev?.currency ?? tx.currency,
|
||||||
|
source: RecurringTemplateSource.transaction,
|
||||||
|
amountRule: prev?.amountRule ?? RecurringAmountRule.lastValue,
|
||||||
|
merchantKey: prev?.merchantKey.isNotEmpty == true
|
||||||
|
? prev!.merchantKey
|
||||||
|
: tx.merchantKey,
|
||||||
|
sourceTxId: tx.id,
|
||||||
|
labelId: prev?.labelId,
|
||||||
|
avgCount: prev?.avgCount ?? 5,
|
||||||
|
autoFromRecurring: true,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
final id = RecurringTemplate.autoIdForTx(tx.id);
|
||||||
|
final existingTpl = _recurringTemplates[id];
|
||||||
|
if (existingTpl != null && existingTpl.autoFromRecurring) {
|
||||||
|
_recurringTemplates.remove(id);
|
||||||
|
}
|
||||||
|
// Also drop auto templates that still point at this tx under another id.
|
||||||
|
_recurringTemplates.removeWhere(
|
||||||
|
(_, tpl) =>
|
||||||
|
tpl.autoFromRecurring &&
|
||||||
|
tpl.sourceTxId == tx.id &&
|
||||||
|
tpl.id != id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_schedulePersist(invalidateLabels: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setInternalTransfer(
|
Future<void> setInternalTransfer(
|
||||||
@@ -654,20 +756,14 @@ class TxMetaController extends ChangeNotifier {
|
|||||||
bool internalTransfer,
|
bool internalTransfer,
|
||||||
) async {
|
) async {
|
||||||
final current = metaFor(tx);
|
final current = metaFor(tx);
|
||||||
_byId = {
|
_byId[tx.id] = current.copyWith(internalTransfer: internalTransfer);
|
||||||
..._byId,
|
|
||||||
tx.id: current.copyWith(internalTransfer: internalTransfer),
|
|
||||||
};
|
|
||||||
final existing = _exactTemplates[tx.exactKey] ?? const TxExactTemplate();
|
final existing = _exactTemplates[tx.exactKey] ?? const TxExactTemplate();
|
||||||
_exactTemplates = {
|
_exactTemplates[tx.exactKey] = TxExactTemplate(
|
||||||
..._exactTemplates,
|
|
||||||
tx.exactKey: TxExactTemplate(
|
|
||||||
notes: existing.notes.isNotEmpty ? existing.notes : current.notes,
|
notes: existing.notes.isNotEmpty ? existing.notes : current.notes,
|
||||||
recurring: existing.recurring || current.recurring,
|
recurring: existing.recurring || current.recurring,
|
||||||
internalTransfer: internalTransfer,
|
internalTransfer: internalTransfer,
|
||||||
),
|
);
|
||||||
};
|
_schedulePersist(invalidateLabels: false);
|
||||||
_schedulePersist();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setNotes(
|
Future<void> setNotes(
|
||||||
@@ -824,27 +920,35 @@ class TxMetaController extends ChangeNotifier {
|
|||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// UI-first: notify on the next frame so callers can paint a busy state first,
|
int _persistGeneration = 0;
|
||||||
/// then write SharedPreferences in the background.
|
int _storeRevision = 0;
|
||||||
void _schedulePersist() {
|
|
||||||
_invalidateLabelCache();
|
/// Bumps on every local mutation — cheap cache key for look-ahead etc.
|
||||||
final binding = WidgetsBinding.instance;
|
int get storeRevision => _storeRevision;
|
||||||
binding.addPostFrameCallback((_) {
|
|
||||||
|
/// Notify immediately; persist after the frame so toggles paint first.
|
||||||
|
void _schedulePersist({bool invalidateLabels = true}) {
|
||||||
|
if (invalidateLabels) _invalidateLabelCache();
|
||||||
|
_storeRevision++;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
});
|
final gen = ++_persistGeneration;
|
||||||
// Ensure a frame is scheduled even if nothing else is dirty yet.
|
unawaited(_writeStoreDeferred(gen));
|
||||||
binding.scheduleFrame();
|
}
|
||||||
unawaited(_writeStore());
|
|
||||||
|
Future<void> _writeStoreDeferred(int gen) async {
|
||||||
|
await WidgetsBinding.instance.endOfFrame;
|
||||||
|
// Coalesce rapid edits — only the latest snapshot is written.
|
||||||
|
if (gen != _persistGeneration) return;
|
||||||
|
await _writeStore();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _writeStore({bool notifySync = true}) async {
|
Future<void> _writeStore({bool notifySync = true}) async {
|
||||||
// Let the busy indicator / label checkbox paint before JSON work.
|
|
||||||
await Future<void>.delayed(Duration.zero);
|
|
||||||
final snapshot = TxMetaStoreData(
|
final snapshot = TxMetaStoreData(
|
||||||
byId: _byId,
|
byId: _byId,
|
||||||
labelsByMerchant: _labelsByMerchant,
|
labelsByMerchant: _labelsByMerchant,
|
||||||
notesByMerchant: _notesByMerchant,
|
notesByMerchant: _notesByMerchant,
|
||||||
exactTemplates: _exactTemplates,
|
exactTemplates: _exactTemplates,
|
||||||
|
recurringTemplates: _recurringTemplates,
|
||||||
customLabels: [
|
customLabels: [
|
||||||
for (final label in _customLabels)
|
for (final label in _customLabels)
|
||||||
label.copyWith(
|
label.copyWith(
|
||||||
|
|||||||
@@ -17,23 +17,11 @@ class AccountScreen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final auth = context.watch<AuthController>();
|
final auth = context.watch<AuthController>();
|
||||||
final sync = context.watch<SyncController>();
|
|
||||||
final meta = context.watch<TxMetaController>();
|
final meta = context.watch<TxMetaController>();
|
||||||
final bank = context.watch<BankController>();
|
final bank = context.watch<BankController>();
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final colors = theme.colorScheme;
|
final colors = theme.colorScheme;
|
||||||
|
|
||||||
final lastSync = sync.lastSyncedAt;
|
|
||||||
final syncSubtitle = sync.isSyncing
|
|
||||||
? (sync.statusDetail.isEmpty
|
|
||||||
? 'Syncing… ${(sync.syncProgress * 100).round()}%'
|
|
||||||
: '${sync.statusDetail} · ${(sync.syncProgress * 100).round()}%')
|
|
||||||
: sync.lastError != null
|
|
||||||
? 'Last error: ${sync.lastError}'
|
|
||||||
: lastSync == null
|
|
||||||
? 'Not synced yet'
|
|
||||||
: 'Last sync ${_formatTime(lastSync)}';
|
|
||||||
|
|
||||||
final categorized = meta.labeledTransactionCount(bank.transactions);
|
final categorized = meta.labeledTransactionCount(bank.transactions);
|
||||||
final perTxAnnotations = meta.syncableAnnotationCount;
|
final perTxAnnotations = meta.syncableAnnotationCount;
|
||||||
final viaMerchantOnly = meta.merchantOnlyLabeledCount(bank.transactions);
|
final viaMerchantOnly = meta.merchantOnlyLabeledCount(bank.transactions);
|
||||||
@@ -83,6 +71,65 @@ class AccountScreen extends StatelessWidget {
|
|||||||
subtitle: Text(kPocketBaseUrl),
|
subtitle: Text(kPocketBaseUrl),
|
||||||
),
|
),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
|
const _CloudSyncTile(),
|
||||||
|
const Divider(height: 1),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.phone_android_outlined),
|
||||||
|
title: const Text('What syncs'),
|
||||||
|
subtitle: Text(
|
||||||
|
'Categorized on device: $categorized\n'
|
||||||
|
'· Per-tx annotations → PocketBase tx_meta: $perTxAnnotations\n'
|
||||||
|
'· Via merchant rules only (not separate tx_meta rows): $viaMerchantOnly\n'
|
||||||
|
'Merchant rules synced: ${meta.merchantRuleCount}\n'
|
||||||
|
'Bank transactions → PocketBase transactions: '
|
||||||
|
'${bank.transactions.length}',
|
||||||
|
),
|
||||||
|
isThreeLine: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () async {
|
||||||
|
await auth.logout();
|
||||||
|
if (!context.mounted) return;
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.logout),
|
||||||
|
label: const Text('Sign out'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Isolated so sync progress ticks do not rebuild account stats.
|
||||||
|
class _CloudSyncTile extends StatelessWidget {
|
||||||
|
const _CloudSyncTile();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: context.read<SyncController>(),
|
||||||
|
builder: (context, _) {
|
||||||
|
final sync = context.read<SyncController>();
|
||||||
|
final lastSync = sync.lastSyncedAt;
|
||||||
|
final syncSubtitle = sync.isSyncing
|
||||||
|
? (sync.statusDetail.isEmpty
|
||||||
|
? 'Syncing… ${(sync.syncProgress * 100).round()}%'
|
||||||
|
: '${sync.statusDetail} · ${(sync.syncProgress * 100).round()}%')
|
||||||
|
: sync.lastError != null
|
||||||
|
? 'Last error: ${sync.lastError}'
|
||||||
|
: lastSync == null
|
||||||
|
? 'Not synced yet'
|
||||||
|
: 'Last sync ${_formatTime(lastSync)}';
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: sync.isSyncing
|
leading: sync.isSyncing
|
||||||
? SizedBox(
|
? SizedBox(
|
||||||
@@ -118,36 +165,9 @@ class AccountScreen extends StatelessWidget {
|
|||||||
subtitle: Text(sync.lastSyncSummary),
|
subtitle: Text(sync.lastSyncSummary),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const Divider(height: 1),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.phone_android_outlined),
|
|
||||||
title: const Text('What syncs'),
|
|
||||||
subtitle: Text(
|
|
||||||
'Categorized on device: $categorized\n'
|
|
||||||
'· Per-tx annotations → PocketBase tx_meta: $perTxAnnotations\n'
|
|
||||||
'· Via merchant rules only (not separate tx_meta rows): $viaMerchantOnly\n'
|
|
||||||
'Merchant rules synced: ${meta.merchantRuleCount}\n'
|
|
||||||
'Bank transactions → PocketBase transactions: '
|
|
||||||
'${bank.transactions.length}',
|
|
||||||
),
|
|
||||||
isThreeLine: true,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: () async {
|
|
||||||
await auth.logout();
|
|
||||||
if (!context.mounted) return;
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.logout),
|
|
||||||
label: const Text('Sign out'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import '../utils/navigation.dart';
|
|||||||
import '../widgets/empty_state.dart';
|
import '../widgets/empty_state.dart';
|
||||||
import '../widgets/loading_view.dart';
|
import '../widgets/loading_view.dart';
|
||||||
import '../widgets/responsive_body.dart';
|
import '../widgets/responsive_body.dart';
|
||||||
|
import '../widgets/shell_tab_scope.dart';
|
||||||
import '../widgets/skip_build_when_covered.dart';
|
import '../widgets/skip_build_when_covered.dart';
|
||||||
import 'transaction_detail_screen.dart';
|
import 'transaction_detail_screen.dart';
|
||||||
import 'transactions_screen.dart';
|
import 'transactions_screen.dart';
|
||||||
@@ -20,8 +21,16 @@ import 'transactions_screen.dart';
|
|||||||
class HomeScreen extends StatelessWidget {
|
class HomeScreen extends StatelessWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
|
|
||||||
|
static const tabIndex = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
// Inactive IndexedStack tabs must not watch providers — otherwise every
|
||||||
|
// annotation save rebuilds Home while the user is on another tab.
|
||||||
|
if (ShellTabScope.of(context) != tabIndex) {
|
||||||
|
return const SizedBox.expand();
|
||||||
|
}
|
||||||
|
|
||||||
final bank = context.watch<BankController>();
|
final bank = context.watch<BankController>();
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|||||||
@@ -0,0 +1,625 @@
|
|||||||
|
/// Look ahead: projected balance from recurring templates through a date.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../models/bank.dart';
|
||||||
|
import '../models/transaction_meta.dart';
|
||||||
|
import '../providers/bank_provider.dart';
|
||||||
|
import '../providers/tx_meta_provider.dart';
|
||||||
|
import '../screens/transaction_detail_screen.dart';
|
||||||
|
import '../utils/app_theme.dart';
|
||||||
|
import '../utils/helpers.dart';
|
||||||
|
import '../utils/look_ahead.dart';
|
||||||
|
import '../utils/navigation.dart';
|
||||||
|
import '../utils/tx_calendar.dart';
|
||||||
|
import '../widgets/empty_state.dart';
|
||||||
|
import '../widgets/recurring_template_sheet.dart';
|
||||||
|
import '../widgets/responsive_body.dart';
|
||||||
|
import '../widgets/shell_tab_scope.dart';
|
||||||
|
import '../widgets/skip_build_when_covered.dart';
|
||||||
|
|
||||||
|
enum _PeriodMode { monthly, yearly }
|
||||||
|
|
||||||
|
class LookAheadScreen extends StatefulWidget {
|
||||||
|
const LookAheadScreen({super.key});
|
||||||
|
|
||||||
|
static const tabIndex = 2;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LookAheadScreen> createState() => _LookAheadScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LookAheadScreenState extends State<LookAheadScreen> {
|
||||||
|
var _period = _PeriodMode.monthly;
|
||||||
|
late DateTime _cursor;
|
||||||
|
var _didBackfill = false;
|
||||||
|
List<RecurringItem>? _cachedItems;
|
||||||
|
int? _itemsCacheKey;
|
||||||
|
|
||||||
|
static const _maxYearsAhead = 5;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final today = dateOnly(DateTime.now());
|
||||||
|
// Default: end of next calendar month.
|
||||||
|
_cursor = monthStart(DateTime(today.year, today.month + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime get _today => dateOnly(DateTime.now());
|
||||||
|
|
||||||
|
DateTime get _minMonth => monthStart(_today);
|
||||||
|
|
||||||
|
DateTime get _maxMonth {
|
||||||
|
final t = _today;
|
||||||
|
return DateTime(t.year + _maxYearsAhead, t.month);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inclusive end date for the selected period.
|
||||||
|
DateTime get _through {
|
||||||
|
if (_period == _PeriodMode.yearly) {
|
||||||
|
return DateTime(_cursor.year, 12, 31);
|
||||||
|
}
|
||||||
|
return DateTime(_cursor.year, _cursor.month + 1, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
String get _periodTitle {
|
||||||
|
if (_period == _PeriodMode.yearly) return '${_cursor.year}';
|
||||||
|
return formatMonthYear(_cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _canGoBack {
|
||||||
|
if (_period == _PeriodMode.yearly) {
|
||||||
|
return _cursor.year > _today.year;
|
||||||
|
}
|
||||||
|
return monthStart(_cursor).isAfter(_minMonth);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _canGoForward {
|
||||||
|
if (_period == _PeriodMode.yearly) {
|
||||||
|
return _cursor.year < _today.year + _maxYearsAhead;
|
||||||
|
}
|
||||||
|
return monthStart(_cursor).isBefore(_maxMonth);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setPeriod(_PeriodMode mode) {
|
||||||
|
if (mode == _period) return;
|
||||||
|
setState(() {
|
||||||
|
_period = mode;
|
||||||
|
if (mode == _PeriodMode.yearly) {
|
||||||
|
_cursor = DateTime(_cursor.year);
|
||||||
|
if (_cursor.year < _today.year) _cursor = DateTime(_today.year);
|
||||||
|
if (_cursor.year > _today.year + _maxYearsAhead) {
|
||||||
|
_cursor = DateTime(_today.year + _maxYearsAhead);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final month = DateTime(_cursor.year, _today.month);
|
||||||
|
_cursor = month.isBefore(_minMonth) ? _minMonth : monthStart(month);
|
||||||
|
if (_cursor.isAfter(_maxMonth)) _cursor = _maxMonth;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _go(int delta) {
|
||||||
|
setState(() {
|
||||||
|
if (_period == _PeriodMode.yearly) {
|
||||||
|
final next = DateTime(_cursor.year + delta);
|
||||||
|
if (next.year < _today.year ||
|
||||||
|
next.year > _today.year + _maxYearsAhead) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_cursor = next;
|
||||||
|
} else {
|
||||||
|
final next = addMonths(monthStart(_cursor), delta);
|
||||||
|
if (next.isBefore(_minMonth) || next.isAfter(_maxMonth)) return;
|
||||||
|
_cursor = next;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (ShellTabScope.of(context) != LookAheadScreen.tabIndex) {
|
||||||
|
return const SizedBox.expand();
|
||||||
|
}
|
||||||
|
return SkipBuildWhenCovered(
|
||||||
|
builder: (context) => _buildContent(context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent(BuildContext context) {
|
||||||
|
final bank = context.watch<BankController>();
|
||||||
|
final meta = context.watch<TxMetaController>();
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colors = theme.colorScheme;
|
||||||
|
|
||||||
|
if (!_didBackfill && bank.transactions.isNotEmpty) {
|
||||||
|
_didBackfill = true;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
context
|
||||||
|
.read<TxMetaController>()
|
||||||
|
.ensureAutoRecurringTemplates(bank.transactions);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
final balance = bank.balance;
|
||||||
|
final currency = balance?.currency ??
|
||||||
|
(bank.transactions.isNotEmpty
|
||||||
|
? bank.transactions.first.currency
|
||||||
|
: 'EUR');
|
||||||
|
final currentAmount = balance?.amount ?? 0;
|
||||||
|
final today = _today;
|
||||||
|
final through = _through;
|
||||||
|
final cacheKey = Object.hash(
|
||||||
|
meta.storeRevision,
|
||||||
|
identityHashCode(bank.transactions),
|
||||||
|
);
|
||||||
|
if (_cachedItems == null || _itemsCacheKey != cacheKey) {
|
||||||
|
_itemsCacheKey = cacheKey;
|
||||||
|
_cachedItems = recurringItemsFromTemplates(
|
||||||
|
templates: meta.recurringTemplates,
|
||||||
|
transactions: bank.transactions,
|
||||||
|
isInternalTransfer: (tx) => meta.metaFor(tx).internalTransfer,
|
||||||
|
labelIdsFor: meta.labelIdsFor,
|
||||||
|
labelName: (id) => meta.labelById(id)?.name ?? 'label',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final items = _cachedItems!;
|
||||||
|
|
||||||
|
final projected = projectedOccurrences(
|
||||||
|
items: items,
|
||||||
|
from: today,
|
||||||
|
to: through,
|
||||||
|
);
|
||||||
|
final predicted = projected.fold<double>(
|
||||||
|
currentAmount,
|
||||||
|
(sum, row) => sum + row.item.amount,
|
||||||
|
);
|
||||||
|
final delta = predicted - currentAmount;
|
||||||
|
|
||||||
|
var income = 0.0;
|
||||||
|
var expenses = 0.0;
|
||||||
|
for (final row in projected) {
|
||||||
|
if (row.item.amount >= 0) {
|
||||||
|
income += row.item.amount;
|
||||||
|
} else {
|
||||||
|
expenses += -row.item.amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Look ahead'),
|
||||||
|
actions: bankAppBarActions(context: context),
|
||||||
|
),
|
||||||
|
body: ResponsiveBody(
|
||||||
|
child: ListView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.fromLTRB(0, 8, 0, 32),
|
||||||
|
children: [
|
||||||
|
_PeriodToolbar(
|
||||||
|
period: _period,
|
||||||
|
onPeriodChanged: _setPeriod,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_PeriodNavigator(
|
||||||
|
title: _periodTitle,
|
||||||
|
canGoBack: _canGoBack,
|
||||||
|
canGoForward: _canGoForward,
|
||||||
|
onBack: () => _go(-1),
|
||||||
|
onForward: () => _go(1),
|
||||||
|
backTooltip:
|
||||||
|
_period == _PeriodMode.yearly ? 'Previous year' : 'Previous month',
|
||||||
|
forwardTooltip:
|
||||||
|
_period == _PeriodMode.yearly ? 'Next year' : 'Next month',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: Ink(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [colors.primary, colors.tertiary],
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: colors.primary.withValues(alpha: 0.28),
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: const Offset(0, 10),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Projected balance',
|
||||||
|
style: theme.textTheme.labelMedium?.copyWith(
|
||||||
|
color: colors.onPrimary.withValues(alpha: 0.8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'As of ${_formatLongDate(through)}',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
color: colors.onPrimary,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
formatMoney(predicted, currency),
|
||||||
|
style: theme.textTheme.headlineMedium?.copyWith(
|
||||||
|
color: colors.onPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
balance == null
|
||||||
|
? 'No current balance loaded — projection starts at 0'
|
||||||
|
: 'Now ${formatMoney(currentAmount, currency)}'
|
||||||
|
' · ${delta >= 0 ? '+' : ''}'
|
||||||
|
'${formatMoney(delta, currency)} by then',
|
||||||
|
style: theme.textTheme.labelMedium?.copyWith(
|
||||||
|
color: colors.onPrimary.withValues(alpha: 0.9),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _SummaryCard(
|
||||||
|
icon: Icons.south_west,
|
||||||
|
iconBackground: AppTheme.accent.withValues(alpha: 0.18),
|
||||||
|
iconColor: AppTheme.accent,
|
||||||
|
amount: formatMoney(income, currency),
|
||||||
|
label: 'Income',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _SummaryCard(
|
||||||
|
icon: Icons.north_east,
|
||||||
|
iconBackground: AppTheme.expense.withValues(alpha: 0.18),
|
||||||
|
iconColor: AppTheme.expense,
|
||||||
|
amount: formatMoney(expenses, currency),
|
||||||
|
label: 'Expenses',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Recurring templates',
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () => showCreateRecurringTemplateSheet(context),
|
||||||
|
icon: const Icon(Icons.add, size: 18),
|
||||||
|
label: const Text('New'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
items.isEmpty
|
||||||
|
? 'Mark transactions as recurring, or create a template.'
|
||||||
|
: '${items.length} template${items.length == 1 ? '' : 's'} · '
|
||||||
|
'projected income ${formatMoney(income, currency)} · '
|
||||||
|
'expenses ${formatMoney(expenses, currency)}.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
if (items.isEmpty)
|
||||||
|
const EmptyState(
|
||||||
|
icon: Icons.event_repeat_outlined,
|
||||||
|
title: 'No recurring templates',
|
||||||
|
message:
|
||||||
|
'Mark a transaction as recurring, or tap New to base a '
|
||||||
|
'template on a transaction or label average.',
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Card(
|
||||||
|
color: colors.surfaceContainer,
|
||||||
|
elevation: 0,
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < items.length; i++) ...[
|
||||||
|
if (i > 0)
|
||||||
|
Divider(
|
||||||
|
height: 1,
|
||||||
|
indent: 16,
|
||||||
|
color: colors.outlineVariant.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
_TemplateTile(
|
||||||
|
item: items[i],
|
||||||
|
template:
|
||||||
|
meta.recurringTemplateById(items[i].templateId),
|
||||||
|
transactions: bank.transactions,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PeriodToolbar extends StatelessWidget {
|
||||||
|
const _PeriodToolbar({
|
||||||
|
required this.period,
|
||||||
|
required this.onPeriodChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final _PeriodMode period;
|
||||||
|
final ValueChanged<_PeriodMode> onPeriodChanged;
|
||||||
|
|
||||||
|
static const _segmentStyle = ButtonStyle(
|
||||||
|
visualDensity: VisualDensity(horizontal: -4, vertical: -3),
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
padding: WidgetStatePropertyAll(EdgeInsets.symmetric(horizontal: 10)),
|
||||||
|
minimumSize: WidgetStatePropertyAll(Size(0, 32)),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: SegmentedButton<_PeriodMode>(
|
||||||
|
showSelectedIcon: false,
|
||||||
|
style: _segmentStyle,
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(
|
||||||
|
value: _PeriodMode.monthly,
|
||||||
|
label: Text('Month'),
|
||||||
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: _PeriodMode.yearly,
|
||||||
|
label: Text('Year'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
selected: {period},
|
||||||
|
onSelectionChanged: (selection) {
|
||||||
|
onPeriodChanged(selection.first);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PeriodNavigator extends StatelessWidget {
|
||||||
|
const _PeriodNavigator({
|
||||||
|
required this.title,
|
||||||
|
required this.canGoBack,
|
||||||
|
required this.canGoForward,
|
||||||
|
required this.onBack,
|
||||||
|
required this.onForward,
|
||||||
|
required this.backTooltip,
|
||||||
|
required this.forwardTooltip,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final bool canGoBack;
|
||||||
|
final bool canGoForward;
|
||||||
|
final VoidCallback onBack;
|
||||||
|
final VoidCallback onForward;
|
||||||
|
final String backTooltip;
|
||||||
|
final String forwardTooltip;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: backTooltip,
|
||||||
|
onPressed: canGoBack ? onBack : null,
|
||||||
|
visualDensity: const VisualDensity(horizontal: -4, vertical: -4),
|
||||||
|
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
icon: const Icon(Icons.chevron_left, size: 22),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: forwardTooltip,
|
||||||
|
onPressed: canGoForward ? onForward : null,
|
||||||
|
visualDensity: const VisualDensity(horizontal: -4, vertical: -4),
|
||||||
|
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
icon: const Icon(Icons.chevron_right, size: 22),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SummaryCard extends StatelessWidget {
|
||||||
|
const _SummaryCard({
|
||||||
|
required this.icon,
|
||||||
|
required this.iconBackground,
|
||||||
|
required this.iconColor,
|
||||||
|
required this.amount,
|
||||||
|
required this.label,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final Color iconBackground;
|
||||||
|
final Color iconColor;
|
||||||
|
final String amount;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colors = theme.colorScheme;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
color: colors.surfaceContainer,
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 14, 14, 14),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: iconBackground,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: iconColor, size: 22),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
amount,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: colors.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
label.toUpperCase(),
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatLongDate(DateTime date) {
|
||||||
|
const months = [
|
||||||
|
'January',
|
||||||
|
'February',
|
||||||
|
'March',
|
||||||
|
'April',
|
||||||
|
'May',
|
||||||
|
'June',
|
||||||
|
'July',
|
||||||
|
'August',
|
||||||
|
'September',
|
||||||
|
'October',
|
||||||
|
'November',
|
||||||
|
'December',
|
||||||
|
];
|
||||||
|
return '${date.day} ${months[date.month - 1]} ${date.year}';
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TemplateTile extends StatelessWidget {
|
||||||
|
const _TemplateTile({
|
||||||
|
required this.item,
|
||||||
|
required this.template,
|
||||||
|
required this.transactions,
|
||||||
|
});
|
||||||
|
|
||||||
|
final RecurringItem item;
|
||||||
|
final RecurringTemplate? template;
|
||||||
|
final List<BankTransaction> transactions;
|
||||||
|
|
||||||
|
BankTransaction? get _sourceTx {
|
||||||
|
final id = item.sourceTxId;
|
||||||
|
if (id == null) return null;
|
||||||
|
for (final tx in transactions) {
|
||||||
|
if (tx.id == id) return tx;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colors = theme.colorScheme;
|
||||||
|
final income = item.amount >= 0;
|
||||||
|
final tpl = template;
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
leading: IconButton(
|
||||||
|
tooltip: 'Edit template',
|
||||||
|
icon: Icon(Icons.tune, color: colors.primary),
|
||||||
|
onPressed: tpl == null
|
||||||
|
? null
|
||||||
|
: () => showEditRecurringTemplateSheet(context, template: tpl),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
item.description,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
[
|
||||||
|
'Day ${item.dayOfMonth}',
|
||||||
|
if (item.ruleSummary.isNotEmpty) item.ruleSummary,
|
||||||
|
].join(' · '),
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: Text(
|
||||||
|
formatMoney(item.amount, item.currency),
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: income ? AppTheme.accent : AppTheme.expense,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
final tx = _sourceTx;
|
||||||
|
if (tx == null) return;
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
builder: (_) => TransactionDetailScreen(tx: tx),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
/// Host scaffold: Home + Overview. Settings lives in the app-bar gear.
|
/// Host scaffold: Home, Overview, Look ahead. Settings lives in the app-bar gear.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../widgets/shell_tab_scope.dart';
|
||||||
import 'home_screen.dart';
|
import 'home_screen.dart';
|
||||||
|
import 'look_ahead_screen.dart';
|
||||||
import 'overview_screen.dart';
|
import 'overview_screen.dart';
|
||||||
|
|
||||||
class MainShell extends StatefulWidget {
|
class MainShell extends StatefulWidget {
|
||||||
@@ -19,11 +21,14 @@ class _MainShellState extends State<MainShell> {
|
|||||||
static const _pages = [
|
static const _pages = [
|
||||||
HomeScreen(),
|
HomeScreen(),
|
||||||
OverviewScreen(),
|
OverviewScreen(),
|
||||||
|
LookAheadScreen(),
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return ShellTabScope(
|
||||||
|
index: _index,
|
||||||
|
child: Scaffold(
|
||||||
body: IndexedStack(
|
body: IndexedStack(
|
||||||
index: _index,
|
index: _index,
|
||||||
children: _pages,
|
children: _pages,
|
||||||
@@ -43,8 +48,14 @@ class _MainShellState extends State<MainShell> {
|
|||||||
selectedIcon: Icon(Icons.insights),
|
selectedIcon: Icon(Icons.insights),
|
||||||
label: 'Overview',
|
label: 'Overview',
|
||||||
),
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.event_repeat_outlined),
|
||||||
|
selectedIcon: Icon(Icons.event_repeat),
|
||||||
|
label: 'Look ahead',
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import '../widgets/empty_state.dart';
|
|||||||
import '../widgets/label_filter_dialog.dart';
|
import '../widgets/label_filter_dialog.dart';
|
||||||
import '../widgets/loading_view.dart';
|
import '../widgets/loading_view.dart';
|
||||||
import '../widgets/responsive_body.dart';
|
import '../widgets/responsive_body.dart';
|
||||||
|
import '../widgets/shell_tab_scope.dart';
|
||||||
import '../widgets/skip_build_when_covered.dart';
|
import '../widgets/skip_build_when_covered.dart';
|
||||||
import 'label_transactions_screen.dart';
|
import 'label_transactions_screen.dart';
|
||||||
import 'transactions_screen.dart';
|
import 'transactions_screen.dart';
|
||||||
@@ -31,6 +32,8 @@ const _kExpenseColor = AppTheme.expense;
|
|||||||
class OverviewScreen extends StatefulWidget {
|
class OverviewScreen extends StatefulWidget {
|
||||||
const OverviewScreen({super.key});
|
const OverviewScreen({super.key});
|
||||||
|
|
||||||
|
static const tabIndex = 1;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<OverviewScreen> createState() => _OverviewScreenState();
|
State<OverviewScreen> createState() => _OverviewScreenState();
|
||||||
}
|
}
|
||||||
@@ -163,6 +166,10 @@ class _OverviewScreenState extends State<OverviewScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
if (ShellTabScope.of(context) != OverviewScreen.tabIndex) {
|
||||||
|
return const SizedBox.expand();
|
||||||
|
}
|
||||||
|
|
||||||
final bank = context.watch<BankController>();
|
final bank = context.watch<BankController>();
|
||||||
final meta = context.watch<TxMetaController>();
|
final meta = context.watch<TxMetaController>();
|
||||||
|
|
||||||
|
|||||||
@@ -26,11 +26,14 @@ class TransactionDetailScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||||
late final TextEditingController _notesController;
|
late final TextEditingController _notesController;
|
||||||
|
late final TextEditingController _labelSearchController;
|
||||||
late SimilarMatchCriteria _matchCriteria;
|
late SimilarMatchCriteria _matchCriteria;
|
||||||
late Set<String> _draftLabelIds;
|
late Set<String> _draftLabelIds;
|
||||||
|
var _similarCount = 0;
|
||||||
|
var _similarReady = false;
|
||||||
var _dirty = false;
|
var _dirty = false;
|
||||||
var _labelsDirty = false;
|
var _labelsDirty = false;
|
||||||
late bool _applyToSimilar;
|
var _applyToSimilar = false;
|
||||||
var _busy = false;
|
var _busy = false;
|
||||||
String? _busyMessage;
|
String? _busyMessage;
|
||||||
|
|
||||||
@@ -40,6 +43,7 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
|||||||
final meta = context.read<TxMetaController>();
|
final meta = context.read<TxMetaController>();
|
||||||
final notes = meta.metaFor(widget.tx).notes;
|
final notes = meta.metaFor(widget.tx).notes;
|
||||||
_notesController = TextEditingController(text: notes);
|
_notesController = TextEditingController(text: notes);
|
||||||
|
_labelSearchController = TextEditingController();
|
||||||
final tx = widget.tx;
|
final tx = widget.tx;
|
||||||
_matchCriteria = SimilarMatchCriteria(
|
_matchCriteria = SimilarMatchCriteria(
|
||||||
pattern: defaultSimilarPattern(tx.description),
|
pattern: defaultSimilarPattern(tx.description),
|
||||||
@@ -50,19 +54,31 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
|||||||
: TxFlowFilter.all,
|
: TxFlowFilter.all,
|
||||||
);
|
);
|
||||||
_draftLabelIds = meta.metaFor(tx).labelIds.toSet();
|
_draftLabelIds = meta.metaFor(tx).labelIds.toSet();
|
||||||
|
// Defer full-list similar scan so the detail route paints immediately.
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _refreshSimilarCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _refreshSimilarCount() {
|
||||||
|
if (!mounted) return;
|
||||||
final all = context.read<BankController>().transactions;
|
final all = context.read<BankController>().transactions;
|
||||||
final similarCount = meta.countSimilar(
|
final count = context.read<TxMetaController>().countSimilar(
|
||||||
tx,
|
widget.tx,
|
||||||
all,
|
all,
|
||||||
criteria: _matchCriteria,
|
criteria: _matchCriteria,
|
||||||
);
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_similarCount = count;
|
||||||
|
_similarReady = true;
|
||||||
// Default on when there are peers — otherwise users never notice the toggle.
|
// Default on when there are peers — otherwise users never notice the toggle.
|
||||||
_applyToSimilar = similarCount > 1;
|
if (count > 1) _applyToSimilar = true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_notesController.dispose();
|
_notesController.dispose();
|
||||||
|
_labelSearchController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,13 +101,10 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
|||||||
if (result == null || !mounted) return;
|
if (result == null || !mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_matchCriteria = result;
|
_matchCriteria = result;
|
||||||
final count = context.read<TxMetaController>().countSimilar(
|
_similarReady = false;
|
||||||
widget.tx,
|
_similarCount = 0;
|
||||||
all,
|
|
||||||
criteria: result,
|
|
||||||
);
|
|
||||||
if (count > 1) _applyToSimilar = true;
|
|
||||||
});
|
});
|
||||||
|
_refreshSimilarCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _runBusy(
|
Future<void> _runBusy(
|
||||||
@@ -282,17 +295,27 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
|||||||
final tx = widget.tx;
|
final tx = widget.tx;
|
||||||
final metaController = context.watch<TxMetaController>();
|
final metaController = context.watch<TxMetaController>();
|
||||||
final meta = metaController.metaFor(tx);
|
final meta = metaController.metaFor(tx);
|
||||||
// Prefer read: bank data rarely changes while editing labels.
|
final similarCount = _similarCount;
|
||||||
final all = context.read<BankController>().transactions;
|
|
||||||
final similarCount = metaController.countSimilar(
|
|
||||||
tx,
|
|
||||||
all,
|
|
||||||
criteria: _matchCriteria,
|
|
||||||
);
|
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final colors = theme.colorScheme;
|
final colors = theme.colorScheme;
|
||||||
final amountColor = tx.amount >= 0 ? AppTheme.accent : AppTheme.expense;
|
final amountColor = tx.amount >= 0 ? AppTheme.accent : AppTheme.expense;
|
||||||
final labelEntries = metaController.labelsInTreeOrder();
|
final needle = _labelSearchController.text.trim().toLowerCase();
|
||||||
|
final allLabelEntries = metaController.labelsInTreeOrder();
|
||||||
|
final labelEntries = needle.isEmpty
|
||||||
|
? allLabelEntries
|
||||||
|
: () {
|
||||||
|
final keep = <String>{};
|
||||||
|
for (final entry in allLabelEntries) {
|
||||||
|
if (!entry.label.name.toLowerCase().contains(needle)) continue;
|
||||||
|
keep.add(entry.label.id);
|
||||||
|
keep.addAll(metaController.ancestorIdsOf(entry.label.id));
|
||||||
|
keep.addAll(metaController.descendantIdsOf(entry.label.id));
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
for (final entry in allLabelEntries)
|
||||||
|
if (keep.contains(entry.label.id)) entry,
|
||||||
|
];
|
||||||
|
}();
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -305,15 +328,14 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: ResponsiveBody(
|
body: Column(
|
||||||
child: ListView(
|
|
||||||
padding: const EdgeInsets.only(top: 8, bottom: 32),
|
|
||||||
children: [
|
children: [
|
||||||
if (_busy) ...[
|
if (_busy) const LinearProgressIndicator(minHeight: 2),
|
||||||
const LinearProgressIndicator(minHeight: 2),
|
if (_busy && _busyMessage != null)
|
||||||
if (_busyMessage != null)
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(0, 8, 0, 4),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
child: Text(
|
child: Text(
|
||||||
_busyMessage!,
|
_busyMessage!,
|
||||||
style: theme.textTheme.labelMedium?.copyWith(
|
style: theme.textTheme.labelMedium?.copyWith(
|
||||||
@@ -321,11 +343,13 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
AbsorbPointer(
|
Expanded(
|
||||||
|
child: ResponsiveBody(
|
||||||
|
child: AbsorbPointer(
|
||||||
absorbing: _busy,
|
absorbing: _busy,
|
||||||
child: Column(
|
child: ListView(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
padding: const EdgeInsets.only(top: 8, bottom: 16),
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
formatMoney(tx.amount, tx.currency),
|
formatMoney(tx.amount, tx.currency),
|
||||||
@@ -372,7 +396,7 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
|||||||
: '$similarCount matching transactions',
|
: '$similarCount matching transactions',
|
||||||
),
|
),
|
||||||
value: _applyToSimilar && similarCount > 1,
|
value: _applyToSimilar && similarCount > 1,
|
||||||
onChanged: _busy || similarCount <= 1
|
onChanged: _busy || !_similarReady || similarCount <= 1
|
||||||
? null
|
? null
|
||||||
: (value) => setState(() => _applyToSimilar = value),
|
: (value) => setState(() => _applyToSimilar = value),
|
||||||
),
|
),
|
||||||
@@ -430,6 +454,26 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: _labelSearchController,
|
||||||
|
enabled: !_busy,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
hintText: 'Filter labels',
|
||||||
|
prefixIcon: const Icon(Icons.search, size: 20),
|
||||||
|
suffixIcon: needle.isEmpty
|
||||||
|
? null
|
||||||
|
: IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
_labelSearchController.clear();
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.clear, size: 18),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
DecoratedBox(
|
DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.surfaceContainerHighest,
|
color: colors.surfaceContainerHighest,
|
||||||
@@ -440,20 +484,37 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
|||||||
),
|
),
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Column(
|
child: SizedBox(
|
||||||
children: [
|
height: 280,
|
||||||
for (final entry in labelEntries) ...[
|
child: labelEntries.isEmpty
|
||||||
if (entry != labelEntries.first)
|
? Center(
|
||||||
Divider(
|
child: Text(
|
||||||
|
needle.isEmpty
|
||||||
|
? 'No labels yet'
|
||||||
|
: 'No labels match',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.separated(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
itemCount: labelEntries.length,
|
||||||
|
separatorBuilder: (_, _) => Divider(
|
||||||
height: 1,
|
height: 1,
|
||||||
indent: 40,
|
indent: 40,
|
||||||
color: colors.outlineVariant.withValues(alpha: 0.45),
|
color: colors.outlineVariant
|
||||||
|
.withValues(alpha: 0.45),
|
||||||
),
|
),
|
||||||
InkWell(
|
itemBuilder: (context, index) {
|
||||||
onTap:
|
final entry = labelEntries[index];
|
||||||
_busy ? null : () => _toggleDraftLabel(entry.label.id),
|
return InkWell(
|
||||||
onLongPress:
|
onTap: _busy
|
||||||
_busy ? null : () => _labelActions(entry.label),
|
? null
|
||||||
|
: () => _toggleDraftLabel(entry.label.id),
|
||||||
|
onLongPress: _busy
|
||||||
|
? null
|
||||||
|
: () => _labelActions(entry.label),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.fromLTRB(
|
padding: EdgeInsets.fromLTRB(
|
||||||
8 + entry.depth * 16.0,
|
8 + entry.depth * 16.0,
|
||||||
@@ -492,50 +553,23 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
|||||||
materialTapTargetSize:
|
materialTapTargetSize:
|
||||||
MaterialTapTargetSize.shrinkWrap,
|
MaterialTapTargetSize.shrinkWrap,
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
value: _draftLabelIds.contains(entry.label.id),
|
value: _draftLabelIds
|
||||||
|
.contains(entry.label.id),
|
||||||
onChanged: _busy
|
onChanged: _busy
|
||||||
? null
|
? null
|
||||||
: (_) =>
|
: (_) => _toggleDraftLabel(
|
||||||
_toggleDraftLabel(entry.label.id),
|
entry.label.id,
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: FilledButton(
|
|
||||||
onPressed: _busy || !_labelsDirty
|
|
||||||
? null
|
|
||||||
: () => _applyLabels(toMatched: false),
|
|
||||||
child: const Text('Apply'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: FilledButton.tonal(
|
|
||||||
onPressed: _busy ||
|
|
||||||
!_labelsDirty ||
|
|
||||||
!_applyToSimilar ||
|
|
||||||
similarCount <= 1
|
|
||||||
? null
|
|
||||||
: () => _applyLabels(toMatched: true),
|
|
||||||
child: Text(
|
|
||||||
similarCount > 1
|
|
||||||
? 'Apply to matched ($similarCount)'
|
|
||||||
: 'Apply to matched',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text('Notes', style: theme.textTheme.titleSmall),
|
Text('Notes', style: theme.textTheme.titleSmall),
|
||||||
@@ -572,9 +606,48 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Material(
|
||||||
|
elevation: 3,
|
||||||
|
color: colors.surface,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 10, 16, 10),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: _busy || !_labelsDirty
|
||||||
|
? null
|
||||||
|
: () => _applyLabels(toMatched: false),
|
||||||
|
child: const Text('Apply'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton.tonal(
|
||||||
|
onPressed: _busy ||
|
||||||
|
!_labelsDirty ||
|
||||||
|
!_applyToSimilar ||
|
||||||
|
similarCount <= 1
|
||||||
|
? null
|
||||||
|
: () => _applyLabels(toMatched: true),
|
||||||
|
child: Text(
|
||||||
|
similarCount > 1
|
||||||
|
? 'Apply to matched ($similarCount)'
|
||||||
|
: 'Apply to matched',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
|||||||
var _showCalendar = false;
|
var _showCalendar = false;
|
||||||
var _flowFilter = TxFlowFilter.all;
|
var _flowFilter = TxFlowFilter.all;
|
||||||
var _amountRange = const TxAmountRange();
|
var _amountRange = const TxAmountRange();
|
||||||
|
var _recurringOnly = false;
|
||||||
|
|
||||||
/// Query actually used for filtering (Enter, or live once ≥3 chars).
|
/// Query actually used for filtering (Enter, or live once ≥3 chars).
|
||||||
String _activeQuery = '';
|
String _activeQuery = '';
|
||||||
@@ -96,11 +97,15 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
|||||||
bool get _hasListFilters =>
|
bool get _hasListFilters =>
|
||||||
_labelFilters.isNotEmpty ||
|
_labelFilters.isNotEmpty ||
|
||||||
_flowFilter != TxFlowFilter.all ||
|
_flowFilter != TxFlowFilter.all ||
|
||||||
_amountRange.isActive;
|
_amountRange.isActive ||
|
||||||
|
_recurringOnly;
|
||||||
|
|
||||||
bool _matchesExtraFilters(BankTransaction tx) =>
|
bool _matchesExtraFilters(BankTransaction tx, TxMetaController meta) {
|
||||||
txMatchesFlow(tx, _flowFilter) &&
|
if (!txMatchesFlow(tx, _flowFilter)) return false;
|
||||||
txMatchesAmountRange(tx, _amountRange);
|
if (!txMatchesAmountRange(tx, _amountRange)) return false;
|
||||||
|
if (_recurringOnly && !meta.metaFor(tx).recurring) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
void _goMonth(int delta) {
|
void _goMonth(int delta) {
|
||||||
final next = addMonths(_month, delta);
|
final next = addMonths(_month, delta);
|
||||||
@@ -167,6 +172,7 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
|||||||
amountRange: _amountRange,
|
amountRange: _amountRange,
|
||||||
query: _activeQuery,
|
query: _activeQuery,
|
||||||
monthOnly: _searchInMonthOnly,
|
monthOnly: _searchInMonthOnly,
|
||||||
|
recurringOnly: _recurringOnly,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (result == null || !mounted) return;
|
if (result == null || !mounted) return;
|
||||||
@@ -178,6 +184,7 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
|||||||
_flowFilter = result.flow;
|
_flowFilter = result.flow;
|
||||||
_amountRange = result.amountRange;
|
_amountRange = result.amountRange;
|
||||||
_searchInMonthOnly = result.monthOnly;
|
_searchInMonthOnly = result.monthOnly;
|
||||||
|
_recurringOnly = result.recurringOnly;
|
||||||
if (!_searchInMonthOnly) {
|
if (!_searchInMonthOnly) {
|
||||||
_showCalendar = false;
|
_showCalendar = false;
|
||||||
_selectedDay = null;
|
_selectedDay = null;
|
||||||
@@ -339,7 +346,7 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
|||||||
_activeQuery,
|
_activeQuery,
|
||||||
labelIds: _labelFilters,
|
labelIds: _labelFilters,
|
||||||
))
|
))
|
||||||
if (_matchesExtraFilters(tx)) tx,
|
if (_matchesExtraFilters(tx, metaController)) tx,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Labels on the current result that aren't part of the active label filter.
|
// Labels on the current result that aren't part of the active label filter.
|
||||||
@@ -675,6 +682,15 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
if (_recurringOnly)
|
||||||
|
InputChip(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
avatar: const Icon(Icons.event_repeat, size: 14),
|
||||||
|
label: const Text('Recurring'),
|
||||||
|
onDeleted: () {
|
||||||
|
setState(() => _recurringOnly = false);
|
||||||
|
},
|
||||||
|
),
|
||||||
for (final id in _labelFilters)
|
for (final id in _labelFilters)
|
||||||
if (id == kUncategorizedFilterId)
|
if (id == kUncategorizedFilterId)
|
||||||
InputChip(
|
InputChip(
|
||||||
|
|||||||
@@ -47,15 +47,30 @@ bool isSameTransaction(BankTransaction a, BankTransaction b) {
|
|||||||
|
|
||||||
int _amountCents(double amount) => (amount * 100).round();
|
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.
|
/// Merge [extra] into [existing], skipping duplicates. Newest date first.
|
||||||
List<BankTransaction> mergeTransactions(
|
List<BankTransaction> mergeTransactions(
|
||||||
List<BankTransaction> existing,
|
List<BankTransaction> existing,
|
||||||
List<BankTransaction> extra,
|
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);
|
final out = List<BankTransaction>.of(existing);
|
||||||
for (final tx in extra) {
|
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);
|
out.add(tx);
|
||||||
|
buckets.putIfAbsent(key, () => []).add(tx);
|
||||||
}
|
}
|
||||||
out.sort((a, b) {
|
out.sort((a, b) {
|
||||||
final byDate = b.date.compareTo(a.date);
|
final byDate = b.date.compareTo(a.date);
|
||||||
@@ -70,9 +85,20 @@ List<BankTransaction> filterNewTransactions(
|
|||||||
List<BankTransaction> existing,
|
List<BankTransaction> existing,
|
||||||
List<BankTransaction> candidates,
|
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 [
|
return [
|
||||||
for (final tx in candidates)
|
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);
|
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>{
|
final labelsByClientId = <String, TxLabel>{
|
||||||
for (final label in a.customLabels) label.id: label,
|
for (final label in a.customLabels) label.id: label,
|
||||||
};
|
};
|
||||||
@@ -66,6 +73,7 @@ TxMetaStoreData mergeTxMetaStores(TxMetaStoreData a, TxMetaStoreData b) {
|
|||||||
labelsByMerchant: labelsByMerchant,
|
labelsByMerchant: labelsByMerchant,
|
||||||
notesByMerchant: notesByMerchant,
|
notesByMerchant: notesByMerchant,
|
||||||
exactTemplates: exactTemplates,
|
exactTemplates: exactTemplates,
|
||||||
|
recurringTemplates: recurringTemplates,
|
||||||
customLabels: labelsByClientId.values.toList(growable: false),
|
customLabels: labelsByClientId.values.toList(growable: false),
|
||||||
labelParents: labelParents,
|
labelParents: labelParents,
|
||||||
);
|
);
|
||||||
@@ -170,6 +178,7 @@ class PbSyncService {
|
|||||||
}) async {
|
}) async {
|
||||||
final out = <RecordModel>[];
|
final out = <RecordModel>[];
|
||||||
var page = 1;
|
var page = 1;
|
||||||
|
var lastReported = -1;
|
||||||
while (true) {
|
while (true) {
|
||||||
final result = await _withTimeout(
|
final result = await _withTimeout(
|
||||||
_pb.collection(collection).getList(
|
_pb.collection(collection).getList(
|
||||||
@@ -181,8 +190,13 @@ class PbSyncService {
|
|||||||
label: '$collection list page $page',
|
label: '$collection list page $page',
|
||||||
);
|
);
|
||||||
out.addAll(result.items);
|
out.addAll(result.items);
|
||||||
onPage?.call(out.length);
|
final finished = result.items.length < _listPageSize;
|
||||||
if (result.items.length < _listPageSize) break;
|
if (onPage != null &&
|
||||||
|
(finished || out.length - lastReported >= 400)) {
|
||||||
|
lastReported = out.length;
|
||||||
|
onPage(out.length);
|
||||||
|
}
|
||||||
|
if (finished) break;
|
||||||
page++;
|
page++;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@@ -213,7 +227,17 @@ class PbSyncService {
|
|||||||
report(0.36);
|
report(0.36);
|
||||||
onStatus?.call('Pulling templates…');
|
onStatus?.call('Pulling templates…');
|
||||||
final exactRecords = await _fullList('exact_templates', filter);
|
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 customLabels = <TxLabel>[];
|
||||||
final labelParents = <String, String>{};
|
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(
|
debugPrint(
|
||||||
'PbSyncService pull: labels=${labelRecords.length} '
|
'PbSyncService pull: labels=${labelRecords.length} '
|
||||||
'tx_meta=${metaRecords.length} merchants=${merchantRecords.length} '
|
'tx_meta=${metaRecords.length} merchants=${merchantRecords.length} '
|
||||||
'exact=${exactRecords.length}',
|
'exact=${exactRecords.length} recurring=${recurringRecords.length}',
|
||||||
);
|
);
|
||||||
|
|
||||||
return TxMetaStoreData(
|
return TxMetaStoreData(
|
||||||
@@ -280,6 +329,7 @@ class PbSyncService {
|
|||||||
labelsByMerchant: labelsByMerchant,
|
labelsByMerchant: labelsByMerchant,
|
||||||
notesByMerchant: notesByMerchant,
|
notesByMerchant: notesByMerchant,
|
||||||
exactTemplates: exactTemplates,
|
exactTemplates: exactTemplates,
|
||||||
|
recurringTemplates: recurringTemplates,
|
||||||
customLabels: customLabels,
|
customLabels: customLabels,
|
||||||
labelParents: labelParents,
|
labelParents: labelParents,
|
||||||
);
|
);
|
||||||
@@ -347,6 +397,15 @@ class PbSyncService {
|
|||||||
report(0.96);
|
report(0.96);
|
||||||
onStatus?.call('Syncing templates…');
|
onStatus?.call('Syncing templates…');
|
||||||
await _syncExactTemplates(userId, filter, store);
|
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);
|
report(1.0);
|
||||||
return txSummary;
|
return txSummary;
|
||||||
}
|
}
|
||||||
@@ -563,8 +622,12 @@ class PbSyncService {
|
|||||||
]);
|
]);
|
||||||
done = end;
|
done = end;
|
||||||
onProgress?.call(done / total);
|
onProgress?.call(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)…');
|
onStatus?.call('Syncing $collection ($done/$total)…');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (errors.isNotEmpty) {
|
if (errors.isNotEmpty) {
|
||||||
throw StateError(
|
throw StateError(
|
||||||
@@ -603,6 +666,12 @@ class PbSyncService {
|
|||||||
filter =
|
filter =
|
||||||
'user="$userId" && exact_key="${key.replaceAll('"', '\\"')}"';
|
'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':
|
case 'transactions':
|
||||||
final key = body['tx_id'];
|
final key = body['tx_id'];
|
||||||
if (userId != null && key is String) {
|
if (userId != null && key is String) {
|
||||||
@@ -853,6 +922,61 @@ class PbSyncService {
|
|||||||
await _runWriteOps('exact_templates', ops);
|
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) {
|
bool _labelUnchanged(RecordModel remote, Map<String, dynamic> body) {
|
||||||
return remote.getStringValue('name') == body['name'] &&
|
return remote.getStringValue('name') == body['name'] &&
|
||||||
remote.getIntValue('color') == body['color'] &&
|
remote.getIntValue('color') == body['color'] &&
|
||||||
@@ -882,6 +1006,34 @@ class PbSyncService {
|
|||||||
remote.getBoolValue('recurring') == t.recurring &&
|
remote.getBoolValue('recurring') == t.recurring &&
|
||||||
remote.getBoolValue('internal_transfer') == t.internalTransfer;
|
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) {
|
List<String> _jsonStringList(dynamic raw) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ library;
|
|||||||
|
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../models/bank.dart';
|
import '../models/bank.dart';
|
||||||
@@ -16,6 +17,7 @@ class TxMetaStoreData {
|
|||||||
this.labelsByMerchant = const {},
|
this.labelsByMerchant = const {},
|
||||||
this.notesByMerchant = const {},
|
this.notesByMerchant = const {},
|
||||||
this.exactTemplates = const {},
|
this.exactTemplates = const {},
|
||||||
|
this.recurringTemplates = const {},
|
||||||
this.customLabels = const [],
|
this.customLabels = const [],
|
||||||
this.labelParents = const {},
|
this.labelParents = const {},
|
||||||
});
|
});
|
||||||
@@ -24,6 +26,7 @@ class TxMetaStoreData {
|
|||||||
final Map<String, List<String>> labelsByMerchant;
|
final Map<String, List<String>> labelsByMerchant;
|
||||||
final Map<String, String> notesByMerchant;
|
final Map<String, String> notesByMerchant;
|
||||||
final Map<String, TxExactTemplate> exactTemplates;
|
final Map<String, TxExactTemplate> exactTemplates;
|
||||||
|
final Map<String, RecurringTemplate> recurringTemplates;
|
||||||
final List<TxLabel> customLabels;
|
final List<TxLabel> customLabels;
|
||||||
|
|
||||||
/// childLabelId → parentLabelId (roots omitted).
|
/// childLabelId → parentLabelId (roots omitted).
|
||||||
@@ -147,8 +150,9 @@ class StorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> saveBankCache(BankCache cache) {
|
Future<void> saveBankCache(BankCache cache) async {
|
||||||
return _prefs.setString(StorageKeys.bankCache, jsonEncode(cache.toJson()));
|
final json = await compute(jsonEncode, cache.toJson());
|
||||||
|
await _prefs.setString(StorageKeys.bankCache, json);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> clearBankCache() {
|
Future<void> clearBankCache() {
|
||||||
@@ -170,7 +174,7 @@ class StorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> saveImportedTransactions(List<BankTransaction> txs) {
|
Future<void> saveImportedTransactions(List<BankTransaction> txs) async {
|
||||||
final encoded = [
|
final encoded = [
|
||||||
for (final tx in txs)
|
for (final tx in txs)
|
||||||
{
|
{
|
||||||
@@ -181,10 +185,8 @@ class StorageService {
|
|||||||
'pending': tx.pending,
|
'pending': tx.pending,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
return _prefs.setString(
|
final json = await compute(jsonEncode, encoded);
|
||||||
StorageKeys.importedTransactions,
|
await _prefs.setString(StorageKeys.importedTransactions, json);
|
||||||
jsonEncode(encoded),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> clearImportedTransactions() {
|
Future<void> clearImportedTransactions() {
|
||||||
@@ -225,7 +227,7 @@ class StorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> saveTxMetaStore(TxMetaStoreData store) {
|
Future<void> saveTxMetaStore(TxMetaStoreData store) async {
|
||||||
final encoded = {
|
final encoded = {
|
||||||
'byId': {
|
'byId': {
|
||||||
for (final e in store.byId.entries) e.key: e.value.toJson(),
|
for (final e in store.byId.entries) e.key: e.value.toJson(),
|
||||||
@@ -247,12 +249,18 @@ class StorageService {
|
|||||||
'exactTemplates': {
|
'exactTemplates': {
|
||||||
for (final e in store.exactTemplates.entries) e.key: e.value.toJson(),
|
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': [
|
'customLabels': [
|
||||||
for (final label in store.customLabels) label.toJson(),
|
for (final label in store.customLabels) label.toJson(),
|
||||||
],
|
],
|
||||||
'labelParents': store.labelParents,
|
'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) {
|
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 customLabels = <TxLabel>[];
|
||||||
final customs = root['customLabels'];
|
final customs = root['customLabels'];
|
||||||
if (customs is List) {
|
if (customs is List) {
|
||||||
@@ -365,6 +386,7 @@ class StorageService {
|
|||||||
labelsByMerchant: labelsByMerchant,
|
labelsByMerchant: labelsByMerchant,
|
||||||
notesByMerchant: notesByMerchant,
|
notesByMerchant: notesByMerchant,
|
||||||
exactTemplates: exactTemplates,
|
exactTemplates: exactTemplates,
|
||||||
|
recurringTemplates: recurringTemplates,
|
||||||
customLabels: customLabels,
|
customLabels: customLabels,
|
||||||
labelParents: labelParents,
|
labelParents: labelParents,
|
||||||
);
|
);
|
||||||
|
|||||||
+193
-27
@@ -1,4 +1,4 @@
|
|||||||
/// Project future balances from recurring transactions.
|
/// Project future balances from recurring templates.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import '../models/bank.dart';
|
import '../models/bank.dart';
|
||||||
@@ -8,54 +8,220 @@ import 'tx_calendar.dart';
|
|||||||
/// One monthly recurring item used for look-ahead forecasts.
|
/// One monthly recurring item used for look-ahead forecasts.
|
||||||
class RecurringItem {
|
class RecurringItem {
|
||||||
const RecurringItem({
|
const RecurringItem({
|
||||||
|
required this.templateId,
|
||||||
required this.description,
|
required this.description,
|
||||||
required this.amount,
|
required this.amount,
|
||||||
required this.currency,
|
required this.currency,
|
||||||
required this.dayOfMonth,
|
required this.dayOfMonth,
|
||||||
|
this.ruleSummary = '',
|
||||||
|
this.sourceTxId,
|
||||||
this.labelIds = const [],
|
this.labelIds = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final String templateId;
|
||||||
final String description;
|
final String description;
|
||||||
final double amount;
|
final double amount;
|
||||||
final String currency;
|
final String currency;
|
||||||
final int dayOfMonth;
|
final int dayOfMonth;
|
||||||
|
final String ruleSummary;
|
||||||
|
final String? sourceTxId;
|
||||||
final List<String> labelIds;
|
final List<String> labelIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build unique recurring templates from annotated transactions.
|
/// Build projection items from first-class [templates].
|
||||||
List<RecurringItem> recurringItemsFrom({
|
List<RecurringItem> recurringItemsFromTemplates({
|
||||||
|
required Iterable<RecurringTemplate> templates,
|
||||||
required List<BankTransaction> transactions,
|
required List<BankTransaction> transactions,
|
||||||
required TxMeta Function(BankTransaction tx) metaFor,
|
required bool Function(BankTransaction tx) isInternalTransfer,
|
||||||
|
required List<String> Function(BankTransaction tx) labelIdsFor,
|
||||||
|
String Function(String? labelId)? labelName,
|
||||||
}) {
|
}) {
|
||||||
final latestByExact = <String, BankTransaction>{};
|
|
||||||
for (final tx in transactions) {
|
|
||||||
final meta = metaFor(tx);
|
|
||||||
if (!meta.recurring) continue;
|
|
||||||
final existing = latestByExact[tx.exactKey];
|
|
||||||
if (existing == null) {
|
|
||||||
latestByExact[tx.exactKey] = tx;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
final existingDate = DateTime.tryParse(existing.date);
|
|
||||||
final nextDate = DateTime.tryParse(tx.date);
|
|
||||||
if (existingDate == null ||
|
|
||||||
(nextDate != null && nextDate.isAfter(existingDate))) {
|
|
||||||
latestByExact[tx.exactKey] = tx;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
for (final tx in latestByExact.values)
|
for (final tpl in templates)
|
||||||
RecurringItem(
|
RecurringItem(
|
||||||
description: tx.description,
|
templateId: tpl.id,
|
||||||
amount: tx.amount,
|
description: tpl.name,
|
||||||
currency: tx.currency,
|
amount: resolveRecurringAmount(
|
||||||
dayOfMonth: (DateTime.tryParse(tx.date)?.day ?? 1).clamp(1, 28),
|
template: tpl,
|
||||||
labelIds: List<String>.of(metaFor(tx).labelIds),
|
transactions: transactions,
|
||||||
|
isInternalTransfer: isInternalTransfer,
|
||||||
|
labelIdsFor: labelIdsFor,
|
||||||
|
),
|
||||||
|
currency: tpl.currency,
|
||||||
|
dayOfMonth: tpl.dayOfMonth.clamp(1, 28),
|
||||||
|
ruleSummary: recurringRuleSummary(
|
||||||
|
template: tpl,
|
||||||
|
labelName: labelName,
|
||||||
|
),
|
||||||
|
sourceTxId: tpl.sourceTxId,
|
||||||
|
labelIds: [
|
||||||
|
if (tpl.labelId != null && tpl.labelId!.isNotEmpty) tpl.labelId!,
|
||||||
|
],
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String recurringRuleSummary({
|
||||||
|
required RecurringTemplate template,
|
||||||
|
String Function(String? labelId)? labelName,
|
||||||
|
}) {
|
||||||
|
switch (template.amountRule) {
|
||||||
|
case RecurringAmountRule.lastValue:
|
||||||
|
return 'Last value';
|
||||||
|
case RecurringAmountRule.avgLastTxs:
|
||||||
|
return 'Avg last ${template.avgCount} matching';
|
||||||
|
case RecurringAmountRule.labelAvgMonths:
|
||||||
|
final name = labelName?.call(template.labelId) ?? 'label';
|
||||||
|
return 'Avg ${template.avgCount} mo · $name';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve projected amount for [template] from bank history.
|
||||||
|
double resolveRecurringAmount({
|
||||||
|
required RecurringTemplate template,
|
||||||
|
required List<BankTransaction> transactions,
|
||||||
|
required bool Function(BankTransaction tx) isInternalTransfer,
|
||||||
|
required List<String> Function(BankTransaction tx) labelIdsFor,
|
||||||
|
}) {
|
||||||
|
switch (template.amountRule) {
|
||||||
|
case RecurringAmountRule.lastValue:
|
||||||
|
return _lastMerchantAmount(
|
||||||
|
template: template,
|
||||||
|
transactions: transactions,
|
||||||
|
) ??
|
||||||
|
0;
|
||||||
|
case RecurringAmountRule.avgLastTxs:
|
||||||
|
return avgLastMatchingTxs(
|
||||||
|
count: template.avgCount,
|
||||||
|
transactions: transactions,
|
||||||
|
merchantKey: template.merchantKey,
|
||||||
|
sourceTxId: template.sourceTxId,
|
||||||
|
);
|
||||||
|
case RecurringAmountRule.labelAvgMonths:
|
||||||
|
final labelId = template.labelId;
|
||||||
|
if (labelId == null || labelId.isEmpty) return 0;
|
||||||
|
return avgLabelMonths(
|
||||||
|
labelId: labelId,
|
||||||
|
months: template.avgCount,
|
||||||
|
transactions: transactions,
|
||||||
|
isInternalTransfer: isInternalTransfer,
|
||||||
|
labelIdsFor: labelIdsFor,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
double? _lastMerchantAmount({
|
||||||
|
required RecurringTemplate template,
|
||||||
|
required List<BankTransaction> transactions,
|
||||||
|
}) {
|
||||||
|
if (template.sourceTxId != null) {
|
||||||
|
for (final tx in transactions) {
|
||||||
|
if (tx.id == template.sourceTxId) return tx.amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final key = template.merchantKey;
|
||||||
|
if (key.isEmpty) return null;
|
||||||
|
BankTransaction? latest;
|
||||||
|
DateTime? latestDate;
|
||||||
|
for (final tx in transactions) {
|
||||||
|
if (tx.merchantKey != key) continue;
|
||||||
|
final date = DateTime.tryParse(tx.date);
|
||||||
|
if (date == null) continue;
|
||||||
|
if (latest == null || date.isAfter(latestDate!)) {
|
||||||
|
latest = tx;
|
||||||
|
latestDate = date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return latest?.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Average of the last [count] txs that match the selected source
|
||||||
|
/// (same similar-pattern + flow as apply-to-similar).
|
||||||
|
double avgLastMatchingTxs({
|
||||||
|
required int count,
|
||||||
|
required List<BankTransaction> transactions,
|
||||||
|
String merchantKey = '',
|
||||||
|
String? sourceTxId,
|
||||||
|
}) {
|
||||||
|
final n = count.clamp(1, 36);
|
||||||
|
BankTransaction? prototype;
|
||||||
|
if (sourceTxId != null && sourceTxId.isNotEmpty) {
|
||||||
|
for (final tx in transactions) {
|
||||||
|
if (tx.id == sourceTxId) {
|
||||||
|
prototype = tx;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (prototype == null && merchantKey.isNotEmpty) {
|
||||||
|
DateTime? latestDate;
|
||||||
|
for (final tx in transactions) {
|
||||||
|
if (tx.merchantKey != merchantKey) continue;
|
||||||
|
final date = DateTime.tryParse(tx.date);
|
||||||
|
if (date == null) continue;
|
||||||
|
if (prototype == null || date.isAfter(latestDate!)) {
|
||||||
|
prototype = tx;
|
||||||
|
latestDate = date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (prototype == null) return 0;
|
||||||
|
|
||||||
|
final criteria = SimilarMatchCriteria(
|
||||||
|
pattern: defaultSimilarPattern(prototype.description),
|
||||||
|
flow: prototype.amount < 0
|
||||||
|
? TxFlowFilter.expense
|
||||||
|
: prototype.amount > 0
|
||||||
|
? TxFlowFilter.income
|
||||||
|
: TxFlowFilter.all,
|
||||||
|
);
|
||||||
|
|
||||||
|
final matches = <({BankTransaction tx, DateTime date})>[];
|
||||||
|
for (final tx in transactions) {
|
||||||
|
if (!txMatchesSimilarCriteria(tx, criteria)) continue;
|
||||||
|
final date = DateTime.tryParse(tx.date);
|
||||||
|
if (date == null) continue;
|
||||||
|
matches.add((tx: tx, date: date));
|
||||||
|
}
|
||||||
|
if (matches.isEmpty) return prototype.amount;
|
||||||
|
matches.sort((a, b) => b.date.compareTo(a.date));
|
||||||
|
final take = matches.take(n).toList(growable: false);
|
||||||
|
final sum = take.fold<double>(0, (s, e) => s + e.tx.amount);
|
||||||
|
return sum / take.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Average monthly signed total for [labelId] over the last [months] completed months.
|
||||||
|
double avgLabelMonths({
|
||||||
|
required String labelId,
|
||||||
|
required int months,
|
||||||
|
required List<BankTransaction> transactions,
|
||||||
|
required bool Function(BankTransaction tx) isInternalTransfer,
|
||||||
|
required List<String> Function(BankTransaction tx) labelIdsFor,
|
||||||
|
DateTime? now,
|
||||||
|
}) {
|
||||||
|
final n = months.clamp(1, 36);
|
||||||
|
final today = dateOnly(now ?? DateTime.now());
|
||||||
|
final currentMonth = monthStart(today);
|
||||||
|
final totals = <DateTime, double>{};
|
||||||
|
for (var i = 1; i <= n; i++) {
|
||||||
|
totals[addMonths(currentMonth, -i)] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final tx in transactions) {
|
||||||
|
if (isInternalTransfer(tx)) continue;
|
||||||
|
final date = DateTime.tryParse(tx.date);
|
||||||
|
if (date == null) continue;
|
||||||
|
final key = monthStart(date);
|
||||||
|
if (!totals.containsKey(key)) continue;
|
||||||
|
final ids = labelIdsFor(tx);
|
||||||
|
if (!ids.contains(labelId)) continue;
|
||||||
|
totals[key] = (totals[key] ?? 0) + tx.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
final sum = totals.values.fold<double>(0, (s, v) => s + v);
|
||||||
|
return sum / n;
|
||||||
|
}
|
||||||
|
|
||||||
/// Occurrences of [items] with dates in `(from, to]` (exclusive start, inclusive end).
|
/// Occurrences of [items] with dates in `(from, to]` (exclusive start, inclusive end).
|
||||||
List<({RecurringItem item, DateTime date})> projectedOccurrences({
|
List<({RecurringItem item, DateTime date})> projectedOccurrences({
|
||||||
required List<RecurringItem> items,
|
required List<RecurringItem> items,
|
||||||
|
|||||||
+57
-24
@@ -10,15 +10,50 @@ import '../screens/account_screen.dart';
|
|||||||
import '../screens/login_screen.dart';
|
import '../screens/login_screen.dart';
|
||||||
import '../screens/settings_screen.dart';
|
import '../screens/settings_screen.dart';
|
||||||
|
|
||||||
Widget authAppBarAction(BuildContext context) {
|
/// Account / sign-in control for app bars.
|
||||||
|
///
|
||||||
|
/// Sync progress is subscribed in a nested widget so progress ticks do not
|
||||||
|
/// rebuild the whole screen (Overview charts, Look ahead, etc.).
|
||||||
|
Widget authAppBarAction(BuildContext context) => const _AuthAppBarAction();
|
||||||
|
|
||||||
|
class _AuthAppBarAction extends StatelessWidget {
|
||||||
|
const _AuthAppBarAction();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
final auth = context.watch<AuthController>();
|
final auth = context.watch<AuthController>();
|
||||||
final sync = context.watch<SyncController>();
|
|
||||||
|
if (!auth.isLoggedIn) {
|
||||||
|
return IconButton(
|
||||||
|
tooltip: 'Sign in',
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute<bool>(builder: (_) => const LoginScreen()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.person_outline),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _SyncAwareAccountMenu(auth: auth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SyncAwareAccountMenu extends StatelessWidget {
|
||||||
|
const _SyncAwareAccountMenu({required this.auth});
|
||||||
|
|
||||||
|
final AuthController auth;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: context.read<SyncController>(),
|
||||||
|
builder: (context, _) {
|
||||||
|
final sync = context.read<SyncController>();
|
||||||
final showRing = sync.showProgressRing;
|
final showRing = sync.showProgressRing;
|
||||||
final progress = sync.syncProgress;
|
final progress = sync.syncProgress;
|
||||||
final success = sync.progressSuccess;
|
final success = sync.progressSuccess;
|
||||||
final syncActive = sync.isSyncing || showRing;
|
final syncActive = sync.isSyncing || showRing;
|
||||||
|
|
||||||
if (auth.isLoggedIn) {
|
|
||||||
final ringColor = success
|
final ringColor = success
|
||||||
? const Color(0xFF16A34A)
|
? const Color(0xFF16A34A)
|
||||||
: Theme.of(context).colorScheme.primary;
|
: Theme.of(context).colorScheme.primary;
|
||||||
@@ -32,7 +67,9 @@ Widget authAppBarAction(BuildContext context) {
|
|||||||
switch (action) {
|
switch (action) {
|
||||||
case _AccountMenuAction.account:
|
case _AccountMenuAction.account:
|
||||||
await Navigator.of(context).push(
|
await Navigator.of(context).push(
|
||||||
MaterialPageRoute<void>(builder: (_) => const AccountScreen()),
|
MaterialPageRoute<void>(
|
||||||
|
builder: (_) => const AccountScreen(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
case _AccountMenuAction.logout:
|
case _AccountMenuAction.logout:
|
||||||
await auth.logout();
|
await auth.logout();
|
||||||
@@ -40,8 +77,6 @@ Widget authAppBarAction(BuildContext context) {
|
|||||||
},
|
},
|
||||||
itemBuilder: (context) {
|
itemBuilder: (context) {
|
||||||
final email = auth.email;
|
final email = auth.email;
|
||||||
// Capture whether sync was active when the menu opened; the tile
|
|
||||||
// itself keeps listening so the % can update while the menu is open.
|
|
||||||
final showSyncRow = syncActive;
|
final showSyncRow = syncActive;
|
||||||
return [
|
return [
|
||||||
if (email != null)
|
if (email != null)
|
||||||
@@ -85,12 +120,14 @@ Widget authAppBarAction(BuildContext context) {
|
|||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
CircleAvatar(
|
||||||
radius: 16,
|
radius: 16,
|
||||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
backgroundColor:
|
||||||
|
Theme.of(context).colorScheme.primaryContainer,
|
||||||
child: Text(
|
child: Text(
|
||||||
auth.initials,
|
auth.initials,
|
||||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||||
color:
|
color: Theme.of(context)
|
||||||
Theme.of(context).colorScheme.onPrimaryContainer,
|
.colorScheme
|
||||||
|
.onPrimaryContainer,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -99,7 +136,8 @@ Widget authAppBarAction(BuildContext context) {
|
|||||||
width: 32,
|
width: 32,
|
||||||
height: 32,
|
height: 32,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
value: progress <= 0 ? null : progress.clamp(0.02, 1.0),
|
value:
|
||||||
|
progress <= 0 ? null : progress.clamp(0.02, 1.0),
|
||||||
strokeWidth: 2.5,
|
strokeWidth: 2.5,
|
||||||
color: ringColor,
|
color: ringColor,
|
||||||
backgroundColor: Theme.of(context)
|
backgroundColor: Theme.of(context)
|
||||||
@@ -113,17 +151,9 @@ Widget authAppBarAction(BuildContext context) {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
return IconButton(
|
|
||||||
tooltip: 'Sign in',
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.of(context).push(
|
|
||||||
MaterialPageRoute<bool>(builder: (_) => const LoginScreen()),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.person_outline),
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Live sync status row inside the account popup (watches [SyncController]).
|
/// Live sync status row inside the account popup (watches [SyncController]).
|
||||||
@@ -132,7 +162,10 @@ class _SyncProgressMenuTile extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final sync = context.watch<SyncController>();
|
return ListenableBuilder(
|
||||||
|
listenable: context.read<SyncController>(),
|
||||||
|
builder: (context, _) {
|
||||||
|
final sync = context.read<SyncController>();
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final colors = theme.colorScheme;
|
final colors = theme.colorScheme;
|
||||||
final pct = (sync.syncProgress * 100).round().clamp(0, 100);
|
final pct = (sync.syncProgress * 100).round().clamp(0, 100);
|
||||||
@@ -169,9 +202,9 @@ class _SyncProgressMenuTile extends StatelessWidget {
|
|||||||
color: colors.onSurface,
|
color: colors.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
subtitle: detail.isEmpty
|
subtitle: detail.isEmpty ? null : Text('Sync in progress ($pct%)'),
|
||||||
? null
|
);
|
||||||
: Text('Sync in progress ($pct%)'),
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class TransactionFilterSelection {
|
|||||||
this.amountRange = const TxAmountRange(),
|
this.amountRange = const TxAmountRange(),
|
||||||
this.query = '',
|
this.query = '',
|
||||||
this.monthOnly = false,
|
this.monthOnly = false,
|
||||||
|
this.recurringOnly = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Set<String> labelIds;
|
final Set<String> labelIds;
|
||||||
@@ -29,10 +30,14 @@ class TransactionFilterSelection {
|
|||||||
/// When true, list is limited to the selected calendar month.
|
/// When true, list is limited to the selected calendar month.
|
||||||
final bool monthOnly;
|
final bool monthOnly;
|
||||||
|
|
||||||
|
/// When true, only transactions marked recurring are shown.
|
||||||
|
final bool recurringOnly;
|
||||||
|
|
||||||
bool get hasActiveExtras =>
|
bool get hasActiveExtras =>
|
||||||
flow != TxFlowFilter.all ||
|
flow != TxFlowFilter.all ||
|
||||||
amountRange.isActive ||
|
amountRange.isActive ||
|
||||||
query.trim().isNotEmpty;
|
query.trim().isNotEmpty ||
|
||||||
|
recurringOnly;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How label series are drawn on the overview chart.
|
/// How label series are drawn on the overview chart.
|
||||||
@@ -78,10 +83,20 @@ Future<LabelFilterSelection?> showLabelFilterDialog(
|
|||||||
bool includeInvertChart = false,
|
bool includeInvertChart = false,
|
||||||
bool initialInvertChart = false,
|
bool initialInvertChart = false,
|
||||||
}) {
|
}) {
|
||||||
return showDialog<LabelFilterSelection>(
|
return showModalBottomSheet<LabelFilterSelection>(
|
||||||
context: context,
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
useSafeArea: true,
|
||||||
|
showDragHandle: true,
|
||||||
builder: (dialogContext) {
|
builder: (dialogContext) {
|
||||||
return _SimpleLabelFilterDialog(
|
final media = MediaQuery.of(dialogContext);
|
||||||
|
final maxHeight =
|
||||||
|
(media.size.height - media.viewInsets.bottom) * 0.92;
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: media.viewInsets.bottom),
|
||||||
|
child: SizedBox(
|
||||||
|
height: maxHeight.clamp(320.0, media.size.height),
|
||||||
|
child: _SimpleLabelFilterDialog(
|
||||||
available: available,
|
available: available,
|
||||||
initiallySelected: initiallySelected,
|
initiallySelected: initiallySelected,
|
||||||
title: title,
|
title: title,
|
||||||
@@ -95,6 +110,8 @@ Future<LabelFilterSelection?> showLabelFilterDialog(
|
|||||||
initialBarStyle: initialBarStyle,
|
initialBarStyle: initialBarStyle,
|
||||||
includeInvertChart: includeInvertChart,
|
includeInvertChart: includeInvertChart,
|
||||||
initialInvertChart: initialInvertChart,
|
initialInvertChart: initialInvertChart,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -241,10 +258,6 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
|
|||||||
final showUncategorized = widget.includeUncategorized &&
|
final showUncategorized = widget.includeUncategorized &&
|
||||||
(needle.isEmpty ||
|
(needle.isEmpty ||
|
||||||
kUncategorizedFilterLabel.name.toLowerCase().contains(needle));
|
kUncategorizedFilterLabel.name.toLowerCase().contains(needle));
|
||||||
final media = MediaQuery.of(context);
|
|
||||||
final maxContentHeight =
|
|
||||||
((media.size.height - media.viewInsets.bottom) * 0.72)
|
|
||||||
.clamp(320.0, 720.0);
|
|
||||||
|
|
||||||
final visibleIds = <String>[
|
final visibleIds = <String>[
|
||||||
for (final e in entries) e.label.id,
|
for (final e in entries) e.label.id,
|
||||||
@@ -255,28 +268,47 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
|
|||||||
visibleIds.isNotEmpty && selectedVisible == visibleIds.length;
|
visibleIds.isNotEmpty && selectedVisible == visibleIds.length;
|
||||||
final noneVisibleSelected = selectedVisible == 0;
|
final noneVisibleSelected = selectedVisible == 0;
|
||||||
|
|
||||||
return AlertDialog(
|
void apply() {
|
||||||
title: Text(widget.title),
|
Navigator.pop(
|
||||||
content: SizedBox(
|
context,
|
||||||
width: double.maxFinite,
|
LabelFilterSelection(
|
||||||
height: maxContentHeight,
|
labelIds: Set<String>.of(_selected),
|
||||||
child: Column(
|
flow: widget.includeFlow ? _flow : TxFlowFilter.all,
|
||||||
|
barStyle: widget.includeBarStyle
|
||||||
|
? _barStyle
|
||||||
|
: LabelChartBarStyle.parallel,
|
||||||
|
invertExpensePerspective: widget.includeInvertChart &&
|
||||||
|
_flow == TxFlowFilter.all &&
|
||||||
|
_invertChart,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
ConstrainedBox(
|
Padding(
|
||||||
constraints: BoxConstraints(maxHeight: maxContentHeight * 0.42),
|
padding: const EdgeInsets.fromLTRB(24, 4, 24, 0),
|
||||||
child: SingleChildScrollView(
|
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
Text(widget.title, style: theme.textTheme.titleLarge),
|
||||||
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
widget.subtitle,
|
widget.subtitle,
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
color: colors.onSurfaceVariant,
|
color: colors.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Expanded(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 4, 24, 8),
|
||||||
|
children: [
|
||||||
if (widget.includeFlow) ...[
|
if (widget.includeFlow) ...[
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(
|
Text(
|
||||||
'Income / expense',
|
'Income / expense',
|
||||||
style: theme.textTheme.titleSmall?.copyWith(
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
@@ -315,13 +347,12 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
|
|||||||
'Expense view: spending up, income/refunds down',
|
'Expense view: spending up, income/refunds down',
|
||||||
),
|
),
|
||||||
value: _invertChart,
|
value: _invertChart,
|
||||||
onChanged: (value) =>
|
onChanged: (value) => setState(() => _invertChart = value),
|
||||||
setState(() => _invertChart = value),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
const SizedBox(height: 14),
|
||||||
],
|
],
|
||||||
if (widget.includeBarStyle) ...[
|
if (widget.includeBarStyle) ...[
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(
|
Text(
|
||||||
'Bar layout',
|
'Bar layout',
|
||||||
style: theme.textTheme.titleSmall?.copyWith(
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
@@ -331,8 +362,7 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_RectToggleBar<LabelChartBarStyle>(
|
_RectToggleBar<LabelChartBarStyle>(
|
||||||
value: _barStyle,
|
value: _barStyle,
|
||||||
onChanged: (value) =>
|
onChanged: (value) => setState(() => _barStyle = value),
|
||||||
setState(() => _barStyle = value),
|
|
||||||
options: const [
|
options: const [
|
||||||
_RectToggleOption(
|
_RectToggleOption(
|
||||||
value: LabelChartBarStyle.parallel,
|
value: LabelChartBarStyle.parallel,
|
||||||
@@ -346,9 +376,9 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
],
|
],
|
||||||
if (widget.allowCreate) ...[
|
if (widget.allowCreate) ...[
|
||||||
const SizedBox(height: 10),
|
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: TextButton.icon(
|
child: TextButton.icon(
|
||||||
@@ -357,12 +387,15 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
|
|||||||
label: const Text('New label'),
|
label: const Text('New label'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
],
|
Text(
|
||||||
|
'Labels',
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 8),
|
||||||
const SizedBox(height: 10),
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -386,7 +419,8 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Tooltip(
|
Tooltip(
|
||||||
message: allVisibleSelected ? 'Deselect all' : 'Select all',
|
message:
|
||||||
|
allVisibleSelected ? 'Deselect all' : 'Select all',
|
||||||
child: Checkbox(
|
child: Checkbox(
|
||||||
tristate: true,
|
tristate: true,
|
||||||
value: allVisibleSelected
|
value: allVisibleSelected
|
||||||
@@ -410,49 +444,29 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Expanded(
|
_LabelChecklist(
|
||||||
child: _LabelChecklist(
|
|
||||||
entries: entries,
|
entries: entries,
|
||||||
showUncategorized: showUncategorized,
|
showUncategorized: showUncategorized,
|
||||||
selected: _selected,
|
selected: _selected,
|
||||||
onToggle: _toggle,
|
onToggle: _toggle,
|
||||||
allowMove: widget.allowMove,
|
allowMove: widget.allowMove,
|
||||||
),
|
shrinkWrap: true,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
Padding(
|
||||||
actions: [
|
padding: const EdgeInsets.fromLTRB(24, 4, 24, 16),
|
||||||
SizedBox(
|
child: Column(
|
||||||
width: double.maxFinite,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
child: Row(
|
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
FilledButton(
|
||||||
child: TextButton(
|
onPressed: apply,
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
flex: 2,
|
|
||||||
child: FilledButton(
|
|
||||||
onPressed: () => Navigator.pop(
|
|
||||||
context,
|
|
||||||
LabelFilterSelection(
|
|
||||||
labelIds: Set<String>.of(_selected),
|
|
||||||
flow: widget.includeFlow ? _flow : TxFlowFilter.all,
|
|
||||||
barStyle: widget.includeBarStyle
|
|
||||||
? _barStyle
|
|
||||||
: LabelChartBarStyle.parallel,
|
|
||||||
invertExpensePerspective: widget.includeInvertChart &&
|
|
||||||
_flow == TxFlowFilter.all &&
|
|
||||||
_invertChart,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: const Text('Apply'),
|
child: const Text('Apply'),
|
||||||
),
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -488,6 +502,7 @@ class _TransactionFilterDialogState extends State<_TransactionFilterDialog> {
|
|||||||
late final Set<String> _selected;
|
late final Set<String> _selected;
|
||||||
late TxFlowFilter _flow;
|
late TxFlowFilter _flow;
|
||||||
late bool _monthOnly;
|
late bool _monthOnly;
|
||||||
|
late bool _recurringOnly;
|
||||||
late final TextEditingController _queryController;
|
late final TextEditingController _queryController;
|
||||||
late final TextEditingController _labelSearchController;
|
late final TextEditingController _labelSearchController;
|
||||||
late final TextEditingController _exactAmountController;
|
late final TextEditingController _exactAmountController;
|
||||||
@@ -501,6 +516,7 @@ class _TransactionFilterDialogState extends State<_TransactionFilterDialog> {
|
|||||||
_selected = Set<String>.of(initial.labelIds);
|
_selected = Set<String>.of(initial.labelIds);
|
||||||
_flow = initial.flow;
|
_flow = initial.flow;
|
||||||
_monthOnly = initial.monthOnly;
|
_monthOnly = initial.monthOnly;
|
||||||
|
_recurringOnly = initial.recurringOnly;
|
||||||
_queryController = TextEditingController(text: initial.query);
|
_queryController = TextEditingController(text: initial.query);
|
||||||
_labelSearchController = TextEditingController();
|
_labelSearchController = TextEditingController();
|
||||||
|
|
||||||
@@ -586,6 +602,7 @@ class _TransactionFilterDialogState extends State<_TransactionFilterDialog> {
|
|||||||
amountRange: _amountResult,
|
amountRange: _amountResult,
|
||||||
query: _queryController.text.trim(),
|
query: _queryController.text.trim(),
|
||||||
monthOnly: _monthOnly,
|
monthOnly: _monthOnly,
|
||||||
|
recurringOnly: _recurringOnly,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -701,6 +718,24 @@ class _TransactionFilterDialogState extends State<_TransactionFilterDialog> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
CheckboxListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
dense: true,
|
||||||
|
value: _recurringOnly,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => _recurringOnly = value ?? false);
|
||||||
|
},
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
secondary: const Icon(Icons.event_repeat, size: 20),
|
||||||
|
title: const Text('Recurring only'),
|
||||||
|
subtitle: Text(
|
||||||
|
'Only transactions marked as recurring',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
Text('Amount', style: sectionStyle),
|
Text('Amount', style: sectionStyle),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|||||||
@@ -0,0 +1,532 @@
|
|||||||
|
/// Create / edit look-ahead recurring templates.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../models/bank.dart';
|
||||||
|
import '../models/transaction_meta.dart';
|
||||||
|
import '../providers/bank_provider.dart';
|
||||||
|
import '../providers/tx_meta_provider.dart';
|
||||||
|
import '../utils/helpers.dart';
|
||||||
|
import '../utils/look_ahead.dart';
|
||||||
|
|
||||||
|
Future<void> showCreateRecurringTemplateSheet(BuildContext context) {
|
||||||
|
return showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
useSafeArea: true,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (context) => const _RecurringTemplateSheet(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> showEditRecurringTemplateSheet(
|
||||||
|
BuildContext context, {
|
||||||
|
required RecurringTemplate template,
|
||||||
|
}) {
|
||||||
|
return showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
useSafeArea: true,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (context) => _RecurringTemplateSheet(existing: template),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RecurringTemplateSheet extends StatefulWidget {
|
||||||
|
const _RecurringTemplateSheet({this.existing});
|
||||||
|
|
||||||
|
final RecurringTemplate? existing;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_RecurringTemplateSheet> createState() =>
|
||||||
|
_RecurringTemplateSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RecurringTemplateSheetState extends State<_RecurringTemplateSheet> {
|
||||||
|
late RecurringTemplateSource _source;
|
||||||
|
late RecurringAmountRule _rule;
|
||||||
|
late final TextEditingController _nameController;
|
||||||
|
late final TextEditingController _dayController;
|
||||||
|
late final TextEditingController _avgController;
|
||||||
|
BankTransaction? _pickedTx;
|
||||||
|
String? _labelId;
|
||||||
|
String _currency = 'EUR';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final existing = widget.existing;
|
||||||
|
_source = existing?.source ?? RecurringTemplateSource.transaction;
|
||||||
|
_rule = existing?.amountRule ?? RecurringAmountRule.lastValue;
|
||||||
|
_nameController = TextEditingController(text: existing?.name ?? '');
|
||||||
|
_dayController = TextEditingController(
|
||||||
|
text: '${existing?.dayOfMonth ?? 1}',
|
||||||
|
);
|
||||||
|
_avgController = TextEditingController(
|
||||||
|
text: '${existing?.avgCount ?? 5}',
|
||||||
|
);
|
||||||
|
_labelId = existing?.labelId;
|
||||||
|
_currency = existing?.currency ?? 'EUR';
|
||||||
|
|
||||||
|
if (existing?.sourceTxId != null) {
|
||||||
|
final txs = context.read<BankController>().transactions;
|
||||||
|
for (final tx in txs) {
|
||||||
|
if (tx.id == existing!.sourceTxId) {
|
||||||
|
_pickedTx = tx;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nameController.dispose();
|
||||||
|
_dayController.dispose();
|
||||||
|
_avgController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
int get _day {
|
||||||
|
final v = int.tryParse(_dayController.text.trim()) ?? 1;
|
||||||
|
return v.clamp(1, 28);
|
||||||
|
}
|
||||||
|
|
||||||
|
int get _avg {
|
||||||
|
final v = int.tryParse(_avgController.text.trim()) ?? 5;
|
||||||
|
return v.clamp(1, 36);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickTransaction() async {
|
||||||
|
final bank = context.read<BankController>();
|
||||||
|
final picked = await showModalBottomSheet<BankTransaction>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
useSafeArea: true,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (context) => _TransactionPickerSheet(
|
||||||
|
transactions: bank.transactions,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (picked == null || !mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_pickedTx = picked;
|
||||||
|
_currency = picked.currency;
|
||||||
|
_source = RecurringTemplateSource.transaction;
|
||||||
|
if (_nameController.text.trim().isEmpty || widget.existing == null) {
|
||||||
|
_nameController.text = picked.description;
|
||||||
|
}
|
||||||
|
_dayController.text =
|
||||||
|
'${(DateTime.tryParse(picked.date)?.day ?? 1).clamp(1, 28)}';
|
||||||
|
if (_rule == RecurringAmountRule.labelAvgMonths) {
|
||||||
|
_rule = RecurringAmountRule.lastValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickLabel() async {
|
||||||
|
final meta = context.read<TxMetaController>();
|
||||||
|
final labels = [...meta.allLabels]
|
||||||
|
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||||
|
final picked = await showModalBottomSheet<TxLabel>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
useSafeArea: true,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (context) => _LabelPickerSheet(labels: labels),
|
||||||
|
);
|
||||||
|
if (picked == null || !mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_labelId = picked.id;
|
||||||
|
_source = RecurringTemplateSource.label;
|
||||||
|
_rule = RecurringAmountRule.labelAvgMonths;
|
||||||
|
if (_nameController.text.trim().isEmpty || widget.existing == null) {
|
||||||
|
_nameController.text = picked.name;
|
||||||
|
}
|
||||||
|
if (_dayController.text.trim().isEmpty) {
|
||||||
|
_dayController.text = '1';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _save() {
|
||||||
|
final name = _nameController.text.trim();
|
||||||
|
if (name.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Enter a name')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_source == RecurringTemplateSource.transaction && _pickedTx == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Pick a transaction')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((_source == RecurringTemplateSource.label ||
|
||||||
|
_rule == RecurringAmountRule.labelAvgMonths) &&
|
||||||
|
(_labelId == null || _labelId!.isEmpty)) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Pick a label')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final existing = widget.existing;
|
||||||
|
final id = existing?.id.isNotEmpty == true
|
||||||
|
? existing!.id
|
||||||
|
: 'rt_${DateTime.now().microsecondsSinceEpoch.toRadixString(16)}';
|
||||||
|
|
||||||
|
final tpl = RecurringTemplate(
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
dayOfMonth: _day,
|
||||||
|
currency: _currency,
|
||||||
|
source: _source,
|
||||||
|
amountRule: _source == RecurringTemplateSource.label
|
||||||
|
? RecurringAmountRule.labelAvgMonths
|
||||||
|
: _rule,
|
||||||
|
merchantKey: _pickedTx?.merchantKey ?? existing?.merchantKey ?? '',
|
||||||
|
sourceTxId: _source == RecurringTemplateSource.transaction
|
||||||
|
? _pickedTx?.id
|
||||||
|
: null,
|
||||||
|
labelId: (_source == RecurringTemplateSource.label ||
|
||||||
|
_rule == RecurringAmountRule.labelAvgMonths)
|
||||||
|
? _labelId
|
||||||
|
: null,
|
||||||
|
avgCount: _avg,
|
||||||
|
autoFromRecurring: existing?.autoFromRecurring ?? false,
|
||||||
|
);
|
||||||
|
// Pop first so the sheet does not rebuild against the notify.
|
||||||
|
final meta = context.read<TxMetaController>();
|
||||||
|
Navigator.pop(context);
|
||||||
|
meta.upsertRecurringTemplate(tpl);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _delete() {
|
||||||
|
final existing = widget.existing;
|
||||||
|
if (existing == null) return;
|
||||||
|
final meta = context.read<TxMetaController>();
|
||||||
|
Navigator.pop(context);
|
||||||
|
meta.removeRecurringTemplate(existing.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colors = theme.colorScheme;
|
||||||
|
final meta = context.read<TxMetaController>();
|
||||||
|
final bank = context.read<BankController>();
|
||||||
|
final media = MediaQuery.of(context);
|
||||||
|
final label = meta.labelById(_labelId);
|
||||||
|
|
||||||
|
final preview = resolveRecurringAmount(
|
||||||
|
template: RecurringTemplate(
|
||||||
|
id: 'preview',
|
||||||
|
name: _nameController.text,
|
||||||
|
dayOfMonth: _day,
|
||||||
|
currency: _currency,
|
||||||
|
source: _source,
|
||||||
|
amountRule: _source == RecurringTemplateSource.label
|
||||||
|
? RecurringAmountRule.labelAvgMonths
|
||||||
|
: _rule,
|
||||||
|
merchantKey: _pickedTx?.merchantKey ?? '',
|
||||||
|
sourceTxId: _pickedTx?.id,
|
||||||
|
labelId: _labelId,
|
||||||
|
avgCount: _avg,
|
||||||
|
),
|
||||||
|
transactions: bank.transactions,
|
||||||
|
isInternalTransfer: (tx) => meta.metaFor(tx).internalTransfer,
|
||||||
|
labelIdsFor: meta.labelIdsFor,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: media.viewInsets.bottom),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 4, 24, 24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.existing == null ? 'New recurring template' : 'Edit template',
|
||||||
|
style: theme.textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _nameController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Name',
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _dayController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Day of month (1–28)',
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text('Based on', style: theme.textTheme.titleSmall),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SegmentedButton<RecurringTemplateSource>(
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(
|
||||||
|
value: RecurringTemplateSource.transaction,
|
||||||
|
label: Text('Transaction'),
|
||||||
|
icon: Icon(Icons.receipt_long, size: 18),
|
||||||
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: RecurringTemplateSource.label,
|
||||||
|
label: Text('Label'),
|
||||||
|
icon: Icon(Icons.label_outline, size: 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
selected: {_source},
|
||||||
|
onSelectionChanged: (next) {
|
||||||
|
setState(() {
|
||||||
|
_source = next.first;
|
||||||
|
if (_source == RecurringTemplateSource.label) {
|
||||||
|
_rule = RecurringAmountRule.labelAvgMonths;
|
||||||
|
} else if (_rule == RecurringAmountRule.labelAvgMonths) {
|
||||||
|
_rule = RecurringAmountRule.lastValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (_source == RecurringTemplateSource.transaction) ...[
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _pickTransaction,
|
||||||
|
icon: const Icon(Icons.search),
|
||||||
|
label: Text(
|
||||||
|
_pickedTx == null
|
||||||
|
? 'Pick transaction'
|
||||||
|
: _pickedTx!.description,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text('Amount', style: theme.textTheme.titleSmall),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
ChoiceChip(
|
||||||
|
label: const Text('Last value'),
|
||||||
|
selected: _rule == RecurringAmountRule.lastValue,
|
||||||
|
onSelected: (_) => setState(
|
||||||
|
() => _rule = RecurringAmountRule.lastValue,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ChoiceChip(
|
||||||
|
label: const Text('Avg last X matching'),
|
||||||
|
selected: _rule == RecurringAmountRule.avgLastTxs,
|
||||||
|
onSelected: (_) => setState(
|
||||||
|
() => _rule = RecurringAmountRule.avgLastTxs,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ChoiceChip(
|
||||||
|
label: const Text('Label avg'),
|
||||||
|
selected: _rule == RecurringAmountRule.labelAvgMonths,
|
||||||
|
onSelected: (_) => setState(
|
||||||
|
() => _rule = RecurringAmountRule.labelAvgMonths,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_rule == RecurringAmountRule.avgLastTxs) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _avgController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Number of matching transactions',
|
||||||
|
helperText:
|
||||||
|
'Matches use the same merchant pattern and income/expense as the picked tx',
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (_rule == RecurringAmountRule.labelAvgMonths) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _pickLabel,
|
||||||
|
icon: Icon(
|
||||||
|
label?.icon ?? Icons.label_outline,
|
||||||
|
color: label?.color,
|
||||||
|
),
|
||||||
|
label: Text(label?.name ?? 'Pick label'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _avgController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Previous months',
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
] else ...[
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _pickLabel,
|
||||||
|
icon: Icon(
|
||||||
|
label?.icon ?? Icons.label_outline,
|
||||||
|
color: label?.color,
|
||||||
|
),
|
||||||
|
label: Text(label?.name ?? 'Pick label'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _avgController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Previous months to average',
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Preview: ${formatMoney(preview, _currency)}',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _save,
|
||||||
|
child: Text(widget.existing == null ? 'Create' : 'Save'),
|
||||||
|
),
|
||||||
|
if (widget.existing != null)
|
||||||
|
TextButton(
|
||||||
|
onPressed: _delete,
|
||||||
|
child: Text(
|
||||||
|
'Delete',
|
||||||
|
style: TextStyle(color: colors.error),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TransactionPickerSheet extends StatefulWidget {
|
||||||
|
const _TransactionPickerSheet({required this.transactions});
|
||||||
|
|
||||||
|
final List<BankTransaction> transactions;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_TransactionPickerSheet> createState() =>
|
||||||
|
_TransactionPickerSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TransactionPickerSheetState extends State<_TransactionPickerSheet> {
|
||||||
|
final _search = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_search.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final needle = _search.text.trim().toLowerCase();
|
||||||
|
final txs = [
|
||||||
|
for (final tx in widget.transactions)
|
||||||
|
if (needle.isEmpty ||
|
||||||
|
tx.description.toLowerCase().contains(needle) ||
|
||||||
|
tx.merchantKey.contains(needle))
|
||||||
|
tx,
|
||||||
|
].take(80).toList(growable: false);
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
height: MediaQuery.of(context).size.height * 0.75,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||||
|
child: TextField(
|
||||||
|
controller: _search,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Search transactions',
|
||||||
|
prefixIcon: Icon(Icons.search),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: ListView.separated(
|
||||||
|
itemCount: txs.length,
|
||||||
|
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final tx = txs[index];
|
||||||
|
return ListTile(
|
||||||
|
title: Text(
|
||||||
|
tx.description,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'${formatIsoDate(tx.date)} · ${formatMoney(tx.amount, tx.currency)}',
|
||||||
|
),
|
||||||
|
onTap: () => Navigator.pop(context, tx),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LabelPickerSheet extends StatelessWidget {
|
||||||
|
const _LabelPickerSheet({required this.labels});
|
||||||
|
|
||||||
|
final List<TxLabel> labels;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
height: MediaQuery.of(context).size.height * 0.6,
|
||||||
|
child: ListView.separated(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
itemCount: labels.length,
|
||||||
|
separatorBuilder: (_, _) => const Divider(height: 1, indent: 56),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final label = labels[index];
|
||||||
|
return ListTile(
|
||||||
|
leading: Icon(label.icon, color: label.color),
|
||||||
|
title: Text(label.name),
|
||||||
|
onTap: () => Navigator.pop(context, label),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/// Provides the active bottom-nav tab index for [MainShell].
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class ShellTabScope extends InheritedWidget {
|
||||||
|
const ShellTabScope({
|
||||||
|
super.key,
|
||||||
|
required this.index,
|
||||||
|
required super.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int index;
|
||||||
|
|
||||||
|
static int of(BuildContext context) {
|
||||||
|
final scope = context.dependOnInheritedWidgetOfExactType<ShellTabScope>();
|
||||||
|
assert(scope != null, 'ShellTabScope not found');
|
||||||
|
return scope!.index;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int? maybeOf(BuildContext context) {
|
||||||
|
return context.dependOnInheritedWidgetOfExactType<ShellTabScope>()?.index;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool updateShouldNotify(ShellTabScope oldWidget) => index != oldWidget.index;
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/// PocketBase migration: recurring look-ahead templates.
|
||||||
|
///
|
||||||
|
/// Apply on the server (from /opt/pocketbase or wherever the binary lives):
|
||||||
|
///
|
||||||
|
/// sudo cp this_file /opt/pocketbase/pb_migrations/
|
||||||
|
/// sudo chown pocketbase:pocketbase /opt/pocketbase/pb_migrations/*.js
|
||||||
|
/// sudo -u pocketbase /opt/pocketbase/pocketbase migrate up
|
||||||
|
/// sudo systemctl restart pocketbase
|
||||||
|
///
|
||||||
|
/// Depends on usersapp (same owner rules as labels / tx_meta).
|
||||||
|
migrate((app) => {
|
||||||
|
const users = app.findCollectionByNameOrId("usersapp");
|
||||||
|
|
||||||
|
const ownerRules = {
|
||||||
|
listRule: "user = @request.auth.id",
|
||||||
|
viewRule: "user = @request.auth.id",
|
||||||
|
createRule: "@request.auth.id != '' && user = @request.auth.id",
|
||||||
|
updateRule: "user = @request.auth.id",
|
||||||
|
deleteRule: "user = @request.auth.id",
|
||||||
|
};
|
||||||
|
|
||||||
|
const recurringTemplates = new Collection({
|
||||||
|
type: "base",
|
||||||
|
name: "recurring_templates",
|
||||||
|
...ownerRules,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: "relation",
|
||||||
|
name: "user",
|
||||||
|
required: true,
|
||||||
|
collectionId: users.id,
|
||||||
|
cascadeDelete: true,
|
||||||
|
maxSelect: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// App-side RecurringTemplate.id
|
||||||
|
type: "text",
|
||||||
|
name: "client_id",
|
||||||
|
required: true,
|
||||||
|
min: 1,
|
||||||
|
max: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
name: "name",
|
||||||
|
required: true,
|
||||||
|
min: 1,
|
||||||
|
max: 400,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "number",
|
||||||
|
name: "day_of_month",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
name: "currency",
|
||||||
|
required: true,
|
||||||
|
min: 1,
|
||||||
|
max: 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// "transaction" | "label"
|
||||||
|
type: "text",
|
||||||
|
name: "source",
|
||||||
|
required: true,
|
||||||
|
min: 1,
|
||||||
|
max: 32,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// "lastValue" | "avgLastTxs" | "labelAvgMonths"
|
||||||
|
type: "text",
|
||||||
|
name: "amount_rule",
|
||||||
|
required: true,
|
||||||
|
min: 1,
|
||||||
|
max: 32,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
name: "merchant_key",
|
||||||
|
required: false,
|
||||||
|
max: 500,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
name: "source_tx_id",
|
||||||
|
required: false,
|
||||||
|
max: 2000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
name: "label_id",
|
||||||
|
required: false,
|
||||||
|
max: 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "number",
|
||||||
|
name: "avg_count",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "bool",
|
||||||
|
name: "auto_from_recurring",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
indexes: [
|
||||||
|
"CREATE UNIQUE INDEX idx_recurring_templates_user_client ON recurring_templates (user, client_id)",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
app.save(recurringTemplates);
|
||||||
|
}, (app) => {
|
||||||
|
try {
|
||||||
|
const col = app.findCollectionByNameOrId("recurring_templates");
|
||||||
|
app.delete(col);
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user