1763 lines
55 KiB
Dart
1763 lines
55 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/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;
|
||
|
||
/// 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;
|
||
|
||
@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,
|
||
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();
|
||
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),
|
||
],
|
||
_ChartToolbar(
|
||
period: _period,
|
||
metric: _metric,
|
||
onPeriodChanged: _setPeriod,
|
||
onMetricChanged: (value) => setState(() => _metric = value),
|
||
),
|
||
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) => _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 => _labelInvertChart
|
||
? 'Labeled net (expense view) · $_periodTitle'
|
||
: 'Labeled net · $_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,
|
||
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 _MetricMode metric,
|
||
required _PeriodMode 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 == _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,
|
||
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,
|
||
}) {
|
||
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.
|
||
///
|
||
/// 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.
|
||
_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 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 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);
|
||
if (ids.isEmpty) continue;
|
||
final amount = tx.amount;
|
||
|
||
for (final id in ids) {
|
||
allByLabel[id] = (allByLabel[id] ?? 0) + amount;
|
||
}
|
||
|
||
final matchesFilter =
|
||
!filtering || ids.any(includedLabelIds.contains);
|
||
if (!matchesFilter) continue;
|
||
|
||
byPeriodSigned[key] = byPeriodSigned[key]! + amount;
|
||
final bucket = amountByPeriodSigned[key]!;
|
||
for (final id in ids) {
|
||
if (filtering && !includedLabelIds.contains(id)) continue;
|
||
bucket[id] = (bucket[id] ?? 0) + amount;
|
||
}
|
||
}
|
||
|
||
double displayAmount(double signed) {
|
||
if (!keepSigned) return signed.abs();
|
||
return signed;
|
||
}
|
||
|
||
final allSpend = <_LabelSpend>[
|
||
for (final entry in allByLabel.entries)
|
||
if (meta.labelById(entry.key) != null && entry.value != 0)
|
||
_LabelSpend(
|
||
label: meta.labelById(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.onPeriodChanged,
|
||
required this.onMetricChanged,
|
||
});
|
||
|
||
final _PeriodMode period;
|
||
final _MetricMode metric;
|
||
final ValueChanged<_PeriodMode> onPeriodChanged;
|
||
final ValueChanged<_MetricMode> onMetricChanged;
|
||
|
||
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) {
|
||
return Row(
|
||
children: [
|
||
SegmentedButton<_PeriodMode>(
|
||
showSelectedIcon: false,
|
||
style: _segmentStyle,
|
||
segments: const [
|
||
ButtonSegment(
|
||
value: _PeriodMode.monthly,
|
||
label: Text('Month'),
|
||
),
|
||
ButtonSegment(
|
||
value: _PeriodMode.yearly,
|
||
label: Text('Year'),
|
||
),
|
||
],
|
||
selected: {period},
|
||
onSelectionChanged: (selection) {
|
||
onPeriodChanged(selection.first);
|
||
},
|
||
),
|
||
const Spacer(),
|
||
SegmentedButton<_MetricMode>(
|
||
showSelectedIcon: false,
|
||
style: _segmentStyle,
|
||
segments: const [
|
||
ButtonSegment(
|
||
value: _MetricMode.flow,
|
||
label: Text('Flow'),
|
||
),
|
||
ButtonSegment(
|
||
value: _MetricMode.labels,
|
||
label: Text('Labels'),
|
||
),
|
||
],
|
||
selected: {metric},
|
||
onSelectionChanged: (selection) {
|
||
onMetricChanged(selection.first);
|
||
},
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ChartCard extends StatelessWidget {
|
||
const _ChartCard({
|
||
required this.period,
|
||
required this.metric,
|
||
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,
|
||
});
|
||
|
||
final _PeriodMode period;
|
||
final _MetricMode metric;
|
||
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) onMonthTap;
|
||
|
||
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),
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final colors = theme.colorScheme;
|
||
final showFilter = metric == _MetricMode.labels;
|
||
|
||
return Card(
|
||
color: colors.surfaceContainer,
|
||
elevation: 0,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(20),
|
||
),
|
||
child: Padding(
|
||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 16),
|
||
child: Column(
|
||
children: [
|
||
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),
|
||
),
|
||
if (showFilter) ...[
|
||
const SizedBox(width: 2),
|
||
_filterButton(colors),
|
||
],
|
||
],
|
||
)
|
||
else
|
||
Row(
|
||
children: [
|
||
// Balance the filter so the title stays centered.
|
||
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 (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,
|
||
barStyle: labelBarStyle,
|
||
invertChart: invertChart,
|
||
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.barStyle,
|
||
required this.invertChart,
|
||
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 LabelChartBarStyle barStyle;
|
||
final bool invertChart;
|
||
final void Function(DateTime month) onMonthTap;
|
||
|
||
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 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 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 == _PeriodMode.yearly ? 18.0 : 14.0)
|
||
: period == _PeriodMode.yearly
|
||
? (seriesCount <= 3 ? 12.0 : 8.0)
|
||
: (seriesCount <= 3
|
||
? 7.0
|
||
: seriesCount <= 5
|
||
? 5.0
|
||
: 4.0);
|
||
|
||
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 == _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()];
|
||
if (stacked && rod.rodStackItems.isNotEmpty) {
|
||
final bucket =
|
||
amountByMonth[_periodKey(month, period)] ?? const {};
|
||
final buffer = StringBuffer(
|
||
'${_periodAxisLabel(month, period)}\n',
|
||
);
|
||
var total = 0.0;
|
||
for (final row in topLabels) {
|
||
final amount = bucket[row.label.id] ?? 0;
|
||
if (amount == 0) continue;
|
||
total += amount;
|
||
buffer.writeln(
|
||
'${row.label.name}: ${formatMoney(amount, currency)}',
|
||
);
|
||
}
|
||
final other = otherFor(month);
|
||
if (other != 0) {
|
||
total += other;
|
||
buffer.writeln('Other: ${formatMoney(other, currency)}');
|
||
}
|
||
buffer.write('Total: ${formatMoney(total, currency)}');
|
||
return BarTooltipItem(
|
||
buffer.toString(),
|
||
TextStyle(color: colors.onInverseSurface),
|
||
);
|
||
}
|
||
final name = rodIndex < topLabels.length
|
||
? topLabels[rodIndex].label.name
|
||
: 'Other';
|
||
return BarTooltipItem(
|
||
'${_periodAxisLabel(month, period)}\n'
|
||
'$name\n'
|
||
'${formatMoney(invertChart ? -rod.toY : 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: 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 == _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: 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});
|
||
|
||
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.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,
|
||
),
|
||
);
|
||
},
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|