feat(front): implémentation UI soins enfant (#207)

- Ajout des formulaires spécifiques (fièvre, bobo, médicament) dans la modale d'ajout
- Formatage personnalisé du résumé des soins dans le feed
- Boutons supprimer / corriger (24h) pour l'AM
- Icônes contextuelles selon le sous-type de soin

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-08 17:54:58 +02:00
co-authored by Cursor
parent 8af579be53
commit 19b5b9e19c
3 changed files with 377 additions and 22 deletions
@@ -38,6 +38,17 @@ class _AjouterCarteModalState extends State<AjouterCarteModal> {
bool _submitting = false;
String? _error;
TimeOfDay _heureReleve = TimeOfDay.now();
// --- Controllers Soins ---
final _motifController = TextEditingController();
final _temperatureController = TextEditingController();
final _medicamentDonneController = TextEditingController();
String? _boboNature;
final _boboDescriptionController = TextEditingController();
final _medicamentNomController = TextEditingController();
final _medicamentContexteController = TextEditingController();
CarteTypeUi get _ui =>
_selected?.ui ??
CarteTypeUi.resolve(typeCode: _selected?.code ?? 'inconnu');
@@ -51,6 +62,16 @@ class _AjouterCarteModalState extends State<AjouterCarteModal> {
_loadTypes();
}
@override
void dispose() {
_temperatureController.dispose();
_medicamentDonneController.dispose();
_boboDescriptionController.dispose();
_medicamentNomController.dispose();
_medicamentContexteController.dispose();
super.dispose();
}
Future<void> _loadTypes() async {
setState(() {
_loadingTypes = true;
@@ -90,6 +111,22 @@ class _AjouterCarteModalState extends State<AjouterCarteModal> {
return '$day/$m/${d.year}';
}
Future<void> _pickTime() async {
final picked = await showTimePicker(
context: context,
initialTime: _heureReleve,
builder: (context, child) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(alwaysUse24HourFormat: true),
child: child ?? const SizedBox(),
);
},
);
if (picked != null && mounted) {
setState(() => _heureReleve = picked);
}
}
Future<void> _pickSingleDay() async {
final picked = await showDatePicker(
context: context,
@@ -180,11 +217,69 @@ class _AjouterCarteModalState extends State<AjouterCarteModal> {
return null;
}
/// V1 : pas de texte libre ; seul un sous-type catalogue peut alimenter motif.
String? _motifOptionnel() => _sousTypeLabel();
/// V1 : pas de texte libre pour le motif si la logique change.
/// (On retire `motifOptionnel` car le backend va dériver du payload).
Map<String, dynamic>? _buildPayload() {
if (_selected?.code != 'soin_enfant') {
final m = _motifController.text.trim();
if (m.isNotEmpty) return {'motif': m};
return null;
}
final base = <String, dynamic>{
'sous_type': _sousTypeCode,
'heure_releve':
'${_heureReleve.hour.toString().padLeft(2, '0')}:${_heureReleve.minute.toString().padLeft(2, '0')}',
};
if (_sousTypeCode == 'fievre') {
final t = _temperatureController.text.trim().replaceAll(',', '.');
if (t.isNotEmpty) {
base['temperature_c'] = double.tryParse(t);
}
base['medicament_donne'] = _medicamentDonneController.text.trim();
} else if (_sousTypeCode == 'bobo') {
base['bobo_nature'] = _boboNature;
base['description'] = _boboDescriptionController.text.trim();
} else if (_sousTypeCode == 'medicament') {
base['medicament_nom'] = _medicamentNomController.text.trim();
base['contexte'] = _medicamentContexteController.text.trim();
}
// Add motif to base payload for soin_enfant too
final m = _motifController.text.trim();
if (m.isNotEmpty) {
base['motif'] = m;
}
return base;
}
Future<void> _submit() async {
if (_selected == null) return;
// Validations locales spécifiques soins
if (_selected?.code == 'soin_enfant') {
if (_sousTypeCode == null) {
setState(() => _error = 'Veuillez choisir un sous-type.');
return;
}
if (_sousTypeCode == 'fievre') {
final t = _temperatureController.text.trim().replaceAll(',', '.');
if (t.isNotEmpty && double.tryParse(t) == null) {
setState(() => _error = 'La température est invalide.');
return;
}
} else if (_sousTypeCode == 'bobo' && _boboNature == null) {
setState(() => _error = 'Veuillez indiquer la nature du bobo.');
return;
} else if (_sousTypeCode == 'medicament' &&
_medicamentNomController.text.trim().isEmpty) {
setState(() => _error = 'Le nom du médicament est obligatoire.');
return;
}
}
if (_dateFin.isBefore(_dateDebut)) {
setState(() => _error = 'La date de fin doit être après la date de début.');
return;
@@ -204,7 +299,7 @@ class _AjouterCarteModalState extends State<AjouterCarteModal> {
? _dateDebut
: _dateFin,
),
motif: _motifOptionnel(),
payload: _buildPayload(),
);
if (!mounted) return;
Navigator.of(context).pop(created);
@@ -255,10 +350,27 @@ class _AjouterCarteModalState extends State<AjouterCarteModal> {
_buildTypeSelector(),
const SizedBox(height: 16),
if (_ui.periodeMode == CartePeriodeMode.singleDay)
_DateField(
label: 'Date',
value: _fmtUi(_dateDebut),
onTap: _submitting ? null : _pickSingleDay,
Row(
children: [
Expanded(
child: _DateField(
label: 'Date',
value: _fmtUi(_dateDebut),
onTap: _submitting ? null : _pickSingleDay,
),
),
if (_selected?.code == 'soin_enfant') ...[
const SizedBox(width: 12),
Expanded(
child: _DateField(
label: 'Heure',
value: _heureReleve.format(context),
icon: Icons.access_time,
onTap: _submitting ? null : _pickTime,
),
),
],
],
)
else
_DateField(
@@ -297,6 +409,26 @@ class _AjouterCarteModalState extends State<AjouterCarteModal> {
],
),
],
if (_selected?.code == 'soin_enfant') _buildSoinForm(),
if (_selected?.code != 'soin_enfant') ...[
const SizedBox(height: 16),
TextFormField(
controller: _motifController,
enabled: !_submitting,
maxLines: 3,
decoration: InputDecoration(
labelText: _ui.motifRequired ? 'Motif *' : 'Motif',
hintText: _ui.motifHint,
border: const OutlineInputBorder(),
),
validator: (v) {
if (!_ui.motifRequired) return null;
final text = v?.trim() ?? '';
if (text.isEmpty) return 'Motif obligatoire';
return null;
},
),
],
],
),
),
@@ -374,17 +506,116 @@ class _AjouterCarteModalState extends State<AjouterCarteModal> {
},
);
}
Widget _buildSoinForm() {
if (_sousTypeCode == null) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 16),
if (_sousTypeCode == 'fievre') ...[
TextFormField(
controller: _temperatureController,
enabled: !_submitting,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
labelText: 'Température (°C) *',
hintText: 'ex: 38.5',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextFormField(
controller: _medicamentDonneController,
enabled: !_submitting,
decoration: const InputDecoration(
labelText: 'Médicament donné (optionnel)',
hintText: 'ex: Doliprane 2.4kg',
border: OutlineInputBorder(),
),
),
] else if (_sousTypeCode == 'bobo') ...[
DropdownButtonFormField<String>(
value: _boboNature,
decoration: const InputDecoration(
labelText: 'Nature du bobo *',
border: OutlineInputBorder(),
),
items: const [
DropdownMenuItem(value: 'Chute', child: Text('Chute')),
DropdownMenuItem(value: 'Egratignure', child: Text('Égratignure')),
DropdownMenuItem(value: 'Coup', child: Text('Coup')),
DropdownMenuItem(value: 'Morsure', child: Text('Morsure')),
DropdownMenuItem(value: 'Autre', child: Text('Autre')),
],
onChanged: _submitting
? null
: (val) {
setState(() {
_boboNature = val;
});
},
),
const SizedBox(height: 12),
TextFormField(
controller: _boboDescriptionController,
enabled: !_submitting,
maxLines: 2,
decoration: const InputDecoration(
labelText: 'Description / Zone touchée *',
hintText: 'ex: Égratignure sur le genou droit',
border: OutlineInputBorder(),
),
),
] else if (_sousTypeCode == 'medicament') ...[
TextFormField(
controller: _medicamentNomController,
enabled: !_submitting,
decoration: const InputDecoration(
labelText: 'Nom du médicament *',
hintText: 'ex: Ventoline',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextFormField(
controller: _medicamentContexteController,
enabled: !_submitting,
maxLines: 2,
decoration: const InputDecoration(
labelText: 'Contexte / Posologie *',
hintText: 'ex: 2 bouffées suite à une quinte de toux',
border: OutlineInputBorder(),
),
),
],
const SizedBox(height: 16),
TextFormField(
controller: _motifController,
enabled: !_submitting,
maxLines: 2,
decoration: const InputDecoration(
labelText: 'Remarques additionnelles (optionnel)',
hintText: 'Observations générales...',
border: OutlineInputBorder(),
),
),
],
);
}
}
class _DateField extends StatelessWidget {
final String label;
final String value;
final VoidCallback? onTap;
final IconData icon;
const _DateField({
required this.label,
required this.value,
required this.onTap,
this.icon = Icons.calendar_today_outlined,
});
@override
@@ -396,7 +627,7 @@ class _DateField extends StatelessWidget {
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
suffixIcon: const Icon(Icons.calendar_today_outlined, size: 18),
suffixIcon: Icon(icon, size: 18),
),
child: Text(
value,
+110 -13
View File
@@ -611,8 +611,15 @@ class _BulleTile extends StatelessWidget {
return '$jj ${_mois[dt.month - 1]} ${dt.year}';
}
/// Une ligne type maquette : « Du 04 au 06 novembre 2026 ».
String _fmtPeriodeUneLigne() {
// Si c'est un soin_enfant en singleDay, on affiche juste "Le ..." ou un résumé si pertinent
if (card.isSoinEnfant) {
final resume = card.resumerSoin;
final jour = card.dateDebut != null ? 'Le ${_fmtLong(card.dateDebut)}' : '';
if (resume != null) return '$jour\n$resume';
return jour;
}
final d1 = DateTime.tryParse(card.dateDebut ?? '');
final d2 = DateTime.tryParse(card.dateFin ?? '');
if (d1 == null && d2 == null) return '';
@@ -850,6 +857,11 @@ class _BulleTile extends StatelessWidget {
}
Widget _buildGeneric(BuildContext context) {
final encre = _Encre.pour(card.colorFamily);
final isEdited = card.modifieLe != null && (card.creeLe == null || card.modifieLe!.isAfter(card.creeLe!));
final createdAt = card.creeLe ?? DateTime.now();
final canEdit = card.isCreator && DateTime.now().difference(createdAt).inHours < 24;
final memeJour = card.dateDebut != null &&
card.dateFin != null &&
card.dateDebut == card.dateFin;
@@ -870,23 +882,41 @@ class _BulleTile extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
card.titreAffiche,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.merienda(
fontSize: 18,
fontWeight: FontWeight.w700,
color: QuotidienTheme.ink,
),
Row(
children: [
Expanded(
child: Text(
card.titreAffiche,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.merienda(
fontSize: 18,
fontWeight: FontWeight.w700,
color: QuotidienTheme.ink,
),
),
),
if (isEdited)
Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(
'(Corrigée)',
style: GoogleFonts.merriweather(
fontSize: 11,
fontStyle: FontStyle.italic,
color: QuotidienTheme.ink.withOpacity(0.6),
),
),
),
],
),
if (card.dateDebut != null || card.dateFin != null) ...[
if (card.dateDebut != null || card.dateFin != null || card.isSoinEnfant) ...[
const SizedBox(height: 10),
Padding(
padding: const EdgeInsets.only(left: 12),
child: memeJour
child: (memeJour || card.isSoinEnfant)
? Text(
'Le ${_fmtLong(card.dateDebut)}',
_fmtPeriodeUneLigne(),
style: GoogleFonts.merienda(
fontSize: 17,
fontWeight: FontWeight.w600,
@@ -960,6 +990,73 @@ class _BulleTile extends StatelessWidget {
),
),
],
// Edit / Delete buttons for creator if within 24h
if (canEdit && !busy) ...[
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
icon: const Icon(Icons.edit, size: 16),
label: const Text('Corriger'),
style: TextButton.styleFrom(
foregroundColor: encre.link,
textStyle: GoogleFonts.merriweather(fontSize: 12),
),
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('La correction de carte sera implémentée ultérieurement.'),
duration: Duration(seconds: 2),
),
);
},
),
TextButton.icon(
icon: const Icon(Icons.delete_outline, size: 16),
label: const Text('Supprimer'),
style: TextButton.styleFrom(
foregroundColor: Colors.red[700],
textStyle: GoogleFonts.merriweather(fontSize: 12),
),
onPressed: () async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text('Supprimer cette carte ?', style: GoogleFonts.merienda()),
content: const Text('Cette action est définitive.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(backgroundColor: Colors.red[700]),
child: const Text('Supprimer'),
),
],
),
);
if (confirm == true && context.mounted) {
try {
await CardsService.supprimer(card.id);
// The stream will handle the removal locally via card.deleted
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: ${e.toString().replaceFirst('Exception: ', '')}')),
);
}
}
}
},
),
],
),
],
if (card.peutRepondre) ...[
const SizedBox(height: 14),
if (busy)