288 lines
9.1 KiB
Dart
288 lines
9.1 KiB
Dart
/// 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'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|