From 87afe271b7d701d176dc4c51841e729a499e96de Mon Sep 17 00:00:00 2001 From: davmar Date: Sun, 6 Sep 2026 21:58:59 +0200 Subject: [PATCH] ok for now --- lib/main.dart | 2 + lib/models/bank.dart | 10 + lib/models/transaction_meta.dart | 135 ++++++ lib/providers/tx_meta_provider.dart | 185 ++++++++ lib/screens/balance_screen.dart | 172 ------- lib/screens/home_screen.dart | 335 +++++++------ lib/screens/main_shell.dart | 30 +- lib/screens/overview_screen.dart | 517 +++++++++++++++++++++ lib/screens/transaction_detail_screen.dart | 153 ++++++ lib/screens/transactions_screen.dart | 499 ++++++++++++++++++-- lib/services/storage_service.dart | 135 ++++++ lib/utils/constants.dart | 2 + lib/utils/helpers.dart | 19 + lib/utils/look_ahead.dart | 105 +++++ lib/utils/navigation.dart | 30 ++ lib/utils/tx_calendar.dart | 48 ++ lib/widgets/tx_heatmap.dart | 204 ++++++++ pubspec.lock | 16 + pubspec.yaml | 2 +- test/widget_test.dart | 49 +- 20 files changed, 2271 insertions(+), 377 deletions(-) create mode 100644 lib/models/transaction_meta.dart create mode 100644 lib/providers/tx_meta_provider.dart delete mode 100644 lib/screens/balance_screen.dart create mode 100644 lib/screens/overview_screen.dart create mode 100644 lib/screens/transaction_detail_screen.dart create mode 100644 lib/utils/look_ahead.dart create mode 100644 lib/utils/navigation.dart create mode 100644 lib/utils/tx_calendar.dart create mode 100644 lib/widgets/tx_heatmap.dart diff --git a/lib/main.dart b/lib/main.dart index 394571a..83e9021 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,6 +9,7 @@ import 'package:provider/provider.dart'; import 'providers/bank_provider.dart'; import 'providers/theme_provider.dart'; +import 'providers/tx_meta_provider.dart'; import 'screens/splash_screen.dart'; import 'services/storage_service.dart'; import 'utils/app_theme.dart'; @@ -79,6 +80,7 @@ class _MyAppState extends State { return MultiProvider( providers: [ ChangeNotifierProvider(create: (_) => ThemeController(widget.storage)), + ChangeNotifierProvider(create: (_) => TxMetaController(widget.storage)), ChangeNotifierProvider.value(value: _bank), ], child: const _MaterialRoot(), diff --git a/lib/models/bank.dart b/lib/models/bank.dart index 142bebf..8a398bb 100644 --- a/lib/models/bank.dart +++ b/lib/models/bank.dart @@ -94,6 +94,16 @@ class BankTransaction { final String currency; final bool pending; + /// Stable local key used for per-occurrence annotations. + String get id => '$date|$currency|$amount|$description'; + + /// Match key for auto-category (same merchant / description). + String get descriptionKey => + description.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' '); + + /// Exact match for copying notes (same description + amount + currency). + String get exactKey => '$descriptionKey|$amount|$currency'; + factory BankTransaction.fromJson(Map json) { final rawAmount = json['amount']; final amount = switch (rawAmount) { diff --git a/lib/models/transaction_meta.dart b/lib/models/transaction_meta.dart new file mode 100644 index 0000000..968d76f --- /dev/null +++ b/lib/models/transaction_meta.dart @@ -0,0 +1,135 @@ +/// Local transaction categories and per-transaction annotations. +library; + +import 'package:flutter/material.dart'; + +/// User-facing spend / income categories (stored locally, not on the bank API). +enum TxCategory { + uncategorized, + groceries, + energy, + salary, + mortgage, + transport, + dining, + shopping, + health, + transfer, + other, +} + +extension TxCategoryX on TxCategory { + String get label => switch (this) { + TxCategory.uncategorized => 'Uncategorized', + TxCategory.groceries => 'Groceries', + TxCategory.energy => 'Energy', + TxCategory.salary => 'Salary', + TxCategory.mortgage => 'Mortgage', + TxCategory.transport => 'Transport', + TxCategory.dining => 'Dining', + TxCategory.shopping => 'Shopping', + TxCategory.health => 'Health', + TxCategory.transfer => 'Transfer', + TxCategory.other => 'Other', + }; + + IconData get icon => switch (this) { + TxCategory.uncategorized => Icons.label_outline, + TxCategory.groceries => Icons.shopping_cart_outlined, + TxCategory.energy => Icons.bolt_outlined, + TxCategory.salary => Icons.payments_outlined, + TxCategory.mortgage => Icons.home_work_outlined, + TxCategory.transport => Icons.directions_bus_outlined, + TxCategory.dining => Icons.restaurant_outlined, + TxCategory.shopping => Icons.shopping_bag_outlined, + TxCategory.health => Icons.local_hospital_outlined, + TxCategory.transfer => Icons.swap_horiz, + TxCategory.other => Icons.more_horiz, + }; + + Color get color => switch (this) { + TxCategory.uncategorized => const Color(0xFF64748B), + TxCategory.groceries => const Color(0xFF16A34A), + TxCategory.energy => const Color(0xFFEAB308), + TxCategory.salary => const Color(0xFF0F766E), + TxCategory.mortgage => const Color(0xFF7C3AED), + TxCategory.transport => const Color(0xFF2563EB), + TxCategory.dining => const Color(0xFFEA580C), + TxCategory.shopping => const Color(0xFFDB2777), + TxCategory.health => const Color(0xFFDC2626), + TxCategory.transfer => const Color(0xFF0891B2), + TxCategory.other => const Color(0xFF57534E), + }; + + static TxCategory fromName(String? name) { + if (name == null || name.isEmpty) return TxCategory.uncategorized; + return TxCategory.values.firstWhere( + (c) => c.name == name, + orElse: () => TxCategory.uncategorized, + ); + } +} + +/// Local overlay for a bank transaction. +class TxMeta { + const TxMeta({ + this.category = TxCategory.uncategorized, + this.recurring = false, + this.notes = '', + }); + + final TxCategory category; + final bool recurring; + final String notes; + + static const empty = TxMeta(); + + bool get hasCategory => category != TxCategory.uncategorized; + bool get hasNotes => notes.trim().isNotEmpty; + + TxMeta copyWith({ + TxCategory? category, + bool? recurring, + String? notes, + }) { + return TxMeta( + category: category ?? this.category, + recurring: recurring ?? this.recurring, + notes: notes ?? this.notes, + ); + } + + Map toJson() => { + 'category': category.name, + 'recurring': recurring, + 'notes': notes, + }; + + factory TxMeta.fromJson(Map json) { + return TxMeta( + category: TxCategoryX.fromName(json['category'] as String?), + recurring: json['recurring'] as bool? ?? false, + notes: json['notes'] as String? ?? '', + ); + } +} + +/// Notes / recurring template keyed by exact merchant+amount match. +class TxExactTemplate { + const TxExactTemplate({this.notes = '', this.recurring = false}); + + final String notes; + final bool recurring; + + Map toJson() => { + 'notes': notes, + 'recurring': recurring, + }; + + factory TxExactTemplate.fromJson(Map json) { + return TxExactTemplate( + notes: json['notes'] as String? ?? '', + recurring: json['recurring'] as bool? ?? false, + ); + } +} diff --git a/lib/providers/tx_meta_provider.dart b/lib/providers/tx_meta_provider.dart new file mode 100644 index 0000000..0c06e18 --- /dev/null +++ b/lib/providers/tx_meta_provider.dart @@ -0,0 +1,185 @@ +/// Local category / notes / recurring annotations for bank transactions. +library; + +import 'package:flutter/foundation.dart'; + +import '../models/bank.dart'; +import '../models/transaction_meta.dart'; +import '../services/storage_service.dart'; + +class TxMetaController extends ChangeNotifier { + TxMetaController(this._storage) { + final loaded = _storage.loadTxMetaStore(); + _byId = Map.of(loaded.byId); + _categoryByDescription = Map.of(loaded.categoryByDescription); + _exactTemplates = Map.of(loaded.exactTemplates); + } + + final StorageService _storage; + + Map _byId = {}; + Map _categoryByDescription = {}; + Map _exactTemplates = {}; + + /// Resolved meta: per-id overrides + inherited category / notes rules. + TxMeta metaFor(BankTransaction tx) { + final stored = _byId[tx.id]; + final template = _exactTemplates[tx.exactKey]; + final inheritedCategory = _categoryByDescription[tx.descriptionKey]; + + final category = stored != null && stored.hasCategory + ? stored.category + : (inheritedCategory ?? TxCategory.uncategorized); + + final notes = stored != null && stored.hasNotes + ? stored.notes + : (template?.notes ?? ''); + + final recurring = stored?.recurring ?? template?.recurring ?? false; + + return TxMeta( + category: category, + notes: notes, + recurring: recurring, + ); + } + + Future setCategory(BankTransaction tx, TxCategory category) async { + final current = metaFor(tx); + _byId = { + ..._byId, + tx.id: current.copyWith(category: category), + }; + if (category == TxCategory.uncategorized) { + _categoryByDescription = Map.of(_categoryByDescription) + ..remove(tx.descriptionKey); + } else { + _categoryByDescription = { + ..._categoryByDescription, + tx.descriptionKey: category, + }; + } + await _persist(); + } + + Future setRecurring(BankTransaction tx, bool recurring) async { + final current = metaFor(tx); + _byId = { + ..._byId, + tx.id: current.copyWith(recurring: recurring), + }; + final existing = _exactTemplates[tx.exactKey] ?? const TxExactTemplate(); + _exactTemplates = { + ..._exactTemplates, + tx.exactKey: TxExactTemplate( + notes: existing.notes.isNotEmpty ? existing.notes : current.notes, + recurring: recurring, + ), + }; + await _persist(); + } + + Future setNotes(BankTransaction tx, String notes) async { + final trimmed = notes.trim(); + final current = metaFor(tx); + _byId = { + ..._byId, + tx.id: current.copyWith(notes: trimmed), + }; + final existing = _exactTemplates[tx.exactKey] ?? const TxExactTemplate(); + if (trimmed.isEmpty && !existing.recurring && !current.recurring) { + _exactTemplates = Map.of(_exactTemplates)..remove(tx.exactKey); + } else { + _exactTemplates = { + ..._exactTemplates, + tx.exactKey: TxExactTemplate( + notes: trimmed, + recurring: existing.recurring || current.recurring, + ), + }; + } + await _persist(); + } + + Future toggleRecurring(BankTransaction tx) { + return setRecurring(tx, !metaFor(tx).recurring); + } + + /// Search description, notes, category label, and date. + /// Optional [categories] require a matching category when non-empty. + List search( + List txs, + String query, { + Set categories = const {}, + }) { + final needle = query.trim().toLowerCase(); + final hasText = needle.isNotEmpty; + final hasCats = categories.isNotEmpty; + if (!hasText && !hasCats) return txs; + + return txs.where((tx) { + final meta = metaFor(tx); + if (hasCats && !categories.contains(meta.category)) return false; + if (!hasText) return true; + return tx.description.toLowerCase().contains(needle) || + meta.notes.toLowerCase().contains(needle) || + meta.category.label.toLowerCase().contains(needle) || + tx.date.toLowerCase().contains(needle) || + tx.amount.toString().contains(needle); + }).toList(growable: false); + } + + /// Categories currently used by [transactions] (excluding uncategorized). + List usedCategories(List transactions) { + final found = {}; + for (final tx in transactions) { + final category = metaFor(tx).category; + if (category != TxCategory.uncategorized) found.add(category); + } + final list = found.toList() + ..sort((a, b) => a.label.compareTo(b.label)); + return list; + } + + Future _persist() async { + notifyListeners(); + await _storage.saveTxMetaStore( + TxMetaStoreData( + byId: _byId, + categoryByDescription: _categoryByDescription, + exactTemplates: _exactTemplates, + ), + ); + } + + /// Absolute spend (outflows only) grouped by category. + Map spendByCategory(List txs) { + final totals = {}; + for (final tx in txs) { + if (tx.amount >= 0) continue; + final category = metaFor(tx).category; + totals[category] = (totals[category] ?? 0) + tx.amount.abs(); + } + return totals; + } + + /// Absolute spend grouped by calendar month key `yyyy-MM`. + Map spendByMonth(List txs) { + final totals = {}; + for (final tx in txs) { + if (tx.amount >= 0) continue; + final key = _monthKey(tx.date); + if (key == null) continue; + totals[key] = (totals[key] ?? 0) + tx.amount.abs(); + } + final keys = totals.keys.toList()..sort(); + return {for (final key in keys) key: totals[key]!}; + } + + String? _monthKey(String raw) { + final parsed = DateTime.tryParse(raw); + if (parsed == null) return null; + final m = parsed.month.toString().padLeft(2, '0'); + return '${parsed.year}-$m'; + } +} diff --git a/lib/screens/balance_screen.dart b/lib/screens/balance_screen.dart deleted file mode 100644 index b478cef..0000000 --- a/lib/screens/balance_screen.dart +++ /dev/null @@ -1,172 +0,0 @@ -/// Balance tab: connect bank CTA, current balance, and refresh. -library; - -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../providers/bank_provider.dart'; -import '../utils/connect_bank.dart'; -import '../utils/helpers.dart'; -import '../widgets/empty_state.dart'; -import '../widgets/loading_view.dart'; -import '../widgets/responsive_body.dart'; - -class BalanceScreen extends StatelessWidget { - const BalanceScreen({super.key}); - - @override - Widget build(BuildContext context) { - final bank = context.watch(); - - return Scaffold( - appBar: AppBar( - title: const Text('Balance'), - actions: [ - IconButton( - tooltip: 'Refresh', - onPressed: bank.isLoading ? null : bank.refresh, - icon: const Icon(Icons.refresh), - ), - ], - ), - body: RefreshIndicator( - onRefresh: bank.refresh, - child: ResponsiveBody( - child: _BalanceBody(bank: bank), - ), - ), - ); - } -} - -class _BalanceBody extends StatelessWidget { - const _BalanceBody({required this.bank}); - - final BankController bank; - - @override - Widget build(BuildContext context) { - if (bank.isLoading && bank.balance == null && !bank.isLinked) { - return const LoadingView(message: 'Checking bank link…'); - } - - if (!bank.isLinked) { - return ListView( - physics: const AlwaysScrollableScrollPhysics(), - children: [ - EmptyState( - icon: Icons.account_balance_outlined, - title: 'Bank not linked', - message: bank.error ?? - 'Connect opens a sandbox bank login in-app. ' - 'Stay in that WebView until it returns here. ' - 'You should then see a balance and transactions.', - actionLabel: bank.isConnecting ? 'Opening…' : 'Connect bank', - onAction: () => runConnectBank(context, bank), - ), - ], - ); - } - - if (bank.error != null && bank.balance == null) { - return ListView( - physics: const AlwaysScrollableScrollPhysics(), - children: [ - EmptyState( - icon: Icons.error_outline, - title: 'Could not load balance', - message: bank.error!, - actionLabel: 'Retry', - onAction: bank.refresh, - ), - ], - ); - } - - final balance = bank.balance; - final theme = Theme.of(context); - final colors = theme.colorScheme; - - return ListView( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.only(top: 8, bottom: 32), - children: [ - Container( - width: double.infinity, - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - gradient: LinearGradient( - colors: [colors.primary, colors.tertiary], - ), - boxShadow: [ - BoxShadow( - color: colors.primary.withValues(alpha: 0.28), - blurRadius: 20, - offset: const Offset(0, 10), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Current balance', - style: theme.textTheme.labelMedium?.copyWith( - color: colors.onPrimary.withValues(alpha: 0.8), - ), - ), - const SizedBox(height: 8), - Text( - balance == null - ? '—' - : formatMoney(balance.amount, balance.currency), - style: theme.textTheme.headlineMedium?.copyWith( - color: colors.onPrimary, - ), - ), - if (balance?.balanceType != null) ...[ - const SizedBox(height: 8), - Text( - balance!.balanceType!, - style: theme.textTheme.labelMedium?.copyWith( - color: colors.onPrimary.withValues(alpha: 0.75), - ), - ), - ], - ], - ), - ), - const SizedBox(height: 20), - ListTile( - contentPadding: EdgeInsets.zero, - leading: Icon( - Icons.check_circle_outline, - color: colors.primary, - ), - title: const Text('Linked'), - subtitle: Text( - bank.status.accountId == null - ? bank.status.status - : 'Account ${bank.status.accountId}', - ), - ), - if (bank.error != null) ...[ - const SizedBox(height: 8), - Text( - bank.error!, - style: theme.textTheme.bodyMedium?.copyWith(color: colors.error), - ), - ], - const SizedBox(height: 12), - OutlinedButton.icon( - onPressed: bank.isConnecting - ? null - : () => runConnectBank(context, bank), - icon: const Icon(Icons.link), - label: Text(bank.isConnecting ? 'Opening…' : 'Reconnect bank'), - ), - ], - ); - } -} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index a713562..9ae5b9b 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -1,39 +1,41 @@ -/// Home tab: greeting, stats, and a [ListView.builder] of catalog items. +/// Home: balance overview; tap balance to open transactions. library; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../providers/item_provider.dart'; +import '../models/transaction_meta.dart'; +import '../providers/bank_provider.dart'; +import '../providers/tx_meta_provider.dart'; +import '../utils/connect_bank.dart'; +import '../utils/helpers.dart'; +import '../utils/navigation.dart'; import '../widgets/empty_state.dart'; -import '../widgets/item_card.dart'; import '../widgets/loading_view.dart'; import '../widgets/responsive_body.dart'; -import 'add_item_screen.dart'; +import 'transaction_detail_screen.dart'; +import 'transactions_screen.dart'; class HomeScreen extends StatelessWidget { const HomeScreen({super.key}); @override Widget build(BuildContext context) { - final controller = context.watch(); - final theme = Theme.of(context); + final bank = context.watch(); return Scaffold( - appBar: AppBar(title: const Text('Home')), - floatingActionButton: FloatingActionButton.extended( - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const AddItemScreen()), - ); - }, - icon: const Icon(Icons.add), - label: const Text('Add item'), + appBar: AppBar( + title: const Text('Home'), + actions: bankAppBarActions( + context: context, + onRefresh: bank.refresh, + refreshing: bank.isLoading, + ), ), body: RefreshIndicator( - onRefresh: controller.loadItems, + onRefresh: bank.refresh, child: ResponsiveBody( - child: _HomeBody(controller: controller, theme: theme), + child: _HomeBody(bank: bank), ), ), ); @@ -41,142 +43,209 @@ class HomeScreen extends StatelessWidget { } class _HomeBody extends StatelessWidget { - const _HomeBody({ - required this.controller, - required this.theme, - }); + const _HomeBody({required this.bank}); - final ItemController controller; - final ThemeData theme; + final BankController bank; + + void _openTransactions(BuildContext context) { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const TransactionsScreen()), + ); + } @override Widget build(BuildContext context) { - if (controller.isLoading && controller.items.isEmpty) { - return const LoadingView(message: 'Fetching catalog…'); + if (bank.isLoading && bank.balance == null && !bank.isLinked) { + return const LoadingView(message: 'Checking bank link…'); } - if (controller.error != null && controller.items.isEmpty) { + if (!bank.isLinked) { + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + EmptyState( + icon: Icons.account_balance_outlined, + title: 'Bank not linked', + message: bank.error ?? + 'Connect your bank to see balance and categorize spending.', + actionLabel: bank.isConnecting ? 'Opening…' : 'Connect bank', + onAction: () => runConnectBank(context, bank), + ), + ], + ); + } + + if (bank.error != null && bank.balance == null) { return ListView( physics: const AlwaysScrollableScrollPhysics(), children: [ EmptyState( icon: Icons.error_outline, - title: 'Something went wrong', - message: controller.error!, + title: 'Could not load balance', + message: bank.error!, actionLabel: 'Retry', - onAction: controller.loadItems, + onAction: bank.refresh, ), ], ); } - if (controller.items.isEmpty) { - return ListView( - physics: const AlwaysScrollableScrollPhysics(), - children: const [ - EmptyState( - icon: Icons.inbox_outlined, - title: 'No items yet', - message: 'Add your first item with the button below.', - ), - ], - ); - } - - // Header occupies the first slot; remaining slots are catalog cards. - return ListView.builder( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.only(top: 8, bottom: 96), - itemCount: controller.items.length + 1, - itemBuilder: (context, index) { - if (index == 0) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _WelcomeBanner(itemCount: controller.count), - const SizedBox(height: 20), - Text('Featured items', style: theme.textTheme.titleMedium), - const SizedBox(height: 12), - ], - ); - } - - final item = controller.items[index - 1]; - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: ItemCard(item: item, heroScope: 'home'), - ); - }, - ); - } -} - -class _WelcomeBanner extends StatelessWidget { - const _WelcomeBanner({required this.itemCount}); - - final int itemCount; - - @override - Widget build(BuildContext context) { + final balance = bank.balance; final theme = Theme.of(context); final colors = theme.colorScheme; + final meta = context.watch(); + final recent = bank.transactions.take(5).toList(growable: false); + final categorized = bank.transactions + .where((tx) => meta.metaFor(tx).hasCategory) + .length; - return Container( - width: double.infinity, - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - gradient: LinearGradient( - colors: [colors.primary, colors.tertiary], + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.only(top: 8, bottom: 32), + children: [ + Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () => _openTransactions(context), + child: Ink( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + gradient: LinearGradient( + colors: [colors.primary, colors.tertiary], + ), + boxShadow: [ + BoxShadow( + color: colors.primary.withValues(alpha: 0.28), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Current balance', + style: theme.textTheme.labelMedium?.copyWith( + color: colors.onPrimary.withValues(alpha: 0.8), + ), + ), + const SizedBox(height: 8), + Text( + balance == null + ? '—' + : formatMoney(balance.amount, balance.currency), + style: theme.textTheme.headlineMedium?.copyWith( + color: colors.onPrimary, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Icon( + Icons.receipt_long, + size: 16, + color: colors.onPrimary.withValues(alpha: 0.9), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + 'Tap to view transactions', + style: theme.textTheme.labelMedium?.copyWith( + color: colors.onPrimary.withValues(alpha: 0.9), + ), + ), + ), + Icon( + Icons.chevron_right, + color: colors.onPrimary.withValues(alpha: 0.9), + ), + ], + ), + ], + ), + ), + ), ), - boxShadow: [ - BoxShadow( - color: colors.primary.withValues(alpha: 0.28), - blurRadius: 20, - offset: const Offset(0, 10), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Welcome back', - style: theme.textTheme.labelMedium?.copyWith( - color: colors.onPrimary.withValues(alpha: 0.8), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: _StatChip( + label: 'Transactions', + value: '${bank.transactions.length}', + icon: Icons.list_alt, + ), ), - ), - const SizedBox(height: 4), - Text( - 'Your catalog at a glance', - style: theme.textTheme.headlineMedium?.copyWith( - color: colors.onPrimary, + const SizedBox(width: 10), + Expanded( + child: _StatChip( + label: 'Categorized', + value: '$categorized', + icon: Icons.label_outline, + ), ), - ), - const SizedBox(height: 16), + ], + ), + if (recent.isNotEmpty) ...[ + const SizedBox(height: 24), Row( children: [ - _StatPill( - label: 'Items', - value: '$itemCount', - icon: Icons.collections_bookmark_outlined, - ), - const SizedBox(width: 10), - const _StatPill( - label: 'Theme', - value: 'M3', - icon: Icons.palette_outlined, + Text('Recent', style: theme.textTheme.titleMedium), + const Spacer(), + TextButton( + onPressed: () => _openTransactions(context), + child: const Text('See all'), ), ], ), + const SizedBox(height: 4), + for (final tx in recent) + ListTile( + contentPadding: EdgeInsets.zero, + dense: true, + leading: Icon( + meta.metaFor(tx).category.icon, + color: meta.metaFor(tx).category.color, + ), + title: Text( + tx.description, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text(formatIsoDate(tx.date)), + trailing: Text( + formatMoney(tx.amount, tx.currency), + style: theme.textTheme.titleSmall?.copyWith( + color: tx.amount >= 0 ? colors.primary : colors.error, + ), + ), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => TransactionDetailScreen(tx: tx), + ), + ); + }, + ), ], - ), + if (bank.error != null) ...[ + const SizedBox(height: 12), + Text( + bank.error!, + style: theme.textTheme.bodyMedium?.copyWith(color: colors.error), + ), + ], + ], ); } } -class _StatPill extends StatelessWidget { - const _StatPill({ +class _StatChip extends StatelessWidget { + const _StatChip({ required this.label, required this.value, required this.icon, @@ -188,22 +257,26 @@ class _StatPill extends StatelessWidget { @override Widget build(BuildContext context) { - final onPrimary = Theme.of(context).colorScheme.onPrimary; + final theme = Theme.of(context); + final colors = theme.colorScheme; return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), decoration: BoxDecoration( - color: onPrimary.withValues(alpha: 0.14), - borderRadius: BorderRadius.circular(12), + color: colors.surfaceContainerLow, + borderRadius: BorderRadius.circular(14), ), child: Row( children: [ - Icon(icon, size: 16, color: onPrimary), - const SizedBox(width: 8), - Text( - '$value $label', - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: onPrimary, - ), + Icon(icon, size: 18, color: colors.primary), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(value, style: theme.textTheme.titleMedium), + Text(label, style: theme.textTheme.labelMedium), + ], + ), ), ], ), diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 5f5a546..44ada4a 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -1,13 +1,11 @@ -/// Host scaffold that owns the bottom navigation bar and its tabs. +/// Host scaffold: Home + Overview. Settings lives in the app-bar gear. library; import 'package:flutter/material.dart'; -import 'balance_screen.dart'; -import 'settings_screen.dart'; -import 'transactions_screen.dart'; +import 'home_screen.dart'; +import 'overview_screen.dart'; -/// Switches between Balance, Transactions, and Settings without stacking routes. class MainShell extends StatefulWidget { const MainShell({super.key}); @@ -19,9 +17,8 @@ class _MainShellState extends State { int _index = 0; static const _pages = [ - BalanceScreen(), - TransactionsScreen(), - SettingsScreen(), + HomeScreen(), + OverviewScreen(), ]; @override @@ -36,19 +33,14 @@ class _MainShellState extends State { onDestinationSelected: (index) => setState(() => _index = index), destinations: const [ NavigationDestination( - icon: Icon(Icons.account_balance_wallet_outlined), - selectedIcon: Icon(Icons.account_balance_wallet), - label: 'Balance', + icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home), + label: 'Home', ), NavigationDestination( - icon: Icon(Icons.receipt_long_outlined), - selectedIcon: Icon(Icons.receipt_long), - label: 'Transactions', - ), - NavigationDestination( - icon: Icon(Icons.settings_outlined), - selectedIcon: Icon(Icons.settings), - label: 'Settings', + icon: Icon(Icons.insights_outlined), + selectedIcon: Icon(Icons.insights), + label: 'Overview', ), ], ), diff --git a/lib/screens/overview_screen.dart b/lib/screens/overview_screen.dart new file mode 100644 index 0000000..e0ddc4b --- /dev/null +++ b/lib/screens/overview_screen.dart @@ -0,0 +1,517 @@ +/// Overview: current balance, charts, and look-ahead balance forecast. +library; + +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../models/transaction_meta.dart'; +import '../providers/bank_provider.dart'; +import '../providers/tx_meta_provider.dart'; +import '../utils/helpers.dart'; +import '../utils/look_ahead.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'; + +enum _StatsMode { category, month } + +class OverviewScreen extends StatefulWidget { + const OverviewScreen({super.key}); + + @override + State createState() => _OverviewScreenState(); +} + +class _OverviewScreenState extends State { + _StatsMode _mode = _StatsMode.category; + var _lookAheadEnabled = false; + DateTime _lookAheadMonth = 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); + } + + @override + Widget build(BuildContext context) { + final bank = context.watch(); + final meta = context.watch(); + + 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, meta), + ), + ), + ); + } + + Widget _buildBody( + BuildContext context, + BankController bank, + TxMetaController meta, + ) { + 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) { + 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 currentBalance = bank.balance?.amount ?? 0; + final byCategory = meta.spendByCategory(bank.transactions); + final byMonth = meta.spendByMonth(bank.transactions); + final totalSpend = byCategory.values.fold(0, (sum, v) => sum + v); + + final recurrings = recurringItemsFrom( + transactions: bank.transactions, + metaFor: meta.metaFor, + ); + final today = dateOnly(DateTime.now()); + final lookAheadEnd = DateTime( + _lookAheadMonth.year, + _lookAheadMonth.month + 1, + 0, + ); + final predicted = predictBalance( + currentBalance: currentBalance, + from: today, + throughDate: lookAheadEnd, + items: recurrings, + ); + final projected = projectedOccurrences( + items: recurrings, + from: today, + to: lookAheadEnd, + ); + final nowMonth = monthStart(DateTime.now()); + + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.only(top: 8, bottom: 32), + children: [ + Text('Current balance', style: theme.textTheme.labelMedium), + const SizedBox(height: 4), + Text( + formatMoney(currentBalance, currency), + style: theme.textTheme.headlineMedium, + ), + const SizedBox(height: 20), + SwitchListTile( + contentPadding: EdgeInsets.zero, + secondary: const Icon(Icons.auto_graph_outlined), + title: const Text('Look ahead'), + subtitle: const Text( + 'Project balance from recurring income and spend', + ), + value: _lookAheadEnabled, + onChanged: (value) { + setState(() { + _lookAheadEnabled = value; + if (value && _lookAheadMonth.isBefore(nowMonth)) { + _lookAheadMonth = nowMonth; + } + }); + }, + ), + if (_lookAheadEnabled) ...[ + const SizedBox(height: 8), + Card( + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + IconButton( + tooltip: 'Previous month', + onPressed: () => _goLookAheadMonth(-1), + icon: const Icon(Icons.chevron_left), + ), + Expanded( + child: Text( + formatMonthYear(_lookAheadMonth), + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium, + ), + ), + IconButton( + tooltip: 'Next month', + onPressed: () => _goLookAheadMonth(1), + icon: const Icon(Icons.chevron_right), + ), + ], + ), + Text( + 'Predicted balance', + textAlign: TextAlign.center, + style: theme.textTheme.labelMedium, + ), + const SizedBox(height: 4), + Text( + formatMoney(predicted, currency), + textAlign: TextAlign.center, + style: theme.textTheme.headlineMedium?.copyWith( + color: predicted >= currentBalance + ? colors.primary + : colors.error, + ), + ), + const SizedBox(height: 4), + Text( + _lookAheadMonth == nowMonth && !lookAheadEnd.isAfter(today) + ? 'Mark transactions as recurring to forecast' + : 'Through ${formatDate(lookAheadEnd)} · ' + '${projected.length} projected ' + '${projected.length == 1 ? 'item' : 'items'}', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium, + ), + if (recurrings.isEmpty) ...[ + const SizedBox(height: 12), + Text( + 'No recurring transactions yet. Open a transaction and ' + 'enable Recurring.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium, + ), + ] else if (projected.isNotEmpty) ...[ + const SizedBox(height: 12), + 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, + ), + title: Text( + row.item.description, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text(formatDate(row.date)), + trailing: Text( + formatMoney(row.item.amount, row.item.currency), + style: theme.textTheme.titleSmall?.copyWith( + color: row.item.amount >= 0 + ? colors.primary + : colors.error, + ), + ), + ), + if (projected.length > 12) + Text( + '+${projected.length - 12} more…', + textAlign: TextAlign.center, + style: theme.textTheme.labelMedium, + ), + ], + ], + ), + ), + ), + ], + const SizedBox(height: 20), + Text('Money out (categorized)', style: theme.textTheme.labelMedium), + const SizedBox(height: 4), + Text( + formatMoney(totalSpend, currency), + style: theme.textTheme.titleLarge, + ), + const SizedBox(height: 16), + SegmentedButton<_StatsMode>( + segments: const [ + ButtonSegment( + value: _StatsMode.category, + label: Text('Category'), + icon: Icon(Icons.label_outline), + ), + ButtonSegment( + value: _StatsMode.month, + label: Text('By date'), + icon: Icon(Icons.calendar_month_outlined), + ), + ], + selected: {_mode}, + onSelectionChanged: (selection) { + setState(() => _mode = selection.first); + }, + ), + const SizedBox(height: 20), + SizedBox( + height: 260, + child: bank.transactions.isEmpty + ? Center( + child: Text( + 'Transactions will appear here once loaded.', + style: theme.textTheme.bodyMedium, + ), + ) + : _mode == _StatsMode.category + ? _CategoryBars(data: byCategory, currency: currency) + : _MonthBars(data: byMonth, currency: currency), + ), + const SizedBox(height: 20), + if (_mode == _StatsMode.category) + ..._categoryLegend(context, byCategory, currency) + else + ..._monthLegend(context, byMonth, currency), + ], + ); + } + + List _categoryLegend( + BuildContext context, + Map data, + String currency, + ) { + final entries = data.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + if (entries.isEmpty) { + return [ + Text( + 'No outflows yet. Categorize spending from the transaction list.', + 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)), + ), + ]; + } + + List _monthLegend( + BuildContext context, + Map data, + String currency, + ) { + if (data.isEmpty) { + return [ + Text( + 'No dated outflows to chart yet.', + style: Theme.of(context).textTheme.bodyMedium, + ), + ]; + } + return [ + for (final entry in data.entries.toList().reversed) + ListTile( + contentPadding: EdgeInsets.zero, + dense: true, + leading: const Icon(Icons.calendar_today_outlined), + title: Text(entry.key), + trailing: Text(formatMoney(entry.value, currency)), + ), + ]; + } +} + +class _CategoryBars extends StatelessWidget { + const _CategoryBars({required this.data, required this.currency}); + + final Map data; + final String currency; + + @override + Widget build(BuildContext context) { + final entries = data.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + if (entries.isEmpty) { + return Center( + child: Text( + 'Categorize transactions to see this chart', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ); + } + + final maxY = entries.map((e) => e.value).reduce((a, b) => a > b ? a : b); + return BarChart( + BarChartData( + maxY: maxY * 1.15, + barTouchData: BarTouchData( + touchTooltipData: BarTouchTooltipData( + getTooltipItem: (group, groupIndex, rod, rodIndex) { + final entry = entries[group.x.toInt()]; + return BarTooltipItem( + '${entry.key.label}\n${formatMoney(entry.value, currency)}', + TextStyle( + color: Theme.of(context).colorScheme.onInverseSurface, + ), + ); + }, + ), + ), + titlesData: FlTitlesData( + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: + const AxisTitles(sideTitles: SideTitles(showTitles: false)), + leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 42, + getTitlesWidget: (value, meta) { + final index = value.toInt(); + if (index < 0 || index >= entries.length) { + return const SizedBox.shrink(); + } + final label = entries[index].key.label; + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + label.length > 8 ? '${label.substring(0, 7)}…' : label, + style: Theme.of(context).textTheme.labelSmall, + ), + ); + }, + ), + ), + ), + gridData: const FlGridData(show: false), + borderData: FlBorderData(show: false), + barGroups: [ + for (var i = 0; i < entries.length; i++) + BarChartGroupData( + x: i, + barRods: [ + BarChartRodData( + toY: entries[i].value, + width: 18, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(6)), + color: entries[i].key.color, + ), + ], + ), + ], + ), + ); + } +} + +class _MonthBars extends StatelessWidget { + const _MonthBars({required this.data, required this.currency}); + + final Map data; + final String currency; + + @override + Widget build(BuildContext context) { + final entries = data.entries.toList(); + if (entries.isEmpty) { + return Center( + child: Text( + 'No monthly spend yet', + style: Theme.of(context).textTheme.bodyMedium, + ), + ); + } + + final maxY = entries.map((e) => e.value).reduce((a, b) => a > b ? a : b); + final colors = Theme.of(context).colorScheme; + return BarChart( + BarChartData( + maxY: maxY * 1.15, + barTouchData: BarTouchData( + touchTooltipData: BarTouchTooltipData( + getTooltipItem: (group, groupIndex, rod, rodIndex) { + final entry = entries[group.x.toInt()]; + return BarTooltipItem( + '${entry.key}\n${formatMoney(entry.value, currency)}', + TextStyle(color: colors.onInverseSurface), + ); + }, + ), + ), + titlesData: FlTitlesData( + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: + const AxisTitles(sideTitles: SideTitles(showTitles: false)), + leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 28, + 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; + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + short, + style: Theme.of(context).textTheme.labelSmall, + ), + ); + }, + ), + ), + ), + gridData: const FlGridData(show: false), + borderData: FlBorderData(show: false), + barGroups: [ + for (var i = 0; i < entries.length; i++) + BarChartGroupData( + x: i, + barRods: [ + BarChartRodData( + toY: entries[i].value, + width: 22, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(6)), + color: colors.primary, + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/screens/transaction_detail_screen.dart b/lib/screens/transaction_detail_screen.dart new file mode 100644 index 0000000..1e1eecf --- /dev/null +++ b/lib/screens/transaction_detail_screen.dart @@ -0,0 +1,153 @@ +/// Transaction detail: category, recurring, and user notes. +library; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../models/bank.dart'; +import '../models/transaction_meta.dart'; +import '../providers/tx_meta_provider.dart'; +import '../utils/helpers.dart'; +import '../widgets/responsive_body.dart'; + +class TransactionDetailScreen extends StatefulWidget { + const TransactionDetailScreen({super.key, required this.tx}); + + final BankTransaction tx; + + @override + State createState() => + _TransactionDetailScreenState(); +} + +class _TransactionDetailScreenState extends State { + late final TextEditingController _notesController; + var _dirty = false; + + @override + void initState() { + super.initState(); + final notes = context.read().metaFor(widget.tx).notes; + _notesController = TextEditingController(text: notes); + } + + @override + void dispose() { + _notesController.dispose(); + super.dispose(); + } + + Future _saveNotes() async { + await context.read().setNotes( + widget.tx, + _notesController.text, + ); + if (!mounted) return; + setState(() => _dirty = false); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Notes saved')), + ); + } + + @override + Widget build(BuildContext context) { + final tx = widget.tx; + final meta = context.watch().metaFor(tx); + final theme = Theme.of(context); + final colors = theme.colorScheme; + final amountColor = tx.amount >= 0 ? colors.primary : colors.error; + + return Scaffold( + appBar: AppBar( + title: const Text('Transaction'), + actions: [ + if (_dirty) + TextButton( + onPressed: _saveNotes, + child: const Text('Save'), + ), + ], + ), + body: ResponsiveBody( + child: ListView( + padding: const EdgeInsets.only(top: 8, bottom: 32), + children: [ + Text( + formatMoney(tx.amount, tx.currency), + style: theme.textTheme.headlineMedium?.copyWith(color: amountColor), + ), + const SizedBox(height: 8), + Text(tx.description, style: theme.textTheme.titleMedium), + const SizedBox(height: 4), + Text( + [ + formatIsoDate(tx.date), + if (tx.pending) 'Pending', + ].join(' · '), + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 24), + SwitchListTile( + contentPadding: EdgeInsets.zero, + secondary: const Icon(Icons.autorenew), + title: const Text('Recurring'), + subtitle: const Text('Salary, mortgage, subscriptions…'), + value: meta.recurring, + onChanged: (value) { + context.read().setRecurring(tx, value); + }, + ), + const SizedBox(height: 16), + Text('Category', style: theme.textTheme.titleSmall), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final category in TxCategory.values) + ChoiceChip( + avatar: Icon( + category.icon, + size: 16, + color: category.color, + ), + label: Text(category.label), + selected: meta.category == category, + onSelected: (_) { + context.read().setCategory(tx, category); + }, + ), + ], + ), + const SizedBox(height: 24), + Text('Notes', style: theme.textTheme.titleSmall), + const SizedBox(height: 8), + TextField( + controller: _notesController, + minLines: 3, + maxLines: 6, + textInputAction: TextInputAction.newline, + decoration: const InputDecoration( + hintText: 'Add a private note…', + ), + onChanged: (_) { + if (!_dirty) setState(() => _dirty = true); + }, + ), + const SizedBox(height: 12), + FilledButton( + onPressed: _dirty ? _saveNotes : null, + child: const Text('Save notes'), + ), + const SizedBox(height: 16), + Text( + 'Matching descriptions reuse this category automatically. ' + 'Exact same amount + description also copies notes.', + style: theme.textTheme.labelMedium, + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/transactions_screen.dart b/lib/screens/transactions_screen.dart index e616528..80149a5 100644 --- a/lib/screens/transactions_screen.dart +++ b/lib/screens/transactions_screen.dart @@ -1,20 +1,146 @@ -/// Transactions tab: recent bank movements from the ingtrak API. +/// Searchable transaction list with month heatmap and money in/out totals. library; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/bank.dart'; +import '../models/transaction_meta.dart'; import '../providers/bank_provider.dart'; +import '../providers/tx_meta_provider.dart'; import '../utils/connect_bank.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 '../widgets/tx_heatmap.dart'; +import 'transaction_detail_screen.dart'; -class TransactionsScreen extends StatelessWidget { +class TransactionsScreen extends StatefulWidget { const TransactionsScreen({super.key}); + @override + State createState() => _TransactionsScreenState(); +} + +class _TransactionsScreenState extends State { + final _searchController = TextEditingController(); + String _query = ''; + DateTime _month = monthStart(DateTime.now()); + DateTime? _selectedDay; + var _searchInMonthOnly = false; + final Set _categoryFilters = {}; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + bool get _isSearching => + _query.trim().isNotEmpty || _categoryFilters.isNotEmpty; + + void _goMonth(int delta) { + final next = addMonths(_month, delta); + final current = monthStart(DateTime.now()); + if (delta > 0 && next.isAfter(current)) return; + setState(() { + _month = next.isAfter(current) ? current : next; + _selectedDay = null; + }); + } + + DateTime? _txDate(BankTransaction tx) => DateTime.tryParse(tx.date); + + bool _inCalendarScope(BankTransaction tx) { + final date = _txDate(tx); + if (date == null) return false; + if (_selectedDay != null) return isSameDay(date, _selectedDay!); + return isSameMonth(date, _month); + } + + bool _inMonth(BankTransaction tx) { + final date = _txDate(tx); + if (date == null) return false; + return isSameMonth(date, _month); + } + + Future _pickCategories(List available) async { + if (available.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('No categories in use yet')), + ); + return; + } + + final selected = Set.of(_categoryFilters); + await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) { + return StatefulBuilder( + builder: (context, setSheetState) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Filter by category', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final category in available) + FilterChip( + avatar: Icon( + category.icon, + size: 16, + color: category.color, + ), + label: Text(category.label), + selected: selected.contains(category), + onSelected: (value) { + setSheetState(() { + if (value) { + selected.add(category); + } else { + selected.remove(category); + } + }); + }, + ), + ], + ), + const SizedBox(height: 16), + FilledButton( + onPressed: () { + setState(() { + _categoryFilters + ..clear() + ..addAll(selected); + }); + Navigator.pop(sheetContext); + }, + child: const Text('Apply'), + ), + ], + ), + ), + ); + }, + ); + }, + ); + } + @override Widget build(BuildContext context) { final bank = context.watch(); @@ -22,31 +148,26 @@ class TransactionsScreen extends StatelessWidget { return Scaffold( appBar: AppBar( title: const Text('Transactions'), - actions: [ - IconButton( - tooltip: 'Refresh', - onPressed: bank.isLoading ? null : bank.refresh, - icon: const Icon(Icons.refresh), - ), - ], + actions: bankAppBarActions( + context: context, + onRefresh: bank.refresh, + refreshing: bank.isLoading, + ), ), body: RefreshIndicator( onRefresh: bank.refresh, child: ResponsiveBody( - child: _TransactionsBody(bank: bank), + child: _buildBody(context, bank), ), ), ); } -} -class _TransactionsBody extends StatelessWidget { - const _TransactionsBody({required this.bank}); + Widget _buildBody(BuildContext context, BankController bank) { + final metaController = context.watch(); + final theme = Theme.of(context); + final colors = theme.colorScheme; - final BankController bank; - - @override - Widget build(BuildContext context) { if (bank.isLoading && bank.transactions.isEmpty) { return const LoadingView(message: 'Loading transactions…'); } @@ -59,7 +180,7 @@ class _TransactionsBody extends StatelessWidget { icon: Icons.receipt_long_outlined, title: 'No transactions yet', message: bank.error ?? - 'Link your bank on the Balance tab to load recent activity.', + 'Link your bank from Home to load recent activity.', actionLabel: bank.isConnecting ? 'Opening…' : 'Connect bank', onAction: () => runConnectBank(context, bank), ), @@ -95,46 +216,332 @@ class _TransactionsBody extends StatelessWidget { ); } - return ListView.separated( + final nowMonth = monthStart(DateTime.now()); + final monthDates = [ + for (final tx in bank.transactions) + if (_txDate(tx) != null && isSameMonth(_txDate(tx)!, _month)) + dateOnly(_txDate(tx)!), + ]; + final counts = txCountsByDay(monthDates); + + final calendarScoped = [ + for (final tx in bank.transactions) + if (_inCalendarScope(tx)) tx, + ]; + + final searchPool = !_isSearching + ? calendarScoped + : _searchInMonthOnly + ? [ + for (final tx in bank.transactions) + if (_inMonth(tx)) tx, + ] + : bank.transactions; + + final filtered = metaController.search( + searchPool, + _query, + categories: _categoryFilters, + ); + + var moneyOut = 0.0; + var moneyIn = 0.0; + for (final tx in calendarScoped) { + if (tx.amount < 0) { + moneyOut += tx.amount.abs(); + } else if (tx.amount > 0) { + moneyIn += tx.amount; + } + } + final currency = bank.balance?.currency ?? + (bank.transactions.isNotEmpty + ? bank.transactions.first.currency + : 'EUR'); + final usedCategories = metaController.usedCategories(bank.transactions); + + return ListView( physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.only(top: 8, bottom: 32), - itemCount: bank.transactions.length, - separatorBuilder: (_, _) => const SizedBox(height: 8), - itemBuilder: (context, index) { - return _TransactionTile(tx: bank.transactions[index]); - }, + padding: const EdgeInsets.only(bottom: 32), + children: [ + Padding( + padding: const EdgeInsets.only(top: 8, bottom: 4), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _searchController, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + hintText: 'Search description, notes…', + prefixIcon: IconButton( + tooltip: 'Categories', + onPressed: () => _pickCategories(usedCategories), + icon: Icon( + Icons.search, + color: _categoryFilters.isEmpty + ? null + : colors.primary, + ), + ), + suffixIcon: _query.isEmpty + ? null + : IconButton( + tooltip: 'Clear', + onPressed: () { + _searchController.clear(); + setState(() => _query = ''); + }, + icon: const Icon(Icons.clear), + ), + ), + onChanged: (value) => setState(() => _query = value), + ), + ), + const SizedBox(width: 4), + Tooltip( + message: 'Search only in calendar month', + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Checkbox( + value: _searchInMonthOnly, + onChanged: (value) { + setState(() => _searchInMonthOnly = value ?? false); + }, + ), + Text('Month', style: theme.textTheme.labelMedium), + ], + ), + ), + ], + ), + ), + if (_categoryFilters.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final category in _categoryFilters) + InputChip( + avatar: Icon(category.icon, size: 16, color: category.color), + label: Text(category.label), + onDeleted: () { + setState(() => _categoryFilters.remove(category)); + }, + ), + ], + ), + ), + TxHeatmap( + month: _month, + counts: counts, + canGoForward: _month.isBefore(nowMonth), + selectedDay: _selectedDay, + onPreviousMonth: () => _goMonth(-1), + onNextMonth: () => _goMonth(1), + onSelectDay: (day) => setState(() => _selectedDay = day), + ), + if (_selectedDay != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Align( + alignment: Alignment.centerLeft, + child: InputChip( + label: Text(formatDate(_selectedDay!)), + onDeleted: () => setState(() => _selectedDay = null), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(0, 4, 0, 12), + child: Row( + children: [ + Expanded( + child: _TotalsCard( + label: 'Money out', + value: formatMoney(moneyOut, currency), + color: colors.error, + icon: Icons.arrow_upward, + ), + ), + const SizedBox(width: 10), + Expanded( + child: _TotalsCard( + label: 'Money in', + value: formatMoney(moneyIn, currency), + color: colors.primary, + icon: Icons.arrow_downward, + ), + ), + ], + ), + ), + if (_isSearching) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + _searchInMonthOnly + ? 'Searching in ${formatMonthYear(_month)}' + : 'Searching all transactions', + style: theme.textTheme.labelMedium, + ), + ), + if (filtered.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Text( + _isSearching + ? 'No transactions match this search.' + : _selectedDay != null + ? 'No transactions on this day.' + : 'No transactions this month.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium, + ), + ) + else + for (var i = 0; i < filtered.length; i++) ...[ + if (i > 0) + Divider( + height: 1, + color: colors.outlineVariant.withValues(alpha: 0.5), + ), + _CompactTransactionTile( + tx: filtered[i], + meta: metaController.metaFor(filtered[i]), + ), + ], + ], ); } } -class _TransactionTile extends StatelessWidget { - const _TransactionTile({required this.tx}); +class _TotalsCard extends StatelessWidget { + const _TotalsCard({ + required this.label, + required this.value, + required this.color, + required this.icon, + }); - final BankTransaction tx; + final String label; + final String value; + final Color color; + final IconData icon; @override Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; - final isCredit = tx.amount >= 0; - final amountColor = isCredit ? colors.primary : colors.error; - return Card( - child: ListTile( - title: Text( - tx.description, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - [ - formatIsoDate(tx.date), - if (tx.pending) 'Pending', - ].where((part) => part.isNotEmpty).join(' · '), - ), - trailing: Text( - formatMoney(tx.amount, tx.currency), - style: theme.textTheme.titleMedium?.copyWith(color: amountColor), + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: colors.surfaceContainerLow, + borderRadius: BorderRadius.circular(14), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 6), + Text(label, style: theme.textTheme.labelMedium), + ], + ), + const SizedBox(height: 6), + Text( + value, + style: theme.textTheme.titleMedium?.copyWith(color: color), + ), + ], + ), + ); + } +} + +class _CompactTransactionTile extends StatelessWidget { + const _CompactTransactionTile({ + required this.tx, + required this.meta, + }); + + final BankTransaction tx; + final TxMeta meta; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final amountColor = tx.amount >= 0 ? colors.primary : colors.error; + + return InkWell( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => TransactionDetailScreen(tx: tx), + ), + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: meta.category.color.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + meta.category.icon, + size: 18, + color: meta.category.color, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + tx.description, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyLarge?.copyWith( + fontSize: 15, + height: 1.2, + ), + ), + const SizedBox(height: 2), + Text( + [ + formatIsoDate(tx.date), + if (meta.hasCategory) meta.category.label, + if (meta.recurring) 'Recurring', + if (meta.hasNotes) 'Note', + if (tx.pending) 'Pending', + ].join(' · '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelMedium, + ), + ], + ), + ), + const SizedBox(width: 8), + Text( + formatMoney(tx.amount, tx.currency), + style: theme.textTheme.titleSmall?.copyWith( + color: amountColor, + fontWeight: FontWeight.w600, + ), + ), + ], ), ), ); diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index ea525a5..456feb6 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -1,10 +1,26 @@ /// Thin wrapper around [SharedPreferences] for local key-value persistence. library; +import 'dart:convert'; + import 'package:shared_preferences/shared_preferences.dart'; +import '../models/transaction_meta.dart'; import '../utils/constants.dart'; +/// Persisted annotation store for transactions. +class TxMetaStoreData { + const TxMetaStoreData({ + this.byId = const {}, + this.categoryByDescription = const {}, + this.exactTemplates = const {}, + }); + + final Map byId; + final Map categoryByDescription; + final Map exactTemplates; +} + /// Loads and stores user preferences. Call [init] once at startup. class StorageService { StorageService({this._preferences}); @@ -35,4 +51,123 @@ class StorageService { Future setUseRemoteData(bool value) { return _prefs.setBool(StorageKeys.useRemoteData, value); } + + TxMetaStoreData loadTxMetaStore() { + final raw = _prefs.getString(StorageKeys.txMeta); + if (raw != null && raw.isNotEmpty) { + return _decodeStore(raw); + } + + // Migrate flat v1 map → v2 store. + final legacy = _prefs.getString(StorageKeys.txMetaLegacy); + if (legacy == null || legacy.isEmpty) return const TxMetaStoreData(); + try { + final decoded = jsonDecode(legacy); + if (decoded is! Map) return const TxMetaStoreData(); + final byId = {}; + final categoryByDescription = {}; + decoded.forEach((key, value) { + if (key is! String || value is! Map) return; + final meta = TxMeta.fromJson(Map.from(value)); + byId[key] = meta; + if (meta.hasCategory) { + final desc = _descriptionFromLegacyId(key); + if (desc != null) { + categoryByDescription[desc] = meta.category; + } + } + }); + return TxMetaStoreData( + byId: byId, + categoryByDescription: categoryByDescription, + ); + } catch (_) { + return const TxMetaStoreData(); + } + } + + Future saveTxMetaStore(TxMetaStoreData store) { + final encoded = { + 'byId': { + for (final e in store.byId.entries) e.key: e.value.toJson(), + }, + 'categoryByDescription': { + for (final e in store.categoryByDescription.entries) + e.key: e.value.name, + }, + 'exactTemplates': { + for (final e in store.exactTemplates.entries) e.key: e.value.toJson(), + }, + }; + return _prefs.setString(StorageKeys.txMeta, jsonEncode(encoded)); + } + + /// Deprecated shim used by older call sites during transition. + Map loadTxMeta() => loadTxMetaStore().byId; + + Future saveTxMeta(Map meta) { + final current = loadTxMetaStore(); + return saveTxMetaStore( + TxMetaStoreData( + byId: meta, + categoryByDescription: current.categoryByDescription, + exactTemplates: current.exactTemplates, + ), + ); + } + + TxMetaStoreData _decodeStore(String raw) { + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) return const TxMetaStoreData(); + final root = Map.from(decoded); + + final byId = {}; + final byIdRaw = root['byId']; + if (byIdRaw is Map) { + byIdRaw.forEach((key, value) { + if (key is String && value is Map) { + byId[key] = TxMeta.fromJson(Map.from(value)); + } + }); + } + + final categoryByDescription = {}; + final cats = root['categoryByDescription']; + if (cats is Map) { + cats.forEach((key, value) { + if (key is String && value is String) { + categoryByDescription[key] = TxCategoryX.fromName(value); + } + }); + } + + final exactTemplates = {}; + final templates = root['exactTemplates']; + if (templates is Map) { + templates.forEach((key, value) { + if (key is String && value is Map) { + exactTemplates[key] = + TxExactTemplate.fromJson(Map.from(value)); + } + }); + } + + return TxMetaStoreData( + byId: byId, + categoryByDescription: categoryByDescription, + exactTemplates: exactTemplates, + ); + } catch (_) { + return const TxMetaStoreData(); + } + } + + /// Legacy id format: `date|currency|amount|description`. + String? _descriptionFromLegacyId(String id) { + final parts = id.split('|'); + if (parts.length < 4) return null; + final description = parts.sublist(3).join('|'); + return description.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' '); + } } diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index bcd2b25..16c7a2b 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -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]. diff --git a/lib/utils/helpers.dart b/lib/utils/helpers.dart index 17f8273..9e52f4f 100644 --- a/lib/utils/helpers.dart +++ b/lib/utils/helpers.dart @@ -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 ''; diff --git a/lib/utils/look_ahead.dart b/lib/utils/look_ahead.dart new file mode 100644 index 0000000..4e2833e --- /dev/null +++ b/lib/utils/look_ahead.dart @@ -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 recurringItemsFrom({ + required List transactions, + required TxMeta Function(BankTransaction tx) metaFor, +}) { + final latestByExact = {}; + 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 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 items, +}) { + final projected = projectedOccurrences( + items: items, + from: from, + to: throughDate, + ); + return projected.fold( + currentBalance, + (sum, row) => sum + row.item.amount, + ); +} diff --git a/lib/utils/navigation.dart b/lib/utils/navigation.dart new file mode 100644 index 0000000..08a457d --- /dev/null +++ b/lib/utils/navigation.dart @@ -0,0 +1,30 @@ +/// Shared helpers to open Settings from app bars. +library; + +import 'package:flutter/material.dart'; + +import '../screens/settings_screen.dart'; + +List 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(builder: (_) => const SettingsScreen()), + ); + }, + icon: const Icon(Icons.settings_outlined), + ), + ]; +} diff --git a/lib/utils/tx_calendar.dart b/lib/utils/tx_calendar.dart new file mode 100644 index 0000000..7be1dba --- /dev/null +++ b/lib/utils/tx_calendar.dart @@ -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 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 = [ + ...List.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 txCountsByDay(Iterable dates) { + final counts = {}; + 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; +} diff --git a/lib/widgets/tx_heatmap.dart b/lib/widgets/tx_heatmap.dart new file mode 100644 index 0000000..99483bd --- /dev/null +++ b/lib/widgets/tx_heatmap.dart @@ -0,0 +1,204 @@ +/// GitHub-style contribution heatmap for one calendar month of transactions. +library; + +import 'package:flutter/material.dart'; + +import '../utils/helpers.dart'; +import '../utils/tx_calendar.dart'; + +class TxHeatmap extends StatelessWidget { + const TxHeatmap({ + super.key, + required this.month, + required this.counts, + required this.canGoForward, + this.selectedDay, + required this.onPreviousMonth, + required this.onNextMonth, + required this.onSelectDay, + }); + + final DateTime month; + final Map counts; + final bool canGoForward; + final DateTime? selectedDay; + final VoidCallback onPreviousMonth; + final VoidCallback onNextMonth; + final ValueChanged onSelectDay; + + static const _weekdays = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final cells = monthGrid(month); + final today = dateOnly(DateTime.now()); + final weeks = cells.length ~/ 7; + + return Card( + margin: const EdgeInsets.fromLTRB(0, 8, 0, 8), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + IconButton( + tooltip: 'Previous month', + onPressed: onPreviousMonth, + icon: const Icon(Icons.chevron_left), + ), + Expanded( + child: Text( + formatMonthYear(month), + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium, + ), + ), + IconButton( + tooltip: 'Next month', + onPressed: canGoForward ? onNextMonth : null, + icon: const Icon(Icons.chevron_right), + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + for (final label in _weekdays) + Expanded( + child: Text( + label, + textAlign: TextAlign.center, + style: theme.textTheme.labelMedium, + ), + ), + ], + ), + const SizedBox(height: 4), + for (var week = 0; week < weeks; week++) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Row( + children: [ + for (var weekday = 0; weekday < 7; weekday++) + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: _HeatCell( + date: cells[week * 7 + weekday], + count: cells[week * 7 + weekday] == null + ? 0 + : counts[cells[week * 7 + weekday]] ?? 0, + today: today, + selectedDay: selectedDay, + onSelect: onSelectDay, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Text('Less', style: theme.textTheme.labelMedium), + const SizedBox(width: 6), + for (var level = 0; level <= 4; level++) + Padding( + padding: const EdgeInsets.only(right: 3), + child: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: _fillForLevel(scheme, level), + borderRadius: BorderRadius.circular(3), + ), + ), + ), + Text('More', style: theme.textTheme.labelMedium), + ], + ), + ], + ), + ), + ); + } +} + +class _HeatCell extends StatelessWidget { + const _HeatCell({ + required this.date, + required this.count, + required this.today, + required this.selectedDay, + required this.onSelect, + }); + + final DateTime? date; + final int count; + final DateTime today; + final DateTime? selectedDay; + final ValueChanged onSelect; + + @override + Widget build(BuildContext context) { + if (date == null) { + return const SizedBox(height: 36); + } + + final scheme = Theme.of(context).colorScheme; + final level = heatmapLevel(count); + final selected = selectedDay != null && isSameDay(date!, selectedDay!); + final isToday = isSameDay(date!, today); + final fill = _fillForLevel(scheme, level); + final textColor = level >= 3 + ? scheme.onPrimary + : scheme.onSurface.withValues(alpha: count == 0 ? 0.45 : 1); + + return Tooltip( + message: count == 0 + ? formatDate(date!) + : '${formatDate(date!)} · ${count == 1 ? '1 tx' : '$count txs'}', + child: InkWell( + onTap: () => onSelect(selected ? null : date), + borderRadius: BorderRadius.circular(6), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + height: 36, + alignment: Alignment.center, + decoration: BoxDecoration( + color: fill, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: selected + ? scheme.primary + : isToday + ? scheme.outline + : Colors.transparent, + width: selected ? 2 : 1, + ), + ), + child: Text( + '${date!.day}', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: textColor, + fontWeight: isToday || selected + ? FontWeight.w700 + : FontWeight.w500, + fontSize: 11, + ), + ), + ), + ), + ); + } +} + +Color _fillForLevel(ColorScheme scheme, int level) { + if (level <= 0) return scheme.surfaceContainerHighest; + final t = (level / 4).clamp(0.28, 1.0); + return Color.lerp(scheme.primary.withValues(alpha: 0.22), scheme.primary, t)!; +} diff --git a/pubspec.lock b/pubspec.lock index 4ed739a..6ef28b5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -81,6 +81,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.9" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" + url: "https://pub.dev" + source: hosted + version: "2.1.0" fake_async: dependency: transitive description: @@ -105,6 +113,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: b938f77d042cbcd822936a7a359a7235bad8bd72070de1f827efc2cc297ac888 + url: "https://pub.dev" + source: hosted + version: "1.2.0" flutter: dependency: "direct main" description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 6435b13..14278ff 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,7 +39,7 @@ dependencies: shared_preferences: ^2.5.5 # Persist theme mode and data-source toggle app_links: ^7.2.1 webview_flutter: ^4.14.1 - + fl_chart: ^1.2.0 dev_dependencies: flutter_test: sdk: flutter diff --git a/test/widget_test.dart b/test/widget_test.dart index 2452777..f86ccc7 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,9 +1,11 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:myapp/main.dart'; import 'package:myapp/models/bank.dart'; +import 'package:myapp/models/transaction_meta.dart'; import 'package:myapp/services/storage_service.dart'; import 'package:myapp/utils/constants.dart'; import 'package:myapp/utils/helpers.dart'; +import 'package:myapp/utils/look_ahead.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() { @@ -16,7 +18,7 @@ void main() { await tester.pumpWidget(MyApp(storage: storage)); } - testWidgets('splash shows the app name then opens Balance', (tester) async { + testWidgets('splash shows the app name then opens Home', (tester) async { await pumpApp(tester); expect(find.text(kAppName), findsWidgets); @@ -25,9 +27,9 @@ void main() { await tester.pump(const Duration(milliseconds: 450)); await tester.pump(const Duration(milliseconds: 350)); - expect(find.text('Balance'), findsWidgets); - expect(find.text('Transactions'), findsOneWidget); - expect(find.text('Settings'), findsOneWidget); + expect(find.text('Home'), findsWidgets); + expect(find.text('Overview'), findsOneWidget); + expect(find.byTooltip('Settings'), findsOneWidget); }); test('ConnectStart.fromJson maps authUrl and state', () { @@ -39,16 +41,47 @@ void main() { expect(start.state, 'abc'); }); - test('BankTransaction.fromJson maps API fields', () { + test('BankTransaction match keys support auto-category and notes', () { final tx = BankTransaction.fromJson({ 'date': '2026-09-01', - 'description': 'Coffee', + 'description': 'Coffee Shop', 'amount': -3.5, 'currency': 'EUR', 'pending': false, }); - expect(tx.description, 'Coffee'); - expect(tx.amount, -3.5); + expect(tx.descriptionKey, 'coffee shop'); + expect(tx.exactKey, contains('coffee shop')); + expect(tx.exactKey, contains('-3.5')); expect(formatMoney(tx.amount, tx.currency), contains('EUR')); + expect(TxCategory.groceries.label, 'Groceries'); + }); + + test('look-ahead applies monthly recurring amounts', () { + final items = [ + const RecurringItem( + description: 'Salary', + amount: 2000, + currency: 'EUR', + dayOfMonth: 25, + category: TxCategory.salary, + ), + const RecurringItem( + description: 'Rent', + amount: -900, + currency: 'EUR', + dayOfMonth: 1, + category: TxCategory.mortgage, + ), + ]; + final from = DateTime(2026, 9, 6); + final through = DateTime(2026, 10, 31); + final predicted = predictBalance( + currentBalance: 1000, + from: from, + throughDate: through, + items: items, + ); + // Sep 25 salary + Oct 1 rent + Oct 25 salary = 1000+2000-900+2000 + expect(predicted, 4100); }); }