ing connected, makign some features
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
/// Transaction detail: category, recurring, and user notes.
|
||||
/// Transaction detail: label, recurring, notes, apply-to-similar.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -6,6 +6,7 @@ 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';
|
||||
@@ -23,12 +24,18 @@ class TransactionDetailScreen extends StatefulWidget {
|
||||
class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
late final TextEditingController _notesController;
|
||||
var _dirty = false;
|
||||
late bool _applyToSimilar;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final notes = context.read<TxMetaController>().metaFor(widget.tx).notes;
|
||||
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
|
||||
@@ -37,25 +44,116 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
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 {
|
||||
await context.read<TxMetaController>().setNotes(
|
||||
widget.tx,
|
||||
_notesController.text,
|
||||
);
|
||||
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(
|
||||
const SnackBar(content: Text('Notes saved')),
|
||||
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 meta = context.watch<TxMetaController>().metaFor(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(
|
||||
@@ -86,7 +184,20 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
].join(' · '),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
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),
|
||||
@@ -98,25 +209,39 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Category', style: theme.textTheme.titleSmall),
|
||||
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 category in TxCategory.values)
|
||||
ChoiceChip(
|
||||
avatar: Icon(
|
||||
category.icon,
|
||||
size: 16,
|
||||
color: category.color,
|
||||
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),
|
||||
),
|
||||
label: Text(category.label),
|
||||
selected: meta.category == category,
|
||||
onSelected: (_) {
|
||||
context.read<TxMetaController>().setCategory(tx, category);
|
||||
},
|
||||
),
|
||||
ActionChip(
|
||||
avatar: const Icon(Icons.add, size: 16),
|
||||
label: const Text('New label'),
|
||||
onPressed: _createLabel,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
@@ -137,12 +262,17 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
onPressed: _dirty ? _saveNotes : null,
|
||||
child: const Text('Save notes'),
|
||||
child: Text(
|
||||
_applyToSimilar && similarCount > 1
|
||||
? 'Save notes for $similarCount'
|
||||
: 'Save notes',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Matching descriptions reuse this category automatically. '
|
||||
'Exact same amount + description also copies notes.',
|
||||
'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,
|
||||
),
|
||||
],
|
||||
@@ -151,3 +281,54 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user