ok for now
This commit is contained in:
@@ -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].
|
||||
|
||||
@@ -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 '';
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user