From 7a3d997ff924ef2442fdcd401cf1b79cac591ebc Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 18:51:00 +0200 Subject: [PATCH] =?UTF-8?q?fix(#132):=20UX=20cr=C3=A9ation=20enfant=20?= =?UTF-8?q?=E2=80=94=20champs,=20genre=20et=20validation.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- frontend/lib/utils/date_display_utils.dart | 83 +++++++- frontend/lib/utils/name_format_utils.dart | 41 ++++ .../common/admin_child_detail_modal.dart | 196 ++++++++++++++---- 3 files changed, 281 insertions(+), 39 deletions(-) diff --git a/frontend/lib/utils/date_display_utils.dart b/frontend/lib/utils/date_display_utils.dart index 61ecb5e..3b7c5bd 100644 --- a/frontend/lib/utils/date_display_utils.dart +++ b/frontend/lib/utils/date_display_utils.dart @@ -1,3 +1,4 @@ +import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; import 'package:p_tits_pas/utils/enfant_status_utils.dart'; @@ -11,10 +12,50 @@ String formatIsoDateFr(String? s, {String ifEmpty = '–'}) { } } -/// Convertit `dd/MM/yyyy` (ou ISO) en `yyyy-MM-dd` pour l'API. +/// Affiche une date ISO en saisie guidée `jj / mm / aaaa`. +String formatIsoDateFrInput(String? s, {String ifEmpty = ''}) { + if (s == null || s.trim().isEmpty) return ifEmpty; + try { + final dt = DateTime.parse(s.trim()); + return formatFrenchDateDigits( + DateFormat('ddMMyyyy').format(dt), + ); + } catch (_) { + return formatFrenchDateDigits(s.replaceAll(RegExp(r'\D'), '')); + } +} + +/// Formate jusqu’à 8 chiffres en `jj / mm / aaaa`. +String formatFrenchDateDigits(String digits) { + final d = digits.replaceAll(RegExp(r'\D'), ''); + final limited = d.length > 8 ? d.substring(0, 8) : d; + final buf = StringBuffer(); + for (var i = 0; i < limited.length; i++) { + if (i == 2 || i == 4) buf.write(' / '); + buf.write(limited[i]); + } + return buf.toString(); +} + +/// Convertit `dd/MM/yyyy`, `dd / MM / yyyy`, 8 chiffres ou ISO en `yyyy-MM-dd`. String? parseFrDateToIso(String text) { final t = text.trim(); if (t.isEmpty) return null; + + final digits = t.replaceAll(RegExp(r'\D'), ''); + if (digits.length == 8) { + final dd = digits.substring(0, 2); + final mm = digits.substring(2, 4); + final yyyy = digits.substring(4, 8); + try { + return DateFormat('dd/MM/yyyy') + .parseStrict('$dd/$mm/$yyyy') + .toIso8601String() + .split('T') + .first; + } catch (_) {} + } + try { return DateFormat('dd/MM/yyyy') .parseStrict(t) @@ -96,3 +137,43 @@ String formatChildAgeLabel({ return 'Né le ${formatIsoDateFr(birthDate)}'; } } + +/// Saisie date FR : 8 chiffres → `jj / mm / aaaa`. +class FrenchDateInputFormatter extends TextInputFormatter { + const FrenchDateInputFormatter(); + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + final digits = newValue.text.replaceAll(RegExp(r'\D'), ''); + final limited = digits.length > 8 ? digits.substring(0, 8) : digits; + final formatted = formatFrenchDateDigits(limited); + + final digitsBeforeCursor = newValue.text + .substring(0, newValue.selection.start.clamp(0, newValue.text.length)) + .replaceAll(RegExp(r'\D'), '') + .length + .clamp(0, limited.length); + + return TextEditingValue( + text: formatted, + selection: TextSelection.collapsed( + offset: _cursorOffset(formatted, digitsBeforeCursor), + ), + ); + } + + static int _cursorOffset(String formatted, int digitsBeforeCursor) { + if (digitsBeforeCursor <= 0) return 0; + var seen = 0; + for (var i = 0; i < formatted.length; i++) { + if (RegExp(r'\d').hasMatch(formatted[i])) { + seen++; + if (seen >= digitsBeforeCursor) return i + 1; + } + } + return formatted.length; + } +} diff --git a/frontend/lib/utils/name_format_utils.dart b/frontend/lib/utils/name_format_utils.dart index feddec5..f09eea9 100644 --- a/frontend/lib/utils/name_format_utils.dart +++ b/frontend/lib/utils/name_format_utils.dart @@ -1,3 +1,5 @@ +import 'package:flutter/services.dart'; + /// Formatage affichage prénom / nom (capitalisation par mot et segments après `-` ou `'`). String formatPersonNameCase(String raw) { @@ -9,6 +11,22 @@ String formatPersonNameCase(String raw) { return words.map(_capitalizeComposedWord).join(' '); } +/// Variante saisie live : pas de majuscule tant que le mot n’a qu’une lettre ; +/// dès la 2ᵉ lettre, capitalisation comme [formatPersonNameCase]. +String formatPersonNameCaseTyping(String raw) { + if (raw.isEmpty) return raw; + final trailingMatch = RegExp(r'(\s*)$').firstMatch(raw); + final trailing = trailingMatch?.group(1) ?? ''; + final core = raw.substring(0, raw.length - trailing.length); + if (core.isEmpty) return raw; + final words = core.split(RegExp(r'\s+')); + final formatted = words.map((w) { + if (w.length < 2) return w; + return _capitalizeComposedWord(w); + }).join(' '); + return formatted + trailing; +} + String _capitalizeComposedWord(String word) { if (word.isEmpty) { return word; @@ -30,3 +48,26 @@ String _capitalizeComposedWord(String word) { } return buffer.toString(); } + +/// Formateur de saisie prénom / nom (capitalisation progressive). +class PersonNameInputFormatter extends TextInputFormatter { + const PersonNameInputFormatter(); + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + final formatted = formatPersonNameCaseTyping(newValue.text); + if (formatted == newValue.text) return newValue; + + final sel = newValue.selection; + final offset = sel.isValid + ? sel.baseOffset.clamp(0, formatted.length) + : formatted.length; + return TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: offset), + ); + } +} diff --git a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart index bf1619e..1a8a6c7 100644 --- a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart @@ -1,6 +1,5 @@ -import 'dart:typed_data'; - import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:image_picker/image_picker.dart'; import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; import 'package:p_tits_pas/models/enfant_admin_model.dart'; @@ -9,6 +8,7 @@ import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/utils/date_display_utils.dart'; import 'package:p_tits_pas/utils/enfant_status_utils.dart'; +import 'package:p_tits_pas/utils/name_format_utils.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart'; @@ -50,7 +50,7 @@ class _AdminChildDetailModalState extends State { late final TextEditingController _birthCtrl; late final TextEditingController _dueCtrl; late String _status; - late String _gender; + late String? _gender; late bool _consentPhoto; late bool _isMultiple; bool _dirty = false; @@ -99,6 +99,22 @@ class _AdminChildDetailModalState extends State { bool get _busy => _saving || _deleting; + /// Création : tous les champs sauf photo / consentement ; édition : au moins une modif. + bool get _canSubmit { + if (_busy) return false; + if (widget.isCreating) return _isCreateFormComplete; + return _dirty; + } + + bool get _isCreateFormComplete { + if (_selectedFamily == null) return false; + if (formatPersonNameCase(_prenomCtrl.text).isEmpty) return false; + if (formatPersonNameCase(_nomCtrl.text).isEmpty) return false; + if (_dateToIso(_activeDateCtrl.text) == null) return false; + if (_gender == null || !_availableGenders.contains(_gender)) return false; + return true; + } + @override void initState() { super.initState(); @@ -106,34 +122,61 @@ class _AdminChildDetailModalState extends State { _prenomCtrl = TextEditingController(text: e?.firstName ?? ''); _nomCtrl = TextEditingController(text: e?.lastName ?? ''); _birthCtrl = TextEditingController( - text: formatIsoDateFr(e?.birthDate, ifEmpty: ''), + text: formatIsoDateFrInput(e?.birthDate, ifEmpty: ''), ); _dueCtrl = TextEditingController( - text: formatIsoDateFr(e?.dueDate, ifEmpty: ''), + text: formatIsoDateFrInput(e?.dueDate, ifEmpty: ''), ); _status = normalizeEnfantStatus(e?.status); if (!enfantStatusValues.contains(_status)) { _status = 'sans_garde'; } - _gender = _normalizeGender(e?.gender, allowUnknown: _isUnborn); + if (widget.isCreating) { + _gender = null; + } else { + _gender = _normalizeGender(e?.gender, allowUnknown: _isUnborn); + } _consentPhoto = e?.consentPhoto ?? false; _isMultiple = e?.isMultiple ?? false; - for (final c in [_prenomCtrl, _nomCtrl, _birthCtrl, _dueCtrl]) { - c.addListener(_markDirty); + for (final c in [_prenomCtrl, _nomCtrl]) { + c.addListener(_onNameChanged); + } + for (final c in [_birthCtrl, _dueCtrl]) { + c.addListener(_onDateChanged); } - _prenomCtrl.addListener(_onNameChanged); - _nomCtrl.addListener(_onNameChanged); _loadCurrentUserRole(); if (widget.isCreating) { _loadingAm = false; _dirty = true; } else { + _prenomCtrl.addListener(_markDirty); + _nomCtrl.addListener(_markDirty); _loadLinkedAm(); } } void _onNameChanged() => setState(() {}); + void _onDateChanged() { + setState(() => _dirty = true); + } + + /// Date prévisionnelle strictement avant aujourd’hui (statut à naître). + bool get _dueDateInPast { + if (!_isUnborn) return false; + final iso = _dateToIso(_dueCtrl.text); + if (iso == null) return false; + try { + final due = DateTime.parse(iso); + final now = DateTime.now(); + final dueDay = DateTime(due.year, due.month, due.day); + final today = DateTime(now.year, now.month, now.day); + return dueDay.isBefore(today); + } catch (_) { + return false; + } + } + Future _loadLinkedAm() async { if (widget.isCreating) { setState(() => _loadingAm = false); @@ -191,8 +234,8 @@ class _AdminChildDetailModalState extends State { } void _coerceGenderForStatus() { - if (!_availableGenders.contains(_gender)) { - _gender = 'H'; + if (_gender != null && !_availableGenders.contains(_gender)) { + _gender = null; } } @@ -321,8 +364,8 @@ class _AdminChildDetailModalState extends State { await UserService.updateEnfant( enfantId: enfantId, body: { - 'first_name': _prenomCtrl.text.trim(), - 'last_name': _nomCtrl.text.trim(), + 'first_name': formatPersonNameCase(_prenomCtrl.text), + 'last_name': formatPersonNameCase(_nomCtrl.text), 'status': _status, 'gender': _gender, if (_isUnborn) @@ -363,8 +406,8 @@ class _AdminChildDetailModalState extends State { ); return; } - final prenom = _prenomCtrl.text.trim(); - final nom = _nomCtrl.text.trim(); + final prenom = formatPersonNameCase(_prenomCtrl.text); + final nom = formatPersonNameCase(_nomCtrl.text); if (prenom.isEmpty || nom.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Prénom et nom sont obligatoires')), @@ -622,70 +665,145 @@ class _AdminChildDetailModalState extends State { }); } - InputDecoration _inputDecoration({String? hint}) { - return ValidationFieldDecoration.input(hint: hint).copyWith( + /// Conserve la date saisie en basculant naissance ↔ prévisionnelle. + void _preserveDateOnStatusChange(String from, String to) { + final becomingUnborn = to == 'a_naitre' && from != 'a_naitre'; + final leavingUnborn = from == 'a_naitre' && to != 'a_naitre'; + if (becomingUnborn) { + if (_dueCtrl.text.trim().isEmpty && + _birthCtrl.text.trim().isNotEmpty) { + _dueCtrl.text = _birthCtrl.text; + } + } else if (leavingUnborn) { + if (_birthCtrl.text.trim().isEmpty && + _dueCtrl.text.trim().isNotEmpty) { + _birthCtrl.text = _dueCtrl.text; + } + } + } + + InputDecoration _inputDecoration({String? hint, bool error = false}) { + final base = ValidationFieldDecoration.input(hint: hint).copyWith( isDense: false, contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), ); + if (!error) return base; + final border = OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: BorderSide(color: Colors.red.shade400), + ); + return base.copyWith( + fillColor: Colors.red.shade50, + enabledBorder: border, + focusedBorder: border, + border: border, + ); } Widget _dropdownField({ Key? key, - required String value, + required String? value, required List> items, required ValueChanged onChanged, + String? hint, }) { final safeValue = - items.any((e) => e.key == value) ? value : items.first.key; + value != null && items.any((e) => e.key == value) ? value : null; return SizedBox( height: _fieldHeight, child: DropdownButtonFormField( key: key, value: safeValue, + hint: hint != null + ? Text(hint, style: TextStyle(color: Colors.grey.shade600)) + : null, isExpanded: true, decoration: _inputDecoration(), - items: items - .map((e) => DropdownMenuItem(value: e.key, child: Text(e.value))) - .toList(), - onChanged: _busy ? null : (v) { - if (v != null) onChanged(v); - }, + items: items + .map((e) => DropdownMenuItem(value: e.key, child: Text(e.value))) + .toList(), + onChanged: _busy + ? null + : (v) { + if (v != null) onChanged(v); + }, ), ); } - Widget _textField(TextEditingController controller, {String? hint, Key? key}) { + Widget _textField( + TextEditingController controller, { + String? hint, + Key? key, + TextInputType? keyboardType, + List? inputFormatters, + bool error = false, + Widget? suffixIcon, + }) { return SizedBox( height: _fieldHeight, child: TextField( key: key, controller: controller, + keyboardType: keyboardType, + inputFormatters: inputFormatters, textAlignVertical: TextAlignVertical.center, - style: const TextStyle(color: Colors.black87, fontSize: 14), - decoration: _inputDecoration(hint: hint), + style: TextStyle( + color: error ? Colors.red.shade800 : Colors.black87, + fontSize: 14, + ), + decoration: _inputDecoration( + hint: hint, + error: error, + ).copyWith(suffixIcon: suffixIcon), ), ); } + Widget _dateField() { + final past = _dueDateInPast; + return _textField( + _activeDateCtrl, + hint: 'jj / mm / aaaa', + key: ValueKey(_status), + keyboardType: TextInputType.number, + inputFormatters: const [FrenchDateInputFormatter()], + error: past, + suffixIcon: past + ? Tooltip( + message: + 'Date prévisionnelle antérieure à aujourd’hui', + child: Icon( + Icons.info_outline, + size: 20, + color: Colors.red.shade700, + ), + ) + : null, + ); + } + Widget _fieldsGrid() { return ValidationEditableSection( rowLayout: _fieldsRowLayout, fields: [ ValidationLabeledField( label: 'Prénom', - field: _textField(_prenomCtrl), + field: _textField( + _prenomCtrl, + inputFormatters: const [PersonNameInputFormatter()], + ), ), ValidationLabeledField( label: 'Nom', - field: _textField(_nomCtrl), + field: _textField( + _nomCtrl, + inputFormatters: const [PersonNameInputFormatter()], + ), ), ValidationLabeledField( label: _dateFieldLabel, - field: _textField( - _activeDateCtrl, - hint: 'jj/mm/aaaa', - key: ValueKey(_status), - ), + field: _dateField(), ), ValidationLabeledField( label: 'Statut', @@ -701,6 +819,7 @@ class _AdminChildDetailModalState extends State { .toList(), onChanged: (v) { setState(() { + _preserveDateOnStatusChange(_status, v); _status = v; _coerceGenderForStatus(); _dirty = true; @@ -716,6 +835,7 @@ class _AdminChildDetailModalState extends State { field: _dropdownField( key: ValueKey('genre-$_status'), value: _gender, + hint: 'Veuillez sélectionner le genre', items: _availableGenders .map((g) => MapEntry(g, _genderLabel(g))) .toList(), @@ -1090,7 +1210,7 @@ class _AdminChildDetailModalState extends State { } String get _placementTitle { - if (widget.isCreating) return 'Famille / dossier'; + if (widget.isCreating) return 'Sélection du dossier de la famille'; return _isScolarise ? 'Scolarisation' : 'Assistante maternelle'; } @@ -1314,7 +1434,7 @@ class _AdminChildDetailModalState extends State { const SizedBox(width: 12), ElevatedButton( style: ValidationModalTheme.primaryElevatedStyle, - onPressed: !_dirty || _busy ? null : _save, + onPressed: !_canSubmit || _busy ? null : _save, child: _saving ? const SizedBox( width: 18,