feat(inscription): email accusé réception, photos enfants, formulaires identité

- Backend: mail après inscription parent avec n° dossier, UPLOAD_PHOTOS_DIR, réponse API
- Frontend: imageBytes + payload photo, utils email/code postal/téléphone, champs dédiés
- Formulaires admin, login (focus), personal_info, child_card, custom_app_text_field

Made-with: Cursor
This commit is contained in:
2026-03-31 00:04:38 +02:00
parent 110240b682
commit bf05b1d7d7
20 changed files with 1148 additions and 273 deletions
@@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/email_utils.dart';
import 'package:p_tits_pas/widgets/email_text_field.dart';
import 'package:p_tits_pas/widgets/french_phone_field.dart';
class AdminCreateDialog extends StatefulWidget {
final AppUser? initialUser;
@@ -61,11 +63,10 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
String? _validateEmail(String? value) {
final base = _required(value, 'Email');
if (base != null) return base;
final email = value!.trim();
final ok = RegExp(r'^[^@]+@[^@]+\.[^@]+$').hasMatch(email);
if (!ok) return 'Format email invalide';
return null;
if (base != null) {
return base;
}
return validateEmail(value, allowEmpty: true);
}
String? _validatePassword(String? value) {
@@ -78,6 +79,17 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
return null;
}
String? _validateTelephone(String? value) {
if (_isEditMode && (value == null || value.trim().isEmpty)) {
return null;
}
final base = _required(value, 'Téléphone');
if (base != null) {
return base;
}
return validateFrenchNationalPhone(value, allowEmpty: false);
}
Future<void> _submit() async {
if (_isSubmitting) return;
if (!_formKey.currentState!.validate()) return;
@@ -305,13 +317,9 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
}
Widget _buildEmailField() {
return TextFormField(
return EmailTextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
),
label: 'Email',
validator: _validateEmail,
);
}
@@ -346,19 +354,10 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
}
Widget _buildTelephoneField() {
return TextFormField(
return FrenchPhoneTextFormField(
controller: _telephoneController,
keyboardType: TextInputType.phone,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
FrenchPhoneNumberFormatter(),
],
decoration: const InputDecoration(
labelText: 'Téléphone (ex: 06 12 34 56 78)',
border: OutlineInputBorder(),
),
validator: (v) => _required(v, 'Téléphone'),
label: 'Téléphone (ex: 06 12 34 56 78)',
validator: _validateTelephone,
);
}
}
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:p_tits_pas/models/relais_model.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/utils/email_utils.dart';
import 'package:p_tits_pas/widgets/email_text_field.dart';
import 'package:p_tits_pas/widgets/french_phone_field.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/relais_service.dart';
import 'package:p_tits_pas/services/user_service.dart';
@@ -163,11 +165,10 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
String? _validateEmail(String? value) {
final base = _required(value, 'Email');
if (base != null) return base;
final email = value!.trim();
final ok = RegExp(r'^[^@]+@[^@]+\.[^@]+$').hasMatch(email);
if (!ok) return 'Format email invalide';
return null;
if (base != null) {
return base;
}
return validateEmail(value, allowEmpty: true);
}
String? _validatePassword(String? value) {
@@ -185,15 +186,10 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
return null;
}
final base = _required(value, 'Téléphone');
if (base != null) return base;
final digits = normalizePhone(value!);
if (digits.length != 10) {
return 'Le téléphone doit contenir 10 chiffres';
if (base != null) {
return base;
}
if (!digits.startsWith('0')) {
return 'Le téléphone doit commencer par 0';
}
return null;
return validateFrenchNationalPhone(value, allowEmpty: false);
}
String _toTitleCase(String raw) {
@@ -536,14 +532,10 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
}
Widget _buildEmailField() {
return TextFormField(
return EmailTextFormField(
controller: _emailController,
readOnly: widget.readOnly,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
),
label: 'Email',
validator: widget.readOnly ? null : _validateEmail,
);
}
@@ -584,21 +576,10 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
}
Widget _buildTelephoneField() {
return TextFormField(
return FrenchPhoneTextFormField(
controller: _telephoneController,
readOnly: widget.readOnly,
keyboardType: TextInputType.phone,
inputFormatters: widget.readOnly
? null
: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
FrenchPhoneNumberFormatter(),
],
decoration: const InputDecoration(
labelText: 'Téléphone (ex: 06 12 34 56 78)',
border: OutlineInputBorder(),
),
label: 'Téléphone (ex: 06 12 34 56 78)',
validator: widget.readOnly ? null : _validatePhone,
);
}
+158 -81
View File
@@ -5,6 +5,7 @@ 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';
@@ -21,6 +22,10 @@ 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;
@@ -36,22 +41,49 @@ class _LoginPageState extends State<LoginScreen> {
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 ?? '';
final v = value?.trim() ?? '';
if (v.isEmpty) {
return 'Veuillez entrer votre email';
return 'Veuillez entrer votre adresse e-mail.';
}
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(v)) {
return 'Veuillez entrer un email valide';
if (!isValidEmailFormat(v)) {
return 'Ladresse e-mail nest pas valide.';
}
return null;
}
@@ -205,53 +237,75 @@ class _LoginPageState extends State<LoginScreen> {
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: AutofillGroup(
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Champs côte à côte
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: CustomAppTextField(
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,
fieldWidth: double.infinity,
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: CustomAppTextField(
controller: _passwordController,
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(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),
@@ -461,46 +515,68 @@ class _LoginPageState extends State<LoginScreen> {
horizontal: 24, vertical: 20),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: AutofillGroup(
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,
),
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(
@@ -571,6 +647,7 @@ class _LoginPageState extends State<LoginScreen> {
),
),
),
),
Padding(
padding: const EdgeInsets.only(bottom: 12, top: 8),
child: Wrap(
@@ -3,6 +3,7 @@ import 'package:google_fonts/google_fonts.dart';
import 'dart:math' as math; // Pour la rotation du chevron
import 'package:image_picker/image_picker.dart';
import 'dart:io' show File;
import 'package:flutter/foundation.dart' show kIsWeb;
import '../../widgets/hover_relief_widget.dart';
import '../../widgets/child_card_widget.dart';
import '../../widgets/custom_navigation_button.dart';
@@ -152,10 +153,19 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
if (pickedFile != null) {
if (childIndex < registrationData.children.length) {
final oldChild = registrationData.children[childIndex];
final updatedChild = oldChild.copyWith(imageFile: File(pickedFile.path));
final bytes = await pickedFile.readAsBytes();
if (bytes.isEmpty) return;
File? file;
if (!kIsWeb) {
try {
final f = File(pickedFile.path);
if (await f.exists()) file = f;
} catch (_) {}
}
final updatedChild = oldChild.copyWith(imageBytes: bytes, imageFile: file);
registrationData.updateChild(childIndex, updatedChild);
}
}
}
} catch (e) { print("Erreur image: $e"); }
}
@@ -285,13 +295,14 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
// Générer les cartes enfants
for (int index = 0; index < registrationData.children.length; index++) ...[
ChildCardWidget(
key: ValueKey(registrationData.children[index].hashCode),
key: ValueKey('parent_register_child_$index'),
childData: registrationData.children[index],
childIndex: index,
onPickImage: () => _pickImage(index, registrationData),
onClearImage: () => setState(() {
final c = registrationData.children[index];
registrationData.updateChild(index, c.copyWith(imageFile: null));
registrationData.updateChild(
index, c.copyWith(imageFile: null, imageBytes: null));
}),
onDateSelect: () => _selectDate(context, index, registrationData),
onFirstNameChanged: (value) => setState(() {
@@ -392,13 +403,14 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
return Padding(
padding: const EdgeInsets.only(right: 20.0),
child: ChildCardWidget(
key: ValueKey(registrationData.children[index].hashCode), // Utiliser une clé basée sur les données
key: ValueKey('parent_register_child_$index'),
childData: registrationData.children[index],
childIndex: index,
onPickImage: () => _pickImage(index, registrationData),
onClearImage: () => setState(() {
final c = registrationData.children[index];
registrationData.updateChild(index, c.copyWith(imageFile: null));
registrationData.updateChild(
index, c.copyWith(imageFile: null, imageBytes: null));
}),
onDateSelect: () => _selectDate(context, index, registrationData),
onFirstNameChanged: (value) => setState(() {