feat(#173): feed bulles parent branché sur GET /cards

Affiche la file d’attention pastel (assets card_*_h) filtrée
par couple courant, avec accept / refus motivé / ack.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-01 17:10:40 +02:00
co-authored by Cursor
parent 290d442123
commit d53682110d
5 changed files with 685 additions and 10 deletions
+146
View File
@@ -0,0 +1,146 @@
import 'package:p_tits_pas/models/card_assets.dart';
/// Statut d'une instance de carte (file d'attention) — contrat #194 / #173.
enum CarteStatut {
ouverte('ouverte'),
refusee('refusee'),
traitee('traitee');
final String apiValue;
const CarteStatut(this.apiValue);
static CarteStatut fromApi(String? raw) {
switch (raw) {
case 'refusee':
return CarteStatut.refusee;
case 'traitee':
return CarteStatut.traitee;
case 'ouverte':
default:
return CarteStatut.ouverte;
}
}
}
/// Mode de réponse attendu selon le type SYSTEM.
enum CarteResponseMode {
none('none'),
ack('ack'),
acceptRefuse('accept_refuse');
final String apiValue;
const CarteResponseMode(this.apiValue);
static CarteResponseMode fromApi(String? raw) {
switch (raw) {
case 'ack':
return CarteResponseMode.ack;
case 'accept_refuse':
return CarteResponseMode.acceptRefuse;
case 'none':
default:
return CarteResponseMode.none;
}
}
}
/// Bulle / carte de la file d'attention.
class CarteBulle {
final String id;
final String typeCode;
final String titre;
final String? couleur;
final String idPlacement;
final String? idAbsence;
final String operation;
final CarteStatut statut;
final Map<String, dynamic> payload;
final DateTime? purgeAt;
final String? creePar;
final bool isCreator;
final CarteResponseMode responseMode;
final String? lastRefuseComment;
final DateTime? creeLe;
final DateTime? modifieLe;
const CarteBulle({
required this.id,
required this.typeCode,
required this.titre,
this.couleur,
required this.idPlacement,
this.idAbsence,
this.operation = 'create',
required this.statut,
this.payload = const {},
this.purgeAt,
this.creePar,
this.isCreator = false,
this.responseMode = CarteResponseMode.none,
this.lastRefuseComment,
this.creeLe,
this.modifieLe,
});
String? get dateDebut => payload['date_debut']?.toString();
String? get dateFin => payload['date_fin']?.toString();
String? get motif => payload['motif']?.toString();
/// Asset horizontal pastel (palette `assets/cards/` uniquement).
String get backgroundAsset {
final key = (couleur ?? '').trim().toLowerCase();
switch (key) {
case 'red':
return CardColorHorizontal.red.path;
case 'pink':
return CardColorHorizontal.pink.path;
case 'peach':
return CardColorHorizontal.peach.path;
case 'lime':
return CardColorHorizontal.lime.path;
case 'lavender':
return CardColorHorizontal.lavender.path;
case 'green':
return CardColorHorizontal.green.path;
case 'blue':
return CardColorHorizontal.blue.path;
default:
return CardColorHorizontal.peach.path;
}
}
/// Le destinataire peut répondre (pas le créateur, carte ouverte).
bool get peutRepondre =>
!isCreator &&
statut == CarteStatut.ouverte &&
responseMode != CarteResponseMode.none;
factory CarteBulle.fromJson(Map<String, dynamic> json) {
final rawPayload = json['payload'];
return CarteBulle(
id: (json['id'] ?? '').toString(),
typeCode: (json['type_code'] ?? '').toString(),
titre: (json['titre'] ?? json['type_code'] ?? 'Carte').toString(),
couleur: json['couleur']?.toString(),
idPlacement: (json['id_placement'] ?? '').toString(),
idAbsence: json['id_absence']?.toString(),
operation: (json['operation'] ?? 'create').toString(),
statut: CarteStatut.fromApi(json['statut']?.toString()),
payload: rawPayload is Map
? Map<String, dynamic>.from(rawPayload)
: const {},
purgeAt: _parseDate(json['purge_at']),
creePar: json['cree_par']?.toString(),
isCreator: json['is_creator'] == true,
responseMode: CarteResponseMode.fromApi(json['response_mode']?.toString()),
lastRefuseComment: json['last_refuse_comment']?.toString(),
creeLe: _parseDate(json['cree_le']),
modifieLe: _parseDate(json['modifie_le']),
);
}
static DateTime? _parseDate(dynamic v) {
if (v == null) return null;
return DateTime.tryParse(v.toString());
}
}
@@ -3,14 +3,14 @@ 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';
/// Tableau de bord parent — coquille 3 colonnes quotidien (#166). /// Tableau de bord parent — coquille 3 colonnes quotidien (#166).
/// Colonne gauche : sélecteur de couple enfant–nounou (#167). /// Colonne gauche : sélecteur de couple (#167) + feed bulles (#173).
/// Métier cartes / blog / messagerie : tickets C/D/E.
class ParentDashboardScreen extends StatefulWidget { class ParentDashboardScreen extends StatefulWidget {
const ParentDashboardScreen({super.key}); const ParentDashboardScreen({super.key});
@@ -63,7 +63,6 @@ class _ParentDashboardScreenState extends State<ParentDashboardScreen> {
Future<void> _selectCouple(CoupleGarde couple) async { Future<void> _selectCouple(CoupleGarde couple) async {
if (couple.id == _selectedCoupleId) return; if (couple.id == _selectedCoupleId) return;
// Optimiste : on bascule tout de suite, l'API persiste ensuite.
setState(() => _selectedCoupleId = couple.id); setState(() => _selectedCoupleId = couple.id);
try { try {
final res = await CoupleGardeService.definirCoupleCourant(couple.id); final res = await CoupleGardeService.definirCoupleCourant(couple.id);
@@ -116,6 +115,7 @@ 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 +138,7 @@ class _ParentDashboardScreenState extends State<ParentDashboardScreen> {
} }
} }
/// Colonne gauche : bandeau couple (#167) puis flux de cartes (#173 à venir). /// Colonne gauche : bandeau couple (#167) puis flux de cartes (#173).
class _LeftColumn extends StatelessWidget { class _LeftColumn extends StatelessWidget {
final List<CoupleGarde> couples; final List<CoupleGarde> couples;
final String? selectedCoupleId; final String? selectedCoupleId;
@@ -146,6 +146,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,
@@ -154,6 +155,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 +174,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 #173).',
icon: Icons.style_outlined,
), ),
), ),
], ],
@@ -93,6 +93,10 @@ class ApiConfig {
static const String conversations = '/conversations'; static const String conversations = '/conversations';
static const String notifications = '/notifications'; static const String notifications = '/notifications';
/// File d'attention / bulles (#173 / #194).
static const String cards = '/cards';
static const String cardsStream = '/cards/stream';
// Headers // Headers
static Map<String, String> get headers => { static Map<String, String> get headers => {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -0,0 +1,87 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:p_tits_pas/models/carte_bulle.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/services/api/tokenService.dart';
/// Accès API Cartes (file d'attention) — tickets #173 / #194.
/// - GET /cards?placementId=
/// - POST /cards/:id/respond
class CardsService {
static Future<Map<String, String>> _headers() async {
final token = await TokenService.getToken();
return token != null
? ApiConfig.authHeaders(token)
: Map<String, String>.from(ApiConfig.headers);
}
static String _extractError(String body, String fallback) {
try {
final decoded = jsonDecode(body);
if (decoded is Map<String, dynamic>) {
final message = decoded['message'];
if (message is String && message.trim().isNotEmpty) {
return message;
}
if (message is Map && message['message'] is String) {
return message['message'] as String;
}
}
} catch (_) {}
return fallback;
}
/// Feed de bulles pour l'utilisateur (filtré par couple / placement).
static Future<List<CarteBulle>> lister({String? placementId}) async {
final query = <String, String>{};
if (placementId != null && placementId.isNotEmpty) {
query['placementId'] = placementId;
}
final uri = Uri.parse(
'${ApiConfig.baseUrl}${ApiConfig.cards}',
).replace(queryParameters: query.isEmpty ? null : query);
final response = await http.get(uri, headers: await _headers());
if (response.statusCode != 200) {
throw Exception(
_extractError(response.body, 'Erreur chargement des cartes'),
);
}
final decoded = jsonDecode(response.body);
final list = (decoded is Map ? decoded['items'] as List? : null) ?? const [];
return list
.whereType<Map>()
.map((e) => CarteBulle.fromJson(Map<String, dynamic>.from(e)))
.toList();
}
/// Répondre : accept | refuse | ack.
static Future<CarteBulle> repondre({
required String cardId,
required String action,
String? comment,
}) async {
final response = await http.post(
Uri.parse(
'${ApiConfig.baseUrl}${ApiConfig.cards}/$cardId/respond',
),
headers: await _headers(),
body: jsonEncode({
'action': action,
if (comment != null && comment.trim().isNotEmpty)
'comment': comment.trim(),
}),
);
if (response.statusCode != 200) {
throw Exception(
_extractError(response.body, 'Erreur lors de la réponse'),
);
}
final decoded = jsonDecode(response.body);
return CarteBulle.fromJson(Map<String, dynamic>.from(decoded as Map));
}
}
@@ -0,0 +1,438 @@
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/services/api/cards_service.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.
class CartesFeed extends StatefulWidget {
final String? placementId;
final VoidCallback? onAjouterBulle;
const CartesFeed({
super.key,
this.placementId,
this.onAjouterBulle,
});
@override
State<CartesFeed> createState() => _CartesFeedState();
}
class _CartesFeedState extends State<CartesFeed> {
List<CarteBulle> _items = const [];
bool _loading = true;
String? _error;
String? _busyCardId;
@override
void initState() {
super.initState();
_load();
}
@override
void didUpdateWidget(covariant CartesFeed oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.placementId != widget.placementId) {
_load();
}
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
try {
final items = await CardsService.lister(
placementId: widget.placementId,
);
if (!mounted) return;
setState(() {
_items = items;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString().replaceFirst('Exception: ', '');
_loading = false;
});
}
}
Future<void> _respond(
CarteBulle card, {
required String action,
String? comment,
}) async {
setState(() => _busyCardId = card.id);
try {
final updated = await CardsService.repondre(
cardId: card.id,
action: action,
comment: comment,
);
if (!mounted) return;
setState(() {
_items = [
for (final c in _items) if (c.id == updated.id) updated else c,
];
_busyCardId = null;
});
} catch (e) {
if (!mounted) return;
setState(() => _busyCardId = null);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.toString().replaceFirst('Exception: ', '')),
),
);
}
}
Future<void> _onRefuse(CarteBulle card) async {
final controller = TextEditingController();
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(
'Motiver le refus',
style: GoogleFonts.merienda(fontWeight: FontWeight.w600),
),
content: TextField(
controller: controller,
maxLines: 3,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Expliquez le motif du refus…',
border: OutlineInputBorder(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () {
if (controller.text.trim().isEmpty) return;
Navigator.pop(ctx, true);
},
child: const Text('Refuser'),
),
],
),
);
if (ok == true && controller.text.trim().isNotEmpty) {
await _respond(
card,
action: 'refuse',
comment: controller.text.trim(),
);
}
controller.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
const Icon(
Icons.style_outlined,
color: QuotidienTheme.lavender,
size: 22,
),
const SizedBox(width: 8),
Text(
'Cartes',
style: GoogleFonts.merienda(
fontSize: 18,
fontWeight: FontWeight.w600,
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()),
],
);
}
Widget _buildBody() {
if (_loading) {
return const Center(
child: SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
if (_error != null) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_error!,
textAlign: TextAlign.center,
style: GoogleFonts.merriweather(
fontSize: 13,
color: QuotidienTheme.ink,
),
),
const SizedBox(height: 8),
TextButton(onPressed: _load, child: const Text('Réessayer')),
],
),
);
}
if (_items.isEmpty) {
return Center(
child: Text(
'Aucune bulle pour le moment.\nLa file d’attention est vide — c’est normal.',
textAlign: TextAlign.center,
style: GoogleFonts.merriweather(
fontSize: 13,
color: QuotidienTheme.muted,
height: 1.4,
),
),
);
}
return RefreshIndicator(
onRefresh: _load,
child: ListView.separated(
padding: const EdgeInsets.only(bottom: 8),
itemCount: _items.length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (context, index) {
final card = _items[index];
return _BulleTile(
card: card,
busy: _busyCardId == card.id,
onAccept: () => _respond(card, action: 'accept'),
onAck: () => _respond(card, action: 'ack'),
onRefuse: () => _onRefuse(card),
);
},
),
);
}
}
class _BulleTile extends StatelessWidget {
final CarteBulle card;
final bool busy;
final VoidCallback onAccept;
final VoidCallback onAck;
final VoidCallback onRefuse;
const _BulleTile({
required this.card,
required this.busy,
required this.onAccept,
required this.onAck,
required this.onRefuse,
});
String get _periode {
final d = card.dateDebut;
final f = card.dateFin;
if (d == null && f == null) return '';
if (d != null && f != null && d == f) return _fmt(d);
return '${_fmt(d)} → ${_fmt(f)}';
}
String _fmt(String? iso) {
if (iso == null || iso.isEmpty) return '…';
final dt = DateTime.tryParse(iso);
if (dt == null) return iso;
final jj = dt.day.toString().padLeft(2, '0');
final mm = dt.month.toString().padLeft(2, '0');
return '$jj/$mm/${dt.year}';
}
String get _statutLabel {
switch (card.statut) {
case CarteStatut.ouverte:
return card.isCreator ? 'En attente' : 'À traiter';
case CarteStatut.refusee:
return 'Refusé';
case CarteStatut.traitee:
return 'Traité';
}
}
@override
Widget build(BuildContext context) {
final treated = card.statut == CarteStatut.traitee;
return Opacity(
opacity: treated ? 0.55 : 1,
child: Container(
constraints: const BoxConstraints(minHeight: 88),
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage(card.backgroundAsset),
fit: BoxFit.fill,
),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Text(
card.titre,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.merienda(
fontSize: 16,
fontWeight: FontWeight.w700,
color: QuotidienTheme.ink,
),
),
),
Text(
_statutLabel,
style: GoogleFonts.merriweather(
fontSize: 11,
color: QuotidienTheme.muted,
),
),
],
),
if (_periode.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
_periode,
style: GoogleFonts.merriweather(
fontSize: 13,
color: QuotidienTheme.ink,
),
),
],
if (card.motif != null && card.motif!.trim().isNotEmpty) ...[
const SizedBox(height: 2),
Text(
card.motif!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.merriweather(
fontSize: 12,
color: QuotidienTheme.muted,
),
),
],
if (card.statut == CarteStatut.refusee &&
card.lastRefuseComment != null &&
card.lastRefuseComment!.trim().isNotEmpty) ...[
const SizedBox(height: 4),
Text(
'Motif refus : ${card.lastRefuseComment}',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.merriweather(
fontSize: 12,
fontStyle: FontStyle.italic,
color: QuotidienTheme.ink,
),
),
],
if (card.peutRepondre) ...[
const SizedBox(height: 8),
if (busy)
const Align(
alignment: Alignment.centerLeft,
child: SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
else
Wrap(
spacing: 8,
runSpacing: 4,
children: [
if (card.responseMode == CarteResponseMode.acceptRefuse) ...[
_ActionChip(
label: 'Accepter',
onTap: onAccept,
),
_ActionChip(
label: 'Refuser',
onTap: onRefuse,
destructive: true,
),
],
if (card.responseMode == CarteResponseMode.ack)
_ActionChip(
label: 'Bien reçu',
onTap: onAck,
),
],
),
],
],
),
),
);
}
}
class _ActionChip extends StatelessWidget {
final String label;
final VoidCallback onTap;
final bool destructive;
const _ActionChip({
required this.label,
required this.onTap,
this.destructive = false,
});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.white.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(16),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Text(
label,
style: GoogleFonts.merriweather(
fontSize: 12,
fontWeight: FontWeight.w600,
color: destructive
? const Color(0xFFB00020)
: QuotidienTheme.ink,
),
),
),
),
);
}
}