Files
ingtrakapp/lib/screens/transaction_detail_screen.dart
T
2026-09-08 23:52:54 +02:00

493 lines
16 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';
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 final TextEditingController _patternController;
var _dirty = 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 pattern = defaultSimilarPattern(widget.tx.description);
_patternController = TextEditingController(text: pattern);
final all = context.read<BankController>().transactions;
final similarCount = meta.countSimilar(
widget.tx,
all,
pattern: pattern,
);
// Default on when there are peers — otherwise users never notice the toggle.
_applyToSimilar = similarCount > 1;
}
@override
void dispose() {
_notesController.dispose();
_patternController.dispose();
super.dispose();
}
String get _pattern => _patternController.text.trim();
List<BankTransaction> _similarScope(TxMetaController meta) {
final all = context.read<BankController>().transactions;
if (!_applyToSimilar) return const [];
return meta.similarTo(widget.tx, all, pattern: _pattern);
}
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> _toggleLabel(String labelId) 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,
);
if (!mounted) return;
if (count > 1) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Labels updated on $count similar transactions'),
),
);
}
},
message: _applyToSimilar
? 'Updating labels on matching transactions…'
: null,
);
}
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> _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 _toggleLabel(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,
pattern: _pattern,
);
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(
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),
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),
),
),
onChanged: (_) => setState(() {}),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
secondary: const Icon(Icons.copy_all_outlined),
title: const Text('Apply to all similar'),
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),
),
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 (meta.hasLabel)
TextButton(
onPressed: _busy ? null : _clearLabels,
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 : () => _toggleLabel(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: selectedIds.contains(entry.label.id),
onChanged: _busy
? null
: (_) => _toggleLabel(entry.label.id),
),
),
],
),
),
),
],
],
),
),
),
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.',
style: theme.textTheme.labelMedium,
),
],
),
),
],
),
),
);
}
}