611 lines
20 KiB
Dart
611 lines
20 KiB
Dart
/// Overview: current balance, charts, and look-ahead balance forecast.
|
|
library;
|
|
|
|
import 'package:fl_chart/fl_chart.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../models/transaction_meta.dart';
|
|
import '../providers/bank_provider.dart';
|
|
import '../providers/tx_meta_provider.dart';
|
|
import '../utils/helpers.dart';
|
|
import '../utils/look_ahead.dart';
|
|
import '../utils/navigation.dart';
|
|
import '../utils/tx_calendar.dart';
|
|
import '../widgets/empty_state.dart';
|
|
import '../widgets/loading_view.dart';
|
|
import '../widgets/responsive_body.dart';
|
|
import 'label_transactions_screen.dart';
|
|
|
|
enum _StatsMode { category, month }
|
|
|
|
class OverviewScreen extends StatefulWidget {
|
|
const OverviewScreen({super.key});
|
|
|
|
@override
|
|
State<OverviewScreen> createState() => _OverviewScreenState();
|
|
}
|
|
|
|
class _OverviewScreenState extends State<OverviewScreen> {
|
|
_StatsMode _mode = _StatsMode.category;
|
|
var _lookAheadEnabled = false;
|
|
DateTime _lookAheadMonth = monthStart(DateTime.now());
|
|
DateTime _chartMonth = monthStart(DateTime.now());
|
|
|
|
void _goLookAheadMonth(int delta) {
|
|
final next = addMonths(_lookAheadMonth, delta);
|
|
final now = monthStart(DateTime.now());
|
|
if (delta < 0 && next.isBefore(addMonths(now, -24))) return;
|
|
if (delta > 0 && next.isAfter(addMonths(now, 36))) return;
|
|
setState(() => _lookAheadMonth = next);
|
|
}
|
|
|
|
void _goChartMonth(int delta) {
|
|
final next = addMonths(_chartMonth, delta);
|
|
final now = monthStart(DateTime.now());
|
|
if (delta > 0 && next.isAfter(now)) return;
|
|
if (delta < 0 && next.isBefore(addMonths(now, -36))) return;
|
|
setState(() => _chartMonth = next.isAfter(now) ? now : next);
|
|
}
|
|
|
|
void _openLabel(TxLabel label) {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => LabelTransactionsScreen(label: label),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final bank = context.watch<BankController>();
|
|
final meta = context.watch<TxMetaController>();
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Overview'),
|
|
actions: bankAppBarActions(
|
|
context: context,
|
|
onRefresh: bank.refresh,
|
|
refreshing: bank.isLoading,
|
|
),
|
|
),
|
|
body: RefreshIndicator(
|
|
onRefresh: bank.refresh,
|
|
child: ResponsiveBody(
|
|
child: _buildBody(context, bank, meta),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildBody(
|
|
BuildContext context,
|
|
BankController bank,
|
|
TxMetaController meta,
|
|
) {
|
|
final theme = Theme.of(context);
|
|
final colors = theme.colorScheme;
|
|
|
|
if (bank.isLoading && bank.transactions.isEmpty && bank.balance == null) {
|
|
return const LoadingView(message: 'Loading overview…');
|
|
}
|
|
|
|
if (!bank.isLinked) {
|
|
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 currentBalance = bank.balance?.amount ?? 0;
|
|
final byLabel = meta.spendByLabel(bank.transactions);
|
|
final byDay = meta.spendByDayInMonth(bank.transactions, _chartMonth);
|
|
final totalSpend = meta.labeledSpendTotal(bank.transactions);
|
|
|
|
final recurrings = recurringItemsFrom(
|
|
transactions: bank.transactions,
|
|
metaFor: meta.metaFor,
|
|
);
|
|
final today = dateOnly(DateTime.now());
|
|
final lookAheadEnd = DateTime(
|
|
_lookAheadMonth.year,
|
|
_lookAheadMonth.month + 1,
|
|
0,
|
|
);
|
|
final predicted = predictBalance(
|
|
currentBalance: currentBalance,
|
|
from: today,
|
|
throughDate: lookAheadEnd,
|
|
items: recurrings,
|
|
);
|
|
final projected = projectedOccurrences(
|
|
items: recurrings,
|
|
from: today,
|
|
to: lookAheadEnd,
|
|
);
|
|
final nowMonth = monthStart(DateTime.now());
|
|
|
|
return ListView(
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|
padding: const EdgeInsets.only(top: 8, bottom: 32),
|
|
children: [
|
|
Text('Current balance', style: theme.textTheme.labelMedium),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
formatMoney(currentBalance, currency),
|
|
style: theme.textTheme.headlineMedium,
|
|
),
|
|
const SizedBox(height: 20),
|
|
SwitchListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
secondary: const Icon(Icons.auto_graph_outlined),
|
|
title: const Text('Look ahead'),
|
|
subtitle: const Text(
|
|
'Project balance from recurring income and spend',
|
|
),
|
|
value: _lookAheadEnabled,
|
|
onChanged: (value) {
|
|
setState(() {
|
|
_lookAheadEnabled = value;
|
|
if (value && _lookAheadMonth.isBefore(nowMonth)) {
|
|
_lookAheadMonth = nowMonth;
|
|
}
|
|
});
|
|
},
|
|
),
|
|
if (_lookAheadEnabled) ...[
|
|
const SizedBox(height: 8),
|
|
Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(8, 8, 8, 16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
IconButton(
|
|
tooltip: 'Previous month',
|
|
onPressed: () => _goLookAheadMonth(-1),
|
|
icon: const Icon(Icons.chevron_left),
|
|
),
|
|
Expanded(
|
|
child: Text(
|
|
formatMonthYear(_lookAheadMonth),
|
|
textAlign: TextAlign.center,
|
|
style: theme.textTheme.titleMedium,
|
|
),
|
|
),
|
|
IconButton(
|
|
tooltip: 'Next month',
|
|
onPressed: () => _goLookAheadMonth(1),
|
|
icon: const Icon(Icons.chevron_right),
|
|
),
|
|
],
|
|
),
|
|
Text(
|
|
'Predicted balance',
|
|
textAlign: TextAlign.center,
|
|
style: theme.textTheme.labelMedium,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
formatMoney(predicted, currency),
|
|
textAlign: TextAlign.center,
|
|
style: theme.textTheme.headlineMedium?.copyWith(
|
|
color: predicted >= currentBalance
|
|
? colors.primary
|
|
: colors.error,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
_lookAheadMonth == nowMonth && !lookAheadEnd.isAfter(today)
|
|
? 'Mark transactions as recurring to forecast'
|
|
: 'Through ${formatDate(lookAheadEnd)} · '
|
|
'${projected.length} projected '
|
|
'${projected.length == 1 ? 'item' : 'items'}',
|
|
textAlign: TextAlign.center,
|
|
style: theme.textTheme.bodyMedium,
|
|
),
|
|
if (recurrings.isEmpty) ...[
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
'No recurring transactions yet. Open a transaction and '
|
|
'enable Recurring.',
|
|
textAlign: TextAlign.center,
|
|
style: theme.textTheme.bodyMedium,
|
|
),
|
|
] else if (projected.isNotEmpty) ...[
|
|
const SizedBox(height: 12),
|
|
for (final row in projected.take(12))
|
|
ListTile(
|
|
dense: true,
|
|
contentPadding:
|
|
const EdgeInsets.symmetric(horizontal: 8),
|
|
leading: Builder(
|
|
builder: (context) {
|
|
final label = row.item.labelIds.isEmpty
|
|
? null
|
|
: meta.labelById(row.item.labelIds.first);
|
|
return Icon(
|
|
label?.icon ?? Icons.autorenew,
|
|
color: label?.color,
|
|
);
|
|
},
|
|
),
|
|
title: Text(
|
|
row.item.description,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
subtitle: Text(formatDate(row.date)),
|
|
trailing: Text(
|
|
formatMoney(row.item.amount, row.item.currency),
|
|
style: theme.textTheme.titleSmall?.copyWith(
|
|
color: row.item.amount >= 0
|
|
? colors.primary
|
|
: colors.error,
|
|
),
|
|
),
|
|
),
|
|
if (projected.length > 12)
|
|
Text(
|
|
'+${projected.length - 12} more…',
|
|
textAlign: TextAlign.center,
|
|
style: theme.textTheme.labelMedium,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 20),
|
|
Text('Money out (labeled)', style: theme.textTheme.labelMedium),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
formatMoney(totalSpend, currency),
|
|
style: theme.textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 16),
|
|
SegmentedButton<_StatsMode>(
|
|
segments: const [
|
|
ButtonSegment(
|
|
value: _StatsMode.category,
|
|
label: Text('Label'),
|
|
icon: Icon(Icons.label_outline),
|
|
),
|
|
ButtonSegment(
|
|
value: _StatsMode.month,
|
|
label: Text('By day'),
|
|
icon: Icon(Icons.bar_chart_outlined),
|
|
),
|
|
],
|
|
selected: {_mode},
|
|
onSelectionChanged: (selection) {
|
|
setState(() => _mode = selection.first);
|
|
},
|
|
),
|
|
if (_mode == _StatsMode.month) ...[
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
IconButton(
|
|
tooltip: 'Previous month',
|
|
onPressed: () => _goChartMonth(-1),
|
|
icon: const Icon(Icons.chevron_left),
|
|
),
|
|
Expanded(
|
|
child: Text(
|
|
formatMonthYear(_chartMonth),
|
|
textAlign: TextAlign.center,
|
|
style: theme.textTheme.titleSmall,
|
|
),
|
|
),
|
|
IconButton(
|
|
tooltip: 'Next month',
|
|
onPressed: _chartMonth.isBefore(nowMonth)
|
|
? () => _goChartMonth(1)
|
|
: null,
|
|
icon: const Icon(Icons.chevron_right),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
const SizedBox(height: 12),
|
|
SizedBox(
|
|
height: 260,
|
|
child: bank.transactions.isEmpty
|
|
? Center(
|
|
child: Text(
|
|
'Transactions will appear here once loaded.',
|
|
style: theme.textTheme.bodyMedium,
|
|
),
|
|
)
|
|
: _mode == _StatsMode.category
|
|
? _LabelBars(
|
|
data: byLabel,
|
|
resolve: meta.labelById,
|
|
currency: currency,
|
|
onBarTap: _openLabel,
|
|
)
|
|
: _DayBars(data: byDay, currency: currency),
|
|
),
|
|
const SizedBox(height: 20),
|
|
if (_mode == _StatsMode.category)
|
|
..._labelLegend(context, byLabel, meta, currency)
|
|
else
|
|
..._dayLegend(context, byDay, currency),
|
|
],
|
|
);
|
|
}
|
|
|
|
List<Widget> _labelLegend(
|
|
BuildContext context,
|
|
Map<String, double> data,
|
|
TxMetaController meta,
|
|
String currency,
|
|
) {
|
|
final entries = data.entries.toList()
|
|
..sort((a, b) => b.value.compareTo(a.value));
|
|
if (entries.isEmpty) {
|
|
return [
|
|
Text(
|
|
'No labeled outflows yet. Add a label from a transaction.',
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
),
|
|
];
|
|
}
|
|
return [
|
|
for (final entry in entries)
|
|
Builder(
|
|
builder: (context) {
|
|
final label = meta.labelById(entry.key);
|
|
if (label == null) return const SizedBox.shrink();
|
|
return ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
dense: true,
|
|
leading: Icon(label.icon, color: label.color),
|
|
title: Text(label.name),
|
|
trailing: Text(formatMoney(entry.value, currency)),
|
|
onTap: () => _openLabel(label),
|
|
);
|
|
},
|
|
),
|
|
];
|
|
}
|
|
|
|
List<Widget> _dayLegend(
|
|
BuildContext context,
|
|
Map<String, double> data,
|
|
String currency,
|
|
) {
|
|
final spent = data.entries.where((e) => e.value > 0).toList().reversed;
|
|
if (spent.isEmpty) {
|
|
return [
|
|
Text(
|
|
'No outflows this month.',
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
),
|
|
];
|
|
}
|
|
return [
|
|
for (final entry in spent)
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
dense: true,
|
|
leading: const Icon(Icons.calendar_today_outlined),
|
|
title: Text(entry.key),
|
|
trailing: Text(formatMoney(entry.value, currency)),
|
|
),
|
|
];
|
|
}
|
|
}
|
|
|
|
class _LabelBars extends StatelessWidget {
|
|
const _LabelBars({
|
|
required this.data,
|
|
required this.resolve,
|
|
required this.currency,
|
|
required this.onBarTap,
|
|
});
|
|
|
|
final Map<String, double> data;
|
|
final TxLabel? Function(String? id) resolve;
|
|
final String currency;
|
|
final void Function(TxLabel label) onBarTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final entries = <({TxLabel label, double value})>[
|
|
for (final entry in data.entries)
|
|
if (resolve(entry.key) != null)
|
|
(label: resolve(entry.key)!, value: entry.value),
|
|
]..sort((a, b) => b.value.compareTo(a.value));
|
|
|
|
if (entries.isEmpty) {
|
|
return Center(
|
|
child: Text(
|
|
'Label transactions to see this chart',
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
);
|
|
}
|
|
|
|
final maxY = entries.map((e) => e.value).reduce((a, b) => a > b ? a : b);
|
|
return BarChart(
|
|
BarChartData(
|
|
maxY: maxY * 1.15,
|
|
barTouchData: BarTouchData(
|
|
touchCallback: (event, response) {
|
|
if (!event.isInterestedForInteractions) return;
|
|
final index = response?.spot?.touchedBarGroupIndex;
|
|
if (index == null || index < 0 || index >= entries.length) return;
|
|
if (event is FlTapUpEvent) {
|
|
onBarTap(entries[index].label);
|
|
}
|
|
},
|
|
touchTooltipData: BarTouchTooltipData(
|
|
getTooltipItem: (group, groupIndex, rod, rodIndex) {
|
|
final entry = entries[group.x.toInt()];
|
|
return BarTooltipItem(
|
|
'${entry.label.name}\n${formatMoney(entry.value, currency)}',
|
|
TextStyle(
|
|
color: Theme.of(context).colorScheme.onInverseSurface,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
titlesData: FlTitlesData(
|
|
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
rightTitles:
|
|
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
bottomTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
reservedSize: 42,
|
|
getTitlesWidget: (value, meta) {
|
|
final index = value.toInt();
|
|
if (index < 0 || index >= entries.length) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
final label = entries[index].label.name;
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: Text(
|
|
label.length > 8 ? '${label.substring(0, 7)}…' : label,
|
|
style: Theme.of(context).textTheme.labelSmall,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
gridData: const FlGridData(show: false),
|
|
borderData: FlBorderData(show: false),
|
|
barGroups: [
|
|
for (var i = 0; i < entries.length; i++)
|
|
BarChartGroupData(
|
|
x: i,
|
|
barRods: [
|
|
BarChartRodData(
|
|
toY: entries[i].value,
|
|
width: 18,
|
|
borderRadius:
|
|
const BorderRadius.vertical(top: Radius.circular(6)),
|
|
color: entries[i].label.color,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DayBars extends StatelessWidget {
|
|
const _DayBars({required this.data, required this.currency});
|
|
|
|
final Map<String, double> data;
|
|
final String currency;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final entries = data.entries.toList();
|
|
if (entries.every((e) => e.value == 0)) {
|
|
return Center(
|
|
child: Text(
|
|
'No spend this month',
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
),
|
|
);
|
|
}
|
|
|
|
final maxY = entries.map((e) => e.value).fold<double>(0, (a, b) => a > b ? a : b);
|
|
final safeMax = maxY <= 0 ? 1.0 : maxY * 1.15;
|
|
final colors = Theme.of(context).colorScheme;
|
|
return BarChart(
|
|
BarChartData(
|
|
maxY: safeMax,
|
|
barTouchData: BarTouchData(
|
|
touchTooltipData: BarTouchTooltipData(
|
|
getTooltipItem: (group, groupIndex, rod, rodIndex) {
|
|
final entry = entries[group.x.toInt()];
|
|
final day = entry.key.length >= 10
|
|
? entry.key.substring(8)
|
|
: entry.key;
|
|
return BarTooltipItem(
|
|
'Day $day\n${formatMoney(entry.value, currency)}',
|
|
TextStyle(color: colors.onInverseSurface),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
titlesData: FlTitlesData(
|
|
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
rightTitles:
|
|
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
bottomTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
reservedSize: 28,
|
|
interval: entries.length > 16 ? 5 : 2,
|
|
getTitlesWidget: (value, meta) {
|
|
final index = value.toInt();
|
|
if (index < 0 || index >= entries.length) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
if (entries.length > 16 && index % 5 != 0) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
final day = (index + 1).toString();
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: Text(
|
|
day,
|
|
style: Theme.of(context).textTheme.labelSmall,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
gridData: const FlGridData(show: false),
|
|
borderData: FlBorderData(show: false),
|
|
barGroups: [
|
|
for (var i = 0; i < entries.length; i++)
|
|
BarChartGroupData(
|
|
x: i,
|
|
barRods: [
|
|
BarChartRodData(
|
|
toY: entries[i].value,
|
|
width: entries.length > 20 ? 6 : 8,
|
|
borderRadius:
|
|
const BorderRadius.vertical(top: Radius.circular(3)),
|
|
color: entries[i].value > 0
|
|
? colors.primary
|
|
: colors.surfaceContainerHighest,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|