Files
ingtrakapp/lib/screens/transaction_detail_screen.dart
T
2026-09-10 00:35:54 +02:00

654 lines
24 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/app_theme.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 final TextEditingController _labelSearchController;
late SimilarMatchCriteria _matchCriteria;
late Set<String> _draftLabelIds;
var _similarCount = 0;
var _similarReady = false;
var _dirty = false;
var _labelsDirty = false;
var _applyToSimilar = false;
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);
_labelSearchController = TextEditingController();
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();
// Defer full-list similar scan so the detail route paints immediately.
WidgetsBinding.instance.addPostFrameCallback((_) => _refreshSimilarCount());
}
void _refreshSimilarCount() {
if (!mounted) return;
final all = context.read<BankController>().transactions;
final count = context.read<TxMetaController>().countSimilar(
widget.tx,
all,
criteria: _matchCriteria,
);
if (!mounted) return;
setState(() {
_similarCount = count;
_similarReady = true;
// Default on when there are peers — otherwise users never notice the toggle.
if (count > 1) _applyToSimilar = true;
});
}
@override
void dispose() {
_notesController.dispose();
_labelSearchController.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;
_similarReady = false;
_similarCount = 0;
});
_refreshSimilarCount();
}
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);
final similarCount = _similarCount;
final theme = Theme.of(context);
final colors = theme.colorScheme;
final amountColor = tx.amount >= 0 ? AppTheme.accent : AppTheme.expense;
final needle = _labelSearchController.text.trim().toLowerCase();
final allLabelEntries = metaController.labelsInTreeOrder();
final labelEntries = needle.isEmpty
? allLabelEntries
: () {
final keep = <String>{};
for (final entry in allLabelEntries) {
if (!entry.label.name.toLowerCase().contains(needle)) continue;
keep.add(entry.label.id);
keep.addAll(metaController.ancestorIdsOf(entry.label.id));
keep.addAll(metaController.descendantIdsOf(entry.label.id));
}
return [
for (final entry in allLabelEntries)
if (keep.contains(entry.label.id)) entry,
];
}();
return Scaffold(
appBar: AppBar(
title: const Text('Transaction'),
actions: [
if (_dirty)
TextButton(
onPressed: _busy ? null : _saveNotes,
child: const Text('Save'),
),
],
),
body: Column(
children: [
if (_busy) const LinearProgressIndicator(minHeight: 2),
if (_busy && _busyMessage != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
_busyMessage!,
style: theme.textTheme.labelMedium?.copyWith(
color: colors.primary,
),
),
),
),
Expanded(
child: ResponsiveBody(
child: AbsorbPointer(
absorbing: _busy,
child: ListView(
padding: const EdgeInsets.only(top: 8, bottom: 16),
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 || !_similarReady || 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);
},
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
secondary: const Icon(Icons.swap_horiz),
title: const Text('Internal transfer'),
subtitle: const Text(
'Between your own accounts — excluded from income/expense',
),
value: meta.internalTransfer,
onChanged: _busy
? null
: (value) {
context
.read<TxMetaController>()
.setInternalTransfer(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),
TextField(
controller: _labelSearchController,
enabled: !_busy,
decoration: InputDecoration(
isDense: true,
hintText: 'Filter labels',
prefixIcon: const Icon(Icons.search, size: 20),
suffixIcon: needle.isEmpty
? null
: IconButton(
onPressed: () {
_labelSearchController.clear();
setState(() {});
},
icon: const Icon(Icons.clear, size: 18),
),
),
onChanged: (_) => setState(() {}),
),
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: SizedBox(
height: 280,
child: labelEntries.isEmpty
? Center(
child: Text(
needle.isEmpty
? 'No labels yet'
: 'No labels match',
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
)
: ListView.separated(
padding: EdgeInsets.zero,
itemCount: labelEntries.length,
separatorBuilder: (_, _) => Divider(
height: 1,
indent: 40,
color: colors.outlineVariant
.withValues(alpha: 0.45),
),
itemBuilder: (context, index) {
final entry = labelEntries[index];
return 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: 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,
),
],
),
),
),
),
SafeArea(
top: false,
child: Material(
elevation: 3,
color: colors.surface,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 10),
child: 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',
),
),
),
],
),
),
),
),
],
),
);
}
}