ing connected, makign some features

This commit is contained in:
davmar
2026-09-07 00:20:58 +02:00
parent 87afe271b7
commit 21df7b0085
15 changed files with 1197 additions and 322 deletions
+145 -52
View File
@@ -15,6 +15,7 @@ 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 }
@@ -29,16 +30,32 @@ 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());
// 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);
}
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>();
@@ -92,9 +109,9 @@ class _OverviewScreenState extends State<OverviewScreen> {
? 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 byLabel = meta.spendByLabel(bank.transactions);
final byDay = meta.spendByDayInMonth(bank.transactions, _chartMonth);
final totalSpend = meta.labeledSpendTotal(bank.transactions);
final recurrings = recurringItemsFrom(
transactions: bank.transactions,
@@ -214,10 +231,18 @@ class _OverviewScreenState extends State<OverviewScreen> {
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,
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,
@@ -247,7 +272,7 @@ class _OverviewScreenState extends State<OverviewScreen> {
),
],
const SizedBox(height: 20),
Text('Money out (categorized)', style: theme.textTheme.labelMedium),
Text('Money out (labeled)', style: theme.textTheme.labelMedium),
const SizedBox(height: 4),
Text(
formatMoney(totalSpend, currency),
@@ -258,13 +283,13 @@ class _OverviewScreenState extends State<OverviewScreen> {
segments: const [
ButtonSegment(
value: _StatsMode.category,
label: Text('Category'),
label: Text('Label'),
icon: Icon(Icons.label_outline),
),
ButtonSegment(
value: _StatsMode.month,
label: Text('By date'),
icon: Icon(Icons.calendar_month_outlined),
label: Text('By day'),
icon: Icon(Icons.bar_chart_outlined),
),
],
selected: {_mode},
@@ -272,7 +297,33 @@ class _OverviewScreenState extends State<OverviewScreen> {
setState(() => _mode = selection.first);
},
),
const SizedBox(height: 20),
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
@@ -283,21 +334,27 @@ class _OverviewScreenState extends State<OverviewScreen> {
),
)
: _mode == _StatsMode.category
? _CategoryBars(data: byCategory, currency: currency)
: _MonthBars(data: byMonth, currency: currency),
? _LabelBars(
data: byLabel,
resolve: meta.labelById,
currency: currency,
onBarTap: _openLabel,
)
: _DayBars(data: byDay, currency: currency),
),
const SizedBox(height: 20),
if (_mode == _StatsMode.category)
..._categoryLegend(context, byCategory, currency)
..._labelLegend(context, byLabel, meta, currency)
else
..._monthLegend(context, byMonth, currency),
..._dayLegend(context, byDay, currency),
],
);
}
List<Widget> _categoryLegend(
List<Widget> _labelLegend(
BuildContext context,
Map<TxCategory, double> data,
Map<String, double> data,
TxMetaController meta,
String currency,
) {
final entries = data.entries.toList()
@@ -305,38 +362,46 @@ class _OverviewScreenState extends State<OverviewScreen> {
if (entries.isEmpty) {
return [
Text(
'No outflows yet. Categorize spending from the transaction list.',
'No labeled outflows yet. Add a label from a transaction.',
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)),
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> _monthLegend(
List<Widget> _dayLegend(
BuildContext context,
Map<String, double> data,
String currency,
) {
if (data.isEmpty) {
final spent = data.entries.where((e) => e.value > 0).toList().reversed;
if (spent.isEmpty) {
return [
Text(
'No dated outflows to chart yet.',
'No outflows this month.',
style: Theme.of(context).textTheme.bodyMedium,
),
];
}
return [
for (final entry in data.entries.toList().reversed)
for (final entry in spent)
ListTile(
contentPadding: EdgeInsets.zero,
dense: true,
@@ -348,20 +413,31 @@ class _OverviewScreenState extends State<OverviewScreen> {
}
}
class _CategoryBars extends StatelessWidget {
const _CategoryBars({required this.data, required this.currency});
class _LabelBars extends StatelessWidget {
const _LabelBars({
required this.data,
required this.resolve,
required this.currency,
required this.onBarTap,
});
final Map<TxCategory, double> data;
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 = data.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
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(
'Categorize transactions to see this chart',
'Label transactions to see this chart',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
@@ -373,11 +449,19 @@ class _CategoryBars extends StatelessWidget {
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.key.label}\n${formatMoney(entry.value, currency)}',
'${entry.label.name}\n${formatMoney(entry.value, currency)}',
TextStyle(
color: Theme.of(context).colorScheme.onInverseSurface,
),
@@ -399,7 +483,7 @@ class _CategoryBars extends StatelessWidget {
if (index < 0 || index >= entries.length) {
return const SizedBox.shrink();
}
final label = entries[index].key.label;
final label = entries[index].label.name;
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
@@ -423,7 +507,7 @@ class _CategoryBars extends StatelessWidget {
width: 18,
borderRadius:
const BorderRadius.vertical(top: Radius.circular(6)),
color: entries[i].key.color,
color: entries[i].label.color,
),
],
),
@@ -433,8 +517,8 @@ class _CategoryBars extends StatelessWidget {
}
}
class _MonthBars extends StatelessWidget {
const _MonthBars({required this.data, required this.currency});
class _DayBars extends StatelessWidget {
const _DayBars({required this.data, required this.currency});
final Map<String, double> data;
final String currency;
@@ -442,26 +526,30 @@ class _MonthBars extends StatelessWidget {
@override
Widget build(BuildContext context) {
final entries = data.entries.toList();
if (entries.isEmpty) {
if (entries.every((e) => e.value == 0)) {
return Center(
child: Text(
'No monthly spend yet',
'No spend this month',
style: Theme.of(context).textTheme.bodyMedium,
),
);
}
final maxY = entries.map((e) => e.value).reduce((a, b) => a > b ? a : b);
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: maxY * 1.15,
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(
'${entry.key}\n${formatMoney(entry.value, currency)}',
'Day $day\n${formatMoney(entry.value, currency)}',
TextStyle(color: colors.onInverseSurface),
);
},
@@ -476,17 +564,20 @@ class _MonthBars extends StatelessWidget {
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();
}
final key = entries[index].key;
final short = key.length >= 7 ? key.substring(5) : key;
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(
short,
day,
style: Theme.of(context).textTheme.labelSmall,
),
);
@@ -503,10 +594,12 @@ class _MonthBars extends StatelessWidget {
barRods: [
BarChartRodData(
toY: entries[i].value,
width: 22,
width: entries.length > 20 ? 6 : 8,
borderRadius:
const BorderRadius.vertical(top: Radius.circular(6)),
color: colors.primary,
const BorderRadius.vertical(top: Radius.circular(3)),
color: entries[i].value > 0
? colors.primary
: colors.surfaceContainerHighest,
),
],
),