From 69a6a5955af41b3635746602dcf5a04eb878a773 Mon Sep 17 00:00:00 2001 From: PianoNic <79938743+Pianonic@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:07:48 +0200 Subject: [PATCH] Let password managers see the login fields Give each catalog field the autofill hints, keyboard type and next/done action its descriptor implies, group them so the platform treats them as one credential, and commit the autofill context after a successful connect so a manager is offered the chance to save. --- lib/ui/account/unified_connect_screen.dart | 4 ++ lib/ui/private/private_connect_screen.dart | 3 + lib/ui/widgets/dynamic_login_form.dart | 73 +++++++++++++++------- 3 files changed, 57 insertions(+), 23 deletions(-) diff --git a/lib/ui/account/unified_connect_screen.dart b/lib/ui/account/unified_connect_screen.dart index 4526ff6..5dd6d6d 100644 --- a/lib/ui/account/unified_connect_screen.dart +++ b/lib/ui/account/unified_connect_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:forui/forui.dart'; import '../../domain/school_system.dart'; @@ -61,6 +62,9 @@ class _UnifiedConnectScreenState extends State { options: ApiClient.handled(), ); final accountId = res.data?['accountId']?.toString(); + // Only once the credentials are known good, so a manager is never asked + // to save something that did not work. + TextInput.finishAutofillContext(); if (mounted) Navigator.of(context).pop(accountId); } catch (e) { setState(() => _error = ApiError.describe(e)); diff --git a/lib/ui/private/private_connect_screen.dart b/lib/ui/private/private_connect_screen.dart index a6d5026..351c43c 100644 --- a/lib/ui/private/private_connect_screen.dart +++ b/lib/ui/private/private_connect_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:forui/forui.dart'; import '../../domain/school_system.dart'; @@ -102,6 +103,7 @@ class _PrivateConnectScreenState extends State { password: password, totpSecret: totpSecret, )); + TextInput.finishAutofillContext(); if (mounted) Navigator.of(context).pop(true); } @@ -118,6 +120,7 @@ class _PrivateConnectScreenState extends State { ); await ScrapeProxyClient.instance.data(account); await PrivateAccountStore.instance.save(account); + TextInput.finishAutofillContext(); if (mounted) Navigator.of(context).pop(true); } diff --git a/lib/ui/widgets/dynamic_login_form.dart b/lib/ui/widgets/dynamic_login_form.dart index e3fd4f4..4d82760 100644 --- a/lib/ui/widgets/dynamic_login_form.dart +++ b/lib/ui/widgets/dynamic_login_form.dart @@ -45,33 +45,60 @@ class DynamicLoginForm extends StatelessWidget { static bool _isTotp(SchoolSystemLoginField f) => f.type == 'totp' || f.key.toLowerCase() == 'totp'; + /// What the platform needs to tell these boxes apart. The form is built from + /// the catalog, so this reads the descriptor rather than naming any school + /// system; without it a password manager sees a row of anonymous text boxes + /// and offers nothing. + static List _autofillHints(SchoolSystemLoginField f) { + final key = f.key.toLowerCase(); + if (f.type == 'password' || key.contains('password')) return const [AutofillHints.password]; + if (f.type == 'url' || key.contains('url')) return const [AutofillHints.url]; + if (f.type == 'email' || key.contains('email') || key.contains('mail')) { + return const [AutofillHints.username, AutofillHints.email]; + } + if (key.contains('user') || key.contains('login')) return const [AutofillHints.username]; + return const []; + } + + static TextInputType _keyboard(SchoolSystemLoginField f) { + final key = f.key.toLowerCase(); + if (f.type == 'url' || key.contains('url')) return TextInputType.url; + if (f.type == 'email' || key.contains('email') || key.contains('mail')) return TextInputType.emailAddress; + return TextInputType.text; + } + @override Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - spacing: 16, - children: [ - for (final field in controller.fields) - if (_isTotp(field)) - TotpFieldPicker( - controller: controller.controllerFor(field.key), - field: field, - ) - else - FTextField( - control: FTextFieldControl.managed( + final fields = controller.fields; + // One group, so the platform treats the boxes as a single credential rather + // than unrelated inputs. + return AutofillGroup( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + spacing: 16, + children: [ + for (final (index, field) in fields.indexed) + if (_isTotp(field)) + TotpFieldPicker( controller: controller.controllerFor(field.key), + field: field, + ) + else + FTextField( + control: FTextFieldControl.managed( + controller: controller.controllerFor(field.key), + ), + label: Text(field.label), + hint: field.placeholder, + obscureText: field.type == 'password', + keyboardType: _keyboard(field), + textInputAction: index == fields.length - 1 ? TextInputAction.done : TextInputAction.next, + autofillHints: _autofillHints(field), + autocorrect: false, ), - label: Text(field.label), - hint: field.placeholder, - obscureText: field.type == 'password', - keyboardType: field.type == 'url' - ? TextInputType.url - : TextInputType.text, - autocorrect: false, - ), - ], + ], + ), ); } }