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:
@@ -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/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<ParentDashboardScreen> {
|
||||
|
||||
Future<void> _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<ParentDashboardScreen> {
|
||||
error: _couplesError,
|
||||
onRetry: _loadCouples,
|
||||
onCoupleSelected: _selectCouple,
|
||||
onAjouterBulle: () => _soon('Ajouter une bulle'),
|
||||
),
|
||||
centerColumn: const QuotidienColumnPlaceholder(
|
||||
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 {
|
||||
final List<CoupleGarde> couples;
|
||||
final String? selectedCoupleId;
|
||||
@@ -146,6 +146,7 @@ class _LeftColumn extends StatelessWidget {
|
||||
final String? error;
|
||||
final VoidCallback onRetry;
|
||||
final ValueChanged<CoupleGarde> 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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<String, String> get headers => {
|
||||
'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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user