fix(inscription-am): étape 1/2 obligatoires alignées API (min 2 car., photo)

- Étape 1 : prénom/nom min 2 caractères (minPersonNameLength sur PersonalInfoFormScreen pour AM)

- Étape 2 : ville/pays naissance min 2 ; photo réelle + consentement obligatoires ; isStep2Complete strict

- registerAM : lieu naissance trim + toujours envoyer photo_base64 ; lieu_* non null si valide

- Étape 4 : blocage envoi si isRegistrationComplete faux

Made-with: Cursor
This commit is contained in:
MARTIN Julien 2026-04-13 13:32:25 +02:00
parent b4b44cb1b9
commit d550e57cb8
6 changed files with 91 additions and 20 deletions

View File

@ -106,11 +106,11 @@ class AmRegistrationData extends ChangeNotifier {
// --- Getters for validation or display --- // --- Getters for validation or display ---
bool get isStep1Complete => bool get isStep1Complete =>
firstName.isNotEmpty && firstName.trim().length >= 2 &&
lastName.isNotEmpty && lastName.trim().length >= 2 &&
streetAddress.isNotEmpty && // Modifié streetAddress.isNotEmpty &&
postalCode.isNotEmpty && // Nouveau postalCode.isNotEmpty &&
city.isNotEmpty && // Nouveau city.isNotEmpty &&
phone.isNotEmpty && phone.isNotEmpty &&
email.isNotEmpty; email.isNotEmpty;
// password n'est pas requis à l'inscription (défini après validation par lien email) // password n'est pas requis à l'inscription (défini après validation par lien email)
@ -123,11 +123,12 @@ class AmRegistrationData extends ChangeNotifier {
!photoPath!.startsWith('assets/')); !photoPath!.startsWith('assets/'));
bool get isStep2Complete => bool get isStep2Complete =>
(_hasUserPhoto ? photoConsent == true : true) && _hasUserPhoto &&
photoConsent == true &&
dateOfBirth != null && dateOfBirth != null &&
birthCity.isNotEmpty && birthCity.trim().length >= 2 &&
birthCountry.isNotEmpty && birthCountry.trim().length >= 2 &&
nir.isNotEmpty && // Basic check, could add validation nir.isNotEmpty &&
agrementNumber.isNotEmpty && agrementNumber.isNotEmpty &&
agreementDate != null && agreementDate != null &&
capacity != null && capacity != null &&

View File

