feat(#98): améliorer le login avec autofill natif et navigation clavier

Active l’autofill navigateur/OS sur le formulaire de connexion et complète l’accessibilité clavier (Tab jusqu’au bouton, Entrée sur le mot de passe) sans stockage local custom des identifiants.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
MARTIN Julien 2026-02-24 23:27:04 +01:00
parent 80d69a5463
commit a4e6cfc50e
3 changed files with 306 additions and 227 deletions

View File

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@ -20,7 +21,7 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController(); final _emailController = TextEditingController();
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
bool _isLoading = false; bool _isLoading = false;
String? _errorMessage; String? _errorMessage;
@ -63,6 +64,11 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
return null; return null;
} }
void _handlePasswordSubmitted(String _) {
if (_isLoading) return;
_handleLogin();
}
/// Gère la connexion de l'utilisateur /// Gère la connexion de l'utilisateur
Future<void> _handleLogin() async { Future<void> _handleLogin() async {
// Réinitialiser le message d'erreur // Réinitialiser le message d'erreur
@ -90,7 +96,7 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
// Vérifier si l'utilisateur doit changer son mot de passe // Vérifier si l'utilisateur doit changer son mot de passe
if (user.changementMdpObligatoire) { if (user.changementMdpObligatoire) {
if (!mounted) return; if (!mounted) return;
// Afficher la modale de changement de mot de passe (non-dismissible) // Afficher la modale de changement de mot de passe (non-dismissible)
final result = await showDialog<bool>( final result = await showDialog<bool>(
context: context, context: context,
@ -106,6 +112,9 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
if (!mounted) return; 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 // Rediriger selon le rôle de l'utilisateur
_redirectUserByRole(user.role); _redirectUserByRole(user.role);
} catch (e) { } catch (e) {
@ -152,47 +161,49 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
final w = constraints.maxWidth; final w = constraints.maxWidth;
final h = constraints.maxHeight; final h = constraints.maxHeight;
return FutureBuilder( return FutureBuilder(
future: _getImageDimensions(), future: _getImageDimensions(),
builder: (context, snapshot) { builder: (context, snapshot) {
if (!snapshot.hasData) { if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
final imageDimensions = snapshot.data!; final imageDimensions = snapshot.data!;
final imageHeight = h; final imageHeight = h;
final imageWidth = imageHeight * (imageDimensions.width / imageDimensions.height); final imageWidth = imageHeight *
final remainingWidth = w - imageWidth; (imageDimensions.width / imageDimensions.height);
final leftMargin = remainingWidth / 4; final remainingWidth = w - imageWidth;
final leftMargin = remainingWidth / 4;
return Stack( return Stack(
children: [ children: [
// Fond en papier // Fond en papier
Positioned.fill( Positioned.fill(
child: Image.asset( child: Image.asset(
'assets/images/paper2.png', 'assets/images/paper2.png',
fit: BoxFit.cover, fit: BoxFit.cover,
repeat: ImageRepeat.repeat, repeat: ImageRepeat.repeat,
),
), ),
// Image principale ),
Positioned( // Image principale
left: leftMargin, Positioned(
top: 0, left: leftMargin,
height: imageHeight, top: 0,
width: imageWidth, height: imageHeight,
child: Image.asset( width: imageWidth,
'assets/images/river_logo_desktop.png', child: Image.asset(
fit: BoxFit.contain, 'assets/images/river_logo_desktop.png',
), fit: BoxFit.contain,
), ),
// Formulaire dans le cadran en bas à droite ),
Positioned( // Formulaire dans le cadran en bas à droite
right: 0, Positioned(
bottom: 0, right: 0,
width: w * 0.6, // 60% de la largeur de l'écran bottom: 0,
height: h * 0.5, // 50% de la hauteur de l'écran width: w * 0.6, // 60% de la largeur de l'écran
child: Padding( height: h * 0.5, // 50% de la hauteur de l'écran
padding: EdgeInsets.all(w * 0.02), // 2% de padding child: Padding(
padding: EdgeInsets.all(w * 0.02), // 2% de padding
child: AutofillGroup(
child: Form( child: Form(
key: _formKey, key: _formKey,
child: Column( child: Column(
@ -207,6 +218,12 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
controller: _emailController, controller: _emailController,
labelText: 'Email', labelText: 'Email',
hintText: 'Votre adresse email', hintText: 'Votre adresse email',
keyboardType: TextInputType.emailAddress,
autofillHints: const [
AutofillHints.username,
AutofillHints.email,
],
textInputAction: TextInputAction.next,
validator: _validateEmail, validator: _validateEmail,
style: CustomAppTextFieldStyle.lavande, style: CustomAppTextFieldStyle.lavande,
fieldHeight: 53, fieldHeight: 53,
@ -220,6 +237,12 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
labelText: 'Mot de passe', labelText: 'Mot de passe',
hintText: 'Votre mot de passe', hintText: 'Votre mot de passe',
obscureText: true, obscureText: true,
autofillHints: const [
AutofillHints.password
],
textInputAction: TextInputAction.done,
onFieldSubmitted:
_handlePasswordSubmitted,
validator: _validatePassword, validator: _validatePassword,
style: CustomAppTextFieldStyle.jaune, style: CustomAppTextFieldStyle.jaune,
fieldHeight: 53, fieldHeight: 53,
@ -229,7 +252,7 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
], ],
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
// Message d'erreur // Message d'erreur
if (_errorMessage != null) if (_errorMessage != null)
Container( Container(
@ -242,7 +265,8 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
), ),
child: Row( child: Row(
children: [ 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), const SizedBox(width: 10),
Expanded( Expanded(
child: Text( child: Text(
@ -256,7 +280,7 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
], ],
), ),
), ),
// Bouton centré // Bouton centré
Center( Center(
child: _isLoading child: _isLoading
@ -309,67 +333,68 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
), ),
), ),
), ),
// Pied de page (Wrap pour éviter overflow sur petite largeur) ),
Positioned( // Pied de page (Wrap pour éviter overflow sur petite largeur)
left: 0, Positioned(
right: 0, left: 0,
bottom: 0, right: 0,
child: Container( bottom: 0,
padding: const EdgeInsets.symmetric(vertical: 8.0), child: Container(
decoration: const BoxDecoration( padding: const EdgeInsets.symmetric(vertical: 8.0),
color: Colors.transparent, decoration: const BoxDecoration(
), color: Colors.transparent,
child: Wrap( ),
alignment: WrapAlignment.center, child: Wrap(
runSpacing: 8, alignment: WrapAlignment.center,
children: [ runSpacing: 8,
_FooterLink( children: [
text: 'Contact support', _FooterLink(
onTap: () async { text: 'Contact support',
final Uri emailLaunchUri = Uri( onTap: () async {
scheme: 'mailto', final Uri emailLaunchUri = Uri(
path: 'support@supernounou.local', scheme: 'mailto',
); path: 'support@supernounou.local',
if (await canLaunchUrl(emailLaunchUri)) { );
await launchUrl(emailLaunchUri); if (await canLaunchUrl(emailLaunchUri)) {
} else { await launchUrl(emailLaunchUri);
ScaffoldMessenger.of(context).showSnackBar( } else {
SnackBar( ScaffoldMessenger.of(context).showSnackBar(
content: Text( SnackBar(
'Impossible d\'ouvrir le client mail', content: Text(
style: GoogleFonts.merienda(), 'Impossible d\'ouvrir le client mail',
), style: GoogleFonts.merienda(),
), ),
); ),
} );
}, }
), },
_FooterLink( ),
text: 'Signaler un bug', _FooterLink(
onTap: () { text: 'Signaler un bug',
_showBugReportDialog(context); onTap: () {
}, _showBugReportDialog(context);
), },
_FooterLink( ),
text: 'Mentions légales', _FooterLink(
onTap: () { text: 'Mentions légales',
context.go('/legal'); onTap: () {
}, context.go('/legal');
), },
_FooterLink( ),
text: 'Politique de confidentialité', _FooterLink(
onTap: () { text: 'Politique de confidentialité',
context.go('/privacy'); onTap: () {
}, context.go('/privacy');
), },
], ),
), ],
), ),
), ),
], ),
); ],
}, );
); },
);
}, },
), ),
); );
@ -378,6 +403,7 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
/// Dimensions de river_logo_mobile.png (à mettre à jour si l'asset change). /// Dimensions de river_logo_mobile.png (à mettre à jour si l'asset change).
static const int _riverLogoMobileWidth = 600; static const int _riverLogoMobileWidth = 600;
static const int _riverLogoMobileHeight = 1080; static const int _riverLogoMobileHeight = 1080;
/// Fraction de la hauteur de l'image où se termine visuellement le slogan (0 = haut, 1 = bas). /// 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 _sloganEndFraction = 0.42;
static const double _gapBelowSlogan = 12.0; static const double _gapBelowSlogan = 12.0;
@ -388,7 +414,8 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
final h = constraints.maxHeight; final h = constraints.maxHeight;
final w = constraints.maxWidth; final w = constraints.maxWidth;
final imageAspectRatio = _riverLogoMobileHeight / _riverLogoMobileWidth; final imageAspectRatio = _riverLogoMobileHeight / _riverLogoMobileWidth;
final formTop = w * imageAspectRatio * _sloganEndFraction + _gapBelowSlogan; final formTop =
w * imageAspectRatio * _sloganEndFraction + _gapBelowSlogan;
return Stack( return Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [
@ -428,95 +455,115 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
children: [ children: [
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20), padding: const EdgeInsets.symmetric(
horizontal: 24, vertical: 20),
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400), constraints: const BoxConstraints(maxWidth: 400),
child: Form( child: AutofillGroup(
key: _formKey, child: Form(
child: Column( key: _formKey,
mainAxisSize: MainAxisSize.min, child: Column(
children: [ mainAxisSize: MainAxisSize.min,
const SizedBox(height: 16), children: [
CustomAppTextField( const SizedBox(height: 16),
controller: _emailController, CustomAppTextField(
labelText: 'Email', controller: _emailController,
showLabel: false, labelText: 'Email',
hintText: 'Votre adresse email', showLabel: false,
validator: _validateEmail, hintText: 'Votre adresse email',
style: CustomAppTextFieldStyle.lavande, keyboardType: TextInputType.emailAddress,
fieldHeight: 48, autofillHints: const [
fieldWidth: double.infinity, AutofillHints.username,
), AutofillHints.email,
const SizedBox(height: 12), ],
CustomAppTextField( textInputAction: TextInputAction.next,
controller: _passwordController, validator: _validateEmail,
labelText: 'Mot de passe', style: CustomAppTextFieldStyle.lavande,
showLabel: false, fieldHeight: 48,
hintText: 'Votre mot de passe', fieldWidth: double.infinity,
obscureText: true, ),
validator: _validatePassword, const SizedBox(height: 12),
style: CustomAppTextFieldStyle.jaune, CustomAppTextField(
fieldHeight: 48, controller: _passwordController,
fieldWidth: double.infinity, labelText: 'Mot de passe',
), showLabel: false,
if (_errorMessage != null) ...[ hintText: 'Votre mot de passe',
const SizedBox(height: 12), obscureText: true,
Container( autofillHints: const [
padding: const EdgeInsets.all(12), AutofillHints.password
decoration: BoxDecoration( ],
color: Colors.red.shade50, textInputAction: TextInputAction.done,
borderRadius: BorderRadius.circular(10), onFieldSubmitted: _handlePasswordSubmitted,
border: Border.all(color: Colors.red.shade300), validator: _validatePassword,
), style: CustomAppTextFieldStyle.jaune,
child: Row( fieldHeight: 48,
children: [ fieldWidth: double.infinity,
Icon(Icons.error_outline, color: Colors.red.shade700, size: 20), ),
const SizedBox(width: 10), if (_errorMessage != null) ...[
Expanded( const SizedBox(height: 12),
child: Text( Container(
_errorMessage!, padding: const EdgeInsets.all(12),
style: GoogleFonts.merienda(fontSize: 12, color: Colors.red.shade700), 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<LoginScreen> with WidgetsBindingObserver {
text: 'Contact support', text: 'Contact support',
fontSize: 11, fontSize: 11,
onTap: () async { onTap: () async {
final uri = Uri(scheme: 'mailto', path: 'support@supernounou.local'); final uri = Uri(
scheme: 'mailto',
path: 'support@supernounou.local');
if (await canLaunchUrl(uri)) { if (await canLaunchUrl(uri)) {
await launchUrl(uri); await launchUrl(uri);
} else if (context.mounted) { } else if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( 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 {
), ),
); );
} }
} }

View File

@ -10,6 +10,7 @@ enum CustomAppTextFieldStyle {
class CustomAppTextField extends StatefulWidget { class CustomAppTextField extends StatefulWidget {
final TextEditingController controller; final TextEditingController controller;
final FocusNode? focusNode;
final String labelText; final String labelText;
final String hintText; final String hintText;
final double fieldWidth; final double fieldWidth;
@ -26,10 +27,14 @@ class CustomAppTextField extends StatefulWidget {
final double labelFontSize; final double labelFontSize;
final double inputFontSize; final double inputFontSize;
final bool showLabel; final bool showLabel;
final Iterable<String>? autofillHints;
final TextInputAction? textInputAction;
final ValueChanged<String>? onFieldSubmitted;
const CustomAppTextField({ const CustomAppTextField({
super.key, super.key,
required this.controller, required this.controller,
this.focusNode,
required this.labelText, required this.labelText,
this.showLabel = true, this.showLabel = true,
this.hintText = '', this.hintText = '',
@ -46,6 +51,9 @@ class CustomAppTextField extends StatefulWidget {
this.suffixIcon, this.suffixIcon,
this.labelFontSize = 18.0, this.labelFontSize = 18.0,
this.inputFontSize = 18.0, this.inputFontSize = 18.0,
this.autofillHints,
this.textInputAction,
this.onFieldSubmitted,
}); });
@override @override
@ -68,7 +76,7 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
const double fontHeightMultiplier = 1.2; const double fontHeightMultiplier = 1.2;
const double internalVerticalPadding = 16.0; const double internalVerticalPadding = 16.0;
final double dynamicFieldHeight = widget.fieldHeight; final double dynamicFieldHeight = widget.fieldHeight;
return Column( return Column(
@ -90,7 +98,7 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
width: widget.fieldWidth, width: widget.fieldWidth,
height: dynamicFieldHeight, height: dynamicFieldHeight,
child: Stack( child: Stack(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
children: [ children: [
Positioned.fill( Positioned.fill(
child: Image.asset( child: Image.asset(
@ -99,40 +107,49 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
), ),
), ),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0, vertical: 8.0), padding:
const EdgeInsets.symmetric(horizontal: 18.0, vertical: 8.0),
child: TextFormField( child: TextFormField(
controller: widget.controller, controller: widget.controller,
focusNode: widget.focusNode,
obscureText: widget.obscureText, obscureText: widget.obscureText,
keyboardType: widget.keyboardType, keyboardType: widget.keyboardType,
autofillHints: widget.autofillHints,
textInputAction: widget.textInputAction,
onFieldSubmitted: widget.onFieldSubmitted,
enabled: widget.enabled, enabled: widget.enabled,
readOnly: widget.readOnly, readOnly: widget.readOnly,
onTap: widget.onTap, onTap: widget.onTap,
style: GoogleFonts.merienda( style: GoogleFonts.merienda(
fontSize: widget.inputFontSize, fontSize: widget.inputFontSize,
color: widget.enabled ? Colors.black87 : Colors.grey color: widget.enabled ? Colors.black87 : Colors.grey),
),
validator: widget.validator ?? validator: widget.validator ??
(value) { (value) {
if (!widget.enabled || widget.readOnly) return null; if (!widget.enabled || widget.readOnly) return null;
if (widget.isRequired && (value == null || value.isEmpty)) { if (widget.isRequired &&
return 'Ce champ est obligatoire'; (value == null || value.isEmpty)) {
} return 'Ce champ est obligatoire';
return null; }
}, return null;
},
decoration: InputDecoration( decoration: InputDecoration(
hintText: widget.hintText, 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, border: InputBorder.none,
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
suffixIcon: widget.suffixIcon != null suffixIcon: widget.suffixIcon != null
? Padding( ? Padding(
padding: const EdgeInsets.only(right: 0.0), 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, : null,
isDense: true, isDense: true,
), ),
textAlignVertical: TextAlignVertical.center, textAlignVertical: TextAlignVertical.center,
), ),
), ),
], ],
@ -141,4 +158,4 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
], ],
); );
} }
} }

View File

@ -23,26 +23,36 @@ class ImageButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MouseRegion( return SizedBox(
cursor: SystemMouseCursors.click, width: width,
child: GestureDetector( height: height,
onTap: onPressed, child: Semantics(
child: Container( button: true,
width: width, label: text,
height: height, child: TextButton(
decoration: BoxDecoration( onPressed: onPressed,
image: DecorationImage( style: TextButton.styleFrom(
image: AssetImage(bg), padding: EdgeInsets.zero,
fit: BoxFit.fill, tapTargetSize: MaterialTapTargetSize.shrinkWrap,
), mouseCursor: SystemMouseCursors.click,
shape:
const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
), ),
child: Center( child: Ink(
child: Text( decoration: BoxDecoration(
text, image: DecorationImage(
style: GoogleFonts.merienda( image: AssetImage(bg),
color: textColor, fit: BoxFit.fill,
fontSize: fontSize, // Utilisation du paramètre ),
fontWeight: FontWeight.bold, ),
child: Center(
child: Text(
text,
style: GoogleFonts.merienda(
color: textColor,
fontSize: fontSize, // Utilisation du paramètre
fontWeight: FontWeight.bold,
),
), ),
), ),
), ),
@ -50,4 +60,4 @@ class ImageButton extends StatelessWidget {
), ),
); );
} }
} }