diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index f9bf033..c8f6266 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -10,8 +10,10 @@ import '../providers/preferences_provider.dart'; import '../providers/tx_meta_provider.dart'; import '../utils/app_theme.dart'; import '../utils/connect_bank.dart'; +import '../utils/deferred_tab_work.dart'; import '../utils/helpers.dart'; import '../utils/navigation.dart'; +import '../utils/tx_filter_metrics.dart'; import '../widgets/empty_state.dart'; import '../widgets/home_filter_widget_card.dart'; import '../widgets/home_widgets_sheet.dart'; @@ -22,6 +24,18 @@ import '../widgets/skip_build_when_covered.dart'; import 'transaction_detail_screen.dart'; import 'transactions_screen.dart'; +class _HomeSnapshot { + const _HomeSnapshot({ + required this.categorized, + required this.statsById, + required this.budgetById, + }); + + final int categorized; + final Map statsById; + final Map budgetById; +} + class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @@ -33,16 +47,93 @@ class HomeScreen extends StatefulWidget { class _HomeScreenState extends State { var _editingWidgets = false; + final _work = DeferredTabWork<_HomeSnapshot>(); + + int _homeCacheKey({ + required TxMetaController meta, + required BankController bank, + required List widgets, + }) { + return Object.hash( + meta.storeRevision, + identityHashCode(bank.transactions), + Object.hashAll(widgets.map((w) => w.id)), + Object.hashAll(widgets.map((w) => w.updatedAt)), + _editingWidgets, + ); + } @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) != HomeScreen.tabIndex) { + final active = ShellTabScope.of(context) == HomeScreen.tabIndex; + if (!active) { + if (_work.isLoading) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (ShellTabScope.of(context) == HomeScreen.tabIndex) return; + _work.cancel(); + }); + } return const SizedBox.expand(); } final bank = context.watch(); + final meta = context.watch(); + final prefs = context.watch(); + final widgets = _editingWidgets + ? prefs.homeFilterWidgets + : prefs.displayedHomeFilterWidgets; + final needsData = bank.isLinked || bank.hasData; + final emptyLoading = bank.isLoading && bank.balance == null && !bank.hasData; + final cacheKey = _homeCacheKey(meta: meta, bank: bank, widgets: widgets); + + if (needsData && !emptyLoading && !_work.readyFor(cacheKey)) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (ShellTabScope.of(context) != HomeScreen.tabIndex) return; + _work.ensure( + key: cacheKey, + mounted: () => mounted, + onUpdate: () { + if (mounted) setState(() {}); + }, + compute: (yieldUi) async { + final categorized = + meta.labeledTransactionCount(bank.transactions); + await yieldUi(); + final stats = {}; + final budgets = {}; + for (var i = 0; i < widgets.length; i++) { + final w = widgets[i]; + if (w.type == HomeWidgetType.budget) { + budgets[w.id] = computeHomeBudgetMetrics( + all: bank.transactions, + meta: meta, + widget: w, + ); + } else { + stats[w.id] = computeTxFilterMetrics( + all: bank.transactions, + meta: meta, + filter: w.filter, + ); + } + if (i % 2 == 1) await yieldUi(); + } + return _HomeSnapshot( + categorized: categorized, + statsById: stats, + budgetById: budgets, + ); + }, + ); + }); + } + + final showSpinner = + needsData && !emptyLoading && !_work.readyFor(cacheKey); return Scaffold( appBar: AppBar( @@ -54,7 +145,9 @@ class _HomeScreenState extends State { onPressed: () => setState(() => _editingWidgets = !_editingWidgets), icon: Icon( - _editingWidgets ? Icons.check : Icons.dashboard_customize_outlined, + _editingWidgets + ? Icons.check + : Icons.dashboard_customize_outlined, ), ), ...bankAppBarActions( @@ -67,10 +160,19 @@ class _HomeScreenState extends State { body: RefreshIndicator( onRefresh: bank.refresh, child: ResponsiveBody( - child: _HomeBody( - bank: bank, - editingWidgets: _editingWidgets, - ), + child: showSpinner + ? ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: const [ + SizedBox(height: 120), + LoadingView(message: 'Loading home…'), + ], + ) + : _HomeBody( + bank: bank, + editingWidgets: _editingWidgets, + snapshot: _work.value, + ), ), ), ); @@ -81,10 +183,12 @@ class _HomeBody extends StatelessWidget { const _HomeBody({ required this.bank, required this.editingWidgets, + required this.snapshot, }); final BankController bank; final bool editingWidgets; + final _HomeSnapshot? snapshot; void _openTransactions(BuildContext context) { Navigator.of(context).push( @@ -141,7 +245,7 @@ class _HomeBody extends StatelessWidget { final colors = theme.colorScheme; final meta = context.watch(); final recent = bank.transactions.take(5).toList(growable: false); - final categorized = + final categorized = snapshot?.categorized ?? meta.labeledTransactionCount(bank.transactions); final widgets = editingWidgets ? prefs.homeFilterWidgets @@ -319,6 +423,9 @@ class _HomeBody extends StatelessWidget { child: HomeFilterWidgetCard( widgetConfig: widgetConfig, editing: true, + precomputedStats: snapshot?.statsById[widgetConfig.id], + precomputedBudget: + snapshot?.budgetById[widgetConfig.id], onToggleDisplay: (display) => prefs.setHomeFilterWidgetDisplay( widgetConfig.id, @@ -347,7 +454,11 @@ class _HomeBody extends StatelessWidget { }, ) else - ..._packedHomeWidgetRows(widgets), + ..._packedHomeWidgetRows( + widgets, + statsById: snapshot?.statsById, + budgetById: snapshot?.budgetById, + ), ], if (_showBuiltIn( editing: editingWidgets, @@ -553,7 +664,11 @@ class _HomeWidgetSlot extends StatelessWidget { } } -List _packedHomeWidgetRows(List widgets) { +List _packedHomeWidgetRows( + List widgets, { + Map? statsById, + Map? budgetById, +}) { final rows = []; var i = 0; while (i < widgets.length) { @@ -571,11 +686,19 @@ List _packedHomeWidgetRows(List widgets) { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded( - child: HomeFilterWidgetCard(widgetConfig: a), + child: HomeFilterWidgetCard( + widgetConfig: a, + precomputedStats: statsById?[a.id], + precomputedBudget: budgetById?[a.id], + ), ), const SizedBox(width: 10), Expanded( - child: HomeFilterWidgetCard(widgetConfig: b), + child: HomeFilterWidgetCard( + widgetConfig: b, + precomputedStats: statsById?[b.id], + precomputedBudget: budgetById?[b.id], + ), ), ], ), @@ -590,7 +713,11 @@ List _packedHomeWidgetRows(List widgets) { padding: const EdgeInsets.only(bottom: 10), child: _HomeWidgetSlot( halfWidth: a.halfWidth, - child: HomeFilterWidgetCard(widgetConfig: a), + child: HomeFilterWidgetCard( + widgetConfig: a, + precomputedStats: statsById?[a.id], + precomputedBudget: budgetById?[a.id], + ), ), ), ); diff --git a/lib/screens/look_ahead_screen.dart b/lib/screens/look_ahead_screen.dart index 67e54a7..2e8b52f 100644 --- a/lib/screens/look_ahead_screen.dart +++ b/lib/screens/look_ahead_screen.dart @@ -1,6 +1,8 @@ /// Look ahead: projected balance from recurring templates through a date. library; +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -16,6 +18,7 @@ import '../utils/look_ahead.dart'; import '../utils/navigation.dart'; import '../utils/tx_calendar.dart'; import '../widgets/empty_state.dart'; +import '../widgets/loading_view.dart'; import '../widgets/recurring_template_sheet.dart'; import '../widgets/responsive_body.dart'; import '../widgets/shell_tab_scope.dart'; @@ -23,6 +26,13 @@ import '../widgets/skip_build_when_covered.dart'; enum _PeriodMode { monthly, yearly } +class _ItemsSnapshot { + const _ItemsSnapshot({required this.key, required this.items}); + + final int key; + final List items; +} + class LookAheadScreen extends StatefulWidget { const LookAheadScreen({super.key}); @@ -35,8 +45,9 @@ class LookAheadScreen extends StatefulWidget { class _LookAheadScreenState extends State { var _period = _PeriodMode.monthly; late DateTime _cursor; - List? _cachedItems; - int? _itemsCacheKey; + _ItemsSnapshot? _snapshot; + int? _loadingKey; + int _computeGen = 0; @override void initState() { @@ -129,9 +140,78 @@ class _LookAheadScreenState extends State { }); } + int _itemsCacheKey(TxMetaController meta, BankController bank) => + Object.hash(meta.storeRevision, identityHashCode(bank.transactions)); + + /// Resolve recurring amounts off the build path, yielding so nav stays live. + void _ensureItemsLoaded({ + required int cacheKey, + required TxMetaController meta, + required BankController bank, + }) { + if (_snapshot?.key == cacheKey || _loadingKey == cacheKey) return; + _loadingKey = cacheKey; + final gen = ++_computeGen; + final templates = List.of(meta.recurringTemplates); + final transactions = bank.transactions; + + unawaited(() async { + // Let the spinner / tab switch paint before heavy work. + await Future.delayed(Duration.zero); + if (!mounted || gen != _computeGen) return; + + final items = []; + for (var i = 0; i < templates.length; i++) { + final tpl = templates[i]; + items.add( + RecurringItem( + templateId: tpl.id, + description: tpl.name, + amount: resolveRecurringAmount( + template: tpl, + transactions: transactions, + isInternalTransfer: (tx) => meta.metaFor(tx).internalTransfer, + labelIdsFor: meta.labelIdsFor, + ), + currency: tpl.currency, + dayOfMonth: tpl.dayOfMonth.clamp(1, 28), + ruleSummary: recurringRuleSummary( + template: tpl, + labelName: (id) => meta.labelById(id)?.name ?? 'label', + ), + sourceTxId: tpl.sourceTxId, + labelIds: [ + if (tpl.labelId != null && tpl.labelId!.isNotEmpty) tpl.labelId!, + ], + ), + ); + // Yield often so bottom-nav taps are not starved. + if (i % 2 == 1) { + await Future.delayed(Duration.zero); + if (!mounted || gen != _computeGen) return; + } + } + + if (!mounted || gen != _computeGen) return; + setState(() { + _snapshot = _ItemsSnapshot(key: cacheKey, items: items); + if (_loadingKey == cacheKey) _loadingKey = null; + }); + }()); + } + @override Widget build(BuildContext context) { - if (ShellTabScope.of(context) != LookAheadScreen.tabIndex) { + final active = ShellTabScope.of(context) == LookAheadScreen.tabIndex; + if (!active) { + if (_loadingKey != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (ShellTabScope.of(context) == LookAheadScreen.tabIndex) return; + _computeGen++; + _loadingKey = null; + }); + } return const SizedBox.expand(); } return SkipBuildWhenCovered( @@ -165,22 +245,29 @@ class _LookAheadScreenState extends State { 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 cacheKey = _itemsCacheKey(meta, bank); + if (_snapshot?.key != cacheKey && _loadingKey != cacheKey) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (ShellTabScope.of(context) != LookAheadScreen.tabIndex) return; + _ensureItemsLoaded(cacheKey: cacheKey, meta: meta, bank: bank); + }); + } + + final snapshot = _snapshot; + final ready = snapshot != null && snapshot.key == cacheKey; + + if (!ready) { + return Scaffold( + appBar: AppBar( + title: const Text('Look ahead'), + actions: bankAppBarActions(context: context), + ), + body: const LoadingView(message: 'Calculating look ahead…'), ); } - final items = _cachedItems!; + final items = snapshot.items; final projected = projectedOccurrences( items: items, from: today, diff --git a/lib/screens/overview_screen.dart b/lib/screens/overview_screen.dart index 1baa498..7918d64 100644 --- a/lib/screens/overview_screen.dart +++ b/lib/screens/overview_screen.dart @@ -12,6 +12,7 @@ import '../providers/bank_provider.dart'; import '../providers/preferences_provider.dart'; import '../providers/tx_meta_provider.dart'; import '../utils/app_theme.dart'; +import '../utils/deferred_tab_work.dart'; import '../utils/helpers.dart'; import '../utils/navigation.dart'; import '../utils/tx_calendar.dart'; @@ -28,6 +29,36 @@ import 'transactions_screen.dart'; const _kIncomeColor = AppTheme.accent; const _kExpenseColor = AppTheme.expense; +class _FavouriteAgg { + const _FavouriteAgg({ + required this.periods, + required this.monthly, + required this.labelSpend, + required this.labelAmountByPeriod, + }); + + final List periods; + final Map monthly; + final List<_LabelSpend> labelSpend; + final Map> labelAmountByPeriod; +} + +class _OverviewAggSnapshot { + const _OverviewAggSnapshot({ + required this.periods, + required this.monthly, + required this.labelSpend, + required this.labelAmountByPeriod, + required this.favourites, + }); + + final List periods; + final Map monthly; + final List<_LabelSpend> labelSpend; + final Map> labelAmountByPeriod; + final Map favourites; +} + class OverviewScreen extends StatefulWidget { const OverviewScreen({super.key}); @@ -59,6 +90,7 @@ class _OverviewScreenState extends State { /// Favourites charts view (vs full overview with history). bool _showFavouritesView = false; var _favouritesDefaultApplied = false; + final _work = DeferredTabWork<_OverviewAggSnapshot>(); @override void initState() { @@ -247,7 +279,15 @@ class _OverviewScreenState extends State { @override Widget build(BuildContext context) { - if (ShellTabScope.of(context) != OverviewScreen.tabIndex) { + final active = ShellTabScope.of(context) == OverviewScreen.tabIndex; + if (!active) { + if (_work.isLoading) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (ShellTabScope.of(context) == OverviewScreen.tabIndex) return; + _work.cancel(); + }); + } return const SizedBox.expand(); } @@ -268,6 +308,41 @@ class _OverviewScreenState extends State { }); } + final emptyLoading = + bank.isLoading && bank.transactions.isEmpty && bank.balance == null; + final needsData = bank.isLinked || bank.hasData; + final displayedFavs = prefs.displayedOverviewFavourites; + final cacheKey = Object.hash( + meta.storeRevision, + identityHashCode(bank.transactions), + _showFavouritesView, + _period, + _metric, + _cursor.year, + Object.hashAll(_includedLabelIds), + _labelFlow, + _labelBarStyle, + _labelInvertChart, + Object.hashAll(displayedFavs.map((f) => f.id)), + Object.hashAll(displayedFavs.map((f) => f.updatedAt)), + ); + + if (needsData && !emptyLoading && !_work.readyFor(cacheKey)) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (ShellTabScope.of(context) != OverviewScreen.tabIndex) return; + _scheduleOverviewCompute( + key: cacheKey, + bank: bank, + meta: meta, + displayedFavs: displayedFavs, + ); + }); + } + + final showSpinner = + needsData && !emptyLoading && !_work.readyFor(cacheKey); + return Scaffold( appBar: AppBar( title: Text(_showFavouritesView ? 'Favourites' : 'Overview'), @@ -288,16 +363,107 @@ class _OverviewScreenState extends State { body: RefreshIndicator( onRefresh: bank.refresh, child: ResponsiveBody( - child: SkipBuildWhenCovered( - builder: (context) => _showFavouritesView - ? _buildFavouritesBody(context, bank, meta, prefs) - : _buildBody(context, bank, meta, prefs), - ), + child: showSpinner + ? ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: const [ + SizedBox(height: 120), + LoadingView(message: 'Loading overview…'), + ], + ) + : SkipBuildWhenCovered( + builder: (context) => _showFavouritesView + ? _buildFavouritesBody(context, bank, meta, prefs) + : _buildBody(context, bank, meta, prefs), + ), ), ), ); } + void _scheduleOverviewCompute({ + required Object key, + required BankController bank, + required TxMetaController meta, + required List displayedFavs, + }) { + _work.ensure( + key: key, + mounted: () => mounted, + onUpdate: () { + if (mounted) setState(() {}); + }, + compute: (yieldUi) async { + final txs = bank.transactions; + final periods = _chartPeriods(txs); + await yieldUi(); + + Map monthly = const {}; + var labelSpend = const <_LabelSpend>[]; + Map> labelAmountByPeriod = const {}; + + if (!_showFavouritesView) { + if (_metric == OverviewMetricMode.flow) { + monthly = _flowTotals(txs, meta, periods, _period); + } else { + final labels = _labelAggregates( + txs, + meta, + periods, + _period, + includedLabelIds: _includedLabelIds, + flow: _labelFlow, + ); + labelSpend = labels.spend; + labelAmountByPeriod = labels.amountByPeriod; + } + await yieldUi(); + } + + final favourites = {}; + if (_showFavouritesView) { + for (var i = 0; i < displayedFavs.length; i++) { + final favourite = displayedFavs[i]; + final favPeriods = _chartPeriodsFor(favourite.period, txs); + Map favMonthly = const {}; + var favLabels = const <_LabelSpend>[]; + Map> favByPeriod = const {}; + if (favourite.metric == OverviewMetricMode.flow) { + favMonthly = + _flowTotals(txs, meta, favPeriods, favourite.period); + } else { + final labels = _labelAggregates( + txs, + meta, + favPeriods, + favourite.period, + includedLabelIds: favourite.labelIds.toSet(), + flow: favourite.flow, + ); + favLabels = labels.spend; + favByPeriod = labels.amountByPeriod; + } + favourites[favourite.id] = _FavouriteAgg( + periods: favPeriods, + monthly: favMonthly, + labelSpend: favLabels, + labelAmountByPeriod: favByPeriod, + ); + await yieldUi(); + } + } + + return _OverviewAggSnapshot( + periods: periods, + monthly: monthly, + labelSpend: labelSpend, + labelAmountByPeriod: labelAmountByPeriod, + favourites: favourites, + ); + }, + ); + } + Widget _buildFavouritesBody( BuildContext context, BankController bank, @@ -329,6 +495,7 @@ class _OverviewScreenState extends State { : 'EUR'); final txs = bank.transactions; final displayed = prefs.displayedOverviewFavourites; + final agg = _work.value; return ListView( physics: const AlwaysScrollableScrollPhysics(), @@ -399,6 +566,7 @@ class _OverviewScreenState extends State { txs: txs, meta: meta, currency: currency, + precomputed: agg?.favourites[displayed[i].id], ), ], ], @@ -412,25 +580,30 @@ class _OverviewScreenState extends State { required List txs, required TxMetaController meta, required String currency, + _FavouriteAgg? precomputed, }) { - final periods = _chartPeriodsFor(favourite.period, txs); - Map monthly = const {}; - var labelSpend = const <_LabelSpend>[]; - Map> labelAmountByPeriod = const {}; + final periods = precomputed?.periods ?? + _chartPeriodsFor(favourite.period, txs); + Map monthly = precomputed?.monthly ?? const {}; + var labelSpend = precomputed?.labelSpend ?? const <_LabelSpend>[]; + Map> labelAmountByPeriod = + precomputed?.labelAmountByPeriod ?? const {}; - if (favourite.metric == OverviewMetricMode.flow) { - monthly = _flowTotals(txs, meta, periods, favourite.period); - } else { - final labels = _labelAggregates( - txs, - meta, - periods, - favourite.period, - includedLabelIds: favourite.labelIds.toSet(), - flow: favourite.flow, - ); - labelSpend = labels.spend; - labelAmountByPeriod = labels.amountByPeriod; + if (precomputed == null) { + if (favourite.metric == OverviewMetricMode.flow) { + monthly = _flowTotals(txs, meta, periods, favourite.period); + } else { + final labels = _labelAggregates( + txs, + meta, + periods, + favourite.period, + includedLabelIds: favourite.labelIds.toSet(), + flow: favourite.flow, + ); + labelSpend = labels.spend; + labelAmountByPeriod = labels.amountByPeriod; + } } final periodTitle = favourite.period == OverviewPeriodMode.monthly @@ -530,28 +703,31 @@ class _OverviewScreenState extends State { ? bank.transactions.first.currency : 'EUR'); final txs = bank.transactions; - final periods = _chartPeriods(txs); + final agg = _work.value; + final periods = agg?.periods ?? _chartPeriods(txs); final historyPeriods = [...periods.reversed]; - // Only aggregate what the active metric needs — toggling was scanning - // all txs 3–4 times and resolving full meta each time. - Map monthly = const {}; - var labelSpend = const <_LabelSpend>[]; - Map> labelAmountByPeriod = const {}; + // Prefer deferred aggregates; fall back only if snapshot is missing. + Map monthly = agg?.monthly ?? const {}; + var labelSpend = agg?.labelSpend ?? const <_LabelSpend>[]; + Map> labelAmountByPeriod = + agg?.labelAmountByPeriod ?? const {}; - if (_metric == OverviewMetricMode.flow) { - monthly = _flowTotals(txs, meta, periods, _period); - } else { - final labels = _labelAggregates( - txs, - meta, - periods, - _period, - includedLabelIds: _includedLabelIds, - flow: _labelFlow, - ); - labelSpend = labels.spend; - labelAmountByPeriod = labels.amountByPeriod; + if (agg == null) { + if (_metric == OverviewMetricMode.flow) { + monthly = _flowTotals(txs, meta, periods, _period); + } else { + final labels = _labelAggregates( + txs, + meta, + periods, + _period, + includedLabelIds: _includedLabelIds, + flow: _labelFlow, + ); + labelSpend = labels.spend; + labelAmountByPeriod = labels.amountByPeriod; + } } final summaryFlow = _metric == OverviewMetricMode.flow diff --git a/lib/widgets/home_filter_widget_card.dart b/lib/widgets/home_filter_widget_card.dart index 92fe688..e1e7521 100644 --- a/lib/widgets/home_filter_widget_card.dart +++ b/lib/widgets/home_filter_widget_card.dart @@ -23,6 +23,8 @@ class HomeFilterWidgetCard extends StatelessWidget { this.onToggleDisplay, this.onEdit, this.dragHandle, + this.precomputedStats, + this.precomputedBudget, }); final HomeFilterWidget widgetConfig; @@ -31,6 +33,10 @@ class HomeFilterWidgetCard extends StatelessWidget { final VoidCallback? onEdit; final Widget? dragHandle; + /// When set, skips scanning all transactions in build. + final TxFilterMetrics? precomputedStats; + final HomeBudgetMetrics? precomputedBudget; + void _openTransactions(BuildContext context) { final filter = widgetConfig.filter; Navigator.of(context).push( @@ -95,12 +101,14 @@ class HomeFilterWidgetCard extends StatelessWidget { currency: currency, transactions: bank.transactions, meta: meta, + precomputed: precomputedBudget, ) : _StatsBody( widgetConfig: widgetConfig, currency: currency, transactions: bank.transactions, meta: meta, + precomputed: precomputedStats, ), ), if (editing && onToggleDisplay != null) @@ -267,22 +275,25 @@ class _StatsBody extends StatelessWidget { required this.currency, required this.transactions, required this.meta, + this.precomputed, }); final HomeFilterWidget widgetConfig; final String currency; final List transactions; final TxMetaController meta; + final TxFilterMetrics? precomputed; @override Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; - final metrics = computeTxFilterMetrics( - all: transactions, - meta: meta, - filter: widgetConfig.filter, - ); + final metrics = precomputed ?? + computeTxFilterMetrics( + all: transactions, + meta: meta, + filter: widgetConfig.filter, + ); // Prefer a single hero "Spent" figure when that's the main metric. final spentOnly = widgetConfig.showAmountOut && @@ -383,22 +394,25 @@ class _BudgetBody extends StatelessWidget { required this.currency, required this.transactions, required this.meta, + this.precomputed, }); final HomeFilterWidget widgetConfig; final String currency; final List transactions; final TxMetaController meta; + final HomeBudgetMetrics? precomputed; @override Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; - final budget = computeHomeBudgetMetrics( - all: transactions, - meta: meta, - widget: widgetConfig, - ); + final budget = precomputed ?? + computeHomeBudgetMetrics( + all: transactions, + meta: meta, + widget: widgetConfig, + ); final progress = budget.progress.clamp(0.0, 1.0); final fillColor = budget.overBudget ? AppTheme.expense