Files
ingtrakapp/lib/screens/overview_screen.dart
T

1544 lines
48 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 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/transaction_meta.dart';
import '../providers/bank_provider.dart';
import '../providers/tx_meta_provider.dart';
import '../utils/app_theme.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/responsive_body.dart';
import '../widgets/skip_build_when_covered.dart';
import 'label_transactions_screen.dart';
import 'transactions_screen.dart';
enum _PeriodMode { monthly, yearly }
enum _MetricMode { flow, labels }
const _kIncomeColor = AppTheme.accent;
const _kExpenseColor = AppTheme.expense;
class OverviewScreen extends StatefulWidget {
const OverviewScreen({super.key});
@override
State<OverviewScreen> createState() => _OverviewScreenState();
}
class _OverviewScreenState extends State<OverviewScreen> {
_PeriodMode _period = _PeriodMode.monthly;
_MetricMode _metric = _MetricMode.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;
/// Selected calendar year for Monthly mode (`DateTime(year)`).
late DateTime _cursor;
@override
void initState() {
super.initState();
_cursor = DateTime(DateTime.now().year);
}
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 == _PeriodMode.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 == _PeriodMode.monthly) return '${_cursor.year}';
return 'All years';
}
bool get _canGoForward =>
_period == _PeriodMode.monthly && _cursor.year < _nowMonth.year;
bool get _canGoBack =>
_period == _PeriodMode.monthly && _cursor.year > _nowMonth.year - 20;
void _go(int delta) {
if (_period != _PeriodMode.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(_PeriodMode mode) {
setState(() {
_period = mode;
_cursor = DateTime(_nowMonth.year);
});
}
Future<void> _pickChartLabels(BuildContext context) async {
final meta = context.read<TxMetaController>();
final available = meta.allLabels;
if (available.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No labels yet — create some first')),
);
return;
}
final initial = Set<String>.of(
_includedLabelIds.isEmpty
? available.map((l) => l.id)
: _includedLabelIds.where((id) => available.any((l) => l.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,
includeFlow: true,
initialFlow: _labelFlow,
);
if (result == null || !mounted) return;
setState(() {
_labelFlow = result.flow;
final allIds = available.map((l) => l.id).toSet();
if (result.labelIds.isEmpty ||
(result.labelIds.length == allIds.length &&
result.labelIds.containsAll(allIds))) {
_includedLabelIds.clear();
} else {
_includedLabelIds
..clear()
..addAll(result.labelIds);
}
_metric = _MetricMode.labels;
});
}
@override
Widget build(BuildContext context) {
final bank = context.watch<BankController>();
final meta = context.watch<TxMetaController>();
return Scaffold(
appBar: AppBar(
title: const Text('Overview'),
actions: bankAppBarActions(
context: context,
onRefresh: bank.refresh,
refreshing: bank.isLoading,
),
),
body: RefreshIndicator(
onRefresh: bank.refresh,
child: ResponsiveBody(
child: SkipBuildWhenCovered(
builder: (context) => _buildBody(context, bank, meta),
),
),
),
);
}
Widget _buildBody(
BuildContext context,
BankController bank,
TxMetaController meta,
) {
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 periods = _chartPeriods(txs);
final historyPeriods = [...periods.reversed];
// Only aggregate what the active metric needs — toggling was scanning
// all txs 3–4 times and resolving full meta each time.
Map<DateTime, _MonthBucket> monthly = const {};
var labelSpend = const <_LabelSpend>[];
Map<DateTime, Map<String, double>> labelAmountByPeriod = const {};
if (_metric == _MetricMode.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 == _MetricMode.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),
],
_ChartCard(
period: _period,
metric: _metric,
periodTitle: _periodTitle,
canGoBack: _canGoBack,
canGoForward: _canGoForward,
onPeriodChanged: _setPeriod,
onMetricChanged: (value) => setState(() => _metric = value),
onBack: () => _go(-1),
onForward: () => _go(1),
months: periods,
monthly: monthly,
labelSpend: labelSpend,
labelAmountByMonth: labelAmountByPeriod,
currency: currency,
includedLabelCount: _includedLabelIds.isEmpty &&
_labelFlow == TxFlowFilter.expense
? null
: (_includedLabelIds.isEmpty
? meta.allLabels.length
: _includedLabelIds.length),
availableLabelCount: meta.allLabels.length,
onPickLabels: () => _pickChartLabels(context),
onLabelTap: (label) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => LabelTransactionsScreen(label: label),
),
);
},
onMonthTap: (month) => _openPeriod(context, month),
),
const SizedBox(height: 14),
if (_metric == _MetricMode.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 => 'Labeled income · $_periodTitle',
TxFlowFilter.expense => 'Labeled spend · $_periodTitle',
TxFlowFilter.all => 'Labeled activity · $_periodTitle',
},
),
],
const SizedBox(height: 22),
Text(
_metric == _MetricMode.flow
? 'History · Income & expenses'
: 'History · Labels',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
_period == _PeriodMode.monthly
? (_metric == _MetricMode.flow
? 'Months in ${_cursor.year}'
: 'Per-label totals by month in ${_cursor.year}')
: (_metric == _MetricMode.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,
currency: currency,
onOpenPeriod: (period) => _openPeriod(context, period),
onOpenLabel: (period, label) =>
_openPeriod(context, period, labelId: label.id),
),
],
);
}
List<Widget> _historyTiles({
required List<DateTime> historyPeriods,
required _MetricMode metric,
required _PeriodMode periodMode,
required Map<DateTime, _MonthBucket> monthly,
required List<_LabelSpend> labelSpend,
required Map<DateTime, Map<String, double>> labelAmountByPeriod,
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 == _MetricMode.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,
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,
}) {
final yearly = _period == _PeriodMode.yearly;
final initial = yearly ? DateTime(period.year, 12) : period;
final labelIds = labelId != null
? {labelId}
: {
// Open with the same labels currently shown on the chart.
for (final id in _includedLabelIds) id,
};
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => TransactionsScreen(
initialMonth: initial,
initialLabelIds: labelIds,
initialSearchInMonthOnly: !yearly && labelIds.isNotEmpty,
initialYear: yearly ? period.year : null,
),
),
);
}
}
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, _PeriodMode mode) {
if (mode == _PeriodMode.yearly) return DateTime(date.year);
return monthStart(date);
}
String _periodAxisLabel(DateTime period, _PeriodMode mode) {
if (mode == _PeriodMode.yearly) return '${period.year}';
return _shortMonth(period);
}
Map<DateTime, _MonthBucket> _flowTotals(
List<BankTransaction> txs,
TxMetaController meta,
List<DateTime> periods,
_PeriodMode 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.
_LabelAggregates _labelAggregates(
List<BankTransaction> txs,
TxMetaController meta,
List<DateTime> periods,
_PeriodMode mode, {
Set<String> includedLabelIds = const {},
TxFlowFilter flow = TxFlowFilter.expense,
}) {
final keys = {for (final p in periods) _periodKey(p, mode)};
final allByLabel = <String, double>{};
final byPeriod = <DateTime, double>{
for (final key in keys) key: 0.0,
};
final amountByPeriod = <DateTime, Map<String, double>>{
for (final key in keys) key: {},
};
final filtering = includedLabelIds.isNotEmpty;
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);
if (ids.isEmpty) continue;
final amount = tx.amount.abs();
for (final id in ids) {
allByLabel[id] = (allByLabel[id] ?? 0) + amount;
}
final matchesFilter =
!filtering || ids.any(includedLabelIds.contains);
if (!matchesFilter) continue;
byPeriod[key] = byPeriod[key]! + amount;
final bucket = amountByPeriod[key]!;
for (final id in ids) {
if (filtering && !includedLabelIds.contains(id)) continue;
bucket[id] = (bucket[id] ?? 0) + amount;
}
}
final allSpend = <_LabelSpend>[
for (final entry in allByLabel.entries)
if (meta.labelById(entry.key) != null)
_LabelSpend(label: meta.labelById(entry.key)!, amount: entry.value),
]..sort((a, b) => b.amount.compareTo(a.amount));
final spend = !filtering
? allSpend
: [
for (final row in allSpend)
if (includedLabelIds.contains(row.label.id)) row,
];
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) {
if (value >= 1000) {
final k = value / 1000;
return k >= 10 ? '${k.round()}k' : '${k.toStringAsFixed(1)}k';
}
return value.round().toString();
}
class _ChartCard extends StatelessWidget {
const _ChartCard({
required this.period,
required this.metric,
required this.periodTitle,
required this.canGoBack,
required this.canGoForward,
required this.onPeriodChanged,
required this.onMetricChanged,
required this.onBack,
required this.onForward,
required this.months,
required this.monthly,
required this.labelSpend,
required this.labelAmountByMonth,
required this.currency,
required this.onLabelTap,
required this.onMonthTap,
required this.onPickLabels,
required this.includedLabelCount,
required this.availableLabelCount,
});
final _PeriodMode period;
final _MetricMode metric;
final String periodTitle;
final bool canGoBack;
final bool canGoForward;
final ValueChanged<_PeriodMode> onPeriodChanged;
final ValueChanged<_MetricMode> onMetricChanged;
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 String currency;
final void Function(TxLabel label) onLabelTap;
final void Function(DateTime month) onMonthTap;
final VoidCallback onPickLabels;
final int? includedLabelCount;
final int availableLabelCount;
@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(20),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 16),
child: Column(
children: [
Row(
children: [
SegmentedButton<_PeriodMode>(
showSelectedIcon: false,
style: const ButtonStyle(
visualDensity: VisualDensity(
horizontal: -4,
vertical: -3,
),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: WidgetStatePropertyAll(
EdgeInsets.symmetric(horizontal: 10),
),
minimumSize: WidgetStatePropertyAll(Size(0, 32)),
),
segments: const [
ButtonSegment(
value: _PeriodMode.monthly,
label: Text('Month'),
),
ButtonSegment(
value: _PeriodMode.yearly,
label: Text('Year'),
),
],
selected: {period},
onSelectionChanged: (selection) {
onPeriodChanged(selection.first);
},
),
if (metric == _MetricMode.labels) ...[
const SizedBox(width: 4),
IconButton(
tooltip: includedLabelCount == null
? 'Filter labels ($availableLabelCount)'
: 'Filter labels ($includedLabelCount/$availableLabelCount)',
onPressed: onPickLabels,
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
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: 18),
),
),
],
const Spacer(),
SegmentedButton<_MetricMode>(
showSelectedIcon: false,
style: const ButtonStyle(
visualDensity: VisualDensity(
horizontal: -4,
vertical: -3,
),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: WidgetStatePropertyAll(
EdgeInsets.symmetric(horizontal: 10),
),
minimumSize: WidgetStatePropertyAll(Size(0, 32)),
),
segments: const [
ButtonSegment(
value: _MetricMode.flow,
label: Text('Flow'),
),
ButtonSegment(
value: _MetricMode.labels,
label: Text('Labels'),
),
],
selected: {metric},
onSelectionChanged: (selection) {
onMetricChanged(selection.first);
},
),
],
),
const SizedBox(height: 8),
if (period == _PeriodMode.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),
),
],
)
else
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(
periodTitle,
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
if (metric == _MetricMode.flow) ...[
const Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
_LegendDot(color: _kIncomeColor, label: 'Income'),
SizedBox(width: 14),
_LegendDot(color: _kExpenseColor, label: 'Expense'),
],
),
const SizedBox(height: 8),
] else if (labelSpend.isNotEmpty) ...[
Align(
alignment: Alignment.centerRight,
child: Wrap(
spacing: 12,
runSpacing: 4,
alignment: WrapAlignment.end,
children: [
for (final row in labelSpend.take(5))
GestureDetector(
onTap: () => onLabelTap(row.label),
child: _LegendDot(
color: row.label.color,
label: row.label.name,
),
),
if (labelSpend.length > 5)
_LegendDot(
color: colors.outline,
label: 'Other',
),
],
),
),
const SizedBox(height: 8),
],
SizedBox(
height: 220,
child: metric == _MetricMode.flow
? _FlowBars(
months: months,
monthly: monthly,
currency: currency,
period: period,
)
: _LabelMonthBars(
months: months,
labelSpend: labelSpend,
amountByMonth: labelAmountByMonth,
currency: currency,
period: period,
onMonthTap: onMonthTap,
),
),
],
),
),
);
}
}
class _FlowBars extends StatelessWidget {
const _FlowBars({
required this.months,
required this.monthly,
required this.currency,
required this.period,
});
final List<DateTime> months;
final Map<DateTime, _MonthBucket> monthly;
final String currency;
final _PeriodMode period;
@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 Center(
child: Text(
period == _PeriodMode.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 == _PeriodMode.yearly ? 16 : 10,
barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData(
getTooltipItem: (group, groupIndex, rod, rodIndex) {
final month = months[group.x.toInt()];
final bucket = monthly[_periodKey(month, period)]!;
final isIncome = rodIndex == 1;
final value = isIncome ? bucket.income : bucket.expense;
return BarTooltipItem(
'${_periodAxisLabel(month, period)}\n'
'${isIncome ? 'Income' : 'Expense'}\n'
'${formatMoney(value, currency)}',
TextStyle(color: colors.onInverseSurface),
);
},
),
),
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 == _PeriodMode.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 == _PeriodMode.yearly ? 14 : 9,
borderRadius:
const BorderRadius.vertical(top: Radius.circular(6)),
color: _kExpenseColor,
),
BarChartRodData(
toY: monthly[_periodKey(months[i], period)]!.income,
width: period == _PeriodMode.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.onMonthTap,
});
static const _maxSeriesLabels = 5;
final List<DateTime> months;
final List<_LabelSpend> labelSpend;
final Map<DateTime, Map<String, double>> amountByMonth;
final String currency;
final _PeriodMode period;
final void Function(DateTime month) onMonthTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
if (labelSpend.isEmpty) {
return Center(
child: Text(
'No labeled expenses 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;
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;
for (final month in months) {
final bucket =
amountByMonth[_periodKey(month, period)] ?? const {};
for (final row in topLabels) {
final value = bucket[row.label.id] ?? 0;
if (value > maxY) maxY = value;
}
final other = otherFor(month);
if (other > maxY) maxY = other;
}
final safeMax = maxY <= 0 ? 1.0 : maxY * 1.15;
if (maxY <= 0) {
return Center(
child: Text(
'No labeled expenses in this period.\nTag transactions to see them here.',
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
);
}
final rodWidth = period == _PeriodMode.yearly
? (seriesCount <= 3 ? 12.0 : 8.0)
: (seriesCount <= 3
? 7.0
: seriesCount <= 5
? 5.0
: 4.0);
return BarChart(
BarChartData(
maxY: safeMax,
groupsSpace: period == _PeriodMode.yearly ? 16 : 10,
barTouchData: BarTouchData(
touchCallback: (event, response) {
if (event is! FlTapUpEvent) return;
final index = response?.spot?.touchedBarGroupIndex;
if (index == null || index < 0 || index >= months.length) return;
onMonthTap(months[index]);
},
touchTooltipData: BarTouchTooltipData(
getTooltipItem: (group, groupIndex, rod, rodIndex) {
final month = months[group.x.toInt()];
final name = rodIndex < topLabels.length
? topLabels[rodIndex].label.name
: 'Other';
return BarTooltipItem(
'${_periodAxisLabel(month, period)}\n'
'$name\n'
'${formatMoney(rod.toY, currency)}',
TextStyle(color: colors.onInverseSurface),
);
},
),
),
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 == _PeriodMode.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: 2,
barRods: [
for (final row in topLabels)
BarChartRodData(
toY: amountByMonth[_periodKey(months[i], period)]
?[row.label.id] ??
0,
width: rodWidth,
borderRadius:
const BorderRadius.vertical(top: Radius.circular(4)),
color: row.label.color,
),
if (hasOther)
BarChartRodData(
toY: otherFor(months[i]),
width: rodWidth,
borderRadius:
const BorderRadius.vertical(top: Radius.circular(4)),
color: otherColor,
),
],
),
],
),
duration: Duration.zero,
);
}
}
class _LegendDot extends StatelessWidget {
const _LegendDot({required this.color, required this.label});
final Color color;
final String label;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Text(label, style: Theme.of(context).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.currency,
required this.onOpenPeriod,
required this.onOpenLabel,
});
final DateTime month;
final String title;
final List<_LabelSpend> rows;
final String currency;
final VoidCallback onOpenPeriod;
final void Function(TxLabel label) onOpenLabel;
@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);
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(-total, currency),
style: theme.textTheme.titleSmall?.copyWith(
color: _kExpenseColor,
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,
),
),
Text(
formatMoney(-rows[i].amount, currency),
style: theme.textTheme.bodyMedium?.copyWith(
color: _kExpenseColor,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
],
],
),
);
}
}