feat(inscription-am): places disponibles, UI étape 2, plafond capacité 4 côté app

- POST /auth/register/am : places_disponibles, enregistrement place_disponible, contrôle ≤ capacité

- Formulaire pro AM : places sur la ligne capacité, mobile un champ par ligne, carte desktop plus compacte (espacements, polices, labelFieldSpacing)

- Capacité et places validées jusqu’à 4 dans l’app (kAmCapaciteAccueilMax) ; hint NIR « 13 chiffres + clé »

- Scripts d’inscription AM (Node + smoke shell) : places_disponibles

Made-with: Cursor
This commit is contained in:
2026-04-13 13:20:26 +02:00
parent 58607cdbc9
commit b4b44cb1b9
12 changed files with 163 additions and 68 deletions
+12 -2
View File
@@ -1,5 +1,8 @@
import 'package:flutter/foundation.dart';
/// Accueil simultané : plafond légal courant en France pour une AM agréée (enfants de moins de 3 ans).
const int kAmCapaciteAccueilMax = 4;
class AmRegistrationData extends ChangeNotifier {
// Step 1: Identity Info
String firstName = '';
@@ -27,6 +30,8 @@ class AmRegistrationData extends ChangeNotifier {
/// Date d'obtention de l'agrément — obligatoire (API `date_agrement`).
DateTime? agreementDate;
int? capacity; // Number of children the AM can look after
/// Places libres actuellement (0 ≤ valeur ≤ capacité) — API `places_disponibles`.
int? placesAvailable;
// Step 3: Presentation & CGU
String presentationText = '';
@@ -72,6 +77,7 @@ class AmRegistrationData extends ChangeNotifier {
String? agrementNumber,
DateTime? agreementDate,
int? capacity,
int? placesAvailable,
}) {
this.photoPath = photoPath;
this.photoBytes = photoBytes;
@@ -85,6 +91,7 @@ class AmRegistrationData extends ChangeNotifier {
this.agrementNumber = agrementNumber ?? this.agrementNumber;
this.agreementDate = agreementDate ?? this.agreementDate;
this.capacity = capacity ?? this.capacity;
this.placesAvailable = placesAvailable ?? this.placesAvailable;
notifyListeners();
}
@@ -125,7 +132,10 @@ class AmRegistrationData extends ChangeNotifier {
agreementDate != null &&
capacity != null &&
capacity! >= 1 &&
capacity! <= 10;
capacity! <= kAmCapaciteAccueilMax &&
placesAvailable != null &&
placesAvailable! >= 0 &&
placesAvailable! <= capacity!;
bool get isStep3Complete =>
// presentationText is optional as per CDC (message au gestionnaire)
@@ -142,7 +152,7 @@ class AmRegistrationData extends ChangeNotifier {
'phone: $phone, email: $email, '
// 'photoPath: $photoPath, photoConsent: $photoConsent, ' // Commenté car déplacé/modifié
'dateOfBirth: $dateOfBirth, birthCity: $birthCity, birthCountry: $birthCountry, '
'nir: $nir, agrementNumber: $agrementNumber, agreementDate: $agreementDate, capacity: $capacity, '
'nir: $nir, agrementNumber: $agrementNumber, agreementDate: $agreementDate, capacity: $capacity, placesAvailable: $placesAvailable, '
'photoPath (step2): $photoPath, photoConsent (step2): $photoConsent, '
'presentationText: $presentationText, cguAccepted: $cguAccepted)';
}
@@ -25,6 +25,7 @@ class AmRegisterStep2Screen extends StatelessWidget {
agrementNumber: registrationData.agrementNumber,
agreementDate: registrationData.agreementDate,
capacity: registrationData.capacity,
placesAvailable: registrationData.placesAvailable,
);
return ProfessionalInfoFormScreen(
@@ -46,6 +47,7 @@ class AmRegisterStep2Screen extends StatelessWidget {
agrementNumber: data.agrementNumber,
agreementDate: data.agreementDate,
capacity: data.capacity,
placesAvailable: data.placesAvailable,
);
context.go('/am-register-step3');
},
@@ -206,6 +206,7 @@ class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
agrementNumber: data.agrementNumber,
agreementDate: data.agreementDate,
capacity: data.capacity,
placesAvailable: data.placesAvailable,
photoConsent: data.photoConsent,
),
onSubmit: (d) {},
+4
View File
@@ -165,6 +165,9 @@ class AuthService {
if (data.agreementDate == null) {
throw Exception('La date d\'obtention de l\'agrément est requise.');
}
if (data.placesAvailable == null) {
throw Exception('Le nombre de places disponibles est requis.');
}
String? photoBase64;
String? photoFilename;
@@ -211,6 +214,7 @@ class AuthService {
'date_agrement':
'${data.agreementDate!.year}-${data.agreementDate!.month.toString().padLeft(2, '0')}-${data.agreementDate!.day.toString().padLeft(2, '0')}',
'capacite_accueil': data.capacity ?? 1,
'places_disponibles': data.placesAvailable!,
'biographie': data.presentationText.isNotEmpty ? data.presentationText : null,
'acceptation_cgu': data.cguAccepted,
'acceptation_privacy': data.cguAccepted,
@@ -27,6 +27,8 @@ class CustomAppTextField extends StatefulWidget {
final IconData? suffixIcon;
final double labelFontSize;
final double inputFontSize;
/// Espace vertical entre le libellé et la zone de saisie.
final double labelFieldSpacing;
final bool showLabel;
final Iterable<String>? autofillHints;
final TextInputAction? textInputAction;
@@ -56,6 +58,7 @@ class CustomAppTextField extends StatefulWidget {
this.suffixIcon,
this.labelFontSize = 18.0,
this.inputFontSize = 18.0,
this.labelFieldSpacing = 6.0,
this.autofillHints,
this.textInputAction,
this.onFieldSubmitted,
@@ -105,7 +108,7 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 6),
SizedBox(height: widget.labelFieldSpacing),
],
// Pas de hauteur fixe sur le TextFormField : le message derreur du
// validateur saffiche en dessous ; un SizedBox fixe le masquait.
+4 -1
View File
@@ -18,12 +18,13 @@ class NirTextField extends StatelessWidget {
final bool enabled;
final bool readOnly;
final CustomAppTextFieldStyle style;
final double labelFieldSpacing;
const NirTextField({
super.key,
required this.controller,
this.labelText = 'N° Sécurité Sociale (NIR)',
this.hintText = '15 caractères dont clé valide (dépt 2A ou 2B si Corse)',
this.hintText = '13 chiffres + clé',
this.validator,
this.fieldWidth = double.infinity,
this.fieldHeight = 53.0,
@@ -32,6 +33,7 @@ class NirTextField extends StatelessWidget {
this.enabled = true,
this.readOnly = false,
this.style = CustomAppTextFieldStyle.beige,
this.labelFieldSpacing = 6.0,
});
@override
@@ -50,6 +52,7 @@ class NirTextField extends StatelessWidget {
enabled: enabled,
readOnly: readOnly,
style: style,
labelFieldSpacing: labelFieldSpacing,
);
}
}
@@ -7,6 +7,7 @@ import 'package:image_picker/image_picker.dart';
import 'package:intl/intl.dart';
import 'dart:math' as math;
import 'dart:io';
import '../models/am_registration_data.dart' show kAmCapaciteAccueilMax;
import '../models/card_assets.dart';
import '../config/display_config.dart';
import '../utils/nir_utils.dart';
@@ -34,6 +35,7 @@ class ProfessionalInfoData {
/// Date d'obtention de l'agrément (obligatoire, API `date_agrement`).
final DateTime? agreementDate;
final int? capacity;
final int? placesAvailable;
ProfessionalInfoData({
this.photoPath,
@@ -48,6 +50,7 @@ class ProfessionalInfoData {
this.agrementNumber = '',
this.agreementDate,
this.capacity,
this.placesAvailable,
});
}
@@ -94,6 +97,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
final _agrementController = TextEditingController();
final _agreementDateController = TextEditingController();
final _capacityController = TextEditingController();
final _placesAvailableController = TextEditingController();
FocusNode? _birthCityFocus;
FocusNode? _birthCountryFocus;
@@ -133,6 +137,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
? DateFormat('dd/MM/yyyy').format(data.agreementDate!)
: '';
_capacityController.text = data.capacity?.toString() ?? '';
_placesAvailableController.text = data.placesAvailable?.toString() ?? '';
_photoPathFramework = data.photoPath;
_photoFile = data.photoFile;
_photoBytes = data.photoBytes;
@@ -180,9 +185,20 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
_agrementController.dispose();
_agreementDateController.dispose();
_capacityController.dispose();
_placesAvailableController.dispose();
super.dispose();
}
String? _validatePlacesAvailable(String? v) {
if (v == null || v.isEmpty) return 'Places disponibles requises';
final n = int.tryParse(v);
if (n == null || n < 0) return 'Nombre entre 0 et la capacité';
if (n > kAmCapaciteAccueilMax) return 'Maximum $kAmCapaciteAccueilMax';
final cap = int.tryParse(_capacityController.text);
if (cap != null && n > cap) return 'Ne peut pas dépasser la capacité';
return null;
}
Future<void> _selectDate(BuildContext context) async {
final DateTime? picked = await showDatePicker(
context: context,
@@ -285,6 +301,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
agrementNumber: _agrementController.text,
agreementDate: _selectedAgreementDate,
capacity: int.tryParse(_capacityController.text),
placesAvailable: int.tryParse(_placesAvailableController.text),
);
widget.onSubmit(data);
@@ -308,7 +325,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
),
Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 40.0),
padding: EdgeInsets.symmetric(vertical: config.isMobile ? 40.0 : 28.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
@@ -329,7 +346,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
),
textAlign: TextAlign.center,
),
SizedBox(height: config.isMobile ? 16 : 30),
SizedBox(height: config.isMobile ? 16 : 20),
_buildCard(context, config, screenSize),
// Boutons mobile sous la carte
@@ -400,7 +417,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
Container(
width: config.isMobile ? screenSize.width * 0.9 : screenSize.width * 0.6,
padding: EdgeInsets.symmetric(
vertical: config.isMobile ? 20 : (config.isReadonly ? 30 : 50),
vertical: config.isMobile ? 20 : (config.isReadonly ? 24 : 28),
horizontal: config.isMobile ? 24 : 50,
),
decoration: BoxDecoration(
@@ -428,7 +445,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
SizedBox(height: config.isMobile ? 20.0 : 14.0),
],
config.isMobile
? _buildMobileFields(context, config)
@@ -550,7 +567,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
),
],
),
const SizedBox(height: 18),
const SizedBox(height: 10),
// Contenu
Expanded(
@@ -578,7 +595,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
},
),
),
const SizedBox(height: 10),
const SizedBox(height: 5),
AppCustomCheckbox(
label: 'J\'accepte l\'utilisation\nde ma photo.',
value: _photoConsent,
@@ -589,7 +606,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
],
),
),
const SizedBox(width: 32),
const SizedBox(width: 24),
// CHAMPS (2/3) - Layout optimisé compact
Expanded(
@@ -605,7 +622,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
Expanded(flex: 3, child: _buildReadonlyField('Ville de naissance', _birthCityController.text)),
],
),
const SizedBox(height: 12),
const SizedBox(height: 5),
// Ligne 2 : Pays + NIR
Row(
@@ -615,7 +632,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
Expanded(flex: 3, child: _buildReadonlyField('NIR', _formatNirForDisplay(_nirController.text))),
],
),
const SizedBox(height: 12),
const SizedBox(height: 5),
// Ligne 3 : N° agrément + Date d'obtention
Row(
@@ -634,12 +651,19 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
),
],
),
const SizedBox(height: 12),
const SizedBox(height: 5),
Row(
children: [
Expanded(
child: _buildReadonlyField('Capacité d\'accueil', _capacityController.text),
),
const SizedBox(width: 16),
Expanded(
child: _buildReadonlyField(
'Places disponibles',
_placesAvailableController.text,
),
),
],
),
],
@@ -668,10 +692,10 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
children: [
Text(
label,
style: GoogleFonts.merienda(fontSize: 18.0, fontWeight: FontWeight.w600),
style: GoogleFonts.merienda(fontSize: 16.0, fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
const SizedBox(height: 3),
Container(
width: double.infinity,
height: 45.0, // Hauteur réduite pour compacter
@@ -685,7 +709,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
),
child: Text(
value.isNotEmpty ? value : '-',
style: GoogleFonts.merienda(fontSize: 16.0),
style: GoogleFonts.merienda(fontSize: 14.0),
overflow: TextOverflow.ellipsis,
),
),
@@ -695,7 +719,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
/// Layout DESKTOP : Photo à gauche, champs à droite
Widget _buildDesktopFields(BuildContext context, DisplayConfig config) {
final double verticalSpacing = config.isReadonly ? 16.0 : 32.0;
final double verticalSpacing = config.isReadonly ? 8.0 : 14.0;
return Column(
mainAxisSize: MainAxisSize.min,
@@ -708,7 +732,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
width: 300,
child: _buildPhotoSection(context, config),
),
const SizedBox(width: 30),
const SizedBox(width: 22),
// Champs à droite
Expanded(
child: Column(
@@ -751,8 +775,9 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
controller: _nirController,
fieldWidth: double.infinity,
fieldHeight: config.isMobile ? 45.0 : 53.0,
labelFontSize: config.isMobile ? 15.0 : 22.0,
inputFontSize: config.isMobile ? 14.0 : 20.0,
labelFontSize: config.isMobile ? 15.0 : 20.0,
inputFontSize: config.isMobile ? 14.0 : 18.0,
labelFieldSpacing: config.isMobile ? 6.0 : 3.0,
),
SizedBox(height: verticalSpacing),
Row(
@@ -767,7 +792,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
validator: (v) => v!.isEmpty ? 'Agrément requis' : null,
),
),
const SizedBox(width: 20),
const SizedBox(width: 14),
Expanded(
child: _buildField(
config: config,
@@ -784,19 +809,41 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
],
),
SizedBox(height: verticalSpacing),
_buildField(
config: config,
label: 'Capacité d\'accueil',
controller: _capacityController,
hint: 'De 1 à 10 enfants',
keyboardType: TextInputType.number,
validator: (v) {
if (v == null || v.isEmpty) return 'Capacité requise';
final n = int.tryParse(v);
if (n == null || n < 1) return 'Entrez un nombre entre 1 et 10';
if (n > 10) return 'Maximum 10 (plafond agrément)';
return null;
},
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _buildField(
config: config,
label: 'Capacité d\'accueil',
controller: _capacityController,
hint: 'De 1 à $kAmCapaciteAccueilMax enfants',
keyboardType: TextInputType.number,
validator: (v) {
if (v == null || v.isEmpty) return 'Capacité requise';
final n = int.tryParse(v);
if (n == null || n < 1) {
return 'Entrez un nombre entre 1 et $kAmCapaciteAccueilMax';
}
if (n > kAmCapaciteAccueilMax) {
return 'Maximum $kAmCapaciteAccueilMax';
}
return null;
},
),
),
const SizedBox(width: 14),
Expanded(
child: _buildField(
config: config,
label: 'Places disponibles',
controller: _placesAvailableController,
hint: 'Entre 0 et la capacité',
keyboardType: TextInputType.number,
validator: _validatePlacesAvailable,
),
),
],
),
],
);
@@ -852,49 +899,53 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _buildField(
config: config,
label: 'N° d\'agrément',
controller: _agrementController,
hint: 'N° agrément',
validator: (v) => v!.isEmpty ? 'Agrément requis' : null,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildField(
config: config,
label: 'Date d\'obtention de l\'agrément',
controller: _agreementDateController,
hint: 'JJ/MM/AAAA',
readOnly: true,
onTap: () => _selectAgreementDate(context),
suffixIcon: Icons.calendar_today,
validator: (_) =>
_selectedAgreementDate == null ? 'Date d\'obtention requise' : null,
),
),
],
_buildField(
config: config,
label: 'N° d\'agrément',
controller: _agrementController,
hint: 'Votre numéro d\'agrément',
validator: (v) => v!.isEmpty ? 'Agrément requis' : null,
),
const SizedBox(height: 12),
_buildField(
config: config,
label: 'Date d\'obtention de l\'agrément',
controller: _agreementDateController,
hint: 'JJ/MM/AAAA',
readOnly: true,
onTap: () => _selectAgreementDate(context),
suffixIcon: Icons.calendar_today,
validator: (_) =>
_selectedAgreementDate == null ? 'Date d\'obtention requise' : null,
),
const SizedBox(height: 12),
_buildField(
config: config,
label: 'Capacité d\'accueil',
controller: _capacityController,
hint: 'De 1 à 10 enfants',
hint: 'De 1 à $kAmCapaciteAccueilMax enfants',
keyboardType: TextInputType.number,
validator: (v) {
if (v == null || v.isEmpty) return 'Capacité requise';
final n = int.tryParse(v);
if (n == null || n < 1) return 'Entrez un nombre entre 1 et 10';
if (n > 10) return 'Maximum 10 (plafond agrément)';
if (n == null || n < 1) {
return 'Entrez un nombre entre 1 et $kAmCapaciteAccueilMax';
}
if (n > kAmCapaciteAccueilMax) {
return 'Maximum $kAmCapaciteAccueilMax';
}
return null;
},
),
const SizedBox(height: 12),
_buildField(
config: config,
label: 'Places disponibles',
controller: _placesAvailableController,
hint: 'Entre 0 et la capacité',
keyboardType: TextInputType.number,
validator: _validatePlacesAvailable,
),
],
);
}
@@ -918,7 +969,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
baseShadowColor: Colors.green.shade300,
isMobile: config.isMobile,
),
const SizedBox(height: 10),
SizedBox(height: config.isMobile ? 10.0 : 6.0),
AppCustomCheckbox(
label: 'J\'accepte l\'utilisation\nde ma photo.',
value: _photoConsent,
@@ -959,8 +1010,9 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
hintText: hint ?? label,
fieldWidth: double.infinity,
fieldHeight: config.isMobile ? 45.0 : 53.0,
labelFontSize: config.isMobile ? 15.0 : 22.0,
inputFontSize: config.isMobile ? 14.0 : 20.0,
labelFontSize: config.isMobile ? 15.0 : 20.0,
inputFontSize: config.isMobile ? 14.0 : 18.0,
labelFieldSpacing: config.isMobile ? 6.0 : 3.0,
keyboardType: keyboardType ?? TextInputType.text,
readOnly: readOnly,
onTap: onTap,