ok for now

This commit is contained in:
davmar
2026-09-06 21:58:59 +02:00
parent 773187f0af
commit 87afe271b7
20 changed files with 2271 additions and 377 deletions
+517
View File
@@ -0,0 +1,517 @@
/// 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';
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());
void _goLookAheadMonth(int delta) {
final next = addMonths(_lookAheadMonth, delta);
final now = monthStart(DateTime.now());
// Look ahead can go forward; still allow browsing past months.
if (delta < 0 && next.isBefore(addMonths(now, -24))) return;
if (delta > 0 && next.isAfter(addMonths(now, 36))) return;
setState(() => _lookAheadMonth = next);
}
@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 byCategory = meta.spendByCategory(bank.transactions);
final byMonth = meta.spendByMonth(bank.transactions);
final totalSpend = byCategory.values.fold<double>(0, (sum, v) => sum + v);
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: Icon(
row.item.category.icon,
color: row.item.category.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 (categorized)', 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('Category'),
icon: Icon(Icons.label_outline),
),
ButtonSegment(
value: _StatsMode.month,
label: Text('By date'),
icon: Icon(Icons.calendar_month_outlined),
),
],
selected: {_mode},
onSelectionChanged: (selection) {
setState(() => _mode = selection.first);
},
),
const SizedBox(height: 20),
SizedBox(
height: 260,
child: bank.transactions.isEmpty
? Center(
child: Text(
'Transactions will appear here once loaded.',
style: theme.textTheme.bodyMedium,
),
)
: _mode == _StatsMode.category
? _CategoryBars(data: byCategory, currency: currency)
: _MonthBars(data: byMonth, currency: currency),
),
const SizedBox(height: 20),
if (_mode == _StatsMode.category)
..._categoryLegend(context, byCategory, currency)
else
..._monthLegend(context, byMonth, currency),
],
);
}
List<Widget> _categoryLegend(
BuildContext context,
Map<TxCategory, double> data,
String currency,
) {
final entries = data.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
if (entries.isEmpty) {
return [
Text(
'No outflows yet. Categorize spending from the transaction list.',
style: Theme.of(context).textTheme.bodyMedium,
),
];
}
return [
for (final entry in entries)
ListTile(
contentPadding: EdgeInsets.zero,
dense: true,
leading: Icon(entry.key.icon, color: entry.key.color),
title: Text(entry.key.label),
trailing: Text(formatMoney(entry.value, currency)),
),
];
}
List<Widget> _monthLegend(
BuildContext context,
Map<String, double> data,
String currency,
) {
if (data.isEmpty) {
return [
Text(
'No dated outflows to chart yet.',
style: Theme.of(context).textTheme.bodyMedium,
),
];
}
return [
for (final entry in data.entries.toList().reversed)
ListTile(
contentPadding: EdgeInsets.zero,
dense: true,
leading: const Icon(Icons.calendar_today_outlined),
title: Text(entry.key),
trailing: Text(formatMoney(entry.value, currency)),
),
];
}
}
class _CategoryBars extends StatelessWidget {
const _CategoryBars({required this.data, required this.currency});
final Map<TxCategory, double> data;
final String currency;
@override
Widget build(BuildContext context) {
final entries = data.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
if (entries.isEmpty) {
return Center(
child: Text(
'Categorize 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(
touchTooltipData: BarTouchTooltipData(
getTooltipItem: (group, groupIndex, rod, rodIndex) {
final entry = entries[group.x.toInt()];
return BarTooltipItem(
'${entry.key.label}\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].key.label;
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].key.color,
),
],
),
],
),
);
}
}
class _MonthBars extends StatelessWidget {
const _MonthBars({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.isEmpty) {
return Center(
child: Text(
'No monthly spend yet',
style: Theme.of(context).textTheme.bodyMedium,
),
);
}
final maxY = entries.map((e) => e.value).reduce((a, b) => a > b ? a : b);
final colors = Theme.of(context).colorScheme;
return BarChart(
BarChartData(
maxY: maxY * 1.15,
barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData(
getTooltipItem: (group, groupIndex, rod, rodIndex) {
final entry = entries[group.x.toInt()];
return BarTooltipItem(
'${entry.key}\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,
getTitlesWidget: (value, meta) {
final index = value.toInt();
if (index < 0 || index >= entries.length) {
return const SizedBox.shrink();
}
final key = entries[index].key;
final short = key.length >= 7 ? key.substring(5) : key;
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
short,
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: 22,
borderRadius:
const BorderRadius.vertical(top: Radius.circular(6)),
color: colors.primary,
),
],
),
],
),
);
}
}