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
+2
View File
@@ -40,6 +40,8 @@ const String kBankRedirectUri = '$kBankRedirectScheme://$kBankRedirectHost';
abstract final class StorageKeys {
static const themeMode = 'theme_mode';
static const useRemoteData = 'use_remote_data';
static const txMeta = 'tx_meta_v2';
static const txMetaLegacy = 'tx_meta_v1';
}
/// Named routes registered on [MaterialApp].
+19
View File
@@ -78,6 +78,25 @@ String formatMoney(double amount, String currency) {
return '$sign${buffer.toString()},$fraction $currency';
}
/// Formats [date] as `MMMM y`, e.g. `September 2026`.
String formatMonthYear(DateTime date) {
const months = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
return '${months[date.month - 1]} ${date.year}';
}
/// Formats an ISO date string (`yyyy-MM-dd` or full ISO) for list rows.
String formatIsoDate(String raw) {
if (raw.isEmpty) return '';
+105
View File
@@ -0,0 +1,105 @@
/// Project future balances from recurring transactions.
library;
import '../models/bank.dart';
import '../models/transaction_meta.dart';
import 'tx_calendar.dart';
/// One monthly recurring item used for look-ahead forecasts.
class RecurringItem {
const RecurringItem({
required this.description,
required this.amount,
required this.currency,
required this.dayOfMonth,
required this.category,
});
final String description;
final double amount;
final String currency;
final int dayOfMonth;
final TxCategory category;
}
/// Build unique recurring templates from annotated transactions.
List<RecurringItem> recurringItemsFrom({
required List<BankTransaction> transactions,
required TxMeta Function(BankTransaction tx) metaFor,
}) {
final latestByExact = <String, BankTransaction>{};
for (final tx in transactions) {
final meta = metaFor(tx);
if (!meta.recurring) continue;
final existing = latestByExact[tx.exactKey];
if (existing == null) {
latestByExact[tx.exactKey] = tx;
continue;
}
final existingDate = DateTime.tryParse(existing.date);
final nextDate = DateTime.tryParse(tx.date);
if (existingDate == null ||
(nextDate != null && nextDate.isAfter(existingDate))) {
latestByExact[tx.exactKey] = tx;
}
}
return [
for (final tx in latestByExact.values)
RecurringItem(
description: tx.description,
amount: tx.amount,
currency: tx.currency,
dayOfMonth: (DateTime.tryParse(tx.date)?.day ?? 1).clamp(1, 28),
category: metaFor(tx).category,
),
];
}
/// Occurrences of [items] with dates in `(from, to]` (exclusive start, inclusive end).
List<({RecurringItem item, DateTime date})> projectedOccurrences({
required List<RecurringItem> items,
required DateTime from,
required DateTime to,
}) {
final start = dateOnly(from);
final end = dateOnly(to);
if (!end.isAfter(start)) return const [];
final out = <({RecurringItem item, DateTime date})>[];
var cursor = monthStart(start);
final lastMonth = monthStart(end);
while (!cursor.isAfter(lastMonth)) {
final daysInMonth = DateTime(cursor.year, cursor.month + 1, 0).day;
for (final item in items) {
final day = item.dayOfMonth.clamp(1, daysInMonth);
final date = DateTime(cursor.year, cursor.month, day);
if (date.isAfter(start) && !date.isAfter(end)) {
out.add((item: item, date: date));
}
}
cursor = addMonths(cursor, 1);
}
out.sort((a, b) => a.date.compareTo(b.date));
return out;
}
/// Current balance plus projected recurring amounts through [throughDate].
double predictBalance({
required double currentBalance,
required DateTime from,
required DateTime throughDate,
required List<RecurringItem> items,
}) {
final projected = projectedOccurrences(
items: items,
from: from,
to: throughDate,
);
return projected.fold<double>(
currentBalance,
(sum, row) => sum + row.item.amount,
);
}
+30
View File
@@ -0,0 +1,30 @@
/// Shared helpers to open Settings from app bars.
library;
import 'package:flutter/material.dart';
import '../screens/settings_screen.dart';
List<Widget> bankAppBarActions({
required BuildContext context,
VoidCallback? onRefresh,
bool refreshing = false,
}) {
return [
if (onRefresh != null)
IconButton(
tooltip: 'Refresh',
onPressed: refreshing ? null : onRefresh,
icon: const Icon(Icons.refresh),
),
IconButton(
tooltip: 'Settings',
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const SettingsScreen()),
);
},
icon: const Icon(Icons.settings_outlined),
),
];
}
+48
View File
@@ -0,0 +1,48 @@
/// Month-grid helpers for the transactions heatmap.
library;
DateTime dateOnly(DateTime date) => DateTime(date.year, date.month, date.day);
DateTime monthStart(DateTime date) => DateTime(date.year, date.month);
bool isSameDay(DateTime a, DateTime b) =>
a.year == b.year && a.month == b.month && a.day == b.day;
bool isSameMonth(DateTime a, DateTime b) =>
a.year == b.year && a.month == b.month;
DateTime addMonths(DateTime month, int delta) =>
DateTime(month.year, month.month + delta);
/// Monday-first calendar cells for [month], padded with nulls to full weeks.
List<DateTime?> monthGrid(DateTime month) {
final start = DateTime(month.year, month.month, 1);
final daysInMonth = DateTime(month.year, month.month + 1, 0).day;
final leading = start.weekday - DateTime.monday;
final cells = <DateTime?>[
...List<DateTime?>.filled(leading, null),
for (var day = 1; day <= daysInMonth; day++)
DateTime(month.year, month.month, day),
];
while (cells.length % 7 != 0) {
cells.add(null);
}
return cells;
}
/// How many transactions landed on each calendar day.
Map<DateTime, int> txCountsByDay(Iterable<DateTime> dates) {
final counts = <DateTime, int>{};
for (final date in dates) {
final key = dateOnly(date);
counts[key] = (counts[key] ?? 0) + 1;
}
return counts;
}
/// GitHub-style 0–4 intensity from a transaction count.
int heatmapLevel(int count) {
if (count <= 0) return 0;
if (count >= 4) return 4;
return count;
}