/// 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/responsive_body.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; var _dirty = false; late bool _applyToSimilar; @override void initState() { super.initState(); final meta = context.read(); final notes = meta.metaFor(widget.tx).notes; _notesController = TextEditingController(text: notes); final all = context.read().transactions; final similarCount = meta.countSimilar(widget.tx, all); // Default on when there are peers — otherwise users never notice the toggle. _applyToSimilar = similarCount > 1; } @override void dispose() { _notesController.dispose(); super.dispose(); } List _similarScope(TxMetaController meta) { final all = context.read().transactions; if (!_applyToSimilar) return const []; return meta.similarTo(widget.tx, all); } Future _saveNotes() async { final meta = context.read(); final similar = _similarScope(meta); await meta.setNotes( widget.tx, _notesController.text, applyToSimilar: similar, ); if (!mounted) return; setState(() => _dirty = false); final count = similar.isEmpty ? 1 : similar.length; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( count == 1 ? 'Notes saved' : 'Notes saved on $count similar transactions', ), ), ); } Future _toggleLabel(String labelId) async { final meta = context.read(); final similar = _similarScope(meta); await meta.toggleLabel( widget.tx, labelId, applyToSimilar: similar, ); if (!mounted) return; final count = similar.isEmpty ? 1 : similar.length; if (count > 1) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Labels updated on $count similar transactions'), ), ); } } Future _clearLabels() async { final meta = context.read(); final similar = _similarScope(meta); await meta.clearLabels( widget.tx, applyToSimilar: similar, ); } Future _createLabel() async { final name = await showDialog( context: context, builder: (context) => const _NewLabelDialog(), ); if (name == null || name.isEmpty || !mounted) return; final meta = context.read(); final label = await meta.createLabel(name); if (!mounted) return; await _toggleLabel(label.id); } 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); final all = context.watch().transactions; final similarCount = metaController.countSimilar(tx, all); final theme = Theme.of(context); final colors = theme.colorScheme; final amountColor = tx.amount >= 0 ? colors.primary : colors.error; final selectedIds = meta.labelIds.toSet(); final merchantPreview = tx.merchantKey.isEmpty ? tx.description : (tx.merchantKey.length > 48 ? '${tx.merchantKey.substring(0, 47)}…' : tx.merchantKey); return Scaffold( appBar: AppBar( title: const Text('Transaction'), actions: [ if (_dirty) TextButton( onPressed: _saveNotes, child: const Text('Save'), ), ], ), body: ResponsiveBody( child: ListView( padding: const EdgeInsets.only(top: 8, bottom: 32), 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, ), if (similarCount > 1) ...[ const SizedBox(height: 16), SwitchListTile( contentPadding: EdgeInsets.zero, secondary: const Icon(Icons.copy_all_outlined), title: const Text('Apply to all similar'), subtitle: Text( '$similarCount matches · $merchantPreview', ), value: _applyToSimilar, onChanged: (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: (value) { context.read().setRecurring(tx, value); }, ), const SizedBox(height: 16), Row( children: [ Text('Labels', style: theme.textTheme.titleSmall), const Spacer(), if (meta.hasLabel) TextButton( onPressed: _clearLabels, child: const Text('Clear'), ), ], ), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, children: [ for (final label in metaController.allLabels) GestureDetector( onLongPress: label.custom ? () => _confirmRemoveLabel(label) : null, child: FilterChip( avatar: Icon(label.icon, size: 16, color: label.color), label: Text(label.name), selected: selectedIds.contains(label.id), onSelected: (_) => _toggleLabel(label.id), ), ), ActionChip( avatar: const Icon(Icons.add, size: 16), label: const Text('New label'), onPressed: _createLabel, ), ], ), const SizedBox(height: 24), Text('Notes', style: theme.textTheme.titleSmall), const SizedBox(height: 8), TextField( controller: _notesController, minLines: 3, maxLines: 6, textInputAction: TextInputAction.newline, decoration: const InputDecoration( hintText: 'Add a private note…', ), onChanged: (_) { if (!_dirty) setState(() => _dirty = true); }, ), const SizedBox(height: 12), FilledButton( onPressed: _dirty ? _saveNotes : null, child: Text( _applyToSimilar && similarCount > 1 ? 'Save notes for $similarCount' : 'Save notes', ), ), const SizedBox(height: 16), Text( 'Tap labels to add or remove several. Long-press a custom label ' 'to delete it. Similar matches strip dates and ids so supermarket ' 'purchases group together.', style: theme.textTheme.labelMedium, ), ], ), ), ); } } class _NewLabelDialog extends StatefulWidget { const _NewLabelDialog(); @override State<_NewLabelDialog> createState() => _NewLabelDialogState(); } class _NewLabelDialogState extends State<_NewLabelDialog> { late final TextEditingController _controller; @override void initState() { super.initState(); _controller = TextEditingController(); } @override void dispose() { _controller.dispose(); super.dispose(); } void _submit() { Navigator.pop(context, _controller.text.trim()); } @override Widget build(BuildContext context) { return AlertDialog( title: const Text('New label'), content: TextField( controller: _controller, autofocus: true, textCapitalization: TextCapitalization.sentences, decoration: const InputDecoration(hintText: 'e.g. Supermarket'), onSubmitted: (_) => _submit(), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancel'), ), FilledButton( onPressed: _submit, child: const Text('Create'), ), ], ); } }