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.
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
/// 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;
|
||||
|
||||
_remoteTransactions = mergeTransactions(_remoteTransactions, fromCloud);
|
||||
@@ -181,7 +184,7 @@ class BankController extends ChangeNotifier {
|
||||
|
||||
await _persistCache();
|
||||
await _persistImported();
|
||||
notifyListeners();
|
||||
if (notify) notifyListeners();
|
||||
}
|
||||
|
||||
/// Load status; if linked, also load balance + transactions.
|
||||
|
||||
@@ -51,6 +51,8 @@ class SyncController extends ChangeNotifier {
|
||||
bool _queuedFullSync = false;
|
||||
Timer? _pushDebounce;
|
||||
Timer? _progressHold;
|
||||
Timer? _uiNotifyThrottle;
|
||||
var _uiNotifyQueued = false;
|
||||
String? _lastError;
|
||||
DateTime? _lastSyncedAt;
|
||||
String _lastSyncSummary = '';
|
||||
@@ -64,6 +66,7 @@ class SyncController extends ChangeNotifier {
|
||||
double _syncProgress = 0;
|
||||
bool _showProgressRing = false;
|
||||
bool _progressSuccess = false;
|
||||
int _lastNotifiedProgressPct = -1;
|
||||
|
||||
bool get isSyncing => _syncing;
|
||||
String? get lastError => _lastError;
|
||||
@@ -86,6 +89,25 @@ class SyncController extends ChangeNotifier {
|
||||
/// Ring turns green when this is true (sync finished successfully).
|
||||
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}) {
|
||||
_feedbackEpoch++;
|
||||
_feedback = SyncFeedback(
|
||||
@@ -94,24 +116,38 @@ class SyncController extends ChangeNotifier {
|
||||
isProgress: isProgress,
|
||||
isError: isError,
|
||||
);
|
||||
notifyListeners();
|
||||
_notifyUiNow();
|
||||
}
|
||||
|
||||
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;
|
||||
_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) {
|
||||
if (_statusDetail == message) return;
|
||||
_statusDetail = message;
|
||||
notifyListeners();
|
||||
_notifyUiThrottled();
|
||||
}
|
||||
|
||||
void _finishProgress({required bool success}) {
|
||||
_progressHold?.cancel();
|
||||
_uiNotifyThrottle?.cancel();
|
||||
_uiNotifyQueued = false;
|
||||
_statusDetail = '';
|
||||
if (success) {
|
||||
_setProgress(1, success: true);
|
||||
@@ -119,13 +155,15 @@ class SyncController extends ChangeNotifier {
|
||||
_showProgressRing = false;
|
||||
_progressSuccess = false;
|
||||
_syncProgress = 0;
|
||||
notifyListeners();
|
||||
_lastNotifiedProgressPct = -1;
|
||||
_notifyUiNow();
|
||||
});
|
||||
} else {
|
||||
_showProgressRing = false;
|
||||
_progressSuccess = false;
|
||||
_syncProgress = 0;
|
||||
notifyListeners();
|
||||
_lastNotifiedProgressPct = -1;
|
||||
_notifyUiNow();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,12 +176,15 @@ class SyncController extends ChangeNotifier {
|
||||
if (!loggedIn) {
|
||||
_pushDebounce?.cancel();
|
||||
_progressHold?.cancel();
|
||||
_uiNotifyThrottle?.cancel();
|
||||
_uiNotifyQueued = false;
|
||||
_lastError = null;
|
||||
_statusDetail = '';
|
||||
_showProgressRing = false;
|
||||
_progressSuccess = false;
|
||||
_syncProgress = 0;
|
||||
notifyListeners();
|
||||
_lastNotifiedProgressPct = -1;
|
||||
_notifyUiNow();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,8 +232,11 @@ class SyncController extends ChangeNotifier {
|
||||
_setProgress(0.5);
|
||||
_setStatus('Merging…');
|
||||
final mergedMeta = mergeTxMetaStores(_meta.exportStore(), remoteMeta);
|
||||
await _meta.replaceStore(mergedMeta);
|
||||
await _bank.applySyncedTransactions(remoteTxs);
|
||||
// Apply without notifying — one rebuild after both stores are ready.
|
||||
await _meta.replaceStore(mergedMeta, notify: false);
|
||||
await _bank.applySyncedTransactions(remoteTxs, notify: false);
|
||||
_meta.notifyListeners();
|
||||
_bank.notifyListeners();
|
||||
_setProgress(0.55);
|
||||
_setStatus('Pushing…');
|
||||
final metaSummary = await service.push(
|
||||
@@ -283,6 +327,7 @@ class SyncController extends ChangeNotifier {
|
||||
void dispose() {
|
||||
_pushDebounce?.cancel();
|
||||
_progressHold?.cancel();
|
||||
_uiNotifyThrottle?.cancel();
|
||||
_auth.removeListener(_onAuthChanged);
|
||||
_meta.onPersisted = null;
|
||||
super.dispose();
|
||||
|
||||
@@ -19,6 +19,7 @@ class TxMetaController extends ChangeNotifier {
|
||||
};
|
||||
_notesByMerchant = Map.of(loaded.notesByMerchant);
|
||||
_exactTemplates = Map.of(loaded.exactTemplates);
|
||||
_recurringTemplates = Map.of(loaded.recurringTemplates);
|
||||
_customLabels = List.of(loaded.customLabels);
|
||||
_labelParents = Map.of(loaded.labelParents);
|
||||
}
|
||||
@@ -32,6 +33,7 @@ class TxMetaController extends ChangeNotifier {
|
||||
Map<String, List<String>> _labelsByMerchant = {};
|
||||
Map<String, String> _notesByMerchant = {};
|
||||
Map<String, TxExactTemplate> _exactTemplates = {};
|
||||
Map<String, RecurringTemplate> _recurringTemplates = {};
|
||||
List<TxLabel> _customLabels = [];
|
||||
Map<String, String> _labelParents = {};
|
||||
|
||||
@@ -45,6 +47,7 @@ class TxMetaController extends ChangeNotifier {
|
||||
},
|
||||
notesByMerchant: Map.of(_notesByMerchant),
|
||||
exactTemplates: Map.of(_exactTemplates),
|
||||
recurringTemplates: Map.of(_recurringTemplates),
|
||||
customLabels: List.of(_customLabels),
|
||||
labelParents: Map.of(_labelParents),
|
||||
);
|
||||
@@ -75,7 +78,10 @@ class TxMetaController extends ChangeNotifier {
|
||||
int get customLabelCount => _customLabels.length;
|
||||
|
||||
/// 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);
|
||||
_labelsByMerchant = {
|
||||
for (final e in store.labelsByMerchant.entries)
|
||||
@@ -83,10 +89,12 @@ class TxMetaController extends ChangeNotifier {
|
||||
};
|
||||
_notesByMerchant = Map.of(store.notesByMerchant);
|
||||
_exactTemplates = Map.of(store.exactTemplates);
|
||||
_recurringTemplates = Map.of(store.recurringTemplates);
|
||||
_customLabels = List.of(store.customLabels);
|
||||
_labelParents = Map.of(store.labelParents);
|
||||
_invalidateLabelCache();
|
||||
notifyListeners();
|
||||
_storeRevision++;
|
||||
if (notify) notifyListeners();
|
||||
await _writeStore(notifySync: false);
|
||||
}
|
||||
|
||||
@@ -396,12 +404,16 @@ class TxMetaController extends ChangeNotifier {
|
||||
String? pattern,
|
||||
SimilarMatchCriteria? criteria,
|
||||
}) {
|
||||
return similarTo(
|
||||
prototype,
|
||||
all,
|
||||
pattern: pattern,
|
||||
criteria: criteria,
|
||||
).length;
|
||||
final query = criteria ??
|
||||
SimilarMatchCriteria(
|
||||
pattern: (pattern ?? prototype.merchantKey).trim(),
|
||||
);
|
||||
if (query.pattern.trim().isEmpty) return 1;
|
||||
var n = 0;
|
||||
for (final tx in all) {
|
||||
if (txMatchesSimilarCriteria(tx, query)) n++;
|
||||
}
|
||||
return n == 0 ? 1 : n;
|
||||
}
|
||||
|
||||
List<BankTransaction> similarTo(
|
||||
@@ -630,23 +642,113 @@ class TxMetaController extends ChangeNotifier {
|
||||
_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 {
|
||||
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();
|
||||
_exactTemplates = {
|
||||
..._exactTemplates,
|
||||
tx.exactKey: TxExactTemplate(
|
||||
notes: existing.notes.isNotEmpty ? existing.notes : current.notes,
|
||||
recurring: recurring,
|
||||
internalTransfer:
|
||||
existing.internalTransfer || current.internalTransfer,
|
||||
),
|
||||
};
|
||||
_schedulePersist();
|
||||
_exactTemplates[tx.exactKey] = TxExactTemplate(
|
||||
notes: existing.notes.isNotEmpty ? existing.notes : current.notes,
|
||||
recurring: recurring,
|
||||
internalTransfer: existing.internalTransfer || current.internalTransfer,
|
||||
);
|
||||
if (recurring) {
|
||||
final id = RecurringTemplate.autoIdForTx(tx.id);
|
||||
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(
|
||||
@@ -654,20 +756,14 @@ class TxMetaController extends ChangeNotifier {
|
||||
bool internalTransfer,
|
||||
) async {
|
||||
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();
|
||||
_exactTemplates = {
|
||||
..._exactTemplates,
|
||||
tx.exactKey: TxExactTemplate(
|
||||
notes: existing.notes.isNotEmpty ? existing.notes : current.notes,
|
||||
recurring: existing.recurring || current.recurring,
|
||||
internalTransfer: internalTransfer,
|
||||
),
|
||||
};
|
||||
_schedulePersist();
|
||||
_exactTemplates[tx.exactKey] = TxExactTemplate(
|
||||
notes: existing.notes.isNotEmpty ? existing.notes : current.notes,
|
||||
recurring: existing.recurring || current.recurring,
|
||||
internalTransfer: internalTransfer,
|
||||
);
|
||||
_schedulePersist(invalidateLabels: false);
|
||||
}
|
||||
|
||||
Future<void> setNotes(
|
||||
@@ -824,27 +920,35 @@ class TxMetaController extends ChangeNotifier {
|
||||
return list;
|
||||
}
|
||||
|
||||
/// UI-first: notify on the next frame so callers can paint a busy state first,
|
||||
/// then write SharedPreferences in the background.
|
||||
void _schedulePersist() {
|
||||
_invalidateLabelCache();
|
||||
final binding = WidgetsBinding.instance;
|
||||
binding.addPostFrameCallback((_) {
|
||||
notifyListeners();
|
||||
});
|
||||
// Ensure a frame is scheduled even if nothing else is dirty yet.
|
||||
binding.scheduleFrame();
|
||||
unawaited(_writeStore());
|
||||
int _persistGeneration = 0;
|
||||
int _storeRevision = 0;
|
||||
|
||||
/// Bumps on every local mutation — cheap cache key for look-ahead etc.
|
||||
int get storeRevision => _storeRevision;
|
||||
|
||||
/// Notify immediately; persist after the frame so toggles paint first.
|
||||
void _schedulePersist({bool invalidateLabels = true}) {
|
||||
if (invalidateLabels) _invalidateLabelCache();
|
||||
_storeRevision++;
|
||||
notifyListeners();
|
||||
final gen = ++_persistGeneration;
|
||||
unawaited(_writeStoreDeferred(gen));
|
||||
}
|
||||
|
||||
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 {
|
||||
// Let the busy indicator / label checkbox paint before JSON work.
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
final snapshot = TxMetaStoreData(
|
||||
byId: _byId,
|
||||
labelsByMerchant: _labelsByMerchant,
|
||||
notesByMerchant: _notesByMerchant,
|
||||
exactTemplates: _exactTemplates,
|
||||
recurringTemplates: _recurringTemplates,
|
||||
customLabels: [
|
||||
for (final label in _customLabels)
|
||||
label.copyWith(
|
||||
|
||||
@@ -17,23 +17,11 @@ class AccountScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final auth = context.watch<AuthController>();
|
||||
final sync = context.watch<SyncController>();
|
||||
final meta = context.watch<TxMetaController>();
|
||||
final bank = context.watch<BankController>();
|
||||
final theme = Theme.of(context);
|
||||
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 perTxAnnotations = meta.syncableAnnotationCount;
|
||||
final viaMerchantOnly = meta.merchantOnlyLabeledCount(bank.transactions);
|
||||
@@ -83,41 +71,7 @@ class AccountScreen extends StatelessWidget {
|
||||
subtitle: Text(kPocketBaseUrl),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: sync.isSyncing
|
||||
? SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
value: sync.syncProgress <= 0
|
||||
? null
|
||||
: sync.syncProgress.clamp(0.02, 1.0),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
sync.lastError == null
|
||||
? Icons.sync
|
||||
: Icons.sync_problem_outlined,
|
||||
),
|
||||
title: const Text('Cloud sync'),
|
||||
subtitle: Text(syncSubtitle),
|
||||
trailing: TextButton(
|
||||
onPressed: sync.isSyncing ? null : () => sync.syncNow(),
|
||||
child: const Text('Sync now'),
|
||||
),
|
||||
),
|
||||
if (sync.lastSyncSummary.isNotEmpty) ...[
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.fact_check_outlined),
|
||||
title: const Text('Last sync detail'),
|
||||
subtitle: Text(sync.lastSyncSummary),
|
||||
),
|
||||
],
|
||||
const _CloudSyncTile(),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.phone_android_outlined),
|
||||
@@ -150,6 +104,72 @@ class AccountScreen extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(
|
||||
leading: sync.isSyncing
|
||||
? SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
value: sync.syncProgress <= 0
|
||||
? null
|
||||
: sync.syncProgress.clamp(0.02, 1.0),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
sync.lastError == null
|
||||
? Icons.sync
|
||||
: Icons.sync_problem_outlined,
|
||||
),
|
||||
title: const Text('Cloud sync'),
|
||||
subtitle: Text(syncSubtitle),
|
||||
trailing: TextButton(
|
||||
onPressed: sync.isSyncing ? null : () => sync.syncNow(),
|
||||
child: const Text('Sync now'),
|
||||
),
|
||||
),
|
||||
if (sync.lastSyncSummary.isNotEmpty) ...[
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.fact_check_outlined),
|
||||
title: const Text('Last sync detail'),
|
||||
subtitle: Text(sync.lastSyncSummary),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static String _formatTime(DateTime time) {
|
||||
final local = time.toLocal();
|
||||
|
||||
@@ -13,6 +13,7 @@ import '../utils/navigation.dart';
|
||||
import '../widgets/empty_state.dart';
|
||||
import '../widgets/loading_view.dart';
|
||||
import '../widgets/responsive_body.dart';
|
||||
import '../widgets/shell_tab_scope.dart';
|
||||
import '../widgets/skip_build_when_covered.dart';
|
||||
import 'transaction_detail_screen.dart';
|
||||
import 'transactions_screen.dart';
|
||||
@@ -20,8 +21,16 @@ import 'transactions_screen.dart';
|
||||
class HomeScreen extends StatelessWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
static const tabIndex = 0;
|
||||
|
||||
@override
|
||||
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>();
|
||||
|
||||
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),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+33
-22
@@ -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;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../widgets/shell_tab_scope.dart';
|
||||
import 'home_screen.dart';
|
||||
import 'look_ahead_screen.dart';
|
||||
import 'overview_screen.dart';
|
||||
|
||||
class MainShell extends StatefulWidget {
|
||||
@@ -19,31 +21,40 @@ class _MainShellState extends State<MainShell> {
|
||||
static const _pages = [
|
||||
HomeScreen(),
|
||||
OverviewScreen(),
|
||||
LookAheadScreen(),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: IndexedStack(
|
||||
index: _index,
|
||||
children: _pages,
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _index,
|
||||
onDestinationSelected: (index) => setState(() => _index = index),
|
||||
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
selectedIcon: Icon(Icons.home),
|
||||
label: 'Home',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.insights_outlined),
|
||||
selectedIcon: Icon(Icons.insights),
|
||||
label: 'Overview',
|
||||
),
|
||||
],
|
||||
return ShellTabScope(
|
||||
index: _index,
|
||||
child: Scaffold(
|
||||
body: IndexedStack(
|
||||
index: _index,
|
||||
children: _pages,
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _index,
|
||||
onDestinationSelected: (index) => setState(() => _index = index),
|
||||
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
selectedIcon: Icon(Icons.home),
|
||||
label: 'Home',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.insights_outlined),
|
||||
selectedIcon: Icon(Icons.insights),
|
||||
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/loading_view.dart';
|
||||
import '../widgets/responsive_body.dart';
|
||||
import '../widgets/shell_tab_scope.dart';
|
||||
import '../widgets/skip_build_when_covered.dart';
|
||||
import 'label_transactions_screen.dart';
|
||||
import 'transactions_screen.dart';
|
||||
@@ -31,6 +32,8 @@ const _kExpenseColor = AppTheme.expense;
|
||||
class OverviewScreen extends StatefulWidget {
|
||||
const OverviewScreen({super.key});
|
||||
|
||||
static const tabIndex = 1;
|
||||
|
||||
@override
|
||||
State<OverviewScreen> createState() => _OverviewScreenState();
|
||||
}
|
||||
@@ -163,6 +166,10 @@ class _OverviewScreenState extends State<OverviewScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (ShellTabScope.of(context) != OverviewScreen.tabIndex) {
|
||||
return const SizedBox.expand();
|
||||
}
|
||||
|
||||
final bank = context.watch<BankController>();
|
||||
final meta = context.watch<TxMetaController>();
|
||||
|
||||
|
||||
@@ -26,11 +26,14 @@ class TransactionDetailScreen extends StatefulWidget {
|
||||
|
||||
class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
late final TextEditingController _notesController;
|
||||
late final TextEditingController _labelSearchController;
|
||||
late SimilarMatchCriteria _matchCriteria;
|
||||
late Set<String> _draftLabelIds;
|
||||
var _similarCount = 0;
|
||||
var _similarReady = false;
|
||||
var _dirty = false;
|
||||
var _labelsDirty = false;
|
||||
late bool _applyToSimilar;
|
||||
var _applyToSimilar = false;
|
||||
var _busy = false;
|
||||
String? _busyMessage;
|
||||
|
||||
@@ -40,6 +43,7 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
final meta = context.read<TxMetaController>();
|
||||
final notes = meta.metaFor(widget.tx).notes;
|
||||
_notesController = TextEditingController(text: notes);
|
||||
_labelSearchController = TextEditingController();
|
||||
final tx = widget.tx;
|
||||
_matchCriteria = SimilarMatchCriteria(
|
||||
pattern: defaultSimilarPattern(tx.description),
|
||||
@@ -50,19 +54,31 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
: TxFlowFilter.all,
|
||||
);
|
||||
_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 similarCount = meta.countSimilar(
|
||||
tx,
|
||||
all,
|
||||
criteria: _matchCriteria,
|
||||
);
|
||||
// Default on when there are peers — otherwise users never notice the toggle.
|
||||
_applyToSimilar = similarCount > 1;
|
||||
final count = context.read<TxMetaController>().countSimilar(
|
||||
widget.tx,
|
||||
all,
|
||||
criteria: _matchCriteria,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_similarCount = count;
|
||||
_similarReady = true;
|
||||
// Default on when there are peers — otherwise users never notice the toggle.
|
||||
if (count > 1) _applyToSimilar = true;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_notesController.dispose();
|
||||
_labelSearchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -85,13 +101,10 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
if (result == null || !mounted) return;
|
||||
setState(() {
|
||||
_matchCriteria = result;
|
||||
final count = context.read<TxMetaController>().countSimilar(
|
||||
widget.tx,
|
||||
all,
|
||||
criteria: result,
|
||||
);
|
||||
if (count > 1) _applyToSimilar = true;
|
||||
_similarReady = false;
|
||||
_similarCount = 0;
|
||||
});
|
||||
_refreshSimilarCount();
|
||||
}
|
||||
|
||||
Future<void> _runBusy(
|
||||
@@ -282,17 +295,27 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
final tx = widget.tx;
|
||||
final metaController = context.watch<TxMetaController>();
|
||||
final meta = metaController.metaFor(tx);
|
||||
// Prefer read: bank data rarely changes while editing labels.
|
||||
final all = context.read<BankController>().transactions;
|
||||
final similarCount = metaController.countSimilar(
|
||||
tx,
|
||||
all,
|
||||
criteria: _matchCriteria,
|
||||
);
|
||||
final similarCount = _similarCount;
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
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(
|
||||
appBar: AppBar(
|
||||
@@ -305,275 +328,325 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ResponsiveBody(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 32),
|
||||
children: [
|
||||
if (_busy) ...[
|
||||
const LinearProgressIndicator(minHeight: 2),
|
||||
if (_busyMessage != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(0, 8, 0, 4),
|
||||
child: Text(
|
||||
_busyMessage!,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: colors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
AbsorbPointer(
|
||||
absorbing: _busy,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
formatMoney(tx.amount, tx.currency),
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
color: amountColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(tx.description, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
[
|
||||
formatIsoDate(tx.date),
|
||||
if (tx.pending) 'Pending',
|
||||
].join(' · '),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Text('Match similar', style: theme.textTheme.titleSmall),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
onPressed: _busy ? null : _editMatchCriteria,
|
||||
icon: const Icon(Icons.tune, size: 18),
|
||||
label: const Text('Edit'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'“${_matchCriteria.pattern}”',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
secondary: const Icon(Icons.copy_all_outlined),
|
||||
title: const Text('Include matched transactions'),
|
||||
subtitle: Text(
|
||||
similarCount <= 1
|
||||
? 'No other matches for this pattern'
|
||||
: '$similarCount matching transactions',
|
||||
),
|
||||
value: _applyToSimilar && similarCount > 1,
|
||||
onChanged: _busy || similarCount <= 1
|
||||
? null
|
||||
: (value) => setState(() => _applyToSimilar = value),
|
||||
),
|
||||
if (_applyToSimilar && similarCount > 1)
|
||||
Align(
|
||||
body: Column(
|
||||
children: [
|
||||
if (_busy) const LinearProgressIndicator(minHeight: 2),
|
||||
if (_busy && _busyMessage != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton(
|
||||
onPressed: _busy ? null : _editMatchCriteria,
|
||||
child: Text('Review $similarCount matches'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
secondary: const Icon(Icons.autorenew),
|
||||
title: const Text('Recurring'),
|
||||
subtitle: const Text('Salary, mortgage, subscriptions…'),
|
||||
value: meta.recurring,
|
||||
onChanged: _busy
|
||||
? null
|
||||
: (value) {
|
||||
context.read<TxMetaController>().setRecurring(tx, value);
|
||||
},
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
secondary: const Icon(Icons.swap_horiz),
|
||||
title: const Text('Internal transfer'),
|
||||
subtitle: const Text(
|
||||
'Between your own accounts — excluded from income/expense',
|
||||
),
|
||||
value: meta.internalTransfer,
|
||||
onChanged: _busy
|
||||
? null
|
||||
: (value) {
|
||||
context
|
||||
.read<TxMetaController>()
|
||||
.setInternalTransfer(tx, value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Text('Labels', style: theme.textTheme.titleSmall),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: _busy ? null : _createLabel,
|
||||
child: const Text('New'),
|
||||
),
|
||||
if (_draftLabelIds.isNotEmpty)
|
||||
TextButton(
|
||||
onPressed: _busy ? null : _clearDraftLabels,
|
||||
child: const Text('Clear'),
|
||||
child: Text(
|
||||
_busyMessage!,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: colors.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: colors.outlineVariant.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Column(
|
||||
),
|
||||
Expanded(
|
||||
child: ResponsiveBody(
|
||||
child: AbsorbPointer(
|
||||
absorbing: _busy,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 16),
|
||||
children: [
|
||||
for (final entry in labelEntries) ...[
|
||||
if (entry != labelEntries.first)
|
||||
Divider(
|
||||
height: 1,
|
||||
indent: 40,
|
||||
color: colors.outlineVariant.withValues(alpha: 0.45),
|
||||
Text(
|
||||
formatMoney(tx.amount, tx.currency),
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
color: amountColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(tx.description, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
[
|
||||
formatIsoDate(tx.date),
|
||||
if (tx.pending) 'Pending',
|
||||
].join(' · '),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Text('Match similar', style: theme.textTheme.titleSmall),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
onPressed: _busy ? null : _editMatchCriteria,
|
||||
icon: const Icon(Icons.tune, size: 18),
|
||||
label: const Text('Edit'),
|
||||
),
|
||||
InkWell(
|
||||
onTap:
|
||||
_busy ? null : () => _toggleDraftLabel(entry.label.id),
|
||||
onLongPress:
|
||||
_busy ? null : () => _labelActions(entry.label),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
8 + entry.depth * 16.0,
|
||||
2,
|
||||
4,
|
||||
2,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (entry.depth > 0) ...[
|
||||
Icon(
|
||||
Icons.subdirectory_arrow_right,
|
||||
size: 14,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Icon(
|
||||
entry.label.icon,
|
||||
size: 18,
|
||||
color: entry.label.color,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
entry.label.name,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 32,
|
||||
width: 32,
|
||||
child: Checkbox(
|
||||
materialTapTargetSize:
|
||||
MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
value: _draftLabelIds.contains(entry.label.id),
|
||||
onChanged: _busy
|
||||
? null
|
||||
: (_) =>
|
||||
_toggleDraftLabel(entry.label.id),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'“${_matchCriteria.pattern}”',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
secondary: const Icon(Icons.copy_all_outlined),
|
||||
title: const Text('Include matched transactions'),
|
||||
subtitle: Text(
|
||||
similarCount <= 1
|
||||
? 'No other matches for this pattern'
|
||||
: '$similarCount matching transactions',
|
||||
),
|
||||
value: _applyToSimilar && similarCount > 1,
|
||||
onChanged: _busy || !_similarReady || similarCount <= 1
|
||||
? null
|
||||
: (value) => setState(() => _applyToSimilar = value),
|
||||
),
|
||||
if (_applyToSimilar && similarCount > 1)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton(
|
||||
onPressed: _busy ? null : _editMatchCriteria,
|
||||
child: Text('Review $similarCount matches'),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
secondary: const Icon(Icons.autorenew),
|
||||
title: const Text('Recurring'),
|
||||
subtitle: const Text('Salary, mortgage, subscriptions…'),
|
||||
value: meta.recurring,
|
||||
onChanged: _busy
|
||||
? null
|
||||
: (value) {
|
||||
context.read<TxMetaController>().setRecurring(tx, value);
|
||||
},
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
secondary: const Icon(Icons.swap_horiz),
|
||||
title: const Text('Internal transfer'),
|
||||
subtitle: const Text(
|
||||
'Between your own accounts — excluded from income/expense',
|
||||
),
|
||||
value: meta.internalTransfer,
|
||||
onChanged: _busy
|
||||
? null
|
||||
: (value) {
|
||||
context
|
||||
.read<TxMetaController>()
|
||||
.setInternalTransfer(tx, value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Text('Labels', style: theme.textTheme.titleSmall),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: _busy ? null : _createLabel,
|
||||
child: const Text('New'),
|
||||
),
|
||||
if (_draftLabelIds.isNotEmpty)
|
||||
TextButton(
|
||||
onPressed: _busy ? null : _clearDraftLabels,
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
),
|
||||
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(
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: colors.outlineVariant.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: SizedBox(
|
||||
height: 280,
|
||||
child: labelEntries.isEmpty
|
||||
? Center(
|
||||
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,
|
||||
indent: 40,
|
||||
color: colors.outlineVariant
|
||||
.withValues(alpha: 0.45),
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final entry = labelEntries[index];
|
||||
return InkWell(
|
||||
onTap: _busy
|
||||
? null
|
||||
: () => _toggleDraftLabel(entry.label.id),
|
||||
onLongPress: _busy
|
||||
? null
|
||||
: () => _labelActions(entry.label),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
8 + entry.depth * 16.0,
|
||||
2,
|
||||
4,
|
||||
2,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (entry.depth > 0) ...[
|
||||
Icon(
|
||||
Icons.subdirectory_arrow_right,
|
||||
size: 14,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Icon(
|
||||
entry.label.icon,
|
||||
size: 18,
|
||||
color: entry.label.color,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
entry.label.name,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 32,
|
||||
width: 32,
|
||||
child: Checkbox(
|
||||
materialTapTargetSize:
|
||||
MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
value: _draftLabelIds
|
||||
.contains(entry.label.id),
|
||||
onChanged: _busy
|
||||
? null
|
||||
: (_) => _toggleDraftLabel(
|
||||
entry.label.id,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('Notes', style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _notesController,
|
||||
minLines: 3,
|
||||
maxLines: 6,
|
||||
enabled: !_busy,
|
||||
textInputAction: TextInputAction.newline,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Add a private note…',
|
||||
),
|
||||
onChanged: (_) {
|
||||
if (!_dirty) setState(() => _dirty = true);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
onPressed: _dirty && !_busy ? _saveNotes : null,
|
||||
child: Text(
|
||||
_applyToSimilar && similarCount > 1
|
||||
? 'Save notes for $similarCount'
|
||||
: 'Save notes',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Sub-labels also select their parents. Unchecking a parent '
|
||||
'clears its sub-labels. Long-press a row to move or delete. '
|
||||
'Use Apply to save label changes.',
|
||||
style: theme.textTheme.labelMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
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',
|
||||
),
|
||||
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',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('Notes', style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _notesController,
|
||||
minLines: 3,
|
||||
maxLines: 6,
|
||||
enabled: !_busy,
|
||||
textInputAction: TextInputAction.newline,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Add a private note…',
|
||||
),
|
||||
onChanged: (_) {
|
||||
if (!_dirty) setState(() => _dirty = true);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
onPressed: _dirty && !_busy ? _saveNotes : null,
|
||||
child: Text(
|
||||
_applyToSimilar && similarCount > 1
|
||||
? 'Save notes for $similarCount'
|
||||
: 'Save notes',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Sub-labels also select their parents. Unchecking a parent '
|
||||
'clears its sub-labels. Long-press a row to move or delete. '
|
||||
'Use Apply to save label changes.',
|
||||
style: theme.textTheme.labelMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
var _showCalendar = false;
|
||||
var _flowFilter = TxFlowFilter.all;
|
||||
var _amountRange = const TxAmountRange();
|
||||
var _recurringOnly = false;
|
||||
|
||||
/// Query actually used for filtering (Enter, or live once ≥3 chars).
|
||||
String _activeQuery = '';
|
||||
@@ -96,11 +97,15 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
bool get _hasListFilters =>
|
||||
_labelFilters.isNotEmpty ||
|
||||
_flowFilter != TxFlowFilter.all ||
|
||||
_amountRange.isActive;
|
||||
_amountRange.isActive ||
|
||||
_recurringOnly;
|
||||
|
||||
bool _matchesExtraFilters(BankTransaction tx) =>
|
||||
txMatchesFlow(tx, _flowFilter) &&
|
||||
txMatchesAmountRange(tx, _amountRange);
|
||||
bool _matchesExtraFilters(BankTransaction tx, TxMetaController meta) {
|
||||
if (!txMatchesFlow(tx, _flowFilter)) return false;
|
||||
if (!txMatchesAmountRange(tx, _amountRange)) return false;
|
||||
if (_recurringOnly && !meta.metaFor(tx).recurring) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
void _goMonth(int delta) {
|
||||
final next = addMonths(_month, delta);
|
||||
@@ -167,6 +172,7 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
amountRange: _amountRange,
|
||||
query: _activeQuery,
|
||||
monthOnly: _searchInMonthOnly,
|
||||
recurringOnly: _recurringOnly,
|
||||
),
|
||||
);
|
||||
if (result == null || !mounted) return;
|
||||
@@ -178,6 +184,7 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
_flowFilter = result.flow;
|
||||
_amountRange = result.amountRange;
|
||||
_searchInMonthOnly = result.monthOnly;
|
||||
_recurringOnly = result.recurringOnly;
|
||||
if (!_searchInMonthOnly) {
|
||||
_showCalendar = false;
|
||||
_selectedDay = null;
|
||||
@@ -339,7 +346,7 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
_activeQuery,
|
||||
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.
|
||||
@@ -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)
|
||||
if (id == kUncategorizedFilterId)
|
||||
InputChip(
|
||||
|
||||
@@ -47,15 +47,30 @@ bool isSameTransaction(BankTransaction a, BankTransaction b) {
|
||||
|
||||
int _amountCents(double amount) => (amount * 100).round();
|
||||
|
||||
/// Coarse bucket so fuzzy [isSameTransaction] only compares likely peers.
|
||||
String _dedupeBucket(BankTransaction tx) =>
|
||||
'${tx.date}|${tx.currency.toUpperCase()}|${_amountCents(tx.amount)}';
|
||||
|
||||
/// Merge [extra] into [existing], skipping duplicates. Newest date first.
|
||||
List<BankTransaction> mergeTransactions(
|
||||
List<BankTransaction> existing,
|
||||
List<BankTransaction> extra,
|
||||
) {
|
||||
if (extra.isEmpty) return existing;
|
||||
final buckets = <String, List<BankTransaction>>{};
|
||||
for (final tx in existing) {
|
||||
buckets.putIfAbsent(_dedupeBucket(tx), () => []).add(tx);
|
||||
}
|
||||
|
||||
final out = List<BankTransaction>.of(existing);
|
||||
for (final tx in extra) {
|
||||
if (out.any((e) => isSameTransaction(e, tx))) continue;
|
||||
final key = _dedupeBucket(tx);
|
||||
final peers = buckets[key];
|
||||
if (peers != null && peers.any((e) => isSameTransaction(e, tx))) {
|
||||
continue;
|
||||
}
|
||||
out.add(tx);
|
||||
buckets.putIfAbsent(key, () => []).add(tx);
|
||||
}
|
||||
out.sort((a, b) {
|
||||
final byDate = b.date.compareTo(a.date);
|
||||
@@ -70,9 +85,20 @@ List<BankTransaction> filterNewTransactions(
|
||||
List<BankTransaction> existing,
|
||||
List<BankTransaction> candidates,
|
||||
) {
|
||||
if (candidates.isEmpty) return const [];
|
||||
if (existing.isEmpty) return List<BankTransaction>.of(candidates);
|
||||
|
||||
final buckets = <String, List<BankTransaction>>{};
|
||||
for (final tx in existing) {
|
||||
buckets.putIfAbsent(_dedupeBucket(tx), () => []).add(tx);
|
||||
}
|
||||
|
||||
return [
|
||||
for (final tx in candidates)
|
||||
if (!existing.any((e) => isSameTransaction(e, tx))) tx,
|
||||
if (!(buckets[_dedupeBucket(tx)]
|
||||
?.any((e) => isSameTransaction(e, tx)) ??
|
||||
false))
|
||||
tx,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,13 @@ TxMetaStoreData mergeTxMetaStores(TxMetaStoreData a, TxMetaStoreData b) {
|
||||
existing == null ? e.value : _mergeExact(existing, e.value);
|
||||
}
|
||||
|
||||
final recurringTemplates = <String, RecurringTemplate>{
|
||||
...a.recurringTemplates,
|
||||
};
|
||||
for (final e in b.recurringTemplates.entries) {
|
||||
recurringTemplates.putIfAbsent(e.key, () => e.value);
|
||||
}
|
||||
|
||||
final labelsByClientId = <String, TxLabel>{
|
||||
for (final label in a.customLabels) label.id: label,
|
||||
};
|
||||
@@ -66,6 +73,7 @@ TxMetaStoreData mergeTxMetaStores(TxMetaStoreData a, TxMetaStoreData b) {
|
||||
labelsByMerchant: labelsByMerchant,
|
||||
notesByMerchant: notesByMerchant,
|
||||
exactTemplates: exactTemplates,
|
||||
recurringTemplates: recurringTemplates,
|
||||
customLabels: labelsByClientId.values.toList(growable: false),
|
||||
labelParents: labelParents,
|
||||
);
|
||||
@@ -170,6 +178,7 @@ class PbSyncService {
|
||||
}) async {
|
||||
final out = <RecordModel>[];
|
||||
var page = 1;
|
||||
var lastReported = -1;
|
||||
while (true) {
|
||||
final result = await _withTimeout(
|
||||
_pb.collection(collection).getList(
|
||||
@@ -181,8 +190,13 @@ class PbSyncService {
|
||||
label: '$collection list page $page',
|
||||
);
|
||||
out.addAll(result.items);
|
||||
onPage?.call(out.length);
|
||||
if (result.items.length < _listPageSize) break;
|
||||
final finished = result.items.length < _listPageSize;
|
||||
if (onPage != null &&
|
||||
(finished || out.length - lastReported >= 400)) {
|
||||
lastReported = out.length;
|
||||
onPage(out.length);
|
||||
}
|
||||
if (finished) break;
|
||||
page++;
|
||||
}
|
||||
return out;
|
||||
@@ -213,7 +227,17 @@ class PbSyncService {
|
||||
report(0.36);
|
||||
onStatus?.call('Pulling templates…');
|
||||
final exactRecords = await _fullList('exact_templates', filter);
|
||||
report(0.45);
|
||||
report(0.42);
|
||||
onStatus?.call('Pulling recurring templates…');
|
||||
List<RecordModel> recurringRecords = const [];
|
||||
try {
|
||||
recurringRecords = await _fullList('recurring_templates', filter);
|
||||
} catch (error) {
|
||||
debugPrint(
|
||||
'PbSyncService pull: recurring_templates unavailable ($error)',
|
||||
);
|
||||
}
|
||||
report(0.48);
|
||||
|
||||
final customLabels = <TxLabel>[];
|
||||
final labelParents = <String, String>{};
|
||||
@@ -269,10 +293,35 @@ class PbSyncService {
|
||||
);
|
||||
}
|
||||
|
||||
final recurringTemplates = <String, RecurringTemplate>{};
|
||||
for (final r in recurringRecords) {
|
||||
final clientId = r.getStringValue('client_id');
|
||||
if (clientId.isEmpty) continue;
|
||||
final sourceTx = r.getStringValue('source_tx_id');
|
||||
final labelId = r.getStringValue('label_id');
|
||||
recurringTemplates[clientId] = RecurringTemplate(
|
||||
id: clientId,
|
||||
name: r.getStringValue('name').isEmpty
|
||||
? 'Recurring'
|
||||
: r.getStringValue('name'),
|
||||
dayOfMonth: r.getIntValue('day_of_month', 1).clamp(1, 28),
|
||||
currency: r.getStringValue('currency').isEmpty
|
||||
? 'EUR'
|
||||
: r.getStringValue('currency'),
|
||||
source: _parseTemplateSource(r.getStringValue('source')),
|
||||
amountRule: _parseAmountRule(r.getStringValue('amount_rule')),
|
||||
merchantKey: r.getStringValue('merchant_key'),
|
||||
sourceTxId: sourceTx.isEmpty ? null : sourceTx,
|
||||
labelId: labelId.isEmpty ? null : labelId,
|
||||
avgCount: r.getIntValue('avg_count', 5).clamp(1, 36),
|
||||
autoFromRecurring: r.getBoolValue('auto_from_recurring'),
|
||||
);
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'PbSyncService pull: labels=${labelRecords.length} '
|
||||
'tx_meta=${metaRecords.length} merchants=${merchantRecords.length} '
|
||||
'exact=${exactRecords.length}',
|
||||
'exact=${exactRecords.length} recurring=${recurringRecords.length}',
|
||||
);
|
||||
|
||||
return TxMetaStoreData(
|
||||
@@ -280,6 +329,7 @@ class PbSyncService {
|
||||
labelsByMerchant: labelsByMerchant,
|
||||
notesByMerchant: notesByMerchant,
|
||||
exactTemplates: exactTemplates,
|
||||
recurringTemplates: recurringTemplates,
|
||||
customLabels: customLabels,
|
||||
labelParents: labelParents,
|
||||
);
|
||||
@@ -347,6 +397,15 @@ class PbSyncService {
|
||||
report(0.96);
|
||||
onStatus?.call('Syncing templates…');
|
||||
await _syncExactTemplates(userId, filter, store);
|
||||
report(0.98);
|
||||
onStatus?.call('Syncing recurring templates…');
|
||||
try {
|
||||
await _syncRecurringTemplates(userId, filter, store);
|
||||
} catch (error) {
|
||||
debugPrint(
|
||||
'PbSyncService push: recurring_templates unavailable ($error)',
|
||||
);
|
||||
}
|
||||
report(1.0);
|
||||
return txSummary;
|
||||
}
|
||||
@@ -563,7 +622,11 @@ class PbSyncService {
|
||||
]);
|
||||
done = end;
|
||||
onProgress?.call(done / total);
|
||||
onStatus?.call('Syncing $collection ($done/$total)…');
|
||||
// Status less often than progress — SyncController already throttles,
|
||||
// but fewer string allocs/churn still help.
|
||||
if (done == total || done == end && (done ~/ _writeConcurrency) % 4 == 0) {
|
||||
onStatus?.call('Syncing $collection ($done/$total)…');
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.isNotEmpty) {
|
||||
@@ -603,6 +666,12 @@ class PbSyncService {
|
||||
filter =
|
||||
'user="$userId" && exact_key="${key.replaceAll('"', '\\"')}"';
|
||||
}
|
||||
case 'recurring_templates':
|
||||
final key = body['client_id'];
|
||||
if (userId != null && key is String) {
|
||||
filter =
|
||||
'user="$userId" && client_id="${key.replaceAll('"', '\\"')}"';
|
||||
}
|
||||
case 'transactions':
|
||||
final key = body['tx_id'];
|
||||
if (userId != null && key is String) {
|
||||
@@ -853,6 +922,61 @@ class PbSyncService {
|
||||
await _runWriteOps('exact_templates', ops);
|
||||
}
|
||||
|
||||
Future<void> _syncRecurringTemplates(
|
||||
String userId,
|
||||
String filter,
|
||||
TxMetaStoreData store,
|
||||
) async {
|
||||
final existing = {
|
||||
for (final r in await _fullList('recurring_templates', filter))
|
||||
r.getStringValue('client_id'): r,
|
||||
};
|
||||
final keep = <String>{};
|
||||
final ops = <_BatchOp>[];
|
||||
|
||||
for (final e in store.recurringTemplates.entries) {
|
||||
final t = e.value;
|
||||
final clientId = _clip(t.id.isNotEmpty ? t.id : e.key, 120);
|
||||
if (clientId.isEmpty) continue;
|
||||
keep.add(clientId);
|
||||
final body = <String, dynamic>{
|
||||
'user': userId,
|
||||
'client_id': clientId,
|
||||
'name': _clip(t.name.isEmpty ? 'Recurring' : t.name, 400),
|
||||
'day_of_month': t.dayOfMonth.clamp(1, 28),
|
||||
'currency': _clip(
|
||||
t.currency.isEmpty ? 'EUR' : t.currency.toUpperCase(),
|
||||
8,
|
||||
),
|
||||
'source': t.source.name,
|
||||
'amount_rule': t.amountRule.name,
|
||||
'merchant_key': _clip(t.merchantKey, 500),
|
||||
'source_tx_id': _clip(t.sourceTxId ?? '', 2000),
|
||||
'label_id': _clip(t.labelId ?? '', 80),
|
||||
'avg_count': t.avgCount.clamp(1, 36),
|
||||
'auto_from_recurring': t.autoFromRecurring,
|
||||
};
|
||||
final remote = existing[clientId];
|
||||
if (remote == null) {
|
||||
ops.add(_BatchOp.create(body));
|
||||
} else if (!_recurringUnchanged(remote, t)) {
|
||||
ops.add(_BatchOp.update(remote.id, body));
|
||||
}
|
||||
}
|
||||
|
||||
for (final e in existing.entries) {
|
||||
if (!keep.contains(e.key)) {
|
||||
ops.add(_BatchOp.delete(e.value.id));
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'PbSyncService recurring_templates: remote=${existing.length} '
|
||||
'ops=${ops.length}',
|
||||
);
|
||||
await _runWriteOps('recurring_templates', ops);
|
||||
}
|
||||
|
||||
bool _labelUnchanged(RecordModel remote, Map<String, dynamic> body) {
|
||||
return remote.getStringValue('name') == body['name'] &&
|
||||
remote.getIntValue('color') == body['color'] &&
|
||||
@@ -882,6 +1006,34 @@ class PbSyncService {
|
||||
remote.getBoolValue('recurring') == t.recurring &&
|
||||
remote.getBoolValue('internal_transfer') == t.internalTransfer;
|
||||
}
|
||||
|
||||
bool _recurringUnchanged(RecordModel remote, RecurringTemplate t) {
|
||||
return remote.getStringValue('name') == t.name &&
|
||||
remote.getIntValue('day_of_month') == t.dayOfMonth &&
|
||||
remote.getStringValue('currency') == t.currency &&
|
||||
remote.getStringValue('source') == t.source.name &&
|
||||
remote.getStringValue('amount_rule') == t.amountRule.name &&
|
||||
remote.getStringValue('merchant_key') == t.merchantKey &&
|
||||
remote.getStringValue('source_tx_id') == (t.sourceTxId ?? '') &&
|
||||
remote.getStringValue('label_id') == (t.labelId ?? '') &&
|
||||
remote.getIntValue('avg_count', 5) == t.avgCount &&
|
||||
remote.getBoolValue('auto_from_recurring') == t.autoFromRecurring;
|
||||
}
|
||||
}
|
||||
|
||||
RecurringTemplateSource _parseTemplateSource(String raw) {
|
||||
return switch (raw) {
|
||||
'label' => RecurringTemplateSource.label,
|
||||
_ => RecurringTemplateSource.transaction,
|
||||
};
|
||||
}
|
||||
|
||||
RecurringAmountRule _parseAmountRule(String raw) {
|
||||
return switch (raw) {
|
||||
'avgLastTxs' => RecurringAmountRule.avgLastTxs,
|
||||
'labelAvgMonths' => RecurringAmountRule.labelAvgMonths,
|
||||
_ => RecurringAmountRule.lastValue,
|
||||
};
|
||||
}
|
||||
|
||||
List<String> _jsonStringList(dynamic raw) {
|
||||
|
||||
@@ -3,6 +3,7 @@ library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/bank.dart';
|
||||
@@ -16,6 +17,7 @@ class TxMetaStoreData {
|
||||
this.labelsByMerchant = const {},
|
||||
this.notesByMerchant = const {},
|
||||
this.exactTemplates = const {},
|
||||
this.recurringTemplates = const {},
|
||||
this.customLabels = const [],
|
||||
this.labelParents = const {},
|
||||
});
|
||||
@@ -24,6 +26,7 @@ class TxMetaStoreData {
|
||||
final Map<String, List<String>> labelsByMerchant;
|
||||
final Map<String, String> notesByMerchant;
|
||||
final Map<String, TxExactTemplate> exactTemplates;
|
||||
final Map<String, RecurringTemplate> recurringTemplates;
|
||||
final List<TxLabel> customLabels;
|
||||
|
||||
/// childLabelId → parentLabelId (roots omitted).
|
||||
@@ -147,8 +150,9 @@ class StorageService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveBankCache(BankCache cache) {
|
||||
return _prefs.setString(StorageKeys.bankCache, jsonEncode(cache.toJson()));
|
||||
Future<void> saveBankCache(BankCache cache) async {
|
||||
final json = await compute(jsonEncode, cache.toJson());
|
||||
await _prefs.setString(StorageKeys.bankCache, json);
|
||||
}
|
||||
|
||||
Future<void> clearBankCache() {
|
||||
@@ -170,7 +174,7 @@ class StorageService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveImportedTransactions(List<BankTransaction> txs) {
|
||||
Future<void> saveImportedTransactions(List<BankTransaction> txs) async {
|
||||
final encoded = [
|
||||
for (final tx in txs)
|
||||
{
|
||||
@@ -181,10 +185,8 @@ class StorageService {
|
||||
'pending': tx.pending,
|
||||
},
|
||||
];
|
||||
return _prefs.setString(
|
||||
StorageKeys.importedTransactions,
|
||||
jsonEncode(encoded),
|
||||
);
|
||||
final json = await compute(jsonEncode, encoded);
|
||||
await _prefs.setString(StorageKeys.importedTransactions, json);
|
||||
}
|
||||
|
||||
Future<void> clearImportedTransactions() {
|
||||
@@ -225,7 +227,7 @@ class StorageService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveTxMetaStore(TxMetaStoreData store) {
|
||||
Future<void> saveTxMetaStore(TxMetaStoreData store) async {
|
||||
final encoded = {
|
||||
'byId': {
|
||||
for (final e in store.byId.entries) e.key: e.value.toJson(),
|
||||
@@ -247,12 +249,18 @@ class StorageService {
|
||||
'exactTemplates': {
|
||||
for (final e in store.exactTemplates.entries) e.key: e.value.toJson(),
|
||||
},
|
||||
'recurringTemplates': {
|
||||
for (final e in store.recurringTemplates.entries)
|
||||
e.key: e.value.toJson(),
|
||||
},
|
||||
'customLabels': [
|
||||
for (final label in store.customLabels) label.toJson(),
|
||||
],
|
||||
'labelParents': store.labelParents,
|
||||
};
|
||||
return _prefs.setString(StorageKeys.txMeta, jsonEncode(encoded));
|
||||
// jsonEncode of large annotation maps is CPU-heavy — keep it off the UI isolate.
|
||||
final json = await compute(jsonEncode, encoded);
|
||||
await _prefs.setString(StorageKeys.txMeta, json);
|
||||
}
|
||||
|
||||
TxMetaStoreData _decodeStore(String raw) {
|
||||
@@ -326,6 +334,19 @@ class StorageService {
|
||||
});
|
||||
}
|
||||
|
||||
final recurringTemplates = <String, RecurringTemplate>{};
|
||||
final recurringRaw = root['recurringTemplates'];
|
||||
if (recurringRaw is Map) {
|
||||
recurringRaw.forEach((key, value) {
|
||||
if (key is String && value is Map) {
|
||||
final tpl =
|
||||
RecurringTemplate.fromJson(Map<String, dynamic>.from(value));
|
||||
if (tpl.id.isEmpty) return;
|
||||
recurringTemplates[tpl.id.isNotEmpty ? tpl.id : key] = tpl;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
final customLabels = <TxLabel>[];
|
||||
final customs = root['customLabels'];
|
||||
if (customs is List) {
|
||||
@@ -365,6 +386,7 @@ class StorageService {
|
||||
labelsByMerchant: labelsByMerchant,
|
||||
notesByMerchant: notesByMerchant,
|
||||
exactTemplates: exactTemplates,
|
||||
recurringTemplates: recurringTemplates,
|
||||
customLabels: customLabels,
|
||||
labelParents: labelParents,
|
||||
);
|
||||
|
||||
+193
-27
@@ -1,4 +1,4 @@
|
||||
/// Project future balances from recurring transactions.
|
||||
/// Project future balances from recurring templates.
|
||||
library;
|
||||
|
||||
import '../models/bank.dart';
|
||||
@@ -8,54 +8,220 @@ import 'tx_calendar.dart';
|
||||
/// One monthly recurring item used for look-ahead forecasts.
|
||||
class RecurringItem {
|
||||
const RecurringItem({
|
||||
required this.templateId,
|
||||
required this.description,
|
||||
required this.amount,
|
||||
required this.currency,
|
||||
required this.dayOfMonth,
|
||||
this.ruleSummary = '',
|
||||
this.sourceTxId,
|
||||
this.labelIds = const [],
|
||||
});
|
||||
|
||||
final String templateId;
|
||||
final String description;
|
||||
final double amount;
|
||||
final String currency;
|
||||
final int dayOfMonth;
|
||||
final String ruleSummary;
|
||||
final String? sourceTxId;
|
||||
final List<String> labelIds;
|
||||
}
|
||||
|
||||
/// Build unique recurring templates from annotated transactions.
|
||||
List<RecurringItem> recurringItemsFrom({
|
||||
/// Build projection items from first-class [templates].
|
||||
List<RecurringItem> recurringItemsFromTemplates({
|
||||
required Iterable<RecurringTemplate> templates,
|
||||
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 [
|
||||
for (final tx in latestByExact.values)
|
||||
for (final tpl in templates)
|
||||
RecurringItem(
|
||||
description: tx.description,
|
||||
amount: tx.amount,
|
||||
currency: tx.currency,
|
||||
dayOfMonth: (DateTime.tryParse(tx.date)?.day ?? 1).clamp(1, 28),
|
||||
labelIds: List<String>.of(metaFor(tx).labelIds),
|
||||
templateId: tpl.id,
|
||||
description: tpl.name,
|
||||
amount: resolveRecurringAmount(
|
||||
template: tpl,
|
||||
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).
|
||||
List<({RecurringItem item, DateTime date})> projectedOccurrences({
|
||||
required List<RecurringItem> items,
|
||||
|
||||
+178
-145
@@ -10,120 +10,150 @@ import '../screens/account_screen.dart';
|
||||
import '../screens/login_screen.dart';
|
||||
import '../screens/settings_screen.dart';
|
||||
|
||||
Widget authAppBarAction(BuildContext context) {
|
||||
final auth = context.watch<AuthController>();
|
||||
final sync = context.watch<SyncController>();
|
||||
final showRing = sync.showProgressRing;
|
||||
final progress = sync.syncProgress;
|
||||
final success = sync.progressSuccess;
|
||||
final syncActive = sync.isSyncing || showRing;
|
||||
/// 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();
|
||||
|
||||
if (auth.isLoggedIn) {
|
||||
final ringColor = success
|
||||
? const Color(0xFF16A34A)
|
||||
: Theme.of(context).colorScheme.primary;
|
||||
class _AuthAppBarAction extends StatelessWidget {
|
||||
const _AuthAppBarAction();
|
||||
|
||||
return PopupMenuButton<_AccountMenuAction>(
|
||||
tooltip: sync.isSyncing
|
||||
? 'Syncing… ${(progress * 100).round()}%'
|
||||
: auth.displayName,
|
||||
offset: const Offset(0, 48),
|
||||
onSelected: (action) async {
|
||||
switch (action) {
|
||||
case _AccountMenuAction.account:
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(builder: (_) => const AccountScreen()),
|
||||
);
|
||||
case _AccountMenuAction.logout:
|
||||
await auth.logout();
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) {
|
||||
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;
|
||||
return [
|
||||
if (email != null)
|
||||
PopupMenuItem<_AccountMenuAction>(
|
||||
enabled: false,
|
||||
child: Text(
|
||||
email,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
if (showSyncRow)
|
||||
const PopupMenuItem<_AccountMenuAction>(
|
||||
enabled: false,
|
||||
child: _SyncProgressMenuTile(),
|
||||
),
|
||||
const PopupMenuItem<_AccountMenuAction>(
|
||||
value: _AccountMenuAction.account,
|
||||
child: ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(Icons.person_outline),
|
||||
title: Text('Account'),
|
||||
),
|
||||
),
|
||||
const PopupMenuItem<_AccountMenuAction>(
|
||||
value: _AccountMenuAction.logout,
|
||||
child: ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(Icons.logout),
|
||||
title: Text('Sign out'),
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
child: Text(
|
||||
auth.initials,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color:
|
||||
Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showRing)
|
||||
SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: CircularProgressIndicator(
|
||||
value: progress <= 0 ? null : progress.clamp(0.02, 1.0),
|
||||
strokeWidth: 2.5,
|
||||
color: ringColor,
|
||||
backgroundColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.outlineVariant
|
||||
.withValues(alpha: 0.45),
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final auth = context.watch<AuthController>();
|
||||
|
||||
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 progress = sync.syncProgress;
|
||||
final success = sync.progressSuccess;
|
||||
final syncActive = sync.isSyncing || showRing;
|
||||
final ringColor = success
|
||||
? const Color(0xFF16A34A)
|
||||
: Theme.of(context).colorScheme.primary;
|
||||
|
||||
return PopupMenuButton<_AccountMenuAction>(
|
||||
tooltip: sync.isSyncing
|
||||
? 'Syncing… ${(progress * 100).round()}%'
|
||||
: auth.displayName,
|
||||
offset: const Offset(0, 48),
|
||||
onSelected: (action) async {
|
||||
switch (action) {
|
||||
case _AccountMenuAction.account:
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => const AccountScreen(),
|
||||
),
|
||||
);
|
||||
case _AccountMenuAction.logout:
|
||||
await auth.logout();
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) {
|
||||
final email = auth.email;
|
||||
final showSyncRow = syncActive;
|
||||
return [
|
||||
if (email != null)
|
||||
PopupMenuItem<_AccountMenuAction>(
|
||||
enabled: false,
|
||||
child: Text(
|
||||
email,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (showSyncRow)
|
||||
const PopupMenuItem<_AccountMenuAction>(
|
||||
enabled: false,
|
||||
child: _SyncProgressMenuTile(),
|
||||
),
|
||||
const PopupMenuItem<_AccountMenuAction>(
|
||||
value: _AccountMenuAction.account,
|
||||
child: ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(Icons.person_outline),
|
||||
title: Text('Account'),
|
||||
),
|
||||
),
|
||||
const PopupMenuItem<_AccountMenuAction>(
|
||||
value: _AccountMenuAction.logout,
|
||||
child: ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(Icons.logout),
|
||||
title: Text('Sign out'),
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.primaryContainer,
|
||||
child: Text(
|
||||
auth.initials,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showRing)
|
||||
SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: CircularProgressIndicator(
|
||||
value:
|
||||
progress <= 0 ? null : progress.clamp(0.02, 1.0),
|
||||
strokeWidth: 2.5,
|
||||
color: ringColor,
|
||||
backgroundColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.outlineVariant
|
||||
.withValues(alpha: 0.45),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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]).
|
||||
@@ -132,46 +162,49 @@ class _SyncProgressMenuTile extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sync = context.watch<SyncController>();
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final pct = (sync.syncProgress * 100).round().clamp(0, 100);
|
||||
final detail = sync.statusDetail.trim();
|
||||
return ListenableBuilder(
|
||||
listenable: context.read<SyncController>(),
|
||||
builder: (context, _) {
|
||||
final sync = context.read<SyncController>();
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final pct = (sync.syncProgress * 100).round().clamp(0, 100);
|
||||
final detail = sync.statusDetail.trim();
|
||||
|
||||
if (sync.progressSuccess) {
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(Icons.check_circle_outline, color: colors.primary),
|
||||
title: Text(
|
||||
'Sync complete',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onSurface,
|
||||
if (sync.progressSuccess) {
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(Icons.check_circle_outline, color: colors.primary),
|
||||
title: Text(
|
||||
'Sync complete',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
value: sync.syncProgress <= 0
|
||||
? null
|
||||
: sync.syncProgress.clamp(0.02, 1.0),
|
||||
strokeWidth: 2.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
value: sync.syncProgress <= 0
|
||||
? null
|
||||
: sync.syncProgress.clamp(0.02, 1.0),
|
||||
strokeWidth: 2.5,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
detail.isEmpty ? 'Sync in progress ($pct%)' : detail,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onSurface,
|
||||
),
|
||||
),
|
||||
subtitle: detail.isEmpty
|
||||
? null
|
||||
: Text('Sync in progress ($pct%)'),
|
||||
title: Text(
|
||||
detail.isEmpty ? 'Sync in progress ($pct%)' : detail,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onSurface,
|
||||
),
|
||||
),
|
||||
subtitle: detail.isEmpty ? null : Text('Sync in progress ($pct%)'),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ class TransactionFilterSelection {
|
||||
this.amountRange = const TxAmountRange(),
|
||||
this.query = '',
|
||||
this.monthOnly = false,
|
||||
this.recurringOnly = false,
|
||||
});
|
||||
|
||||
final Set<String> labelIds;
|
||||
@@ -29,10 +30,14 @@ class TransactionFilterSelection {
|
||||
/// When true, list is limited to the selected calendar month.
|
||||
final bool monthOnly;
|
||||
|
||||
/// When true, only transactions marked recurring are shown.
|
||||
final bool recurringOnly;
|
||||
|
||||
bool get hasActiveExtras =>
|
||||
flow != TxFlowFilter.all ||
|
||||
amountRange.isActive ||
|
||||
query.trim().isNotEmpty;
|
||||
query.trim().isNotEmpty ||
|
||||
recurringOnly;
|
||||
}
|
||||
|
||||
/// How label series are drawn on the overview chart.
|
||||
@@ -78,23 +83,35 @@ Future<LabelFilterSelection?> showLabelFilterDialog(
|
||||
bool includeInvertChart = false,
|
||||
bool initialInvertChart = false,
|
||||
}) {
|
||||
return showDialog<LabelFilterSelection>(
|
||||
return showModalBottomSheet<LabelFilterSelection>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
showDragHandle: true,
|
||||
builder: (dialogContext) {
|
||||
return _SimpleLabelFilterDialog(
|
||||
available: available,
|
||||
initiallySelected: initiallySelected,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
includeUncategorized: includeUncategorized,
|
||||
allowMove: allowMove,
|
||||
allowCreate: allowCreate,
|
||||
includeFlow: includeFlow,
|
||||
initialFlow: initialFlow,
|
||||
includeBarStyle: includeBarStyle,
|
||||
initialBarStyle: initialBarStyle,
|
||||
includeInvertChart: includeInvertChart,
|
||||
initialInvertChart: initialInvertChart,
|
||||
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,
|
||||
initiallySelected: initiallySelected,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
includeUncategorized: includeUncategorized,
|
||||
allowMove: allowMove,
|
||||
allowCreate: allowCreate,
|
||||
includeFlow: includeFlow,
|
||||
initialFlow: initialFlow,
|
||||
includeBarStyle: includeBarStyle,
|
||||
initialBarStyle: initialBarStyle,
|
||||
includeInvertChart: includeInvertChart,
|
||||
initialInvertChart: initialInvertChart,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -241,10 +258,6 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
|
||||
final showUncategorized = widget.includeUncategorized &&
|
||||
(needle.isEmpty ||
|
||||
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>[
|
||||
for (final e in entries) e.label.id,
|
||||
@@ -255,204 +268,205 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
|
||||
visibleIds.isNotEmpty && selectedVisible == visibleIds.length;
|
||||
final noneVisibleSelected = selectedVisible == 0;
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(widget.title),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
height: maxContentHeight,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: maxContentHeight * 0.42),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
widget.subtitle,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (widget.includeFlow) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Income / expense',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_RectToggleBar<TxFlowFilter>(
|
||||
value: _flow,
|
||||
onChanged: (value) => setState(() => _flow = value),
|
||||
options: const [
|
||||
_RectToggleOption(
|
||||
value: TxFlowFilter.all,
|
||||
label: 'All',
|
||||
),
|
||||
_RectToggleOption(
|
||||
value: TxFlowFilter.income,
|
||||
label: 'In',
|
||||
icon: Icons.south_west,
|
||||
),
|
||||
_RectToggleOption(
|
||||
value: TxFlowFilter.expense,
|
||||
label: 'Out',
|
||||
icon: Icons.north_east,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (widget.includeInvertChart &&
|
||||
_flow == TxFlowFilter.all) ...[
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
title: const Text('Invert chart'),
|
||||
subtitle: const Text(
|
||||
'Expense view: spending up, income/refunds down',
|
||||
),
|
||||
value: _invertChart,
|
||||
onChanged: (value) =>
|
||||
setState(() => _invertChart = value),
|
||||
),
|
||||
],
|
||||
],
|
||||
if (widget.includeBarStyle) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Bar layout',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_RectToggleBar<LabelChartBarStyle>(
|
||||
value: _barStyle,
|
||||
onChanged: (value) =>
|
||||
setState(() => _barStyle = value),
|
||||
options: const [
|
||||
_RectToggleOption(
|
||||
value: LabelChartBarStyle.parallel,
|
||||
label: 'Parallel',
|
||||
icon: Icons.view_week_outlined,
|
||||
),
|
||||
_RectToggleOption(
|
||||
value: LabelChartBarStyle.stacked,
|
||||
label: 'Stacked',
|
||||
icon: Icons.layers_outlined,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (widget.allowCreate) ...[
|
||||
const SizedBox(height: 10),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: _createLabel,
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: const Text('New label'),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
void apply() {
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 4, 24, 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(widget.title, style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
widget.subtitle,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _labelSearchController,
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(24, 4, 24, 8),
|
||||
children: [
|
||||
if (widget.includeFlow) ...[
|
||||
Text(
|
||||
'Income / expense',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_RectToggleBar<TxFlowFilter>(
|
||||
value: _flow,
|
||||
onChanged: (value) => setState(() => _flow = value),
|
||||
options: const [
|
||||
_RectToggleOption(
|
||||
value: TxFlowFilter.all,
|
||||
label: 'All',
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
_RectToggleOption(
|
||||
value: TxFlowFilter.income,
|
||||
label: 'In',
|
||||
icon: Icons.south_west,
|
||||
),
|
||||
_RectToggleOption(
|
||||
value: TxFlowFilter.expense,
|
||||
label: 'Out',
|
||||
icon: Icons.north_east,
|
||||
),
|
||||
],
|
||||
),
|
||||
Tooltip(
|
||||
message: allVisibleSelected ? 'Deselect all' : 'Select all',
|
||||
child: Checkbox(
|
||||
tristate: true,
|
||||
value: allVisibleSelected
|
||||
? true
|
||||
: noneVisibleSelected
|
||||
? false
|
||||
: null,
|
||||
onChanged: visibleIds.isEmpty
|
||||
? null
|
||||
: (_) {
|
||||
setState(() {
|
||||
if (allVisibleSelected) {
|
||||
_selected.removeAll(visibleIds);
|
||||
} else {
|
||||
_selected.addAll(visibleIds);
|
||||
}
|
||||
});
|
||||
},
|
||||
if (widget.includeInvertChart &&
|
||||
_flow == TxFlowFilter.all) ...[
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
title: const Text('Invert chart'),
|
||||
subtitle: const Text(
|
||||
'Expense view: spending up, income/refunds down',
|
||||
),
|
||||
value: _invertChart,
|
||||
onChanged: (value) => setState(() => _invertChart = value),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: _LabelChecklist(
|
||||
if (widget.includeBarStyle) ...[
|
||||
Text(
|
||||
'Bar layout',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_RectToggleBar<LabelChartBarStyle>(
|
||||
value: _barStyle,
|
||||
onChanged: (value) => setState(() => _barStyle = value),
|
||||
options: const [
|
||||
_RectToggleOption(
|
||||
value: LabelChartBarStyle.parallel,
|
||||
label: 'Parallel',
|
||||
icon: Icons.view_week_outlined,
|
||||
),
|
||||
_RectToggleOption(
|
||||
value: LabelChartBarStyle.stacked,
|
||||
label: 'Stacked',
|
||||
icon: Icons.layers_outlined,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
if (widget.allowCreate) ...[
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: _createLabel,
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: const Text('New label'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
Text(
|
||||
'Labels',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _labelSearchController,
|
||||
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(() {}),
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message:
|
||||
allVisibleSelected ? 'Deselect all' : 'Select all',
|
||||
child: Checkbox(
|
||||
tristate: true,
|
||||
value: allVisibleSelected
|
||||
? true
|
||||
: noneVisibleSelected
|
||||
? false
|
||||
: null,
|
||||
onChanged: visibleIds.isEmpty
|
||||
? null
|
||||
: (_) {
|
||||
setState(() {
|
||||
if (allVisibleSelected) {
|
||||
_selected.removeAll(visibleIds);
|
||||
} else {
|
||||
_selected.addAll(visibleIds);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_LabelChecklist(
|
||||
entries: entries,
|
||||
showUncategorized: showUncategorized,
|
||||
selected: _selected,
|
||||
onToggle: _toggle,
|
||||
allowMove: widget.allowMove,
|
||||
shrinkWrap: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
actions: [
|
||||
SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: Row(
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 4, 24, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: apply,
|
||||
child: const Text('Apply'),
|
||||
),
|
||||
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'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -488,6 +502,7 @@ class _TransactionFilterDialogState extends State<_TransactionFilterDialog> {
|
||||
late final Set<String> _selected;
|
||||
late TxFlowFilter _flow;
|
||||
late bool _monthOnly;
|
||||
late bool _recurringOnly;
|
||||
late final TextEditingController _queryController;
|
||||
late final TextEditingController _labelSearchController;
|
||||
late final TextEditingController _exactAmountController;
|
||||
@@ -501,6 +516,7 @@ class _TransactionFilterDialogState extends State<_TransactionFilterDialog> {
|
||||
_selected = Set<String>.of(initial.labelIds);
|
||||
_flow = initial.flow;
|
||||
_monthOnly = initial.monthOnly;
|
||||
_recurringOnly = initial.recurringOnly;
|
||||
_queryController = TextEditingController(text: initial.query);
|
||||
_labelSearchController = TextEditingController();
|
||||
|
||||
@@ -586,6 +602,7 @@ class _TransactionFilterDialogState extends State<_TransactionFilterDialog> {
|
||||
amountRange: _amountResult,
|
||||
query: _queryController.text.trim(),
|
||||
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),
|
||||
Text('Amount', style: sectionStyle),
|
||||
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