Files
ingtrakapp/lib/screens/overview_screen.dart
T
2026-09-08 12:58:32 +02:00

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,
),
),
],
),
],
),
),
),
);
}
}