fix tx listing

This commit is contained in:
davmar
2026-09-09 11:40:05 +02:00
parent 2a741c7f2a
commit 903a2980de
8 changed files with 1638 additions and 281 deletions
File diff suppressed because it is too large Load Diff
+287
View File
@@ -0,0 +1,287 @@
/// 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/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<SimilarMatchCriteria?> showSimilarMatchDialog(
BuildContext context, {
required BankTransaction prototype,
required List<BankTransaction> all,
required SimilarMatchCriteria initial,
}) {
return showDialog<SimilarMatchCriteria>(
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<BankTransaction> 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<BankTransaction> 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<void>(
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<TxFlowFilter>(
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
? colors.primary
: colors.error,
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'),
),
],
);
}
}