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