335 lines
10 KiB
Dart
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'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|