5 Commits
Author SHA1 Message Date
davmar 22a535a6fd pass suggestions 2026-09-17 15:45:11 +02:00
davmar bd3bc5357e projected balance chart 2026-09-17 01:23:07 +02:00
davmar a23739d717 bar tap correct 2026-09-17 01:00:11 +02:00
davmar b62164dde5 spinner feedback to ui 2 2026-09-17 00:50:15 +02:00
davmar 5cd9a88682 spinner feedback to ui 2026-09-17 00:50:07 +02:00
7 changed files with 1347 additions and 373 deletions
+136 -9
View File
@@ -10,8 +10,10 @@ import '../providers/preferences_provider.dart';
import '../providers/tx_meta_provider.dart'; import '../providers/tx_meta_provider.dart';
import '../utils/app_theme.dart'; import '../utils/app_theme.dart';
import '../utils/connect_bank.dart'; import '../utils/connect_bank.dart';
import '../utils/deferred_tab_work.dart';
import '../utils/helpers.dart'; import '../utils/helpers.dart';
import '../utils/navigation.dart'; import '../utils/navigation.dart';
import '../utils/tx_filter_metrics.dart';
import '../widgets/empty_state.dart'; import '../widgets/empty_state.dart';
import '../widgets/home_filter_widget_card.dart'; import '../widgets/home_filter_widget_card.dart';
import '../widgets/home_widgets_sheet.dart'; import '../widgets/home_widgets_sheet.dart';
@@ -22,6 +24,18 @@ import '../widgets/skip_build_when_covered.dart';
import 'transaction_detail_screen.dart'; import 'transaction_detail_screen.dart';
import 'transactions_screen.dart'; import 'transactions_screen.dart';
class _HomeSnapshot {
const _HomeSnapshot({
required this.categorized,
required this.statsById,
required this.budgetById,
});
final int categorized;
final Map<String, TxFilterMetrics> statsById;
final Map<String, HomeBudgetMetrics> budgetById;
}
class HomeScreen extends StatefulWidget { class HomeScreen extends StatefulWidget {
const HomeScreen({super.key}); const HomeScreen({super.key});
@@ -33,16 +47,93 @@ class HomeScreen extends StatefulWidget {
class _HomeScreenState extends State<HomeScreen> { class _HomeScreenState extends State<HomeScreen> {
var _editingWidgets = false; var _editingWidgets = false;
final _work = DeferredTabWork<_HomeSnapshot>();
int _homeCacheKey({
required TxMetaController meta,
required BankController bank,
required List<HomeFilterWidget> 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Inactive IndexedStack tabs must not watch providers — otherwise every // Inactive IndexedStack tabs must not watch providers — otherwise every
// annotation save rebuilds Home while the user is on another tab. // 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(); return const SizedBox.expand();
} }
final bank = context.watch<BankController>(); final bank = context.watch<BankController>();
final meta = context.watch<TxMetaController>();
final prefs = context.watch<PreferencesController>();
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 = <String, TxFilterMetrics>{};
final budgets = <String, HomeBudgetMetrics>{};
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( return Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -54,7 +145,9 @@ class _HomeScreenState extends State<HomeScreen> {
onPressed: () => onPressed: () =>
setState(() => _editingWidgets = !_editingWidgets), setState(() => _editingWidgets = !_editingWidgets),
icon: Icon( icon: Icon(
_editingWidgets ? Icons.check : Icons.dashboard_customize_outlined, _editingWidgets
? Icons.check
: Icons.dashboard_customize_outlined,
), ),
), ),
...bankAppBarActions( ...bankAppBarActions(
@@ -67,9 +160,18 @@ class _HomeScreenState extends State<HomeScreen> {
body: RefreshIndicator( body: RefreshIndicator(
onRefresh: bank.refresh, onRefresh: bank.refresh,
child: ResponsiveBody( child: ResponsiveBody(
child: _HomeBody( child: showSpinner
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(height: 120),
LoadingView(message: 'Loading home…'),
],
)
: _HomeBody(
bank: bank, bank: bank,
editingWidgets: _editingWidgets, editingWidgets: _editingWidgets,
snapshot: _work.value,
), ),
), ),
), ),
@@ -81,10 +183,12 @@ class _HomeBody extends StatelessWidget {
const _HomeBody({ const _HomeBody({
required this.bank, required this.bank,
required this.editingWidgets, required this.editingWidgets,
required this.snapshot,
}); });
final BankController bank; final BankController bank;
final bool editingWidgets; final bool editingWidgets;
final _HomeSnapshot? snapshot;
void _openTransactions(BuildContext context) { void _openTransactions(BuildContext context) {
Navigator.of(context).push( Navigator.of(context).push(
@@ -141,7 +245,7 @@ class _HomeBody extends StatelessWidget {
final colors = theme.colorScheme; final colors = theme.colorScheme;
final meta = context.watch<TxMetaController>(); final meta = context.watch<TxMetaController>();
final recent = bank.transactions.take(5).toList(growable: false); final recent = bank.transactions.take(5).toList(growable: false);
final categorized = final categorized = snapshot?.categorized ??
meta.labeledTransactionCount(bank.transactions); meta.labeledTransactionCount(bank.transactions);
final widgets = editingWidgets final widgets = editingWidgets
? prefs.homeFilterWidgets ? prefs.homeFilterWidgets
@@ -319,6 +423,9 @@ class _HomeBody extends StatelessWidget {
child: HomeFilterWidgetCard( child: HomeFilterWidgetCard(
widgetConfig: widgetConfig, widgetConfig: widgetConfig,
editing: true, editing: true,
precomputedStats: snapshot?.statsById[widgetConfig.id],
precomputedBudget:
snapshot?.budgetById[widgetConfig.id],
onToggleDisplay: (display) => onToggleDisplay: (display) =>
prefs.setHomeFilterWidgetDisplay( prefs.setHomeFilterWidgetDisplay(
widgetConfig.id, widgetConfig.id,
@@ -347,7 +454,11 @@ class _HomeBody extends StatelessWidget {
}, },
) )
else else
..._packedHomeWidgetRows(widgets), ..._packedHomeWidgetRows(
widgets,
statsById: snapshot?.statsById,
budgetById: snapshot?.budgetById,
),
], ],
if (_showBuiltIn( if (_showBuiltIn(
editing: editingWidgets, editing: editingWidgets,
@@ -553,7 +664,11 @@ class _HomeWidgetSlot extends StatelessWidget {
} }
} }
List<Widget> _packedHomeWidgetRows(List<HomeFilterWidget> widgets) { List<Widget> _packedHomeWidgetRows(
List<HomeFilterWidget> widgets, {
Map<String, TxFilterMetrics>? statsById,
Map<String, HomeBudgetMetrics>? budgetById,
}) {
final rows = <Widget>[]; final rows = <Widget>[];
var i = 0; var i = 0;
while (i < widgets.length) { while (i < widgets.length) {
@@ -571,11 +686,19 @@ List<Widget> _packedHomeWidgetRows(List<HomeFilterWidget> widgets) {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Expanded( Expanded(
child: HomeFilterWidgetCard(widgetConfig: a), child: HomeFilterWidgetCard(
widgetConfig: a,
precomputedStats: statsById?[a.id],
precomputedBudget: budgetById?[a.id],
),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: HomeFilterWidgetCard(widgetConfig: b), child: HomeFilterWidgetCard(
widgetConfig: b,
precomputedStats: statsById?[b.id],
precomputedBudget: budgetById?[b.id],
),
), ),
], ],
), ),
@@ -590,7 +713,11 @@ List<Widget> _packedHomeWidgetRows(List<HomeFilterWidget> widgets) {
padding: const EdgeInsets.only(bottom: 10), padding: const EdgeInsets.only(bottom: 10),
child: _HomeWidgetSlot( child: _HomeWidgetSlot(
halfWidth: a.halfWidth, halfWidth: a.halfWidth,
child: HomeFilterWidgetCard(widgetConfig: a), child: HomeFilterWidgetCard(
widgetConfig: a,
precomputedStats: statsById?[a.id],
precomputedBudget: budgetById?[a.id],
),
), ),
), ),
); );
+13 -1
View File
@@ -2,6 +2,7 @@
library; library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pocketbase/pocketbase.dart'; import 'package:pocketbase/pocketbase.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -46,6 +47,8 @@ class _LoginScreenState extends State<LoginScreen> {
email: _emailController.text, email: _emailController.text,
password: _passwordController.text, password: _passwordController.text,
); );
// Lets iOS/Android password managers offer to save / confirm the login.
TextInput.finishAutofillContext();
if (!mounted) return; if (!mounted) return;
if (!widget.asRoot) { if (!widget.asRoot) {
Navigator.of(context).pop(true); Navigator.of(context).pop(true);
@@ -96,7 +99,8 @@ class _LoginScreenState extends State<LoginScreen> {
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Form( AutofillGroup(
child: Form(
key: _formKey, key: _formKey,
child: Column( child: Column(
children: [ children: [
@@ -106,6 +110,10 @@ class _LoginScreenState extends State<LoginScreen> {
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
autofocus: true, autofocus: true,
autofillHints: const [
AutofillHints.email,
AutofillHints.username,
],
validator: (value) { validator: (value) {
final text = value?.trim() ?? ''; final text = value?.trim() ?? '';
if (text.isEmpty) return 'Enter your email'; if (text.isEmpty) return 'Enter your email';
@@ -118,6 +126,9 @@ class _LoginScreenState extends State<LoginScreen> {
controller: _passwordController, controller: _passwordController,
obscureText: _obscurePassword, obscureText: _obscurePassword,
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.password],
enableSuggestions: false,
autocorrect: false,
onFieldSubmitted: (_) => _submit(), onFieldSubmitted: (_) => _submit(),
validator: (value) { validator: (value) {
if (value == null || value.isEmpty) { if (value == null || value.isEmpty) {
@@ -160,6 +171,7 @@ class _LoginScreenState extends State<LoginScreen> {
], ],
), ),
), ),
),
], ],
), ),
), ),
+567 -41
View File
@@ -1,6 +1,9 @@
/// Look ahead: projected balance from recurring templates through a date. /// Look ahead: projected balance from recurring templates through a date.
library; library;
import 'dart:async';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -16,6 +19,7 @@ import '../utils/look_ahead.dart';
import '../utils/navigation.dart'; import '../utils/navigation.dart';
import '../utils/tx_calendar.dart'; import '../utils/tx_calendar.dart';
import '../widgets/empty_state.dart'; import '../widgets/empty_state.dart';
import '../widgets/loading_view.dart';
import '../widgets/recurring_template_sheet.dart'; import '../widgets/recurring_template_sheet.dart';
import '../widgets/responsive_body.dart'; import '../widgets/responsive_body.dart';
import '../widgets/shell_tab_scope.dart'; import '../widgets/shell_tab_scope.dart';
@@ -23,6 +27,25 @@ import '../widgets/skip_build_when_covered.dart';
enum _PeriodMode { monthly, yearly } enum _PeriodMode { monthly, yearly }
class _ItemsSnapshot {
const _ItemsSnapshot({required this.key, required this.items});
final int key;
final List<RecurringItem> items;
}
class _BalancePoint {
const _BalancePoint({
required this.key,
required this.label,
required this.balance,
});
final DateTime key;
final String label;
final double balance;
}
class LookAheadScreen extends StatefulWidget { class LookAheadScreen extends StatefulWidget {
const LookAheadScreen({super.key}); const LookAheadScreen({super.key});
@@ -35,8 +58,10 @@ class LookAheadScreen extends StatefulWidget {
class _LookAheadScreenState extends State<LookAheadScreen> { class _LookAheadScreenState extends State<LookAheadScreen> {
var _period = _PeriodMode.monthly; var _period = _PeriodMode.monthly;
late DateTime _cursor; late DateTime _cursor;
List<RecurringItem>? _cachedItems; _ItemsSnapshot? _snapshot;
int? _itemsCacheKey; int? _loadingKey;
int _computeGen = 0;
var _showChart = false;
@override @override
void initState() { void initState() {
@@ -52,14 +77,15 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
/// Inclusive end date for the selected period. /// Inclusive end date for the selected period.
DateTime get _through { DateTime get _through {
if (_period == _PeriodMode.yearly) { // Chart view (and yearly list) always project through year-end.
if (_showChart || _period == _PeriodMode.yearly) {
return DateTime(_cursor.year, 12, 31); return DateTime(_cursor.year, 12, 31);
} }
return DateTime(_cursor.year, _cursor.month + 1, 0); return DateTime(_cursor.year, _cursor.month + 1, 0);
} }
String get _periodTitle { String get _periodTitle {
if (_period == _PeriodMode.yearly) return '${_cursor.year}'; if (_showChart || _period == _PeriodMode.yearly) return '${_cursor.year}';
return formatMonthYear(_cursor); return formatMonthYear(_cursor);
} }
@@ -68,28 +94,32 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
return DateTime(t.year + maxYears, t.month); return DateTime(t.year + maxYears, t.month);
} }
int _maxYear(int maxYears) => _today.year + maxYears;
bool get _navByYear => _showChart || _period == _PeriodMode.yearly;
bool _canGoBack() { bool _canGoBack() {
if (_period == _PeriodMode.yearly) { if (_navByYear) return _cursor.year > _today.year;
return _cursor.year > _today.year;
}
return monthStart(_cursor).isAfter(_minMonth); return monthStart(_cursor).isAfter(_minMonth);
} }
bool _canGoForward(int maxYears) { bool _canGoForward(int maxYears) {
if (_period == _PeriodMode.yearly) { if (_navByYear) return _cursor.year < _maxYear(maxYears);
return _cursor.year < _today.year + maxYears;
}
return monthStart(_cursor).isBefore(_maxMonthFor(maxYears)); return monthStart(_cursor).isBefore(_maxMonthFor(maxYears));
} }
void _clampCursor(int maxYears) { void _clampCursor(int maxYears) {
if (_period == _PeriodMode.yearly) { if (_navByYear) {
if (_cursor.year > _today.year + maxYears) { if (_cursor.year > _maxYear(maxYears)) {
_cursor = DateTime(_today.year + maxYears); _cursor = DateTime(_maxYear(maxYears));
}
if (_cursor.year < _today.year) {
_cursor = DateTime(_today.year);
} }
} else { } else {
final maxMonth = _maxMonthFor(maxYears); final maxMonth = _maxMonthFor(maxYears);
if (_cursor.isAfter(maxMonth)) _cursor = maxMonth; if (_cursor.isAfter(maxMonth)) _cursor = maxMonth;
if (_cursor.isBefore(_minMonth)) _cursor = _minMonth;
} }
} }
@@ -97,26 +127,22 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
if (mode == _period) return; if (mode == _period) return;
setState(() { setState(() {
_period = mode; _period = mode;
if (mode == _PeriodMode.yearly) { if (mode == _PeriodMode.yearly || _showChart) {
_cursor = DateTime(_cursor.year); _cursor = DateTime(_cursor.year);
if (_cursor.year < _today.year) _cursor = DateTime(_today.year); _clampCursor(maxYears);
if (_cursor.year > _today.year + maxYears) {
_cursor = DateTime(_today.year + maxYears);
}
} else { } else {
final month = DateTime(_cursor.year, _today.month); final month = DateTime(_cursor.year, _today.month);
_cursor = month.isBefore(_minMonth) ? _minMonth : monthStart(month); _cursor = month.isBefore(_minMonth) ? _minMonth : monthStart(month);
final maxMonth = _maxMonthFor(maxYears); _clampCursor(maxYears);
if (_cursor.isAfter(maxMonth)) _cursor = maxMonth;
} }
}); });
} }
void _go(int delta, int maxYears) { void _go(int delta, int maxYears) {
setState(() { setState(() {
if (_period == _PeriodMode.yearly) { if (_navByYear) {
final next = DateTime(_cursor.year + delta); final next = DateTime(_cursor.year + delta);
if (next.year < _today.year || next.year > _today.year + maxYears) { if (next.year < _today.year || next.year > _maxYear(maxYears)) {
return; return;
} }
_cursor = next; _cursor = next;
@@ -129,9 +155,149 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
}); });
} }
void _toggleChart(int maxYears) {
setState(() {
_showChart = !_showChart;
if (_showChart) {
_cursor = DateTime(_cursor.year);
} else if (_period == _PeriodMode.monthly) {
final month = DateTime(_cursor.year, _today.month);
_cursor = month.isBefore(_minMonth) ? _minMonth : monthStart(month);
}
_clampCursor(maxYears);
});
}
int _itemsCacheKey(TxMetaController meta, BankController bank) =>
Object.hash(meta.storeRevision, identityHashCode(bank.transactions));
/// Projected account balance at each month-end / year-end.
List<_BalancePoint> _balanceSeries({
required List<RecurringItem> items,
required double currentBalance,
required int maxYears,
}) {
final today = _today;
if (_period == _PeriodMode.monthly) {
final year = _cursor.year;
const months = [
'J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D',
];
final out = <_BalancePoint>[];
for (var m = 1; m <= 12; m++) {
final end = DateTime(year, m + 1, 0);
final balance = !end.isAfter(today)
? currentBalance
: predictBalance(
currentBalance: currentBalance,
from: today,
throughDate: end,
items: items,
);
out.add(
_BalancePoint(
key: DateTime(year, m),
label: months[m - 1],
balance: balance,
),
);
}
return out;
}
final startYear = _today.year;
final endYear = _maxYear(maxYears);
final out = <_BalancePoint>[];
for (var y = startYear; y <= endYear; y++) {
final end = DateTime(y, 12, 31);
final balance = !end.isAfter(today)
? currentBalance
: predictBalance(
currentBalance: currentBalance,
from: today,
throughDate: end,
items: items,
);
out.add(
_BalancePoint(
key: DateTime(y),
label: '$y',
balance: balance,
),
);
}
return out;
}
/// 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<RecurringTemplate>.of(meta.recurringTemplates);
final transactions = bank.transactions;
unawaited(() async {
// Let the spinner / tab switch paint before heavy work.
await Future<void>.delayed(Duration.zero);
if (!mounted || gen != _computeGen) return;
final items = <RecurringItem>[];
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<void>.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 @override
Widget build(BuildContext context) { 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 const SizedBox.expand();
} }
return SkipBuildWhenCovered( return SkipBuildWhenCovered(
@@ -165,22 +331,38 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
final currentAmount = balance?.amount ?? 0; final currentAmount = balance?.amount ?? 0;
final today = _today; final today = _today;
final through = _through; final through = _through;
final cacheKey = Object.hash( final cacheKey = _itemsCacheKey(meta, bank);
meta.storeRevision, if (_snapshot?.key != cacheKey && _loadingKey != cacheKey) {
identityHashCode(bank.transactions), WidgetsBinding.instance.addPostFrameCallback((_) {
); if (!mounted) return;
if (_cachedItems == null || _itemsCacheKey != cacheKey) { if (ShellTabScope.of(context) != LookAheadScreen.tabIndex) return;
_itemsCacheKey = cacheKey; _ensureItemsLoaded(cacheKey: cacheKey, meta: meta, bank: bank);
_cachedItems = recurringItemsFromTemplates( });
templates: meta.recurringTemplates, }
transactions: bank.transactions,
isInternalTransfer: (tx) => meta.metaFor(tx).internalTransfer, final snapshot = _snapshot;
labelIdsFor: meta.labelIdsFor, final ready = snapshot != null && snapshot.key == cacheKey;
labelName: (id) => meta.labelById(id)?.name ?? 'label',
if (!ready) {
return Scaffold(
appBar: AppBar(
title: const Text('Look ahead'),
actions: [
IconButton(
tooltip: _showChart ? 'Show list' : 'Show chart',
onPressed: () => _toggleChart(maxYears),
icon: Icon(
_showChart ? Icons.view_list_outlined : Icons.calendar_month,
),
),
...bankAppBarActions(context: context),
],
),
body: const LoadingView(message: 'Calculating look ahead…'),
); );
} }
final items = _cachedItems!;
final items = snapshot.items;
final projected = projectedOccurrences( final projected = projectedOccurrences(
items: items, items: items,
from: today, from: today,
@@ -216,7 +398,16 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Look ahead'), title: const Text('Look ahead'),
actions: bankAppBarActions(context: context), actions: [
IconButton(
tooltip: _showChart ? 'Show list' : 'Show chart',
onPressed: () => _toggleChart(maxYears),
icon: Icon(
_showChart ? Icons.view_list_outlined : Icons.calendar_month,
),
),
...bankAppBarActions(context: context),
],
), ),
body: ResponsiveBody( body: ResponsiveBody(
child: ListView( child: ListView(
@@ -234,10 +425,8 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
canGoForward: _canGoForward(maxYears), canGoForward: _canGoForward(maxYears),
onBack: () => _go(-1, maxYears), onBack: () => _go(-1, maxYears),
onForward: () => _go(1, maxYears), onForward: () => _go(1, maxYears),
backTooltip: backTooltip: _navByYear ? 'Previous year' : 'Previous month',
_period == _PeriodMode.yearly ? 'Previous year' : 'Previous month', forwardTooltip: _navByYear ? 'Next year' : 'Next month',
forwardTooltip:
_period == _PeriodMode.yearly ? 'Next year' : 'Next month',
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Material( Material(
@@ -321,6 +510,28 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
), ),
], ],
), ),
if (_showChart) ...[
const SizedBox(height: 16),
_LookAheadBalanceChart(
points: _balanceSeries(
items: items,
currentBalance: currentAmount,
maxYears: maxYears,
),
period: _period,
currency: currency,
),
const SizedBox(height: 8),
Text(
_period == _PeriodMode.yearly
? 'Projected account balance by year'
: 'Projected account balance by month',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
] else ...[
const SizedBox(height: 20), const SizedBox(height: 20),
Row( Row(
children: [ children: [
@@ -362,6 +573,11 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
], ],
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
_SavingsCard(
monthlySavings: monthlyIncome - monthlyExpense,
currency: currency,
),
const SizedBox(height: 10),
if (items.isEmpty) if (items.isEmpty)
const EmptyState( const EmptyState(
icon: Icons.event_repeat_outlined, icon: Icons.event_repeat_outlined,
@@ -398,12 +614,322 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
), ),
), ),
], ],
],
), ),
), ),
); );
} }
} }
class _LookAheadBalanceChart extends StatelessWidget {
const _LookAheadBalanceChart({
required this.points,
required this.period,
required this.currency,
});
final List<_BalancePoint> points;
final _PeriodMode period;
final String currency;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
if (points.isEmpty) {
return Card(
color: colors.surfaceContainer,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
'No projected balance for this period',
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
),
);
}
var minY = points.first.balance;
var maxY = points.first.balance;
for (final p in points) {
if (p.balance < minY) minY = p.balance;
if (p.balance > maxY) maxY = p.balance;
}
if (minY == maxY) {
minY -= 1;
maxY += 1;
}
final pad = (maxY - minY).abs() * 0.12;
final safeMin = minY - pad;
final safeMax = maxY + pad;
final interval = ((safeMax - safeMin) / 3).abs();
final safeInterval = interval <= 0 ? 1.0 : interval;
final spots = [
for (var i = 0; i < points.length; i++)
FlSpot(i.toDouble(), points[i].balance),
];
return Card(
color: colors.surfaceContainer,
elevation: 0,
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Projected balance',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 12),
SizedBox(
height: 220,
child: LineChart(
LineChartData(
minX: 0,
maxX: (points.length - 1).toDouble(),
minY: safeMin,
maxY: safeMax,
lineTouchData: LineTouchData(
handleBuiltInTouches: true,
touchTooltipData: LineTouchTooltipData(
getTooltipItems: (touched) {
return [
for (final spot in touched)
if (spot.x.round() >= 0 &&
spot.x.round() < points.length)
LineTooltipItem(
'${points[spot.x.round()].label}\n'
'${formatMoney(points[spot.x.round()].balance, currency)}',
theme.textTheme.labelMedium!.copyWith(
color: colors.onInverseSurface,
fontWeight: FontWeight.w600,
),
),
];
},
),
),
titlesData: FlTitlesData(
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 44,
interval: safeInterval,
getTitlesWidget: (value, meta) {
if ((value - safeMax).abs() < 0.01 ||
(value - safeMin).abs() < 0.01) {
return const SizedBox.shrink();
}
return Text(
_compactMoney(value),
style: theme.textTheme.labelSmall,
);
},
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 28,
interval: 1,
getTitlesWidget: (value, meta) {
final index = value.round();
if (index < 0 || index >= points.length) {
return const SizedBox.shrink();
}
if (period == _PeriodMode.yearly &&
points.length > 8 &&
index % 2 == 1) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
points[index].label,
style: theme.textTheme.labelSmall,
),
);
},
),
),
),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: safeInterval,
getDrawingHorizontalLine: (value) => FlLine(
color: colors.outlineVariant.withValues(alpha: 0.35),
strokeWidth: 1,
dashArray: const [4, 4],
),
),
borderData: FlBorderData(show: false),
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: true,
curveSmoothness: 0.18,
color: colors.primary,
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, bar, index) {
return FlDotCirclePainter(
radius: 3.5,
color: colors.primary,
strokeWidth: 0,
);
},
),
belowBarData: BarAreaData(
show: true,
color: colors.primary.withValues(alpha: 0.14),
),
),
],
),
duration: Duration.zero,
),
),
],
),
),
);
}
}
class _SavingsCard extends StatelessWidget {
const _SavingsCard({
required this.monthlySavings,
required this.currency,
});
final double monthlySavings;
final String currency;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final yearly = monthlySavings * 12;
final positive = monthlySavings >= 0;
final tone = positive ? AppTheme.accent : AppTheme.expense;
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: tone.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.savings_outlined, color: tone, size: 22),
),
const SizedBox(width: 12),
Expanded(
child: _SavingsMetric(
label: 'Monthly savings',
amount: formatMoney(monthlySavings, currency),
color: tone,
),
),
const SizedBox(width: 12),
Expanded(
child: _SavingsMetric(
label: 'Yearly savings',
amount: formatMoney(yearly, currency),
color: tone,
alignEnd: true,
),
),
],
),
),
);
}
}
class _SavingsMetric extends StatelessWidget {
const _SavingsMetric({
required this.label,
required this.amount,
required this.color,
this.alignEnd = false,
});
final String label;
final String amount;
final Color color;
final bool alignEnd;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
return Column(
crossAxisAlignment:
alignEnd ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
Text(
label.toUpperCase(),
style: theme.textTheme.labelSmall?.copyWith(
letterSpacing: 0.6,
fontWeight: FontWeight.w600,
color: colors.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Text(
amount,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
color: color,
),
),
],
);
}
}
String _compactMoney(double value) {
final abs = value.abs();
if (abs >= 1000000) return '${(value / 1000000).toStringAsFixed(1)}M';
if (abs >= 1000) return '${(value / 1000).toStringAsFixed(1)}k';
if (abs >= 100) return value.toStringAsFixed(0);
return value.toStringAsFixed(abs < 10 && abs > 0 ? 1 : 0);
}
class _PeriodToolbar extends StatelessWidget { class _PeriodToolbar extends StatelessWidget {
const _PeriodToolbar({ const _PeriodToolbar({
required this.period, required this.period,
+271 -38
View File
@@ -12,6 +12,7 @@ import '../providers/bank_provider.dart';
import '../providers/preferences_provider.dart'; import '../providers/preferences_provider.dart';
import '../providers/tx_meta_provider.dart'; import '../providers/tx_meta_provider.dart';
import '../utils/app_theme.dart'; import '../utils/app_theme.dart';
import '../utils/deferred_tab_work.dart';
import '../utils/helpers.dart'; import '../utils/helpers.dart';
import '../utils/navigation.dart'; import '../utils/navigation.dart';
import '../utils/tx_calendar.dart'; import '../utils/tx_calendar.dart';
@@ -28,6 +29,36 @@ import 'transactions_screen.dart';
const _kIncomeColor = AppTheme.accent; const _kIncomeColor = AppTheme.accent;
const _kExpenseColor = AppTheme.expense; const _kExpenseColor = AppTheme.expense;
class _FavouriteAgg {
const _FavouriteAgg({
required this.periods,
required this.monthly,
required this.labelSpend,
required this.labelAmountByPeriod,
});
final List<DateTime> periods;
final Map<DateTime, _MonthBucket> monthly;
final List<_LabelSpend> labelSpend;
final Map<DateTime, Map<String, double>> labelAmountByPeriod;
}
class _OverviewAggSnapshot {
const _OverviewAggSnapshot({
required this.periods,
required this.monthly,
required this.labelSpend,
required this.labelAmountByPeriod,
required this.favourites,
});
final List<DateTime> periods;
final Map<DateTime, _MonthBucket> monthly;
final List<_LabelSpend> labelSpend;
final Map<DateTime, Map<String, double>> labelAmountByPeriod;
final Map<String, _FavouriteAgg> favourites;
}
class OverviewScreen extends StatefulWidget { class OverviewScreen extends StatefulWidget {
const OverviewScreen({super.key}); const OverviewScreen({super.key});
@@ -59,6 +90,7 @@ class _OverviewScreenState extends State<OverviewScreen> {
/// Favourites charts view (vs full overview with history). /// Favourites charts view (vs full overview with history).
bool _showFavouritesView = false; bool _showFavouritesView = false;
var _favouritesDefaultApplied = false; var _favouritesDefaultApplied = false;
final _work = DeferredTabWork<_OverviewAggSnapshot>();
@override @override
void initState() { void initState() {
@@ -247,7 +279,15 @@ class _OverviewScreenState extends State<OverviewScreen> {
@override @override
Widget build(BuildContext context) { 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(); return const SizedBox.expand();
} }
@@ -268,6 +308,41 @@ class _OverviewScreenState extends State<OverviewScreen> {
}); });
} }
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( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(_showFavouritesView ? 'Favourites' : 'Overview'), title: Text(_showFavouritesView ? 'Favourites' : 'Overview'),
@@ -288,7 +363,15 @@ class _OverviewScreenState extends State<OverviewScreen> {
body: RefreshIndicator( body: RefreshIndicator(
onRefresh: bank.refresh, onRefresh: bank.refresh,
child: ResponsiveBody( child: ResponsiveBody(
child: SkipBuildWhenCovered( child: showSpinner
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(height: 120),
LoadingView(message: 'Loading overview…'),
],
)
: SkipBuildWhenCovered(
builder: (context) => _showFavouritesView builder: (context) => _showFavouritesView
? _buildFavouritesBody(context, bank, meta, prefs) ? _buildFavouritesBody(context, bank, meta, prefs)
: _buildBody(context, bank, meta, prefs), : _buildBody(context, bank, meta, prefs),
@@ -298,6 +381,89 @@ class _OverviewScreenState extends State<OverviewScreen> {
); );
} }
void _scheduleOverviewCompute({
required Object key,
required BankController bank,
required TxMetaController meta,
required List<OverviewFavourite> 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<DateTime, _MonthBucket> monthly = const {};
var labelSpend = const <_LabelSpend>[];
Map<DateTime, Map<String, double>> 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 = <String, _FavouriteAgg>{};
if (_showFavouritesView) {
for (var i = 0; i < displayedFavs.length; i++) {
final favourite = displayedFavs[i];
final favPeriods = _chartPeriodsFor(favourite.period, txs);
Map<DateTime, _MonthBucket> favMonthly = const {};
var favLabels = const <_LabelSpend>[];
Map<DateTime, Map<String, double>> 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( Widget _buildFavouritesBody(
BuildContext context, BuildContext context,
BankController bank, BankController bank,
@@ -329,6 +495,7 @@ class _OverviewScreenState extends State<OverviewScreen> {
: 'EUR'); : 'EUR');
final txs = bank.transactions; final txs = bank.transactions;
final displayed = prefs.displayedOverviewFavourites; final displayed = prefs.displayedOverviewFavourites;
final agg = _work.value;
return ListView( return ListView(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
@@ -399,6 +566,7 @@ class _OverviewScreenState extends State<OverviewScreen> {
txs: txs, txs: txs,
meta: meta, meta: meta,
currency: currency, currency: currency,
precomputed: agg?.favourites[displayed[i].id],
), ),
], ],
], ],
@@ -412,12 +580,16 @@ class _OverviewScreenState extends State<OverviewScreen> {
required List<BankTransaction> txs, required List<BankTransaction> txs,
required TxMetaController meta, required TxMetaController meta,
required String currency, required String currency,
_FavouriteAgg? precomputed,
}) { }) {
final periods = _chartPeriodsFor(favourite.period, txs); final periods = precomputed?.periods ??
Map<DateTime, _MonthBucket> monthly = const {}; _chartPeriodsFor(favourite.period, txs);
var labelSpend = const <_LabelSpend>[]; Map<DateTime, _MonthBucket> monthly = precomputed?.monthly ?? const {};
Map<DateTime, Map<String, double>> labelAmountByPeriod = const {}; var labelSpend = precomputed?.labelSpend ?? const <_LabelSpend>[];
Map<DateTime, Map<String, double>> labelAmountByPeriod =
precomputed?.labelAmountByPeriod ?? const {};
if (precomputed == null) {
if (favourite.metric == OverviewMetricMode.flow) { if (favourite.metric == OverviewMetricMode.flow) {
monthly = _flowTotals(txs, meta, periods, favourite.period); monthly = _flowTotals(txs, meta, periods, favourite.period);
} else { } else {
@@ -432,6 +604,7 @@ class _OverviewScreenState extends State<OverviewScreen> {
labelSpend = labels.spend; labelSpend = labels.spend;
labelAmountByPeriod = labels.amountByPeriod; labelAmountByPeriod = labels.amountByPeriod;
} }
}
final periodTitle = favourite.period == OverviewPeriodMode.monthly final periodTitle = favourite.period == OverviewPeriodMode.monthly
? '${_cursor.year}' ? '${_cursor.year}'
@@ -530,15 +703,17 @@ class _OverviewScreenState extends State<OverviewScreen> {
? bank.transactions.first.currency ? bank.transactions.first.currency
: 'EUR'); : 'EUR');
final txs = bank.transactions; final txs = bank.transactions;
final periods = _chartPeriods(txs); final agg = _work.value;
final periods = agg?.periods ?? _chartPeriods(txs);
final historyPeriods = [...periods.reversed]; final historyPeriods = [...periods.reversed];
// Only aggregate what the active metric needs — toggling was scanning // Prefer deferred aggregates; fall back only if snapshot is missing.
// all txs 3–4 times and resolving full meta each time. Map<DateTime, _MonthBucket> monthly = agg?.monthly ?? const {};
Map<DateTime, _MonthBucket> monthly = const {}; var labelSpend = agg?.labelSpend ?? const <_LabelSpend>[];
var labelSpend = const <_LabelSpend>[]; Map<DateTime, Map<String, double>> labelAmountByPeriod =
Map<DateTime, Map<String, double>> labelAmountByPeriod = const {}; agg?.labelAmountByPeriod ?? const {};
if (agg == null) {
if (_metric == OverviewMetricMode.flow) { if (_metric == OverviewMetricMode.flow) {
monthly = _flowTotals(txs, meta, periods, _period); monthly = _flowTotals(txs, meta, periods, _period);
} else { } else {
@@ -553,6 +728,7 @@ class _OverviewScreenState extends State<OverviewScreen> {
labelSpend = labels.spend; labelSpend = labels.spend;
labelAmountByPeriod = labels.amountByPeriod; labelAmountByPeriod = labels.amountByPeriod;
} }
}
final summaryFlow = _metric == OverviewMetricMode.flow final summaryFlow = _metric == OverviewMetricMode.flow
? _sumBuckets(monthly.values) ? _sumBuckets(monthly.values)
@@ -781,10 +957,12 @@ class _OverviewScreenState extends State<OverviewScreen> {
final resolvedLabelIds = labelId != null final resolvedLabelIds = labelId != null
? {labelId} ? {labelId}
: (labelIds ?? : (labelIds ??
{ (_metric == OverviewMetricMode.labels
? {
// Open with the same labels currently shown on the chart. // Open with the same labels currently shown on the chart.
for (final id in _includedLabelIds) id, for (final id in _includedLabelIds) id,
}); }
: <String>{}));
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute<void>( MaterialPageRoute<void>(
builder: (_) => TransactionsScreen( builder: (_) => TransactionsScreen(
@@ -792,7 +970,8 @@ class _OverviewScreenState extends State<OverviewScreen> {
initialLabelIds: resolvedLabelIds, initialLabelIds: resolvedLabelIds,
initialLabelMatch: LabelMatchMode.or, initialLabelMatch: LabelMatchMode.or,
initialFlow: flow, initialFlow: flow,
initialSearchInMonthOnly: !yearly && resolvedLabelIds.isNotEmpty, // A specific bar/period should always scope to that month (or year).
initialSearchInMonthOnly: !yearly,
initialYear: yearly ? period.year : null, initialYear: yearly ? period.year : null,
), ),
), ),
@@ -1439,10 +1618,7 @@ class _ChartCard extends StatelessWidget {
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(borderRadius: radius), shape: RoundedRectangleBorder(borderRadius: radius),
// Whole card opens the chart filter; nested buttons/legend keep their own taps. // Header/legend open the chart-wide filter; bars handle their own taps.
child: InkWell(
onTap: onChartTap,
borderRadius: radius,
child: Padding( child: Padding(
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
compact ? 12 : 14, compact ? 12 : 14,
@@ -1450,6 +1626,13 @@ class _ChartCard extends StatelessWidget {
compact ? 10 : 14, compact ? 10 : 14,
compact ? 12 : 16, compact ? 12 : 16,
), ),
child: Column(
children: [
Material(
color: Colors.transparent,
child: InkWell(
onTap: onChartTap,
borderRadius: BorderRadius.circular(12),
child: Column( child: Column(
children: [ children: [
if (compact) ...[ if (compact) ...[
@@ -1598,6 +1781,10 @@ class _ChartCard extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
], ],
], ],
],
),
),
),
SizedBox( SizedBox(
height: chartHeight, height: chartHeight,
child: metric == OverviewMetricMode.flow child: metric == OverviewMetricMode.flow
@@ -1624,7 +1811,6 @@ class _ChartCard extends StatelessWidget {
], ],
), ),
), ),
),
); );
} }
} }
@@ -1682,17 +1868,37 @@ class _FlowBars extends StatelessWidget {
); );
} }
return GestureDetector( return BarChart(
behavior: HitTestBehavior.opaque,
onTap: onChartTap,
child: IgnorePointer(
// fl_chart otherwise owns the gesture arena and empty taps go nowhere.
// Tap opens this chart's filters; legend / history still open columns.
child: BarChart(
BarChartData( BarChartData(
maxY: safeMax, maxY: safeMax,
groupsSpace: period == OverviewPeriodMode.yearly ? 16 : 10, groupsSpace: period == OverviewPeriodMode.yearly ? 16 : 10,
barTouchData: const BarTouchData(enabled: false), barTouchData: BarTouchData(
handleBuiltInTouches: false,
touchTooltipData: BarTouchTooltipData(
getTooltipItem: (group, groupIndex, rod, rodIndex) => null,
),
touchCallback: (event, response) {
if (event is! FlTapUpEvent) return;
final spot = response?.spot;
if (spot == null) {
onChartTap();
return;
}
final index = spot.touchedBarGroupIndex;
if (index < 0 || index >= months.length) {
onChartTap();
return;
}
final rod = spot.touchedRodDataIndex;
// Rod 0 = expense, rod 1 = income (see barGroups below).
final flow = rod == 0
? TxFlowFilter.expense
: rod == 1
? TxFlowFilter.income
: null;
onMonthTap(months[index], flow: flow);
},
),
titlesData: FlTitlesData( titlesData: FlTitlesData(
topTitles: topTitles:
const AxisTitles(sideTitles: SideTitles(showTitles: false)), const AxisTitles(sideTitles: SideTitles(showTitles: false)),
@@ -1778,8 +1984,6 @@ class _FlowBars extends StatelessWidget {
], ],
), ),
duration: Duration.zero, duration: Duration.zero,
),
),
); );
} }
} }
@@ -1989,16 +2193,47 @@ class _LabelMonthBars extends StatelessWidget {
final axisInterval = ((safeMax - safeMin) / 3).abs(); final axisInterval = ((safeMax - safeMin) / 3).abs();
final interval = axisInterval <= 0 ? 1.0 : axisInterval; final interval = axisInterval <= 0 ? 1.0 : axisInterval;
return GestureDetector( return BarChart(
behavior: HitTestBehavior.opaque,
onTap: onChartTap,
child: IgnorePointer(
child: BarChart(
BarChartData( BarChartData(
minY: safeMin, minY: safeMin,
maxY: safeMax, maxY: safeMax,
groupsSpace: period == OverviewPeriodMode.yearly ? 16 : 10, groupsSpace: period == OverviewPeriodMode.yearly ? 16 : 10,
barTouchData: const BarTouchData(enabled: false), barTouchData: BarTouchData(
handleBuiltInTouches: false,
touchTooltipData: BarTouchTooltipData(
getTooltipItem: (group, groupIndex, rod, rodIndex) => null,
),
touchCallback: (event, response) {
if (event is! FlTapUpEvent) return;
final spot = response?.spot;
if (spot == null) {
onChartTap();
return;
}
final index = spot.touchedBarGroupIndex;
if (index < 0 || index >= months.length) {
onChartTap();
return;
}
final month = months[index];
if (stacked) {
final stackIndex = spot.touchedStackItemIndex;
if (stackIndex >= 0 && stackIndex < topLabels.length) {
onMonthTap(month, labelId: topLabels[stackIndex].label.id);
return;
}
// "Other" segment or whole column → that period with chart labels.
onMonthTap(month);
return;
}
final rod = spot.touchedRodDataIndex;
if (rod >= 0 && rod < topLabels.length) {
onMonthTap(month, labelId: topLabels[rod].label.id);
return;
}
onMonthTap(month);
},
),
titlesData: FlTitlesData( titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: rightTitles:
@@ -2071,8 +2306,6 @@ class _LabelMonthBars extends StatelessWidget {
], ],
), ),
duration: Duration.zero, duration: Duration.zero,
),
),
); );
} }
} }
+50
View File
@@ -0,0 +1,50 @@
/// Schedule heavy tab work off the first paint, yielding so nav stays responsive.
library;
import 'dart:async';
/// Runs [compute] after a frame, cancellable via [cancel] / generation bumps.
class DeferredTabWork<T> {
int _gen = 0;
Object? _loadingKey;
T? value;
Object? valueKey;
bool readyFor(Object key) => value != null && valueKey == key;
bool get isLoading => _loadingKey != null;
void cancel() {
_gen++;
_loadingKey = null;
}
/// Starts work for [key] unless already ready or in flight for that key.
void ensure({
required Object key,
required Future<T> Function(Future<void> Function() yieldUi) compute,
required void Function() onUpdate,
required bool Function() mounted,
}) {
if (readyFor(key) || _loadingKey == key) return;
_loadingKey = key;
final gen = ++_gen;
unawaited(() async {
await Future<void>.delayed(Duration.zero);
if (!mounted() || gen != _gen) return;
Future<void> yieldUi() async {
await Future<void>.delayed(Duration.zero);
}
final result = await compute(yieldUi);
if (!mounted() || gen != _gen) return;
value = result;
valueKey = key;
if (_loadingKey == key) _loadingKey = null;
onUpdate();
}());
}
}
+12
View File
@@ -14,6 +14,10 @@ class AppTextField extends StatelessWidget {
this.keyboardType, this.keyboardType,
this.validator, this.validator,
this.autofocus = false, this.autofocus = false,
this.autofillHints,
this.obscureText = false,
this.enableSuggestions,
this.autocorrect,
}); });
final TextEditingController controller; final TextEditingController controller;
@@ -24,6 +28,10 @@ class AppTextField extends StatelessWidget {
final TextInputType? keyboardType; final TextInputType? keyboardType;
final String? Function(String?)? validator; final String? Function(String?)? validator;
final bool autofocus; final bool autofocus;
final Iterable<String>? autofillHints;
final bool obscureText;
final bool? enableSuggestions;
final bool? autocorrect;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -31,8 +39,12 @@ class AppTextField extends StatelessWidget {
controller: controller, controller: controller,
maxLines: maxLines, maxLines: maxLines,
autofocus: autofocus, autofocus: autofocus,
obscureText: obscureText,
textInputAction: textInputAction, textInputAction: textInputAction,
keyboardType: keyboardType, keyboardType: keyboardType,
autofillHints: autofillHints,
enableSuggestions: enableSuggestions ?? !obscureText,
autocorrect: autocorrect ?? !obscureText,
validator: validator, validator: validator,
decoration: InputDecoration( decoration: InputDecoration(
labelText: label, labelText: label,
+16 -2
View File
@@ -23,6 +23,8 @@ class HomeFilterWidgetCard extends StatelessWidget {
this.onToggleDisplay, this.onToggleDisplay,
this.onEdit, this.onEdit,
this.dragHandle, this.dragHandle,
this.precomputedStats,
this.precomputedBudget,
}); });
final HomeFilterWidget widgetConfig; final HomeFilterWidget widgetConfig;
@@ -31,6 +33,10 @@ class HomeFilterWidgetCard extends StatelessWidget {
final VoidCallback? onEdit; final VoidCallback? onEdit;
final Widget? dragHandle; final Widget? dragHandle;
/// When set, skips scanning all transactions in build.
final TxFilterMetrics? precomputedStats;
final HomeBudgetMetrics? precomputedBudget;
void _openTransactions(BuildContext context) { void _openTransactions(BuildContext context) {
final filter = widgetConfig.filter; final filter = widgetConfig.filter;
Navigator.of(context).push( Navigator.of(context).push(
@@ -95,12 +101,14 @@ class HomeFilterWidgetCard extends StatelessWidget {
currency: currency, currency: currency,
transactions: bank.transactions, transactions: bank.transactions,
meta: meta, meta: meta,
precomputed: precomputedBudget,
) )
: _StatsBody( : _StatsBody(
widgetConfig: widgetConfig, widgetConfig: widgetConfig,
currency: currency, currency: currency,
transactions: bank.transactions, transactions: bank.transactions,
meta: meta, meta: meta,
precomputed: precomputedStats,
), ),
), ),
if (editing && onToggleDisplay != null) if (editing && onToggleDisplay != null)
@@ -267,18 +275,21 @@ class _StatsBody extends StatelessWidget {
required this.currency, required this.currency,
required this.transactions, required this.transactions,
required this.meta, required this.meta,
this.precomputed,
}); });
final HomeFilterWidget widgetConfig; final HomeFilterWidget widgetConfig;
final String currency; final String currency;
final List<BankTransaction> transactions; final List<BankTransaction> transactions;
final TxMetaController meta; final TxMetaController meta;
final TxFilterMetrics? precomputed;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colors = theme.colorScheme; final colors = theme.colorScheme;
final metrics = computeTxFilterMetrics( final metrics = precomputed ??
computeTxFilterMetrics(
all: transactions, all: transactions,
meta: meta, meta: meta,
filter: widgetConfig.filter, filter: widgetConfig.filter,
@@ -383,18 +394,21 @@ class _BudgetBody extends StatelessWidget {
required this.currency, required this.currency,
required this.transactions, required this.transactions,
required this.meta, required this.meta,
this.precomputed,
}); });
final HomeFilterWidget widgetConfig; final HomeFilterWidget widgetConfig;
final String currency; final String currency;
final List<BankTransaction> transactions; final List<BankTransaction> transactions;
final TxMetaController meta; final TxMetaController meta;
final HomeBudgetMetrics? precomputed;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colors = theme.colorScheme; final colors = theme.colorScheme;
final budget = computeHomeBudgetMetrics( final budget = precomputed ??
computeHomeBudgetMetrics(
all: transactions, all: transactions,
meta: meta, meta: meta,
widget: widgetConfig, widget: widgetConfig,