Files
ingtrakapp/lib/screens/transaction_detail_screen.dart
T

335 lines
10 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/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;
var _dirty = false;
late bool _applyToSimilar;
@override
void initState() {
super.initState();
final meta = context.read<TxMetaController>();
final notes = meta.metaFor(widget.tx).notes;
_notesController = TextEditingController(text: notes);
final all = context.read<BankController>().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<BankTransaction> _similarScope(TxMetaController meta) {
final all = context.read<BankController>().transactions;
if (!_applyToSimilar) return const [];
return meta.similarTo(widget.tx, all);
}
Future<void> _saveNotes() async {
final meta = context.read<TxMetaController>();
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<void> _toggleLabel(String labelId) async {
final meta = context.read<TxMetaController>();
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<void> _clearLabels() async {
final meta = context.read<TxMetaController>();
final similar = _similarScope(meta);
await meta.clearLabels(
widget.tx,
applyToSimilar: similar,
);
}
Future<void> _createLabel() async {
final name = await showDialog<String>(
context: context,
builder: (context) => const _NewLabelDialog(),
);
if (name == null || name.isEmpty || !mounted) return;
final meta = context.read<TxMetaController>();
final label = await meta.createLabel(name);
if (!mounted) return;
await _toggleLabel(label.id);
}
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 all = context.watch<BankController>().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<TxMetaController>().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'),
),
],
);
}
}