fix tx listing
This commit is contained in:
@@ -11,6 +11,7 @@ import '../providers/tx_meta_provider.dart';
|
||||
import '../utils/helpers.dart';
|
||||
import '../widgets/label_parent_picker.dart';
|
||||
import '../widgets/responsive_body.dart';
|
||||
import '../widgets/similar_match_dialog.dart';
|
||||
|
||||
class TransactionDetailScreen extends StatefulWidget {
|
||||
const TransactionDetailScreen({super.key, required this.tx});
|
||||
@@ -24,8 +25,10 @@ class TransactionDetailScreen extends StatefulWidget {
|
||||
|
||||
class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
late final TextEditingController _notesController;
|
||||
late final TextEditingController _patternController;
|
||||
late SimilarMatchCriteria _matchCriteria;
|
||||
late Set<String> _draftLabelIds;
|
||||
var _dirty = false;
|
||||
var _labelsDirty = false;
|
||||
late bool _applyToSimilar;
|
||||
var _busy = false;
|
||||
String? _busyMessage;
|
||||
@@ -36,13 +39,21 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
final meta = context.read<TxMetaController>();
|
||||
final notes = meta.metaFor(widget.tx).notes;
|
||||
_notesController = TextEditingController(text: notes);
|
||||
final pattern = defaultSimilarPattern(widget.tx.description);
|
||||
_patternController = TextEditingController(text: pattern);
|
||||
final tx = widget.tx;
|
||||
_matchCriteria = SimilarMatchCriteria(
|
||||
pattern: defaultSimilarPattern(tx.description),
|
||||
flow: tx.amount < 0
|
||||
? TxFlowFilter.expense
|
||||
: tx.amount > 0
|
||||
? TxFlowFilter.income
|
||||
: TxFlowFilter.all,
|
||||
);
|
||||
_draftLabelIds = meta.metaFor(tx).labelIds.toSet();
|
||||
final all = context.read<BankController>().transactions;
|
||||
final similarCount = meta.countSimilar(
|
||||
widget.tx,
|
||||
tx,
|
||||
all,
|
||||
pattern: pattern,
|
||||
criteria: _matchCriteria,
|
||||
);
|
||||
// Default on when there are peers — otherwise users never notice the toggle.
|
||||
_applyToSimilar = similarCount > 1;
|
||||
@@ -51,16 +62,35 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
@override
|
||||
void dispose() {
|
||||
_notesController.dispose();
|
||||
_patternController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String get _pattern => _patternController.text.trim();
|
||||
String get _pattern => _matchCriteria.pattern;
|
||||
|
||||
List<BankTransaction> _similarScope(TxMetaController meta) {
|
||||
final all = context.read<BankController>().transactions;
|
||||
if (!_applyToSimilar) return const [];
|
||||
return meta.similarTo(widget.tx, all, pattern: _pattern);
|
||||
return meta.similarTo(widget.tx, all, criteria: _matchCriteria);
|
||||
}
|
||||
|
||||
Future<void> _editMatchCriteria() async {
|
||||
final all = context.read<BankController>().transactions;
|
||||
final result = await showSimilarMatchDialog(
|
||||
context,
|
||||
prototype: widget.tx,
|
||||
all: all,
|
||||
initial: _matchCriteria,
|
||||
);
|
||||
if (result == null || !mounted) return;
|
||||
setState(() {
|
||||
_matchCriteria = result;
|
||||
final count = context.read<TxMetaController>().countSimilar(
|
||||
widget.tx,
|
||||
all,
|
||||
criteria: result,
|
||||
);
|
||||
if (count > 1) _applyToSimilar = true;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _runBusy(
|
||||
@@ -116,47 +146,57 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _toggleLabel(String labelId) async {
|
||||
Future<void> _toggleDraftLabel(String labelId) async {
|
||||
final meta = context.read<TxMetaController>();
|
||||
setState(() {
|
||||
if (_draftLabelIds.contains(labelId)) {
|
||||
final remove = {labelId, ...meta.descendantIdsOf(labelId)};
|
||||
_draftLabelIds.removeWhere(remove.contains);
|
||||
} else {
|
||||
_draftLabelIds.add(labelId);
|
||||
_draftLabelIds.addAll(meta.ancestorIdsOf(labelId));
|
||||
}
|
||||
_labelsDirty = true;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _applyLabels({required bool toMatched}) async {
|
||||
await _runBusy(
|
||||
() async {
|
||||
final meta = context.read<TxMetaController>();
|
||||
final similar = _similarScope(meta);
|
||||
final count = similar.isEmpty ? 1 : similar.length;
|
||||
await meta.toggleLabel(
|
||||
widget.tx,
|
||||
labelId,
|
||||
applyToSimilar: similar,
|
||||
matchPattern: _pattern,
|
||||
final similar = toMatched ? _similarScope(meta) : const <BankTransaction>[];
|
||||
final targets = similar.isEmpty ? [widget.tx] : similar;
|
||||
await meta.setLabelsForMany(
|
||||
targets,
|
||||
_draftLabelIds.toList(growable: false),
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (count > 1) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Labels updated on $count similar transactions'),
|
||||
setState(() {
|
||||
_labelsDirty = false;
|
||||
_draftLabelIds = meta.metaFor(widget.tx).labelIds.toSet();
|
||||
});
|
||||
final count = targets.length;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
count == 1
|
||||
? 'Labels saved'
|
||||
: 'Labels updated on $count transactions',
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
},
|
||||
message: _applyToSimilar
|
||||
message: toMatched
|
||||
? 'Updating labels on matching transactions…'
|
||||
: null,
|
||||
: 'Saving labels…',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _clearLabels() async {
|
||||
await _runBusy(
|
||||
() {
|
||||
final meta = context.read<TxMetaController>();
|
||||
return meta.clearLabels(
|
||||
widget.tx,
|
||||
applyToSimilar: _similarScope(meta),
|
||||
matchPattern: _pattern,
|
||||
);
|
||||
},
|
||||
message: _applyToSimilar
|
||||
? 'Clearing labels on matching transactions…'
|
||||
: 'Clearing labels…',
|
||||
);
|
||||
Future<void> _clearDraftLabels() async {
|
||||
setState(() {
|
||||
_draftLabelIds.clear();
|
||||
_labelsDirty = true;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _createLabel() async {
|
||||
@@ -171,7 +211,7 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
parentId: result.parentId,
|
||||
);
|
||||
if (!mounted) return;
|
||||
await _toggleLabel(label.id);
|
||||
await _toggleDraftLabel(label.id);
|
||||
}
|
||||
|
||||
Future<void> _labelActions(TxLabel label) async {
|
||||
@@ -246,12 +286,11 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
final similarCount = metaController.countSimilar(
|
||||
tx,
|
||||
all,
|
||||
pattern: _pattern,
|
||||
criteria: _matchCriteria,
|
||||
);
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final amountColor = tx.amount >= 0 ? colors.primary : colors.error;
|
||||
final selectedIds = meta.labelIds.toSet();
|
||||
final labelEntries = metaController.labelsInTreeOrder();
|
||||
|
||||
return Scaffold(
|
||||
@@ -304,36 +343,28 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Match similar', style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _patternController,
|
||||
textInputAction: TextInputAction.done,
|
||||
enabled: !_busy,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'e.g. albert heijn or /albert\\s*heijn/',
|
||||
helperText:
|
||||
'Plain text = contains. Wrap with /…/ for regex.',
|
||||
helperMaxLines: 2,
|
||||
suffixIcon: IconButton(
|
||||
tooltip: 'Reset default',
|
||||
onPressed: _busy
|
||||
? null
|
||||
: () {
|
||||
setState(() {
|
||||
_patternController.text =
|
||||
defaultSimilarPattern(tx.description);
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.refresh),
|
||||
Row(
|
||||
children: [
|
||||
Text('Match similar', style: theme.textTheme.titleSmall),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
onPressed: _busy ? null : _editMatchCriteria,
|
||||
icon: const Icon(Icons.tune, size: 18),
|
||||
label: const Text('Edit'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'“${_matchCriteria.pattern}”',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
secondary: const Icon(Icons.copy_all_outlined),
|
||||
title: const Text('Apply to all similar'),
|
||||
title: const Text('Include matched transactions'),
|
||||
subtitle: Text(
|
||||
similarCount <= 1
|
||||
? 'No other matches for this pattern'
|
||||
@@ -344,6 +375,14 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
? null
|
||||
: (value) => setState(() => _applyToSimilar = value),
|
||||
),
|
||||
if (_applyToSimilar && similarCount > 1)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton(
|
||||
onPressed: _busy ? null : _editMatchCriteria,
|
||||
child: Text('Review $similarCount matches'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
@@ -366,9 +405,9 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
onPressed: _busy ? null : _createLabel,
|
||||
child: const Text('New'),
|
||||
),
|
||||
if (meta.hasLabel)
|
||||
if (_draftLabelIds.isNotEmpty)
|
||||
TextButton(
|
||||
onPressed: _busy ? null : _clearLabels,
|
||||
onPressed: _busy ? null : _clearDraftLabels,
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
@@ -394,7 +433,8 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
color: colors.outlineVariant.withValues(alpha: 0.45),
|
||||
),
|
||||
InkWell(
|
||||
onTap: _busy ? null : () => _toggleLabel(entry.label.id),
|
||||
onTap:
|
||||
_busy ? null : () => _toggleDraftLabel(entry.label.id),
|
||||
onLongPress:
|
||||
_busy ? null : () => _labelActions(entry.label),
|
||||
child: Padding(
|
||||
@@ -435,10 +475,11 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
materialTapTargetSize:
|
||||
MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
value: selectedIds.contains(entry.label.id),
|
||||
value: _draftLabelIds.contains(entry.label.id),
|
||||
onChanged: _busy
|
||||
? null
|
||||
: (_) => _toggleLabel(entry.label.id),
|
||||
: (_) =>
|
||||
_toggleDraftLabel(entry.label.id),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -450,6 +491,35 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: _busy || !_labelsDirty
|
||||
? null
|
||||
: () => _applyLabels(toMatched: false),
|
||||
child: const Text('Apply'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FilledButton.tonal(
|
||||
onPressed: _busy ||
|
||||
!_labelsDirty ||
|
||||
!_applyToSimilar ||
|
||||
similarCount <= 1
|
||||
? null
|
||||
: () => _applyLabels(toMatched: true),
|
||||
child: Text(
|
||||
similarCount > 1
|
||||
? 'Apply to matched ($similarCount)'
|
||||
: 'Apply to matched',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('Notes', style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 8),
|
||||
@@ -478,7 +548,8 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Sub-labels also select their parents. Unchecking a parent '
|
||||
'clears its sub-labels. Long-press a row to move or delete.',
|
||||
'clears its sub-labels. Long-press a row to move or delete. '
|
||||
'Use Apply to save label changes.',
|
||||
style: theme.textTheme.labelMedium,
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user