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
+67 -47
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,41 +71,7 @@ class AccountScreen extends StatelessWidget {
subtitle: Text(kPocketBaseUrl),
),
const Divider(height: 1),
ListTile(
leading: sync.isSyncing
? SizedBox(
width: 24,
height: 24,
child: Padding(
padding: const EdgeInsets.all(2),
child: CircularProgressIndicator(
strokeWidth: 2,
value: sync.syncProgress <= 0
? null
: sync.syncProgress.clamp(0.02, 1.0),
),
),
)
: Icon(
sync.lastError == null
? Icons.sync
: Icons.sync_problem_outlined,
),
title: const Text('Cloud sync'),
subtitle: Text(syncSubtitle),
trailing: TextButton(
onPressed: sync.isSyncing ? null : () => sync.syncNow(),
child: const Text('Sync now'),
),
),
if (sync.lastSyncSummary.isNotEmpty) ...[
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.fact_check_outlined),
title: const Text('Last sync detail'),
subtitle: Text(sync.lastSyncSummary),
),
],
const _CloudSyncTile(),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.phone_android_outlined),
@@ -150,6 +104,72 @@ class AccountScreen extends StatelessWidget {
),
);
}
}
/// Isolated so sync progress ticks do not rebuild account stats.
class _CloudSyncTile extends StatelessWidget {
const _CloudSyncTile();
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: context.read<SyncController>(),
builder: (context, _) {
final sync = context.read<SyncController>();
final lastSync = sync.lastSyncedAt;
final syncSubtitle = sync.isSyncing
? (sync.statusDetail.isEmpty
? 'Syncing… ${(sync.syncProgress * 100).round()}%'
: '${sync.statusDetail} · ${(sync.syncProgress * 100).round()}%')
: sync.lastError != null
? 'Last error: ${sync.lastError}'
: lastSync == null
? 'Not synced yet'
: 'Last sync ${_formatTime(lastSync)}';
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: sync.isSyncing
? SizedBox(
width: 24,
height: 24,
child: Padding(
padding: const EdgeInsets.all(2),
child: CircularProgressIndicator(
strokeWidth: 2,
value: sync.syncProgress <= 0
? null
: sync.syncProgress.clamp(0.02, 1.0),
),
),
)
: Icon(
sync.lastError == null
? Icons.sync
: Icons.sync_problem_outlined,
),
title: const Text('Cloud sync'),
subtitle: Text(syncSubtitle),
trailing: TextButton(
onPressed: sync.isSyncing ? null : () => sync.syncNow(),
child: const Text('Sync now'),
),
),
if (sync.lastSyncSummary.isNotEmpty) ...[
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.fact_check_outlined),
title: const Text('Last sync detail'),
subtitle: Text(sync.lastSyncSummary),
),
],
],
);
},
);
}
static String _formatTime(DateTime time) {
final local = time.toLocal();
+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),
),
);
},
);
}
}
+33 -22
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,31 +21,40 @@ class _MainShellState extends State<MainShell> {
static const _pages = [
HomeScreen(),
OverviewScreen(),
LookAheadScreen(),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
index: _index,
children: _pages,
),
bottomNavigationBar: NavigationBar(
selectedIndex: _index,
onDestinationSelected: (index) => setState(() => _index = index),
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: 'Home',
),
NavigationDestination(
icon: Icon(Icons.insights_outlined),
selectedIcon: Icon(Icons.insights),
label: 'Overview',
),
],
return ShellTabScope(
index: _index,
child: Scaffold(
body: IndexedStack(
index: _index,
children: _pages,
),
bottomNavigationBar: NavigationBar(
selectedIndex: _index,
onDestinationSelected: (index) => setState(() => _index = index),
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: 'Home',
),
NavigationDestination(
icon: Icon(Icons.insights_outlined),
selectedIcon: Icon(Icons.insights),
label: 'Overview',
),
NavigationDestination(
icon: Icon(Icons.event_repeat_outlined),
selectedIcon: Icon(Icons.event_repeat),
label: 'Look ahead',
),
],
),
),
);
}
+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>();
+348 -275
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,
all,
criteria: _matchCriteria,
);
// Default on when there are peers — otherwise users never notice the toggle.
_applyToSimilar = similarCount > 1;
final count = context.read<TxMetaController>().countSimilar(
widget.tx,
all,
criteria: _matchCriteria,
);
if (!mounted) return;
setState(() {
_similarCount = count;
_similarReady = true;
// Default on when there are peers — otherwise users never notice the toggle.
if (count > 1) _applyToSimilar = true;
});
}
@override
void dispose() {
_notesController.dispose();
_labelSearchController.dispose();
super.dispose();
}
@@ -85,13 +101,10 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
if (result == null || !mounted) return;
setState(() {
_matchCriteria = result;
final count = context.read<TxMetaController>().countSimilar(
widget.tx,
all,
criteria: result,
);
if (count > 1) _applyToSimilar = true;
_similarReady = false;
_similarCount = 0;
});
_refreshSimilarCount();
}
Future<void> _runBusy(
@@ -282,17 +295,27 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
final tx = widget.tx;
final metaController = context.watch<TxMetaController>();
final meta = metaController.metaFor(tx);
// Prefer read: bank data rarely changes while editing labels.
final all = context.read<BankController>().transactions;
final similarCount = metaController.countSimilar(
tx,
all,
criteria: _matchCriteria,
);
final similarCount = _similarCount;
final theme = Theme.of(context);
final colors = theme.colorScheme;
final amountColor = tx.amount >= 0 ? AppTheme.accent : AppTheme.expense;
final labelEntries = metaController.labelsInTreeOrder();
final needle = _labelSearchController.text.trim().toLowerCase();
final allLabelEntries = metaController.labelsInTreeOrder();
final labelEntries = needle.isEmpty
? allLabelEntries
: () {
final keep = <String>{};
for (final entry in allLabelEntries) {
if (!entry.label.name.toLowerCase().contains(needle)) continue;
keep.add(entry.label.id);
keep.addAll(metaController.ancestorIdsOf(entry.label.id));
keep.addAll(metaController.descendantIdsOf(entry.label.id));
}
return [
for (final entry in allLabelEntries)
if (keep.contains(entry.label.id)) entry,
];
}();
return Scaffold(
appBar: AppBar(
@@ -305,275 +328,325 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
),
],
),
body: ResponsiveBody(
child: ListView(
padding: const EdgeInsets.only(top: 8, bottom: 32),
children: [
if (_busy) ...[
const LinearProgressIndicator(minHeight: 2),
if (_busyMessage != null)
Padding(
padding: const EdgeInsets.fromLTRB(0, 8, 0, 4),
child: Text(
_busyMessage!,
style: theme.textTheme.labelMedium?.copyWith(
color: colors.primary,
),
),
),
],
AbsorbPointer(
absorbing: _busy,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
formatMoney(tx.amount, tx.currency),
style: theme.textTheme.headlineMedium?.copyWith(
color: amountColor,
),
),
const SizedBox(height: 8),
Text(tx.description, style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
Text(
[
formatIsoDate(tx.date),
if (tx.pending) 'Pending',
].join(' · '),
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
Row(
children: [
Text('Match similar', style: theme.textTheme.titleSmall),
const Spacer(),
TextButton.icon(
onPressed: _busy ? null : _editMatchCriteria,
icon: const Icon(Icons.tune, size: 18),
label: const Text('Edit'),
),
],
),
const SizedBox(height: 4),
Text(
'“${_matchCriteria.pattern}”',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
secondary: const Icon(Icons.copy_all_outlined),
title: const Text('Include matched transactions'),
subtitle: Text(
similarCount <= 1
? 'No other matches for this pattern'
: '$similarCount matching transactions',
),
value: _applyToSimilar && similarCount > 1,
onChanged: _busy || similarCount <= 1
? null
: (value) => setState(() => _applyToSimilar = value),
),
if (_applyToSimilar && similarCount > 1)
Align(
body: Column(
children: [
if (_busy) const LinearProgressIndicator(minHeight: 2),
if (_busy && _busyMessage != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: _busy ? null : _editMatchCriteria,
child: Text('Review $similarCount matches'),
),
),
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
secondary: const Icon(Icons.autorenew),
title: const Text('Recurring'),
subtitle: const Text('Salary, mortgage, subscriptions…'),
value: meta.recurring,
onChanged: _busy
? null
: (value) {
context.read<TxMetaController>().setRecurring(tx, value);
},
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
secondary: const Icon(Icons.swap_horiz),
title: const Text('Internal transfer'),
subtitle: const Text(
'Between your own accounts — excluded from income/expense',
),
value: meta.internalTransfer,
onChanged: _busy
? null
: (value) {
context
.read<TxMetaController>()
.setInternalTransfer(tx, value);
},
),
const SizedBox(height: 16),
Row(
children: [
Text('Labels', style: theme.textTheme.titleSmall),
const Spacer(),
TextButton(
onPressed: _busy ? null : _createLabel,
child: const Text('New'),
),
if (_draftLabelIds.isNotEmpty)
TextButton(
onPressed: _busy ? null : _clearDraftLabels,
child: const Text('Clear'),
child: Text(
_busyMessage!,
style: theme.textTheme.labelMedium?.copyWith(
color: colors.primary,
),
],
),
const SizedBox(height: 8),
DecoratedBox(
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: colors.outlineVariant.withValues(alpha: 0.8),
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Column(
),
Expanded(
child: ResponsiveBody(
child: AbsorbPointer(
absorbing: _busy,
child: ListView(
padding: const EdgeInsets.only(top: 8, bottom: 16),
children: [
for (final entry in labelEntries) ...[
if (entry != labelEntries.first)
Divider(
height: 1,
indent: 40,
color: colors.outlineVariant.withValues(alpha: 0.45),
Text(
formatMoney(tx.amount, tx.currency),
style: theme.textTheme.headlineMedium?.copyWith(
color: amountColor,
),
),
const SizedBox(height: 8),
Text(tx.description, style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
Text(
[
formatIsoDate(tx.date),
if (tx.pending) 'Pending',
].join(' · '),
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
Row(
children: [
Text('Match similar', style: theme.textTheme.titleSmall),
const Spacer(),
TextButton.icon(
onPressed: _busy ? null : _editMatchCriteria,
icon: const Icon(Icons.tune, size: 18),
label: const Text('Edit'),
),
InkWell(
onTap:
_busy ? null : () => _toggleDraftLabel(entry.label.id),
onLongPress:
_busy ? null : () => _labelActions(entry.label),
child: Padding(
padding: EdgeInsets.fromLTRB(
8 + entry.depth * 16.0,
2,
4,
2,
),
child: Row(
children: [
if (entry.depth > 0) ...[
Icon(
Icons.subdirectory_arrow_right,
size: 14,
color: colors.onSurfaceVariant,
),
const SizedBox(width: 4),
],
Icon(
entry.label.icon,
size: 18,
color: entry.label.color,
),
const SizedBox(width: 10),
Expanded(
child: Text(
entry.label.name,
style: theme.textTheme.bodyMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
SizedBox(
height: 32,
width: 32,
child: Checkbox(
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
value: _draftLabelIds.contains(entry.label.id),
onChanged: _busy
? null
: (_) =>
_toggleDraftLabel(entry.label.id),
),
),
],
),
],
),
const SizedBox(height: 4),
Text(
'“${_matchCriteria.pattern}”',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
secondary: const Icon(Icons.copy_all_outlined),
title: const Text('Include matched transactions'),
subtitle: Text(
similarCount <= 1
? 'No other matches for this pattern'
: '$similarCount matching transactions',
),
value: _applyToSimilar && similarCount > 1,
onChanged: _busy || !_similarReady || similarCount <= 1
? null
: (value) => setState(() => _applyToSimilar = value),
),
if (_applyToSimilar && similarCount > 1)
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: _busy ? null : _editMatchCriteria,
child: Text('Review $similarCount matches'),
),
),
],
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
secondary: const Icon(Icons.autorenew),
title: const Text('Recurring'),
subtitle: const Text('Salary, mortgage, subscriptions…'),
value: meta.recurring,
onChanged: _busy
? null
: (value) {
context.read<TxMetaController>().setRecurring(tx, value);
},
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
secondary: const Icon(Icons.swap_horiz),
title: const Text('Internal transfer'),
subtitle: const Text(
'Between your own accounts — excluded from income/expense',
),
value: meta.internalTransfer,
onChanged: _busy
? null
: (value) {
context
.read<TxMetaController>()
.setInternalTransfer(tx, value);
},
),
const SizedBox(height: 16),
Row(
children: [
Text('Labels', style: theme.textTheme.titleSmall),
const Spacer(),
TextButton(
onPressed: _busy ? null : _createLabel,
child: const Text('New'),
),
if (_draftLabelIds.isNotEmpty)
TextButton(
onPressed: _busy ? null : _clearDraftLabels,
child: const Text('Clear'),
),
],
),
const SizedBox(height: 8),
TextField(
controller: _labelSearchController,
enabled: !_busy,
decoration: InputDecoration(
isDense: true,
hintText: 'Filter labels',
prefixIcon: const Icon(Icons.search, size: 20),
suffixIcon: needle.isEmpty
? null
: IconButton(
onPressed: () {
_labelSearchController.clear();
setState(() {});
},
icon: const Icon(Icons.clear, size: 18),
),
),
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 8),
DecoratedBox(
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: colors.outlineVariant.withValues(alpha: 0.8),
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
height: 280,
child: labelEntries.isEmpty
? Center(
child: Text(
needle.isEmpty
? 'No labels yet'
: 'No labels match',
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
)
: ListView.separated(
padding: EdgeInsets.zero,
itemCount: labelEntries.length,
separatorBuilder: (_, _) => Divider(
height: 1,
indent: 40,
color: colors.outlineVariant
.withValues(alpha: 0.45),
),
itemBuilder: (context, index) {
final entry = labelEntries[index];
return InkWell(
onTap: _busy
? null
: () => _toggleDraftLabel(entry.label.id),
onLongPress: _busy
? null
: () => _labelActions(entry.label),
child: Padding(
padding: EdgeInsets.fromLTRB(
8 + entry.depth * 16.0,
2,
4,
2,
),
child: Row(
children: [
if (entry.depth > 0) ...[
Icon(
Icons.subdirectory_arrow_right,
size: 14,
color: colors.onSurfaceVariant,
),
const SizedBox(width: 4),
],
Icon(
entry.label.icon,
size: 18,
color: entry.label.color,
),
const SizedBox(width: 10),
Expanded(
child: Text(
entry.label.name,
style: theme.textTheme.bodyMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
SizedBox(
height: 32,
width: 32,
child: Checkbox(
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
value: _draftLabelIds
.contains(entry.label.id),
onChanged: _busy
? null
: (_) => _toggleDraftLabel(
entry.label.id,
),
),
),
],
),
),
);
},
),
),
),
),
const SizedBox(height: 24),
Text('Notes', style: theme.textTheme.titleSmall),
const SizedBox(height: 8),
TextField(
controller: _notesController,
minLines: 3,
maxLines: 6,
enabled: !_busy,
textInputAction: TextInputAction.newline,
decoration: const InputDecoration(
hintText: 'Add a private note…',
),
onChanged: (_) {
if (!_dirty) setState(() => _dirty = true);
},
),
const SizedBox(height: 12),
FilledButton(
onPressed: _dirty && !_busy ? _saveNotes : null,
child: Text(
_applyToSimilar && similarCount > 1
? 'Save notes for $similarCount'
: 'Save notes',
),
),
const SizedBox(height: 16),
Text(
'Sub-labels also select their parents. Unchecking a parent '
'clears its sub-labels. Long-press a row to move or delete. '
'Use Apply to save label changes.',
style: theme.textTheme.labelMedium,
),
],
),
),
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: FilledButton(
onPressed: _busy || !_labelsDirty
? null
: () => _applyLabels(toMatched: false),
child: const Text('Apply'),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton.tonal(
onPressed: _busy ||
!_labelsDirty ||
!_applyToSimilar ||
similarCount <= 1
? null
: () => _applyLabels(toMatched: true),
child: Text(
similarCount > 1
? 'Apply to matched ($similarCount)'
: 'Apply to matched',
),
SafeArea(
top: false,
child: Material(
elevation: 3,
color: colors.surface,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 10),
child: Row(
children: [
Expanded(
child: FilledButton(
onPressed: _busy || !_labelsDirty
? null
: () => _applyLabels(toMatched: false),
child: const Text('Apply'),
),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton.tonal(
onPressed: _busy ||
!_labelsDirty ||
!_applyToSimilar ||
similarCount <= 1
? null
: () => _applyLabels(toMatched: true),
child: Text(
similarCount > 1
? 'Apply to matched ($similarCount)'
: 'Apply to matched',
),
),
),
],
),
],
),
const SizedBox(height: 24),
Text('Notes', style: theme.textTheme.titleSmall),
const SizedBox(height: 8),
TextField(
controller: _notesController,
minLines: 3,
maxLines: 6,
enabled: !_busy,
textInputAction: TextInputAction.newline,
decoration: const InputDecoration(
hintText: 'Add a private note…',
),
onChanged: (_) {
if (!_dirty) setState(() => _dirty = true);
},
),
const SizedBox(height: 12),
FilledButton(
onPressed: _dirty && !_busy ? _saveNotes : null,
child: Text(
_applyToSimilar && similarCount > 1
? 'Save notes for $similarCount'
: 'Save notes',
),
),
const SizedBox(height: 16),
Text(
'Sub-labels also select their parents. Unchecking a parent '
'clears its sub-labels. Long-press a row to move or delete. '
'Use Apply to save label changes.',
style: theme.textTheme.labelMedium,
),
],
),
),
],
),
),
],
),
);
}
+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(