From 0df5dc03fa936fc47ed64f907fffbd5bebd03925 Mon Sep 17 00:00:00 2001 From: Thomas Fransolet Date: Tue, 8 Sep 2026 22:07:17 +0200 Subject: [PATCH] Web mobile : le clavier se refermait aussitot ouvert sur le login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
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 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) --- lib/Components/rounded_input_field.dart | 12 +- lib/Components/rounded_password_field.dart | 9 +- lib/Screens/login_screen.dart | 48 +++- lib/main.dart | 265 +++++++++------------ pubspec.yaml | 2 +- 5 files changed, 163 insertions(+), 173 deletions(-) diff --git a/lib/Components/rounded_input_field.dart b/lib/Components/rounded_input_field.dart index 16da056..08e6419 100644 --- a/lib/Components/rounded_input_field.dart +++ b/lib/Components/rounded_input_field.dart @@ -14,6 +14,10 @@ class RoundedInputField extends StatelessWidget { final double fontSize; final String? autofill; 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({ Key? key, this.hintText, @@ -27,7 +31,8 @@ class RoundedInputField extends StatelessWidget { this.isEmail = false, this.fontSize = 13, this.autofill, - this.isInt = false + this.isInt = false, + this.controller, }) : super(key: key); @override @@ -36,10 +41,11 @@ class RoundedInputField extends StatelessWidget { color: color, child: TextFormField ( onChanged: onChanged, - initialValue: initialValue, + controller: controller, + initialValue: controller == null ? initialValue : null, cursorColor: textColor, maxLength: maxLength, - autofillHints: autofill != null ? [autofill!] : [], + autofillHints: autofill != null ? [autofill!] : const [], keyboardType: isEmail ? TextInputType.emailAddress : isInt ? TextInputType.number : TextInputType.text, inputFormatters: !isInt ? [] : [ FilteringTextInputFormatter.digitsOnly diff --git a/lib/Components/rounded_password_field.dart b/lib/Components/rounded_password_field.dart index e5a6ef1..bd1c10a 100644 --- a/lib/Components/rounded_password_field.dart +++ b/lib/Components/rounded_password_field.dart @@ -6,10 +6,12 @@ import 'package:manager_app/constants.dart'; class RoundedPasswordField extends StatefulWidget { final ValueChanged onChanged; final String initialValue; + final TextEditingController? controller; const RoundedPasswordField({ Key? key, required this.onChanged, - required this.initialValue + required this.initialValue, + this.controller, }) : super(key: key); @override @@ -25,8 +27,9 @@ class _RoundedPasswordFieldState extends State { child: TextFormField( obscureText: isVisible, onChanged: widget.onChanged, - initialValue: widget.initialValue, - autofillHints: [AutofillHints.password], + controller: widget.controller, + initialValue: widget.controller == null ? widget.initialValue : null, + autofillHints: const [AutofillHints.password], cursorColor: kPrimaryColor, style: TextStyle(fontSize: 16, color: kInk), decoration: InputDecoration( diff --git a/lib/Screens/login_screen.dart b/lib/Screens/login_screen.dart index ad39466..0607813 100644 --- a/lib/Screens/login_screen.dart +++ b/lib/Screens/login_screen.dart @@ -2,7 +2,6 @@ import 'dart:html'; import 'package:auto_size_text/auto_size_text.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_svg/svg.dart'; import 'package:go_router/go_router.dart'; import 'package:manager_app/Components/common_loader.dart'; @@ -44,6 +43,12 @@ class _LoginScreenState extends State { Storage localStorage = window.localStorage; late final Future 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 { clientAPI = Client(this.host!); @@ -188,9 +193,23 @@ class _LoginScreenState extends State { 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(); } + @override + void dispose() { + emailController.dispose(); + passwordController.dispose(); + super.dispose(); + } + ManagerAppContext initInstance(ManagerAppContext managerAppContext) { var url = window.location.href; if (!url.contains("localhost")) { @@ -209,8 +228,6 @@ class _LoginScreenState extends State { print("subdomain not found"); } } else { - this.email = "test@email.be"; - this.password = "kljqsdkljqsd"; print("localhost.. set mymuseum instance by default"); } @@ -224,6 +241,11 @@ class _LoginScreenState extends State { initInstance(appContext.getContext()); 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( builder: (context, constraints) { final isMobile = constraints.maxWidth < 550; @@ -307,23 +329,31 @@ class _LoginScreenState extends State { }, ), SizedBox(height: 32), + // Pas d'AutofillGroup ici : sur Flutter web il se + // materialise par un 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( key: widget.key, - child: AutofillGroup( - child: Column( + child: Column( children: [ RoundedInputField( + key: const ValueKey('login-email'), hintText: AppLocalizations.of(context)!.email, autofill: AutofillHints.email, onChanged: (value) => email = value, icon: Icons.person_outline, - initialValue: email, + controller: emailController, isEmail: true, fontSize: 16, ), const SizedBox(height: kSpace4), RoundedPasswordField( + key: const ValueKey('login-password'), initialValue: password, + controller: passwordController, onChanged: (value) => password = value, ), if (kIsWeb) @@ -357,10 +387,7 @@ class _LoginScreenState extends State { fontSize: 16, vertical: 15, horizontal: 30, - press: () { - TextInput.finishAutofillContext(); - authenticateTRY(appContext, true); - }, + press: () => authenticateTRY(appContext, true), ), ) : CommonLoader(iconSize: 40), @@ -374,7 +401,6 @@ class _LoginScreenState extends State { ), ], ), - ), ), ], ), diff --git a/lib/main.dart b/lib/main.dart index e3bd53a..7fb7b0c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,10 +11,8 @@ import 'package:manager_app/Screens/Main/main_screen.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:responsive_framework/responsive_framework.dart'; -import 'Components/common_loader.dart'; import 'Helpers/FileHelper.dart'; import 'Models/session.dart'; -import 'Screens/Main/main_screen.dart'; import 'Screens/login_screen.dart'; import 'Screens/Auth/forgot_password_screen.dart'; import 'Screens/Auth/set_password_screen.dart'; @@ -52,161 +50,118 @@ Future main() async { usePathUrlStrategy(); - runApp( - ChangeNotifierProvider( - create: (_) => AppContext(managerAppContext), - child: MaterialApp( - debugShowCheckedModeBanner: false, - 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.instanceDTO = instanceDTO; + // getInstanceInfo etait appele depuis l'arbre passe a runApp, et le GoRouter + // fabrique dans le builder d'un FutureBuilder : chaque passage du builder + // rendait un routeur neuf, donc un arbre neuf et un historique de navigation + // perdu. Les deux sont desormais resolus une seule fois, avant runApp. + var instanceDTO = await getInstanceInfo(managerAppContext); + managerAppContext.instanceId = instanceDTO?.id; + managerAppContext.instanceDTO = instanceDTO; - String initialRoute = currentPath!.isNotEmpty ? currentPath : '/login'; - - var _router = GoRouter( - initialLocation: initialRoute, - redirect: (context, state) { - var instanceId = managerAppContext.instanceId; - const publicPaths = ['/login', '/forgot-password', '/set-password']; - if (instanceId == null && !publicPaths.contains(state.fullPath)) { - return '/login'; - } - - - if (instanceId != null) { - if(state.fullPath == '/login' || state.fullPath == "") { - if(managerAppContext.instanceDTO!.isMobile!) { - return '/main/mobile'; - } - if(managerAppContext.instanceDTO!.isTablet!) { - return '/main/tablet'; - } - if(managerAppContext.instanceDTO!.isWeb!) { - return '/main/web'; - } - if(managerAppContext.instanceDTO!.isVR!) { - return '/main/vr'; - } - } - return state.matchedLocation; - } - - return null; - }, - routes: [ - GoRoute( - path: '/login', - builder: (context, state) => LoginScreen(), - ), - GoRoute( - path: '/forgot-password', - builder: (context, state) => ForgotPasswordScreen(), - ), - GoRoute( - path: '/set-password', - builder: (context, state) => SetPasswordScreen( - token: state.uri.queryParameters['token'], - ), - ), - GoRoute( - path: '/main/:view', - builder: (context, state) { - final view = state.pathParameters['view']; - // La clé porte l'instance : sans elle, un SuperAdmin - // qui change d'instance en restant sur la même vue - // gardait le State de tous les écrans en dessous, donc - // les données de l'instance précédente. - return MainScreen( - key: ValueKey('${managerAppContext.instanceId}/$view'), - instance: managerAppContext.instanceDTO!, - view: view, - ); - }, - ), - GoRoute( - path: '/policy', - builder: (context, state) => PolicyScreen(), - ), - GoRoute( - path: '/policy/mdlf', - builder: (context, state) => PolicyScreen(param: "mdlf"), - ), - GoRoute( - path: '/policy/fort', - builder: (context, state) => PolicyScreen(param: "fort"), - ), - ], - errorBuilder: (context, state) => MaterialApp( - debugShowCheckedModeBanner: false, - home: Scaffold( - body: Center( - child: SizedBox( - width: MediaQuery.of(context).size.width * 0.85, - child: Text( - AppLocalizations.of(context)!.pageNotFound, - textAlign: TextAlign.center, - style: TextStyle(color: kPrimaryColor, fontSize: 20.0), - ), - ), - ), - ), - ), - ); - - 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() - ), - ), - ), - ), - ); - } - } - ), + runApp( + ChangeNotifierProvider( + create: (_) => AppContext(managerAppContext), + child: MyApp( + session: session, + managerAppContext: managerAppContext, + router: buildRouter( + managerAppContext, + currentPath!.isNotEmpty ? currentPath : '/login', ), - ) + ), + ), + ); +} + +GoRouter buildRouter(ManagerAppContext managerAppContext, String initialLocation) { + return GoRouter( + initialLocation: initialLocation, + redirect: (context, state) { + var instanceId = managerAppContext.instanceId; + const publicPaths = ['/login', '/forgot-password', '/set-password']; + if (instanceId == null && !publicPaths.contains(state.fullPath)) { + return '/login'; + } + + + if (instanceId != null) { + if(state.fullPath == '/login' || state.fullPath == "") { + if(managerAppContext.instanceDTO!.isMobile!) { + return '/main/mobile'; + } + if(managerAppContext.instanceDTO!.isTablet!) { + return '/main/tablet'; + } + if(managerAppContext.instanceDTO!.isWeb!) { + return '/main/web'; + } + if(managerAppContext.instanceDTO!.isVR!) { + return '/main/vr'; + } + } + return state.matchedLocation; + } + + return null; + }, + routes: [ + GoRoute( + path: '/login', + builder: (context, state) => LoginScreen(), + ), + GoRoute( + path: '/forgot-password', + builder: (context, state) => ForgotPasswordScreen(), + ), + GoRoute( + path: '/set-password', + builder: (context, state) => SetPasswordScreen( + token: state.uri.queryParameters['token'], + ), + ), + GoRoute( + path: '/main/:view', + builder: (context, state) { + final view = state.pathParameters['view']; + // La clé porte l'instance : sans elle, un SuperAdmin + // qui change d'instance en restant sur la même vue + // gardait le State de tous les écrans en dessous, donc + // les données de l'instance précédente. + return MainScreen( + key: ValueKey('${managerAppContext.instanceId}/$view'), + instance: managerAppContext.instanceDTO!, + view: view, + ); + }, + ), + GoRoute( + path: '/policy', + builder: (context, state) => PolicyScreen(), + ), + GoRoute( + path: '/policy/mdlf', + builder: (context, state) => PolicyScreen(param: "mdlf"), + ), + GoRoute( + path: '/policy/fort', + builder: (context, state) => PolicyScreen(param: "fort"), + ), + ], + errorBuilder: (context, state) => MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + body: Center( + child: SizedBox( + width: MediaQuery.of(context).size.width * 0.85, + child: Text( + AppLocalizations.of(context)!.pageNotFound, + textAlign: TextAlign.center, + style: TextStyle(color: kPrimaryColor, fontSize: 20.0), + ), + ), + ), + ), + ), ); } diff --git a/pubspec.yaml b/pubspec.yaml index a044ba8..8d24064 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 3.1.3+11 +version: 3.1.4+12 environment: sdk: ">=3.1.0 <4.0.0"