564 lines
19 KiB
Dart
564 lines
19 KiB
Dart
/// Transaction detail: label, recurring, notes, apply-to-similar.
|
|
library;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../models/bank.dart';
|
|
import '../models/transaction_meta.dart';
|
|
import '../providers/bank_provider.dart';
|
|
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});
|
|
|
|
final BankTransaction tx;
|
|
|
|
@override
|
|
State<TransactionDetailScreen> createState() =>
|
|
_TransactionDetailScreenState();
|
|
}
|
|
|
|
class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
|
late final TextEditingController _notesController;
|
|
late SimilarMatchCriteria _matchCriteria;
|
|
late Set<String> _draftLabelIds;
|
|
var _dirty = false;
|
|
var _labelsDirty = false;
|
|
late bool _applyToSimilar;
|
|
var _busy = false;
|
|
String? _busyMessage;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final meta = context.read<TxMetaController>();
|
|
final notes = meta.metaFor(widget.tx).notes;
|
|
_notesController = TextEditingController(text: notes);
|
|
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(
|
|
tx,
|
|
all,
|
|
criteria: _matchCriteria,
|
|
);
|
|
// Default on when there are peers — otherwise users never notice the toggle.
|
|
_applyToSimilar = similarCount > 1;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_notesController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
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, 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(
|
|
Future<void> Function() action, {
|
|
String? message,
|
|
}) async {
|
|
if (_busy) return;
|
|
setState(() {
|
|
_busy = true;
|
|
_busyMessage = message;
|
|
});
|
|
// Let the progress bar paint before heavy similar-matching / rebuilds.
|
|
await WidgetsBinding.instance.endOfFrame;
|
|
try {
|
|
await action();
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_busy = false;
|
|
_busyMessage = null;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _saveNotes() async {
|
|
await _runBusy(
|
|
() async {
|
|
final meta = context.read<TxMetaController>();
|
|
final similar = _similarScope(meta);
|
|
final count = similar.isEmpty ? 1 : similar.length;
|
|
await meta.setNotes(
|
|
widget.tx,
|
|
_notesController.text,
|
|
applyToSimilar: similar,
|
|
matchPattern: _pattern,
|
|
);
|
|
if (!mounted) return;
|
|
setState(() => _dirty = false);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
count == 1
|
|
? 'Notes saved'
|
|
: 'Notes saved on $count similar transactions',
|
|
),
|
|
),
|
|
);
|
|
},
|
|
message: _applyToSimilar
|
|
? 'Saving notes on matching transactions…'
|
|
: 'Saving notes…',
|
|
);
|
|
}
|
|
|
|
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 = toMatched ? _similarScope(meta) : const <BankTransaction>[];
|
|
final targets = similar.isEmpty ? [widget.tx] : similar;
|
|
await meta.setLabelsForMany(
|
|
targets,
|
|
_draftLabelIds.toList(growable: false),
|
|
);
|
|
if (!mounted) return;
|
|
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: toMatched
|
|
? 'Updating labels on matching transactions…'
|
|
: 'Saving labels…',
|
|
);
|
|
}
|
|
|
|
Future<void> _clearDraftLabels() async {
|
|
setState(() {
|
|
_draftLabelIds.clear();
|
|
_labelsDirty = true;
|
|
});
|
|
}
|
|
|
|
Future<void> _createLabel() async {
|
|
final meta = context.read<TxMetaController>();
|
|
final result = await showCreateLabelDialog(
|
|
context,
|
|
parents: meta.allLabels,
|
|
);
|
|
if (result == null || !mounted) return;
|
|
final label = await meta.createLabel(
|
|
result.name,
|
|
parentId: result.parentId,
|
|
);
|
|
if (!mounted) return;
|
|
await _toggleDraftLabel(label.id);
|
|
}
|
|
|
|
Future<void> _labelActions(TxLabel label) async {
|
|
final action = await showModalBottomSheet<String>(
|
|
context: context,
|
|
showDragHandle: true,
|
|
builder: (sheetContext) {
|
|
return SafeArea(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ListTile(
|
|
leading: const Icon(Icons.account_tree_outlined),
|
|
title: const Text('Move under…'),
|
|
onTap: () => Navigator.pop(sheetContext, 'move'),
|
|
),
|
|
if (label.custom)
|
|
ListTile(
|
|
leading: Icon(
|
|
Icons.delete_outline,
|
|
color: Theme.of(sheetContext).colorScheme.error,
|
|
),
|
|
title: const Text('Remove label'),
|
|
onTap: () => Navigator.pop(sheetContext, 'remove'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
if (!mounted || action == null) return;
|
|
if (action == 'move') {
|
|
await showMoveLabelDialog(context, label: label);
|
|
} else if (action == 'remove') {
|
|
await _confirmRemoveLabel(label);
|
|
}
|
|
}
|
|
|
|
Future<void> _confirmRemoveLabel(TxLabel label) async {
|
|
final ok = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) {
|
|
return AlertDialog(
|
|
title: Text('Remove “${label.name}”?'),
|
|
content: const Text(
|
|
'This deletes the label. Transactions keep any other labels.',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(context, true),
|
|
child: const Text('Remove'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
if (ok != true || !mounted) return;
|
|
await context.read<TxMetaController>().removeLabel(label.id);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final tx = widget.tx;
|
|
final metaController = context.watch<TxMetaController>();
|
|
final meta = metaController.metaFor(tx);
|
|
// Prefer read: bank data rarely changes while editing labels.
|
|
final all = context.read<BankController>().transactions;
|
|
final similarCount = metaController.countSimilar(
|
|
tx,
|
|
all,
|
|
criteria: _matchCriteria,
|
|
);
|
|
final theme = Theme.of(context);
|
|
final colors = theme.colorScheme;
|
|
final amountColor = tx.amount >= 0 ? colors.primary : colors.error;
|
|
final labelEntries = metaController.labelsInTreeOrder();
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Transaction'),
|
|
actions: [
|
|
if (_dirty)
|
|
TextButton(
|
|
onPressed: _busy ? null : _saveNotes,
|
|
child: const Text('Save'),
|
|
),
|
|
],
|
|
),
|
|
body: ResponsiveBody(
|
|
child: ListView(
|
|
padding: const EdgeInsets.only(top: 8, bottom: 32),
|
|
children: [
|
|
if (_busy) ...[
|
|
const LinearProgressIndicator(minHeight: 2),
|
|
if (_busyMessage != null)
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(0, 8, 0, 4),
|
|
child: Text(
|
|
_busyMessage!,
|
|
style: theme.textTheme.labelMedium?.copyWith(
|
|
color: colors.primary,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
AbsorbPointer(
|
|
absorbing: _busy,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Text(
|
|
formatMoney(tx.amount, tx.currency),
|
|
style: theme.textTheme.headlineMedium?.copyWith(
|
|
color: amountColor,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(tx.description, style: theme.textTheme.titleMedium),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
[
|
|
formatIsoDate(tx.date),
|
|
if (tx.pending) 'Pending',
|
|
].join(' · '),
|
|
style: theme.textTheme.bodyMedium,
|
|
),
|
|
const SizedBox(height: 16),
|
|
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,
|
|
),
|
|
),
|
|
SwitchListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
secondary: const Icon(Icons.copy_all_outlined),
|
|
title: const Text('Include matched transactions'),
|
|
subtitle: Text(
|
|
similarCount <= 1
|
|
? 'No other matches for this pattern'
|
|
: '$similarCount matching transactions',
|
|
),
|
|
value: _applyToSimilar && similarCount > 1,
|
|
onChanged: _busy || similarCount <= 1
|
|
? 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,
|
|
secondary: const Icon(Icons.autorenew),
|
|
title: const Text('Recurring'),
|
|
subtitle: const Text('Salary, mortgage, subscriptions…'),
|
|
value: meta.recurring,
|
|
onChanged: _busy
|
|
? null
|
|
: (value) {
|
|
context.read<TxMetaController>().setRecurring(tx, value);
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
children: [
|
|
Text('Labels', style: theme.textTheme.titleSmall),
|
|
const Spacer(),
|
|
TextButton(
|
|
onPressed: _busy ? null : _createLabel,
|
|
child: const Text('New'),
|
|
),
|
|
if (_draftLabelIds.isNotEmpty)
|
|
TextButton(
|
|
onPressed: _busy ? null : _clearDraftLabels,
|
|
child: const Text('Clear'),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: colors.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: colors.outlineVariant.withValues(alpha: 0.8),
|
|
),
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Column(
|
|
children: [
|
|
for (final entry in labelEntries) ...[
|
|
if (entry != labelEntries.first)
|
|
Divider(
|
|
height: 1,
|
|
indent: 40,
|
|
color: colors.outlineVariant.withValues(alpha: 0.45),
|
|
),
|
|
InkWell(
|
|
onTap:
|
|
_busy ? null : () => _toggleDraftLabel(entry.label.id),
|
|
onLongPress:
|
|
_busy ? null : () => _labelActions(entry.label),
|
|
child: Padding(
|
|
padding: EdgeInsets.fromLTRB(
|
|
8 + entry.depth * 16.0,
|
|
2,
|
|
4,
|
|
2,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
if (entry.depth > 0) ...[
|
|
Icon(
|
|
Icons.subdirectory_arrow_right,
|
|
size: 14,
|
|
color: colors.onSurfaceVariant,
|
|
),
|
|
const SizedBox(width: 4),
|
|
],
|
|
Icon(
|
|
entry.label.icon,
|
|
size: 18,
|
|
color: entry.label.color,
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text(
|
|
entry.label.name,
|
|
style: theme.textTheme.bodyMedium,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
SizedBox(
|
|
height: 32,
|
|
width: 32,
|
|
child: Checkbox(
|
|
materialTapTargetSize:
|
|
MaterialTapTargetSize.shrinkWrap,
|
|
visualDensity: VisualDensity.compact,
|
|
value: _draftLabelIds.contains(entry.label.id),
|
|
onChanged: _busy
|
|
? null
|
|
: (_) =>
|
|
_toggleDraftLabel(entry.label.id),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
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),
|
|
TextField(
|
|
controller: _notesController,
|
|
minLines: 3,
|
|
maxLines: 6,
|
|
enabled: !_busy,
|
|
textInputAction: TextInputAction.newline,
|
|
decoration: const InputDecoration(
|
|
hintText: 'Add a private note…',
|
|
),
|
|
onChanged: (_) {
|
|
if (!_dirty) setState(() => _dirty = true);
|
|
},
|
|
),
|
|
const SizedBox(height: 12),
|
|
FilledButton(
|
|
onPressed: _dirty && !_busy ? _saveNotes : null,
|
|
child: Text(
|
|
_applyToSimilar && similarCount > 1
|
|
? 'Save notes for $similarCount'
|
|
: 'Save notes',
|
|
),
|
|
),
|
|
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. '
|
|
'Use Apply to save label changes.',
|
|
style: theme.textTheme.labelMedium,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|