Files
petitspas/frontend/lib/widgets/quotidien/ajouter_carte_modal.dart
T
jmartinandCursor 19b5b9e19c 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>
2026-10-08 17:54:58 +02:00

640 lines
21 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:p_tits_pas/models/carte_bulle.dart';
import 'package:p_tits_pas/models/carte_type_ui.dart';
import 'package:p_tits_pas/services/api/cards_service.dart';
import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart';
/// Ouvre la modale Material générique « Ajouter une carte » (#175).
/// Retourne la bulle créée, ou `null` si annulé.
Future<CarteBulle?> showAjouterCarteModal(
BuildContext context, {
required String placementId,
}) {
return showDialog<CarteBulle>(
context: context,
barrierDismissible: false,
builder: (ctx) => AjouterCarteModal(placementId: placementId),
);
}
/// Modale générique : types API + catalogue UI, période (pas de motif V1).
class AjouterCarteModal extends StatefulWidget {
final String placementId;
const AjouterCarteModal({super.key, required this.placementId});
@override
State<AjouterCarteModal> createState() => _AjouterCarteModalState();
}
class _AjouterCarteModalState extends State<AjouterCarteModal> {
List<CarteTypeEmissible> _types = const [];
CarteTypeEmissible? _selected;
String? _sousTypeCode;
DateTime _dateDebut = DateTime.now();
DateTime _dateFin = DateTime.now();
bool _loadingTypes = true;
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');
@override
void initState() {
super.initState();
final now = DateTime.now();
_dateDebut = DateTime(now.year, now.month, now.day);
_dateFin = _dateDebut;
_loadTypes();
}
@override
void dispose() {
_temperatureController.dispose();
_medicamentDonneController.dispose();
_boboDescriptionController.dispose();
_medicamentNomController.dispose();
_medicamentContexteController.dispose();
super.dispose();
}
Future<void> _loadTypes() async {
setState(() {
_loadingTypes = true;
_error = null;
});
try {
final types = await CardsService.listerTypes();
if (!mounted) return;
setState(() {
_types = types;
_selected = types.isNotEmpty ? types.first : null;
_sousTypeCode = null;
_loadingTypes = false;
if (types.isEmpty) {
_error = 'Aucun type de carte disponible pour votre rôle.';
}
});
} catch (e) {
if (!mounted) return;
setState(() {
_loadingTypes = false;
_error = e.toString().replaceFirst('Exception: ', '');
});
}
}
String _fmtIso(DateTime d) {
final y = d.year.toString().padLeft(4, '0');
final m = d.month.toString().padLeft(2, '0');
final day = d.day.toString().padLeft(2, '0');
return '$y-$m-$day';
}
String _fmtUi(DateTime d) {
final day = d.day.toString().padLeft(2, '0');
final m = d.month.toString().padLeft(2, '0');
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,
useRootNavigator: true,
initialDate: _dateDebut,
firstDate: DateTime(2020),
lastDate: DateTime(2040),
helpText: 'Date',
cancelText: 'Annuler',
confirmText: 'Valider',
);
if (picked == null || !mounted) return;
final day = DateTime(picked.year, picked.month, picked.day);
setState(() {
_dateDebut = day;
_dateFin = day;
});
}
/// Plage Material (jours surlignés), en boîte compacte — pas plein écran.
/// Astuce : le mode calendrier de [DateRangePickerDialog] prend
/// `MediaQuery.size` comme taille de dialog ; on la bride.
Future<void> _pickDateRange() async {
final initialEnd =
_dateFin.isBefore(_dateDebut) ? _dateDebut : _dateFin;
final picked = await showDialog<DateTimeRange>(
context: context,
useRootNavigator: true,
barrierDismissible: true,
builder: (dialogContext) {
final base = MediaQuery.of(dialogContext);
final theme = Theme.of(dialogContext);
return MediaQuery(
data: base.copyWith(size: const Size(420, 560)),
child: Theme(
data: theme.copyWith(
datePickerTheme: theme.datePickerTheme.copyWith(
rangePickerShape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
dialogTheme: theme.dialogTheme.copyWith(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
),
child: DateRangePickerDialog(
initialDateRange: DateTimeRange(
start: _dateDebut,
end: initialEnd,
),
firstDate: DateTime(2020),
lastDate: DateTime(2040),
currentDate: DateTime.now(),
helpText: 'Début, puis fin (même jour : 2×)',
saveText: 'Valider',
cancelText: 'Annuler',
),
),
);
},
);
if (picked == null || !mounted) return;
setState(() {
_dateDebut = DateTime(
picked.start.year,
picked.start.month,
picked.start.day,
);
_dateFin = DateTime(
picked.end.year,
picked.end.month,
picked.end.day,
);
});
}
String? _sousTypeLabel() {
final sous = _sousTypeCode;
if (sous == null) return null;
for (final s in _ui.sousTypes) {
if (s.code == sous) return s.label;
}
return null;
}
/// 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;
}
setState(() {
_submitting = true;
_error = null;
});
try {
final created = await CardsService.creer(
typeCode: _selected!.code,
idPlacement: widget.placementId,
dateDebut: _fmtIso(_dateDebut),
dateFin: _fmtIso(
_ui.periodeMode == CartePeriodeMode.singleDay
? _dateDebut
: _dateFin,
),
payload: _buildPayload(),
);
if (!mounted) return;
Navigator.of(context).pop(created);
} catch (e) {
if (!mounted) return;
setState(() {
_submitting = false;
_error = e.toString().replaceFirst('Exception: ', '');
});
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(
'Ajouter une carte',
style: GoogleFonts.merienda(fontWeight: FontWeight.w600),
),
content: SizedBox(
width: 420,
child: _loadingTypes
? const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(
child: SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
)
: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_error != null) ...[
Text(
_error!,
style: GoogleFonts.merriweather(
fontSize: 13,
color: Theme.of(context).colorScheme.error,
),
),
const SizedBox(height: 12),
],
_buildTypeSelector(),
const SizedBox(height: 16),
if (_ui.periodeMode == CartePeriodeMode.singleDay)
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(
label: 'Période',
value: _dateDebut == _dateFin
? _fmtUi(_dateDebut)
: '${_fmtUi(_dateDebut)} → ${_fmtUi(_dateFin)}',
onTap: _submitting ? null : _pickDateRange,
),
if (_ui.sousTypes.isNotEmpty) ...[
const SizedBox(height: 16),
Text(
'Sous-type',
style: GoogleFonts.merriweather(
fontSize: 13,
color: QuotidienTheme.ink,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final s in _ui.sousTypes)
ChoiceChip(
label: Text(s.label),
selected: _sousTypeCode == s.code,
onSelected: _submitting
? null
: (sel) {
setState(() {
_sousTypeCode = sel ? s.code : null;
});
},
),
],
),
],
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;
},
),
],
],
),
),
),
actions: [
TextButton(
onPressed: _submitting ? null : () => Navigator.pop(context),
child: const Text('Annuler'),
),
FilledButton(
onPressed: (_submitting ||
_loadingTypes ||
_selected == null ||
_types.isEmpty)
? null
: _submit,
child: _submitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Envoyer'),
),
],
);
}
Widget _buildTypeSelector() {
if (_types.isEmpty) {
return Text(
'Aucun type disponible.',
style: GoogleFonts.merriweather(fontSize: 13),
);
}
if (_types.length == 1) {
return Text(
_types.first.label,
style: GoogleFonts.merienda(
fontSize: 16,
fontWeight: FontWeight.w600,
color: QuotidienTheme.ink,
),
);
}
return DropdownButtonFormField<String>(
value: _selected?.code,
decoration: const InputDecoration(
labelText: 'Type de carte',
border: OutlineInputBorder(),
),
items: [
for (final t in _types)
DropdownMenuItem(value: t.code, child: Text(t.label)),
],
onChanged: _submitting
? null
: (code) {
if (code == null) return;
CarteTypeEmissible? next;
for (final t in _types) {
if (t.code == code) {
next = t;
break;
}
}
if (next == null) return;
setState(() {
_selected = next;
_sousTypeCode = null;
if (next!.ui.periodeMode == CartePeriodeMode.singleDay) {
_dateFin = _dateDebut;
}
});
},
);
}
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
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: InputDecorator(
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
suffixIcon: Icon(icon, size: 18),
),
child: Text(
value,
style: GoogleFonts.merriweather(fontSize: 14),
),
),
);
}
}