Compare commits
3
Commits
e1881acc18
...
v2.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a23739d717 | ||
|
|
b62164dde5 | ||
|
|
5cd9a88682 |
+139
-12
@@ -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,10 +160,19 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
body: RefreshIndicator(
|
body: RefreshIndicator(
|
||||||
onRefresh: bank.refresh,
|
onRefresh: bank.refresh,
|
||||||
child: ResponsiveBody(
|
child: ResponsiveBody(
|
||||||
child: _HomeBody(
|
child: showSpinner
|
||||||
bank: bank,
|
? ListView(
|
||||||
editingWidgets: _editingWidgets,
|
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({
|
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],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
/// 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:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
@@ -16,6 +18,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 +26,13 @@ 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 LookAheadScreen extends StatefulWidget {
|
class LookAheadScreen extends StatefulWidget {
|
||||||
const LookAheadScreen({super.key});
|
const LookAheadScreen({super.key});
|
||||||
|
|
||||||
@@ -35,8 +45,9 @@ 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;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -129,9 +140,78 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<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 +245,29 @@ 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: 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,
|
||||||
|
|||||||
+410
-177
@@ -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,16 +363,107 @@ class _OverviewScreenState extends State<OverviewScreen> {
|
|||||||
body: RefreshIndicator(
|
body: RefreshIndicator(
|
||||||
onRefresh: bank.refresh,
|
onRefresh: bank.refresh,
|
||||||
child: ResponsiveBody(
|
child: ResponsiveBody(
|
||||||
child: SkipBuildWhenCovered(
|
child: showSpinner
|
||||||
builder: (context) => _showFavouritesView
|
? ListView(
|
||||||
? _buildFavouritesBody(context, bank, meta, prefs)
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
: _buildBody(context, bank, meta, prefs),
|
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<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,25 +580,30 @@ 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 (favourite.metric == OverviewMetricMode.flow) {
|
if (precomputed == null) {
|
||||||
monthly = _flowTotals(txs, meta, periods, favourite.period);
|
if (favourite.metric == OverviewMetricMode.flow) {
|
||||||
} else {
|
monthly = _flowTotals(txs, meta, periods, favourite.period);
|
||||||
final labels = _labelAggregates(
|
} else {
|
||||||
txs,
|
final labels = _labelAggregates(
|
||||||
meta,
|
txs,
|
||||||
periods,
|
meta,
|
||||||
favourite.period,
|
periods,
|
||||||
includedLabelIds: favourite.labelIds.toSet(),
|
favourite.period,
|
||||||
flow: favourite.flow,
|
includedLabelIds: favourite.labelIds.toSet(),
|
||||||
);
|
flow: favourite.flow,
|
||||||
labelSpend = labels.spend;
|
);
|
||||||
labelAmountByPeriod = labels.amountByPeriod;
|
labelSpend = labels.spend;
|
||||||
|
labelAmountByPeriod = labels.amountByPeriod;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final periodTitle = favourite.period == OverviewPeriodMode.monthly
|
final periodTitle = favourite.period == OverviewPeriodMode.monthly
|
||||||
@@ -530,28 +703,31 @@ 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 (_metric == OverviewMetricMode.flow) {
|
if (agg == null) {
|
||||||
monthly = _flowTotals(txs, meta, periods, _period);
|
if (_metric == OverviewMetricMode.flow) {
|
||||||
} else {
|
monthly = _flowTotals(txs, meta, periods, _period);
|
||||||
final labels = _labelAggregates(
|
} else {
|
||||||
txs,
|
final labels = _labelAggregates(
|
||||||
meta,
|
txs,
|
||||||
periods,
|
meta,
|
||||||
_period,
|
periods,
|
||||||
includedLabelIds: _includedLabelIds,
|
_period,
|
||||||
flow: _labelFlow,
|
includedLabelIds: _includedLabelIds,
|
||||||
);
|
flow: _labelFlow,
|
||||||
labelSpend = labels.spend;
|
);
|
||||||
labelAmountByPeriod = labels.amountByPeriod;
|
labelSpend = labels.spend;
|
||||||
|
labelAmountByPeriod = labels.amountByPeriod;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final summaryFlow = _metric == OverviewMetricMode.flow
|
final summaryFlow = _metric == OverviewMetricMode.flow
|
||||||
@@ -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.
|
? {
|
||||||
for (final id in _includedLabelIds) id,
|
// Open with the same labels currently shown on the chart.
|
||||||
});
|
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,19 +1618,23 @@ 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(
|
child: Padding(
|
||||||
onTap: onChartTap,
|
padding: EdgeInsets.fromLTRB(
|
||||||
borderRadius: radius,
|
compact ? 12 : 14,
|
||||||
child: Padding(
|
compact ? 10 : 12,
|
||||||
padding: EdgeInsets.fromLTRB(
|
compact ? 10 : 14,
|
||||||
compact ? 12 : 14,
|
compact ? 12 : 16,
|
||||||
compact ? 10 : 12,
|
),
|
||||||
compact ? 10 : 14,
|
child: Column(
|
||||||
compact ? 12 : 16,
|
children: [
|
||||||
),
|
Material(
|
||||||
child: Column(
|
color: Colors.transparent,
|
||||||
children: [
|
child: InkWell(
|
||||||
|
onTap: onChartTap,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
if (compact) ...[
|
if (compact) ...[
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -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,104 +1868,122 @@ class _FlowBars extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return GestureDetector(
|
return BarChart(
|
||||||
behavior: HitTestBehavior.opaque,
|
BarChartData(
|
||||||
onTap: onChartTap,
|
maxY: safeMax,
|
||||||
child: IgnorePointer(
|
groupsSpace: period == OverviewPeriodMode.yearly ? 16 : 10,
|
||||||
// fl_chart otherwise owns the gesture arena and empty taps go nowhere.
|
barTouchData: BarTouchData(
|
||||||
// Tap opens this chart's filters; legend / history still open columns.
|
handleBuiltInTouches: false,
|
||||||
child: BarChart(
|
touchTooltipData: BarTouchTooltipData(
|
||||||
BarChartData(
|
getTooltipItem: (group, groupIndex, rod, rodIndex) => null,
|
||||||
maxY: safeMax,
|
|
||||||
groupsSpace: period == OverviewPeriodMode.yearly ? 16 : 10,
|
|
||||||
barTouchData: const BarTouchData(enabled: false),
|
|
||||||
titlesData: FlTitlesData(
|
|
||||||
topTitles:
|
|
||||||
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
||||||
rightTitles:
|
|
||||||
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
||||||
leftTitles: AxisTitles(
|
|
||||||
sideTitles: SideTitles(
|
|
||||||
showTitles: true,
|
|
||||||
reservedSize: 42,
|
|
||||||
interval: safeMax / 3,
|
|
||||||
getTitlesWidget: (value, meta) {
|
|
||||||
if (value <= 0) {
|
|
||||||
return Text('0', style: theme.textTheme.labelSmall);
|
|
||||||
}
|
|
||||||
if ((value - safeMax).abs() < 0.01) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
return Text(
|
|
||||||
_compactMoney(value),
|
|
||||||
style: theme.textTheme.labelSmall,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
bottomTitles: AxisTitles(
|
|
||||||
sideTitles: SideTitles(
|
|
||||||
showTitles: true,
|
|
||||||
reservedSize: 28,
|
|
||||||
getTitlesWidget: (value, meta) {
|
|
||||||
final index = value.toInt();
|
|
||||||
if (index < 0 || index >= months.length) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
if (period == OverviewPeriodMode.monthly &&
|
|
||||||
index % 2 == 1 &&
|
|
||||||
months.length > 8) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 8),
|
|
||||||
child: Text(
|
|
||||||
_periodAxisLabel(months[index], period),
|
|
||||||
style: theme.textTheme.labelSmall,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
gridData: FlGridData(
|
|
||||||
show: true,
|
|
||||||
drawVerticalLine: false,
|
|
||||||
horizontalInterval: safeMax / 3,
|
|
||||||
getDrawingHorizontalLine: (value) => FlLine(
|
|
||||||
color: colors.outlineVariant.withValues(alpha: 0.35),
|
|
||||||
strokeWidth: 1,
|
|
||||||
dashArray: const [4, 4],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
borderData: FlBorderData(show: false),
|
|
||||||
barGroups: [
|
|
||||||
for (var i = 0; i < months.length; i++)
|
|
||||||
BarChartGroupData(
|
|
||||||
x: i,
|
|
||||||
barsSpace: 3,
|
|
||||||
barRods: [
|
|
||||||
BarChartRodData(
|
|
||||||
toY: monthly[_periodKey(months[i], period)]!.expense,
|
|
||||||
width: period == OverviewPeriodMode.yearly ? 14 : 9,
|
|
||||||
borderRadius:
|
|
||||||
const BorderRadius.vertical(top: Radius.circular(6)),
|
|
||||||
color: _kExpenseColor,
|
|
||||||
),
|
|
||||||
BarChartRodData(
|
|
||||||
toY: monthly[_periodKey(months[i], period)]!.income,
|
|
||||||
width: period == OverviewPeriodMode.yearly ? 14 : 9,
|
|
||||||
borderRadius:
|
|
||||||
const BorderRadius.vertical(top: Radius.circular(6)),
|
|
||||||
color: _kIncomeColor,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
duration: Duration.zero,
|
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(
|
||||||
|
topTitles:
|
||||||
|
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||||
|
rightTitles:
|
||||||
|
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||||
|
leftTitles: AxisTitles(
|
||||||
|
sideTitles: SideTitles(
|
||||||
|
showTitles: true,
|
||||||
|
reservedSize: 42,
|
||||||
|
interval: safeMax / 3,
|
||||||
|
getTitlesWidget: (value, meta) {
|
||||||
|
if (value <= 0) {
|
||||||
|
return Text('0', style: theme.textTheme.labelSmall);
|
||||||
|
}
|
||||||
|
if ((value - safeMax).abs() < 0.01) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
return Text(
|
||||||
|
_compactMoney(value),
|
||||||
|
style: theme.textTheme.labelSmall,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
bottomTitles: AxisTitles(
|
||||||
|
sideTitles: SideTitles(
|
||||||
|
showTitles: true,
|
||||||
|
reservedSize: 28,
|
||||||
|
getTitlesWidget: (value, meta) {
|
||||||
|
final index = value.toInt();
|
||||||
|
if (index < 0 || index >= months.length) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
if (period == OverviewPeriodMode.monthly &&
|
||||||
|
index % 2 == 1 &&
|
||||||
|
months.length > 8) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: Text(
|
||||||
|
_periodAxisLabel(months[index], period),
|
||||||
|
style: theme.textTheme.labelSmall,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
gridData: FlGridData(
|
||||||
|
show: true,
|
||||||
|
drawVerticalLine: false,
|
||||||
|
horizontalInterval: safeMax / 3,
|
||||||
|
getDrawingHorizontalLine: (value) => FlLine(
|
||||||
|
color: colors.outlineVariant.withValues(alpha: 0.35),
|
||||||
|
strokeWidth: 1,
|
||||||
|
dashArray: const [4, 4],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
borderData: FlBorderData(show: false),
|
||||||
|
barGroups: [
|
||||||
|
for (var i = 0; i < months.length; i++)
|
||||||
|
BarChartGroupData(
|
||||||
|
x: i,
|
||||||
|
barsSpace: 3,
|
||||||
|
barRods: [
|
||||||
|
BarChartRodData(
|
||||||
|
toY: monthly[_periodKey(months[i], period)]!.expense,
|
||||||
|
width: period == OverviewPeriodMode.yearly ? 14 : 9,
|
||||||
|
borderRadius:
|
||||||
|
const BorderRadius.vertical(top: Radius.circular(6)),
|
||||||
|
color: _kExpenseColor,
|
||||||
|
),
|
||||||
|
BarChartRodData(
|
||||||
|
toY: monthly[_periodKey(months[i], period)]!.income,
|
||||||
|
width: period == OverviewPeriodMode.yearly ? 14 : 9,
|
||||||
|
borderRadius:
|
||||||
|
const BorderRadius.vertical(top: Radius.circular(6)),
|
||||||
|
color: _kIncomeColor,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
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,
|
BarChartData(
|
||||||
onTap: onChartTap,
|
minY: safeMin,
|
||||||
child: IgnorePointer(
|
maxY: safeMax,
|
||||||
child: BarChart(
|
groupsSpace: period == OverviewPeriodMode.yearly ? 16 : 10,
|
||||||
BarChartData(
|
barTouchData: BarTouchData(
|
||||||
minY: safeMin,
|
handleBuiltInTouches: false,
|
||||||
maxY: safeMax,
|
touchTooltipData: BarTouchTooltipData(
|
||||||
groupsSpace: period == OverviewPeriodMode.yearly ? 16 : 10,
|
getTooltipItem: (group, groupIndex, rod, rodIndex) => null,
|
||||||
barTouchData: const BarTouchData(enabled: false),
|
),
|
||||||
|
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:
|
||||||
@@ -2061,18 +2296,16 @@ class _LabelMonthBars extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
borderData: FlBorderData(show: false),
|
borderData: FlBorderData(show: false),
|
||||||
barGroups: [
|
barGroups: [
|
||||||
for (var i = 0; i < months.length; i++)
|
for (var i = 0; i < months.length; i++)
|
||||||
BarChartGroupData(
|
BarChartGroupData(
|
||||||
x: i,
|
x: i,
|
||||||
barsSpace: stacked ? 0 : 2,
|
barsSpace: stacked ? 0 : 2,
|
||||||
barRods: rodsFor(months[i]),
|
barRods: rodsFor(months[i]),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
duration: Duration.zero,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
duration: Duration.zero,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,22 +275,25 @@ 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 ??
|
||||||
all: transactions,
|
computeTxFilterMetrics(
|
||||||
meta: meta,
|
all: transactions,
|
||||||
filter: widgetConfig.filter,
|
meta: meta,
|
||||||
);
|
filter: widgetConfig.filter,
|
||||||
|
);
|
||||||
|
|
||||||
// Prefer a single hero "Spent" figure when that's the main metric.
|
// Prefer a single hero "Spent" figure when that's the main metric.
|
||||||
final spentOnly = widgetConfig.showAmountOut &&
|
final spentOnly = widgetConfig.showAmountOut &&
|
||||||
@@ -383,22 +394,25 @@ 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 ??
|
||||||
all: transactions,
|
computeHomeBudgetMetrics(
|
||||||
meta: meta,
|
all: transactions,
|
||||||
widget: widgetConfig,
|
meta: meta,
|
||||||
);
|
widget: widgetConfig,
|
||||||
|
);
|
||||||
final progress = budget.progress.clamp(0.0, 1.0);
|
final progress = budget.progress.clamp(0.0, 1.0);
|
||||||
final fillColor = budget.overBudget
|
final fillColor = budget.overBudget
|
||||||
? AppTheme.expense
|
? AppTheme.expense
|
||||||
|
|||||||
Reference in New Issue
Block a user