ing connected, makign some features

This commit is contained in:
davmar
2026-09-07 00:20:58 +02:00
parent 87afe271b7
commit 21df7b0085
15 changed files with 1197 additions and 322 deletions
+205 -24
View File
@@ -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'),
),
],
);
}
}