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 showAjouterCarteModal( BuildContext context, { required String placementId, }) { return showDialog( 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 createState() => _AjouterCarteModalState(); } class _AjouterCarteModalState extends State { List _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 _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 _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, 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 _pickDateRange() async { final initialEnd = _dateFin.isBefore(_dateDebut) ? _dateDebut : _dateFin; final picked = await showDialog( 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? _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; } 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( 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( 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), ), ), ); } }