diff --git a/frontend/lib/models/carte_bulle.dart b/frontend/lib/models/carte_bulle.dart new file mode 100644 index 0000000..94a3665 --- /dev/null +++ b/frontend/lib/models/carte_bulle.dart @@ -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 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 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.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()); + } +} diff --git a/frontend/lib/screens/home/parent_screen/ParentDashboardScreen.dart b/frontend/lib/screens/home/parent_screen/ParentDashboardScreen.dart index e2d2e82..568d77f 100644 --- a/frontend/lib/screens/home/parent_screen/ParentDashboardScreen.dart +++ b/frontend/lib/screens/home/parent_screen/ParentDashboardScreen.dart @@ -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/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'; /// Tableau de bord parent — coquille 3 colonnes quotidien (#166). -/// Colonne gauche : sélecteur de couple enfant–nounou (#167). -/// Métier cartes / blog / messagerie : tickets C/D/E. +/// Colonne gauche : sélecteur de couple (#167) + feed bulles (#173). class ParentDashboardScreen extends StatefulWidget { const ParentDashboardScreen({super.key}); @@ -63,7 +63,6 @@ class _ParentDashboardScreenState extends State { Future _selectCouple(CoupleGarde couple) async { if (couple.id == _selectedCoupleId) return; - // Optimiste : on bascule tout de suite, l'API persiste ensuite. setState(() => _selectedCoupleId = couple.id); try { final res = await CoupleGardeService.definirCoupleCourant(couple.id); @@ -116,6 +115,7 @@ class _ParentDashboardScreenState extends State { error: _couplesError, onRetry: _loadCouples, onCoupleSelected: _selectCouple, + onAjouterBulle: () => _soon('Ajouter une bulle'), ), centerColumn: const QuotidienColumnPlaceholder( title: 'Blog', @@ -138,7 +138,7 @@ class _ParentDashboardScreenState extends State { } } -/// 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 { final List couples; final String? selectedCoupleId; @@ -146,6 +146,7 @@ class _LeftColumn extends StatelessWidget { final String? error; final VoidCallback onRetry; final ValueChanged onCoupleSelected; + final VoidCallback onAjouterBulle; const _LeftColumn({ required this.couples, @@ -154,6 +155,7 @@ class _LeftColumn extends StatelessWidget { required this.error, required this.onRetry, required this.onCoupleSelected, + required this.onAjouterBulle, }); @override @@ -172,12 +174,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 #173).', - icon: Icons.style_outlined, + Expanded( + child: CartesFeed( + placementId: selectedCoupleId, + onAjouterBulle: onAjouterBulle, ), ), ], diff --git a/frontend/lib/services/api/api_config.dart b/frontend/lib/services/api/api_config.dart index 4c67371..717af7f 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -93,6 +93,10 @@ class ApiConfig { static const String conversations = '/conversations'; static const String notifications = '/notifications'; + /// File d'attention / bulles (#173 / #194). + static const String cards = '/cards'; + static const String cardsStream = '/cards/stream'; + // Headers static Map get headers => { 'Content-Type': 'application/json', diff --git a/frontend/lib/services/api/cards_service.dart b/frontend/lib/services/api/cards_service.dart new file mode 100644 index 0000000..14f64f9 --- /dev/null +++ b/frontend/lib/services/api/cards_service.dart @@ -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> _headers() async { + final token = await TokenService.getToken(); + return token != null + ? ApiConfig.authHeaders(token) + : Map.from(ApiConfig.headers); + } + + static String _extractError(String body, String fallback) { + try { + final decoded = jsonDecode(body); + if (decoded is Map) { + 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> lister({String? placementId}) async { + final query = {}; + 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((e) => CarteBulle.fromJson(Map.from(e))) + .toList(); + } + + /// Répondre : accept | refuse | ack. + static Future 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.from(decoded as Map)); + } +} diff --git a/frontend/lib/widgets/quotidien/cartes_feed.dart b/frontend/lib/widgets/quotidien/cartes_feed.dart new file mode 100644 index 0000000..e8b37b3 --- /dev/null +++ b/frontend/lib/widgets/quotidien/cartes_feed.dart @@ -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 createState() => _CartesFeedState(); +} + +class _CartesFeedState extends State { + List _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 _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 _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 _onRefuse(CarteBulle card) async { + final controller = TextEditingController(); + final ok = await showDialog( + 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, + ), + ), + ), + ), + ); + } +}