petitspas/frontend/lib/screens/auth/login_screen.dart
Julien Martin b99745e0fe feat(#112): reprise après refus — dossier complet, email resoumission
- GET/PATCH reprise-dossier enrichis (parents, enfants, motivation, fiche AM)
- Front: lien mail, modale identify login, wizards préremplis, PATCH complet
- Email accusé resoumission aux parents avec n° de dossier
- Fixes préremplissage AM (dates, places, ValueKey étape 2)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 19:19:55 +02:00

870 lines
35 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
import 'package:p_tits_pas/services/bug_report_service.dart';
import 'package:p_tits_pas/utils/email_utils.dart';
import '../../widgets/image_button.dart';
import '../../widgets/custom_app_text_field.dart';
import '../../services/auth_service.dart';
import '../../widgets/auth/change_password_dialog.dart';
import '../../widgets/auth/reprise_identify_dialog.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final GlobalKey<FormFieldState<String>> _emailFormKey =
GlobalKey<FormFieldState<String>>();
late final FocusNode _emailFocus;
late final FocusNode _passwordFocus;
bool _isLoading = false;
String? _errorMessage;
static const double _mobileBreakpoint = 900.0;
/// Une seule fois : évite de relancer `_getImageDimensions()` à chaque rebuild (sinon sur web,
/// tout événement de layout / métriques recréait un Future et pouvait provoquer des erreurs DWDS
/// ou des comportements bizarres pendant la saisie dans les champs).
late final Future<ImageDimensions> _desktopRiverLogoDimensionsFuture;
@override
void initState() {
super.initState();
_desktopRiverLogoDimensionsFuture = _getImageDimensions();
_emailFocus = FocusNode();
_passwordFocus = FocusNode();
_emailFocus.addListener(_onEmailFocusChange);
}
void _onEmailFocusChange() {
if (_emailFocus.hasFocus) {
return;
}
// Reporter au frame suivant : une mise à jour synchrone du controller pendant
// un transfert de focus (Tab) peut casser la navigation au clavier.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _emailFocus.hasFocus) {
return;
}
final normalized = normalizeEmailText(_emailController.text);
if (normalized != _emailController.text) {
_emailController.value = TextEditingValue(
text: normalized,
selection: TextSelection.collapsed(offset: normalized.length),
);
}
_emailFormKey.currentState?.validate();
});
}
@override
void dispose() {
_emailFocus.removeListener(_onEmailFocusChange);
_emailFocus.dispose();
_passwordFocus.dispose();
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
String? _validateEmail(String? value) {
final v = value?.trim() ?? '';
if (v.isEmpty) {
return 'Veuillez entrer votre adresse e-mail.';
}
if (!isValidEmailFormat(v)) {
return 'Ladresse e-mail nest pas valide.';
}
return null;
}
String? _validatePassword(String? value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer votre mot de passe';
}
return null;
}
void _handlePasswordSubmitted(String _) {
if (_isLoading) return;
_handleLogin();
}
Future<void> _openRepriseIdentify() async {
final token = await showDialog<String>(
context: context,
builder: (ctx) => RepriseIdentifyDialog(
initialEmail: _emailController.text.trim(),
),
);
if (!mounted || token == null || token.isEmpty) return;
context.go('/reprise?token=${Uri.encodeComponent(token)}');
}
Widget _buildRepriseDossierLink({double fontSize = 14}) {
return TextButton(
onPressed: _isLoading ? null : _openRepriseIdentify,
child: Text(
'Jai un numéro de dossier',
style: GoogleFonts.merienda(
fontSize: fontSize,
color: const Color(0xFF2D6A4F),
decoration: TextDecoration.underline,
),
),
);
}
/// Gère la connexion de l'utilisateur
Future<void> _handleLogin() async {
// Réinitialiser le message d'erreur
setState(() {
_errorMessage = null;
});
if (!(_formKey.currentState?.validate() ?? false)) {
return;
}
setState(() {
_isLoading = true;
});
try {
// Appeler le service d'authentification
final user = await AuthService.login(
_emailController.text.trim(),
_passwordController.text,
);
if (!mounted) return;
// 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,
barrierDismissible: false,
builder: (context) => const ChangePasswordDialog(),
);
// Si le changement de mot de passe a réussi, rafraîchir l'utilisateur
if (result == true && mounted) {
await AuthService.refreshCurrentUser();
}
}
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.isEmpty ? null : user.role);
} catch (e) {
setState(() {
_isLoading = false;
_errorMessage = e.toString().replaceAll('Exception: ', '');
});
}
}
/// Redirige l'utilisateur selon son rôle (GoRouter : context.go).
void _redirectUserByRole(String? role) {
setState(() => _isLoading = false);
final r = (role ?? '').toLowerCase();
switch (r) {
case 'super_admin':
case 'administrateur':
context.go('/admin-dashboard');
break;
case 'gestionnaire':
context.go('/gestionnaire-dashboard');
break;
case 'parent':
context.go('/parent-dashboard');
break;
case 'assistante_maternelle':
context.go('/am-dashboard');
break;
default:
context.go('/home');
}
}
@override
Widget build(BuildContext context) {
final isMobile = MediaQuery.of(context).size.width < _mobileBreakpoint;
if (isMobile) {
return Scaffold(
backgroundColor: Colors.transparent,
body: _buildMobileLayout(context),
);
}
return Scaffold(
backgroundColor: Colors.transparent,
body: LayoutBuilder(
builder: (context, constraints) {
final w = constraints.maxWidth;
final h = constraints.maxHeight;
return FutureBuilder<ImageDimensions>(
future: _desktopRiverLogoDimensionsFuture,
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;
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,
),
),
// 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(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Champs côte à côte
FocusTraversalGroup(
policy: OrderedTraversalPolicy(),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: FocusTraversalOrder(
order: const NumericFocusOrder(1),
child: CustomAppTextField(
formFieldKey: _emailFormKey,
controller: _emailController,
focusNode: _emailFocus,
labelText: 'Email',
hintText: 'Votre adresse email',
keyboardType:
TextInputType.emailAddress,
autocorrect: false,
enableSuggestions: false,
autofillHints: const [
AutofillHints.username,
AutofillHints.email,
],
inputFormatters: const [
EmailMaxLengthFormatter(),
],
textInputAction: TextInputAction.next,
onFieldSubmitted: (_) =>
_passwordFocus.requestFocus(),
validator: _validateEmail,
style:
CustomAppTextFieldStyle.lavande,
fieldHeight: 53,
fieldWidth: double.infinity,
),
),
),
const SizedBox(width: 20),
Expanded(
child: FocusTraversalOrder(
order: const NumericFocusOrder(2),
child: CustomAppTextField(
controller: _passwordController,
focusNode: _passwordFocus,
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,
fieldWidth: double.infinity,
),
),
),
],
),
),
const SizedBox(height: 20),
// Message d'erreur
if (_errorMessage != null)
Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 15),
decoration: BoxDecoration(
color: Colors.red[50],
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.red[300]!),
),
child: Row(
children: [
Icon(Icons.error_outline,
color: Colors.red[700], size: 20),
const SizedBox(width: 10),
Expanded(
child: Text(
_errorMessage!,
style: GoogleFonts.merienda(
fontSize: 12,
color: Colors.red[700],
),
),
),
],
),
),
// Bouton centré
Center(
child: _isLoading
? const CircularProgressIndicator()
: ImageButton(
bg: 'assets/images/bg_green.png',
width: 300,
height: 40,
text: 'Se connecter',
textColor: const Color(0xFF2D6A4F),
onPressed: _handleLogin,
),
),
const SizedBox(height: 10),
// Lien mot de passe oublié
Center(
child: TextButton(
onPressed: () {
context.go('/forgot-password');
},
child: Text(
'Mot de passe oublié ?',
style: GoogleFonts.merienda(
fontSize: 14,
color: const Color(0xFF2D6A4F),
decoration: TextDecoration.underline,
),
),
),
),
const SizedBox(height: 20),
// Lien de création de compte (version originale)
Center(
child: TextButton(
onPressed: () {
context.go('/register-choice');
},
child: Text(
'Créer un compte',
style: GoogleFonts.merienda(
fontSize: 16,
color: const Color(0xFF2D6A4F),
decoration: TextDecoration.underline,
),
),
),
),
Center(child: _buildRepriseDossierLink()),
],
),
),
),
),
),
// 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');
},
),
],
),
),
),
],
);
},
);
},
),
);
}
/// 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;
Widget _buildMobileLayout(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final h = constraints.maxHeight;
final w = constraints.maxWidth;
final imageAspectRatio = _riverLogoMobileHeight / _riverLogoMobileWidth;
final formTop =
w * imageAspectRatio * _sloganEndFraction + _gapBelowSlogan;
return Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: Image.asset(
'assets/images/paper2.png',
fit: BoxFit.cover,
repeat: ImageRepeat.repeat,
),
),
Positioned(
top: 0,
left: 0,
right: 0,
height: h * 1.2,
child: OverflowBox(
alignment: Alignment.topCenter,
minWidth: w,
maxWidth: w,
minHeight: 0,
maxHeight: h * 2.5,
child: Image.asset(
'assets/images/river_logo_mobile.png',
width: w,
fit: BoxFit.fitWidth,
),
),
),
Positioned(
top: formTop,
left: 0,
right: 0,
bottom: 0,
child: SafeArea(
top: false,
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(
horizontal: 24, vertical: 20),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: AutofillGroup(
child: Form(
key: _formKey,
child: FocusTraversalGroup(
policy: OrderedTraversalPolicy(),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
FocusTraversalOrder(
order: const NumericFocusOrder(1),
child: CustomAppTextField(
formFieldKey: _emailFormKey,
controller: _emailController,
focusNode: _emailFocus,
labelText: 'Email',
showLabel: false,
hintText: 'Votre adresse email',
keyboardType:
TextInputType.emailAddress,
autocorrect: false,
enableSuggestions: false,
autofillHints: const [
AutofillHints.username,
AutofillHints.email,
],
inputFormatters: const [
EmailMaxLengthFormatter(),
],
textInputAction: TextInputAction.next,
onFieldSubmitted: (_) =>
_passwordFocus.requestFocus(),
validator: _validateEmail,
style:
CustomAppTextFieldStyle.lavande,
fieldHeight: 48,
fieldWidth: double.infinity,
),
),
const SizedBox(height: 12),
FocusTraversalOrder(
order: const NumericFocusOrder(2),
child: CustomAppTextField(
controller: _passwordController,
focusNode: _passwordFocus,
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: () => context.go('/forgot-password'),
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,
),
),
),
_buildRepriseDossierLink(),
],
),
),
),
),
),
),
),
Padding(
padding: const EdgeInsets.only(bottom: 12, top: 8),
child: Wrap(
alignment: WrapAlignment.center,
runSpacing: 6,
spacing: 4,
children: [
_FooterLink(
text: 'Contact support',
fontSize: 11,
onTap: () async {
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())),
);
}
},
),
_FooterLink(
text: 'Signaler un bug',
fontSize: 11,
onTap: () => _showBugReportDialog(context),
),
_FooterLink(
text: 'Mentions légales',
fontSize: 11,
onTap: () => context.go('/legal'),
),
_FooterLink(
text: 'Politique de confidentialité',
fontSize: 11,
onTap: () => context.go('/privacy'),
),
],
),
),
],
),
),
),
],
);
},
);
}
void _showBugReportDialog(BuildContext context) {
final TextEditingController controller = TextEditingController();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(
'Signaler un bug',
style: GoogleFonts.merienda(),
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: controller,
maxLines: 5,
decoration: InputDecoration(
hintText: 'Décrivez le problème rencontré...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
'Annuler',
style: GoogleFonts.merienda(),
),
),
TextButton(
onPressed: () async {
if (controller.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Veuillez décrire le problème',
style: GoogleFonts.merienda(),
),
),
);
return;
}
try {
await BugReportService.sendReport(controller.text);
if (context.mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Rapport envoyé avec succès',
style: GoogleFonts.merienda(),
),
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Erreur lors de l\'envoi du rapport',
style: GoogleFonts.merienda(),
),
),
);
}
}
},
child: Text(
'Envoyer',
style: GoogleFonts.merienda(),
),
),
],
),
);
}
Future<ImageDimensions> _getImageDimensions() async {
final image = Image.asset('assets/images/river_logo_desktop.png');
final completer = Completer<ImageDimensions>();
image.image.resolve(const ImageConfiguration()).addListener(
ImageStreamListener((info, _) {
completer.complete(ImageDimensions(
width: info.image.width.toDouble(),
height: info.image.height.toDouble(),
));
}),
);
return completer.future;
}
}
class ImageDimensions {
final double width;
final double height;
ImageDimensions({required this.width, required this.height});
}
// ───────────────────────────────────────────────────────────────
// Lien du pied de page
// ───────────────────────────────────────────────────────────────
class _FooterLink extends StatelessWidget {
final String text;
final VoidCallback onTap;
final double fontSize;
const _FooterLink({
required this.text,
required this.onTap,
this.fontSize = 14.0,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: fontSize > 12 ? 8.0 : 4.0),
child: Text(
text,
style: GoogleFonts.merienda(
fontSize: fontSize,
color: Colors.black87,
decoration: TextDecoration.underline,
),
),
),
);
}
}