diff --git a/frontend/lib/models/carte_bulle.dart b/frontend/lib/models/carte_bulle.dart index f314a60..81fbe6f 100644 --- a/frontend/lib/models/carte_bulle.dart +++ b/frontend/lib/models/carte_bulle.dart @@ -109,6 +109,28 @@ class CarteBulle { String? get dateDebut => payload['date_debut']?.toString(); String? get dateFin => payload['date_fin']?.toString(); String? get motif => payload['motif']?.toString(); + + /// Helper pour extraire les informations de soin + String? get resumerSoin { + if (!isSoinEnfant) return null; + + final st = payload['sous_type']?.toString(); + final h = payload['heure_releve']?.toString(); + final hStr = h != null && h.isNotEmpty ? ' à $h' : ''; + + if (st == 'fievre') { + final t = payload['temperature_c']?.toString() ?? '?'; + return 'Température : $t°C$hStr'; + } else if (st == 'bobo') { + final nature = payload['bobo_nature']?.toString() ?? 'Bobo'; + final desc = payload['description']?.toString() ?? ''; + return desc.isNotEmpty ? '$nature$hStr ($desc)' : '$nature$hStr'; + } else if (st == 'medicament') { + final nom = payload['medicament_nom']?.toString() ?? 'Médicament'; + return '$nom donné$hStr'; + } + return null; + } /// Motif de refus : réponse API, sinon motif payload (co-parent / reload). String? get motifRefus { @@ -165,7 +187,12 @@ class CarteBulle { case 'absence_enfant': case 'absence_enfant_modif': return 'assets/images/sac_lapin.png'; - // soin_enfant : icône selon sous-type payload — à brancher plus tard + case 'soin_enfant': + final st = payload['sous_type']?.toString(); + if (st == 'fievre') return 'assets/images/thermo_croix.png'; + if (st == 'medicament') return 'assets/images/cuillere.png'; // Using generic fallback or specific if added later. Assuming thermo_croix or similar could work. + if (st == 'bobo') return 'assets/images/sac_lapin.png'; // Will adjust these later if better assets exist. Let's use generic ones for now, or null if no appropriate assets. + return null; default: return null; } diff --git a/frontend/lib/widgets/quotidien/ajouter_carte_modal.dart b/frontend/lib/widgets/quotidien/ajouter_carte_modal.dart index d17baec..087c599 100644 --- a/frontend/lib/widgets/quotidien/ajouter_carte_modal.dart +++ b/frontend/lib/widgets/quotidien/ajouter_carte_modal.dart @@ -38,6 +38,17 @@ class _AjouterCarteModalState extends State { 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 { _loadTypes(); } + @override + void dispose() { + _temperatureController.dispose(); + _medicamentDonneController.dispose(); + _boboDescriptionController.dispose(); + _medicamentNomController.dispose(); + _medicamentContexteController.dispose(); + super.dispose(); + } + Future _loadTypes() async { setState(() { _loadingTypes = true; @@ -90,6 +111,22 @@ class _AjouterCarteModalState extends State { return '$day/$m/${d.year}'; } + Future _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 _pickSingleDay() async { final picked = await showDatePicker( context: context, @@ -180,11 +217,69 @@ class _AjouterCarteModalState extends State { 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? _buildPayload() { + if (_selected?.code != 'soin_enfant') { + final m = _motifController.text.trim(); + if (m.isNotEmpty) return {'motif': m}; + return null; + } + + final base = { + '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 _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 { ? _dateDebut : _dateFin, ), - motif: _motifOptionnel(), + payload: _buildPayload(), ); if (!mounted) return; Navigator.of(context).pop(created); @@ -255,10 +350,27 @@ class _AjouterCarteModalState extends State { _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 { ], ), ], + 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 { }, ); } + 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( + 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, diff --git a/frontend/lib/widgets/quotidien/cartes_feed.dart b/frontend/lib/widgets/quotidien/cartes_feed.dart index 586d720..69cd6f6 100644 --- a/frontend/lib/widgets/quotidien/cartes_feed.dart +++ b/frontend/lib/widgets/quotidien/cartes_feed.dart @@ -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( + 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)