Files
ingtrakapp/lib/screens/overview_screen.dart
T
2026-09-17 01:00:11 +02:00

2652 lines
84 KiB
Dart

/// Overview: navigable income/expense or label charts, summary, month history.
library;
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/bank.dart';
import '../models/overview_favourite.dart';
import '../models/transaction_meta.dart';
import '../providers/bank_provider.dart';
import '../providers/preferences_provider.dart';
import '../providers/tx_meta_provider.dart';
import '../utils/app_theme.dart';
import '../utils/deferred_tab_work.dart';
import '../utils/helpers.dart';
import '../utils/navigation.dart';
import '../utils/tx_calendar.dart';
import '../widgets/empty_state.dart';
import '../widgets/label_filter_dialog.dart';
import '../widgets/loading_view.dart';
import '../widgets/overview_favourites_sheet.dart';
import '../widgets/responsive_body.dart';
import '../widgets/shell_tab_scope.dart';
import '../widgets/skip_build_when_covered.dart';
import 'label_transactions_screen.dart';
import 'transactions_screen.dart';
const _kIncomeColor = AppTheme.accent;
const _kExpenseColor = AppTheme.expense;
class _FavouriteAgg {
const _FavouriteAgg({
required this.periods,
required this.monthly,
required this.labelSpend,
required this.labelAmountByPeriod,
});
final List<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 {
const OverviewScreen({super.key});
static const tabIndex = 1;
@override
State<OverviewScreen> createState() => _OverviewScreenState();
}
class _OverviewScreenState extends State<OverviewScreen> {
OverviewPeriodMode _period = OverviewPeriodMode.monthly;
OverviewMetricMode _metric = OverviewMetricMode.flow;
/// Empty = include every label. Non-empty = only these ids on the Labels chart.
final Set<String> _includedLabelIds = {};
/// Labels chart flow filter (defaults to expenses / labeled spend).
var _labelFlow = TxFlowFilter.expense;
/// Parallel (default) vs stacked bars on the Labels chart.
var _labelBarStyle = LabelChartBarStyle.parallel;
/// When flow is All: flip signs so expenses plot upward.
var _labelInvertChart = false;
/// Selected calendar year for Monthly mode (`DateTime(year)`).
late DateTime _cursor;
/// Favourites charts view (vs full overview with history).
bool _showFavouritesView = false;
var _favouritesDefaultApplied = false;
final _work = DeferredTabWork<_OverviewAggSnapshot>();
@override
void initState() {
super.initState();
_cursor = DateTime(DateTime.now().year);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_favouritesDefaultApplied) return;
_favouritesDefaultApplied = true;
final prefs = context.read<PreferencesController>();
if (prefs.hasDisplayedOverviewFavourites) {
_showFavouritesView = true;
}
}
DateTime get _nowMonth => monthStart(DateTime.now());
/// Chart X buckets: 12 months of [_cursor] year, or one Jan-1 per data year.
List<DateTime> _chartPeriods(List<BankTransaction> txs) {
if (_period == OverviewPeriodMode.monthly) {
final year = _cursor.year;
return [for (var m = 1; m <= 12; m++) DateTime(year, m)];
}
final years = <int>{_nowMonth.year};
for (final tx in txs) {
final date = _txDate(tx);
if (date != null) years.add(date.year);
}
final sorted = years.toList()..sort();
return [for (final year in sorted) DateTime(year)];
}
String get _periodTitle {
if (_period == OverviewPeriodMode.monthly) return '${_cursor.year}';
return 'All years';
}
bool get _canGoForward =>
_period == OverviewPeriodMode.monthly && _cursor.year < _nowMonth.year;
bool get _canGoBack =>
_period == OverviewPeriodMode.monthly && _cursor.year > _nowMonth.year - 20;
void _go(int delta) {
if (_period != OverviewPeriodMode.monthly) return;
setState(() {
final next = DateTime(_cursor.year + delta);
if (next.year > _nowMonth.year) {
_cursor = DateTime(_nowMonth.year);
} else if (next.year < _nowMonth.year - 20) {
return;
} else {
_cursor = next;
}
});
}
void _setPeriod(OverviewPeriodMode mode) {
setState(() {
_period = mode;
_cursor = DateTime(_nowMonth.year);
});
}
Future<void> _pickChartLabels(BuildContext context) async {
final meta = context.read<TxMetaController>();
final available = meta.allLabels;
final initial = <String>{
if (_includedLabelIds.isEmpty)
...available.map((l) => l.id)
else ...[
for (final id in _includedLabelIds)
if (id == kUncategorizedFilterId ||
available.any((l) => l.id == id))
id,
],
};
final result = await showLabelFilterDialog(
context,
available: available,
initiallySelected: initial,
title: 'Labels on chart',
subtitle:
'Choose which labels appear. Long-press to nest under another.',
allowMove: true,
includeUncategorized: true,
includeFlow: true,
initialFlow: _labelFlow,
includeBarStyle: true,
initialBarStyle: _labelBarStyle,
includeInvertChart: true,
initialInvertChart: _labelInvertChart,
);
if (result == null || !mounted) return;
setState(() {
_labelFlow = result.flow;
_labelBarStyle = result.barStyle;
_labelInvertChart = result.flow == TxFlowFilter.all &&
result.invertExpensePerspective;
final allIds = available.map((l) => l.id).toSet();
final hasUncategorized =
result.labelIds.contains(kUncategorizedFilterId);
final realIds = result.labelIds
.where((id) => id != kUncategorizedFilterId)
.toSet();
final allRealSelected = realIds.length == allIds.length &&
realIds.containsAll(allIds);
if (!hasUncategorized &&
(realIds.isEmpty || allRealSelected)) {
_includedLabelIds.clear();
} else {
_includedLabelIds
..clear()
..addAll(result.labelIds);
}
_metric = OverviewMetricMode.labels;
});
}
OverviewFavourite _currentFilterSnapshot({String name = ''}) {
return OverviewFavourite(
id: 'fav_${DateTime.now().millisecondsSinceEpoch}',
name: name,
display: true,
metric: _metric,
period: _period,
labelIds: _includedLabelIds.toList(growable: false),
flow: _labelFlow,
barStyle: _labelBarStyle,
invertExpensePerspective: _labelInvertChart,
);
}
Future<void> _openFavouritesManager() {
return showOverviewFavouritesSheet(
context,
currentFilterSnapshot: () => _currentFilterSnapshot(),
);
}
Future<void> _saveCurrentAsFavourite() async {
final name = await promptFavouriteName(context);
if (name == null || !mounted) return;
await Future<void>.delayed(Duration.zero);
if (!mounted) return;
await context.read<PreferencesController>().addOverviewFavourite(
_currentFilterSnapshot(name: name),
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Saved “$name”')),
);
}
void _openFavouritesView() {
final prefs = context.read<PreferencesController>();
if (!prefs.hasDisplayedOverviewFavourites) {
_openFavouritesManager();
return;
}
setState(() => _showFavouritesView = true);
}
List<DateTime> _chartPeriodsFor(
OverviewPeriodMode period,
List<BankTransaction> txs,
) {
if (period == OverviewPeriodMode.monthly) {
final year = _cursor.year;
return [for (var m = 1; m <= 12; m++) DateTime(year, m)];
}
final years = <int>{_nowMonth.year};
for (final tx in txs) {
final date = _txDate(tx);
if (date != null) years.add(date.year);
}
final sorted = years.toList()..sort();
return [for (final year in sorted) DateTime(year)];
}
@override
Widget build(BuildContext context) {
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();
}
final bank = context.watch<BankController>();
final meta = context.watch<TxMetaController>();
final prefs = context.watch<PreferencesController>();
// Drop out of favourites view if nothing is displayed anymore.
final shouldLeaveFavourites =
_showFavouritesView && !prefs.hasDisplayedOverviewFavourites;
if (shouldLeaveFavourites) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (_showFavouritesView &&
!context.read<PreferencesController>().hasDisplayedOverviewFavourites) {
setState(() => _showFavouritesView = false);
}
});
}
final emptyLoading =
bank.isLoading && bank.transactions.isEmpty && bank.balance == null;
final needsData = bank.isLinked || bank.hasData;
final displayedFavs = prefs.displayedOverviewFavourites;
final cacheKey = Object.hash(
meta.storeRevision,
identityHashCode(bank.transactions),
_showFavouritesView,
_period,
_metric,
_cursor.year,
Object.hashAll(_includedLabelIds),
_labelFlow,
_labelBarStyle,
_labelInvertChart,
Object.hashAll(displayedFavs.map((f) => f.id)),
Object.hashAll(displayedFavs.map((f) => f.updatedAt)),
);
if (needsData && !emptyLoading && !_work.readyFor(cacheKey)) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (ShellTabScope.of(context) != OverviewScreen.tabIndex) return;
_scheduleOverviewCompute(
key: cacheKey,
bank: bank,
meta: meta,
displayedFavs: displayedFavs,
);
});
}
final showSpinner =
needsData && !emptyLoading && !_work.readyFor(cacheKey);
return Scaffold(
appBar: AppBar(
title: Text(_showFavouritesView ? 'Favourites' : 'Overview'),
actions: [
if (_showFavouritesView)
IconButton(
tooltip: 'Manage favourites',
onPressed: _openFavouritesManager,
icon: const Icon(Icons.tune_outlined),
),
...bankAppBarActions(
context: context,
onRefresh: bank.refresh,
refreshing: bank.isLoading,
),
],
),
body: RefreshIndicator(
onRefresh: bank.refresh,
child: ResponsiveBody(
child: showSpinner
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(height: 120),
LoadingView(message: 'Loading overview…'),
],
)
: SkipBuildWhenCovered(
builder: (context) => _showFavouritesView
? _buildFavouritesBody(context, bank, meta, prefs)
: _buildBody(context, bank, meta, prefs),
),
),
),
);
}
void _scheduleOverviewCompute({
required Object key,
required BankController bank,
required TxMetaController meta,
required List<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(
BuildContext context,
BankController bank,
TxMetaController meta,
PreferencesController prefs,
) {
final theme = Theme.of(context);
if (bank.isLoading && bank.transactions.isEmpty && bank.balance == null) {
return const LoadingView(message: 'Loading overview…');
}
if (!bank.isLinked && !bank.hasData) {
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
EmptyState(
icon: Icons.star_outline,
title: 'No favourites charts yet',
message: 'Connect your bank on Home to see charts.',
),
],
);
}
final currency = bank.balance?.currency ??
(bank.transactions.isNotEmpty
? bank.transactions.first.currency
: 'EUR');
final txs = bank.transactions;
final displayed = prefs.displayedOverviewFavourites;
final agg = _work.value;
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(0, 4, 0, 24),
children: [
if (bank.error != null) ...[
Text(
bank.error!,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.error,
),
),
const SizedBox(height: 8),
],
Row(
children: [
Expanded(
child: Text(
'Favourite charts',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
TextButton(
onPressed: _openFavouritesManager,
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: const EdgeInsets.symmetric(horizontal: 8),
),
child: const Text('Manage'),
),
TextButton.icon(
onPressed: () => setState(() => _showFavouritesView = false),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: const EdgeInsets.symmetric(horizontal: 8),
),
icon: const Icon(Icons.open_in_new, size: 16),
label: const Text('Full overview'),
),
],
),
if (displayed.isEmpty) ...[
const SizedBox(height: 8),
Text(
'All favourites with Display checked appear here. '
'Use Full overview to set a filter, then ★ → Save current filter.',
style: theme.textTheme.labelMedium,
),
const SizedBox(height: 16),
Text(
'No displayed favourites yet. Tap Manage to enable Display, '
'or save a new filter from Full overview.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
] else ...[
const SizedBox(height: 8),
for (var i = 0; i < displayed.length; i++) ...[
if (i > 0) const SizedBox(height: 10),
_favouriteChartCard(
context: context,
favourite: displayed[i],
txs: txs,
meta: meta,
currency: currency,
precomputed: agg?.favourites[displayed[i].id],
),
],
],
],
);
}
Widget _favouriteChartCard({
required BuildContext context,
required OverviewFavourite favourite,
required List<BankTransaction> txs,
required TxMetaController meta,
required String currency,
_FavouriteAgg? precomputed,
}) {
final periods = precomputed?.periods ??
_chartPeriodsFor(favourite.period, txs);
Map<DateTime, _MonthBucket> monthly = precomputed?.monthly ?? const {};
var labelSpend = precomputed?.labelSpend ?? const <_LabelSpend>[];
Map<DateTime, Map<String, double>> labelAmountByPeriod =
precomputed?.labelAmountByPeriod ?? const {};
if (precomputed == null) {
if (favourite.metric == OverviewMetricMode.flow) {
monthly = _flowTotals(txs, meta, periods, favourite.period);
} else {
final labels = _labelAggregates(
txs,
meta,
periods,
favourite.period,
includedLabelIds: favourite.labelIds.toSet(),
flow: favourite.flow,
);
labelSpend = labels.spend;
labelAmountByPeriod = labels.amountByPeriod;
}
}
final periodTitle = favourite.period == OverviewPeriodMode.monthly
? '${_cursor.year}'
: 'All years';
return _ChartCard(
period: favourite.period,
metric: favourite.metric,
heading: favourite.name,
periodTitle: periodTitle,
canGoBack: favourite.period == OverviewPeriodMode.monthly &&
_cursor.year > _nowMonth.year - 20,
canGoForward: favourite.period == OverviewPeriodMode.monthly &&
_cursor.year < _nowMonth.year,
onBack: () => _go(-1),
onForward: () => _go(1),
months: periods,
monthly: monthly,
labelSpend: labelSpend,
labelAmountByMonth: labelAmountByPeriod,
labelBarStyle: favourite.barStyle,
invertChart: favourite.flow == TxFlowFilter.all &&
favourite.invertExpensePerspective,
currency: currency,
onPickLabels: null,
includedLabelCount: null,
availableLabelCount: meta.allLabels.length,
onPeriodModeChanged: (mode) {
context.read<PreferencesController>().updateOverviewFavourite(
favourite.copyWith(period: mode),
);
},
onEdit: () {
showEditOverviewFavouriteSheet(context, favourite: favourite);
},
onLabelTap: (label) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => LabelTransactionsScreen(label: label),
),
);
},
onMonthTap: (month, {labelId, flow}) => _openPeriod(
context,
month,
labelId: labelId,
labelIds: {
for (final id in favourite.labelIds) id,
},
flow: flow ??
(favourite.metric == OverviewMetricMode.labels
? favourite.flow
: null),
periodMode: favourite.period,
),
onChartTap: () => _openChartFilter(
context,
labelIds: {
for (final id in favourite.labelIds) id,
},
flow: favourite.metric == OverviewMetricMode.labels
? favourite.flow
: null,
periodMode: favourite.period,
),
);
}
Widget _buildBody(
BuildContext context,
BankController bank,
TxMetaController meta,
PreferencesController prefs,
) {
final theme = Theme.of(context);
if (bank.isLoading && bank.transactions.isEmpty && bank.balance == null) {
return const LoadingView(message: 'Loading overview…');
}
if (!bank.isLinked && !bank.hasData) {
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
EmptyState(
icon: Icons.insights_outlined,
title: 'No overview yet',
message: 'Connect your bank on Home to see balance and charts.',
),
],
);
}
final currency = bank.balance?.currency ??
(bank.transactions.isNotEmpty
? bank.transactions.first.currency
: 'EUR');
final txs = bank.transactions;
final agg = _work.value;
final periods = agg?.periods ?? _chartPeriods(txs);
final historyPeriods = [...periods.reversed];
// Prefer deferred aggregates; fall back only if snapshot is missing.
Map<DateTime, _MonthBucket> monthly = agg?.monthly ?? const {};
var labelSpend = agg?.labelSpend ?? const <_LabelSpend>[];
Map<DateTime, Map<String, double>> labelAmountByPeriod =
agg?.labelAmountByPeriod ?? const {};
if (agg == null) {
if (_metric == OverviewMetricMode.flow) {
monthly = _flowTotals(txs, meta, periods, _period);
} else {
final labels = _labelAggregates(
txs,
meta,
periods,
_period,
includedLabelIds: _includedLabelIds,
flow: _labelFlow,
);
labelSpend = labels.spend;
labelAmountByPeriod = labels.amountByPeriod;
}
}
final summaryFlow = _metric == OverviewMetricMode.flow
? _sumBuckets(monthly.values)
: const _MonthBucket(income: 0, expense: 0);
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(0, 8, 0, 32),
children: [
if (bank.error != null) ...[
Text(
bank.error!,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.error,
),
),
const SizedBox(height: 12),
],
_ChartToolbar(
period: _period,
metric: _metric,
hasDisplayedFavourites: prefs.hasDisplayedOverviewFavourites,
onPeriodChanged: _setPeriod,
onMetricChanged: (value) => setState(() => _metric = value),
onOpenFavourites: _openFavouritesView,
onSaveFavourite: _saveCurrentAsFavourite,
onManageFavourites: _openFavouritesManager,
),
const SizedBox(height: 10),
_ChartCard(
period: _period,
metric: _metric,
periodTitle: _periodTitle,
canGoBack: _canGoBack,
canGoForward: _canGoForward,
onBack: () => _go(-1),
onForward: () => _go(1),
months: periods,
monthly: monthly,
labelSpend: labelSpend,
labelAmountByMonth: labelAmountByPeriod,
labelBarStyle: _labelBarStyle,
invertChart: _labelFlow == TxFlowFilter.all && _labelInvertChart,
currency: currency,
onPickLabels: () => _pickChartLabels(context),
includedLabelCount: _includedLabelIds.isEmpty &&
_labelFlow == TxFlowFilter.expense
? null
: (_includedLabelIds.isEmpty
? meta.allLabels.length
: _includedLabelIds.length),
availableLabelCount: meta.allLabels.length,
onLabelTap: (label) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => LabelTransactionsScreen(label: label),
),
);
},
onMonthTap: (month, {labelId, flow}) => _openPeriod(
context,
month,
labelId: labelId,
flow: flow ??
(_metric == OverviewMetricMode.labels ? _labelFlow : null),
),
onChartTap: () => _openChartFilter(
context,
flow: _metric == OverviewMetricMode.labels ? _labelFlow : null,
),
),
const SizedBox(height: 14),
if (_metric == OverviewMetricMode.flow) ...[
Row(
children: [
Expanded(
child: _SummaryCard(
icon: Icons.south_west,
iconBackground: _kIncomeColor.withValues(alpha: 0.18),
iconColor: _kIncomeColor,
amount: formatMoney(summaryFlow.income, currency),
label: 'Income',
),
),
const SizedBox(width: 12),
Expanded(
child: _SummaryCard(
icon: Icons.north_east,
iconBackground: _kExpenseColor.withValues(alpha: 0.18),
iconColor: _kExpenseColor,
amount: formatMoney(summaryFlow.expense, currency),
label: 'Expenses',
),
),
],
),
] else ...[
_SummaryCard(
icon: Icons.label_outline,
iconBackground:
theme.colorScheme.primary.withValues(alpha: 0.16),
iconColor: theme.colorScheme.primary,
amount: formatMoney(
labelSpend.fold<double>(0, (s, e) => s + e.amount),
currency,
),
label: switch (_labelFlow) {
TxFlowFilter.income => 'Income · $_periodTitle',
TxFlowFilter.expense => 'Spend · $_periodTitle',
TxFlowFilter.all => _labelInvertChart
? 'Net (expense view) · $_periodTitle'
: 'Net · $_periodTitle',
},
),
],
const SizedBox(height: 22),
Text(
_metric == OverviewMetricMode.flow
? 'History · Income & expenses'
: 'History · Labels',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
_period == OverviewPeriodMode.monthly
? (_metric == OverviewMetricMode.flow
? 'Months in ${_cursor.year}'
: 'Per-label totals by month in ${_cursor.year}')
: (_metric == OverviewMetricMode.flow
? 'One row per year'
: 'Per-label totals by year'),
style: theme.textTheme.labelMedium,
),
const SizedBox(height: 12),
if (historyPeriods.isEmpty)
Text(
'No activity to show.',
style: theme.textTheme.bodyMedium,
)
else
..._historyTiles(
historyPeriods: historyPeriods,
metric: _metric,
periodMode: _period,
monthly: monthly,
labelSpend: labelSpend,
labelAmountByPeriod: labelAmountByPeriod,
labelFlow: _labelFlow,
currency: currency,
onOpenPeriod: (period) => _openPeriod(context, period),
onOpenLabel: (period, label) =>
_openPeriod(context, period, labelId: label.id),
),
],
);
}
List<Widget> _historyTiles({
required List<DateTime> historyPeriods,
required OverviewMetricMode metric,
required OverviewPeriodMode periodMode,
required Map<DateTime, _MonthBucket> monthly,
required List<_LabelSpend> labelSpend,
required Map<DateTime, Map<String, double>> labelAmountByPeriod,
required TxFlowFilter labelFlow,
required String currency,
required void Function(DateTime period) onOpenPeriod,
required void Function(DateTime period, TxLabel label) onOpenLabel,
}) {
final out = <Widget>[];
for (final period in historyPeriods) {
final key = _periodKey(period, periodMode);
final title = _periodAxisLabel(period, periodMode);
if (metric == OverviewMetricMode.flow) {
out.add(
_HistoryCard(
month: period,
title: title,
income: monthly[key]?.income ?? 0,
expense: monthly[key]?.expense ?? 0,
currency: currency,
onTap: () => onOpenPeriod(period),
),
);
} else {
final bucket = labelAmountByPeriod[key] ?? const {};
final rows = <_LabelSpend>[
for (final row in labelSpend)
if ((bucket[row.label.id] ?? 0) != 0)
_LabelSpend(
label: row.label,
amount: bucket[row.label.id]!,
),
];
if (rows.isEmpty) continue;
out.add(
_LabelHistoryCard(
month: period,
title: title,
rows: rows,
flow: labelFlow,
currency: currency,
onOpenPeriod: () => onOpenPeriod(period),
onOpenLabel: (label) => onOpenLabel(period, label),
),
);
}
out.add(const SizedBox(height: 10));
}
return out;
}
void _openPeriod(
BuildContext context,
DateTime period, {
String? labelId,
Set<String>? labelIds,
TxFlowFilter? flow,
OverviewPeriodMode? periodMode,
}) {
final mode = periodMode ?? _period;
final yearly = mode == OverviewPeriodMode.yearly;
final initial = yearly ? DateTime(period.year, 12) : period;
final resolvedLabelIds = labelId != null
? {labelId}
: (labelIds ??
(_metric == OverviewMetricMode.labels
? {
// Open with the same labels currently shown on the chart.
for (final id in _includedLabelIds) id,
}
: <String>{}));
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => TransactionsScreen(
initialMonth: initial,
initialLabelIds: resolvedLabelIds,
initialLabelMatch: LabelMatchMode.or,
initialFlow: flow,
// A specific bar/period should always scope to that month (or year).
initialSearchInMonthOnly: !yearly,
initialYear: yearly ? period.year : null,
),
),
);
}
/// Open Transactions with this chart's filters (no specific bar/column).
void _openChartFilter(
BuildContext context, {
Set<String>? labelIds,
TxFlowFilter? flow,
OverviewPeriodMode? periodMode,
}) {
final mode = periodMode ?? _period;
final yearly = mode == OverviewPeriodMode.yearly;
final resolvedLabelIds = labelIds ??
{
for (final id in _includedLabelIds) id,
};
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => TransactionsScreen(
// Monthly charts show a year — open that whole year with the chart filter.
initialMonth: yearly ? null : DateTime(_cursor.year, 12),
initialYear: yearly ? null : _cursor.year,
initialLabelIds: resolvedLabelIds,
initialLabelMatch: LabelMatchMode.or,
initialFlow: flow,
initialSearchInMonthOnly: false,
),
),
);
}
}
class _MonthBucket {
const _MonthBucket({required this.income, required this.expense});
final double income;
final double expense;
}
class _LabelSpend {
const _LabelSpend({required this.label, required this.amount});
final TxLabel label;
final double amount;
}
_MonthBucket _sumBuckets(Iterable<_MonthBucket> buckets) {
var income = 0.0;
var expense = 0.0;
for (final b in buckets) {
income += b.income;
expense += b.expense;
}
return _MonthBucket(income: income, expense: expense);
}
DateTime? _txDate(BankTransaction tx) {
final parsed = DateTime.tryParse(tx.date);
return parsed == null ? null : dateOnly(parsed);
}
DateTime _periodKey(DateTime date, OverviewPeriodMode mode) {
if (mode == OverviewPeriodMode.yearly) return DateTime(date.year);
return monthStart(date);
}
String _periodAxisLabel(DateTime period, OverviewPeriodMode mode) {
if (mode == OverviewPeriodMode.yearly) return '${period.year}';
return _shortMonth(period);
}
Map<DateTime, _MonthBucket> _flowTotals(
List<BankTransaction> txs,
TxMetaController meta,
List<DateTime> periods,
OverviewPeriodMode mode,
) {
final keys = {for (final p in periods) _periodKey(p, mode)};
final totals = {
for (final key in keys) key: (income: 0.0, expense: 0.0),
};
for (final tx in txs) {
if (meta.metaFor(tx).internalTransfer) continue;
final date = _txDate(tx);
if (date == null) continue;
final key = _periodKey(date, mode);
final current = totals[key];
if (current == null) continue;
if (tx.amount >= 0) {
totals[key] = (income: current.income + tx.amount, expense: current.expense);
} else {
totals[key] = (
income: current.income,
expense: current.expense + tx.amount.abs(),
);
}
}
return {
for (final period in periods)
_periodKey(period, mode): _MonthBucket(
income: totals[_periodKey(period, mode)]!.income,
expense: totals[_periodKey(period, mode)]!.expense,
),
};
}
class _LabelAggregates {
const _LabelAggregates({
required this.spend,
required this.allSpend,
required this.byPeriod,
required this.amountByPeriod,
});
final List<_LabelSpend> spend;
final List<_LabelSpend> allSpend;
final Map<DateTime, double> byPeriod;
final Map<DateTime, Map<String, double>> amountByPeriod;
int get availableCount => allSpend.length;
}
/// One pass over labeled amounts: totals, per-period series, and history.
///
/// Amounts are summed with sign (refunds offset spend). For [TxFlowFilter.all]
/// the signed net is kept (can be negative). For In/Out, bars use absolute nets.
/// Chart-only invert is applied later in [_LabelMonthBars], not here — history
/// always follows bank transaction signs.
///
/// Empty [includedLabelIds] = every real label (not uncategorized). Include
/// [kUncategorizedFilterId] explicitly for unlabeled txs.
_LabelAggregates _labelAggregates(
List<BankTransaction> txs,
TxMetaController meta,
List<DateTime> periods,
OverviewPeriodMode mode, {
Set<String> includedLabelIds = const {},
TxFlowFilter flow = TxFlowFilter.expense,
}) {
final keys = {for (final p in periods) _periodKey(p, mode)};
final allByLabel = <String, double>{};
final byPeriodSigned = <DateTime, double>{
for (final key in keys) key: 0.0,
};
final amountByPeriodSigned = <DateTime, Map<String, double>>{
for (final key in keys) key: {},
};
final filtering = includedLabelIds.isNotEmpty;
final wantUncategorized =
includedLabelIds.contains(kUncategorizedFilterId);
final realFilterIds = {
for (final id in includedLabelIds)
if (id != kUncategorizedFilterId) id,
};
final keepSigned = flow == TxFlowFilter.all;
for (final tx in txs) {
if (meta.metaFor(tx).internalTransfer) continue;
if (!txMatchesFlow(tx, flow)) continue;
final date = _txDate(tx);
if (date == null) continue;
final key = _periodKey(date, mode);
if (!keys.contains(key)) continue;
final ids = meta.labelIdsFor(tx);
final amount = tx.amount;
if (ids.isEmpty) {
if (filtering && !wantUncategorized) continue;
if (!filtering) continue; // default "all labels" skips unlabeled
allByLabel[kUncategorizedFilterId] =
(allByLabel[kUncategorizedFilterId] ?? 0) + amount;
byPeriodSigned[key] = byPeriodSigned[key]! + amount;
final bucket = amountByPeriodSigned[key]!;
bucket[kUncategorizedFilterId] =
(bucket[kUncategorizedFilterId] ?? 0) + amount;
continue;
}
for (final id in ids) {
allByLabel[id] = (allByLabel[id] ?? 0) + amount;
}
final matchesFilter =
!filtering || ids.any(realFilterIds.contains);
if (!matchesFilter) continue;
byPeriodSigned[key] = byPeriodSigned[key]! + amount;
final bucket = amountByPeriodSigned[key]!;
for (final id in ids) {
if (filtering && !realFilterIds.contains(id)) continue;
bucket[id] = (bucket[id] ?? 0) + amount;
}
}
double displayAmount(double signed) {
if (!keepSigned) return signed.abs();
return signed;
}
TxLabel? resolveLabel(String id) {
if (id == kUncategorizedFilterId) return kUncategorizedFilterLabel;
return meta.labelById(id);
}
final allSpend = <_LabelSpend>[
for (final entry in allByLabel.entries)
if (resolveLabel(entry.key) != null && entry.value != 0)
_LabelSpend(
label: resolveLabel(entry.key)!,
amount: displayAmount(entry.value),
),
]..sort((a, b) => b.amount.abs().compareTo(a.amount.abs()));
final spend = !filtering
? allSpend
: [
for (final row in allSpend)
if (includedLabelIds.contains(row.label.id)) row,
];
final byPeriod = <DateTime, double>{
for (final e in byPeriodSigned.entries)
if (e.value != 0) e.key: displayAmount(e.value),
};
final amountByPeriod = <DateTime, Map<String, double>>{
for (final e in amountByPeriodSigned.entries)
e.key: {
for (final labelEntry in e.value.entries)
if (labelEntry.value != 0)
labelEntry.key: displayAmount(labelEntry.value),
},
};
return _LabelAggregates(
spend: spend,
allSpend: allSpend,
byPeriod: byPeriod,
amountByPeriod: amountByPeriod,
);
}
String _shortMonth(DateTime month) {
const names = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
return names[month.month - 1];
}
String _compactMoney(double value) {
final abs = value.abs();
final sign = value < 0 ? '-' : '';
if (abs >= 1000) {
final k = abs / 1000;
return sign + (k >= 10 ? '${k.round()}k' : '${k.toStringAsFixed(1)}k');
}
return '$sign${abs.round()}';
}
class _ChartToolbar extends StatelessWidget {
const _ChartToolbar({
required this.period,
required this.metric,
required this.hasDisplayedFavourites,
required this.onPeriodChanged,
required this.onMetricChanged,
required this.onOpenFavourites,
required this.onSaveFavourite,
required this.onManageFavourites,
});
final OverviewPeriodMode period;
final OverviewMetricMode metric;
final bool hasDisplayedFavourites;
final ValueChanged<OverviewPeriodMode> onPeriodChanged;
final ValueChanged<OverviewMetricMode> onMetricChanged;
final VoidCallback onOpenFavourites;
final VoidCallback onSaveFavourite;
final VoidCallback onManageFavourites;
static const _segmentStyle = ButtonStyle(
visualDensity: VisualDensity(horizontal: -4, vertical: -3),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: WidgetStatePropertyAll(EdgeInsets.symmetric(horizontal: 10)),
minimumSize: WidgetStatePropertyAll(Size(0, 32)),
);
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Row(
children: [
SegmentedButton<OverviewPeriodMode>(
showSelectedIcon: false,
style: _segmentStyle,
segments: const [
ButtonSegment(
value: OverviewPeriodMode.monthly,
label: Text('Month'),
),
ButtonSegment(
value: OverviewPeriodMode.yearly,
label: Text('Year'),
),
],
selected: {period},
onSelectionChanged: (selection) {
onPeriodChanged(selection.first);
},
),
const Spacer(),
SegmentedButton<OverviewMetricMode>(
showSelectedIcon: false,
style: _segmentStyle,
segments: const [
ButtonSegment(
value: OverviewMetricMode.flow,
label: Text('Flow'),
),
ButtonSegment(
value: OverviewMetricMode.labels,
label: Text('Labels'),
),
],
selected: {metric},
onSelectionChanged: (selection) {
onMetricChanged(selection.first);
},
),
const SizedBox(width: 6),
MenuAnchor(
alignmentOffset: const Offset(0, 4),
style: MenuStyle(
alignment: AlignmentDirectional.bottomEnd,
backgroundColor:
WidgetStatePropertyAll(colors.surfaceContainerHigh),
surfaceTintColor: const WidgetStatePropertyAll(Colors.transparent),
elevation: const WidgetStatePropertyAll(6),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
builder: (context, controller, child) {
return IconButton(
tooltip: 'Favourites',
onPressed: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
visualDensity:
const VisualDensity(horizontal: -4, vertical: -4),
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
padding: EdgeInsets.zero,
style: IconButton.styleFrom(
backgroundColor: hasDisplayedFavourites
? colors.primary.withValues(alpha: 0.22)
: colors.surfaceContainerHigh,
foregroundColor: hasDisplayedFavourites
? colors.primary
: colors.onSurfaceVariant,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
icon: Icon(
hasDisplayedFavourites ? Icons.star : Icons.star_outline,
size: 20,
),
);
},
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(Icons.star_outline),
onPressed: onSaveFavourite,
child: const Text('Save current filter'),
),
MenuItemButton(
leadingIcon: const Icon(Icons.tune_outlined),
onPressed: onManageFavourites,
child: const Text('Manage favourites'),
),
if (hasDisplayedFavourites)
MenuItemButton(
leadingIcon: const Icon(Icons.grid_view_outlined),
onPressed: onOpenFavourites,
child: const Text('View favourites'),
),
],
),
],
);
}
}
class _ChartCard extends StatelessWidget {
const _ChartCard({
required this.period,
required this.metric,
this.heading,
required this.periodTitle,
required this.canGoBack,
required this.canGoForward,
required this.onBack,
required this.onForward,
required this.months,
required this.monthly,
required this.labelSpend,
required this.labelAmountByMonth,
required this.labelBarStyle,
required this.invertChart,
required this.currency,
required this.onPickLabels,
required this.includedLabelCount,
required this.availableLabelCount,
required this.onLabelTap,
required this.onMonthTap,
required this.onChartTap,
this.onPeriodModeChanged,
this.onEdit,
});
final OverviewPeriodMode period;
final OverviewMetricMode metric;
final String? heading;
final String periodTitle;
final bool canGoBack;
final bool canGoForward;
final VoidCallback onBack;
final VoidCallback onForward;
final List<DateTime> months;
final Map<DateTime, _MonthBucket> monthly;
final List<_LabelSpend> labelSpend;
final Map<DateTime, Map<String, double>> labelAmountByMonth;
final LabelChartBarStyle labelBarStyle;
final bool invertChart;
final String currency;
final VoidCallback? onPickLabels;
final int? includedLabelCount;
final int availableLabelCount;
final void Function(TxLabel label) onLabelTap;
final void Function(
DateTime month, {
String? labelId,
TxFlowFilter? flow,
}) onMonthTap;
final VoidCallback onChartTap;
final ValueChanged<OverviewPeriodMode>? onPeriodModeChanged;
final VoidCallback? onEdit;
static const _compactSegmentStyle = ButtonStyle(
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: WidgetStatePropertyAll(EdgeInsets.symmetric(horizontal: 6)),
minimumSize: WidgetStatePropertyAll(Size(0, 28)),
textStyle: WidgetStatePropertyAll(
TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
);
Widget _filterButton(ColorScheme colors) {
return IconButton(
tooltip: includedLabelCount == null
? 'Filter labels ($availableLabelCount)'
: 'Filter labels ($includedLabelCount/$availableLabelCount)',
onPressed: onPickLabels,
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
constraints: const BoxConstraints(
minWidth: 36,
minHeight: 36,
),
padding: EdgeInsets.zero,
style: IconButton.styleFrom(
backgroundColor: includedLabelCount == null
? colors.surfaceContainerHigh
: colors.primary.withValues(alpha: 0.22),
foregroundColor: includedLabelCount == null
? colors.onSurfaceVariant
: colors.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
icon: Badge(
isLabelVisible: includedLabelCount != null,
smallSize: 6,
child: const Icon(Icons.filter_list, size: 20),
),
);
}
Widget _editButton(ColorScheme colors, {required bool compact}) {
return IconButton(
tooltip: 'Edit favourite',
onPressed: onEdit,
visualDensity: const VisualDensity(horizontal: -4, vertical: -4),
constraints: BoxConstraints(
minWidth: compact ? 28 : 36,
minHeight: compact ? 28 : 36,
),
padding: EdgeInsets.zero,
style: IconButton.styleFrom(
backgroundColor:
compact ? null : colors.surfaceContainerHigh,
foregroundColor: colors.onSurfaceVariant,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
icon: Icon(Icons.edit_outlined, size: compact ? 16 : 18),
);
}
Widget _legendRow(ColorScheme colors, {required bool compact}) {
if (metric == OverviewMetricMode.flow) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
_LegendDot(
color: _kIncomeColor,
label: 'Income',
compact: compact,
),
SizedBox(width: compact ? 10 : 14),
_LegendDot(
color: _kExpenseColor,
label: 'Expense',
compact: compact,
),
],
);
}
if (labelSpend.isEmpty) return const SizedBox.shrink();
return Wrap(
spacing: compact ? 8 : 12,
runSpacing: 2,
alignment: WrapAlignment.end,
children: [
for (final row in labelSpend.take(6))
GestureDetector(
onTap: () => onLabelTap(row.label),
child: _LegendDot(
color: row.label.color,
label: row.label.name,
compact: compact,
),
),
if (labelSpend.length > 6)
_LegendDot(
color: colors.outline,
label: 'Other',
compact: compact,
),
],
);
}
Widget _periodControls(ThemeData theme, {required bool compact}) {
final titleStyle = (compact
? theme.textTheme.labelMedium
: theme.textTheme.titleMedium)
?.copyWith(
fontWeight: FontWeight.w600,
color: compact ? theme.colorScheme.onSurfaceVariant : null,
);
if (period == OverviewPeriodMode.monthly) {
return Row(
children: [
IconButton(
tooltip: 'Previous year',
onPressed: canGoBack ? onBack : null,
visualDensity: const VisualDensity(horizontal: -4, vertical: -4),
constraints: BoxConstraints(
minWidth: compact ? 28 : 32,
minHeight: compact ? 28 : 32,
),
padding: EdgeInsets.zero,
icon: Icon(Icons.chevron_left, size: compact ? 20 : 22),
),
Flexible(
child: Text(
periodTitle,
textAlign: TextAlign.left,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: titleStyle,
),
),
IconButton(
tooltip: 'Next year',
onPressed: canGoForward ? onForward : null,
visualDensity: const VisualDensity(horizontal: -4, vertical: -4),
constraints: BoxConstraints(
minWidth: compact ? 28 : 32,
minHeight: compact ? 28 : 32,
),
padding: EdgeInsets.zero,
icon: Icon(Icons.chevron_right, size: compact ? 20 : 22),
),
],
);
}
return Text(periodTitle, style: titleStyle);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final showFilter =
metric == OverviewMetricMode.labels && onPickLabels != null;
final compact = onPeriodModeChanged != null || onEdit != null;
final chartHeight = compact ? 176.0 : 220.0;
final legend = _legendRow(colors, compact: compact);
final hasLegend = metric == OverviewMetricMode.flow ||
labelSpend.isNotEmpty;
final radius = BorderRadius.circular(compact ? 16 : 20);
return Card(
color: colors.surfaceContainer,
elevation: 0,
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(borderRadius: radius),
// Header/legend open the chart-wide filter; bars handle their own taps.
child: Padding(
padding: EdgeInsets.fromLTRB(
compact ? 12 : 14,
compact ? 10 : 12,
compact ? 10 : 14,
compact ? 12 : 16,
),
child: Column(
children: [
Material(
color: Colors.transparent,
child: InkWell(
onTap: onChartTap,
borderRadius: BorderRadius.circular(12),
child: Column(
children: [
if (compact) ...[
Row(
children: [
if (onPeriodModeChanged != null)
SegmentedButton<OverviewPeriodMode>(
showSelectedIcon: false,
style: _compactSegmentStyle,
segments: const [
ButtonSegment(
value: OverviewPeriodMode.monthly,
label: Text('Month'),
),
ButtonSegment(
value: OverviewPeriodMode.yearly,
label: Text('Year'),
),
],
selected: {period},
onSelectionChanged: (selection) {
onPeriodModeChanged!(selection.first);
},
),
if (heading != null) ...[
const SizedBox(width: 8),
Expanded(
child: Text(
heading!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
] else
const Spacer(),
if (showFilter) ...[
const SizedBox(width: 4),
_filterButton(colors),
],
if (onEdit != null) ...[
const SizedBox(width: 2),
_editButton(colors, compact: true),
],
],
),
const SizedBox(height: 4),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: _periodControls(theme, compact: true),
),
if (hasLegend) ...[
const SizedBox(width: 8),
Flexible(child: legend),
],
],
),
const SizedBox(height: 6),
] else ...[
if (heading != null) ...[
Align(
alignment: Alignment.centerLeft,
child: Text(
heading!,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(height: 8),
],
if (period == OverviewPeriodMode.monthly)
Row(
children: [
IconButton(
tooltip: 'Previous year',
onPressed: canGoBack ? onBack : null,
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
padding: EdgeInsets.zero,
icon: const Icon(Icons.chevron_left, size: 22),
),
Expanded(
child: Text(
periodTitle,
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
IconButton(
tooltip: 'Next year',
onPressed: canGoForward ? onForward : null,
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
padding: EdgeInsets.zero,
icon: const Icon(Icons.chevron_right, size: 22),
),
if (showFilter) ...[
const SizedBox(width: 2),
_filterButton(colors),
],
],
)
else
Row(
children: [
SizedBox(width: showFilter ? 36 : 0),
Expanded(
child: Text(
periodTitle,
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
if (showFilter)
_filterButton(colors)
else
const SizedBox(width: 0),
],
),
if (hasLegend) ...[
Align(
alignment: Alignment.centerRight,
child: legend,
),
const SizedBox(height: 8),
],
],
],
),
),
),
SizedBox(
height: chartHeight,
child: metric == OverviewMetricMode.flow
? _FlowBars(
months: months,
monthly: monthly,
currency: currency,
period: period,
onMonthTap: onMonthTap,
onChartTap: onChartTap,
)
: _LabelMonthBars(
months: months,
labelSpend: labelSpend,
amountByMonth: labelAmountByMonth,
currency: currency,
period: period,
barStyle: labelBarStyle,
invertChart: invertChart,
onMonthTap: onMonthTap,
onChartTap: onChartTap,
),
),
],
),
),
);
}
}
class _FlowBars extends StatelessWidget {
const _FlowBars({
required this.months,
required this.monthly,
required this.currency,
required this.period,
required this.onMonthTap,
required this.onChartTap,
});
final List<DateTime> months;
final Map<DateTime, _MonthBucket> monthly;
final String currency;
final OverviewPeriodMode period;
final void Function(
DateTime month, {
String? labelId,
TxFlowFilter? flow,
}) onMonthTap;
final VoidCallback onChartTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
var maxY = 0.0;
for (final month in months) {
final bucket = monthly[_periodKey(month, period)]!;
if (bucket.income > maxY) maxY = bucket.income;
if (bucket.expense > maxY) maxY = bucket.expense;
}
final safeMax = maxY <= 0 ? 1.0 : maxY * 1.15;
final empty = months.every((m) {
final b = monthly[_periodKey(m, period)]!;
return b.income == 0 && b.expense == 0;
});
if (empty) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onChartTap,
child: Center(
child: Text(
period == OverviewPeriodMode.yearly
? 'No income or expenses in these years'
: 'No income or expenses in this year',
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
),
);
}
return BarChart(
BarChartData(
maxY: safeMax,
groupsSpace: period == OverviewPeriodMode.yearly ? 16 : 10,
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(
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,
);
}
}
class _LabelMonthBars extends StatelessWidget {
const _LabelMonthBars({
required this.months,
required this.labelSpend,
required this.amountByMonth,
required this.currency,
required this.period,
required this.barStyle,
required this.invertChart,
required this.onMonthTap,
required this.onChartTap,
});
static const _maxSeriesLabels = 6;
final List<DateTime> months;
final List<_LabelSpend> labelSpend;
final Map<DateTime, Map<String, double>> amountByMonth;
final String currency;
final OverviewPeriodMode period;
final LabelChartBarStyle barStyle;
final bool invertChart;
final void Function(
DateTime month, {
String? labelId,
TxFlowFilter? flow,
}) onMonthTap;
final VoidCallback onChartTap;
double _plot(double value) => invertChart ? -value : value;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
if (labelSpend.isEmpty) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onChartTap,
child: Center(
child: Text(
'No labeled activity in this period.\nTag transactions to see them here.',
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
),
);
}
final topLabels = labelSpend.take(_maxSeriesLabels).toList(growable: false);
final topIds = {for (final row in topLabels) row.label.id};
final otherColor = colors.outline;
final stacked = barStyle == LabelChartBarStyle.stacked;
double otherFor(DateTime month) {
final bucket =
amountByMonth[_periodKey(month, period)] ?? const {};
var other = 0.0;
for (final entry in bucket.entries) {
if (!topIds.contains(entry.key)) other += entry.value;
}
return other;
}
final hasOther = months.any((m) => otherFor(m) != 0);
final seriesCount = topLabels.length + (hasOther ? 1 : 0);
var maxY = 0.0;
var minY = 0.0;
for (final month in months) {
final bucket =
amountByMonth[_periodKey(month, period)] ?? const {};
if (stacked) {
var pos = 0.0;
var neg = 0.0;
void accumulate(double value) {
final plotted = _plot(value);
if (plotted > 0) {
pos += plotted;
} else if (plotted < 0) {
neg += plotted;
}
}
for (final row in topLabels) {
accumulate(bucket[row.label.id] ?? 0);
}
accumulate(otherFor(month));
if (pos > maxY) maxY = pos;
if (neg < minY) minY = neg;
} else {
for (final row in topLabels) {
final value = _plot(bucket[row.label.id] ?? 0);
if (value > maxY) maxY = value;
if (value < minY) minY = value;
}
final other = _plot(otherFor(month));
if (other > maxY) maxY = other;
if (other < minY) minY = other;
}
}
if (maxY == 0 && minY == 0) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onChartTap,
child: Center(
child: Text(
'No labeled activity in this period.\nTag transactions to see them here.',
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
),
);
}
final span = (maxY - minY).abs();
final pad = span <= 0 ? 1.0 : span * 0.15;
final safeMax = maxY + (maxY == 0 && minY < 0 ? 0 : pad);
final safeMin = minY - (minY == 0 && maxY > 0 ? 0 : pad);
final rodWidth = stacked
? (period == OverviewPeriodMode.yearly ? 18.0 : 14.0)
: period == OverviewPeriodMode.yearly
? (seriesCount <= 3 ? 12.0 : seriesCount <= 6 ? 8.0 : 6.0)
: (seriesCount <= 3
? 7.0
: seriesCount <= 6
? 4.5
: 3.5);
List<BarChartRodData> rodsFor(DateTime month) {
final bucket =
amountByMonth[_periodKey(month, period)] ?? const {};
if (stacked) {
final stackItems = <BarChartRodStackItem>[];
var pos = 0.0;
var neg = 0.0;
void addSegment(double value, Color color) {
final plotted = _plot(value);
if (plotted > 0) {
stackItems.add(BarChartRodStackItem(pos, pos + plotted, color));
pos += plotted;
} else if (plotted < 0) {
stackItems.add(BarChartRodStackItem(neg + plotted, neg, color));
neg += plotted;
}
}
for (final row in topLabels) {
addSegment(bucket[row.label.id] ?? 0, row.label.color);
}
addSegment(otherFor(month), otherColor);
if (stackItems.isEmpty) {
return [
BarChartRodData(
toY: 0,
width: rodWidth,
color: colors.surfaceContainerHighest,
),
];
}
return [
BarChartRodData(
fromY: neg,
toY: pos,
width: rodWidth,
borderRadius: BorderRadius.vertical(
top: pos > 0 ? const Radius.circular(4) : Radius.zero,
bottom: neg < 0 ? const Radius.circular(4) : Radius.zero,
),
color: colors.surfaceContainerHighest,
rodStackItems: stackItems,
),
];
}
BorderRadius rodRadius(double value) {
if (value >= 0) {
return const BorderRadius.vertical(top: Radius.circular(4));
}
return const BorderRadius.vertical(bottom: Radius.circular(4));
}
return [
for (final row in topLabels)
BarChartRodData(
toY: _plot(bucket[row.label.id] ?? 0),
width: rodWidth,
borderRadius: rodRadius(_plot(bucket[row.label.id] ?? 0)),
color: row.label.color,
),
if (hasOther)
BarChartRodData(
toY: _plot(otherFor(month)),
width: rodWidth,
borderRadius: rodRadius(_plot(otherFor(month))),
color: otherColor,
),
];
}
final axisInterval = ((safeMax - safeMin) / 3).abs();
final interval = axisInterval <= 0 ? 1.0 : axisInterval;
return BarChart(
BarChartData(
minY: safeMin,
maxY: safeMax,
groupsSpace: period == OverviewPeriodMode.yearly ? 16 : 10,
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(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles:
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 42,
interval: interval,
getTitlesWidget: (value, meta) {
if (value.abs() < 0.01) {
return Text('0', style: theme.textTheme.labelSmall);
}
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,
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: interval,
getDrawingHorizontalLine: (value) => FlLine(
color: value.abs() < 0.01
? colors.outline.withValues(alpha: 0.55)
: colors.outlineVariant.withValues(alpha: 0.35),
strokeWidth: value.abs() < 0.01 ? 1.2 : 1,
dashArray: value.abs() < 0.01 ? null : const [4, 4],
),
),
borderData: FlBorderData(show: false),
barGroups: [
for (var i = 0; i < months.length; i++)
BarChartGroupData(
x: i,
barsSpace: stacked ? 0 : 2,
barRods: rodsFor(months[i]),
),
],
),
duration: Duration.zero,
);
}
}
class _LegendDot extends StatelessWidget {
const _LegendDot({
required this.color,
required this.label,
this.compact = false,
});
final Color color;
final String label;
final bool compact;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: compact ? 6 : 8,
height: compact ? 6 : 8,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
SizedBox(width: compact ? 4 : 6),
Text(
label,
style: compact
? theme.textTheme.labelSmall
: theme.textTheme.labelMedium,
),
],
);
}
}
class _SummaryCard extends StatelessWidget {
const _SummaryCard({
required this.icon,
required this.iconBackground,
required this.iconColor,
required this.amount,
required this.label,
});
final IconData icon;
final Color iconBackground;
final Color iconColor;
final String amount;
final String label;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
return Card(
color: colors.surfaceContainer,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 14),
child: Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: iconBackground,
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: iconColor, size: 22),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
amount,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
color: colors.onSurface,
),
),
const SizedBox(height: 2),
Text(
label.toUpperCase(),
style: theme.textTheme.labelSmall?.copyWith(
letterSpacing: 0.6,
fontWeight: FontWeight.w600,
color: colors.onSurfaceVariant,
),
),
],
),
),
],
),
),
);
}
}
class _HistoryCard extends StatelessWidget {
const _HistoryCard({
required this.month,
required this.title,
required this.income,
required this.expense,
required this.currency,
required this.onTap,
});
final DateTime month;
final String title;
final double income;
final double expense;
final String currency;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final isYear = title.length == 4 && int.tryParse(title) != null;
return Card(
color: colors.surfaceContainer,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isYear ? 'YEAR' : 'MONTH',
style: theme.textTheme.labelSmall?.copyWith(
letterSpacing: 0.8,
fontWeight: FontWeight.w600,
color: colors.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
isYear ? title : formatMonthYear(month),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
color: colors.onSurface,
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
formatMoney(income, currency),
style: theme.textTheme.titleSmall?.copyWith(
color: _kIncomeColor,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
formatMoney(-expense, currency),
style: theme.textTheme.titleSmall?.copyWith(
color: _kExpenseColor,
fontWeight: FontWeight.w700,
),
),
],
),
],
),
),
),
);
}
}
class _LabelHistoryCard extends StatelessWidget {
const _LabelHistoryCard({
required this.month,
required this.title,
required this.rows,
required this.flow,
required this.currency,
required this.onOpenPeriod,
required this.onOpenLabel,
});
final DateTime month;
final String title;
final List<_LabelSpend> rows;
final TxFlowFilter flow;
final String currency;
final VoidCallback onOpenPeriod;
final void Function(TxLabel label) onOpenLabel;
/// Display amount + color for history rows.
///
/// Keep the aggregate sign (All already signed; In/Out use abs then restore
/// bank-like sign). Color follows the displayed value.
(double display, Color color) _styleFor(double amount) {
final signed = switch (flow) {
TxFlowFilter.expense => -amount.abs(),
TxFlowFilter.income => amount.abs(),
TxFlowFilter.all => amount,
};
if (signed > 0) return (signed, _kIncomeColor);
if (signed < 0) return (signed, _kExpenseColor);
return (0, _kExpenseColor);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final isYear = title.length == 4 && int.tryParse(title) != null;
final periodLabel = isYear ? title : formatMonthYear(month);
final total = rows.fold<double>(0, (sum, row) => sum + row.amount);
final totalStyle = _styleFor(total);
return Card(
color: colors.surfaceContainer,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
InkWell(
onTap: onOpenPeriod,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 10),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isYear ? 'YEAR' : 'MONTH',
style: theme.textTheme.labelSmall?.copyWith(
letterSpacing: 0.8,
fontWeight: FontWeight.w600,
color: colors.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
periodLabel,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
color: colors.onSurface,
),
),
],
),
),
Text(
formatMoney(totalStyle.$1, currency),
style: theme.textTheme.titleSmall?.copyWith(
color: totalStyle.$2,
fontWeight: FontWeight.w700,
),
),
],
),
),
),
Divider(
height: 1,
color: colors.outlineVariant.withValues(alpha: 0.45),
),
for (var i = 0; i < rows.length; i++) ...[
if (i > 0)
Divider(
height: 1,
indent: 52,
color: colors.outlineVariant.withValues(alpha: 0.35),
),
InkWell(
onTap: () => onOpenLabel(rows[i].label),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 10),
child: Row(
children: [
Icon(
rows[i].label.icon,
size: 20,
color: rows[i].label.color,
),
const SizedBox(width: 12),
Expanded(
child: Text(
rows[i].label.name,
style: theme.textTheme.bodyMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Builder(
builder: (context) {
final style = _styleFor(rows[i].amount);
return Text(
formatMoney(style.$1, currency),
style: theme.textTheme.bodyMedium?.copyWith(
color: style.$2,
fontWeight: FontWeight.w600,
),
);
},
),
],
),
),
),
],
],
),
);
}
}