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:
parent
842f69ebc9
commit
0df5dc03fa
@ -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 ? [] : <TextInputFormatter>[
|
||||
FilteringTextInputFormatter.digitsOnly
|
||||
|
||||
@ -6,10 +6,12 @@ import 'package:manager_app/constants.dart';
|
||||
class RoundedPasswordField extends StatefulWidget {
|
||||
final ValueChanged<String> 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<RoundedPasswordField> {
|
||||
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(
|
||||
|
||||
@ -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<LoginScreen> {
|
||||
Storage localStorage = window.localStorage;
|
||||
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 {
|
||||
clientAPI = Client(this.host!);
|
||||
|
||||
@ -188,9 +193,23 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
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<LoginScreen> {
|
||||
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<LoginScreen> {
|
||||
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<LoginScreen> {
|
||||
},
|
||||
),
|
||||
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(
|
||||
key: widget.key,
|
||||
child: AutofillGroup(
|
||||
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<LoginScreen> {
|
||||
fontSize: 16,
|
||||
vertical: 15,
|
||||
horizontal: 30,
|
||||
press: () {
|
||||
TextInput.finishAutofillContext();
|
||||
authenticateTRY(appContext, true);
|
||||
},
|
||||
press: () => authenticateTRY(appContext, true),
|
||||
),
|
||||
)
|
||||
: CommonLoader(iconSize: 40),
|
||||
@ -375,7 +402,6 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@ -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,32 +50,32 @@ Future<void> main() async {
|
||||
|
||||
usePathUrlStrategy();
|
||||
|
||||
runApp(
|
||||
ChangeNotifierProvider<AppContext>(
|
||||
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;
|
||||
// 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';
|
||||
runApp(
|
||||
ChangeNotifierProvider<AppContext>(
|
||||
create: (_) => AppContext(managerAppContext),
|
||||
child: MyApp(
|
||||
session: session,
|
||||
managerAppContext: managerAppContext,
|
||||
router: buildRouter(
|
||||
managerAppContext,
|
||||
currentPath!.isNotEmpty ? currentPath : '/login',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
var _router = GoRouter(
|
||||
initialLocation: initialRoute,
|
||||
GoRouter buildRouter(ManagerAppContext managerAppContext, String initialLocation) {
|
||||
return GoRouter(
|
||||
initialLocation: initialLocation,
|
||||
redirect: (context, state) {
|
||||
var instanceId = managerAppContext.instanceId;
|
||||
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 {
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user