2 Commits
Author SHA1 Message Date
davmar a93dbbe30d categorize multiple tags 2026-10-15 18:33:04 +02:00
davmar 22a535a6fd pass suggestions 2026-09-17 15:45:11 +02:00
4 changed files with 107 additions and 61 deletions
+23 -2
View File
@@ -521,10 +521,15 @@ class TxMetaController extends ChangeNotifier {
} }
/// Replace labels on every transaction in [txs] (per-id overrides). /// Replace labels on every transaction in [txs] (per-id overrides).
///
/// When [matchPattern] is set (apply-to-matched), also refreshes the
/// merchant rule so future transactions matching the pattern inherit the
/// full label set (parent + sub-labels).
Future<void> setLabelsForMany( Future<void> setLabelsForMany(
List<BankTransaction> txs, List<BankTransaction> txs,
List<String> labelIds, List<String> labelIds, {
) async { String? matchPattern,
}) async {
if (txs.isEmpty) return; if (txs.isEmpty) return;
final expanded = expandWithAncestors([ final expanded = expandWithAncestors([
@@ -545,6 +550,22 @@ class TxMetaController extends ChangeNotifier {
: current.copyWith(labelIds: unique); : current.copyWith(labelIds: unique);
} }
_byId = nextById; _byId = nextById;
final pattern = (matchPattern ?? '').trim().toLowerCase();
if (pattern.isNotEmpty) {
final nextRules = Map<String, List<String>>.of(_labelsByMerchant);
if (unique.isEmpty) {
nextRules.remove(pattern);
for (final tx in txs) {
nextRules.remove(tx.merchantKey);
nextRules.remove(tx.descriptionKey);
}
} else {
nextRules[pattern] = unique;
}
_labelsByMerchant = nextRules;
}
_schedulePersist(); _schedulePersist();
} }
+71 -59
View File
@@ -2,6 +2,7 @@
library; library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pocketbase/pocketbase.dart'; import 'package:pocketbase/pocketbase.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -46,6 +47,8 @@ class _LoginScreenState extends State<LoginScreen> {
email: _emailController.text, email: _emailController.text,
password: _passwordController.text, password: _passwordController.text,
); );
// Lets iOS/Android password managers offer to save / confirm the login.
TextInput.finishAutofillContext();
if (!mounted) return; if (!mounted) return;
if (!widget.asRoot) { if (!widget.asRoot) {
Navigator.of(context).pop(true); Navigator.of(context).pop(true);
@@ -96,68 +99,77 @@ class _LoginScreenState extends State<LoginScreen> {
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Form( AutofillGroup(
key: _formKey, child: Form(
child: Column( key: _formKey,
children: [ child: Column(
AppTextField( children: [
controller: _emailController, AppTextField(
label: 'Email', controller: _emailController,
keyboardType: TextInputType.emailAddress, label: 'Email',
textInputAction: TextInputAction.next, keyboardType: TextInputType.emailAddress,
autofocus: true, textInputAction: TextInputAction.next,
validator: (value) { autofocus: true,
final text = value?.trim() ?? ''; autofillHints: const [
if (text.isEmpty) return 'Enter your email'; AutofillHints.email,
if (!text.contains('@')) return 'Enter a valid email'; AutofillHints.username,
return null; ],
}, validator: (value) {
), final text = value?.trim() ?? '';
const SizedBox(height: 16), if (text.isEmpty) return 'Enter your email';
TextFormField( if (!text.contains('@')) return 'Enter a valid email';
controller: _passwordController, return null;
obscureText: _obscurePassword, },
textInputAction: TextInputAction.done, ),
onFieldSubmitted: (_) => _submit(), const SizedBox(height: 16),
validator: (value) { TextFormField(
if (value == null || value.isEmpty) { controller: _passwordController,
return 'Enter your password'; obscureText: _obscurePassword,
} textInputAction: TextInputAction.done,
return null; autofillHints: const [AutofillHints.password],
}, enableSuggestions: false,
decoration: InputDecoration( autocorrect: false,
labelText: 'Password', onFieldSubmitted: (_) => _submit(),
suffixIcon: IconButton( validator: (value) {
tooltip: _obscurePassword if (value == null || value.isEmpty) {
? 'Show password' return 'Enter your password';
: 'Hide password', }
onPressed: () => setState( return null;
() => _obscurePassword = !_obscurePassword, },
), decoration: InputDecoration(
icon: Icon( labelText: 'Password',
_obscurePassword suffixIcon: IconButton(
? Icons.visibility_outlined tooltip: _obscurePassword
: Icons.visibility_off_outlined, ? 'Show password'
: 'Hide password',
onPressed: () => setState(
() => _obscurePassword = !_obscurePassword,
),
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
), ),
), ),
), ),
), const SizedBox(height: 24),
const SizedBox(height: 24), FilledButton.icon(
FilledButton.icon( onPressed: _busy ? null : _submit,
onPressed: _busy ? null : _submit, icon: _busy
icon: _busy ? SizedBox(
? SizedBox( width: 18,
width: 18, height: 18,
height: 18, child: CircularProgressIndicator(
child: CircularProgressIndicator( strokeWidth: 2,
strokeWidth: 2, color: theme.colorScheme.onPrimary,
color: theme.colorScheme.onPrimary, ),
), )
) : const Icon(Icons.login),
: const Icon(Icons.login), label: Text(_busy ? 'Signing in…' : 'Sign in'),
label: Text(_busy ? 'Signing in…' : 'Sign in'), ),
), ],
], ),
), ),
), ),
], ],
@@ -188,6 +188,7 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
await meta.setLabelsForMany( await meta.setLabelsForMany(
targets, targets,
_draftLabelIds.toList(growable: false), _draftLabelIds.toList(growable: false),
matchPattern: toMatched ? _pattern : null,
); );
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
+12
View File
@@ -14,6 +14,10 @@ class AppTextField extends StatelessWidget {
this.keyboardType, this.keyboardType,
this.validator, this.validator,
this.autofocus = false, this.autofocus = false,
this.autofillHints,
this.obscureText = false,
this.enableSuggestions,
this.autocorrect,
}); });
final TextEditingController controller; final TextEditingController controller;
@@ -24,6 +28,10 @@ class AppTextField extends StatelessWidget {
final TextInputType? keyboardType; final TextInputType? keyboardType;
final String? Function(String?)? validator; final String? Function(String?)? validator;
final bool autofocus; final bool autofocus;
final Iterable<String>? autofillHints;
final bool obscureText;
final bool? enableSuggestions;
final bool? autocorrect;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -31,8 +39,12 @@ class AppTextField extends StatelessWidget {
controller: controller, controller: controller,
maxLines: maxLines, maxLines: maxLines,
autofocus: autofocus, autofocus: autofocus,
obscureText: obscureText,
textInputAction: textInputAction, textInputAction: textInputAction,
keyboardType: keyboardType, keyboardType: keyboardType,
autofillHints: autofillHints,
enableSuggestions: enableSuggestions ?? !obscureText,
autocorrect: autocorrect ?? !obscureText,
validator: validator, validator: validator,
decoration: InputDecoration( decoration: InputDecoration(
labelText: label, labelText: label,