petitspas/frontend/lib/widgets/professional_info_form_screen.dart
Julien Martin b4b44cb1b9 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
2026-04-13 13:20:26 +02:00

1090 lines
37 KiB
Dart

import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:go_router/go_router.dart';
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';
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 {
final String? photoPath;
final File? photoFile;
final Uint8List? photoBytes;
final String? photoFilename;
final bool photoConsent;
final DateTime? dateOfBirth;
final String birthCity;
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;
final int? placesAvailable;
ProfessionalInfoData({
this.photoPath,
this.photoFile,
this.photoBytes,
this.photoFilename,
this.photoConsent = false,
this.dateOfBirth,
this.birthCity = '',
this.birthCountry = '',
this.nir = '',
this.agrementNumber = '',
this.agreementDate,
this.capacity,
this.placesAvailable,
});
}
/// Widget générique pour le formulaire d'informations professionnelles
/// Utilisé pour l'inscription des Assistantes Maternelles
/// Supporte mode éditable et readonly, responsive mobile/desktop
class ProfessionalInfoFormScreen extends StatefulWidget {
final DisplayMode mode;
final String stepText;
final String title;
final CardColorHorizontal cardColor;
final ProfessionalInfoData? initialData;
final String previousRoute;
final Function(ProfessionalInfoData) onSubmit;
final Future<void> Function()? onPickPhoto;
final bool embedContentOnly;
final VoidCallback? onEdit;
const ProfessionalInfoFormScreen({
super.key,
this.mode = DisplayMode.editable,
required this.stepText,
required this.title,
required this.cardColor,
this.initialData,
required this.previousRoute,
required this.onSubmit,
this.onPickPhoto,
this.embedContentOnly = false,
this.onEdit,
});
@override
State<ProfessionalInfoFormScreen> createState() => _ProfessionalInfoFormScreenState();
}
class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen> {
final _formKey = GlobalKey<FormState>();
final _dateOfBirthController = TextEditingController();
final _birthCityController = TextEditingController();
final _birthCountryController = TextEditingController();
final _nirController = TextEditingController();
final _agrementController = TextEditingController();
final _agreementDateController = TextEditingController();
final _capacityController = TextEditingController();
final _placesAvailableController = TextEditingController();
FocusNode? _birthCityFocus;
FocusNode? _birthCountryFocus;
DateTime? _selectedDate;
DateTime? _selectedAgreementDate;
String? _photoPathFramework;
File? _photoFile;
Uint8List? _photoBytes;
String? _photoFilename;
bool _photoConsent = false;
bool get _hasRealPhoto =>
(_photoBytes != null && _photoBytes!.isNotEmpty) ||
_photoFile != null ||
(_photoPathFramework != null &&
_photoPathFramework!.isNotEmpty &&
!_photoPathFramework!.startsWith('assets/'));
@override
void initState() {
super.initState();
final data = widget.initialData;
if (data != null) {
_selectedDate = data.dateOfBirth;
_dateOfBirthController.text = data.dateOfBirth != null
? DateFormat('dd/MM/yyyy').format(data.dateOfBirth!)
: '';
_birthCityController.text = data.birthCity;
_birthCountryController.text = data.birthCountry;
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() ?? '';
_placesAvailableController.text = data.placesAvailable?.toString() ?? '';
_photoPathFramework = data.photoPath;
_photoFile = data.photoFile;
_photoBytes = data.photoBytes;
_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();
_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,
initialDate: _selectedDate ?? DateTime.now().subtract(const Duration(days: 365 * 25)),
firstDate: DateTime(1920, 1),
lastDate: DateTime.now().subtract(const Duration(days: 365 * 18)),
locale: const Locale('fr', 'FR'),
);
if (picked != null && picked != _selectedDate) {
setState(() {
_selectedDate = picked;
_dateOfBirthController.text = DateFormat('dd/MM/yyyy').format(picked);
});
}
}
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!();
return;
}
final picker = ImagePicker();
try {
final XFile? picked = await picker.pickImage(
source: ImageSource.gallery,
imageQuality: 75,
maxWidth: 1200,
maxHeight: 1200,
);
if (picked == null) return;
final bytes = await picked.readAsBytes();
if (bytes.isEmpty) return;
File? file;
if (!kIsWeb) {
try {
final f = File(picked.path);
if (await f.exists()) file = f;
} catch (_) {}
}
final name = picked.name.trim();
setState(() {
_photoBytes = bytes;
_photoFile = file;
_photoPathFramework = null;
_photoFilename = name.isNotEmpty ? name : 'photo_am.jpg';
});
} 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(
const SnackBar(content: Text('Veuillez accepter le consentement photo pour continuer.')),
);
return;
}
final data = ProfessionalInfoData(
photoPath: _photoBytes != null ? null : _photoPathFramework,
photoFile: _photoFile,
photoBytes: _photoBytes,
photoFilename: _photoFilename,
photoConsent: _photoConsent,
dateOfBirth: _selectedDate,
birthCity: _birthCityController.text,
birthCountry: _birthCountryController.text,
nir: normalizeNir(_nirController.text),
agrementNumber: _agrementController.text,
agreementDate: _selectedAgreementDate,
capacity: int.tryParse(_capacityController.text),
placesAvailable: int.tryParse(_placesAvailableController.text),
);
widget.onSubmit(data);
}
}
@override
Widget build(BuildContext context) {
final screenSize = MediaQuery.of(context).size;
final config = DisplayConfig.fromContext(context, mode: widget.mode);
if (widget.embedContentOnly) {
return _buildCard(context, config, screenSize);
}
return Scaffold(
body: Stack(
children: [
Positioned.fill(
child: Image.asset('assets/images/paper2.png', fit: BoxFit.cover, repeat: ImageRepeat.repeat),
),
Center(
child: SingleChildScrollView(
padding: EdgeInsets.symmetric(vertical: config.isMobile ? 40.0 : 28.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
widget.stepText,
style: GoogleFonts.merienda(
fontSize: config.isMobile ? 13 : 16,
color: Colors.black54,
),
),
SizedBox(height: config.isMobile ? 6 : 10),
Text(
widget.title,
style: GoogleFonts.merienda(
fontSize: config.isMobile ? 18 : 24,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
textAlign: TextAlign.center,
),
SizedBox(height: config.isMobile ? 16 : 20),
_buildCard(context, config, screenSize),
// Boutons mobile sous la carte
if (config.isMobile) ...[
const SizedBox(height: 20),
_buildMobileButtons(context, config, screenSize),
const SizedBox(height: 10),
],
],
),
),
),
// Chevrons desktop uniquement
if (!config.isMobile) ...[
// Chevron Gauche (Retour)
Positioned(
top: screenSize.height / 2 - 20,
left: 40,
child: IconButton(
icon: Transform(
alignment: Alignment.center,
transform: Matrix4.rotationY(math.pi),
child: Image.asset('assets/images/chevron_right.png', height: 40),
),
onPressed: () {
if (context.canPop()) {
context.pop();
} else {
context.go(widget.previousRoute);
}
},
tooltip: 'Précédent',
),
),
// Chevron Droit (Suivant)
Positioned(
top: screenSize.height / 2 - 20,
right: 40,
child: IconButton(
icon: Image.asset('assets/images/chevron_right.png', height: 40),
onPressed: _submitForm,
tooltip: 'Suivant',
),
),
],
],
),
);
}
Widget _buildCard(BuildContext context, DisplayConfig config, Size screenSize) {
// Si mode Readonly Desktop : Layout spécial "Vintage" horizontal
if (config.isReadonly && !config.isMobile && widget.embedContentOnly) {
return _buildReadonlyDesktopCard(context, config, screenSize);
}
// Si mode Readonly Mobile : Layout spécial "Vintage" vertical
if (config.isReadonly && config.isMobile && widget.embedContentOnly) {
return Padding(
padding: EdgeInsets.symmetric(horizontal: screenSize.width * 0.05),
child: _buildMobileReadonlyCard(context, config, screenSize),
);
}
return Stack(
clipBehavior: Clip.none,
children: [
Container(
width: config.isMobile ? screenSize.width * 0.9 : screenSize.width * 0.6,
padding: EdgeInsets.symmetric(
vertical: config.isMobile ? 20 : (config.isReadonly ? 24 : 28),
horizontal: config.isMobile ? 24 : 50,
),
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage(
config.isMobile
? _getVerticalCardAsset()
: widget.cardColor.path
),
fit: BoxFit.fill,
),
),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.embedContentOnly) ...[
Text(
widget.title,
style: GoogleFonts.merienda(
fontSize: config.isMobile ? 18 : 22,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
textAlign: TextAlign.center,
),
SizedBox(height: config.isMobile ? 20.0 : 14.0),
],
config.isMobile
? _buildMobileFields(context, config)
: _buildDesktopFields(context, config),
],
),
),
),
if (config.isReadonly && widget.onEdit != null)
Positioned(
top: 10,
right: 10,
child: IconButton(
icon: const Icon(Icons.edit, color: Colors.black54),
onPressed: widget.onEdit,
tooltip: 'Modifier',
),
),
],
);
}
/// Carte en mode readonly MOBILE avec hauteur adaptative
Widget _buildMobileReadonlyCard(BuildContext context, DisplayConfig config, Size screenSize) {
return Container(
width: double.infinity,
// Pas de height fixe
padding: const EdgeInsets.symmetric(vertical: 20.0, horizontal: 24.0),
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage(_getVerticalCardAsset()),
fit: BoxFit.fill, // Fill pour s'adapter
),
borderRadius: BorderRadius.circular(15),
),
child: Stack(
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
// Titre + Edit Button
Row(
children: [
Expanded(
child: Text(
widget.title,
style: GoogleFonts.merienda(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
textAlign: TextAlign.center,
),
),
if (widget.onEdit != null)
const SizedBox(width: 28),
],
),
// Contenu aligné en haut
Padding(
padding: const EdgeInsets.only(top: 20.0),
child: _buildMobileFields(context, config),
),
],
),
if (widget.onEdit != null)
Positioned(
top: 0,
right: 0,
child: IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
icon: const Icon(Icons.edit, color: Colors.black54, size: 24),
onPressed: widget.onEdit,
tooltip: 'Modifier',
),
),
],
),
);
}
/// Carte en mode readonly desktop avec AspectRatio 2:1
Widget _buildReadonlyDesktopCard(BuildContext context, DisplayConfig config, Size screenSize) {
final cardWidth = screenSize.width / 2.0;
return SizedBox(
width: cardWidth,
child: AspectRatio(
aspectRatio: 2.0,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 20.0, horizontal: 25.0),
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage(widget.cardColor.path),
fit: BoxFit.cover,
),
borderRadius: BorderRadius.circular(15),
),
child: Column(
children: [
// Titre + Edit Button
Row(
children: [
Expanded(
child: Text(
widget.title,
style: GoogleFonts.merienda(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.black87),
textAlign: TextAlign.center,
),
),
if (widget.onEdit != null)
IconButton(
icon: const Icon(Icons.edit, color: Colors.black54, size: 28),
onPressed: widget.onEdit,
tooltip: 'Modifier',
),
],
),
const SizedBox(height: 10),
// Contenu
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// PHOTO (1/3)
Expanded(
flex: 1,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AspectRatio(
aspectRatio: 1,
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: 5),
AppCustomCheckbox(
label: 'J\'accepte l\'utilisation\nde ma photo.',
value: _photoConsent,
onChanged: (_) {}, // Readonly
checkboxSize: 22.0,
fontSize: 14.0,
),
],
),
),
const SizedBox(width: 24),
// CHAMPS (2/3) - Layout optimisé compact
Expanded(
flex: 2,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Ligne 1 : Date de naissance + Ville
Row(
children: [
Expanded(flex: 2, child: _buildReadonlyField('Date de naissance', _dateOfBirthController.text)),
const SizedBox(width: 16),
Expanded(flex: 3, child: _buildReadonlyField('Ville de naissance', _birthCityController.text)),
],
),
const SizedBox(height: 5),
// Ligne 2 : Pays + NIR
Row(
children: [
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: 5),
// Ligne 3 : N° agrément + Date d'obtention
Row(
children: [
Expanded(
flex: 3,
child: _buildReadonlyField('N° d\'agrément', _agrementController.text),
),
const SizedBox(width: 16),
Expanded(
flex: 2,
child: _buildReadonlyField(
'Date d\'obtention de l\'agrément',
_agreementDateController.text.isNotEmpty ? _agreementDateController.text : '',
),
),
],
),
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,
),
),
],
),
],
),
),
],
),
),
],
),
),
),
);
}
/// NIR formaté pour affichage (1 12 34 56 789 012 - 34 ou 2A pour la Corse).
String _formatNirForDisplay(String value) {
final raw = nirToRaw(value);
return raw.length == 15 ? formatNir(raw) : value;
}
/// Helper pour champ Readonly style "Beige"
Widget _buildReadonlyField(String label, String value) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: GoogleFonts.merienda(fontSize: 16.0, fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
Container(
width: double.infinity,
height: 45.0, // Hauteur réduite pour compacter
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 10.0),
decoration: BoxDecoration(
image: const DecorationImage(
image: AssetImage('assets/images/bg_beige.png'),
fit: BoxFit.fill,
),
borderRadius: BorderRadius.circular(8),
),
child: Text(
value.isNotEmpty ? value : '-',
style: GoogleFonts.merienda(fontSize: 14.0),
overflow: TextOverflow.ellipsis,
),
),
],
);
}
/// Layout DESKTOP : Photo à gauche, champs à droite
Widget _buildDesktopFields(BuildContext context, DisplayConfig config) {
final double verticalSpacing = config.isReadonly ? 8.0 : 14.0;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Photo + Checkbox à gauche
SizedBox(
width: 300,
child: _buildPhotoSection(context, config),
),
const SizedBox(width: 22),
// Champs à droite
Expanded(
child: Column(
children: [
_buildField(
config: config,
label: 'Date de naissance',
controller: _dateOfBirthController,
hint: 'JJ/MM/AAAA',
readOnly: true,
onTap: () => _selectDate(context),
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,
),
],
),
),
],
),
SizedBox(height: verticalSpacing),
NirTextField(
controller: _nirController,
fieldWidth: double.infinity,
fieldHeight: config.isMobile ? 45.0 : 53.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(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _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(width: 14),
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,
),
),
],
),
SizedBox(height: verticalSpacing),
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,
),
),
],
),
],
);
}
/// Layout MOBILE : Tout empilé verticalement
Widget _buildMobileFields(BuildContext context, DisplayConfig config) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Photo + Checkbox en premier
_buildPhotoSection(context, config),
const SizedBox(height: 20),
_buildField(
config: config,
label: 'Date de naissance',
controller: _dateOfBirthController,
hint: 'JJ/MM/AAAA',
readOnly: true,
onTap: () => _selectDate(context),
suffixIcon: Icons.calendar_today,
validator: (v) => _selectedDate == null ? 'Date requise' : null,
),
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,
fieldHeight: 45.0,
labelFontSize: 15.0,
inputFontSize: 14.0,
),
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,
),
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 à $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(height: 12),
_buildField(
config: config,
label: 'Places disponibles',
controller: _placesAvailableController,
hint: 'Entre 0 et la capacité',
keyboardType: TextInputType.number,
validator: _validatePlacesAvailable,
),
],
);
}
/// Section photo + checkbox (même logique visuelle que les enfants : cadre, croix).
Widget _buildPhotoSection(BuildContext context, DisplayConfig config) {
final photoSize = config.isMobile ? 200.0 : 270.0;
final scaleFactor = config.isMobile ? 0.9 : 1.1;
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
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,
),
SizedBox(height: config.isMobile ? 10.0 : 6.0),
AppCustomCheckbox(
label: 'J\'accepte l\'utilisation\nde ma photo.',
value: _photoConsent,
onChanged: (val) {
if (config.isReadonly) return;
setState(() => _photoConsent = val == true);
},
),
],
);
}
/// Construit un champ individuel
Widget _buildField({
required DisplayConfig config,
required String label,
required TextEditingController controller,
String? hint,
TextInputType? keyboardType,
bool readOnly = false,
VoidCallback? onTap,
IconData? suffixIcon,
String? Function(String?)? validator,
List<TextInputFormatter>? inputFormatters,
FocusNode? focusNode,
}) {
if (config.isReadonly) {
return FormFieldWrapper(
config: config,
label: label,
value: controller.text,
);
} else {
return CustomAppTextField(
controller: controller,
focusNode: focusNode,
labelText: label,
hintText: hint ?? label,
fieldWidth: double.infinity,
fieldHeight: config.isMobile ? 45.0 : 53.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,
suffixIcon: suffixIcon,
validator: validator,
inputFormatters: inputFormatters,
);
}
}
/// Boutons mobile
Widget _buildMobileButtons(BuildContext context, DisplayConfig config, Size screenSize) {
return Padding(
padding: EdgeInsets.symmetric(
horizontal: screenSize.width * 0.05,
),
child: Row(
children: [
Expanded(
child: HoverReliefWidget(
child: CustomNavigationButton(
text: 'Précédent',
style: NavigationButtonStyle.purple,
onPressed: () {
if (context.canPop()) {
context.pop();
} else {
context.go(widget.previousRoute);
}
},
width: double.infinity,
height: 50,
fontSize: 16,
),
),
),
const SizedBox(width: 16),
Expanded(
child: HoverReliefWidget(
child: CustomNavigationButton(
text: 'Suivant',
style: NavigationButtonStyle.green,
onPressed: _submitForm,
width: double.infinity,
height: 50,
fontSize: 16,
),
),
),
],
),
);
}
/// Retourne l'asset de carte vertical correspondant à la couleur
String _getVerticalCardAsset() {
switch (widget.cardColor) {
case CardColorHorizontal.blue:
return CardColorVertical.blue.path;
case CardColorHorizontal.green:
return CardColorVertical.green.path;
case CardColorHorizontal.lavender:
return CardColorVertical.lavender.path;
case CardColorHorizontal.lime:
return CardColorVertical.lime.path;
case CardColorHorizontal.peach:
return CardColorVertical.peach.path;
case CardColorHorizontal.pink:
return CardColorVertical.pink.path;
case CardColorHorizontal.red:
return CardColorVertical.red.path;
}
}
}