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 ---
bool get isStep1Complete =>
firstName.isNotEmpty &&
lastName.isNotEmpty &&
streetAddress.isNotEmpty && // Modifié
postalCode.isNotEmpty && // Nouveau
city.isNotEmpty && // Nouveau
firstName.trim().length >= 2 &&
lastName.trim().length >= 2 &&
streetAddress.isNotEmpty &&
postalCode.isNotEmpty &&
city.isNotEmpty &&
phone.isNotEmpty &&
email.isNotEmpty;
// 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/'));
bool get isStep2Complete =>
(_hasUserPhoto ? photoConsent == true : true) &&
_hasUserPhoto &&
photoConsent == true &&
dateOfBirth != null &&
birthCity.isNotEmpty &&
birthCountry.isNotEmpty &&
nir.isNotEmpty && // Basic check, could add validation
birthCity.trim().length >= 2 &&
birthCountry.trim().length >= 2 &&
nir.isNotEmpty &&
agrementNumber.isNotEmpty &&
agreementDate != null &&
capacity != null &&

View File

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

View File

@ -27,6 +27,19 @@ class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
Future<void> _submitAMRegistration(AmRegistrationData registrationData) async {
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);
try {
await AuthService.registerAM(registrationData);

View File

@ -168,6 +168,13 @@ class AuthService {
if (data.placesAvailable == null) {
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? 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>{
'email': data.email,
'prenom': data.firstName,
@ -201,14 +212,14 @@ class AuthService {
'adresse': data.streetAddress.isNotEmpty ? data.streetAddress : null,
'code_postal': data.postalCode.isNotEmpty ? data.postalCode : null,
'ville': data.city.isNotEmpty ? data.city : null,
if (photoBase64 != null) 'photo_base64': photoBase64,
if (photoBase64 != null) 'photo_filename': photoFilename ?? 'photo_am.jpg',
'photo_base64': photoBase64,
'photo_filename': photoFilename ?? 'photo_am.jpg',
'consentement_photo': data.photoConsent,
'date_naissance': data.dateOfBirth != null
? '${data.dateOfBirth!.year}-${data.dateOfBirth!.month.toString().padLeft(2, '0')}-${data.dateOfBirth!.day.toString().padLeft(2, '0')}'
: null,
'lieu_naissance_ville': data.birthCity.isNotEmpty ? data.birthCity : null,
'lieu_naissance_pays': data.birthCountry.isNotEmpty ? data.birthCountry : null,
'lieu_naissance_ville': lieuVille,
'lieu_naissance_pays': lieuPays,
'nir': normalizeNir(data.nir),
'numero_agrement': data.agrementNumber,
'date_agrement':

View File

@ -55,6 +55,8 @@ class PersonalInfoFormScreen extends StatefulWidget {
final PersonalInfoData? referenceAddressData; // Pour pré-remplir si "même adresse"
final bool embedContentOnly; // Si true, affiche seulement la carte (sans scaffold/fond/titre)
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({
super.key,
@ -72,6 +74,7 @@ class PersonalInfoFormScreen extends StatefulWidget {
this.referenceAddressData,
this.embedContentOnly = false,
this.onEdit,
this.minPersonNameLength,
});
@override
@ -214,6 +217,16 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
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é.
String? _validateFrenchPhoneIfSecondParentNeeded(String? value) {
if (widget.showSecondPersonToggle && !_fieldsEnabled) {
@ -837,6 +850,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
enabled: _fieldsEnabled,
focusOrder: _focusLastName,
focusNode: _lastNameFocus,
fieldValidator: widget.minPersonNameLength != null
? _validatePersonNameWithMinLength
: null,
),
),
const SizedBox(width: 20),
@ -849,6 +865,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
enabled: _fieldsEnabled,
focusOrder: _focusFirstName,
focusNode: _firstNameFocus,
fieldValidator: widget.minPersonNameLength != null
? _validatePersonNameWithMinLength
: null,
),
),
],
@ -1106,6 +1125,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
enabled: _fieldsEnabled,
focusOrder: _focusLastName,
focusNode: _lastNameFocus,
fieldValidator: widget.minPersonNameLength != null
? _validatePersonNameWithMinLength
: null,
),
const SizedBox(height: 12),
@ -1118,6 +1140,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
enabled: _fieldsEnabled,
focusOrder: _focusFirstName,
focusNode: _firstNameFocus,
fieldValidator: widget.minPersonNameLength != null
? _validatePersonNameWithMinLength
: null,
),
const SizedBox(height: 12),

View File

@ -189,6 +189,20 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
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) {
if (v == null || v.isEmpty) return 'Places disponibles requises';
final n = int.tryParse(v);
@ -281,7 +295,13 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
_applyPlaceNameFormat(_birthCityController);
_applyPlaceNameFormat(_birthCountryController);
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(
const SnackBar(content: Text('Veuillez accepter le consentement photo pour continuer.')),
);
@ -295,8 +315,8 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
photoFilename: _photoFilename,
photoConsent: _photoConsent,
dateOfBirth: _selectedDate,
birthCity: _birthCityController.text,
birthCountry: _birthCountryController.text,
birthCity: _birthCityController.text.trim(),
birthCountry: _birthCountryController.text.trim(),
nir: normalizeNir(_nirController.text),
agrementNumber: _agrementController.text,
agreementDate: _selectedAgreementDate,
@ -753,7 +773,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
label: 'Ville de naissance',
controller: _birthCityController,
hint: 'Ex. Ajaccio, Paris, Casablanca…',
validator: (v) => v!.isEmpty ? 'Ville requise' : null,
validator: _validateBirthCity,
focusNode: _birthCityFocus,
),
SizedBox(height: verticalSpacing),
@ -762,7 +782,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
label: 'Pays de naissance',
controller: _birthCountryController,
hint: 'Ex. France, Maroc…',
validator: (v) => v!.isEmpty ? 'Pays requis' : null,
validator: _validateBirthCountry,
focusNode: _birthCountryFocus,
),
],
@ -875,7 +895,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
label: 'Ville de naissance',
controller: _birthCityController,
hint: 'Ex. Ajaccio, Paris, Casablanca…',
validator: (v) => v!.isEmpty ? 'Ville requise' : null,
validator: _validateBirthCity,
focusNode: _birthCityFocus,
),
const SizedBox(height: 12),
@ -885,7 +905,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
label: 'Pays de naissance',
controller: _birthCountryController,
hint: 'Ex. France, Maroc…',
validator: (v) => v!.isEmpty ? 'Pays requis' : null,
validator: _validateBirthCountry,
focusNode: _birthCountryFocus,
),
const SizedBox(height: 12),