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/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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user