fix tx listing
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user