Files
petitspas/frontend/lib/widgets/quotidien/cartes_feed.dart
T
jmartinandCursor 32f6eeeeed feat(#173): carte congés maquette + mini-spec bulles pour alignement back
Layout aqua (valise, période une ligne, pilules peintes, lien agenda),
encre pétrole, et docs/32 pour palette sémantique + demandes API couleur.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 17:10:41 +02:00

882 lines
28 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/carte.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> {
static const double _fadeExtent = 0.08;
List<CarteBulle> _items = const [];
bool _loading = true;
String? _error;
String? _busyCardId;
final ScrollController _scrollController = ScrollController();
bool _isScrollable = false;
bool _fadeTop = false;
bool _fadeBottom = false;
@override
void initState() {
super.initState();
_scrollController.addListener(_syncScrollFades);
_load();
}
@override
void dispose() {
_scrollController.removeListener(_syncScrollFades);
_scrollController.dispose();
super.dispose();
}
@override
void didUpdateWidget(covariant CartesFeed oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.placementId != widget.placementId) {
_load();
}
}
void _syncScrollFades() {
if (!_scrollController.hasClients) return;
final position = _scrollController.position;
final scrollable = position.maxScrollExtent > 0.0;
final threshold = position.viewportDimension * _fadeExtent / 2;
final fadeTop = scrollable && position.pixels > threshold;
final fadeBottom = scrollable &&
position.pixels < (position.maxScrollExtent - threshold);
if (scrollable != _isScrollable ||
fadeTop != _fadeTop ||
fadeBottom != _fadeBottom) {
setState(() {
_isScrollable = scrollable;
_fadeTop = fadeTop;
_fadeBottom = fadeBottom;
});
}
}
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;
});
WidgetsBinding.instance.addPostFrameCallback((_) => _syncScrollFades());
} 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> _onAccept(CarteBulle card) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(
'Accepter la demande ?',
style: GoogleFonts.merienda(fontWeight: FontWeight.w600),
),
content: Text(
'Confirmez-vous l’acceptation de « ${card.titreAffiche} » '
'(${_periodeCourte(card)}) ?',
style: GoogleFonts.merriweather(fontSize: 14, height: 1.4),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Confirmer'),
),
],
),
);
if (ok == true) {
await _respond(card, action: 'accept');
}
}
Future<void> _onAck(CarteBulle card) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(
'Prise en compte',
style: GoogleFonts.merienda(fontWeight: FontWeight.w600),
),
content: Text(
'Confirmez-vous avoir bien reçu cet arrêt maladie '
'(${_periodeCourte(card)}) ?',
style: GoogleFonts.merriweather(fontSize: 14, height: 1.4),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Bien reçu'),
),
],
),
);
if (ok == true) {
await _respond(card, action: 'ack');
}
}
Future<void> _onRefuse(CarteBulle card) async {
final controller = TextEditingController();
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(
'Refuser la demande',
style: GoogleFonts.merienda(fontWeight: FontWeight.w600),
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Indiquez le motif du refus pour « ${card.titreAffiche} ».',
style: GoogleFonts.merriweather(fontSize: 14, height: 1.4),
),
const SizedBox(height: 12),
TextField(
controller: controller,
maxLines: 3,
autofocus: true,
decoration: const InputDecoration(
hintText: '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();
}
static String _periodeCourte(CarteBulle card) {
final d = card.dateDebut;
final f = card.dateFin;
String fmt(String? iso) {
if (iso == null || iso.isEmpty) return '…';
final dt = DateTime.tryParse(iso);
if (dt == null) return iso;
return '${dt.day.toString().padLeft(2, '0')}/'
'${dt.month.toString().padLeft(2, '0')}/${dt.year}';
}
if (d == null && f == null) return 'période non précisée';
if (d != null && f != null && d == f) return fmt(d);
return '${fmt(d)} → ${fmt(f)}';
}
@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 NotificationListener<ScrollMetricsNotification>(
onNotification: (_) {
_syncScrollFades();
return false;
},
child: ShaderMask(
blendMode: BlendMode.dstIn,
shaderCallback: (Rect bounds) {
final stops = <double>[
0.0,
_fadeExtent,
1.0 - _fadeExtent,
1.0,
];
if (!_isScrollable) {
return const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[
Colors.black,
Colors.black,
Colors.black,
Colors.black,
],
stops: [0.0, 0.08, 0.92, 1.0],
).createShader(bounds);
}
final topMask = _fadeTop ? Colors.transparent : Colors.black;
final bottomMask = _fadeBottom ? Colors.transparent : Colors.black;
return LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[
topMask,
Colors.black,
Colors.black,
bottomMask,
],
stops: stops,
).createShader(bounds);
},
child: RefreshIndicator(
onRefresh: _load,
child: ListView.separated(
controller: _scrollController,
padding: const EdgeInsets.only(top: 4, 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: () => _onAccept(card),
onAck: () => _onAck(card),
onRefuse: () => _onRefuse(card),
);
},
),
),
),
);
}
}
class _BulleTile extends StatelessWidget {
/// Encre aqua foncé — harmonisée fond aqua / valise (pas le violet).
static const Color _aquaInk = Color(0xFF315F63);
/// Date : même famille, ~12 % plus claire.
static const Color _aquaDate = Color(0xFF4A7276);
/// Lien agenda : légère dérive bleue.
static const Color _aquaLink = Color(0xFF2B5868);
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,
});
static const _mois = [
'janvier',
'février',
'mars',
'avril',
'mai',
'juin',
'juillet',
'août',
'septembre',
'octobre',
'novembre',
'décembre',
];
String _fmtLong(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');
return '$jj ${_mois[dt.month - 1]} ${dt.year}';
}
/// Une ligne type maquette : « Du 04 au 06 novembre 2026 ».
String _fmtPeriodeUneLigne() {
final d1 = DateTime.tryParse(card.dateDebut ?? '');
final d2 = DateTime.tryParse(card.dateFin ?? '');
if (d1 == null && d2 == null) return '';
if (d1 != null && d2 == null) return 'Le ${_fmtLong(card.dateDebut)}';
if (d1 == null && d2 != null) return 'Au ${_fmtLong(card.dateFin)}';
final a = d1!;
final b = d2!;
final j1 = a.day.toString().padLeft(2, '0');
final j2 = b.day.toString().padLeft(2, '0');
if (a.year == b.year && a.month == b.month && a.day == b.day) {
return 'Le $j1 ${_mois[a.month - 1]} ${a.year}';
}
if (a.year == b.year && a.month == b.month) {
return 'Du $j1 au $j2 ${_mois[a.month - 1]} ${a.year}';
}
if (a.year == b.year) {
return 'Du $j1 ${_mois[a.month - 1]} au $j2 ${_mois[b.month - 1]} ${a.year}';
}
return 'Du $j1 ${_mois[a.month - 1]} ${a.year} '
'au $j2 ${_mois[b.month - 1]} ${b.year}';
}
/// Arrêt maladie : reste opaque tant que la période n’est pas finie.
bool get _attenuee {
if (card.isArretMaladie && card.periodeEncoreActive) return false;
return card.statut == CarteStatut.traitee;
}
@override
Widget build(BuildContext context) {
if (card.isCongeAm) return _buildConge(context);
return _buildGeneric(context);
}
Widget _buildConge(BuildContext context) {
final periode = _fmtPeriodeUneLigne();
return Opacity(
opacity: _attenuee ? 0.55 : 1,
child: Carte(
assetPath: card.backgroundAsset,
constraints: const BoxConstraints(minHeight: 140),
padding: const EdgeInsets.fromLTRB(22, 18, 18, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
card.titreAffiche,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.merienda(
fontSize: 18,
fontWeight: FontWeight.w700,
color: _aquaInk,
),
),
if (periode.isNotEmpty) ...[
const SizedBox(height: 10),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Image.asset(
'assets/images/cartes/icon_calendrier_crayon.png',
width: 28,
height: 28,
fit: BoxFit.contain,
filterQuality: FilterQuality.high,
),
const SizedBox(width: 8),
Expanded(
child: Text(
periode,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.merienda(
fontSize: 15,
fontWeight: FontWeight.w600,
color: _aquaDate,
height: 1.25,
),
),
),
],
),
],
if (card.statut == CarteStatut.refusee) ...[
const SizedBox(height: 8),
Text(
card.lastRefuseComment != null &&
card.lastRefuseComment!.trim().isNotEmpty
? 'Refusé — ${card.lastRefuseComment}'
: 'Refusé',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.merriweather(
fontSize: 12,
fontStyle: FontStyle.italic,
color: _aquaInk,
),
),
] else if (card.statut == CarteStatut.ouverte &&
card.isCreator) ...[
const SizedBox(height: 8),
Text(
'En attente de réponse',
style: GoogleFonts.merriweather(
fontSize: 12,
color: QuotidienTheme.muted,
),
),
],
],
),
),
if (card.iconAsset != null) ...[
const SizedBox(width: 8),
Image.asset(
card.iconAsset!,
width: 72,
height: 72,
fit: BoxFit.contain,
filterQuality: FilterQuality.high,
),
],
],
),
const SizedBox(height: 10),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (card.peutRepondre)
busy
? const SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
_PilulePeinteButton(
asset:
'assets/images/cartes/btn_coche_pilule.png',
tooltip: 'Accepter',
onTap: onAccept,
),
const SizedBox(width: 10),
_PilulePeinteButton(
asset:
'assets/images/cartes/btn_croix_pilule.png',
tooltip: 'Refuser',
onTap: onRefuse,
),
],
),
const Spacer(),
InkWell(
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Agenda — à venir.'),
duration: Duration(seconds: 2),
),
);
},
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 4,
),
child: Text(
'Ouvrir dans l’agenda ↗',
style: GoogleFonts.merienda(
fontSize: 13,
fontWeight: FontWeight.w600,
color: _aquaLink,
decoration: TextDecoration.underline,
decorationColor: _aquaLink,
),
),
),
),
],
),
],
),
),
);
}
Widget _buildGeneric(BuildContext context) {
final memeJour = card.dateDebut != null &&
card.dateFin != null &&
card.dateDebut == card.dateFin;
return Opacity(
opacity: _attenuee ? 0.55 : 1,
child: Carte(
assetPath: card.backgroundAsset,
constraints: const BoxConstraints(minHeight: 120),
padding: const EdgeInsets.fromLTRB(22, 16, 22, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
card.titreAffiche,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.merienda(
fontSize: 18,
fontWeight: FontWeight.w700,
color: QuotidienTheme.ink,
),
),
if (card.dateDebut != null || card.dateFin != null) ...[
const SizedBox(height: 10),
Padding(
padding: const EdgeInsets.only(left: 12),
child: memeJour
? Text(
'Le ${_fmtLong(card.dateDebut)}',
style: GoogleFonts.merienda(
fontSize: 17,
fontWeight: FontWeight.w600,
color: QuotidienTheme.ink,
height: 1.35,
),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Du ${_fmtLong(card.dateDebut)}',
style: GoogleFonts.merienda(
fontSize: 17,
fontWeight: FontWeight.w600,
color: QuotidienTheme.ink,
height: 1.35,
),
),
Text(
'Au ${_fmtLong(card.dateFin)}',
style: GoogleFonts.merienda(
fontSize: 17,
fontWeight: FontWeight.w600,
color: QuotidienTheme.ink,
height: 1.35,
),
),
],
),
),
],
],
),
),
if (card.iconAsset != null) ...[
const SizedBox(width: 10),
Image.asset(
card.iconAsset!,
width: 80,
height: 80,
fit: BoxFit.contain,
filterQuality: FilterQuality.high,
),
],
],
),
if (card.statut == CarteStatut.refusee) ...[
const SizedBox(height: 6),
Text(
card.lastRefuseComment != null &&
card.lastRefuseComment!.trim().isNotEmpty
? 'Refusé — ${card.lastRefuseComment}'
: 'Refusé',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.merriweather(
fontSize: 12,
fontStyle: FontStyle.italic,
color: QuotidienTheme.ink,
),
),
] else if (card.statut == CarteStatut.ouverte &&
card.isCreator) ...[
const SizedBox(height: 6),
Text(
'En attente de réponse',
style: GoogleFonts.merriweather(
fontSize: 12,
color: QuotidienTheme.muted,
),
),
],
if (card.peutRepondre) ...[
const SizedBox(height: 14),
if (busy)
const Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
else if (card.responseMode == CarteResponseMode.acceptRefuse)
Row(
children: [
Expanded(
child: _ActionPill(
asset: QuotidienTheme.pillTurquoiseAsset,
iconAsset: 'assets/images/coche.png',
tooltip: 'Accepter',
onTap: onAccept,
),
),
const SizedBox(width: 12),
Expanded(
child: _ActionPill(
asset: QuotidienTheme.pillCorailAsset,
iconAsset: 'assets/images/red_cross2.png',
tooltip: 'Refuser',
onTap: onRefuse,
),
),
],
)
else if (card.responseMode == CarteResponseMode.ack)
_ActionPill(
asset: QuotidienTheme.pillTurquoiseAsset,
iconAsset: 'assets/images/coche.png',
tooltip: 'Bien reçu',
onTap: onAck,
),
],
],
),
),
);
}
}
/// Bouton pilule peinte (coche / croix intégrées) — carte congés.
class _PilulePeinteButton extends StatelessWidget {
final String asset;
final String tooltip;
final VoidCallback onTap;
const _PilulePeinteButton({
required this.asset,
required this.tooltip,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(20),
child: Image.asset(
asset,
height: 36,
fit: BoxFit.contain,
filterQuality: FilterQuality.high,
),
),
);
}
}
/// Bouton pilule dessiné (fond pastel + coche / croix).
class _ActionPill extends StatelessWidget {
final String asset;
final String iconAsset;
final String tooltip;
final VoidCallback onTap;
const _ActionPill({
required this.asset,
required this.iconAsset,
required this.tooltip,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(24),
child: SizedBox(
height: 40,
child: Stack(
alignment: Alignment.center,
children: [
Positioned.fill(
child: Image.asset(asset, fit: BoxFit.fill),
),
Image.asset(
iconAsset,
height: 22,
width: 22,
fit: BoxFit.contain,
),
],
),
),
),
);
}
}