- Ordre de focus explicite (NumericFocusOrder) pour l’inscription AM étape 2 : chaîne des champs jusqu’aux chevrons, Pays → NIR, places → Suivant (10) puis Précédent (11). - FocusTraversalGroup avec OrderedTraversalPolicy sur le Scaffold pour respecter cet ordre (desktop / web). - Champ pays : TextInputAction.next, onFieldSubmitted et onEditingComplete vers le NIR ; CustomAppTextField expose onEditingComplete. - NirTextField : focusNode et focusTraversalOrder optionnels. Made-with: Cursor
1180 lines
41 KiB
Dart
1180 lines
41 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>();
|
||
|
||
/// Ordre Tab explicite : sans cela, la zone photo (InkWell) peut s’insérer entre pays et NIR.
|
||
static const double _kTabPhotoPick = 1;
|
||
static const double _kTabDateBirth = 2;
|
||
static const double _kTabBirthCity = 3;
|
||
static const double _kTabBirthCountry = 4;
|
||
static const double _kTabNir = 5;
|
||
static const double _kTabAgrement = 6;
|
||
static const double _kTabAgreementDate = 7;
|
||
static const double _kTabCapacity = 8;
|
||
static const double _kTabPlaces = 9;
|
||
static const double _kTabChevronNext = 10;
|
||
static const double _kTabChevronPrev = 11;
|
||
|
||
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;
|
||
FocusNode? _nirFocus;
|
||
|
||
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();
|
||
_nirFocus = 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();
|
||
_nirFocus?.dispose();
|
||
_dateOfBirthController.dispose();
|
||
_birthCityController.dispose();
|
||
_birthCountryController.dispose();
|
||
_nirController.dispose();
|
||
_agrementController.dispose();
|
||
_agreementDateController.dispose();
|
||
_capacityController.dispose();
|
||
_placesAvailableController.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) {
|
||
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) {
|
||
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.')),
|
||
);
|
||
return;
|
||
}
|
||
|
||
final data = ProfessionalInfoData(
|
||
photoPath: _photoBytes != null ? null : _photoPathFramework,
|
||
photoFile: _photoFile,
|
||
photoBytes: _photoBytes,
|
||
photoFilename: _photoFilename,
|
||
photoConsent: _photoConsent,
|
||
dateOfBirth: _selectedDate,
|
||
birthCity: _birthCityController.text.trim(),
|
||
birthCountry: _birthCountryController.text.trim(),
|
||
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 FocusTraversalGroup(
|
||
policy: OrderedTraversalPolicy(),
|
||
child: 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 : visuellement sur les côtés ; ordre Tab 10–11 = après « Places disponibles ».
|
||
if (!config.isMobile) ...[
|
||
Positioned(
|
||
top: screenSize.height / 2 - 20,
|
||
left: 40,
|
||
child: FocusTraversalOrder(
|
||
order: const NumericFocusOrder(_kTabChevronPrev),
|
||
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',
|
||
),
|
||
),
|
||
),
|
||
Positioned(
|
||
top: screenSize.height / 2 - 20,
|
||
right: 40,
|
||
child: FocusTraversalOrder(
|
||
order: const NumericFocusOrder(_kTabChevronNext),
|
||
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,
|
||
focusTraversalOrder: _kTabDateBirth,
|
||
),
|
||
SizedBox(height: verticalSpacing),
|
||
_buildField(
|
||
config: config,
|
||
label: 'Ville de naissance',
|
||
controller: _birthCityController,
|
||
hint: 'Ex. Ajaccio, Paris, Casablanca…',
|
||
validator: _validateBirthCity,
|
||
focusNode: _birthCityFocus,
|
||
focusTraversalOrder: _kTabBirthCity,
|
||
),
|
||
SizedBox(height: verticalSpacing),
|
||
_buildField(
|
||
config: config,
|
||
label: 'Pays de naissance',
|
||
controller: _birthCountryController,
|
||
hint: 'Ex. France, Maroc…',
|
||
validator: _validateBirthCountry,
|
||
focusNode: _birthCountryFocus,
|
||
focusTraversalOrder: _kTabBirthCountry,
|
||
textInputAction: TextInputAction.next,
|
||
onFieldSubmitted: (_) => _nirFocus?.requestFocus(),
|
||
onEditingComplete: () => _nirFocus?.requestFocus(),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
SizedBox(height: verticalSpacing),
|
||
NirTextField(
|
||
controller: _nirController,
|
||
focusNode: _nirFocus,
|
||
focusTraversalOrder: _nirFocus != null ? _kTabNir : null,
|
||
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,
|
||
focusTraversalOrder: _kTabAgrement,
|
||
),
|
||
),
|
||
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,
|
||
focusTraversalOrder: _kTabAgreementDate,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
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;
|
||
},
|
||
focusTraversalOrder: _kTabCapacity,
|
||
),
|
||
),
|
||
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,
|
||
focusTraversalOrder: _kTabPlaces,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// 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,
|
||
focusTraversalOrder: _kTabDateBirth,
|
||
),
|
||
const SizedBox(height: 12),
|
||
|
||
_buildField(
|
||
config: config,
|
||
label: 'Ville de naissance',
|
||
controller: _birthCityController,
|
||
hint: 'Ex. Ajaccio, Paris, Casablanca…',
|
||
validator: _validateBirthCity,
|
||
focusNode: _birthCityFocus,
|
||
focusTraversalOrder: _kTabBirthCity,
|
||
),
|
||
const SizedBox(height: 12),
|
||
|
||
_buildField(
|
||
config: config,
|
||
label: 'Pays de naissance',
|
||
controller: _birthCountryController,
|
||
hint: 'Ex. France, Maroc…',
|
||
validator: _validateBirthCountry,
|
||
focusNode: _birthCountryFocus,
|
||
focusTraversalOrder: _kTabBirthCountry,
|
||
textInputAction: TextInputAction.next,
|
||
onFieldSubmitted: (_) => _nirFocus?.requestFocus(),
|
||
onEditingComplete: () => _nirFocus?.requestFocus(),
|
||
),
|
||
const SizedBox(height: 12),
|
||
|
||
NirTextField(
|
||
controller: _nirController,
|
||
focusNode: _nirFocus,
|
||
focusTraversalOrder: _nirFocus != null ? _kTabNir : null,
|
||
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,
|
||
focusTraversalOrder: _kTabAgrement,
|
||
),
|
||
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,
|
||
focusTraversalOrder: _kTabAgreementDate,
|
||
),
|
||
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;
|
||
},
|
||
focusTraversalOrder: _kTabCapacity,
|
||
),
|
||
const SizedBox(height: 12),
|
||
_buildField(
|
||
config: config,
|
||
label: 'Places disponibles',
|
||
controller: _placesAvailableController,
|
||
hint: 'Entre 0 et la capacité',
|
||
keyboardType: TextInputType.number,
|
||
validator: _validatePlacesAvailable,
|
||
focusTraversalOrder: _kTabPlaces,
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// 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;
|
||
|
||
final slot = 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,
|
||
);
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
if (config.isReadonly)
|
||
slot
|
||
else
|
||
FocusTraversalOrder(
|
||
order: const NumericFocusOrder(_kTabPhotoPick),
|
||
child: slot,
|
||
),
|
||
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,
|
||
double? focusTraversalOrder,
|
||
TextInputAction? textInputAction,
|
||
ValueChanged<String>? onFieldSubmitted,
|
||
VoidCallback? onEditingComplete,
|
||
}) {
|
||
if (config.isReadonly) {
|
||
return FormFieldWrapper(
|
||
config: config,
|
||
label: label,
|
||
value: controller.text,
|
||
);
|
||
} else {
|
||
final field = 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,
|
||
textInputAction: textInputAction,
|
||
onFieldSubmitted: onFieldSubmitted,
|
||
onEditingComplete: onEditingComplete,
|
||
);
|
||
final o = focusTraversalOrder;
|
||
if (o != null) {
|
||
return FocusTraversalOrder(
|
||
order: NumericFocusOrder(o),
|
||
child: field,
|
||
);
|
||
}
|
||
return field;
|
||
}
|
||
}
|
||
|
||
/// 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;
|
||
}
|
||
}
|
||
}
|