/// Searchable transaction list with month heatmap and money in/out totals. library; import 'dart:async'; 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/preferences_provider.dart'; import '../providers/tx_meta_provider.dart'; import '../utils/app_theme.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/home_widgets_sheet.dart'; import '../widgets/label_filter_dialog.dart'; import '../widgets/loading_view.dart'; import '../widgets/responsive_body.dart'; import '../widgets/skip_build_when_covered.dart'; import '../widgets/tx_heatmap.dart'; import 'transaction_detail_screen.dart'; class TransactionsScreen extends StatefulWidget { const TransactionsScreen({ super.key, this.initialMonth, this.initialLabelIds = const {}, this.initialSearchInMonthOnly, this.initialYear, this.initialQuery, this.initialFlow, this.initialAmountRange, }); /// When set, opens on this calendar month instead of the current month. final DateTime? initialMonth; /// Pre-select these label filters (e.g. from overview history). final Set initialLabelIds; /// When set, overrides the default "search in month" checkbox. final bool? initialSearchInMonthOnly; /// When set, label/search results are limited to this calendar year. final int? initialYear; /// Pre-fill search query (e.g. from a Home filter widget). final String? initialQuery; final TxFlowFilter? initialFlow; final TxAmountRange? initialAmountRange; @override State createState() => _TransactionsScreenState(); } class _TransactionsScreenState extends State { static const _minLiveSearchChars = 3; static const _searchDebounce = Duration(milliseconds: 280); final _searchController = TextEditingController(); late DateTime _month; DateTime? _selectedDay; late bool _searchInMonthOnly; late final Set _labelFilters; int? _yearFilter; var _showCalendar = false; var _flowFilter = TxFlowFilter.all; var _amountRange = const TxAmountRange(); /// Query actually used for filtering (Enter, or live once ≥3 chars). String _activeQuery = ''; Timer? _searchDebounceTimer; @override void initState() { super.initState(); final initial = widget.initialMonth; final now = monthStart(DateTime.now()); if (initial == null) { _month = now; } else { final start = monthStart(initial); _month = start.isAfter(now) ? now : start; } _labelFilters = Set.of(widget.initialLabelIds); // Default to current month so the list, navigator, and filter popup agree. _searchInMonthOnly = widget.initialSearchInMonthOnly ?? true; _yearFilter = widget.initialYear; _flowFilter = widget.initialFlow ?? TxFlowFilter.all; _amountRange = widget.initialAmountRange ?? const TxAmountRange(); final initialQuery = widget.initialQuery?.trim() ?? ''; if (initialQuery.isNotEmpty) { _searchController.text = initialQuery; _activeQuery = initialQuery; } } @override void dispose() { _searchDebounceTimer?.cancel(); _searchController.dispose(); super.dispose(); } bool get _isSearching => _activeQuery.trim().isNotEmpty || _labelFilters.isNotEmpty; bool get _hasListFilters => _labelFilters.isNotEmpty || _flowFilter != TxFlowFilter.all || _amountRange.isActive; bool _matchesExtraFilters(BankTransaction tx, TxMetaController meta) { if (!txMatchesFlow(tx, _flowFilter)) return false; if (!txMatchesAmountRange(tx, _amountRange)) return false; return true; } 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); List _yearScoped(List all) { final year = _yearFilter; if (year == null) return all; return [ for (final tx in all) if (_txDate(tx)?.year == year) tx, ]; } void _onSearchChanged(String value) { final trimmed = value.trim(); _searchDebounceTimer?.cancel(); if (trimmed.length >= _minLiveSearchChars) { // TextField updates via the controller; only rebuild the list after debounce. _searchDebounceTimer = Timer(_searchDebounce, () { if (!mounted) return; if (_activeQuery == trimmed) return; setState(() => _activeQuery = trimmed); }); return; } // Below threshold: no live search. Clear active text filter if needed. if (_activeQuery.isNotEmpty) { setState(() => _activeQuery = ''); } } void _onSearchSubmitted(String value) { _searchDebounceTimer?.cancel(); setState(() => _activeQuery = value.trim()); } void _clearSearch() { _searchDebounceTimer?.cancel(); _searchController.clear(); setState(() => _activeQuery = ''); } Future _pickFilters(List available) async { final bank = context.read(); final result = await showTransactionFilterDialog( context, available: available, transactions: bank.transactions, currentMonth: _month, initial: TransactionFilterSelection( labelIds: Set.of(_labelFilters), flow: _flowFilter, amountRange: _amountRange, query: _activeQuery, monthOnly: _searchInMonthOnly, ), ); if (result == null || !mounted) return; setState(() { _labelFilters ..clear() ..addAll(result.labelIds); _flowFilter = result.flow; _amountRange = result.amountRange; _searchInMonthOnly = result.monthOnly; if (!_searchInMonthOnly) { _showCalendar = false; _selectedDay = null; } _activeQuery = result.query.trim(); _searchController.text = _activeQuery; if (_activeQuery.isNotEmpty) { _searchDebounceTimer?.cancel(); } }); } Future _editLabelsForSubset(List subset) async { if (subset.isEmpty) return; final meta = context.read(); final initial = meta.commonLabelIds(subset); final result = await showLabelFilterDialog( context, available: meta.allLabels, initiallySelected: initial, title: 'Edit labels', subtitle: 'Apply to ${subset.length} transaction${subset.length == 1 ? '' : 's'}. ' 'Checked labels replace the current ones.', includeUncategorized: false, allowCreate: true, ); if (result == null || !mounted) return; await meta.setLabelsForMany(subset, result.labelIds.toList(growable: false)); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( result.labelIds.isEmpty ? 'Cleared labels on ${subset.length} transactions' : 'Updated labels on ${subset.length} transactions', ), ), ); } Future _saveAsHomeWidget() async { final created = await composeHomeFilterWidget( context, initialFilter: TransactionFilterSelection( labelIds: Set.of(_labelFilters), flow: _flowFilter, amountRange: _amountRange, query: _activeQuery, monthOnly: _searchInMonthOnly, ), skipFilterDialog: true, ); if (created == null || !mounted) return; await context.read().addHomeFilterWidget(created); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Added “${created.name}” to Home')), ); } @override Widget build(BuildContext context) { final bank = context.watch(); return Scaffold( appBar: AppBar( title: const Text('Transactions'), actions: [ IconButton( tooltip: 'Save as Home widget', onPressed: _saveAsHomeWidget, icon: const Icon(Icons.dashboard_customize_outlined), ), ...bankAppBarActions( context: context, onRefresh: bank.refresh, refreshing: bank.isLoading, ), ], ), body: Column( children: [ if (bank.isLoading && bank.transactions.isNotEmpty) const LinearProgressIndicator(minHeight: 2), Expanded( child: RefreshIndicator( onRefresh: bank.refresh, child: ResponsiveBody( child: SkipBuildWhenCovered( builder: (context) => _buildBody(context, bank), ), ), ), ), ], ), ); } Widget _buildBody(BuildContext context, BankController bank) { final metaController = context.watch(); final theme = Theme.of(context); final colors = theme.colorScheme; final txs = bank.transactions; if (bank.isLoading && txs.isEmpty) { return const LoadingView(message: 'Loading transactions…'); } if (!bank.isLinked && txs.isEmpty) { 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 && txs.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 (txs.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 = []; final calendarScoped = []; final monthScoped = []; for (final tx in txs) { final date = _txDate(tx); if (date == null) continue; if (isSameMonth(date, _month)) { monthDates.add(dateOnly(date)); monthScoped.add(tx); if (_selectedDay == null || isSameDay(date, _selectedDay!)) { calendarScoped.add(tx); } } else if (_selectedDay != null && isSameDay(date, _selectedDay!)) { calendarScoped.add(tx); } } final counts = txCountsByDay(monthDates); // Period always drives the pool (not only when text/labels are active), // so Income/Expense with Period=All searches the full list. final searchPool = _searchInMonthOnly ? (_selectedDay != null ? calendarScoped : monthScoped) : _yearScoped(txs); // Apply text + label filters, then flow / amount. final filtered = [ for (final tx in metaController.search( searchPool, _activeQuery, labelIds: _labelFilters, )) if (_matchesExtraFilters(tx, metaController)) tx, ]; // Labels on the current result that aren't part of the active label filter. final otherLabels = [ for (final label in metaController.usedLabels(filtered)) if (!_labelFilters.contains(label.id)) label, ]; final showUncategorizedOther = filtered.isNotEmpty && filtered.any((tx) => !metaController.metaFor(tx).hasLabel) && !_labelFilters.contains(kUncategorizedFilterId); var moneyOut = 0.0; var moneyIn = 0.0; for (final tx in filtered) { if (metaController.metaFor(tx).internalTransfer) continue; if (tx.amount < 0) { moneyOut += tx.amount.abs(); } else if (tx.amount > 0) { moneyIn += tx.amount; } } final currency = bank.balance?.currency ?? (txs.isNotEmpty ? txs.first.currency : 'EUR'); final labelById = { for (final label in metaController.allLabels) label.id: label, }; return CustomScrollView( physics: const AlwaysScrollableScrollPhysics(), slivers: [ if (bank.error != null && txs.isNotEmpty) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.only(top: 8, bottom: 4), child: Material( color: colors.errorContainer, borderRadius: BorderRadius.circular(10), child: Padding( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 8, ), child: Text( bank.error!, style: theme.textTheme.bodySmall?.copyWith( color: colors.onErrorContainer, ), ), ), ), ), ), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.only(top: 8, bottom: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: ValueListenableBuilder( valueListenable: _searchController, builder: (context, value, _) { final draft = value.text.trim(); final showHint = draft.isNotEmpty && draft.length < _minLiveSearchChars && draft != _activeQuery.trim(); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TextField( controller: _searchController, textInputAction: TextInputAction.search, decoration: InputDecoration( hintText: 'Search (3+ chars, or press search)', prefixIcon: const Icon(Icons.search), suffixIcon: value.text.isNotEmpty ? IconButton( tooltip: 'Clear', onPressed: _clearSearch, icon: const Icon(Icons.clear), ) : null, ), onChanged: _onSearchChanged, onSubmitted: _onSearchSubmitted, ), if (showHint) Padding( padding: const EdgeInsets.only(top: 6), child: Text( 'Press search to look up short terms', style: theme.textTheme.labelMedium?.copyWith( color: colors.onSurfaceVariant, ), ), ), ], ); }, ), ), const SizedBox(width: 2), IconButton( tooltip: _hasListFilters ? 'Filters active' : 'Filter transactions', onPressed: () => _pickFilters( metaController.usedLabels(txs), ), style: IconButton.styleFrom( backgroundColor: _hasListFilters ? colors.primary.withValues(alpha: 0.22) : colors.surfaceContainer, foregroundColor: _hasListFilters ? colors.primary : colors.onSurfaceVariant, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), icon: Badge( isLabelVisible: _hasListFilters, smallSize: 6, child: const Icon(Icons.filter_list), ), ), IconButton( tooltip: _showCalendar ? 'Hide calendar' : 'Show calendar', onPressed: () { setState(() { if (_showCalendar) { _showCalendar = false; _selectedDay = null; } else { _showCalendar = true; // Calendar navigation requires month scope. _searchInMonthOnly = true; } }); }, style: IconButton.styleFrom( backgroundColor: _showCalendar && _searchInMonthOnly ? colors.primary.withValues(alpha: 0.22) : colors.surfaceContainer, foregroundColor: _showCalendar && _searchInMonthOnly ? colors.primary : colors.onSurfaceVariant, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), icon: Icon( _showCalendar && _searchInMonthOnly ? Icons.calendar_month : Icons.calendar_month_outlined, ), ), ValueListenableBuilder( valueListenable: _searchController, builder: (context, value, _) { if (value.text.trim().isEmpty && !_showCalendar) { return const SizedBox.shrink(); } return Tooltip( message: 'Limit results to calendar month', child: Row( mainAxisSize: MainAxisSize.min, children: [ Checkbox( value: _searchInMonthOnly, onChanged: (checked) { setState(() { _searchInMonthOnly = checked ?? false; if (!_searchInMonthOnly) { _showCalendar = false; _selectedDay = null; } }); }, ), Text('Month', style: theme.textTheme.labelMedium), ], ), ); }, ), ], ), ), ), if (_searchInMonthOnly && _showCalendar) ...[ SliverToBoxAdapter( child: 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) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.only(bottom: 8), child: Align( alignment: Alignment.centerLeft, child: InputChip( label: Text(formatDate(_selectedDay!)), onDeleted: () => setState(() => _selectedDay = null), ), ), ), ), ] else if (_searchInMonthOnly) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(0, 4, 0, 8), child: Row( children: [ IconButton( tooltip: 'Previous month', onPressed: () => _goMonth(-1), icon: const Icon(Icons.chevron_left), ), Expanded( child: Text( formatMonthYear(_month), textAlign: TextAlign.center, style: theme.textTheme.titleMedium, ), ), IconButton( tooltip: 'Next month', onPressed: _month.isBefore(nowMonth) ? () => _goMonth(1) : null, icon: const Icon(Icons.chevron_right), ), ], ), ), ), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(0, 4, 0, 12), child: Row( children: [ Expanded( child: _TotalsCard( label: 'Money out', value: formatMoney(moneyOut, currency), color: AppTheme.expense, icon: Icons.arrow_upward, ), ), const SizedBox(width: 10), Expanded( child: _TotalsCard( label: 'Money in', value: formatMoney(moneyIn, currency), color: AppTheme.accent, icon: Icons.arrow_downward, ), ), ], ), ), ), if (_isSearching || _hasListFilters) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.only(bottom: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( _yearFilter != null ? 'Filtered to $_yearFilter' : _searchInMonthOnly ? 'Showing ${formatMonthYear(_month)}' : 'Showing all transactions', style: theme.textTheme.labelMedium, ), if (_hasListFilters) ...[ const SizedBox(height: 8), Wrap( spacing: 6, runSpacing: 6, children: [ if (_flowFilter == TxFlowFilter.income) InputChip( visualDensity: VisualDensity.compact, avatar: const Icon(Icons.south_west, size: 14), label: const Text('Income'), onDeleted: () { setState(() => _flowFilter = TxFlowFilter.all); }, ), if (_flowFilter == TxFlowFilter.expense) InputChip( visualDensity: VisualDensity.compact, avatar: const Icon(Icons.north_east, size: 14), label: const Text('Expense'), onDeleted: () { setState(() => _flowFilter = TxFlowFilter.all); }, ), if (_amountRange.isActive) InputChip( visualDensity: VisualDensity.compact, label: Text( _amountRange.exact != null ? '= ${_amountRange.exact!.toStringAsFixed( _amountRange.exact! == _amountRange.exact! .roundToDouble() ? 0 : 2, )}' : [ if (_amountRange.min != null) '≥ ${_amountRange.min!.toStringAsFixed(0)}', if (_amountRange.max != null) '≤ ${_amountRange.max!.toStringAsFixed(0)}', ].join(' '), ), onDeleted: () { setState( () => _amountRange = const TxAmountRange(), ); }, ), for (final id in _labelFilters) if (id == kUncategorizedFilterId) InputChip( visualDensity: VisualDensity.compact, avatar: Icon( kUncategorizedFilterLabel.icon, size: 14, color: kUncategorizedFilterLabel.color, ), label: const Text('Uncategorized'), onDeleted: () { setState( () => _labelFilters .remove(kUncategorizedFilterId), ); }, ) else if (labelById[id] != null) InputChip( visualDensity: VisualDensity.compact, avatar: Icon( labelById[id]!.icon, size: 14, color: labelById[id]!.color, ), label: Text(labelById[id]!.name), onDeleted: () { setState(() => _labelFilters.remove(id)); }, ), ], ), ], if (otherLabels.isNotEmpty || showUncategorizedOther) ...[ const SizedBox(height: 8), Text( 'Other labels found', style: theme.textTheme.labelSmall?.copyWith( color: colors.onSurfaceVariant, ), ), const SizedBox(height: 6), Wrap( spacing: 6, runSpacing: 6, children: [ if (showUncategorizedOther) FilterChip( visualDensity: VisualDensity.compact, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, avatar: Icon( kUncategorizedFilterLabel.icon, size: 14, color: kUncategorizedFilterLabel.color, ), label: const Text('Uncategorized'), selected: false, onSelected: (_) { setState( () => _labelFilters.add(kUncategorizedFilterId), ); }, ), for (final label in otherLabels) FilterChip( visualDensity: VisualDensity.compact, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, avatar: Icon( label.icon, size: 14, color: label.color, ), label: Text(label.name), selected: false, onSelected: (_) { setState(() => _labelFilters.add(label.id)); }, ), ], ), ], ], ), ), ), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.only(bottom: 8), child: Row( children: [ Expanded( child: Text( filtered.length == 1 ? '1 transaction' : '${filtered.length} transactions', style: theme.textTheme.titleSmall?.copyWith( color: colors.onSurfaceVariant, ), ), ), if ((_hasListFilters || _activeQuery.trim().isNotEmpty) && filtered.isNotEmpty) IconButton( tooltip: 'Edit labels for these transactions', onPressed: () => _editLabelsForSubset(filtered), icon: const Icon(Icons.edit_outlined), ), ], ), ), ), if (filtered.isEmpty) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.symmetric(vertical: 24), child: Text( _isSearching || _hasListFilters ? 'No transactions match these filters.' : _selectedDay != null ? 'No transactions on this day.' : _searchInMonthOnly ? 'No transactions this month.' : 'No transactions yet.', textAlign: TextAlign.center, style: theme.textTheme.bodyMedium, ), ), ) else SliverList( delegate: SliverChildBuilderDelegate( (context, index) { final tx = filtered[index]; final meta = metaController.metaFor(tx); return Column( mainAxisSize: MainAxisSize.min, children: [ if (index > 0) Divider( height: 1, color: colors.outlineVariant.withValues(alpha: 0.5), ), _CompactTransactionTile( tx: tx, meta: meta, labels: metaController.leafLabelsFor(meta), ), ], ); }, childCount: filtered.length, addAutomaticKeepAlives: false, ), ), const SliverToBoxAdapter(child: SizedBox(height: 32)), ], ); } } 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 Material( color: colors.surfaceContainer, borderRadius: BorderRadius.circular(16), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), 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, fontWeight: FontWeight.w700, ), ), ], ), ), ); } } 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 ? AppTheme.accent : AppTheme.expense; 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.internalTransfer) 'Transfer', 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, ), ), ], ), ), ); } }