@ -28,6 +28,7 @@ class AmRegisterStep1Screen extends StatelessWidget {
title: 'Vos informations personnelles', title: 'Vos informations personnelles',
cardColor: CardColorHorizontal.blue, cardColor: CardColorHorizontal.blue,
initialData: initialData, initialData: initialData,
minPersonNameLength: 2,
previousRoute: '/register-choice', previousRoute: '/register-choice',
onSubmit: (data, {hasSecondPerson, sameAddress}) { onSubmit: (data, {hasSecondPerson, sameAddress}) {
registrationData.updateIdentityInfo( registrationData.updateIdentityInfo(

View File

@ -27,6 +27,19 @@ class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
Future<void> _submitAMRegistration(AmRegistrationData registrationData) async { Future<void> _submitAMRegistration(AmRegistrationData registrationData) async {
if (_isSubmitting) return; if (_isSubmitting) return;
if (!registrationData.isRegistrationComplete) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Le dossier est incomplet. Vérifiez chaque étape (photo, consentement, lieux de naissance, etc.).',
style: GoogleFonts.merienda(fontSize: 14),
),
backgroundColor: Colors.red.shade700,
),
);
return;
}
setState(() => _isSubmitting = true); setState(() => _isSubmitting = true);
try { try {
await AuthService.registerAM(registrationData); await AuthService.registerAM(registrationData);

View File

@ -168,6 +168,13 @@ class AuthService {
if (data.placesAvailable == null) { if (data.placesAvailable == null) {
throw Exception('Le nombre de places disponibles est requis.'); throw Exception('Le nombre de places disponibles est requis.');
} }
final lieuVille = data.birthCity.trim();
final lieuPays = data.birthCountry.trim();
if (lieuVille.length < 2 || lieuPays.length < 2) {
throw Exception(
'La ville et le pays de naissance sont requis (au moins 2 caractères chacun).',
);
}
String? photoBase64; String? photoBase64;
String? photoFilename; String? photoFilename;
@ -193,6 +200,10 @@ class AuthService {
} }
} }
if (photoBase64 == null || photoBase64.isEmpty) {
throw Exception('Une photo de profil est requise pour finaliser linscription.');
}
final body = <String, dynamic>{ final body = <String, dynamic>{
'email': data.email, 'email': data.email,
'prenom': data.firstName, 'prenom': data.firstName,
@ -201,14 +212,14 @@ class AuthService {
'adresse': data.streetAddress.isNotEmpty ? data.streetAddress : null, 'adresse': data.streetAddress.isNotEmpty ? data.streetAddress : null,
'code_postal': data.postalCode.isNotEmpty ? data.postalCode : null, 'code_postal': data.postalCode.isNotEmpty ? data.postalCode : null,
'ville': data.city.isNotEmpty ? data.city : null, 'ville': data.city.isNotEmpty ? data.city : null,
if (photoBase64 != null) 'photo_base64': photoBase64, 'photo_base64': photoBase64,
if (photoBase64 != null) 'photo_filename': photoFilename ?? 'photo_am.jpg', 'photo_filename': photoFilename ?? 'photo_am.jpg',
'consentement_photo': data.photoConsent, 'consentement_photo': data.photoConsent,
'date_naissance': data.dateOfBirth != null 'date_naissance': data.dateOfBirth != null
? '${data.dateOfBirth!.year}-${data.dateOfBirth!.month.toString().padLeft(2, '0')}-${data.dateOfBirth!.day.toString().padLeft(2, '0')}' ? '${data.dateOfBirth!.year}-${data.dateOfBirth!.month.toString().padLeft(2, '0')}-${data.dateOfBirth!.day.toString().padLeft(2, '0')}'
: null, : null,
'lieu_naissance_ville': data.birthCity.isNotEmpty ? data.birthCity : null, 'lieu_naissance_ville': lieuVille,
'lieu_naissance_pays': data.birthCountry.isNotEmpty ? data.birthCountry : null, 'lieu_naissance_pays': lieuPays,
'nir': normalizeNir(data.nir), 'nir': normalizeNir(data.nir),
'numero_agrement': data.agrementNumber, 'numero_agrement': data.agrementNumber,
'date_agrement': 'date_agrement':

View File

@ -55,6 +55,8 @@ class PersonalInfoFormScreen extends StatefulWidget {
final PersonalInfoData? referenceAddressData; // Pour pré-remplir si "même adresse" final PersonalInfoData? referenceAddressData; // Pour pré-remplir si "même adresse"
final bool embedContentOnly; // Si true, affiche seulement la carte (sans scaffold/fond/titre) final bool embedContentOnly; // Si true, affiche seulement la carte (sans scaffold/fond/titre)
final VoidCallback? onEdit; // Callback pour le bouton d'édition (si affiché) final VoidCallback? onEdit; // Callback pour le bouton d'édition (si affiché)
/// Si non null (ex. 2 pour inscription AM), nom/prénom : trim + longueur minimale (aligné API).
final int? minPersonNameLength;
const PersonalInfoFormScreen({ const PersonalInfoFormScreen({
super.key, super.key,
@ -72,6 +74,7 @@ class PersonalInfoFormScreen extends StatefulWidget {
this.referenceAddressData, this.referenceAddressData,
this.embedContentOnly = false, this.embedContentOnly = false,
this.onEdit, this.onEdit,
this.minPersonNameLength,
}); });
@override @override
@ -214,6 +217,16 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
return validateEmail(value, allowEmpty: true); return validateEmail(value, allowEmpty: true);
} }
/// Prénom / nom avec longueur minimale (ex. inscription AM, aligné `MinLength` API).
String? _validatePersonNameWithMinLength(String? value) {
final min = widget.minPersonNameLength;
if (min == null) return null;
final t = (value ?? '').trim();
if (t.isEmpty) return 'Ce champ est obligatoire';
if (t.length < min) return 'Au moins $min caractères';
return null;
}
/// Téléphone / e-mail du parent 2 : pas de validation si « Ajouter Parent 2 » est désactivé. /// Téléphone / e-mail du parent 2 : pas de validation si « Ajouter Parent 2 » est désactivé.
String? _validateFrenchPhoneIfSecondParentNeeded(String? value) { String? _validateFrenchPhoneIfSecondParentNeeded(String? value) {
if (widget.showSecondPersonToggle && !_fieldsEnabled) { if (widget.showSecondPersonToggle && !_fieldsEnabled) {
@ -837,6 +850,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
focusOrder: _focusLastName, focusOrder: _focusLastName,
focusNode: _lastNameFocus, focusNode: _lastNameFocus,
fieldValidator: widget.minPersonNameLength != null
? _validatePersonNameWithMinLength
: null,
), ),
), ),
const SizedBox(width: 20), const SizedBox(width: 20),
@ -849,6 +865,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
focusOrder: _focusFirstName, focusOrder: _focusFirstName,
focusNode: _firstNameFocus, focusNode: _firstNameFocus,
fieldValidator: widget.minPersonNameLength != null
? _validatePersonNameWithMinLength
: null,
), ),
), ),
], ],
@ -1106,6 +1125,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
focusOrder: _focusLastName, focusOrder: _focusLastName,
focusNode: _lastNameFocus, focusNode: _lastNameFocus,
fieldValidator: widget.minPersonNameLength != null
? _validatePersonNameWithMinLength
: null,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -1118,6 +1140,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
focusOrder: _focusFirstName, focusOrder: _focusFirstName,
focusNode: _firstNameFocus, focusNode: _firstNameFocus,
fieldValidator: widget.minPersonNameLength != null
? _validatePersonNameWithMinLength
: null,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),

