/// Dialog to refine “apply to similar” matching with a live match list. library; import 'package:flutter/material.dart'; import '../models/bank.dart'; import '../models/transaction_meta.dart'; import '../utils/app_theme.dart'; import '../utils/helpers.dart'; class _RegexExample { const _RegexExample({required this.label, required this.pattern}); final String label; final String pattern; } List<_RegexExample> _regexExamplesFor(BankTransaction tx) { final stem = defaultSimilarPattern(tx.description); final escaped = RegExp.escape(stem); return [ _RegexExample( label: 'Contains stem (flexible spaces)', pattern: '/${escaped.replaceAll(r'\ ', r'\s+')}/', ), _RegexExample( label: 'Starts with stem', pattern: '/^$escaped/', ), _RegexExample( label: 'Whole description is stem', pattern: '/^$escaped\$/', ), const _RegexExample( label: 'Digits / reference', pattern: r'/\d{4,}/', ), _RegexExample( label: 'Stem as whole word', pattern: '/\\b$escaped\\b/', ), ]; } Future showSimilarMatchDialog( BuildContext context, { required BankTransaction prototype, required List all, required SimilarMatchCriteria initial, }) { return showDialog( context: context, builder: (dialogContext) { return _SimilarMatchDialog( prototype: prototype, all: all, initial: initial, ); }, ); } class _SimilarMatchDialog extends StatefulWidget { const _SimilarMatchDialog({ required this.prototype, required this.all, required this.initial, }); final BankTransaction prototype; final List all; final SimilarMatchCriteria initial; @override State<_SimilarMatchDialog> createState() => _SimilarMatchDialogState(); } class _SimilarMatchDialogState extends State<_SimilarMatchDialog> { late final TextEditingController _containsController; late TxFlowFilter _flow; @override void initState() { super.initState(); _containsController = TextEditingController(text: widget.initial.pattern); _flow = widget.initial.flow; } @override void dispose() { _containsController.dispose(); super.dispose(); } SimilarMatchCriteria get _criteria => SimilarMatchCriteria( pattern: _containsController.text.trim(), flow: _flow, ); List get _matches { final criteria = _criteria; if (criteria.pattern.isEmpty) return const []; return [ for (final tx in widget.all) if (txMatchesSimilarCriteria(tx, criteria)) tx, ]; } void _showRegexExamples() { final examples = _regexExamplesFor(widget.prototype); showModalBottomSheet( context: context, showDragHandle: true, builder: (sheetContext) { return SafeArea( child: ListView( shrinkWrap: true, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), child: Text( 'Regex examples', style: Theme.of(sheetContext).textTheme.titleMedium, ), ), for (final example in examples) ListTile( title: Text(example.label), subtitle: Text( example.pattern, maxLines: 2, overflow: TextOverflow.ellipsis, ), onTap: () { Navigator.pop(sheetContext); setState(() => _containsController.text = example.pattern); }, ), ], ), ); }, ); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; final matches = _matches; final maxListHeight = MediaQuery.sizeOf(context).height * 0.35; return AlertDialog( title: const Text('Match similar'), content: SizedBox( width: double.maxFinite, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TextField( controller: _containsController, textInputAction: TextInputAction.done, decoration: InputDecoration( labelText: 'Name contains', hintText: 'plain text or /regex/', helperText: 'Plain text = contains. Wrap with /…/ for regex.', helperMaxLines: 2, suffixIcon: IconButton( tooltip: 'Regex examples', onPressed: _showRegexExamples, icon: const Icon(Icons.data_object_outlined), ), ), onChanged: (_) => setState(() {}), ), const SizedBox(height: 12), Text( 'Income / expense', style: theme.textTheme.titleSmall, ), const SizedBox(height: 8), SegmentedButton( segments: const [ ButtonSegment( value: TxFlowFilter.all, label: Text('All'), ), ButtonSegment( value: TxFlowFilter.income, label: Text('In'), icon: Icon(Icons.south_west, size: 16), ), ButtonSegment( value: TxFlowFilter.expense, label: Text('Out'), icon: Icon(Icons.north_east, size: 16), ), ], selected: {_flow}, onSelectionChanged: (selected) { setState(() => _flow = selected.first); }, ), const SizedBox(height: 12), Text( matches.isEmpty ? 'No matches' : '${matches.length} matching transaction' '${matches.length == 1 ? '' : 's'}', style: theme.textTheme.titleSmall?.copyWith( color: colors.onSurface, ), ), const SizedBox(height: 6), ConstrainedBox( constraints: BoxConstraints(maxHeight: maxListHeight), child: DecoratedBox( decoration: BoxDecoration( color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(12), border: Border.all( color: colors.outlineVariant.withValues(alpha: 0.8), ), ), child: matches.isEmpty ? Padding( padding: const EdgeInsets.all(16), child: Text( 'Adjust the pattern to see matches.', style: theme.textTheme.bodyMedium?.copyWith( color: colors.onSurfaceVariant, ), ), ) : ListView.separated( shrinkWrap: true, padding: const EdgeInsets.symmetric(vertical: 4), itemCount: matches.length, separatorBuilder: (_, _) => Divider( height: 1, indent: 12, color: colors.outlineVariant.withValues(alpha: 0.45), ), itemBuilder: (context, index) { final tx = matches[index]; final isPrototype = tx.id == widget.prototype.id; return ListTile( dense: true, title: Text( tx.description, maxLines: 1, overflow: TextOverflow.ellipsis, ), subtitle: Text(formatIsoDate(tx.date)), trailing: Text( formatMoney(tx.amount, tx.currency), style: theme.textTheme.bodyMedium?.copyWith( color: tx.amount >= 0 ? AppTheme.accent : AppTheme.expense, fontWeight: isPrototype ? FontWeight.w700 : null, ), ), ); }, ), ), ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancel'), ), FilledButton( onPressed: _criteria.pattern.isEmpty ? null : () => Navigator.pop(context, _criteria), child: const Text('Apply'), ), ], ); } }