/// 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 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(); return Scaffold( appBar: AppBar( title: const Text('Transactions'), actions: bankAppBarActions( context: context, onRefresh: bank.refresh, refreshing: bank.isLoading, ), ), body: RefreshIndicator( onRefresh: bank.refresh, child: ResponsiveBody( child: _buildBody(context, bank), ), ), ); } Widget _buildBody(BuildContext context, BankController bank) { final metaController = context.watch(); final theme = Theme.of(context); final colors = theme.colorScheme; if (bank.isLoading && bank.transactions.isEmpty) { return const LoadingView(message: 'Loading transactions…'); } if (!bank.isLinked) { return ListView( physics: const AlwaysScrollableScrollPhysics(), children: [ EmptyState( icon: Icons.receipt_long_outlined, title: 'No transactions yet', message: bank.error ?? 'Link your bank from Home to load recent activity.', actionLabel: bank.isConnecting ? 'Opening…' : 'Connect bank', onAction: () => runConnectBank(context, bank), ), ], ); } if (bank.error != null && bank.transactions.isEmpty) { return ListView( physics: const AlwaysScrollableScrollPhysics(), children: [ EmptyState( icon: Icons.error_outline, title: 'Could not load transactions', message: bank.error!, actionLabel: 'Retry', onAction: bank.refresh, ), ], ); } if (bank.transactions.isEmpty) { return ListView( physics: const AlwaysScrollableScrollPhysics(), children: const [ EmptyState( icon: Icons.inbox_outlined, title: 'No recent activity', message: 'The backend returned an empty transaction list.', ), ], ); } 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(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 _TotalsCard extends StatelessWidget { const _TotalsCard({ required this.label, required this.value, required this.color, required this.icon, }); 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; 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, ), ), ], ), ), ); } }