Web mobile : le clavier se refermait aussitot ouvert sur le login

Le meta viewport manquant n'expliquait pas tout : le clavier s'ouvrait puis se
refermait apres une demi-seconde. C'est le focus qui etait perdu, pas la saisie
qui n'arrivait pas.

Sur Flutter web, un AutofillGroup se materialise par un <form> DOM qui porte les
inputs caches par lesquels passe la saisie. L'ouverture du clavier redimensionne
la fenetre, l'ecran se reconstruit, le groupe d'autofill est renegocie et le
<form> recree : l'input focus disparait avec lui. Sur desktop rien ne
redimensionne au clic, d'ou un bug invisible hors mobile.

L'AutofillGroup est retire — les autofillHints portes par chaque champ suffisent
au navigateur pour proposer le remplissage, le groupe ne servait qu'a
finishAutofillContext. Trois protections completent la correction :

- les deux champs passent par un TextEditingController detenu par le State, donc
  la saisie survit a une reconstruction, la ou initialValue repartait de zero ;
- ils portent une ValueKey stable, pour etre reapparies plutot que recrees si la
  structure de leurs freres bouge ;
- le Scaffold ne se redimensionne plus a l'ouverture du clavier ; le contenu est
  deja dans un SingleChildScrollView, rien ne reste masque.

Au passage, le pre-remplissage de developpement (localhost) etait ecrit dans
build() a chaque reconstruction : il passe dans initState, ou il a du sens.

Le routeur, lui, etait fabrique dans le builder d'un FutureBuilder et l'appel a
getInstanceInfo partait de l'arbre passe a runApp. Chaque passage du builder
rendait un GoRouter neuf, donc un arbre neuf et un historique perdu. Les deux
sont desormais resolus une fois, avant runApp — 45 lignes de moins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thomas Fransolet 2026-09-08 22:07:17 +02:00
parent 842f69ebc9
commit 0df5dc03fa
5 changed files with 163 additions and 173 deletions

View File

