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