Compare commits

..
Author SHA1 Message Date
jmartinandCursor a36e9ef176 feat(#175,#176): modale générique Ajouter une carte + CTA pilule
Creation absence (parent) et conge/arret (AM) via une seule modale
Material, catalogue UI extensible, calendrier de periode compact.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 22:36:29 +02:00
jmartin 5d36b0f4c1 Merge branch 'feature/174-feed-bulles-am' into develop
#174 feed bulles AM (widget partagé + polish UX).
2026-10-05 16:16:41 +02:00
8 changed files with 677 additions and 32 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

+15
View File
@@ -139,6 +139,21 @@ class CarteBulle {
bool get isArretMaladie => typeCode == 'arret_maladie_am'; bool get isArretMaladie => typeCode == 'arret_maladie_am';
bool get isCongeAm => typeCode == 'conge_am'; bool get isCongeAm => typeCode == 'conge_am';
bool get isSoinEnfant => typeCode == 'soin_enfant'; bool get isSoinEnfant => typeCode == 'soin_enfant';
bool get isAbsenceEnfant =>
typeCode == 'absence_enfant' || typeCode == 'absence_enfant_modif';
/// Info seule (`response_mode: none`) : créée déjà `traitee`, mais reste
/// pertinente dans la file tant que la période n’est pas finie.
bool get restePleineDansFile {
if (statut == CarteStatut.ouverte) return true;
if (statut != CarteStatut.traitee) return false;
if (!periodeEncoreActive) return false;
// Arrêt maladie ack : opaque jusqu’à date_fin.
if (isArretMaladie) return true;
// Absence (et autres infos) : pas d’atténuation tant que période active.
if (responseMode == CarteResponseMode.none) return true;
return false;
}
/// Icône dessin (haut droite) selon le type de bulle. /// Icône dessin (haut droite) selon le type de bulle.
String? get iconAsset { String? get iconAsset {
+110
View File
@@ -0,0 +1,110 @@
/// Configuration UI d'un type de carte — catalogue front (#175).
/// Les types émissibles viennent de l'API ; ce fichier affine libellés / hints.
enum CartePeriodeMode {
/// Plage date_debut → date_fin.
range,
/// Une seule date (début = fin).
singleDay,
}
class CarteSousTypeUi {
final String code;
final String label;
const CarteSousTypeUi({required this.code, required this.label});
}
class CarteTypeUi {
final String typeCode;
final String label;
final String motifHint;
final bool motifRequired;
final CartePeriodeMode periodeMode;
final List<CarteSousTypeUi> sousTypes;
const CarteTypeUi({
required this.typeCode,
required this.label,
this.motifHint = 'Motif (optionnel)',
this.motifRequired = false,
this.periodeMode = CartePeriodeMode.range,
this.sousTypes = const [],
});
static const _catalog = <String, CarteTypeUi>{
'absence_enfant': CarteTypeUi(
typeCode: 'absence_enfant',
label: 'Notification d’absence',
motifHint: 'Motif de l’absence (optionnel)',
),
// absence_enfant_modif : hors scope cartes — via agenda uniquement.
'conge_am': CarteTypeUi(
typeCode: 'conge_am',
label: 'Demande de congés',
motifHint: 'Précisions (optionnel)',
),
'arret_maladie_am': CarteTypeUi(
typeCode: 'arret_maladie_am',
label: 'Arrêt maladie',
motifHint: 'Précisions (optionnel)',
),
// Point d’extension #soins — visible dès que le back l’émet.
'soin_enfant': CarteTypeUi(
typeCode: 'soin_enfant',
label: 'Soin / bobo',
motifHint: 'Décrivez le soin ou le bobo',
motifRequired: true,
periodeMode: CartePeriodeMode.singleDay,
sousTypes: [
CarteSousTypeUi(code: 'temperature', label: 'Température'),
CarteSousTypeUi(code: 'bobo', label: 'Bobo'),
CarteSousTypeUi(code: 'medicament', label: 'Médicament'),
CarteSousTypeUi(code: 'autre', label: 'Autre'),
],
),
};
/// Fusionne un type API avec le catalogue ; fallback générique sinon.
static CarteTypeUi resolve({
required String typeCode,
String? apiTitre,
}) {
final known = _catalog[typeCode];
if (known != null) return known;
final titre = (apiTitre ?? '').trim();
return CarteTypeUi(
typeCode: typeCode,
label: titre.isNotEmpty ? titre : typeCode,
motifHint: 'Motif (optionnel)',
);
}
}
/// Type SYSTEM émissible renvoyé par GET /cards/types.
class CarteTypeEmissible {
final String code;
final String titre;
final String? responseMode;
final CarteTypeUi ui;
const CarteTypeEmissible({
required this.code,
required this.titre,
this.responseMode,
required this.ui,
});
factory CarteTypeEmissible.fromJson(Map<String, dynamic> json) {
final code = (json['code'] ?? '').toString();
final titre = (json['titre'] ?? code).toString();
return CarteTypeEmissible(
code: code,
titre: titre,
responseMode: json['response_mode']?.toString(),
ui: CarteTypeUi.resolve(typeCode: code, apiTitre: titre),
);
}
String get label => ui.label;
}
@@ -11,7 +11,7 @@ import 'package:p_tits_pas/screens/home/parent_screen/agenda_absences_stub.dart'
/// Dashboard assistante maternelle – coquille 3 colonnes quotidien (#169). /// Dashboard assistante maternelle – coquille 3 colonnes quotidien (#169).
/// Colonne gauche : sélecteur couple (#170) + feed bulles (#174). /// Colonne gauche : sélecteur couple (#170) + feed bulles (#174).
/// Création « Ajouter une bulle » (congé / arrêt) : ticket #176. /// Création « Ajouter une carte » : modale générique (#175, réutilisée #176).
class AmDashboardScreen extends StatefulWidget { class AmDashboardScreen extends StatefulWidget {
const AmDashboardScreen({super.key}); const AmDashboardScreen({super.key});
@@ -117,7 +117,6 @@ class _AmDashboardScreenState extends State<AmDashboardScreen> {
error: _couplesError, error: _couplesError,
onRetry: _loadCouples, onRetry: _loadCouples,
onCoupleSelected: _selectCouple, onCoupleSelected: _selectCouple,
onAjouterBulle: () => _soon('Ajouter une bulle (#176)'),
), ),
centerColumn: const QuotidienColumnPlaceholder( centerColumn: const QuotidienColumnPlaceholder(
title: 'Blog', title: 'Blog',
@@ -140,7 +139,7 @@ class _AmDashboardScreenState extends State<AmDashboardScreen> {
} }
} }
/// Colonne gauche : bandeau couple AM (#170) puis flux de cartes (#174). /// Colonne gauche : bandeau couple AM (#170) puis flux de cartes (#174 / #175).
class _LeftColumn extends StatelessWidget { class _LeftColumn extends StatelessWidget {
final List<CoupleGarde> couples; final List<CoupleGarde> couples;
final String? selectedCoupleId; final String? selectedCoupleId;
@@ -148,7 +147,6 @@ class _LeftColumn extends StatelessWidget {
final String? error; final String? error;
final VoidCallback onRetry; final VoidCallback onRetry;
final ValueChanged<CoupleGarde> onCoupleSelected; final ValueChanged<CoupleGarde> onCoupleSelected;
final VoidCallback onAjouterBulle;
const _LeftColumn({ const _LeftColumn({
required this.couples, required this.couples,
@@ -157,7 +155,6 @@ class _LeftColumn extends StatelessWidget {
required this.error, required this.error,
required this.onRetry, required this.onRetry,
required this.onCoupleSelected, required this.onCoupleSelected,
required this.onAjouterBulle,
}); });
@override @override
@@ -180,7 +177,6 @@ class _LeftColumn extends StatelessWidget {
Expanded( Expanded(
child: CartesFeed( child: CartesFeed(
placementId: selectedCoupleId, placementId: selectedCoupleId,
onAjouterBulle: onAjouterBulle,
), ),
), ),
], ],
@@ -115,7 +115,6 @@ class _ParentDashboardScreenState extends State<ParentDashboardScreen> {
error: _couplesError, error: _couplesError,
onRetry: _loadCouples, onRetry: _loadCouples,
onCoupleSelected: _selectCouple, onCoupleSelected: _selectCouple,
onAjouterBulle: () => _soon('Ajouter une bulle'),
), ),
centerColumn: const QuotidienColumnPlaceholder( centerColumn: const QuotidienColumnPlaceholder(
title: 'Blog', title: 'Blog',
@@ -138,7 +137,7 @@ class _ParentDashboardScreenState extends State<ParentDashboardScreen> {
} }
} }
/// Colonne gauche : bandeau couple (#167) puis flux de cartes (#173). /// Colonne gauche : bandeau couple (#167) puis flux de cartes (#173 / #175).
class _LeftColumn extends StatelessWidget { class _LeftColumn extends StatelessWidget {
final List<CoupleGarde> couples; final List<CoupleGarde> couples;
final String? selectedCoupleId; final String? selectedCoupleId;
@@ -146,7 +145,6 @@ class _LeftColumn extends StatelessWidget {
final String? error; final String? error;
final VoidCallback onRetry; final VoidCallback onRetry;
final ValueChanged<CoupleGarde> onCoupleSelected; final ValueChanged<CoupleGarde> onCoupleSelected;
final VoidCallback onAjouterBulle;
const _LeftColumn({ const _LeftColumn({
required this.couples, required this.couples,
@@ -155,7 +153,6 @@ class _LeftColumn extends StatelessWidget {
required this.error, required this.error,
required this.onRetry, required this.onRetry,
required this.onCoupleSelected, required this.onCoupleSelected,
required this.onAjouterBulle,
}); });
@override @override
@@ -177,7 +174,6 @@ class _LeftColumn extends StatelessWidget {
Expanded( Expanded(
child: CartesFeed( child: CartesFeed(
placementId: selectedCoupleId, placementId: selectedCoupleId,
onAjouterBulle: onAjouterBulle,
), ),
), ),
], ],
+59 -2
View File
@@ -2,11 +2,14 @@ import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:p_tits_pas/models/carte_bulle.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/api_config.dart'; import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/services/api/tokenService.dart'; import 'package:p_tits_pas/services/api/tokenService.dart';
/// Accès API Cartes (file d'attention) — tickets #173 / #194. /// Accès API Cartes (file d'attention) — tickets #173 / #175 / #194.
/// - GET /cards?placementId= /// - GET /cards?placementId=
/// - GET /cards/types
/// - POST /cards
/// - POST /cards/:id/respond /// - POST /cards/:id/respond
class CardsService { class CardsService {
static Future<Map<String, String>> _headers() async { static Future<Map<String, String>> _headers() async {
@@ -57,6 +60,60 @@ class CardsService {
.toList(); .toList();
} }
/// Types SYSTEM émissibles pour le rôle connecté.
static Future<List<CarteTypeEmissible>> listerTypes() async {
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.cards}/types'),
headers: await _headers(),
);
if (response.statusCode != 200) {
throw Exception(
_extractError(response.body, 'Erreur chargement des types de cartes'),
);
}
final decoded = jsonDecode(response.body);
final list = decoded is List
? decoded
: (decoded is Map ? decoded['items'] as List? : null) ?? const [];
return list
.whereType<Map>()
.map((e) => CarteTypeEmissible.fromJson(Map<String, dynamic>.from(e)))
.where((t) => t.code.isNotEmpty)
// Modif d’absence = agenda, pas la modale cartes (#175).
.where((t) => t.code != 'absence_enfant_modif')
.toList();
}
/// Créer une bulle (+ événement agenda côté back).
static Future<CarteBulle> creer({
required String typeCode,
required String idPlacement,
required String dateDebut,
required String dateFin,
String? motif,
}) async {
final response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.cards}'),
headers: await _headers(),
body: jsonEncode({
'type_code': typeCode,
'id_placement': idPlacement,
'date_debut': dateDebut,
'date_fin': dateFin,
if (motif != null && motif.trim().isNotEmpty) 'motif': motif.trim(),
}),
);
if (response.statusCode != 201 && response.statusCode != 200) {
throw Exception(
_extractError(response.body, 'Erreur lors de la création'),
);
}
final decoded = jsonDecode(response.body);
return CarteBulle.fromJson(Map<String, dynamic>.from(decoded as Map));
}
/// Répondre : accept | refuse | ack. /// Répondre : accept | refuse | ack.
static Future<CarteBulle> repondre({ static Future<CarteBulle> repondre({
required String cardId, required String cardId,
@@ -75,7 +132,7 @@ class CardsService {
}), }),
); );
if (response.statusCode != 200) { if (response.statusCode != 200 && response.statusCode != 201) {
throw Exception( throw Exception(
_extractError(response.body, 'Erreur lors de la réponse'), _extractError(response.body, 'Erreur lors de la réponse'),
); );
@@ -0,0 +1,408 @@
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;
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();
}
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> _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 ; seul un sous-type catalogue peut alimenter motif.
String? _motifOptionnel() => _sousTypeLabel();
Future<void> _submit() async {
if (_selected == null) 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,
),
motif: _motifOptionnel(),
);
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)
_DateField(
label: 'Date',
value: _fmtUi(_dateDebut),
onTap: _submitting ? null : _pickSingleDay,
)
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;
});
},
),
],
),
],
],
),
),
),
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;
}
});
},
);
}
}
class _DateField extends StatelessWidget {
final String label;
final String value;
final VoidCallback? onTap;
const _DateField({
required this.label,
required this.value,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: InputDecorator(
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
suffixIcon: const Icon(Icons.calendar_today_outlined, size: 18),
),
child: Text(
value,
style: GoogleFonts.merriweather(fontSize: 14),
),
),
);
}
}
+82 -19
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_service.dart';
import 'package:p_tits_pas/services/api/cards_stream_client.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/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/carte.dart';
import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.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. /// Widget partagé ; les actions dépendent du rôle + payload API.
class CartesFeed extends StatefulWidget { class CartesFeed extends StatefulWidget {
final String? placementId; final String? placementId;
/// Conservé pour override éventuel ; sinon le CTA ouvre [showAjouterCarteModal].
final VoidCallback? onAjouterBulle; final VoidCallback? onAjouterBulle;
const CartesFeed({ const CartesFeed({
@@ -171,24 +174,25 @@ class _CartesFeedState extends State<CartesFeed> {
} }
} }
/// Tri stable partagé : refusées → ouvertes → traitées, puis /// Tri stable partagé : refusées → encore actives (ouvertes / infos
/// `cree_le` DESC + `id` (pas `modifie_le` : une validation ne /// période en cours) → traitées passées, puis `cree_le` DESC + `id`.
/// re-mélange plus la file différemment selon les clients).
static List<CarteBulle> _trier(List<CarteBulle> items) { static List<CarteBulle> _trier(List<CarteBulle> items) {
int rang(CarteStatut s) { int rang(CarteBulle c) {
switch (s) { switch (c.statut) {
case CarteStatut.refusee: case CarteStatut.refusee:
return 0; return 0;
case CarteStatut.ouverte: case CarteStatut.ouverte:
return 1; return 1;
case CarteStatut.traitee: 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); final out = List<CarteBulle>.from(items);
out.sort((a, b) { 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; if (byStatut != 0) return byStatut;
final ac = a.creeLe?.millisecondsSinceEpoch ?? 0; final ac = a.creeLe?.millisecondsSinceEpoch ?? 0;
final bc = b.creeLe?.millisecondsSinceEpoch ?? 0; final bc = b.creeLe?.millisecondsSinceEpoch ?? 0;
@@ -372,23 +376,42 @@ class _CartesFeedState extends State<CartesFeed> {
color: QuotidienTheme.ink, 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), const SizedBox(height: 8),
Expanded(child: _buildBody()), 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() { Widget _buildBody() {
if (_loading) { if (_loading) {
return const Center( return const Center(
@@ -477,7 +500,7 @@ class _CartesFeedState extends State<CartesFeed> {
onRefresh: _load, onRefresh: _load,
child: ListView.separated( child: ListView.separated(
controller: _scrollController, controller: _scrollController,
padding: const EdgeInsets.only(top: 4, bottom: 8), padding: const EdgeInsets.only(top: 4, bottom: 4),
itemCount: _items.length, itemCount: _items.length,
separatorBuilder: (_, __) => const SizedBox(height: 10), separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (context, index) { itemBuilder: (context, index) {
@@ -612,9 +635,10 @@ class _BulleTile extends StatelessWidget {
'au $j2 ${_mois[b.month - 1]} ${b.year}'; '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 { bool get _attenuee {
if (card.isArretMaladie && card.periodeEncoreActive) return false; if (card.restePleineDansFile) return false;
return card.statut == CarteStatut.traitee; return card.statut == CarteStatut.traitee;
} }
@@ -1057,6 +1081,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. /// Bouton pilule peinte (coche / croix intégrées) — carte congés.
class _PilulePeinteButton extends StatelessWidget { class _PilulePeinteButton extends StatelessWidget {
final String asset; final String asset;