look ahead

This commit is contained in:
davmar
2026-09-10 00:35:54 +02:00
parent 91d6dc9e54
commit 5b22fc8694
20 changed files with 2942 additions and 799 deletions
+120
View File
@@ -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`
+5 -2
View File
@@ -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.
+54 -9
View File
@@ -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();
+146 -42
View File
@@ -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(
_exactTemplates[tx.exactKey] = TxExactTemplate(
notes: existing.notes.isNotEmpty ? existing.notes : current.notes,
recurring: recurring,
internalTransfer:
existing.internalTransfer || current.internalTransfer,
),
};
_schedulePersist();
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(
_exactTemplates[tx.exactKey] = TxExactTemplate(
notes: existing.notes.isNotEmpty ? existing.notes : current.notes,
recurring: existing.recurring || current.recurring,
internalTransfer: internalTransfer,
),
};
_schedulePersist();
);
_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((_) {
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();
});
// Ensure a frame is scheduled even if nothing else is dirty yet.
binding.scheduleFrame();
unawaited(_writeStore());
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(
+60 -40
View File
@@ -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,6 +71,65 @@ class AccountScreen extends StatelessWidget {
subtitle: Text(kPocketBaseUrl),
),
const Divider(height: 1),
const _CloudSyncTile(),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.phone_android_outlined),
title: const Text('What syncs'),
subtitle: Text(
'Categorized on device: $categorized\n'
'· Per-tx annotations → PocketBase tx_meta: $perTxAnnotations\n'
'· Via merchant rules only (not separate tx_meta rows): $viaMerchantOnly\n'
'Merchant rules synced: ${meta.merchantRuleCount}\n'
'Bank transactions → PocketBase transactions: '
'${bank.transactions.length}',
),
isThreeLine: true,
),
],
),
),
const SizedBox(height: 24),
OutlinedButton.icon(
onPressed: () async {
await auth.logout();
if (!context.mounted) return;
Navigator.of(context).pop();
},
icon: const Icon(Icons.logout),
label: const Text('Sign out'),
),
],
),
),
);
}
}
/// Isolated so sync progress ticks do not rebuild account stats.
class _CloudSyncTile extends StatelessWidget {
const _CloudSyncTile();
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: context.read<SyncController>(),
builder: (context, _) {
final sync = context.read<SyncController>();
final lastSync = sync.lastSyncedAt;
final syncSubtitle = sync.isSyncing
? (sync.statusDetail.isEmpty
? 'Syncing… ${(sync.syncProgress * 100).round()}%'
: '${sync.statusDetail} · ${(sync.syncProgress * 100).round()}%')
: sync.lastError != null
? 'Last error: ${sync.lastError}'
: lastSync == null
? 'Not synced yet'
: 'Last sync ${_formatTime(lastSync)}';
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: sync.isSyncing
? SizedBox(
@@ -118,36 +165,9 @@ class AccountScreen extends StatelessWidget {
subtitle: Text(sync.lastSyncSummary),
),
],
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.phone_android_outlined),
title: const Text('What syncs'),
subtitle: Text(
'Categorized on device: $categorized\n'
'· Per-tx annotations → PocketBase tx_meta: $perTxAnnotations\n'
'· Via merchant rules only (not separate tx_meta rows): $viaMerchantOnly\n'
'Merchant rules synced: ${meta.merchantRuleCount}\n'
'Bank transactions → PocketBase transactions: '
'${bank.transactions.length}',
),
isThreeLine: true,
),
],
),
),
const SizedBox(height: 24),
OutlinedButton.icon(
onPressed: () async {
await auth.logout();
if (!context.mounted) return;
Navigator.of(context).pop();
);
},
icon: const Icon(Icons.logout),
label: const Text('Sign out'),
),
],
),
),
);
}
+9
View File
@@ -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(
+625
View File
@@ -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),
),
);
},
);
}
}
+13 -2
View File
@@ -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,11 +21,14 @@ class _MainShellState extends State<MainShell> {
static const _pages = [
HomeScreen(),
OverviewScreen(),
LookAheadScreen(),
];
@override
Widget build(BuildContext context) {
return Scaffold(
return ShellTabScope(
index: _index,
child: Scaffold(
body: IndexedStack(
index: _index,
children: _pages,
@@ -43,8 +48,14 @@ class _MainShellState extends State<MainShell> {
selectedIcon: Icon(Icons.insights),
label: 'Overview',
),
NavigationDestination(
icon: Icon(Icons.event_repeat_outlined),
selectedIcon: Icon(Icons.event_repeat),
label: 'Look ahead',
),
],
),
),
);
}
}
+7
View File
@@ -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>();
+149 -76
View File
@@ -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,
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.
_applyToSimilar = similarCount > 1;
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,15 +328,14 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
),
],
),
body: ResponsiveBody(
child: ListView(
padding: const EdgeInsets.only(top: 8, bottom: 32),
body: Column(
children: [
if (_busy) ...[
const LinearProgressIndicator(minHeight: 2),
if (_busyMessage != null)
if (_busy) const LinearProgressIndicator(minHeight: 2),
if (_busy && _busyMessage != null)
Padding(
padding: const EdgeInsets.fromLTRB(0, 8, 0, 4),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
_busyMessage!,
style: theme.textTheme.labelMedium?.copyWith(
@@ -321,11 +343,13 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
),
),
),
],
AbsorbPointer(
),
Expanded(
child: ResponsiveBody(
child: AbsorbPointer(
absorbing: _busy,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
child: ListView(
padding: const EdgeInsets.only(top: 8, bottom: 16),
children: [
Text(
formatMoney(tx.amount, tx.currency),
@@ -372,7 +396,7 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
: '$similarCount matching transactions',
),
value: _applyToSimilar && similarCount > 1,
onChanged: _busy || similarCount <= 1
onChanged: _busy || !_similarReady || similarCount <= 1
? null
: (value) => setState(() => _applyToSimilar = value),
),
@@ -430,6 +454,26 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
],
),
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,
@@ -440,20 +484,37 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Column(
children: [
for (final entry in labelEntries) ...[
if (entry != labelEntries.first)
Divider(
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),
color: colors.outlineVariant
.withValues(alpha: 0.45),
),
InkWell(
onTap:
_busy ? null : () => _toggleDraftLabel(entry.label.id),
onLongPress:
_busy ? null : () => _labelActions(entry.label),
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,
@@ -492,50 +553,23 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
value: _draftLabelIds.contains(entry.label.id),
value: _draftLabelIds
.contains(entry.label.id),
onChanged: _busy
? null
: (_) =>
_toggleDraftLabel(entry.label.id),
),
),
],
: (_) => _toggleDraftLabel(
entry.label.id,
),
),
),
],
],
),
),
);
},
),
),
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: FilledButton(
onPressed: _busy || !_labelsDirty
? null
: () => _applyLabels(toMatched: false),
child: const Text('Apply'),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton.tonal(
onPressed: _busy ||
!_labelsDirty ||
!_applyToSimilar ||
similarCount <= 1
? null
: () => _applyLabels(toMatched: true),
child: Text(
similarCount > 1
? 'Apply to matched ($similarCount)'
: 'Apply to matched',
),
),
),
],
),
const SizedBox(height: 24),
Text('Notes', style: theme.textTheme.titleSmall),
@@ -572,9 +606,48 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
],
),
),
),
),
SafeArea(
top: false,
child: Material(
elevation: 3,
color: colors.surface,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 10),
child: Row(
children: [
Expanded(
child: FilledButton(
onPressed: _busy || !_labelsDirty
? null
: () => _applyLabels(toMatched: false),
child: const Text('Apply'),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton.tonal(
onPressed: _busy ||
!_labelsDirty ||
!_applyToSimilar ||
similarCount <= 1
? null
: () => _applyLabels(toMatched: true),
child: Text(
similarCount > 1
? 'Apply to matched ($similarCount)'
: 'Apply to matched',
),
),
),
],
),
),
),
),
],
),
);
}
}
+21 -5
View File
@@ -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(
+28 -2
View File
@@ -47,15 +47,30 @@ bool isSameTransaction(BankTransaction a, BankTransaction b) {
int _amountCents(double amount) => (amount * 100).round();
/// Coarse bucket so fuzzy [isSameTransaction] only compares likely peers.
String _dedupeBucket(BankTransaction tx) =>
'${tx.date}|${tx.currency.toUpperCase()}|${_amountCents(tx.amount)}';
/// Merge [extra] into [existing], skipping duplicates. Newest date first.
List<BankTransaction> mergeTransactions(
List<BankTransaction> existing,
List<BankTransaction> extra,
) {
if (extra.isEmpty) return existing;
final buckets = <String, List<BankTransaction>>{};
for (final tx in existing) {
buckets.putIfAbsent(_dedupeBucket(tx), () => []).add(tx);
}
final out = List<BankTransaction>.of(existing);
for (final tx in extra) {
if (out.any((e) => isSameTransaction(e, tx))) continue;
final key = _dedupeBucket(tx);
final peers = buckets[key];
if (peers != null && peers.any((e) => isSameTransaction(e, tx))) {
continue;
}
out.add(tx);
buckets.putIfAbsent(key, () => []).add(tx);
}
out.sort((a, b) {
final byDate = b.date.compareTo(a.date);
@@ -70,9 +85,20 @@ List<BankTransaction> filterNewTransactions(
List<BankTransaction> existing,
List<BankTransaction> candidates,
) {
if (candidates.isEmpty) return const [];
if (existing.isEmpty) return List<BankTransaction>.of(candidates);
final buckets = <String, List<BankTransaction>>{};
for (final tx in existing) {
buckets.putIfAbsent(_dedupeBucket(tx), () => []).add(tx);
}
return [
for (final tx in candidates)
if (!existing.any((e) => isSameTransaction(e, tx))) tx,
if (!(buckets[_dedupeBucket(tx)]
?.any((e) => isSameTransaction(e, tx)) ??
false))
tx,
];
}
+156 -4
View File
@@ -52,6 +52,13 @@ TxMetaStoreData mergeTxMetaStores(TxMetaStoreData a, TxMetaStoreData b) {
existing == null ? e.value : _mergeExact(existing, e.value);
}
final recurringTemplates = <String, RecurringTemplate>{
...a.recurringTemplates,
};
for (final e in b.recurringTemplates.entries) {
recurringTemplates.putIfAbsent(e.key, () => e.value);
}
final labelsByClientId = <String, TxLabel>{
for (final label in a.customLabels) label.id: label,
};
@@ -66,6 +73,7 @@ TxMetaStoreData mergeTxMetaStores(TxMetaStoreData a, TxMetaStoreData b) {
labelsByMerchant: labelsByMerchant,
notesByMerchant: notesByMerchant,
exactTemplates: exactTemplates,
recurringTemplates: recurringTemplates,
customLabels: labelsByClientId.values.toList(growable: false),
labelParents: labelParents,
);
@@ -170,6 +178,7 @@ class PbSyncService {
}) async {
final out = <RecordModel>[];
var page = 1;
var lastReported = -1;
while (true) {
final result = await _withTimeout(
_pb.collection(collection).getList(
@@ -181,8 +190,13 @@ class PbSyncService {
label: '$collection list page $page',
);
out.addAll(result.items);
onPage?.call(out.length);
if (result.items.length < _listPageSize) break;
final finished = result.items.length < _listPageSize;
if (onPage != null &&
(finished || out.length - lastReported >= 400)) {
lastReported = out.length;
onPage(out.length);
}
if (finished) break;
page++;
}
return out;
@@ -213,7 +227,17 @@ class PbSyncService {
report(0.36);
onStatus?.call('Pulling templates…');
final exactRecords = await _fullList('exact_templates', filter);
report(0.45);
report(0.42);
onStatus?.call('Pulling recurring templates…');
List<RecordModel> recurringRecords = const [];
try {
recurringRecords = await _fullList('recurring_templates', filter);
} catch (error) {
debugPrint(
'PbSyncService pull: recurring_templates unavailable ($error)',
);
}
report(0.48);
final customLabels = <TxLabel>[];
final labelParents = <String, String>{};
@@ -269,10 +293,35 @@ class PbSyncService {
);
}
final recurringTemplates = <String, RecurringTemplate>{};
for (final r in recurringRecords) {
final clientId = r.getStringValue('client_id');
if (clientId.isEmpty) continue;
final sourceTx = r.getStringValue('source_tx_id');
final labelId = r.getStringValue('label_id');
recurringTemplates[clientId] = RecurringTemplate(
id: clientId,
name: r.getStringValue('name').isEmpty
? 'Recurring'
: r.getStringValue('name'),
dayOfMonth: r.getIntValue('day_of_month', 1).clamp(1, 28),
currency: r.getStringValue('currency').isEmpty
? 'EUR'
: r.getStringValue('currency'),
source: _parseTemplateSource(r.getStringValue('source')),
amountRule: _parseAmountRule(r.getStringValue('amount_rule')),
merchantKey: r.getStringValue('merchant_key'),
sourceTxId: sourceTx.isEmpty ? null : sourceTx,
labelId: labelId.isEmpty ? null : labelId,
avgCount: r.getIntValue('avg_count', 5).clamp(1, 36),
autoFromRecurring: r.getBoolValue('auto_from_recurring'),
);
}
debugPrint(
'PbSyncService pull: labels=${labelRecords.length} '
'tx_meta=${metaRecords.length} merchants=${merchantRecords.length} '
'exact=${exactRecords.length}',
'exact=${exactRecords.length} recurring=${recurringRecords.length}',
);
return TxMetaStoreData(
@@ -280,6 +329,7 @@ class PbSyncService {
labelsByMerchant: labelsByMerchant,
notesByMerchant: notesByMerchant,
exactTemplates: exactTemplates,
recurringTemplates: recurringTemplates,
customLabels: customLabels,
labelParents: labelParents,
);
@@ -347,6 +397,15 @@ class PbSyncService {
report(0.96);
onStatus?.call('Syncing templates…');
await _syncExactTemplates(userId, filter, store);
report(0.98);
onStatus?.call('Syncing recurring templates…');
try {
await _syncRecurringTemplates(userId, filter, store);
} catch (error) {
debugPrint(
'PbSyncService push: recurring_templates unavailable ($error)',
);
}
report(1.0);
return txSummary;
}
@@ -563,8 +622,12 @@ class PbSyncService {
]);
done = end;
onProgress?.call(done / total);
// Status less often than progress — SyncController already throttles,
// but fewer string allocs/churn still help.
if (done == total || done == end && (done ~/ _writeConcurrency) % 4 == 0) {
onStatus?.call('Syncing $collection ($done/$total)…');
}
}
if (errors.isNotEmpty) {
throw StateError(
@@ -603,6 +666,12 @@ class PbSyncService {
filter =
'user="$userId" && exact_key="${key.replaceAll('"', '\\"')}"';
}
case 'recurring_templates':
final key = body['client_id'];
if (userId != null && key is String) {
filter =
'user="$userId" && client_id="${key.replaceAll('"', '\\"')}"';
}
case 'transactions':
final key = body['tx_id'];
if (userId != null && key is String) {
@@ -853,6 +922,61 @@ class PbSyncService {
await _runWriteOps('exact_templates', ops);
}
Future<void> _syncRecurringTemplates(
String userId,
String filter,
TxMetaStoreData store,
) async {
final existing = {
for (final r in await _fullList('recurring_templates', filter))
r.getStringValue('client_id'): r,
};
final keep = <String>{};
final ops = <_BatchOp>[];
for (final e in store.recurringTemplates.entries) {
final t = e.value;
final clientId = _clip(t.id.isNotEmpty ? t.id : e.key, 120);
if (clientId.isEmpty) continue;
keep.add(clientId);
final body = <String, dynamic>{
'user': userId,
'client_id': clientId,
'name': _clip(t.name.isEmpty ? 'Recurring' : t.name, 400),
'day_of_month': t.dayOfMonth.clamp(1, 28),
'currency': _clip(
t.currency.isEmpty ? 'EUR' : t.currency.toUpperCase(),
8,
),
'source': t.source.name,
'amount_rule': t.amountRule.name,
'merchant_key': _clip(t.merchantKey, 500),
'source_tx_id': _clip(t.sourceTxId ?? '', 2000),
'label_id': _clip(t.labelId ?? '', 80),
'avg_count': t.avgCount.clamp(1, 36),
'auto_from_recurring': t.autoFromRecurring,
};
final remote = existing[clientId];
if (remote == null) {
ops.add(_BatchOp.create(body));
} else if (!_recurringUnchanged(remote, t)) {
ops.add(_BatchOp.update(remote.id, body));
}
}
for (final e in existing.entries) {
if (!keep.contains(e.key)) {
ops.add(_BatchOp.delete(e.value.id));
}
}
debugPrint(
'PbSyncService recurring_templates: remote=${existing.length} '
'ops=${ops.length}',
);
await _runWriteOps('recurring_templates', ops);
}
bool _labelUnchanged(RecordModel remote, Map<String, dynamic> body) {
return remote.getStringValue('name') == body['name'] &&
remote.getIntValue('color') == body['color'] &&
@@ -882,6 +1006,34 @@ class PbSyncService {
remote.getBoolValue('recurring') == t.recurring &&
remote.getBoolValue('internal_transfer') == t.internalTransfer;
}
bool _recurringUnchanged(RecordModel remote, RecurringTemplate t) {
return remote.getStringValue('name') == t.name &&
remote.getIntValue('day_of_month') == t.dayOfMonth &&
remote.getStringValue('currency') == t.currency &&
remote.getStringValue('source') == t.source.name &&
remote.getStringValue('amount_rule') == t.amountRule.name &&
remote.getStringValue('merchant_key') == t.merchantKey &&
remote.getStringValue('source_tx_id') == (t.sourceTxId ?? '') &&
remote.getStringValue('label_id') == (t.labelId ?? '') &&
remote.getIntValue('avg_count', 5) == t.avgCount &&
remote.getBoolValue('auto_from_recurring') == t.autoFromRecurring;
}
}
RecurringTemplateSource _parseTemplateSource(String raw) {
return switch (raw) {
'label' => RecurringTemplateSource.label,
_ => RecurringTemplateSource.transaction,
};
}
RecurringAmountRule _parseAmountRule(String raw) {
return switch (raw) {
'avgLastTxs' => RecurringAmountRule.avgLastTxs,
'labelAvgMonths' => RecurringAmountRule.labelAvgMonths,
_ => RecurringAmountRule.lastValue,
};
}
List<String> _jsonStringList(dynamic raw) {
+31 -9
View File
@@ -3,6 +3,7 @@ library;
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/bank.dart';
@@ -16,6 +17,7 @@ class TxMetaStoreData {
this.labelsByMerchant = const {},
this.notesByMerchant = const {},
this.exactTemplates = const {},
this.recurringTemplates = const {},
this.customLabels = const [],
this.labelParents = const {},
});
@@ -24,6 +26,7 @@ class TxMetaStoreData {
final Map<String, List<String>> labelsByMerchant;
final Map<String, String> notesByMerchant;
final Map<String, TxExactTemplate> exactTemplates;
final Map<String, RecurringTemplate> recurringTemplates;
final List<TxLabel> customLabels;
/// childLabelId → parentLabelId (roots omitted).
@@ -147,8 +150,9 @@ class StorageService {
}
}
Future<void> saveBankCache(BankCache cache) {
return _prefs.setString(StorageKeys.bankCache, jsonEncode(cache.toJson()));
Future<void> saveBankCache(BankCache cache) async {
final json = await compute(jsonEncode, cache.toJson());
await _prefs.setString(StorageKeys.bankCache, json);
}
Future<void> clearBankCache() {
@@ -170,7 +174,7 @@ class StorageService {
}
}
Future<void> saveImportedTransactions(List<BankTransaction> txs) {
Future<void> saveImportedTransactions(List<BankTransaction> txs) async {
final encoded = [
for (final tx in txs)
{
@@ -181,10 +185,8 @@ class StorageService {
'pending': tx.pending,
},
];
return _prefs.setString(
StorageKeys.importedTransactions,
jsonEncode(encoded),
);
final json = await compute(jsonEncode, encoded);
await _prefs.setString(StorageKeys.importedTransactions, json);
}
Future<void> clearImportedTransactions() {
@@ -225,7 +227,7 @@ class StorageService {
}
}
Future<void> saveTxMetaStore(TxMetaStoreData store) {
Future<void> saveTxMetaStore(TxMetaStoreData store) async {
final encoded = {
'byId': {
for (final e in store.byId.entries) e.key: e.value.toJson(),
@@ -247,12 +249,18 @@ class StorageService {
'exactTemplates': {
for (final e in store.exactTemplates.entries) e.key: e.value.toJson(),
},
'recurringTemplates': {
for (final e in store.recurringTemplates.entries)
e.key: e.value.toJson(),
},
'customLabels': [
for (final label in store.customLabels) label.toJson(),
],
'labelParents': store.labelParents,
};
return _prefs.setString(StorageKeys.txMeta, jsonEncode(encoded));
// jsonEncode of large annotation maps is CPU-heavy — keep it off the UI isolate.
final json = await compute(jsonEncode, encoded);
await _prefs.setString(StorageKeys.txMeta, json);
}
TxMetaStoreData _decodeStore(String raw) {
@@ -326,6 +334,19 @@ class StorageService {
});
}
final recurringTemplates = <String, RecurringTemplate>{};
final recurringRaw = root['recurringTemplates'];
if (recurringRaw is Map) {
recurringRaw.forEach((key, value) {
if (key is String && value is Map) {
final tpl =
RecurringTemplate.fromJson(Map<String, dynamic>.from(value));
if (tpl.id.isEmpty) return;
recurringTemplates[tpl.id.isNotEmpty ? tpl.id : key] = tpl;
}
});
}
final customLabels = <TxLabel>[];
final customs = root['customLabels'];
if (customs is List) {
@@ -365,6 +386,7 @@ class StorageService {
labelsByMerchant: labelsByMerchant,
notesByMerchant: notesByMerchant,
exactTemplates: exactTemplates,
recurringTemplates: recurringTemplates,
customLabels: customLabels,
labelParents: labelParents,
);
+193 -27
View File
@@ -1,4 +1,4 @@
/// Project future balances from recurring transactions.
/// Project future balances from recurring templates.
library;
import '../models/bank.dart';
@@ -8,52 +8,218 @@ 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;
return [
for (final tpl in templates)
RecurringItem(
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!,
],
),
];
}
final existingDate = DateTime.tryParse(existing.date);
final nextDate = DateTime.tryParse(tx.date);
if (existingDate == null ||
(nextDate != null && nextDate.isAfter(existingDate))) {
latestByExact[tx.exactKey] = tx;
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';
}
}
return [
for (final tx in latestByExact.values)
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),
),
];
/// 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).
+57 -24
View File
@@ -10,15 +10,50 @@ import '../screens/account_screen.dart';
import '../screens/login_screen.dart';
import '../screens/settings_screen.dart';
Widget authAppBarAction(BuildContext context) {
/// Account / sign-in control for app bars.
///
/// Sync progress is subscribed in a nested widget so progress ticks do not
/// rebuild the whole screen (Overview charts, Look ahead, etc.).
Widget authAppBarAction(BuildContext context) => const _AuthAppBarAction();
class _AuthAppBarAction extends StatelessWidget {
const _AuthAppBarAction();
@override
Widget build(BuildContext context) {
final auth = context.watch<AuthController>();
final sync = context.watch<SyncController>();
if (!auth.isLoggedIn) {
return IconButton(
tooltip: 'Sign in',
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute<bool>(builder: (_) => const LoginScreen()),
);
},
icon: const Icon(Icons.person_outline),
);
}
return _SyncAwareAccountMenu(auth: auth);
}
}
class _SyncAwareAccountMenu extends StatelessWidget {
const _SyncAwareAccountMenu({required this.auth});
final AuthController auth;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: context.read<SyncController>(),
builder: (context, _) {
final sync = context.read<SyncController>();
final showRing = sync.showProgressRing;
final progress = sync.syncProgress;
final success = sync.progressSuccess;
final syncActive = sync.isSyncing || showRing;
if (auth.isLoggedIn) {
final ringColor = success
? const Color(0xFF16A34A)
: Theme.of(context).colorScheme.primary;
@@ -32,7 +67,9 @@ Widget authAppBarAction(BuildContext context) {
switch (action) {
case _AccountMenuAction.account:
await Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const AccountScreen()),
MaterialPageRoute<void>(
builder: (_) => const AccountScreen(),
),
);
case _AccountMenuAction.logout:
await auth.logout();
@@ -40,8 +77,6 @@ Widget authAppBarAction(BuildContext context) {
},
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)
@@ -85,12 +120,14 @@ Widget authAppBarAction(BuildContext context) {
children: [
CircleAvatar(
radius: 16,
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
backgroundColor:
Theme.of(context).colorScheme.primaryContainer,
child: Text(
auth.initials,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color:
Theme.of(context).colorScheme.onPrimaryContainer,
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
),
),
),
@@ -99,7 +136,8 @@ Widget authAppBarAction(BuildContext context) {
width: 32,
height: 32,
child: CircularProgressIndicator(
value: progress <= 0 ? null : progress.clamp(0.02, 1.0),
value:
progress <= 0 ? null : progress.clamp(0.02, 1.0),
strokeWidth: 2.5,
color: ringColor,
backgroundColor: Theme.of(context)
@@ -113,18 +151,10 @@ Widget authAppBarAction(BuildContext context) {
),
),
);
}
return IconButton(
tooltip: 'Sign in',
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute<bool>(builder: (_) => const LoginScreen()),
);
},
icon: const Icon(Icons.person_outline),
);
}
}
/// Live sync status row inside the account popup (watches [SyncController]).
class _SyncProgressMenuTile extends StatelessWidget {
@@ -132,7 +162,10 @@ class _SyncProgressMenuTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final sync = context.watch<SyncController>();
return ListenableBuilder(
listenable: context.read<SyncController>(),
builder: (context, _) {
final sync = context.read<SyncController>();
final theme = Theme.of(context);
final colors = theme.colorScheme;
final pct = (sync.syncProgress * 100).round().clamp(0, 100);
@@ -169,9 +202,9 @@ class _SyncProgressMenuTile extends StatelessWidget {
color: colors.onSurface,
),
),
subtitle: detail.isEmpty
? null
: Text('Sync in progress ($pct%)'),
subtitle: detail.isEmpty ? null : Text('Sync in progress ($pct%)'),
);
},
);
}
}
+94 -59
View File
@@ -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,10 +83,20 @@ 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(
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,
@@ -95,6 +110,8 @@ Future<LabelFilterSelection?> showLabelFilterDialog(
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,28 +268,47 @@ 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(
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: [
ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxContentHeight * 0.42),
child: SingleChildScrollView(
Padding(
padding: const EdgeInsets.fromLTRB(24, 4, 24, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
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: 8),
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(24, 4, 24, 8),
children: [
if (widget.includeFlow) ...[
const SizedBox(height: 12),
Text(
'Income / expense',
style: theme.textTheme.titleSmall?.copyWith(
@@ -315,13 +347,12 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
'Expense view: spending up, income/refunds down',
),
value: _invertChart,
onChanged: (value) =>
setState(() => _invertChart = value),
onChanged: (value) => setState(() => _invertChart = value),
),
],
const SizedBox(height: 14),
],
if (widget.includeBarStyle) ...[
const SizedBox(height: 12),
Text(
'Bar layout',
style: theme.textTheme.titleSmall?.copyWith(
@@ -331,8 +362,7 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
const SizedBox(height: 8),
_RectToggleBar<LabelChartBarStyle>(
value: _barStyle,
onChanged: (value) =>
setState(() => _barStyle = value),
onChanged: (value) => setState(() => _barStyle = value),
options: const [
_RectToggleOption(
value: LabelChartBarStyle.parallel,
@@ -346,9 +376,9 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
),
],
),
const SizedBox(height: 14),
],
if (widget.allowCreate) ...[
const SizedBox(height: 10),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
@@ -357,12 +387,15 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
label: const Text('New label'),
),
),
const SizedBox(height: 8),
],
],
Text(
'Labels',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(height: 10),
const SizedBox(height: 8),
Row(
children: [
Expanded(
@@ -386,7 +419,8 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
),
),
Tooltip(
message: allVisibleSelected ? 'Deselect all' : 'Select all',
message:
allVisibleSelected ? 'Deselect all' : 'Select all',
child: Checkbox(
tristate: true,
value: allVisibleSelected
@@ -410,49 +444,29 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
],
),
const SizedBox(height: 8),
Expanded(
child: _LabelChecklist(
_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'),
),
),
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,
),
),
FilledButton(
onPressed: apply,
child: const Text('Apply'),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
],
),
@@ -488,6 +502,7 @@ class _TransactionFilterDialogState extends State<_TransactionFilterDialog> {
late final Set<String> _selected;
late 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),
+532
View File
@@ -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),
);
},
),
);
}
}
+27
View File
@@ -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 (_) {}
});