feat(inscription AM #120): UI étape pro, photo unifiée, scripts et données test

- Panneau AM : validation NIR alignée API, date d’agrément requise côté app,
  capacité 1–10, n° agrément + date sur une ligne, ville/pays formatés au blur.
- Widget RegistrationPhotoSlot (cadre, croix) partagé avec les cartes enfant.
- AuthService : MIME PNG/JPEG pour la photo ; payload date_agrement.
- Scripts register-am-dubois / mansouri ; chemins tests/ressources/photos ;
  doc test-data + seed ; smoke curl inscription AM.

Made-with: Cursor
This commit is contained in:
2026-04-13 13:19:32 +02:00
parent 12bf85b7bd
commit 58607cdbc9
18 changed files with 763 additions and 284 deletions
@@ -24,6 +24,8 @@ class AmRegistrationData extends ChangeNotifier {
// String placeOfBirth = ''; // Remplacé par birthCity et birthCountry
String nir = ''; // Numéro de Sécurité Sociale
String agrementNumber = ''; // Numéro d'agrément
/// Date d'obtention de l'agrément — obligatoire (API `date_agrement`).
DateTime? agreementDate;
int? capacity; // Number of children the AM can look after
// Step 3: Presentation & CGU
@@ -68,6 +70,7 @@ class AmRegistrationData extends ChangeNotifier {
// String? placeOfBirth, // Remplacé
String? nir,
String? agrementNumber,
DateTime? agreementDate,
int? capacity,
}) {
this.photoPath = photoPath;
@@ -80,6 +83,7 @@ class AmRegistrationData extends ChangeNotifier {
// this.placeOfBirth = placeOfBirth ?? this.placeOfBirth; // Remplacé
this.nir = nir ?? this.nir;
this.agrementNumber = agrementNumber ?? this.agrementNumber;
this.agreementDate = agreementDate ?? this.agreementDate;
this.capacity = capacity ?? this.capacity;
notifyListeners();
}
@@ -118,7 +122,10 @@ class AmRegistrationData extends ChangeNotifier {
birthCountry.isNotEmpty &&
nir.isNotEmpty && // Basic check, could add validation
agrementNumber.isNotEmpty &&
capacity != null && capacity! > 0;
agreementDate != null &&
capacity != null &&
capacity! >= 1 &&
capacity! <= 10;
bool get isStep3Complete =>
// presentationText is optional as per CDC (message au gestionnaire)
@@ -135,7 +142,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, capacity: $capacity, '
'nir: $nir, agrementNumber: $agrementNumber, agreementDate: $agreementDate, capacity: $capacity, '
'photoPath (step2): $photoPath, photoConsent (step2): $photoConsent, '
'presentationText: $presentationText, cguAccepted: $cguAccepted)';
}
@@ -23,6 +23,7 @@ class AmRegisterStep2Screen extends StatelessWidget {
birthCountry: registrationData.birthCountry,
nir: registrationData.nir,
agrementNumber: registrationData.agrementNumber,
agreementDate: registrationData.agreementDate,
capacity: registrationData.capacity,
);
@@ -43,6 +44,7 @@ class AmRegisterStep2Screen extends StatelessWidget {
birthCountry: data.birthCountry,
nir: data.nir,
agrementNumber: data.agrementNumber,
agreementDate: data.agreementDate,
capacity: data.capacity,
);
context.go('/am-register-step3');
@@ -204,6 +204,7 @@ class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
birthCountry: data.birthCountry,
nir: data.nir,
agrementNumber: data.agrementNumber,
agreementDate: data.agreementDate,
capacity: data.capacity,
photoConsent: data.photoConsent,
),
+21 -2
View File
@@ -1,5 +1,6 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:http/http.dart' as http;
@@ -22,6 +23,17 @@ class AuthService {
return name.isEmpty ? null : name;
}
static String _imageMimeForBytes(Uint8List bytes) {
if (bytes.length >= 8 &&
bytes[0] == 0x89 &&
bytes[1] == 0x50 &&
bytes[2] == 0x4E &&
bytes[3] == 0x47) {
return 'image/png';
}
return 'image/jpeg';
}
/// Connexion de l'utilisateur
/// Retourne l'utilisateur connecté avec le flag changement_mdp_obligatoire
static Future<AppUser> login(String email, String password) async {
@@ -150,11 +162,15 @@ class AuthService {
/// Inscription AM complète (POST /auth/register/am).
/// En cas de succès (201), aucune donnée utilisateur retournée ; rediriger vers login.
static Future<void> registerAM(AmRegistrationData data) async {
if (data.agreementDate == null) {
throw Exception('La date d\'obtention de l\'agrément est requise.');
}
String? photoBase64;
String? photoFilename;
if (data.photoBytes != null && data.photoBytes!.isNotEmpty) {
photoBase64 = 'data:image/jpeg;base64,${base64Encode(data.photoBytes!)}';
final mime = _imageMimeForBytes(data.photoBytes!);
photoBase64 = 'data:$mime;base64,${base64Encode(data.photoBytes!)}';
final fn = (data.photoFilename ?? '').trim();
photoFilename = fn.isNotEmpty ? fn : 'photo_am.jpg';
} else if (data.photoPath != null &&
@@ -165,7 +181,8 @@ class AuthService {
final file = File(data.photoPath!);
if (await file.exists()) {
final bytes = await file.readAsBytes();
photoBase64 = 'data:image/jpeg;base64,${base64Encode(bytes)}';
final mime = _imageMimeForBytes(bytes);
photoBase64 = 'data:$mime;base64,${base64Encode(bytes)}';
photoFilename =
_basenameFromPath(data.photoPath!) ?? 'photo_am.jpg';
}
@@ -191,6 +208,8 @@ class AuthService {
'lieu_naissance_pays': data.birthCountry.isNotEmpty ? data.birthCountry : null,
'nir': normalizeNir(data.nir),
'numero_agrement': data.agrementNumber,
'date_agrement':
'${data.agreementDate!.year}-${data.agreementDate!.month.toString().padLeft(2, '0')}-${data.agreementDate!.day.toString().padLeft(2, '0')}',
'capacite_accueil': data.capacity ?? 1,
'biographie': data.presentationText.isNotEmpty ? data.presentationText : null,
'acceptation_cgu': data.cguAccepted,
+12 -10
View File
@@ -57,20 +57,22 @@ String formatNir(String raw) {
return '${r.substring(0, 1)} ${r.substring(1, 3)} ${r.substring(3, 5)} ${r.substring(5, 7)} ${r.substring(7, 10)} ${r.substring(10, 13)} - ${r.substring(13, 15)}';
}
/// Vérifie le format : 15 caractères, structure 1+2+2+2+3+3+2, département 2A/2B autorisé.
/// Aligné sur le backend (NIR côté API / nir.util.ts) : sexe 13, département 2A ou 2B pour la Corse.
bool _isFormatValid(String raw) {
if (raw.length != 15) return false;
final dept = raw.substring(5, 7);
final restDigits = raw.substring(0, 5) + (dept == '2A' ? '19' : dept == '2B' ? '18' : dept) + raw.substring(7, 15);
if (!RegExp(r'^[12]\d{12}\d{2}$').hasMatch(restDigits)) return false;
return RegExp(r'^[12]\d{4}(?:\d{2}|2A|2B)\d{8}$').hasMatch(raw);
final r = raw.toUpperCase();
if (r.length != 15) return false;
return RegExp(r'^[1-3]\d{4}(?:2A|2B|\d{2})\d{6}\d{2}$').hasMatch(r);
}
/// Calcule la clé de contrôle (97 - (NIR13 mod 97)). Pour 2A→19, 2B→18.
/// Clé INSEE : 97 - (NIR13 mod 97). Corse : 2A→19, 2B→20 (aligné backend).
int _controlKey(String raw13) {
String n = raw13;
if (raw13.length >= 7 && (raw13.substring(5, 7) == '2A' || raw13.substring(5, 7) == '2B')) {
n = raw13.substring(0, 5) + (raw13.substring(5, 7) == '2A' ? '19' : '18') + raw13.substring(7);
String n;
if (raw13.length >= 7 && raw13.substring(5, 7) == '2A') {
n = '${raw13.substring(0, 5)}19${raw13.substring(7)}';
} else if (raw13.length >= 7 && raw13.substring(5, 7) == '2B') {
n = '${raw13.substring(0, 5)}20${raw13.substring(7)}';
} else {
n = '${raw13.substring(0, 5)}${raw13.substring(5, 7)}${raw13.substring(7)}';
}
final big = int.tryParse(n);
if (big == null) return -1;
+14 -109
View File
@@ -7,7 +7,7 @@ import '../models/user_registration_data.dart';
import '../models/card_assets.dart';
import 'custom_app_text_field.dart';
import 'form_field_wrapper.dart';
import 'hover_relief_widget.dart';
import 'registration_photo_slot.dart';
import '../config/display_config.dart';
import 'package:p_tits_pas/utils/name_format_utils.dart';
@@ -16,13 +16,12 @@ const String _photoConsentTooltip =
'Suivi du dossier et organisation de laccueil (affichage interne, outils pédagogiques).\n'
'Dans le respect de la politique de confidentialité.';
/// Cadre photo (centre transparent), même dossier que `photo.png`.
const String _photoSketchFrameAsset = 'assets/images/photo_frame.png';
bool _hasChildPhoto(ChildData c) {
final b = c.imageBytes;
if (b != null && b.isNotEmpty) return true;
return c.imageFile != null;
return registrationPhotoSlotHasImage(
imageBytes: c.imageBytes,
imageFile: c.imageFile,
imagePathOrAsset: null,
);
}
Widget _buildChildPhotoImage(ChildData c, {required BoxFit fit}) {
@@ -194,11 +193,6 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
final Color baseCardColorForShadow = widget.childData.cardColor == CardColorVertical.lavender
? Colors.purple.shade200
: (widget.childData.cardColor == CardColorVertical.pink ? Colors.pink.shade200 : Colors.grey.shade200);
final Color initialPhotoShadow =
Color.alphaBlend(Colors.black.withOpacity(0.22), baseCardColorForShadow);
final Color hoverPhotoShadow =
Color.alphaBlend(Colors.black.withOpacity(0.32), baseCardColorForShadow);
final double photoSide = 200.0 * (config.isMobile ? 0.8 : 1.0);
final double editableCardWidth = config.isMobile
? double.infinity
@@ -222,14 +216,15 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildEditablePhotoArea(
context: context,
config: config,
RegistrationPhotoSlot(
side: photoSide,
scaleFactor: scaleFactor,
photoSide: photoSide,
childData: widget.childData,
initialPhotoShadow: initialPhotoShadow,
hoverPhotoShadow: hoverPhotoShadow,
imageBytes: widget.childData.imageBytes,
imageFile: widget.childData.imageFile,
onTapPick: !config.isReadonly ? widget.onPickImage : null,
onClear: !config.isReadonly ? widget.onClearImage : null,
baseShadowColor: baseCardColorForShadow,
isMobile: config.isMobile,
),
SizedBox(height: 8.0 * scaleFactor),
_buildPhotoConsentRow(
@@ -321,96 +316,6 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
);
}
Widget _buildEditablePhotoArea({
required BuildContext context,
required DisplayConfig config,
required double scaleFactor,
required double photoSide,
required ChildData childData,
required Color initialPhotoShadow,
required Color hoverPhotoShadow,
}) {
final canInteract = !config.isReadonly;
final hasPhoto = _hasChildPhoto(childData);
final outerRadius = BorderRadius.circular(10 * scaleFactor);
// Arrondi photo (sous le cadre) : proportionnel au carré, plus fort quavant (~10×scaleFactor).
final photoClipRadius = BorderRadius.circular(photoSide * 0.14);
return Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
HoverReliefWidget(
onPressed: canInteract ? widget.onPickImage : null,
borderRadius: outerRadius,
initialElevation: 10,
hoverElevation: 16,
initialShadowColor: initialPhotoShadow,
hoverShadowColor: hoverPhotoShadow,
// Pas de clip Material sur la pile : larrondi de la photo est géré par ClipRRect (plus marqué).
clipBehavior: hasPhoto ? Clip.none : Clip.antiAlias,
child: SizedBox(
width: photoSide,
height: photoSide,
child: !hasPhoto
? ClipRRect(
borderRadius: outerRadius,
child: Center(
child: Image.asset(
'assets/images/photo.png',
fit: BoxFit.contain,
width: photoSide,
height: photoSide,
),
),
)
: Stack(
fit: StackFit.expand,
children: [
// Pas de fond opaque : les coins hors ClipRRect laissent voir la carte (aquarelle).
Positioned.fill(
child: ClipRRect(
borderRadius: photoClipRadius,
child: _buildChildPhotoImage(childData, fit: BoxFit.cover),
),
),
Positioned.fill(
child: Image.asset(
_photoSketchFrameAsset,
fit: BoxFit.fill,
errorBuilder: (context, error, stackTrace) =>
const SizedBox.shrink(),
),
),
],
),
),
),
if (hasPhoto && canInteract)
Positioned(
top: 8 * scaleFactor,
right: 8 * scaleFactor,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: widget.onClearImage,
customBorder: const CircleBorder(),
child: Padding(
padding: const EdgeInsets.all(4),
child: Image.asset(
'assets/images/cross.png',
width: config.isMobile ? 26 : 30,
height: config.isMobile ? 26 : 30,
fit: BoxFit.contain,
),
),
),
),
),
],
);
}
/// Layout SPÉCIAL Readonly Desktop (Ancien Design Horizontal)
Widget _buildReadonlyDesktopCard(BuildContext context, DisplayConfig config, Size screenSize) {
// Convertir la couleur verticale (pour mobile) en couleur horizontale (pour desktop/récap)
+1 -1
View File
@@ -23,7 +23,7 @@ class NirTextField extends StatelessWidget {
super.key,
required this.controller,
this.labelText = 'N° Sécurité Sociale (NIR)',
this.hintText = '15 car. (ex. 1 12 34 56 789 012 - 34 ou 2A Corse)',
this.hintText = '15 caractères dont clé valide (dépt 2A ou 2B si Corse)',
this.validator,
this.fieldWidth = double.infinity,
this.fieldHeight = 53.0,
@@ -10,12 +10,14 @@ import 'dart:io';
import '../models/card_assets.dart';
import '../config/display_config.dart';
import '../utils/nir_utils.dart';
import '../utils/name_format_utils.dart';
import 'custom_app_text_field.dart';
import 'nir_text_field.dart';
import 'form_field_wrapper.dart';
import 'app_custom_checkbox.dart';
import 'hover_relief_widget.dart';
import 'custom_navigation_button.dart';
import 'registration_photo_slot.dart';
/// Données pour le formulaire d'informations professionnelles
class ProfessionalInfoData {
@@ -29,6 +31,8 @@ class ProfessionalInfoData {
final String birthCountry;
final String nir;
final String agrementNumber;
/// Date d'obtention de l'agrément (obligatoire, API `date_agrement`).
final DateTime? agreementDate;
final int? capacity;
ProfessionalInfoData({
@@ -42,6 +46,7 @@ class ProfessionalInfoData {
this.birthCountry = '',
this.nir = '',
this.agrementNumber = '',
this.agreementDate,
this.capacity,
});
}
@@ -87,9 +92,14 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
final _birthCountryController = TextEditingController();
final _nirController = TextEditingController();
final _agrementController = TextEditingController();
final _agreementDateController = TextEditingController();
final _capacityController = TextEditingController();
FocusNode? _birthCityFocus;
FocusNode? _birthCountryFocus;
DateTime? _selectedDate;
DateTime? _selectedAgreementDate;
String? _photoPathFramework;
File? _photoFile;
Uint8List? _photoBytes;
@@ -118,6 +128,10 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
final nirRaw = nirToRaw(data.nir);
_nirController.text = nirRaw.length == 15 ? formatNir(nirRaw) : data.nir;
_agrementController.text = data.agrementNumber;
_selectedAgreementDate = data.agreementDate;
_agreementDateController.text = data.agreementDate != null
? DateFormat('dd/MM/yyyy').format(data.agreementDate!)
: '';
_capacityController.text = data.capacity?.toString() ?? '';
_photoPathFramework = data.photoPath;
_photoFile = data.photoFile;
@@ -125,15 +139,46 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
_photoFilename = data.photoFilename;
_photoConsent = data.photoConsent;
}
if (widget.mode == DisplayMode.editable) {
_birthCityFocus = FocusNode();
_birthCountryFocus = FocusNode();
_birthCityFocus!.addListener(_onBirthCityFocusChange);
_birthCountryFocus!.addListener(_onBirthCountryFocusChange);
}
}
void _onBirthCityFocusChange() {
if (_birthCityFocus == null || _birthCityFocus!.hasFocus) return;
_applyPlaceNameFormat(_birthCityController);
}
void _onBirthCountryFocusChange() {
if (_birthCountryFocus == null || _birthCountryFocus!.hasFocus) return;
_applyPlaceNameFormat(_birthCountryController);
}
void _applyPlaceNameFormat(TextEditingController controller) {
final formatted = formatPersonNameCase(controller.text);
if (formatted == controller.text) return;
controller.value = TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
);
}
@override
void dispose() {
_birthCityFocus?.removeListener(_onBirthCityFocusChange);
_birthCountryFocus?.removeListener(_onBirthCountryFocusChange);
_birthCityFocus?.dispose();
_birthCountryFocus?.dispose();
_dateOfBirthController.dispose();
_birthCityController.dispose();
_birthCountryController.dispose();
_nirController.dispose();
_agrementController.dispose();
_agreementDateController.dispose();
_capacityController.dispose();
super.dispose();
}
@@ -154,6 +199,22 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
}
}
Future<void> _selectAgreementDate(BuildContext context) async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _selectedAgreementDate ?? DateTime.now().subtract(const Duration(days: 365 * 5)),
firstDate: DateTime(1970, 1),
lastDate: DateTime.now(),
locale: const Locale('fr', 'FR'),
);
if (picked != null) {
setState(() {
_selectedAgreementDate = picked;
_agreementDateController.text = DateFormat('dd/MM/yyyy').format(picked);
});
}
}
Future<void> _pickPhoto() async {
if (widget.onPickPhoto != null) {
await widget.onPickPhoto!();
@@ -190,7 +251,19 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
} catch (_) {}
}
void _clearRegistrationPhoto() {
setState(() {
_photoBytes = null;
_photoFile = null;
_photoPathFramework = null;
_photoFilename = null;
_photoConsent = false;
});
}
void _submitForm() {
_applyPlaceNameFormat(_birthCityController);
_applyPlaceNameFormat(_birthCountryController);
if (_formKey.currentState!.validate()) {
if (_hasRealPhoto && !_photoConsent) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -210,6 +283,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
birthCountry: _birthCountryController.text,
nir: normalizeNir(_nirController.text),
agrementNumber: _agrementController.text,
agreementDate: _selectedAgreementDate,
capacity: int.tryParse(_capacityController.text),
);
@@ -491,35 +565,24 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
children: [
AspectRatio(
aspectRatio: 1,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(18),
child: _photoBytes != null && _photoBytes!.isNotEmpty
? Image.memory(_photoBytes!, fit: BoxFit.cover)
: _photoFile != null
? Image.file(_photoFile!, fit: BoxFit.cover)
: (_photoPathFramework != null &&
_photoPathFramework!.startsWith('assets/')
? Image.asset(_photoPathFramework!, fit: BoxFit.contain)
: Image.asset('assets/images/photo.png', fit: BoxFit.contain)),
),
child: LayoutBuilder(
builder: (context, constraints) {
final side = constraints.biggest.shortestSide;
return RegistrationPhotoSlot(
side: side,
imageBytes: _photoBytes,
imageFile: _photoFile,
imagePathOrAsset: _photoPathFramework,
baseShadowColor: Colors.green.shade300,
);
},
),
),
const SizedBox(height: 10),
AppCustomCheckbox(
label: 'J\'accepte l\'utilisation\nde ma photo.',
value: _photoConsent,
onChanged: (v) {}, // Readonly
onChanged: (_) {}, // Readonly
checkboxSize: 22.0,
fontSize: 14.0,
),
@@ -534,32 +597,49 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Ligne 1 : Ville + Pays
// Ligne 1 : Date de naissance + Ville
Row(
children: [
Expanded(child: _buildReadonlyField('Ville de naissance', _birthCityController.text)),
Expanded(flex: 2, child: _buildReadonlyField('Date de naissance', _dateOfBirthController.text)),
const SizedBox(width: 16),
Expanded(child: _buildReadonlyField('Pays de naissance', _birthCountryController.text)),
Expanded(flex: 3, child: _buildReadonlyField('Ville de naissance', _birthCityController.text)),
],
),
const SizedBox(height: 12),
// Ligne 2 : Date + NIR (NIR prend plus de place si possible ou 50/50)
// Ligne 2 : Pays + NIR
Row(
children: [
Expanded(flex: 2, child: _buildReadonlyField('Date de naissance', _dateOfBirthController.text)),
Expanded(flex: 2, child: _buildReadonlyField('Pays de naissance', _birthCountryController.text)),
const SizedBox(width: 16),
Expanded(flex: 3, child: _buildReadonlyField('NIR', _formatNirForDisplay(_nirController.text))),
],
),
const SizedBox(height: 12),
// Ligne 3 : Agrément + Capacité
// Ligne 3 : N° agrément + Date d'obtention
Row(
children: [
Expanded(flex: 3, child: _buildReadonlyField('N° Agrément', _agrementController.text)),
Expanded(
flex: 3,
child: _buildReadonlyField('N° d\'agrément', _agrementController.text),
),
const SizedBox(width: 16),
Expanded(flex: 2, child: _buildReadonlyField('Capacité', _capacityController.text)),
Expanded(
flex: 2,
child: _buildReadonlyField(
'Date d\'obtention de l\'agrément',
_agreementDateController.text.isNotEmpty ? _agreementDateController.text : '',
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildReadonlyField('Capacité d\'accueil', _capacityController.text),
),
],
),
],
@@ -633,22 +713,6 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
Expanded(
child: Column(
children: [
_buildField(
config: config,
label: 'Ville de naissance',
controller: _birthCityController,
hint: 'Votre ville de naissance',
validator: (v) => v!.isEmpty ? 'Ville requise' : null,
),
SizedBox(height: verticalSpacing),
_buildField(
config: config,
label: 'Pays de naissance',
controller: _birthCountryController,
hint: 'Votre pays de naissance',
validator: (v) => v!.isEmpty ? 'Pays requis' : null,
),
SizedBox(height: verticalSpacing),
_buildField(
config: config,
label: 'Date de naissance',
@@ -659,6 +723,24 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
suffixIcon: Icons.calendar_today,
validator: (v) => _selectedDate == null ? 'Date requise' : null,
),
SizedBox(height: verticalSpacing),
_buildField(
config: config,
label: 'Ville de naissance',
controller: _birthCityController,
hint: 'Ex. Ajaccio, Paris, Casablanca…',
validator: (v) => v!.isEmpty ? 'Ville requise' : null,
focusNode: _birthCityFocus,
),
SizedBox(height: verticalSpacing),
_buildField(
config: config,
label: 'Pays de naissance',
controller: _birthCountryController,
hint: 'Ex. France, Maroc…',
validator: (v) => v!.isEmpty ? 'Pays requis' : null,
focusNode: _birthCountryFocus,
),
],
),
),
@@ -674,6 +756,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
),
SizedBox(height: verticalSpacing),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _buildField(
@@ -688,20 +771,33 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
Expanded(
child: _buildField(
config: config,
label: 'Capacité d\'accueil',
controller: _capacityController,
hint: 'Ex: 3',
keyboardType: TextInputType.number,
validator: (v) {
if (v == null || v.isEmpty) return 'Capacité requise';
final n = int.tryParse(v);
if (n == null || n <= 0) return 'Nombre invalide';
return null;
},
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,
),
),
],
),
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;
},
),
],
);
}
@@ -715,24 +811,6 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
_buildPhotoSection(context, config),
const SizedBox(height: 20),
_buildField(
config: config,
label: 'Ville de naissance',
controller: _birthCityController,
hint: 'Votre ville de naissance',
validator: (v) => v!.isEmpty ? 'Ville requise' : null,
),
const SizedBox(height: 12),
_buildField(
config: config,
label: 'Pays de naissance',
controller: _birthCountryController,
hint: 'Votre pays de naissance',
validator: (v) => v!.isEmpty ? 'Pays requis' : null,
),
const SizedBox(height: 12),
_buildField(
config: config,
label: 'Date de naissance',
@@ -745,6 +823,26 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
),
const SizedBox(height: 12),
_buildField(
config: config,
label: 'Ville de naissance',
controller: _birthCityController,
hint: 'Ex. Ajaccio, Paris, Casablanca…',
validator: (v) => v!.isEmpty ? 'Ville requise' : null,
focusNode: _birthCityFocus,
),
const SizedBox(height: 12),
_buildField(
config: config,
label: 'Pays de naissance',
controller: _birthCountryController,
hint: 'Ex. France, Maroc…',
validator: (v) => v!.isEmpty ? 'Pays requis' : null,
focusNode: _birthCountryFocus,
),
const SizedBox(height: 12),
NirTextField(
controller: _nirController,
fieldWidth: double.infinity,
@@ -754,25 +852,46 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
),
const SizedBox(height: 12),
_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,
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,
),
),
],
),
const SizedBox(height: 12),
_buildField(
config: config,
label: 'Capacité d\'accueil',
controller: _capacityController,
hint: 'Ex: 3',
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 <= 0) return 'Nombre invalide';
if (n == null || n < 1) return 'Entrez un nombre entre 1 et 10';
if (n > 10) return 'Maximum 10 (plafond agrément)';
return null;
},
),
@@ -780,52 +899,33 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
);
}
/// Section photo + checkbox
/// Section photo + checkbox (même logique visuelle que les enfants : cadre, croix).
Widget _buildPhotoSection(BuildContext context, DisplayConfig config) {
final Color baseCardColorForShadow = Colors.green.shade300;
final Color initialPhotoShadow = baseCardColorForShadow.withAlpha(90);
final Color hoverPhotoShadow = baseCardColorForShadow.withAlpha(130);
ImageProvider? currentImageProvider;
if (_photoBytes != null && _photoBytes!.isNotEmpty) {
currentImageProvider = MemoryImage(_photoBytes!);
} else if (_photoFile != null) {
currentImageProvider = FileImage(_photoFile!);
} else if (_photoPathFramework != null && _photoPathFramework!.startsWith('assets/')) {
currentImageProvider = AssetImage(_photoPathFramework!);
}
final photoSize = config.isMobile ? 200.0 : 270.0;
final scaleFactor = config.isMobile ? 0.9 : 1.1;
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
HoverReliefWidget(
onPressed: _pickPhoto,
borderRadius: BorderRadius.circular(10.0),
initialShadowColor: initialPhotoShadow,
hoverShadowColor: hoverPhotoShadow,
child: SizedBox(
height: photoSize,
width: photoSize,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
image: currentImageProvider != null
? DecorationImage(image: currentImageProvider, fit: BoxFit.cover)
: null,
),
child: currentImageProvider == null
? Image.asset('assets/images/photo.png', fit: BoxFit.contain)
: null,
),
),
RegistrationPhotoSlot(
side: photoSize,
scaleFactor: scaleFactor,
imageBytes: _photoBytes,
imageFile: _photoFile,
imagePathOrAsset: _photoPathFramework,
onTapPick: config.isReadonly ? null : _pickPhoto,
onClear: config.isReadonly ? null : _clearRegistrationPhoto,
baseShadowColor: Colors.green.shade300,
isMobile: config.isMobile,
),
const SizedBox(height: 10),
AppCustomCheckbox(
label: 'J\'accepte l\'utilisation\nde ma photo.',
value: _photoConsent,
onChanged: (val) => setState(() => _photoConsent = val == true),
onChanged: (val) {
if (config.isReadonly) return;
setState(() => _photoConsent = val == true);
},
),
],
);
@@ -843,6 +943,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
IconData? suffixIcon,
String? Function(String?)? validator,
List<TextInputFormatter>? inputFormatters,
FocusNode? focusNode,
}) {
if (config.isReadonly) {
return FormFieldWrapper(
@@ -853,6 +954,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
} else {
return CustomAppTextField(
controller: controller,
focusNode: focusNode,
labelText: label,
hintText: hint ?? label,
fieldWidth: double.infinity,
@@ -0,0 +1,169 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'hover_relief_widget.dart';
/// Cadre « croquis » par-dessus la photo (même ressource que les cartes enfant).
const String kRegistrationPhotoFrameAsset = 'assets/images/photo_frame.png';
/// Indique si une photo « réelle » est présente (hors seul placeholder asset).
bool registrationPhotoSlotHasImage({
Uint8List? imageBytes,
File? imageFile,
String? imagePathOrAsset,
}) {
if (imageBytes != null && imageBytes.isNotEmpty) return true;
if (imageFile != null) return true;
if (imagePathOrAsset == null || imagePathOrAsset.isEmpty) return false;
return !imagePathOrAsset.startsWith('assets/');
}
/// Zone photo uniforme : cadre gris / placeholder, tap pour choisir, croix pour retirer, cadre dessin si photo.
///
/// Utilisé inscription **enfants** ([ChildCardWidget]) et **AM** ([ProfessionalInfoFormScreen]).
class RegistrationPhotoSlot extends StatelessWidget {
final double side;
final double scaleFactor;
final Uint8List? imageBytes;
final File? imageFile;
/// Chemin fichier local (hors `assets/`) ou URI asset `assets/...`.
final String? imagePathOrAsset;
final VoidCallback? onTapPick;
final VoidCallback? onClear;
final Color baseShadowColor;
final String placeholderAsset;
final bool isMobile;
const RegistrationPhotoSlot({
super.key,
required this.side,
this.scaleFactor = 1.0,
this.imageBytes,
this.imageFile,
this.imagePathOrAsset,
this.onTapPick,
this.onClear,
required this.baseShadowColor,
this.placeholderAsset = 'assets/images/photo.png',
this.isMobile = false,
});
bool get _hasImage => registrationPhotoSlotHasImage(
imageBytes: imageBytes,
imageFile: imageFile,
imagePathOrAsset: imagePathOrAsset,
);
Widget _buildImage({required BoxFit fit}) {
final b = imageBytes;
if (b != null && b.isNotEmpty) {
return Image.memory(b, fit: fit);
}
final f = imageFile;
if (f != null) {
return kIsWeb ? Image.network(f.path, fit: fit) : Image.file(f, fit: fit);
}
final p = imagePathOrAsset;
if (p != null && p.isNotEmpty) {
if (p.startsWith('assets/')) {
return Image.asset(p, fit: fit);
}
if (!kIsWeb) {
try {
final file = File(p);
if (file.existsSync()) {
return Image.file(file, fit: fit);
}
} catch (_) {}
}
}
return Image.asset(placeholderAsset, fit: BoxFit.contain);
}
@override
Widget build(BuildContext context) {
final canPick = onTapPick != null;
final canClear = onClear != null && _hasImage;
final outerRadius = BorderRadius.circular(10 * scaleFactor);
final photoClipRadius = BorderRadius.circular(side * 0.14);
final initialPhotoShadow =
Color.alphaBlend(Colors.black.withOpacity(0.22), baseShadowColor);
final hoverPhotoShadow =
Color.alphaBlend(Colors.black.withOpacity(0.32), baseShadowColor);
return Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
HoverReliefWidget(
onPressed: canPick ? onTapPick : null,
borderRadius: outerRadius,
initialElevation: 10,
hoverElevation: 16,
initialShadowColor: initialPhotoShadow,
hoverShadowColor: hoverPhotoShadow,
clipBehavior: _hasImage ? Clip.none : Clip.antiAlias,
child: SizedBox(
width: side,
height: side,
child: !_hasImage
? ClipRRect(
borderRadius: outerRadius,
child: Center(
child: Image.asset(
placeholderAsset,
fit: BoxFit.contain,
width: side,
height: side,
),
),
)
: Stack(
fit: StackFit.expand,
children: [
Positioned.fill(
child: ClipRRect(
borderRadius: photoClipRadius,
child: _buildImage(fit: BoxFit.cover),
),
),
Positioned.fill(
child: Image.asset(
kRegistrationPhotoFrameAsset,
fit: BoxFit.fill,
errorBuilder: (context, error, stackTrace) =>
const SizedBox.shrink(),
),
),
],
),
),
),
if (canClear)
Positioned(
top: 8 * scaleFactor,
right: 8 * scaleFactor,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onClear,
customBorder: const CircleBorder(),
child: Padding(
padding: const EdgeInsets.all(4),
child: Image.asset(
'assets/images/cross.png',
width: isMobile ? 26 : 30,
height: isMobile ? 26 : 30,
fit: BoxFit.contain,
),
),
),
),
),
],
);
}
}