/// 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/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/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, }); /// 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; @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; /// 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); _searchInMonthOnly = widget.initialSearchInMonthOnly ?? false; _yearFilter = widget.initialYear; } @override void dispose() { _searchDebounceTimer?.cancel(); _searchController.dispose(); super.dispose(); } bool get _isSearching => _activeQuery.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); 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 _pickLabels(List available) async { if (available.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('No labels in use yet')), ); return; } final result = await showLabelFilterDialog( context, available: available, initiallySelected: Set.of(_labelFilters), title: 'Filter transactions', subtitle: 'Show transactions with any of these labels.', includeUncategorized: true, ); if (result == null || !mounted) return; setState(() { _labelFilters ..clear() ..addAll(result); }); } @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: 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); final searchPool = !_isSearching ? calendarScoped : _searchInMonthOnly ? monthScoped : _yearScoped(txs); // Text hits first (no label filter) so refine chips stay available while selecting. final textMatched = _activeQuery.trim().isEmpty ? const [] : metaController.search(searchPool, _activeQuery); final filtered = metaController.search( searchPool, _activeQuery, labelIds: _labelFilters, ); final refineLabels = _activeQuery.trim().isEmpty ? const [] : metaController.usedLabels(textMatched); final showUncategorizedRefine = _activeQuery.trim().isNotEmpty && textMatched.any((tx) => !metaController.metaFor(tx).hasLabel); final refineChoices = [ if (showUncategorizedRefine && !_labelFilters.contains(kUncategorizedFilterId)) kUncategorizedFilterLabel, for (final label in refineLabels) if (!_labelFilters.contains(label.id)) label, ]; 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 ?? (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: _labelFilters.isEmpty ? 'Filter by label' : 'Filter by label (${_labelFilters.length})', onPressed: () => _pickLabels( metaController.usedLabels(txs), ), style: IconButton.styleFrom( backgroundColor: _labelFilters.isEmpty ? null : colors.secondaryContainer, ), icon: Badge( isLabelVisible: _labelFilters.isNotEmpty, smallSize: 6, child: const Icon(Icons.filter_list), ), ), IconButton( tooltip: _showCalendar ? 'Hide calendar' : 'Show calendar', onPressed: () { setState(() { _showCalendar = !_showCalendar; if (!_showCalendar) _selectedDay = null; }); }, style: IconButton.styleFrom( backgroundColor: _showCalendar ? colors.secondaryContainer : null, ), icon: Icon( _showCalendar ? Icons.calendar_month : Icons.calendar_month_outlined, ), ), 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 (_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 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: 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 || _labelFilters.isNotEmpty) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.only(bottom: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (_isSearching) Text( _yearFilter != null ? 'Filtered to $_yearFilter' : _searchInMonthOnly ? 'Searching in ${formatMonthYear(_month)}' : 'Searching all transactions', style: theme.textTheme.labelMedium, ), if (_labelFilters.isNotEmpty) ...[ if (_isSearching) const SizedBox(height: 8), Wrap( spacing: 6, runSpacing: 6, children: [ 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 (refineChoices.isNotEmpty) ...[ const SizedBox(height: 8), Text( 'Refine by label', style: theme.textTheme.labelSmall?.copyWith( color: colors.onSurfaceVariant, ), ), const SizedBox(height: 6), Wrap( spacing: 6, runSpacing: 6, children: [ for (final label in refineChoices) 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: Text( filtered.length == 1 ? '1 transaction' : '${filtered.length} transactions', style: theme.textTheme.titleSmall?.copyWith( color: colors.onSurfaceVariant, ), ), ), ), if (filtered.isEmpty) SliverToBoxAdapter( child: 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 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 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, ), ), ], ), ), ); } }