632 lines
20 KiB
Dart
632 lines
20 KiB
Dart
/// Overview: monthly income/expense chart, summary cards, and day history.
|
|
library;
|
|
|
|
import 'package:fl_chart/fl_chart.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../models/bank.dart';
|
|
import '../providers/bank_provider.dart';
|
|
import '../utils/helpers.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 'transactions_screen.dart';
|
|
|
|
class OverviewScreen extends StatefulWidget {
|
|
const OverviewScreen({super.key});
|
|
|
|
@override
|
|
State<OverviewScreen> createState() => _OverviewScreenState();
|
|
}
|
|
|
|
class _OverviewScreenState extends State<OverviewScreen> {
|
|
static const _chartMonths = 6;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final bank = context.watch<BankController>();
|
|
|
|
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),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildBody(BuildContext context, BankController bank) {
|
|
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 && !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 months = _lastMonths(_chartMonths);
|
|
final monthly = _monthlyTotals(bank.transactions, months);
|
|
final nowMonth = monthStart(DateTime.now());
|
|
final current = monthly[nowMonth] ??
|
|
const _MonthBucket(income: 0, expense: 0);
|
|
final history = _monthHistory(bank.transactions);
|
|
final incomeColor = colors.primary;
|
|
final expenseColor = const Color(0xFF6FCF97);
|
|
|
|
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: colors.error),
|
|
),
|
|
const SizedBox(height: 12),
|
|
],
|
|
_ChartCard(
|
|
months: months,
|
|
monthly: monthly,
|
|
currency: currency,
|
|
incomeColor: incomeColor,
|
|
expenseColor: expenseColor,
|
|
),
|
|
const SizedBox(height: 14),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _SummaryCard(
|
|
icon: Icons.savings_outlined,
|
|
iconBackground: incomeColor.withValues(alpha: 0.14),
|
|
iconColor: incomeColor,
|
|
amount: formatMoney(current.income, currency),
|
|
label: 'Income',
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: _SummaryCard(
|
|
icon: Icons.receipt_long_outlined,
|
|
iconBackground: expenseColor.withValues(alpha: 0.18),
|
|
iconColor: const Color(0xFF2F9E62),
|
|
amount: formatMoney(current.expense, currency),
|
|
label: 'Expenses',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
formatMonthYear(nowMonth),
|
|
style: theme.textTheme.labelMedium,
|
|
),
|
|
const SizedBox(height: 20),
|
|
Text('History', style: theme.textTheme.titleLarge),
|
|
const SizedBox(height: 12),
|
|
if (history.isEmpty)
|
|
Text(
|
|
'No monthly activity to show.',
|
|
style: theme.textTheme.bodyMedium,
|
|
)
|
|
else
|
|
for (final month in history) ...[
|
|
_HistoryCard(
|
|
month: month.month,
|
|
income: month.income,
|
|
expense: month.expense,
|
|
currency: currency,
|
|
onTap: () => _openMonth(context, month.month),
|
|
),
|
|
const SizedBox(height: 10),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
void _openMonth(BuildContext context, DateTime month) {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => TransactionsScreen(initialMonth: month),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MonthBucket {
|
|
const _MonthBucket({required this.income, required this.expense});
|
|
final double income;
|
|
final double expense;
|
|
}
|
|
|
|
class _MonthHistoryRow {
|
|
const _MonthHistoryRow({
|
|
required this.month,
|
|
required this.income,
|
|
required this.expense,
|
|
});
|
|
final DateTime month;
|
|
final double income;
|
|
final double expense;
|
|
}
|
|
|
|
List<DateTime> _lastMonths(int count) {
|
|
final now = monthStart(DateTime.now());
|
|
return [
|
|
for (var i = count - 1; i >= 0; i--) addMonths(now, -i),
|
|
];
|
|
}
|
|
|
|
DateTime? _txDate(BankTransaction tx) {
|
|
final parsed = DateTime.tryParse(tx.date);
|
|
return parsed == null ? null : dateOnly(parsed);
|
|
}
|
|
|
|
Map<DateTime, _MonthBucket> _monthlyTotals(
|
|
List<BankTransaction> txs,
|
|
List<DateTime> months,
|
|
) {
|
|
final totals = {
|
|
for (final month in months) month: (income: 0.0, expense: 0.0),
|
|
};
|
|
for (final tx in txs) {
|
|
final date = _txDate(tx);
|
|
if (date == null) continue;
|
|
final key = monthStart(date);
|
|
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 e in totals.entries)
|
|
e.key: _MonthBucket(income: e.value.income, expense: e.value.expense),
|
|
};
|
|
}
|
|
|
|
/// Months that have activity, newest first.
|
|
List<_MonthHistoryRow> _monthHistory(List<BankTransaction> txs) {
|
|
final map = <DateTime, ({double income, double expense})>{};
|
|
for (final tx in txs) {
|
|
final date = _txDate(tx);
|
|
if (date == null) continue;
|
|
final key = monthStart(date);
|
|
final current = map[key] ?? (income: 0.0, expense: 0.0);
|
|
if (tx.amount >= 0) {
|
|
map[key] = (income: current.income + tx.amount, expense: current.expense);
|
|
} else {
|
|
map[key] = (
|
|
income: current.income,
|
|
expense: current.expense + tx.amount.abs(),
|
|
);
|
|
}
|
|
}
|
|
final keys = map.keys.toList()..sort((a, b) => b.compareTo(a));
|
|
return [
|
|
for (final key in keys)
|
|
_MonthHistoryRow(
|
|
month: key,
|
|
income: map[key]!.income,
|
|
expense: map[key]!.expense,
|
|
),
|
|
];
|
|
}
|
|
|
|
class _ChartCard extends StatelessWidget {
|
|
const _ChartCard({
|
|
required this.months,
|
|
required this.monthly,
|
|
required this.currency,
|
|
required this.incomeColor,
|
|
required this.expenseColor,
|
|
});
|
|
|
|
final List<DateTime> months;
|
|
final Map<DateTime, _MonthBucket> monthly;
|
|
final String currency;
|
|
final Color incomeColor;
|
|
final Color expenseColor;
|
|
|
|
@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[month]!;
|
|
if (bucket.income > maxY) maxY = bucket.income;
|
|
if (bucket.expense > maxY) maxY = bucket.expense;
|
|
}
|
|
final safeMax = maxY <= 0 ? 1.0 : maxY * 1.15;
|
|
|
|
return Card(
|
|
color: colors.surface,
|
|
elevation: 0,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(24),
|
|
side: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.5)),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Container(
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: colors.surfaceContainerHighest.withValues(alpha: 0.55),
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text('Monthly', style: theme.textTheme.labelLarge),
|
|
const SizedBox(width: 4),
|
|
Icon(
|
|
Icons.keyboard_arrow_down,
|
|
size: 18,
|
|
color: colors.onSurfaceVariant,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Spacer(),
|
|
_LegendDot(color: incomeColor, label: 'Income'),
|
|
const SizedBox(width: 14),
|
|
_LegendDot(color: expenseColor, label: 'Expense'),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
SizedBox(
|
|
height: 220,
|
|
child: months.every(
|
|
(m) =>
|
|
monthly[m]!.income == 0 && monthly[m]!.expense == 0,
|
|
)
|
|
? Center(
|
|
child: Text(
|
|
'No income or expenses in this period',
|
|
style: theme.textTheme.bodyMedium,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
)
|
|
: BarChart(
|
|
BarChartData(
|
|
maxY: safeMax,
|
|
groupsSpace: 18,
|
|
barTouchData: BarTouchData(
|
|
touchTooltipData: BarTouchTooltipData(
|
|
getTooltipItem: (group, groupIndex, rod, rodIndex) {
|
|
final month = months[group.x.toInt()];
|
|
final bucket = monthly[month]!;
|
|
final isIncome = rodIndex == 1;
|
|
final value =
|
|
isIncome ? bucket.income : bucket.expense;
|
|
return BarTooltipItem(
|
|
'${_shortMonth(month)}\n'
|
|
'${isIncome ? 'Income' : 'Expense'}\n'
|
|
'${formatMoney(value, currency)}',
|
|
TextStyle(color: colors.onInverseSurface),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
titlesData: FlTitlesData(
|
|
topTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false),
|
|
),
|
|
rightTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false),
|
|
),
|
|
leftTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
reservedSize: 42,
|
|
interval: safeMax / 3,
|
|
getTitlesWidget: (value, meta) {
|
|
if (value <= 0) {
|
|
return Text(
|
|
'0',
|
|
style: theme.textTheme.labelSmall,
|
|
);
|
|
}
|
|
if ((value - safeMax).abs() < 0.01) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
return Text(
|
|
_compactMoney(value),
|
|
style: theme.textTheme.labelSmall,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
bottomTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
reservedSize: 28,
|
|
getTitlesWidget: (value, meta) {
|
|
final index = value.toInt();
|
|
if (index < 0 || index >= months.length) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: Text(
|
|
_shortMonth(months[index]),
|
|
style: theme.textTheme.labelSmall,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
gridData: FlGridData(
|
|
show: true,
|
|
drawVerticalLine: false,
|
|
horizontalInterval: safeMax / 3,
|
|
getDrawingHorizontalLine: (value) => FlLine(
|
|
color: colors.outlineVariant.withValues(alpha: 0.45),
|
|
strokeWidth: 1,
|
|
),
|
|
),
|
|
borderData: FlBorderData(show: false),
|
|
barGroups: [
|
|
for (var i = 0; i < months.length; i++)
|
|
BarChartGroupData(
|
|
x: i,
|
|
barsSpace: 4,
|
|
barRods: [
|
|
BarChartRodData(
|
|
toY: monthly[months[i]]!.expense,
|
|
width: 12,
|
|
borderRadius: const BorderRadius.vertical(
|
|
top: Radius.circular(8),
|
|
),
|
|
color: expenseColor,
|
|
),
|
|
BarChartRodData(
|
|
toY: monthly[months[i]]!.income,
|
|
width: 12,
|
|
borderRadius: const BorderRadius.vertical(
|
|
top: Radius.circular(8),
|
|
),
|
|
color: incomeColor,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
static String _shortMonth(DateTime month) {
|
|
const names = [
|
|
'Jan',
|
|
'Feb',
|
|
'Mar',
|
|
'Apr',
|
|
'May',
|
|
'Jun',
|
|
'Jul',
|
|
'Aug',
|
|
'Sep',
|
|
'Oct',
|
|
'Nov',
|
|
'Dec',
|
|
];
|
|
return names[month.month - 1];
|
|
}
|
|
|
|
static String _compactMoney(double value) {
|
|
if (value >= 1000) {
|
|
final k = value / 1000;
|
|
return k >= 10 ? '${k.round()}k' : '${k.toStringAsFixed(1)}k';
|
|
}
|
|
return value.round().toString();
|
|
}
|
|
}
|
|
|
|
class _LegendDot extends StatelessWidget {
|
|
const _LegendDot({required this.color, required this.label});
|
|
|
|
final Color color;
|
|
final String label;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 8,
|
|
height: 8,
|
|
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Text(label, style: Theme.of(context).textTheme.labelMedium),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SummaryCard extends StatelessWidget {
|
|
const _SummaryCard({
|
|
required this.icon,
|
|
required this.iconBackground,
|
|
required this.iconColor,
|
|
required this.amount,
|
|
required this.label,
|
|
});
|
|
|
|
final IconData icon;
|
|
final Color iconBackground;
|
|
final Color iconColor;
|
|
final String amount;
|
|
final String label;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final colors = theme.colorScheme;
|
|
|
|
return Card(
|
|
color: colors.surface,
|
|
elevation: 0,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(20),
|
|
side: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.5)),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(12, 14, 12, 14),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(
|
|
color: iconBackground,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Icon(icon, color: iconColor, size: 22),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
amount,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: theme.textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
Text(label, style: theme.textTheme.labelMedium),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _HistoryCard extends StatelessWidget {
|
|
const _HistoryCard({
|
|
required this.month,
|
|
required this.income,
|
|
required this.expense,
|
|
required this.currency,
|
|
required this.onTap,
|
|
});
|
|
|
|
final DateTime month;
|
|
final double income;
|
|
final double expense;
|
|
final String currency;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final colors = theme.colorScheme;
|
|
|
|
return Card(
|
|
color: colors.surface,
|
|
elevation: 0,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(18),
|
|
side: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.5)),
|
|
),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(18),
|
|
onTap: onTap,
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('Month', style: theme.textTheme.labelMedium),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
formatMonthYear(month),
|
|
style: theme.textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Text(
|
|
formatMoney(income, currency),
|
|
style: theme.textTheme.titleSmall?.copyWith(
|
|
color: const Color(0xFF2F9E62),
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
formatMoney(-expense, currency),
|
|
style: theme.textTheme.titleSmall?.copyWith(
|
|
color: colors.error,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|