fix(#132): UX création enfant — champs, genre et validation.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
6b89d7b405
commit
7a3d997ff9
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<AdminChildDetailModal> {
|
||||
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<AdminChildDetailModal> {
|
||||
|
||||
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<AdminChildDetailModal> {
|
||||
_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';
|
||||
}
|
||||
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<void> _loadLinkedAm() async {
|
||||
if (widget.isCreating) {
|
||||
setState(() => _loadingAm = false);
|
||||
@ -191,8 +234,8 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||
}
|
||||
|
||||
void _coerceGenderForStatus() {
|
||||
if (!_availableGenders.contains(_gender)) {
|
||||
_gender = 'H';
|
||||
if (_gender != null && !_availableGenders.contains(_gender)) {
|
||||
_gender = null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -321,8 +364,8 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||
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<AdminChildDetailModal> {
|
||||
);
|
||||
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,48 +665,121 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||
});
|
||||
}
|
||||
|
||||
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<MapEntry<String, String>> items,
|
||||
required ValueChanged<String> 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<String>(
|
||||
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) {
|
||||
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<TextInputFormatter>? 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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -673,19 +789,21 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||
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<AdminChildDetailModal> {
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
_preserveDateOnStatusChange(_status, v);
|
||||
_status = v;
|
||||
_coerceGenderForStatus();
|
||||
_dirty = true;
|
||||
@ -716,6 +835,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||
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<AdminChildDetailModal> {
|
||||
}
|
||||
|
||||
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<AdminChildDetailModal> {
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton(
|
||||
style: ValidationModalTheme.primaryElevatedStyle,
|
||||
onPressed: !_dirty || _busy ? null : _save,
|
||||
onPressed: !_canSubmit || _busy ? null : _save,
|
||||
child: _saving
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user