1527 lines
48 KiB
Dart
1527 lines
48 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/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 }
|
||
|
||
/// Distinct, intuitive colors (avoid two greens).
|
||
const _kIncomeColor = Color(0xFF2563EB); // blue
|
||
const _kExpenseColor = Color(0xFFDC2626); // red
|
||
|
||
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 = {};
|
||
|
||
/// 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,
|
||
List<TxLabel> available,
|
||
) async {
|
||
if (available.isEmpty) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('No labels in use for this period yet')),
|
||
);
|
||
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,
|
||
);
|
||
if (result == null || !mounted) return;
|
||
|
||
setState(() {
|
||
final allIds = available.map((l) => l.id).toSet();
|
||
if (result.isEmpty ||
|
||
(result.length == allIds.length && result.containsAll(allIds))) {
|
||
_includedLabelIds.clear();
|
||
} else {
|
||
_includedLabelIds
|
||
..clear()
|
||
..addAll(result);
|
||
}
|
||
_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>[];
|
||
var availableLabels = const <TxLabel>[];
|
||
Map<DateTime, Map<String, double>> labelAmountByPeriod = const {};
|
||
|
||
if (_metric == _MetricMode.flow) {
|
||
monthly = _flowTotals(txs, periods, _period);
|
||
} else {
|
||
final labels = _labelAggregates(
|
||
txs,
|
||
meta,
|
||
periods,
|
||
_period,
|
||
includedLabelIds: _includedLabelIds,
|
||
);
|
||
labelSpend = labels.spend;
|
||
availableLabels = [for (final row in labels.allSpend) row.label];
|
||
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
|
||
? null
|
||
: _includedLabelIds.length,
|
||
availableLabelCount: availableLabels.length,
|
||
onPickLabels: () => _pickChartLabels(context, availableLabels),
|
||
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.12),
|
||
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.12),
|
||
iconColor: _kExpenseColor,
|
||
amount: formatMoney(summaryFlow.expense, currency),
|
||
label: 'Expenses',
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
_period == _PeriodMode.monthly
|
||
? 'Totals for ${_cursor.year}'
|
||
: 'Totals across all years',
|
||
style: theme.textTheme.labelMedium,
|
||
),
|
||
] else ...[
|
||
_SummaryCard(
|
||
icon: Icons.label_outline,
|
||
iconBackground:
|
||
theme.colorScheme.secondaryContainer.withValues(alpha: 0.6),
|
||
iconColor: theme.colorScheme.onSecondaryContainer,
|
||
amount: formatMoney(
|
||
labelSpend.fold<double>(0, (s, e) => s + e.amount),
|
||
currency,
|
||
),
|
||
label: 'Labeled spend · $_periodTitle',
|
||
),
|
||
],
|
||
const SizedBox(height: 20),
|
||
Text(
|
||
_metric == _MetricMode.flow
|
||
? 'History · Income & expenses'
|
||
: 'History · Labels',
|
||
style: theme.textTheme.titleLarge,
|
||
),
|
||
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,
|
||
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) {
|
||
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 outflows: totals, per-period series, and history amounts.
|
||
_LabelAggregates _labelAggregates(
|
||
List<BankTransaction> txs,
|
||
TxMetaController meta,
|
||
List<DateTime> periods,
|
||
_PeriodMode mode, {
|
||
Set<String> includedLabelIds = const {},
|
||
}) {
|
||
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 (tx.amount >= 0) 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.surface,
|
||
elevation: 0,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(24),
|
||
side: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.5)),
|
||
),
|
||
child: Padding(
|
||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 16),
|
||
child: Column(
|
||
children: [
|
||
Row(
|
||
children: [
|
||
SegmentedButton<_PeriodMode>(
|
||
showSelectedIcon: false,
|
||
style: ButtonStyle(
|
||
visualDensity: const VisualDensity(
|
||
horizontal: -4,
|
||
vertical: -3,
|
||
),
|
||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||
padding: const WidgetStatePropertyAll(
|
||
EdgeInsets.symmetric(horizontal: 8),
|
||
),
|
||
minimumSize: const WidgetStatePropertyAll(Size(0, 28)),
|
||
textStyle: WidgetStatePropertyAll(
|
||
theme.textTheme.labelSmall?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
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: 2),
|
||
IconButton(
|
||
tooltip: includedLabelCount == null
|
||
? 'Filter labels ($availableLabelCount)'
|
||
: 'Filter labels ($includedLabelCount/$availableLabelCount)',
|
||
onPressed: onPickLabels,
|
||
visualDensity: const VisualDensity(
|
||
horizontal: -4,
|
||
vertical: -4,
|
||
),
|
||
constraints: const BoxConstraints(
|
||
minWidth: 28,
|
||
minHeight: 28,
|
||
),
|
||
padding: EdgeInsets.zero,
|
||
style: IconButton.styleFrom(
|
||
backgroundColor: includedLabelCount == null
|
||
? null
|
||
: colors.secondaryContainer,
|
||
),
|
||
icon: Badge(
|
||
isLabelVisible: includedLabelCount != null,
|
||
smallSize: 6,
|
||
child: Icon(
|
||
Icons.filter_list,
|
||
size: 18,
|
||
color: includedLabelCount == null
|
||
? colors.onSurfaceVariant
|
||
: colors.onSecondaryContainer,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
const Spacer(),
|
||
SegmentedButton<_MetricMode>(
|
||
showSelectedIcon: false,
|
||
style: ButtonStyle(
|
||
visualDensity: const VisualDensity(
|
||
horizontal: -4,
|
||
vertical: -3,
|
||
),
|
||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||
padding: const WidgetStatePropertyAll(
|
||
EdgeInsets.symmetric(horizontal: 8),
|
||
),
|
||
minimumSize: const WidgetStatePropertyAll(Size(0, 28)),
|
||
textStyle: WidgetStatePropertyAll(
|
||
theme.textTheme.labelSmall?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
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: 6),
|
||
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.labelLarge?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
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.labelLarge?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
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.45),
|
||
strokeWidth: 1,
|
||
),
|
||
),
|
||
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.45),
|
||
strokeWidth: 1,
|
||
),
|
||
),
|
||
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.surface,
|
||
elevation: 0,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(20),
|
||
side: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.5)),
|
||
),
|
||
child: Padding(
|
||
padding: const EdgeInsets.fromLTRB(12, 14, 12, 14),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 40,
|
||
height: 40,
|
||
decoration: BoxDecoration(
|
||
color: iconBackground,
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Icon(icon, color: iconColor, size: 22),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
amount,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: theme.textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
Text(label, style: theme.textTheme.labelMedium),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
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;
|
||
|
||
return Card(
|
||
color: colors.surface,
|
||
elevation: 0,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(18),
|
||
side: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.5)),
|
||
),
|
||
child: InkWell(
|
||
borderRadius: BorderRadius.circular(18),
|
||
onTap: onTap,
|
||
child: Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
month.month == 1 && title.length == 4 ? 'Year' : 'Month',
|
||
style: theme.textTheme.labelMedium,
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
title.length == 4 && int.tryParse(title) != null
|
||
? title
|
||
: formatMonthYear(month),
|
||
style: theme.textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Column(
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
Text(
|
||
formatMoney(income, currency),
|
||
style: theme.textTheme.titleSmall?.copyWith(
|
||
color: _kIncomeColor,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
formatMoney(-expense, currency),
|
||
style: theme.textTheme.titleSmall?.copyWith(
|
||
color: _kExpenseColor,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
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.surface,
|
||
elevation: 0,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(18),
|
||
side: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.5)),
|
||
),
|
||
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.labelMedium,
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
periodLabel,
|
||
style: theme.textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Text(
|
||
formatMoney(-total, currency),
|
||
style: theme.textTheme.titleSmall?.copyWith(
|
||
color: _kExpenseColor,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
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,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|