/// 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 _labelFilters = {}; @override void dispose() { _searchController.dispose(); super.dispose(); } bool get _isSearching => _query.trim().isNotEmpty || _labelFilters.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 _pickLabels(List available) async { if (available.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('No labels in use yet')), ); return; } final selected = Set.of(_labelFilters); 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 label', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 12), Wrap( spacing: 8, runSpacing: 8, children: [ for (final label in available) FilterChip( avatar: Icon( label.icon, size: 16, color: label.color, ), label: Text(label.name), selected: selected.contains(label.id), onSelected: (value) { setSheetState(() { if (value) { selected.add(label.id); } else { selected.remove(label.id); } }); }, ), ], ), const SizedBox(height: 16), FilledButton( onPressed: () { setState(() { _labelFilters ..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, labelIds: _labelFilters, ); 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 usedLabels = metaController.usedLabels(bank.transactions); final labelById = { for (final label in metaController.allLabels) label.id: label, }; 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: 'Labels', onPressed: () => _pickLabels(usedLabels), icon: Icon( Icons.search, color: _labelFilters.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 (_labelFilters.isNotEmpty) Padding( padding: const EdgeInsets.only(bottom: 8), child: Wrap( spacing: 8, runSpacing: 8, children: [ for (final id in _labelFilters) if (labelById[id] != null) InputChip( avatar: Icon( labelById[id]!.icon, size: 16, color: labelById[id]!.color, ), label: Text(labelById[id]!.name), onDeleted: () { setState(() => _labelFilters.remove(id)); }, ), ], ), ), 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]), labels: metaController .labelsFor(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, this.labels = const [], }); final BankTransaction tx; final TxMeta meta; final List labels; @override Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; final amountColor = tx.amount >= 0 ? colors.primary : colors.error; final primary = labels.isEmpty ? null : labels.first; final iconColor = primary?.color ?? colors.outline; final iconData = primary?.icon ?? Icons.receipt_long_outlined; 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: iconColor.withValues(alpha: 0.14), borderRadius: BorderRadius.circular(10), ), child: Icon( iconData, size: 18, color: iconColor, ), ), 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 (labels.isNotEmpty) labels.map((l) => l.name).join(', '), 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, ), ), ], ), ), ); } }