From 619e39219f153adfd1b082611d149a47d6db80ca Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Wed, 25 Feb 2026 12:00:51 +0100 Subject: [PATCH] merge: squash develop into master (login autofill + clavier #98) Co-authored-by: Cursor --- frontend/lib/screens/auth/login_screen.dart | 432 ++++++++++-------- .../lib/widgets/custom_app_text_field.dart | 51 ++- frontend/lib/widgets/image_button.dart | 52 ++- 3 files changed, 308 insertions(+), 227 deletions(-) diff --git a/frontend/lib/screens/auth/login_screen.dart b/frontend/lib/screens/auth/login_screen.dart index b441062..8efaf2e 100644 --- a/frontend/lib/screens/auth/login_screen.dart +++ b/frontend/lib/screens/auth/login_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:go_router/go_router.dart'; @@ -20,7 +21,7 @@ class _LoginPageState extends State with WidgetsBindingObserver { final _formKey = GlobalKey(); final _emailController = TextEditingController(); final _passwordController = TextEditingController(); - + bool _isLoading = false; String? _errorMessage; @@ -63,6 +64,11 @@ class _LoginPageState extends State with WidgetsBindingObserver { return null; } + void _handlePasswordSubmitted(String _) { + if (_isLoading) return; + _handleLogin(); + } + /// Gère la connexion de l'utilisateur Future _handleLogin() async { // Réinitialiser le message d'erreur @@ -90,7 +96,7 @@ class _LoginPageState extends State with WidgetsBindingObserver { // Vérifier si l'utilisateur doit changer son mot de passe if (user.changementMdpObligatoire) { if (!mounted) return; - + // Afficher la modale de changement de mot de passe (non-dismissible) final result = await showDialog( context: context, @@ -106,6 +112,9 @@ class _LoginPageState extends State with WidgetsBindingObserver { if (!mounted) return; + // Laisse au navigateur/OS la possibilité de mémoriser les identifiants. + TextInput.finishAutofillContext(shouldSave: true); + // Rediriger selon le rôle de l'utilisateur _redirectUserByRole(user.role); } catch (e) { @@ -152,47 +161,49 @@ class _LoginPageState extends State with WidgetsBindingObserver { final w = constraints.maxWidth; final h = constraints.maxHeight; return FutureBuilder( - future: _getImageDimensions(), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const Center(child: CircularProgressIndicator()); - } + future: _getImageDimensions(), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Center(child: CircularProgressIndicator()); + } - final imageDimensions = snapshot.data!; - final imageHeight = h; - final imageWidth = imageHeight * (imageDimensions.width / imageDimensions.height); - final remainingWidth = w - imageWidth; - final leftMargin = remainingWidth / 4; + final imageDimensions = snapshot.data!; + final imageHeight = h; + final imageWidth = imageHeight * + (imageDimensions.width / imageDimensions.height); + final remainingWidth = w - imageWidth; + final leftMargin = remainingWidth / 4; - return Stack( - children: [ - // Fond en papier - Positioned.fill( - child: Image.asset( - 'assets/images/paper2.png', - fit: BoxFit.cover, - repeat: ImageRepeat.repeat, - ), + return Stack( + children: [ + // Fond en papier + Positioned.fill( + child: Image.asset( + 'assets/images/paper2.png', + fit: BoxFit.cover, + repeat: ImageRepeat.repeat, ), - // Image principale - Positioned( - left: leftMargin, - top: 0, - height: imageHeight, - width: imageWidth, - child: Image.asset( - 'assets/images/river_logo_desktop.png', - fit: BoxFit.contain, - ), + ), + // Image principale + Positioned( + left: leftMargin, + top: 0, + height: imageHeight, + width: imageWidth, + child: Image.asset( + 'assets/images/river_logo_desktop.png', + fit: BoxFit.contain, ), - // Formulaire dans le cadran en bas à droite - Positioned( - right: 0, - bottom: 0, - width: w * 0.6, // 60% de la largeur de l'écran - height: h * 0.5, // 50% de la hauteur de l'écran - child: Padding( - padding: EdgeInsets.all(w * 0.02), // 2% de padding + ), + // Formulaire dans le cadran en bas à droite + Positioned( + right: 0, + bottom: 0, + width: w * 0.6, // 60% de la largeur de l'écran + height: h * 0.5, // 50% de la hauteur de l'écran + child: Padding( + padding: EdgeInsets.all(w * 0.02), // 2% de padding + child: AutofillGroup( child: Form( key: _formKey, child: Column( @@ -207,6 +218,12 @@ class _LoginPageState extends State with WidgetsBindingObserver { controller: _emailController, labelText: 'Email', hintText: 'Votre adresse email', + keyboardType: TextInputType.emailAddress, + autofillHints: const [ + AutofillHints.username, + AutofillHints.email, + ], + textInputAction: TextInputAction.next, validator: _validateEmail, style: CustomAppTextFieldStyle.lavande, fieldHeight: 53, @@ -220,6 +237,12 @@ class _LoginPageState extends State with WidgetsBindingObserver { labelText: 'Mot de passe', hintText: 'Votre mot de passe', obscureText: true, + autofillHints: const [ + AutofillHints.password + ], + textInputAction: TextInputAction.done, + onFieldSubmitted: + _handlePasswordSubmitted, validator: _validatePassword, style: CustomAppTextFieldStyle.jaune, fieldHeight: 53, @@ -229,7 +252,7 @@ class _LoginPageState extends State with WidgetsBindingObserver { ], ), const SizedBox(height: 20), - + // Message d'erreur if (_errorMessage != null) Container( @@ -242,7 +265,8 @@ class _LoginPageState extends State with WidgetsBindingObserver { ), child: Row( children: [ - Icon(Icons.error_outline, color: Colors.red[700], size: 20), + Icon(Icons.error_outline, + color: Colors.red[700], size: 20), const SizedBox(width: 10), Expanded( child: Text( @@ -256,7 +280,7 @@ class _LoginPageState extends State with WidgetsBindingObserver { ], ), ), - + // Bouton centré Center( child: _isLoading @@ -309,67 +333,68 @@ class _LoginPageState extends State with WidgetsBindingObserver { ), ), ), - // Pied de page (Wrap pour éviter overflow sur petite largeur) - Positioned( - left: 0, - right: 0, - bottom: 0, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 8.0), - decoration: const BoxDecoration( - color: Colors.transparent, - ), - child: Wrap( - alignment: WrapAlignment.center, - runSpacing: 8, - children: [ - _FooterLink( - text: 'Contact support', - onTap: () async { - final Uri emailLaunchUri = Uri( - scheme: 'mailto', - path: 'support@supernounou.local', - ); - if (await canLaunchUrl(emailLaunchUri)) { - await launchUrl(emailLaunchUri); - } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Impossible d\'ouvrir le client mail', - style: GoogleFonts.merienda(), - ), + ), + // Pied de page (Wrap pour éviter overflow sur petite largeur) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8.0), + decoration: const BoxDecoration( + color: Colors.transparent, + ), + child: Wrap( + alignment: WrapAlignment.center, + runSpacing: 8, + children: [ + _FooterLink( + text: 'Contact support', + onTap: () async { + final Uri emailLaunchUri = Uri( + scheme: 'mailto', + path: 'support@supernounou.local', + ); + if (await canLaunchUrl(emailLaunchUri)) { + await launchUrl(emailLaunchUri); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Impossible d\'ouvrir le client mail', + style: GoogleFonts.merienda(), ), - ); - } - }, - ), - _FooterLink( - text: 'Signaler un bug', - onTap: () { - _showBugReportDialog(context); - }, - ), - _FooterLink( - text: 'Mentions légales', - onTap: () { - context.go('/legal'); - }, - ), - _FooterLink( - text: 'Politique de confidentialité', - onTap: () { - context.go('/privacy'); - }, - ), - ], - ), + ), + ); + } + }, + ), + _FooterLink( + text: 'Signaler un bug', + onTap: () { + _showBugReportDialog(context); + }, + ), + _FooterLink( + text: 'Mentions légales', + onTap: () { + context.go('/legal'); + }, + ), + _FooterLink( + text: 'Politique de confidentialité', + onTap: () { + context.go('/privacy'); + }, + ), + ], ), ), - ], - ); - }, - ); + ), + ], + ); + }, + ); }, ), ); @@ -378,6 +403,7 @@ class _LoginPageState extends State with WidgetsBindingObserver { /// Dimensions de river_logo_mobile.png (à mettre à jour si l'asset change). static const int _riverLogoMobileWidth = 600; static const int _riverLogoMobileHeight = 1080; + /// Fraction de la hauteur de l'image où se termine visuellement le slogan (0 = haut, 1 = bas). static const double _sloganEndFraction = 0.42; static const double _gapBelowSlogan = 12.0; @@ -388,7 +414,8 @@ class _LoginPageState extends State with WidgetsBindingObserver { final h = constraints.maxHeight; final w = constraints.maxWidth; final imageAspectRatio = _riverLogoMobileHeight / _riverLogoMobileWidth; - final formTop = w * imageAspectRatio * _sloganEndFraction + _gapBelowSlogan; + final formTop = + w * imageAspectRatio * _sloganEndFraction + _gapBelowSlogan; return Stack( clipBehavior: Clip.none, children: [ @@ -428,95 +455,115 @@ class _LoginPageState extends State with WidgetsBindingObserver { children: [ Expanded( child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20), + padding: const EdgeInsets.symmetric( + horizontal: 24, vertical: 20), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 400), - child: Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 16), - CustomAppTextField( - controller: _emailController, - labelText: 'Email', - showLabel: false, - hintText: 'Votre adresse email', - validator: _validateEmail, - style: CustomAppTextFieldStyle.lavande, - fieldHeight: 48, - fieldWidth: double.infinity, - ), - const SizedBox(height: 12), - CustomAppTextField( - controller: _passwordController, - labelText: 'Mot de passe', - showLabel: false, - hintText: 'Votre mot de passe', - obscureText: true, - validator: _validatePassword, - style: CustomAppTextFieldStyle.jaune, - fieldHeight: 48, - fieldWidth: double.infinity, - ), - if (_errorMessage != null) ...[ - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.red.shade50, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: Colors.red.shade300), - ), - child: Row( - children: [ - Icon(Icons.error_outline, color: Colors.red.shade700, size: 20), - const SizedBox(width: 10), - Expanded( - child: Text( - _errorMessage!, - style: GoogleFonts.merienda(fontSize: 12, color: Colors.red.shade700), - ), + child: AutofillGroup( + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 16), + CustomAppTextField( + controller: _emailController, + labelText: 'Email', + showLabel: false, + hintText: 'Votre adresse email', + keyboardType: TextInputType.emailAddress, + autofillHints: const [ + AutofillHints.username, + AutofillHints.email, + ], + textInputAction: TextInputAction.next, + validator: _validateEmail, + style: CustomAppTextFieldStyle.lavande, + fieldHeight: 48, + fieldWidth: double.infinity, + ), + const SizedBox(height: 12), + CustomAppTextField( + controller: _passwordController, + labelText: 'Mot de passe', + showLabel: false, + hintText: 'Votre mot de passe', + obscureText: true, + autofillHints: const [ + AutofillHints.password + ], + textInputAction: TextInputAction.done, + onFieldSubmitted: _handlePasswordSubmitted, + validator: _validatePassword, + style: CustomAppTextFieldStyle.jaune, + fieldHeight: 48, + fieldWidth: double.infinity, + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: Colors.red.shade300), + ), + child: Row( + children: [ + Icon(Icons.error_outline, + color: Colors.red.shade700, + size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + _errorMessage!, + style: GoogleFonts.merienda( + fontSize: 12, + color: Colors.red.shade700), + ), + ), + ], + ), + ), + ], + const SizedBox(height: 12), + _isLoading + ? const CircularProgressIndicator() + : ImageButton( + bg: 'assets/images/bg_green.png', + width: double.infinity, + height: 44, + text: 'Se connecter', + textColor: const Color(0xFF2D6A4F), + onPressed: _handleLogin, + ), + const SizedBox(height: 12), + TextButton( + onPressed: () {/* TODO */}, + child: Text( + 'Mot de passe oublié ?', + style: GoogleFonts.merienda( + fontSize: 14, + color: const Color(0xFF2D6A4F), + decoration: TextDecoration.underline, + ), + ), + ), + TextButton( + onPressed: () => + context.go('/register-choice'), + child: Text( + 'Créer un compte', + style: GoogleFonts.merienda( + fontSize: 16, + color: const Color(0xFF2D6A4F), + decoration: TextDecoration.underline, + ), + ), + ), + ], ), - ], - ), - ), - ], - const SizedBox(height: 12), - _isLoading - ? const CircularProgressIndicator() - : ImageButton( - bg: 'assets/images/bg_green.png', - width: double.infinity, - height: 44, - text: 'Se connecter', - textColor: const Color(0xFF2D6A4F), - onPressed: _handleLogin, - ), - const SizedBox(height: 12), - TextButton( - onPressed: () { /* TODO */ }, - child: Text( - 'Mot de passe oublié ?', - style: GoogleFonts.merienda( - fontSize: 14, - color: const Color(0xFF2D6A4F), - decoration: TextDecoration.underline, - ), - ), - ), - TextButton( - onPressed: () => context.go('/register-choice'), - child: Text( - 'Créer un compte', - style: GoogleFonts.merienda( - fontSize: 16, - color: const Color(0xFF2D6A4F), - decoration: TextDecoration.underline, - ), - ), - ), - ], ), ), ), @@ -533,12 +580,17 @@ class _LoginPageState extends State with WidgetsBindingObserver { text: 'Contact support', fontSize: 11, onTap: () async { - final uri = Uri(scheme: 'mailto', path: 'support@supernounou.local'); + final uri = Uri( + scheme: 'mailto', + path: 'support@supernounou.local'); if (await canLaunchUrl(uri)) { await launchUrl(uri); } else if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Impossible d\'ouvrir le client mail', style: GoogleFonts.merienda())), + SnackBar( + content: Text( + 'Impossible d\'ouvrir le client mail', + style: GoogleFonts.merienda())), ); } }, @@ -707,4 +759,4 @@ class _FooterLink extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/frontend/lib/widgets/custom_app_text_field.dart b/frontend/lib/widgets/custom_app_text_field.dart index f593a15..0645bb1 100644 --- a/frontend/lib/widgets/custom_app_text_field.dart +++ b/frontend/lib/widgets/custom_app_text_field.dart @@ -10,6 +10,7 @@ enum CustomAppTextFieldStyle { class CustomAppTextField extends StatefulWidget { final TextEditingController controller; + final FocusNode? focusNode; final String labelText; final String hintText; final double fieldWidth; @@ -26,10 +27,14 @@ class CustomAppTextField extends StatefulWidget { final double labelFontSize; final double inputFontSize; final bool showLabel; + final Iterable? autofillHints; + final TextInputAction? textInputAction; + final ValueChanged? onFieldSubmitted; const CustomAppTextField({ super.key, required this.controller, + this.focusNode, required this.labelText, this.showLabel = true, this.hintText = '', @@ -46,6 +51,9 @@ class CustomAppTextField extends StatefulWidget { this.suffixIcon, this.labelFontSize = 18.0, this.inputFontSize = 18.0, + this.autofillHints, + this.textInputAction, + this.onFieldSubmitted, }); @override @@ -68,7 +76,7 @@ class _CustomAppTextFieldState extends State { @override Widget build(BuildContext context) { const double fontHeightMultiplier = 1.2; - const double internalVerticalPadding = 16.0; + const double internalVerticalPadding = 16.0; final double dynamicFieldHeight = widget.fieldHeight; return Column( @@ -90,7 +98,7 @@ class _CustomAppTextFieldState extends State { width: widget.fieldWidth, height: dynamicFieldHeight, child: Stack( - alignment: Alignment.centerLeft, + alignment: Alignment.centerLeft, children: [ Positioned.fill( child: Image.asset( @@ -99,40 +107,49 @@ class _CustomAppTextFieldState extends State { ), ), Padding( - padding: const EdgeInsets.symmetric(horizontal: 18.0, vertical: 8.0), + padding: + const EdgeInsets.symmetric(horizontal: 18.0, vertical: 8.0), child: TextFormField( controller: widget.controller, + focusNode: widget.focusNode, obscureText: widget.obscureText, keyboardType: widget.keyboardType, + autofillHints: widget.autofillHints, + textInputAction: widget.textInputAction, + onFieldSubmitted: widget.onFieldSubmitted, enabled: widget.enabled, readOnly: widget.readOnly, onTap: widget.onTap, style: GoogleFonts.merienda( - fontSize: widget.inputFontSize, - color: widget.enabled ? Colors.black87 : Colors.grey - ), + fontSize: widget.inputFontSize, + color: widget.enabled ? Colors.black87 : Colors.grey), validator: widget.validator ?? (value) { - if (!widget.enabled || widget.readOnly) return null; - if (widget.isRequired && (value == null || value.isEmpty)) { - return 'Ce champ est obligatoire'; - } - return null; - }, + if (!widget.enabled || widget.readOnly) return null; + if (widget.isRequired && + (value == null || value.isEmpty)) { + return 'Ce champ est obligatoire'; + } + return null; + }, decoration: InputDecoration( hintText: widget.hintText, - hintStyle: GoogleFonts.merienda(fontSize: widget.inputFontSize, color: Colors.black54.withOpacity(0.7)), + hintStyle: GoogleFonts.merienda( + fontSize: widget.inputFontSize, + color: Colors.black54.withOpacity(0.7)), border: InputBorder.none, contentPadding: EdgeInsets.zero, - suffixIcon: widget.suffixIcon != null + suffixIcon: widget.suffixIcon != null ? Padding( padding: const EdgeInsets.only(right: 0.0), - child: Icon(widget.suffixIcon, color: Colors.black54, size: widget.inputFontSize * 1.1), + child: Icon(widget.suffixIcon, + color: Colors.black54, + size: widget.inputFontSize * 1.1), ) : null, isDense: true, ), - textAlignVertical: TextAlignVertical.center, + textAlignVertical: TextAlignVertical.center, ), ), ], @@ -141,4 +158,4 @@ class _CustomAppTextFieldState extends State { ], ); } -} \ No newline at end of file +} diff --git a/frontend/lib/widgets/image_button.dart b/frontend/lib/widgets/image_button.dart index 2d81362..9d63bad 100644 --- a/frontend/lib/widgets/image_button.dart +++ b/frontend/lib/widgets/image_button.dart @@ -23,26 +23,38 @@ class ImageButton extends StatelessWidget { @override Widget build(BuildContext context) { - return MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: onPressed, - child: Container( - width: width, - height: height, - decoration: BoxDecoration( - image: DecorationImage( - image: AssetImage(bg), - fit: BoxFit.fill, + return SizedBox( + width: width, + height: height, + child: Semantics( + button: true, + label: text, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: TextButton( + onPressed: onPressed, + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: + const RoundedRectangleBorder(borderRadius: BorderRadius.zero), ), - ), - child: Center( - child: Text( - text, - style: GoogleFonts.merienda( - color: textColor, - fontSize: fontSize, // Utilisation du paramètre - fontWeight: FontWeight.bold, + child: Ink( + decoration: BoxDecoration( + image: DecorationImage( + image: AssetImage(bg), + fit: BoxFit.fill, + ), + ), + child: Center( + child: Text( + text, + style: GoogleFonts.merienda( + color: textColor, + fontSize: fontSize, // Utilisation du paramètre + fontWeight: FontWeight.bold, + ), + ), ), ), ), @@ -50,4 +62,4 @@ class ImageButton extends StatelessWidget { ), ); } -} \ No newline at end of file +}