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
+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,54 +8,220 @@ import 'tx_calendar.dart';
/// One monthly recurring item used for look-ahead forecasts.
class RecurringItem {
const RecurringItem({
required this.templateId,
required this.description,
required this.amount,
required this.currency,
required this.dayOfMonth,
this.ruleSummary = '',
this.sourceTxId,
this.labelIds = const [],
});
final String templateId;
final String description;
final double amount;
final String currency;
final int dayOfMonth;
final String ruleSummary;
final String? sourceTxId;
final List<String> labelIds;
}
/// Build unique recurring templates from annotated transactions.
List<RecurringItem> recurringItemsFrom({
/// Build projection items from first-class [templates].
List<RecurringItem> recurringItemsFromTemplates({
required Iterable<RecurringTemplate> templates,
required List<BankTransaction> transactions,
required TxMeta Function(BankTransaction tx) metaFor,
required bool Function(BankTransaction tx) isInternalTransfer,
required List<String> Function(BankTransaction tx) labelIdsFor,
String Function(String? labelId)? labelName,
}) {
final latestByExact = <String, BankTransaction>{};
for (final tx in transactions) {
final meta = metaFor(tx);
if (!meta.recurring) continue;
final existing = latestByExact[tx.exactKey];
if (existing == null) {
latestByExact[tx.exactKey] = tx;
continue;
}
final existingDate = DateTime.tryParse(existing.date);
final nextDate = DateTime.tryParse(tx.date);
if (existingDate == null ||
(nextDate != null && nextDate.isAfter(existingDate))) {
latestByExact[tx.exactKey] = tx;
}
}
return [
for (final tx in latestByExact.values)
for (final tpl in templates)
RecurringItem(
description: tx.description,
amount: tx.amount,
currency: tx.currency,
dayOfMonth: (DateTime.tryParse(tx.date)?.day ?? 1).clamp(1, 28),
labelIds: List<String>.of(metaFor(tx).labelIds),
templateId: tpl.id,
description: tpl.name,
amount: resolveRecurringAmount(
template: tpl,
transactions: transactions,
isInternalTransfer: isInternalTransfer,
labelIdsFor: labelIdsFor,
),
currency: tpl.currency,
dayOfMonth: tpl.dayOfMonth.clamp(1, 28),
ruleSummary: recurringRuleSummary(
template: tpl,
labelName: labelName,
),
sourceTxId: tpl.sourceTxId,
labelIds: [
if (tpl.labelId != null && tpl.labelId!.isNotEmpty) tpl.labelId!,
],
),
];
}
String recurringRuleSummary({
required RecurringTemplate template,
String Function(String? labelId)? labelName,
}) {
switch (template.amountRule) {
case RecurringAmountRule.lastValue:
return 'Last value';
case RecurringAmountRule.avgLastTxs:
return 'Avg last ${template.avgCount} matching';
case RecurringAmountRule.labelAvgMonths:
final name = labelName?.call(template.labelId) ?? 'label';
return 'Avg ${template.avgCount} mo · $name';
}
}
/// Resolve projected amount for [template] from bank history.
double resolveRecurringAmount({
required RecurringTemplate template,
required List<BankTransaction> transactions,
required bool Function(BankTransaction tx) isInternalTransfer,
required List<String> Function(BankTransaction tx) labelIdsFor,
}) {
switch (template.amountRule) {
case RecurringAmountRule.lastValue:
return _lastMerchantAmount(
template: template,
transactions: transactions,
) ??
0;
case RecurringAmountRule.avgLastTxs:
return avgLastMatchingTxs(
count: template.avgCount,
transactions: transactions,
merchantKey: template.merchantKey,
sourceTxId: template.sourceTxId,
);
case RecurringAmountRule.labelAvgMonths:
final labelId = template.labelId;
if (labelId == null || labelId.isEmpty) return 0;
return avgLabelMonths(
labelId: labelId,
months: template.avgCount,
transactions: transactions,
isInternalTransfer: isInternalTransfer,
labelIdsFor: labelIdsFor,
);
}
}
double? _lastMerchantAmount({
required RecurringTemplate template,
required List<BankTransaction> transactions,
}) {
if (template.sourceTxId != null) {
for (final tx in transactions) {
if (tx.id == template.sourceTxId) return tx.amount;
}
}
final key = template.merchantKey;
if (key.isEmpty) return null;
BankTransaction? latest;
DateTime? latestDate;
for (final tx in transactions) {
if (tx.merchantKey != key) continue;
final date = DateTime.tryParse(tx.date);
if (date == null) continue;
if (latest == null || date.isAfter(latestDate!)) {
latest = tx;
latestDate = date;
}
}
return latest?.amount;
}
/// Average of the last [count] txs that match the selected source
/// (same similar-pattern + flow as apply-to-similar).
double avgLastMatchingTxs({
required int count,
required List<BankTransaction> transactions,
String merchantKey = '',
String? sourceTxId,
}) {
final n = count.clamp(1, 36);
BankTransaction? prototype;
if (sourceTxId != null && sourceTxId.isNotEmpty) {
for (final tx in transactions) {
if (tx.id == sourceTxId) {
prototype = tx;
break;
}
}
}
if (prototype == null && merchantKey.isNotEmpty) {
DateTime? latestDate;
for (final tx in transactions) {
if (tx.merchantKey != merchantKey) continue;
final date = DateTime.tryParse(tx.date);
if (date == null) continue;
if (prototype == null || date.isAfter(latestDate!)) {
prototype = tx;
latestDate = date;
}
}
}
if (prototype == null) return 0;
final criteria = SimilarMatchCriteria(
pattern: defaultSimilarPattern(prototype.description),
flow: prototype.amount < 0
? TxFlowFilter.expense
: prototype.amount > 0
? TxFlowFilter.income
: TxFlowFilter.all,
);
final matches = <({BankTransaction tx, DateTime date})>[];
for (final tx in transactions) {
if (!txMatchesSimilarCriteria(tx, criteria)) continue;
final date = DateTime.tryParse(tx.date);
if (date == null) continue;
matches.add((tx: tx, date: date));
}
if (matches.isEmpty) return prototype.amount;
matches.sort((a, b) => b.date.compareTo(a.date));
final take = matches.take(n).toList(growable: false);
final sum = take.fold<double>(0, (s, e) => s + e.tx.amount);
return sum / take.length;
}
/// Average monthly signed total for [labelId] over the last [months] completed months.
double avgLabelMonths({
required String labelId,
required int months,
required List<BankTransaction> transactions,
required bool Function(BankTransaction tx) isInternalTransfer,
required List<String> Function(BankTransaction tx) labelIdsFor,
DateTime? now,
}) {
final n = months.clamp(1, 36);
final today = dateOnly(now ?? DateTime.now());
final currentMonth = monthStart(today);
final totals = <DateTime, double>{};
for (var i = 1; i <= n; i++) {
totals[addMonths(currentMonth, -i)] = 0;
}
for (final tx in transactions) {
if (isInternalTransfer(tx)) continue;
final date = DateTime.tryParse(tx.date);
if (date == null) continue;
final key = monthStart(date);
if (!totals.containsKey(key)) continue;
final ids = labelIdsFor(tx);
if (!ids.contains(labelId)) continue;
totals[key] = (totals[key] ?? 0) + tx.amount;
}
final sum = totals.values.fold<double>(0, (s, v) => s + v);
return sum / n;
}
/// Occurrences of [items] with dates in `(from, to]` (exclusive start, inclusive end).
List<({RecurringItem item, DateTime date})> projectedOccurrences({
required List<RecurringItem> items,
+178 -145
View File
@@ -10,120 +10,150 @@ import '../screens/account_screen.dart';
import '../screens/login_screen.dart';
import '../screens/settings_screen.dart';
Widget authAppBarAction(BuildContext context) {
final auth = context.watch<AuthController>();
final sync = context.watch<SyncController>();
final showRing = sync.showProgressRing;
final progress = sync.syncProgress;
final success = sync.progressSuccess;
final syncActive = sync.isSyncing || showRing;
/// Account / sign-in control for app bars.
///
/// Sync progress is subscribed in a nested widget so progress ticks do not
/// rebuild the whole screen (Overview charts, Look ahead, etc.).
Widget authAppBarAction(BuildContext context) => const _AuthAppBarAction();
if (auth.isLoggedIn) {
final ringColor = success
? const Color(0xFF16A34A)
: Theme.of(context).colorScheme.primary;
class _AuthAppBarAction extends StatelessWidget {
const _AuthAppBarAction();
return PopupMenuButton<_AccountMenuAction>(
tooltip: sync.isSyncing
? 'Syncing… ${(progress * 100).round()}%'
: auth.displayName,
offset: const Offset(0, 48),
onSelected: (action) async {
switch (action) {
case _AccountMenuAction.account:
await Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const AccountScreen()),
);
case _AccountMenuAction.logout:
await auth.logout();
}
},
itemBuilder: (context) {
final email = auth.email;
// Capture whether sync was active when the menu opened; the tile
// itself keeps listening so the % can update while the menu is open.
final showSyncRow = syncActive;
return [
if (email != null)
PopupMenuItem<_AccountMenuAction>(
enabled: false,
child: Text(
email,
style: Theme.of(context).textTheme.bodySmall,
),
),
if (showSyncRow)
const PopupMenuItem<_AccountMenuAction>(
enabled: false,
child: _SyncProgressMenuTile(),
),
const PopupMenuItem<_AccountMenuAction>(
value: _AccountMenuAction.account,
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.person_outline),
title: Text('Account'),
),
),
const PopupMenuItem<_AccountMenuAction>(
value: _AccountMenuAction.logout,
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.logout),
title: Text('Sign out'),
),
),
];
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: SizedBox(
width: 32,
height: 32,
child: Stack(
alignment: Alignment.center,
children: [
CircleAvatar(
radius: 16,
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
child: Text(
auth.initials,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color:
Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
if (showRing)
SizedBox(
width: 32,
height: 32,
child: CircularProgressIndicator(
value: progress <= 0 ? null : progress.clamp(0.02, 1.0),
strokeWidth: 2.5,
color: ringColor,
backgroundColor: Theme.of(context)
.colorScheme
.outlineVariant
.withValues(alpha: 0.45),
@override
Widget build(BuildContext context) {
final auth = context.watch<AuthController>();
if (!auth.isLoggedIn) {
return IconButton(
tooltip: 'Sign in',
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute<bool>(builder: (_) => const LoginScreen()),
);
},
icon: const Icon(Icons.person_outline),
);
}
return _SyncAwareAccountMenu(auth: auth);
}
}
class _SyncAwareAccountMenu extends StatelessWidget {
const _SyncAwareAccountMenu({required this.auth});
final AuthController auth;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: context.read<SyncController>(),
builder: (context, _) {
final sync = context.read<SyncController>();
final showRing = sync.showProgressRing;
final progress = sync.syncProgress;
final success = sync.progressSuccess;
final syncActive = sync.isSyncing || showRing;
final ringColor = success
? const Color(0xFF16A34A)
: Theme.of(context).colorScheme.primary;
return PopupMenuButton<_AccountMenuAction>(
tooltip: sync.isSyncing
? 'Syncing… ${(progress * 100).round()}%'
: auth.displayName,
offset: const Offset(0, 48),
onSelected: (action) async {
switch (action) {
case _AccountMenuAction.account:
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => const AccountScreen(),
),
);
case _AccountMenuAction.logout:
await auth.logout();
}
},
itemBuilder: (context) {
final email = auth.email;
final showSyncRow = syncActive;
return [
if (email != null)
PopupMenuItem<_AccountMenuAction>(
enabled: false,
child: Text(
email,
style: Theme.of(context).textTheme.bodySmall,
),
),
],
if (showSyncRow)
const PopupMenuItem<_AccountMenuAction>(
enabled: false,
child: _SyncProgressMenuTile(),
),
const PopupMenuItem<_AccountMenuAction>(
value: _AccountMenuAction.account,
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.person_outline),
title: Text('Account'),
),
),
const PopupMenuItem<_AccountMenuAction>(
value: _AccountMenuAction.logout,
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.logout),
title: Text('Sign out'),
),
),
];
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: SizedBox(
width: 32,
height: 32,
child: Stack(
alignment: Alignment.center,
children: [
CircleAvatar(
radius: 16,
backgroundColor:
Theme.of(context).colorScheme.primaryContainer,
child: Text(
auth.initials,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
),
),
),
if (showRing)
SizedBox(
width: 32,
height: 32,
child: CircularProgressIndicator(
value:
progress <= 0 ? null : progress.clamp(0.02, 1.0),
strokeWidth: 2.5,
color: ringColor,
backgroundColor: Theme.of(context)
.colorScheme
.outlineVariant
.withValues(alpha: 0.45),
),
),
],
),
),
),
),
),
);
},
);
}
return IconButton(
tooltip: 'Sign in',
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute<bool>(builder: (_) => const LoginScreen()),
);
},
icon: const Icon(Icons.person_outline),
);
}
/// Live sync status row inside the account popup (watches [SyncController]).
@@ -132,46 +162,49 @@ class _SyncProgressMenuTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final sync = context.watch<SyncController>();
final theme = Theme.of(context);
final colors = theme.colorScheme;
final pct = (sync.syncProgress * 100).round().clamp(0, 100);
final detail = sync.statusDetail.trim();
return ListenableBuilder(
listenable: context.read<SyncController>(),
builder: (context, _) {
final sync = context.read<SyncController>();
final theme = Theme.of(context);
final colors = theme.colorScheme;
final pct = (sync.syncProgress * 100).round().clamp(0, 100);
final detail = sync.statusDetail.trim();
if (sync.progressSuccess) {
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.check_circle_outline, color: colors.primary),
title: Text(
'Sync complete',
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurface,
if (sync.progressSuccess) {
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.check_circle_outline, color: colors.primary),
title: Text(
'Sync complete',
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurface,
),
),
);
}
return ListTile(
contentPadding: EdgeInsets.zero,
leading: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
value: sync.syncProgress <= 0
? null
: sync.syncProgress.clamp(0.02, 1.0),
strokeWidth: 2.5,
),
),
),
);
}
return ListTile(
contentPadding: EdgeInsets.zero,
leading: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
value: sync.syncProgress <= 0
? null
: sync.syncProgress.clamp(0.02, 1.0),
strokeWidth: 2.5,
),
),
title: Text(
detail.isEmpty ? 'Sync in progress ($pct%)' : detail,
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurface,
),
),
subtitle: detail.isEmpty
? null
: Text('Sync in progress ($pct%)'),
title: Text(
detail.isEmpty ? 'Sync in progress ($pct%)' : detail,
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurface,
),
),
subtitle: detail.isEmpty ? null : Text('Sync in progress ($pct%)'),
);
},
);
}
}