/// 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 createState() => _TransactionDetailScreenState(); } class _TransactionDetailScreenState extends State { late final TextEditingController _notesController; late SimilarMatchCriteria _matchCriteria; late Set _draftLabelIds; var _dirty = false; var _labelsDirty = false; late bool _applyToSimilar; var _busy = false; String? _busyMessage; @override void initState() { super.initState(); final meta = context.read(); 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().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 _similarScope(TxMetaController meta) { final all = context.read().transactions; if (!_applyToSimilar) return const []; return meta.similarTo(widget.tx, all, criteria: _matchCriteria); } Future _editMatchCriteria() async { final all = context.read().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().countSimilar( widget.tx, all, criteria: result, ); if (count > 1) _applyToSimilar = true; }); } Future _runBusy( Future 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 _saveNotes() async { await _runBusy( () async { final meta = context.read(); 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 _toggleDraftLabel(String labelId) async { final meta = context.read(); 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 _applyLabels({required bool toMatched}) async { await _runBusy( () async { final meta = context.read(); final similar = toMatched ? _similarScope(meta) : const []; 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 _clearDraftLabels() async { setState(() { _draftLabelIds.clear(); _labelsDirty = true; }); } Future _createLabel() async { final meta = context.read(); 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 _labelActions(TxLabel label) async { final action = await showModalBottomSheet( 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 _confirmRemoveLabel(TxLabel label) async { final ok = await showDialog( 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().removeLabel(label.id); } @override Widget build(BuildContext context) { final tx = widget.tx; final metaController = context.watch(); final meta = metaController.metaFor(tx); // Prefer read: bank data rarely changes while editing labels. final all = context.read().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().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, ), ], ), ), ], ), ), ); } }