@ -14,6 +14,10 @@ class RoundedInputField extends StatelessWidget {
final double fontSize; final double fontSize;
final String? autofill; final String? autofill;
final bool isInt; final bool isInt;
/// A preferer a [initialValue] pour un champ vivant dans un ecran qui se
/// reconstruit : le texte survit alors a la reconstruction du TextFormField.
final TextEditingController? controller;
const RoundedInputField({ const RoundedInputField({
Key? key, Key? key,
this.hintText, this.hintText,
@ -27,7 +31,8 @@ class RoundedInputField extends StatelessWidget {
this.isEmail = false, this.isEmail = false,
this.fontSize = 13, this.fontSize = 13,
this.autofill, this.autofill,
this.isInt = false this.isInt = false,
this.controller,
}) : super(key: key); }) : super(key: key);
@override @override
@ -36,10 +41,11 @@ class RoundedInputField extends StatelessWidget {
color: color, color: color,
child: TextFormField ( child: TextFormField (
onChanged: onChanged, onChanged: onChanged,
initialValue: initialValue, controller: controller,
initialValue: controller == null ? initialValue : null,
cursorColor: textColor, cursorColor: textColor,
maxLength: maxLength, maxLength: maxLength,
autofillHints: autofill != null ? [autofill!] : [], autofillHints: autofill != null ? [autofill!] : const [],
keyboardType: isEmail ? TextInputType.emailAddress : isInt ? TextInputType.number : TextInputType.text, keyboardType: isEmail ? TextInputType.emailAddress : isInt ? TextInputType.number : TextInputType.text,
inputFormatters: !isInt ? [] : <TextInputFormatter>[ inputFormatters: !isInt ? [] : <TextInputFormatter>[
FilteringTextInputFormatter.digitsOnly FilteringTextInputFormatter.digitsOnly

View File

@ -6,10 +6,12 @@ import 'package:manager_app/constants.dart';
class RoundedPasswordField extends StatefulWidget { class RoundedPasswordField extends StatefulWidget {
final ValueChanged<String> onChanged; final ValueChanged<String> onChanged;
final String initialValue; final String initialValue;
final TextEditingController? controller;
const RoundedPasswordField({ const RoundedPasswordField({
Key? key, Key? key,
required this.onChanged, required this.onChanged,
required this.initialValue required this.initialValue,
this.controller,
}) : super(key: key); }) : super(key: key);
@override @override
@ -25,8 +27,9 @@ class _RoundedPasswordFieldState extends State<RoundedPasswordField> {
child: TextFormField( child: TextFormField(
obscureText: isVisible, obscureText: isVisible,
onChanged: widget.onChanged, onChanged: widget.onChanged,
initialValue: widget.initialValue, controller: widget.controller,
autofillHints: [AutofillHints.password], initialValue: widget.controller == null ? widget.initialValue : null,
autofillHints: const [AutofillHints.password],
cursorColor: kPrimaryColor, cursorColor: kPrimaryColor,
style: TextStyle(fontSize: 16, color: kInk), style: TextStyle(fontSize: 16, color: kInk),
decoration: InputDecoration( decoration: InputDecoration(

View File

@ -2,7 +2,6 @@ import 'dart:html';
import 'package:auto_size_text/auto_size_text.dart'; import 'package:auto_size_text/auto_size_text.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_svg/svg.dart'; import 'package:flutter_svg/svg.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:manager_app/Components/common_loader.dart'; import 'package:manager_app/Components/common_loader.dart';
@ -44,6 +43,12 @@ class _LoginScreenState extends State<LoginScreen> {
Storage localStorage = window.localStorage; Storage localStorage = window.localStorage;
late final Future<String> appVersion = getAppVersion(); late final Future<String> appVersion = getAppVersion();
// Champs pilotes par un controller, et non par initialValue : l'ouverture du
// clavier mobile redimensionne la fenetre, ce qui reconstruit l'ecran. Avec
// initialValue, le TextFormField repartait de zero a chaque reconstruction.
final emailController = TextEditingController();
final passwordController = TextEditingController();
void authenticateTRY(AppContext appContext, bool fromClick) async { void authenticateTRY(AppContext appContext, bool fromClick) async {
clientAPI = Client(this.host!); clientAPI = Client(this.host!);
@ -188,9 +193,23 @@ class _LoginScreenState extends State<LoginScreen> {
this.pinCode = localStorage.entries.where((e) => e.key == "pinCode").first.value; this.pinCode = localStorage.entries.where((e) => e.key == "pinCode").first.value;
} }
} }
if (window.location.href.contains("localhost")) {
email = "test@email.be";
password = "kljqsdkljqsd";
}
emailController.text = email;
passwordController.text = password;
super.initState(); super.initState();
} }
@override
void dispose() {
emailController.dispose();
passwordController.dispose();
super.dispose();
}
ManagerAppContext initInstance(ManagerAppContext managerAppContext) { ManagerAppContext initInstance(ManagerAppContext managerAppContext) {
var url = window.location.href; var url = window.location.href;
if (!url.contains("localhost")) { if (!url.contains("localhost")) {
@ -209,8 +228,6 @@ class _LoginScreenState extends State<LoginScreen> {
print("subdomain not found"); print("subdomain not found");
} }
} else { } else {
this.email = "test@email.be";
this.password = "kljqsdkljqsd";
print("localhost.. set mymuseum instance by default"); print("localhost.. set mymuseum instance by default");
} }
@ -224,6 +241,11 @@ class _LoginScreenState extends State<LoginScreen> {
initInstance(appContext.getContext()); initInstance(appContext.getContext());
return Scaffold( return Scaffold(
// Le Scaffold ne se redimensionne pas a l'ouverture du clavier : sur web
// mobile ce redimensionnement relance une mise en page pendant que le
// navigateur ouvre le clavier, et le focus n'y survit pas. Le contenu est
// deja dans un SingleChildScrollView, rien ne reste sous le clavier.
resizeToAvoidBottomInset: false,
body: LayoutBuilder( body: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final isMobile = constraints.maxWidth < 550; final isMobile = constraints.maxWidth < 550;
@ -307,23 +329,31 @@ class _LoginScreenState extends State<LoginScreen> {
}, },
), ),
SizedBox(height: 32), SizedBox(height: 32),
// Pas d'AutofillGroup ici : sur Flutter web il se
// materialise par un <form> DOM qui porte les inputs
// caches. La reconstruction declenchee par l'ouverture
// du clavier mobile le recreait, l'input focus etait
// detruit et le clavier se refermait aussitot. Les
// autofillHints des champs suffisent au navigateur.
Form( Form(
key: widget.key, key: widget.key,
child: AutofillGroup(
child: Column( child: Column(
children: [ children: [
RoundedInputField( RoundedInputField(
key: const ValueKey('login-email'),
hintText: AppLocalizations.of(context)!.email, hintText: AppLocalizations.of(context)!.email,
autofill: AutofillHints.email, autofill: AutofillHints.email,
onChanged: (value) => email = value, onChanged: (value) => email = value,
icon: Icons.person_outline, icon: Icons.person_outline,
initialValue: email, controller: emailController,
isEmail: true, isEmail: true,
fontSize: 16, fontSize: 16,
), ),
const SizedBox(height: kSpace4), const SizedBox(height: kSpace4),
RoundedPasswordField( RoundedPasswordField(
key: const ValueKey('login-password'),
initialValue: password, initialValue: password,
controller: passwordController,
onChanged: (value) => password = value, onChanged: (value) => password = value,
), ),
if (kIsWeb) if (kIsWeb)
@ -357,10 +387,7 @@ class _LoginScreenState extends State<LoginScreen> {
fontSize: 16, fontSize: 16,
vertical: 15, vertical: 15,
horizontal: 30, horizontal: 30,
press: () { press: () => authenticateTRY(appContext, true),
TextInput.finishAutofillContext();
authenticateTRY(appContext, true);
},
), ),
) )
: CommonLoader(iconSize: 40), : CommonLoader(iconSize: 40),
@ -375,7 +402,6 @@ class _LoginScreenState extends State<LoginScreen> {
], ],
), ),
), ),
),
], ],
), ),
), ),

View File

@ -11,10 +11,8 @@ import 'package:manager_app/Screens/Main/main_screen.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:responsive_framework/responsive_framework.dart'; import 'package:responsive_framework/responsive_framework.dart';
import 'Components/common_loader.dart';
import 'Helpers/FileHelper.dart'; import 'Helpers/FileHelper.dart';
import 'Models/session.dart'; import 'Models/session.dart';
import 'Screens/Main/main_screen.dart';
import 'Screens/login_screen.dart'; import 'Screens/login_screen.dart';
import 'Screens/Auth/forgot_password_screen.dart'; import 'Screens/Auth/forgot_password_screen.dart';
import 'Screens/Auth/set_password_screen.dart'; import 'Screens/Auth/set_password_screen.dart';
@ -52,32 +50,32 @@ Future<void> main() async {
usePathUrlStrategy(); usePathUrlStrategy();
runApp( // getInstanceInfo etait appele depuis l'arbre passe a runApp, et le GoRouter
ChangeNotifierProvider<AppContext>( // fabrique dans le builder d'un FutureBuilder : chaque passage du builder
create: (_) => AppContext(managerAppContext), // rendait un routeur neuf, donc un arbre neuf et un historique de navigation
child: MaterialApp( // perdu. Les deux sont desormais resolus une seule fois, avant runApp.
debugShowCheckedModeBanner: false, var instanceDTO = await getInstanceInfo(managerAppContext);
locale: const Locale('fr'),
supportedLocales: const [Locale('fr')],
localizationsDelegates: const [
AppLocalizations.delegate,
FlutterQuillLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
home: FutureBuilder(
future: getInstanceInfo(managerAppContext),
builder: (context, asyncSnapshot) {
if (asyncSnapshot.connectionState == ConnectionState.done) {
var instanceDTO = asyncSnapshot.data;
managerAppContext.instanceId = instanceDTO?.id; managerAppContext.instanceId = instanceDTO?.id;
managerAppContext.instanceDTO = instanceDTO; managerAppContext.instanceDTO = instanceDTO;
String initialRoute = currentPath!.isNotEmpty ? currentPath : '/login'; runApp(
ChangeNotifierProvider<AppContext>(
create: (_) => AppContext(managerAppContext),
child: MyApp(
session: session,
managerAppContext: managerAppContext,
router: buildRouter(
managerAppContext,
currentPath!.isNotEmpty ? currentPath : '/login',
),
),
),
);
}
var _router = GoRouter( GoRouter buildRouter(ManagerAppContext managerAppContext, String initialLocation) {
initialLocation: initialRoute, return GoRouter(
initialLocation: initialLocation,
redirect: (context, state) { redirect: (context, state) {
var instanceId = managerAppContext.instanceId; var instanceId = managerAppContext.instanceId;
const publicPaths = ['/login', '/forgot-password', '/set-password']; const publicPaths = ['/login', '/forgot-password', '/set-password'];
@ -165,49 +163,6 @@ Future<void> main() async {
), ),
), ),
); );
return MyApp(
session: session,
managerAppContext: managerAppContext,
router: _router
);
} else if (asyncSnapshot.connectionState == ConnectionState.none) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
body: Center(
child: SizedBox(
width: MediaQuery.of(context).size.width * 0.85,
child: Text(
"No data",
textAlign: TextAlign.center,
style: TextStyle(color: kPrimaryColor, fontSize: 20.0),
),
),
),
),
);
} else {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
body: Center(
child: SizedBox(
width: MediaQuery.of(context).size.width * 0.85,
child: Container(
height: 250,
child: CommonLoader()
),
),
),
),
);
}
}
),
),
)
);
} }
Future<InstanceDTO?> getInstanceInfo(ManagerAppContext managerAppContext) async { Future<InstanceDTO?> getInstanceInfo(ManagerAppContext managerAppContext) async {

View File

@ -15,7 +15,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at # Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 3.1.3+11 version: 3.1.4+12
environment: environment:
sdk: ">=3.1.0 <4.0.0" sdk: ">=3.1.0 <4.0.0"