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:
2026-02-24 23:27:04 +01:00
co-authored by Cursor
parent 80d69a5463
commit a4e6cfc50e
3 changed files with 306 additions and 227 deletions
+242 -190
View File
@@ -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<LoginScreen> with WidgetsBindingObserver {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _isLoading = false;
String? _errorMessage;
@@ -63,6 +64,11 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
return null;
}
void _handlePasswordSubmitted(String _) {
if (_isLoading) return;
_handleLogin();
}
/// Gère la connexion de l'utilisateur
Future<void> _handleLogin() async {
// 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
if (user.changementMdpObligatoire) {
if (!mounted) return;
// Afficher la modale de changement de mot de passe (non-dismissible)
final result = await showDialog<bool>(
context: context,
@@ -106,6 +112,9 @@ class _LoginPageState extends State<LoginScreen> 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<LoginScreen> 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<LoginScreen> 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<LoginScreen> 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<LoginScreen> with WidgetsBindingObserver {
],
),
const SizedBox(height: 20),
// Message d'erreur
if (_errorMessage != null)
Container(
@@ -242,7 +265,8 @@ class _LoginPageState extends State<LoginScreen> 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<LoginScreen> with WidgetsBindingObserver {
],
),
),
// Bouton centré
Center(
child: _isLoading
@@ -309,67 +333,68 @@ class _LoginPageState extends State<LoginScreen> 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<LoginScreen> 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<LoginScreen> 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<LoginScreen> 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<LoginScreen> 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 {
),
);
}
}
}