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,
|
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 dateDebut => payload['date_debut']?.toString();
|
||||||
String? get dateFin => payload['date_fin']?.toString();
|
String? get dateFin => payload['date_fin']?.toString();
|
||||||
String? get motif => payload['motif']?.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/models/user.dart';
|
||||||
import 'package:p_tits_pas/services/auth_service.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/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/couple_selector_bandeau.dart';
|
||||||
import 'package:p_tits_pas/widgets/quotidien/quotidien_shell.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/widgets/quotidien/quotidien_theme.dart';
|
||||||
import 'package:p_tits_pas/screens/home/parent_screen/agenda_absences_stub.dart';
|
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 de couple enfant–parent(s) (#170).
|
/// Colonne gauche : sélecteur couple (#170) + feed bulles (#174).
|
||||||
/// Métier cartes / blog / messagerie : tickets C/D/E.
|
/// Création « Ajouter une bulle » (congé / arrêt) : ticket #176.
|
||||||
class AmDashboardScreen extends StatefulWidget {
|
class AmDashboardScreen extends StatefulWidget {
|
||||||
const AmDashboardScreen({super.key});
|
const AmDashboardScreen({super.key});
|
||||||
|
|
||||||
@@ -116,6 +117,7 @@ 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',
|
||||||
@@ -138,6 +140,7 @@ class _AmDashboardScreenState extends State<AmDashboardScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Colonne gauche : bandeau couple AM (#170) puis flux de cartes (#174).
|
||||||
class _LeftColumn extends StatelessWidget {
|
class _LeftColumn extends StatelessWidget {
|
||||||
final List<CoupleGarde> couples;
|
final List<CoupleGarde> couples;
|
||||||
final String? selectedCoupleId;
|
final String? selectedCoupleId;
|
||||||
@@ -145,6 +148,7 @@ 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,
|
||||||
@@ -153,6 +157,7 @@ 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
|
||||||
@@ -172,12 +177,10 @@ class _LeftColumn extends StatelessWidget {
|
|||||||
onCoupleSelected: onCoupleSelected,
|
onCoupleSelected: onCoupleSelected,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
const Expanded(
|
Expanded(
|
||||||
child: QuotidienColumnPlaceholder(
|
child: CartesFeed(
|
||||||
title: 'Cartes',
|
placementId: selectedCoupleId,
|
||||||
subtitle:
|
onAjouterBulle: onAjouterBulle,
|
||||||
'Absences, congés AM, sorties à valider\n(à brancher — ticket #174).',
|
|
||||||
icon: Icons.style_outlined,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:p_tits_pas/models/card_assets.dart';
|
import 'package:p_tits_pas/models/card_assets.dart';
|
||||||
import 'package:p_tits_pas/models/carte_bulle.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_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/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';
|
||||||
|
|
||||||
/// Feed de bulles (file d'attention) — ticket #173.
|
/// Feed de bulles (file d'attention) — tickets #173 (parent) / #174 (AM).
|
||||||
/// Widget partagé parent / AM ; 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;
|
||||||
final VoidCallback? onAjouterBulle;
|
final VoidCallback? onAjouterBulle;
|
||||||
@@ -37,15 +40,23 @@ class _CartesFeedState extends State<CartesFeed> {
|
|||||||
bool _fadeBottom = false;
|
bool _fadeBottom = false;
|
||||||
|
|
||||||
CardsStreamClient? _stream;
|
CardsStreamClient? _stream;
|
||||||
|
String? _userId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_scrollController.addListener(_syncScrollFades);
|
_scrollController.addListener(_syncScrollFades);
|
||||||
|
unawaited(_loadUserId());
|
||||||
_load();
|
_load();
|
||||||
_startStream();
|
_startStream();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadUserId() async {
|
||||||
|
final user = await AuthService.getCurrentUser();
|
||||||
|
if (!mounted) return;
|
||||||
|
_userId = user?.id;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_stream?.stop();
|
_stream?.stop();
|
||||||
@@ -73,9 +84,25 @@ class _CartesFeedState extends State<CartesFeed> {
|
|||||||
)..start();
|
)..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) {
|
void _onStreamEvent(CardsStreamEvent event) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (event.type == 'heartbeat') 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') {
|
if (event.type == 'card.deleted') {
|
||||||
final id = event.cardId;
|
final id = event.cardId;
|
||||||
@@ -90,26 +117,36 @@ class _CartesFeedState extends State<CartesFeed> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final card = event.card;
|
final raw = event.card;
|
||||||
if (card == null) return;
|
if (raw == null) return;
|
||||||
|
|
||||||
final filter = widget.placementId;
|
final filter = widget.placementId;
|
||||||
final matchesFilter = filter == null ||
|
final matchesFilter = filter == null ||
|
||||||
filter.isEmpty ||
|
filter.isEmpty ||
|
||||||
card.idPlacement == filter;
|
raw.idPlacement == filter;
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
if (!matchesFilter) {
|
if (!matchesFilter) {
|
||||||
_items = _trier([
|
_items = _trier([
|
||||||
for (final c in _items)
|
for (final c in _items)
|
||||||
if (c.id != card.id) c,
|
if (c.id != raw.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,
|
|
||||||
]);
|
]);
|
||||||
} else {
|
} 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());
|
WidgetsBinding.instance.addPostFrameCallback((_) => _syncScrollFades());
|
||||||
@@ -134,8 +171,9 @@ class _CartesFeedState extends State<CartesFeed> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Aligné sur le tri API : refusées → ouvertes → traitées, puis
|
/// Tri stable partagé : refusées → ouvertes → traitées, puis
|
||||||
/// `modifie_le` DESC, `id` en départage (ordre stable entre parents).
|
/// `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) {
|
static List<CarteBulle> _trier(List<CarteBulle> items) {
|
||||||
int rang(CarteStatut s) {
|
int rang(CarteStatut s) {
|
||||||
switch (s) {
|
switch (s) {
|
||||||
@@ -152,10 +190,10 @@ class _CartesFeedState extends State<CartesFeed> {
|
|||||||
out.sort((a, b) {
|
out.sort((a, b) {
|
||||||
final byStatut = rang(a.statut).compareTo(rang(b.statut));
|
final byStatut = rang(a.statut).compareTo(rang(b.statut));
|
||||||
if (byStatut != 0) return byStatut;
|
if (byStatut != 0) return byStatut;
|
||||||
final am = a.modifieLe?.millisecondsSinceEpoch ?? 0;
|
final ac = a.creeLe?.millisecondsSinceEpoch ?? 0;
|
||||||
final bm = b.modifieLe?.millisecondsSinceEpoch ?? 0;
|
final bc = b.creeLe?.millisecondsSinceEpoch ?? 0;
|
||||||
final byModif = bm.compareTo(am); // DESC
|
final byCree = bc.compareTo(ac); // DESC
|
||||||
if (byModif != 0) return byModif;
|
if (byCree != 0) return byCree;
|
||||||
return a.id.compareTo(b.id);
|
return a.id.compareTo(b.id);
|
||||||
});
|
});
|
||||||
return out;
|
return out;
|
||||||
@@ -580,20 +618,27 @@ class _BulleTile extends StatelessWidget {
|
|||||||
return card.statut == CarteStatut.traitee;
|
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 {
|
bool get _montreStatutReponse {
|
||||||
if (card.peutRepondre) return false;
|
if (card.peutRepondre) return false;
|
||||||
if (card.isCongeAm &&
|
if (card.isCongeAm) {
|
||||||
(card.statut == CarteStatut.traitee ||
|
if (card.statut == CarteStatut.ouverte) return card.isCreator;
|
||||||
card.statut == CarteStatut.refusee)) {
|
return card.statut == CarteStatut.traitee ||
|
||||||
return true;
|
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 &&
|
return card.responseMode == CarteResponseMode.ack &&
|
||||||
card.statut == CarteStatut.traitee;
|
card.statut == CarteStatut.traitee;
|
||||||
}
|
}
|
||||||
|
|
||||||
String get _libelleStatutReponse {
|
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 ».
|
// Accordé à « Demande de congés ».
|
||||||
return card.statut == CarteStatut.refusee ? 'Refusée' : 'Validée';
|
return card.statut == CarteStatut.refusee ? 'Refusée' : 'Validée';
|
||||||
}
|
}
|
||||||
@@ -675,20 +720,6 @@ class _BulleTile extends StatelessWidget {
|
|||||||
color: encre.ink,
|
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,
|
_libelleStatutReponse,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: GoogleFonts.merienda(
|
style: GoogleFonts.merienda(
|
||||||
fontSize: 14,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
fontStyle: FontStyle.italic,
|
fontStyle: FontStyle.italic,
|
||||||
color: encre.ink.withValues(alpha: 0.75),
|
color: encre.ink.withValues(alpha: 0.75),
|
||||||
|
|||||||
Reference in New Issue
Block a user