feat(#206,#207): cartes rose soin_enfant (backend + UI)

Closes #206

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-09 16:49:37 +02:00
co-authored by Cursor
parent 9a74c8eefe
commit f7ae13a909
20 changed files with 1464 additions and 77 deletions
@@ -0,0 +1,704 @@
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,
CarteBulle? carteToEdit,
}) {
return showDialog<CarteBulle>(
context: context,
barrierDismissible: false,
builder: (ctx) => AjouterCarteModal(
placementId: placementId,
carteToEdit: carteToEdit,
),
);
}
/// Modale générique : types API + catalogue UI, période, soins
class AjouterCarteModal extends StatefulWidget {
final String placementId;
final CarteBulle? carteToEdit;
const AjouterCarteModal({
super.key,
required this.placementId,
this.carteToEdit,
});
@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();
}
void _prefillIfEdit(List<CarteTypeEmissible> types) {
if (widget.carteToEdit == null) return;
final card = widget.carteToEdit!;
final type = types.where((t) => t.code == card.typeCode).firstOrNull;
if (type != null) {
_selected = type;
}
if (card.dateDebut != null) {
_dateDebut = DateTime.tryParse(card.dateDebut!) ?? _dateDebut;
}
if (card.dateFin != null) {
_dateFin = DateTime.tryParse(card.dateFin!) ?? _dateFin;
}
_motifController.text = card.motif ?? '';
if (card.typeCode == 'soin_enfant') {
final p = card.payload;
_sousTypeCode = p['sous_type']?.toString();
final h = p['heure_releve']?.toString();
if (h != null && h.contains(':')) {
final parts = h.split(':');
_heureReleve = TimeOfDay(
hour: int.tryParse(parts[0]) ?? 0,
minute: int.tryParse(parts[1]) ?? 0,
);
}
if (_sousTypeCode == 'fievre') {
_temperatureController.text = p['temperature_c']?.toString() ?? '';
_medicamentDonneController.text = p['medicament_donne']?.toString() ?? '';
} else if (_sousTypeCode == 'bobo') {
final nature = p['bobo_nature']?.toString() ?? '';
final naturesValides = ['Chute', 'Coup', 'Égratignure', 'Piqûre', 'Brûlure', 'Autre'];
if (naturesValides.map((n) => n.toLowerCase().replaceAll('é', 'e').replaceAll('û', 'u')).contains(nature.toLowerCase())) {
_boboNature = naturesValides.firstWhere(
(n) => n.toLowerCase().replaceAll('é', 'e').replaceAll('û', 'u') == nature.toLowerCase().replaceAll('é', 'e').replaceAll('û', 'u'),
orElse: () => 'Autre'
);
} else {
_boboNature = 'Autre';
}
_boboDescriptionController.text = p['description']?.toString() ?? '';
} else if (_sousTypeCode == 'medicament') {
_medicamentNomController.text = p['medicament_nom']?.toString() ?? '';
_medicamentContexteController.text = p['contexte']?.toString() ?? '';
}
}
}
@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.';
} else {
_prefillIfEdit(types);
}
});
} 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') {
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') {
// Normaliser l'enregistrement pour le back :
final nature = _boboNature?.toLowerCase().replaceAll('é', 'e').replaceAll('û', 'u') ?? 'autre';
base['bobo_nature'] = nature;
base['description'] = _boboDescriptionController.text.trim();
} else if (_sousTypeCode == 'medicament') {
base['medicament_nom'] = _medicamentNomController.text.trim();
base['contexte'] = _medicamentContexteController.text.trim();
}
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 payload = _buildPayload();
final dynamic created;
if (widget.carteToEdit != null) {
created = await CardsService.maj(
cardId: widget.carteToEdit!.id,
dateDebut: _fmtIso(_dateDebut),
dateFin: _fmtIso(
_ui.periodeMode == CartePeriodeMode.singleDay
? _dateDebut
: _dateFin,
),
motif: _selected?.code == 'soin_enfant' ? null : _motifController.text.trim(),
payload: payload,
);
} else {
created = await CardsService.creer(
typeCode: _selected!.code,
idPlacement: widget.placementId,
dateDebut: _fmtIso(_dateDebut),
dateFin: _fmtIso(
_ui.periodeMode == CartePeriodeMode.singleDay
? _dateDebut
: _dateFin,
),
motif: _selected?.code == 'soin_enfant' ? null : _motifController.text.trim(),
payload: payload,
);
}
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: 'Égratignure', child: Text('Égratignure')),
DropdownMenuItem(value: 'Coup', child: Text('Coup')),
DropdownMenuItem(value: 'Piqûre', child: Text('Piqûre')),
DropdownMenuItem(value: 'Brûlure', child: Text('Brûlure')),
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 (optionnel)',
hintText: 'ex: 2 bouffées suite à une quinte de toux',
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),
),
),
);
}
}
+209 -36
View File
@@ -7,6 +7,7 @@ import 'package:p_tits_pas/models/carte_bulle.dart';
import 'package:p_tits_pas/services/api/cards_service.dart';
import 'package:p_tits_pas/services/api/cards_stream_client.dart';
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/widgets/quotidien/ajouter_carte_modal.dart';
import 'package:p_tits_pas/widgets/quotidien/carte.dart';
import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart';
@@ -14,6 +15,8 @@ import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart';
/// Widget partagé ; les actions dépendent du rôle + payload API.
class CartesFeed extends StatefulWidget {
final String? placementId;
/// Conservé pour override éventuel ; sinon le CTA ouvre [showAjouterCarteModal].
final VoidCallback? onAjouterBulle;
const CartesFeed({
@@ -171,24 +174,25 @@ class _CartesFeedState extends State<CartesFeed> {
}
}
/// Tri stable partagé : refusées → ouvertes → traitées, puis
/// `cree_le` DESC + `id` (pas `modifie_le` : une validation ne
/// re-mélange plus la file différemment selon les clients).
/// Tri stable partagé : refusées → encore actives (ouvertes / infos
/// période en cours) → traitées passées, puis `cree_le` DESC + `id`.
static List<CarteBulle> _trier(List<CarteBulle> items) {
int rang(CarteStatut s) {
switch (s) {
int rang(CarteBulle c) {
switch (c.statut) {
case CarteStatut.refusee:
return 0;
case CarteStatut.ouverte:
return 1;
case CarteStatut.traitee:
return 2;
// Absence info / arrêt encore dans la période : même rang que
// ouverte (sinon elles tombent en bas dès la création).
return c.restePleineDansFile ? 1 : 2;
}
}
final out = List<CarteBulle>.from(items);
out.sort((a, b) {
final byStatut = rang(a.statut).compareTo(rang(b.statut));
final byStatut = rang(a).compareTo(rang(b));
if (byStatut != 0) return byStatut;
final ac = a.creeLe?.millisecondsSinceEpoch ?? 0;
final bc = b.creeLe?.millisecondsSinceEpoch ?? 0;
@@ -372,23 +376,42 @@ class _CartesFeedState extends State<CartesFeed> {
color: QuotidienTheme.ink,
),
),
const Spacer(),
TextButton.icon(
onPressed: widget.onAjouterBulle,
icon: const Icon(Icons.add, size: 18),
label: Text(
'Ajouter',
style: GoogleFonts.merriweather(fontSize: 13),
),
),
],
),
const SizedBox(height: 8),
Expanded(child: _buildBody()),
_AjouterCarteCta(onTap: _onAjouterCarte),
],
);
}
Future<void> _onAjouterCarte() async {
if (widget.onAjouterBulle != null) {
widget.onAjouterBulle!();
return;
}
final placementId = widget.placementId;
if (placementId == null || placementId.isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Sélectionnez un couple de garde pour ajouter une carte.',
),
),
);
return;
}
final created = await showAjouterCarteModal(
context,
placementId: placementId,
);
if (created != null && mounted) {
// SSE peut précéder ; refresh pour garantir la cohérence locale.
await _load();
}
}
Widget _buildBody() {
if (_loading) {
return const Center(
@@ -477,7 +500,7 @@ class _CartesFeedState extends State<CartesFeed> {
onRefresh: _load,
child: ListView.separated(
controller: _scrollController,
padding: const EdgeInsets.only(top: 4, bottom: 8),
padding: const EdgeInsets.only(top: 4, bottom: 4),
itemCount: _items.length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (context, index) {
@@ -588,8 +611,18 @@ class _BulleTile extends StatelessWidget {
return '$jj ${_mois[dt.month - 1]} ${dt.year}';
}
/// Une ligne type maquette : « Du 04 au 06 novembre 2026 ».
String _fmtPeriodeUneLigne() {
// Soin : « Le 08 octobre 2026 à 18h19 » (détail en italique sous la ligne).
if (card.isSoinEnfant) {
final jour = card.dateDebut != null
? 'Le ${_fmtLong(card.dateDebut)}'
: '';
final h = card.heureReleveAffichee;
if (jour.isEmpty) return h != null ? 'À $h' : '';
if (h == null || h.isEmpty) return jour;
return '$jour à $h';
}
final d1 = DateTime.tryParse(card.dateDebut ?? '');
final d2 = DateTime.tryParse(card.dateFin ?? '');
if (d1 == null && d2 == null) return '';
@@ -612,9 +645,10 @@ class _BulleTile extends StatelessWidget {
'au $j2 ${_mois[b.month - 1]} ${b.year}';
}
/// Arrêt maladie : reste opaque tant que la période n’est pas finie.
/// Arrêt / absence info : reste opaque tant que la période n’est pas finie
/// (même si `traitee` — absence = info seule dès la création).
bool get _attenuee {
if (card.isArretMaladie && card.periodeEncoreActive) return false;
if (card.restePleineDansFile) return false;
return card.statut == CarteStatut.traitee;
}
@@ -692,8 +726,6 @@ class _BulleTile extends StatelessWidget {
Expanded(
child: Text(
periode,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.merienda(
fontSize: 15,
fontWeight: FontWeight.w600,
@@ -705,14 +737,27 @@ class _BulleTile extends StatelessWidget {
],
),
],
if (card.isSoinEnfant &&
(card.detailSoin?.trim().isNotEmpty ?? false)) ...[
const SizedBox(height: 8),
Text(
card.detailSoin!,
softWrap: true,
style: GoogleFonts.merriweather(
fontSize: 13,
fontStyle: FontStyle.italic,
height: 1.35,
color: encre.ink,
),
),
],
if (card.isCongeAm &&
card.statut == CarteStatut.refusee &&
card.motifRefus != null) ...[
const SizedBox(height: 8),
Text(
'Motif : ${card.motifRefus}',
maxLines: 4,
overflow: TextOverflow.ellipsis,
softWrap: true,
style: GoogleFonts.merriweather(
fontSize: 13,
fontStyle: FontStyle.italic,
@@ -826,6 +871,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;
@@ -846,23 +896,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,
@@ -936,6 +1004,72 @@ 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: () {
showAjouterCarteModal(
context,
placementId: card.idPlacement,
carteToEdit: card,
);
},
),
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)
@@ -1057,6 +1191,45 @@ class _RefuseDialogState extends State<_RefuseDialog> {
}
}
/// CTA « Ajouter une carte » — pilule aquarelle en bas du feed (#175).
class _AjouterCarteCta extends StatelessWidget {
final VoidCallback onTap;
const _AjouterCarteCta({required this.onTap});
static const _asset =
'assets/images/cartes/btn_ajouter_carte_pilule.png';
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(4, 12, 4, 14),
child: Center(
child: Tooltip(
message: 'Ajouter une carte',
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(28),
child: Semantics(
button: true,
label: 'Ajouter une carte',
child: Image.asset(
_asset,
height: 48,
fit: BoxFit.contain,
filterQuality: FilterQuality.high,
),
),
),
),
),
),
);
}
}
/// Bouton pilule peinte (coche / croix intégrées) — carte congés.
class _PilulePeinteButton extends StatelessWidget {
final String asset;