fix(#132): UX création enfant — champs, genre et validation.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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';
|
||||
}
|
||||
_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<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,70 +665,145 @@ 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) {
|
||||
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<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,
|
||||
);
|
||||
}
|
||||
|
||||
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<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,
|
||||
|
||||
Reference in New Issue
Block a user