View File

@ -189,6 +189,20 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
super.dispose(); super.dispose();
} }
String? _validateBirthCity(String? v) {
final t = (v ?? '').trim();
if (t.isEmpty) return 'Ville requise';
if (t.length < 2) return 'Au moins 2 caractères';
return null;
}
String? _validateBirthCountry(String? v) {
final t = (v ?? '').trim();
if (t.isEmpty) return 'Pays requis';
if (t.length < 2) return 'Au moins 2 caractères';
return null;
}
String? _validatePlacesAvailable(String? v) { String? _validatePlacesAvailable(String? v) {
if (v == null || v.isEmpty) return 'Places disponibles requises'; if (v == null || v.isEmpty) return 'Places disponibles requises';
final n = int.tryParse(v); final n = int.tryParse(v);
@ -281,7 +295,13 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
_applyPlaceNameFormat(_birthCityController); _applyPlaceNameFormat(_birthCityController);
_applyPlaceNameFormat(_birthCountryController); _applyPlaceNameFormat(_birthCountryController);
if (_formKey.currentState!.validate()) { if (_formKey.currentState!.validate()) {
if (_hasRealPhoto && !_photoConsent) { if (!_hasRealPhoto) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Veuillez ajouter une photo de profil.')),
);
return;
}
if (!_photoConsent) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Veuillez accepter le consentement photo pour continuer.')), const SnackBar(content: Text('Veuillez accepter le consentement photo pour continuer.')),
); );
@ -295,8 +315,8 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
photoFilename: _photoFilename, photoFilename: _photoFilename,
photoConsent: _photoConsent, photoConsent: _photoConsent,
dateOfBirth: _selectedDate, dateOfBirth: _selectedDate,
birthCity: _birthCityController.text, birthCity: _birthCityController.text.trim(),
birthCountry: _birthCountryController.text, birthCountry: _birthCountryController.text.trim(),
nir: normalizeNir(_nirController.text), nir: normalizeNir(_nirController.text),
agrementNumber: _agrementController.text, agrementNumber: _agrementController.text,
agreementDate: _selectedAgreementDate, agreementDate: _selectedAgreementDate,
@ -753,7 +773,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
label: 'Ville de naissance', label: 'Ville de naissance',
controller: _birthCityController, controller: _birthCityController,
hint: 'Ex. Ajaccio, Paris, Casablanca…', hint: 'Ex. Ajaccio, Paris, Casablanca…',
validator: (v) => v!.isEmpty ? 'Ville requise' : null, validator: _validateBirthCity,
focusNode: _birthCityFocus, focusNode: _birthCityFocus,
), ),
SizedBox(height: verticalSpacing), SizedBox(height: verticalSpacing),
@ -762,7 +782,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
label: 'Pays de naissance', label: 'Pays de naissance',
controller: _birthCountryController, controller: _birthCountryController,
hint: 'Ex. France, Maroc…', hint: 'Ex. France, Maroc…',
validator: (v) => v!.isEmpty ? 'Pays requis' : null, validator: _validateBirthCountry,
focusNode: _birthCountryFocus, focusNode: _birthCountryFocus,
), ),
], ],
@ -875,7 +895,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
label: 'Ville de naissance', label: 'Ville de naissance',
controller: _birthCityController, controller: _birthCityController,
hint: 'Ex. Ajaccio, Paris, Casablanca…', hint: 'Ex. Ajaccio, Paris, Casablanca…',
validator: (v) => v!.isEmpty ? 'Ville requise' : null, validator: _validateBirthCity,
focusNode: _birthCityFocus, focusNode: _birthCityFocus,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -885,7 +905,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
label: 'Pays de naissance', label: 'Pays de naissance',
controller: _birthCountryController, controller: _birthCountryController,
hint: 'Ex. France, Maroc…', hint: 'Ex. France, Maroc…',
validator: (v) => v!.isEmpty ? 'Pays requis' : null, validator: _validateBirthCountry,
focusNode: _birthCountryFocus, focusNode: _birthCountryFocus,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),