Merge branch 'feature/174-feed-bulles-am' into develop
#174 feed bulles AM (widget partagé + polish UX).
This commit is contained in:
@@ -80,6 +80,32 @@ class CarteBulle {
|
||||
this.modifieLe,
|
||||
});
|
||||
|
||||
CarteBulle copyWith({
|
||||
CarteStatut? statut,
|
||||
bool? isCreator,
|
||||
String? lastRefuseComment,
|
||||
DateTime? modifieLe,
|
||||
Map<String, dynamic>? payload,
|
||||
}) {
|
||||
return CarteBulle(
|
||||
id: id,
|
||||
typeCode: typeCode,
|
||||
titre: titre,
|
||||
idPlacement: idPlacement,
|
||||
idAbsence: idAbsence,
|
||||
operation: operation,
|
||||
statut: statut ?? this.statut,
|
||||
payload: payload ?? this.payload,
|
||||
purgeAt: purgeAt,
|
||||
creePar: creePar,
|
||||
isCreator: isCreator ?? this.isCreator,
|
||||
responseMode: responseMode,
|
||||
lastRefuseComment: lastRefuseComment ?? this.lastRefuseComment,
|
||||
creeLe: creeLe,
|
||||
modifieLe: modifieLe ?? this.modifieLe,
|
||||
);
|
||||
}
|
||||
|
||||
String? get dateDebut => payload['date_debut']?.toString();
|
||||
String? get dateFin => payload['date_fin']?.toString();
|
||||
String? get motif => payload['motif']?.toString();
|
||||
|
||||
@@ -3,14 +3,15 @@ import 'package:p_tits_pas/models/couple_garde.dart';
|
||||
import 'package:p_tits_pas/models/user.dart';
|
||||
import 'package:p_tits_pas/services/auth_service.dart';
|
||||
import 'package:p_tits_pas/services/couple_garde_service.dart';
|
||||
import 'package:p_tits_pas/widgets/quotidien/cartes_feed.dart';
|
||||
import 'package:p_tits_pas/widgets/quotidien/couple_selector_bandeau.dart';
|
||||
import 'package:p_tits_pas/widgets/quotidien/quotidien_shell.dart';
|
||||
import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart';
|
||||
import 'package:p_tits_pas/screens/home/parent_screen/agenda_absences_stub.dart';
|
||||
|
||||
/// Dashboard assistante maternelle – coquille 3 colonnes quotidien (#169).
|
||||
/// Colonne gauche : sélecteur de couple enfant–parent(s) (#170).
|
||||
/// Métier cartes / blog / messagerie : tickets C/D/E.
|
||||
/// Colonne gauche : sélecteur couple (#170) + feed bulles (#174).
|
||||
/// Création « Ajouter une bulle » (congé / arrêt) : ticket #176.
|
||||
class AmDashboardScreen extends StatefulWidget {
|
||||
const AmDashboardScreen({super.key});
|
||||
|
||||
@@ -116,6 +117,7 @@ class _AmDashboardScreenState extends State<AmDashboardScreen> {
|
||||
error: _couplesError,
|
||||
onRetry: _loadCouples,
|
||||
onCoupleSelected: _selectCouple,
|
||||
onAjouterBulle: () => _soon('Ajouter une bulle (#176)'),
|
||||
),
|
||||
centerColumn: const QuotidienColumnPlaceholder(
|
||||
title: 'Blog',
|
||||
@@ -138,6 +140,7 @@ class _AmDashboardScreenState extends State<AmDashboardScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Colonne gauche : bandeau couple AM (#170) puis flux de cartes (#174).
|
||||
class _LeftColumn extends StatelessWidget {
|
||||
final List<CoupleGarde> couples;
|
||||
final String? selectedCoupleId;
|
||||
@@ -145,6 +148,7 @@ class _LeftColumn extends StatelessWidget {
|
||||
final String? error;
|
||||
final VoidCallback onRetry;
|
||||
final ValueChanged<CoupleGarde> onCoupleSelected;
|
||||
final VoidCallback onAjouterBulle;
|
||||
|
||||
const _LeftColumn({
|
||||
required this.couples,
|
||||
@@ -153,6 +157,7 @@ class _LeftColumn extends StatelessWidget {
|
||||
required this.error,
|
||||
required this.onRetry,
|
||||
required this.onCoupleSelected,
|
||||
required this.onAjouterBulle,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -172,12 +177,10 @@ class _LeftColumn extends StatelessWidget {
|
||||
onCoupleSelected: onCoupleSelected,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const Expanded(
|
||||
child: QuotidienColumnPlaceholder(
|
||||
title: 'Cartes',
|
||||
subtitle:
|
||||
'Absences, congés AM, sorties à valider\n(à brancher — ticket #174).',
|
||||
icon: Icons.style_outlined,
|
||||
Expanded(
|
||||
child: CartesFeed(
|
||||
placementId: selectedCoupleId,
|
||||
onAjouterBulle: onAjouterBulle,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:p_tits_pas/models/card_assets.dart';
|
||||
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/carte.dart';
|
||||
import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart';
|
||||
|
||||
/// Feed de bulles (file d'attention) — ticket #173.
|
||||
/// Widget partagé parent / AM ; les actions dépendent du rôle + payload API.
|
||||
/// Feed de bulles (file d'attention) — tickets #173 (parent) / #174 (AM).
|
||||
/// Widget partagé ; les actions dépendent du rôle + payload API.
|
||||
class CartesFeed extends StatefulWidget {
|
||||
final String? placementId;
|
||||
final VoidCallback? onAjouterBulle;
|
||||
@@ -37,15 +40,23 @@ class _CartesFeedState extends State<CartesFeed> {
|
||||
bool _fadeBottom = false;
|
||||
|
||||
CardsStreamClient? _stream;
|
||||
String? _userId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_syncScrollFades);
|
||||
unawaited(_loadUserId());
|
||||
_load();
|
||||
_startStream();
|
||||
}
|
||||
|
||||
Future<void> _loadUserId() async {
|
||||
final user = await AuthService.getCurrentUser();
|
||||
if (!mounted) return;
|
||||
_userId = user?.id;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stream?.stop();
|
||||
@@ -73,9 +84,25 @@ class _CartesFeedState extends State<CartesFeed> {
|
||||
)..start();
|
||||
}
|
||||
|
||||
/// SSE diffuse le DTO du répondant / créateur : recalcule `is_creator` local.
|
||||
CarteBulle _avecIsCreatorLocal(CarteBulle incoming, {CarteBulle? previous}) {
|
||||
if (previous != null) {
|
||||
return incoming.copyWith(isCreator: previous.isCreator);
|
||||
}
|
||||
final uid = _userId;
|
||||
if (uid != null &&
|
||||
incoming.creePar != null &&
|
||||
incoming.creePar!.isNotEmpty) {
|
||||
return incoming.copyWith(isCreator: incoming.creePar == uid);
|
||||
}
|
||||
return incoming;
|
||||
}
|
||||
|
||||
void _onStreamEvent(CardsStreamEvent event) {
|
||||
if (!mounted) return;
|
||||
if (event.type == 'heartbeat') return;
|
||||
// `response.added` puis `card.updated` : on ne traite que l’update carte.
|
||||
if (event.type == 'response.added') return;
|
||||
|
||||
if (event.type == 'card.deleted') {
|
||||
final id = event.cardId;
|
||||
@@ -90,26 +117,36 @@ class _CartesFeedState extends State<CartesFeed> {
|
||||
return;
|
||||
}
|
||||
|
||||
final card = event.card;
|
||||
if (card == null) return;
|
||||
final raw = event.card;
|
||||
if (raw == null) return;
|
||||
|
||||
final filter = widget.placementId;
|
||||
final matchesFilter = filter == null ||
|
||||
filter.isEmpty ||
|
||||
card.idPlacement == filter;
|
||||
raw.idPlacement == filter;
|
||||
|
||||
setState(() {
|
||||
if (!matchesFilter) {
|
||||
_items = _trier([
|
||||
for (final c in _items)
|
||||
if (c.id != card.id) c,
|
||||
]);
|
||||
} else if (_items.any((c) => c.id == card.id)) {
|
||||
_items = _trier([
|
||||
for (final c in _items) if (c.id == card.id) card else c,
|
||||
if (c.id != raw.id) c,
|
||||
]);
|
||||
} else {
|
||||
_items = _trier([..._items, card]);
|
||||
CarteBulle? previous;
|
||||
for (final c in _items) {
|
||||
if (c.id == raw.id) {
|
||||
previous = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
final card = _avecIsCreatorLocal(raw, previous: previous);
|
||||
if (previous != null) {
|
||||
_items = _trier([
|
||||
for (final c in _items) if (c.id == card.id) card else c,
|
||||
]);
|
||||
} else {
|
||||
_items = _trier([..._items, card]);
|
||||
}
|
||||
}
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _syncScrollFades());
|
||||
@@ -134,8 +171,9 @@ class _CartesFeedState extends State<CartesFeed> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Aligné sur le tri API : refusées → ouvertes → traitées, puis
|
||||
/// `modifie_le` DESC, `id` en départage (ordre stable entre parents).
|
||||
/// 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).
|
||||
static List<CarteBulle> _trier(List<CarteBulle> items) {
|
||||
int rang(CarteStatut s) {
|
||||
switch (s) {
|
||||
@@ -152,10 +190,10 @@ class _CartesFeedState extends State<CartesFeed> {
|
||||
out.sort((a, b) {
|
||||
final byStatut = rang(a.statut).compareTo(rang(b.statut));
|
||||
if (byStatut != 0) return byStatut;
|
||||
final am = a.modifieLe?.millisecondsSinceEpoch ?? 0;
|
||||
final bm = b.modifieLe?.millisecondsSinceEpoch ?? 0;
|
||||
final byModif = bm.compareTo(am); // DESC
|
||||
if (byModif != 0) return byModif;
|
||||
final ac = a.creeLe?.millisecondsSinceEpoch ?? 0;
|
||||
final bc = b.creeLe?.millisecondsSinceEpoch ?? 0;
|
||||
final byCree = bc.compareTo(ac); // DESC
|
||||
if (byCree != 0) return byCree;
|
||||
return a.id.compareTo(b.id);
|
||||
});
|
||||
return out;
|
||||
@@ -580,20 +618,27 @@ class _BulleTile extends StatelessWidget {
|
||||
return card.statut == CarteStatut.traitee;
|
||||
}
|
||||
|
||||
/// Congés (Validée / Refusée) ou carte ack (Bien reçu).
|
||||
/// Statut bas de carte (centré) : Validée / Refusée / En attente / Vu / Bien reçu.
|
||||
bool get _montreStatutReponse {
|
||||
if (card.peutRepondre) return false;
|
||||
if (card.isCongeAm &&
|
||||
(card.statut == CarteStatut.traitee ||
|
||||
card.statut == CarteStatut.refusee)) {
|
||||
return true;
|
||||
if (card.isCongeAm) {
|
||||
if (card.statut == CarteStatut.ouverte) return card.isCreator;
|
||||
return card.statut == CarteStatut.traitee ||
|
||||
card.statut == CarteStatut.refusee;
|
||||
}
|
||||
// Arrêt (ack) : AM créateur → « Vu » seulement après ack parent.
|
||||
// Destinataire → « Bien reçu » une fois traité.
|
||||
return card.responseMode == CarteResponseMode.ack &&
|
||||
card.statut == CarteStatut.traitee;
|
||||
}
|
||||
|
||||
String get _libelleStatutReponse {
|
||||
if (card.responseMode == CarteResponseMode.ack) return 'Bien reçu';
|
||||
if (card.responseMode == CarteResponseMode.ack) {
|
||||
return card.isCreator ? 'Vu' : 'Bien reçu';
|
||||
}
|
||||
if (card.isCongeAm && card.statut == CarteStatut.ouverte) {
|
||||
return 'En attente de validation';
|
||||
}
|
||||
// Accordé à « Demande de congés ».
|
||||
return card.statut == CarteStatut.refusee ? 'Refusée' : 'Validée';
|
||||
}
|
||||
@@ -675,20 +720,6 @@ class _BulleTile extends StatelessWidget {
|
||||
color: encre.ink,
|
||||
),
|
||||
),
|
||||
] else if (card.statut == CarteStatut.ouverte &&
|
||||
card.isCreator &&
|
||||
(card.isCongeAm ||
|
||||
card.responseMode == CarteResponseMode.ack)) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
card.responseMode == CarteResponseMode.ack
|
||||
? 'En attente d’acquittement'
|
||||
: 'En attente de réponse',
|
||||
style: GoogleFonts.merriweather(
|
||||
fontSize: 12,
|
||||
color: QuotidienTheme.muted,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -747,7 +778,7 @@ class _BulleTile extends StatelessWidget {
|
||||
_libelleStatutReponse,
|
||||
textAlign: TextAlign.center,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: 14,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: encre.ink.withValues(alpha: 0.75),
|
||||
|
||||
Reference in New Issue
Block a user