Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a36e9ef176 | ||
|
|
5d36b0f4c1 | ||
|
|
ad48ab28cc | ||
|
|
342686f4e0 |
Binary file not shown.
|
After Width: | Height: | Size: 310 KiB |
@@ -80,6 +80,32 @@ class CarteBulle {
|
||||
this.modifieLe,
|
||||
});
|
||||
|
||||
CarteBulle copyWith({
|
||||
CarteStatut? statut,
|
||||
bool? isCreator,
|
||||
String? lastRefuseComment,
|
||||
DateTime? modifieLe,
|
||||
Map<String, dynamic>? payload,
|
||||
}) {
|
||||
return CarteBulle(
|
||||
id: id,
|
||||
typeCode: typeCode,
|
||||
titre: titre,
|
||||
idPlacement: idPlacement,
|
||||
idAbsence: idAbsence,
|
||||
operation: operation,
|
||||
statut: statut ?? this.statut,
|
||||
payload: payload ?? this.payload,
|
||||
purgeAt: purgeAt,
|
||||
creePar: creePar,
|
||||
isCreator: isCreator ?? this.isCreator,
|
||||
responseMode: responseMode,
|
||||
lastRefuseComment: lastRefuseComment ?? this.lastRefuseComment,
|
||||
creeLe: creeLe,
|
||||
modifieLe: modifieLe ?? this.modifieLe,
|
||||
);
|
||||
}
|
||||
|
||||
String? get dateDebut => payload['date_debut']?.toString();
|
||||
String? get dateFin => payload['date_fin']?.toString();
|
||||
String? get motif => payload['motif']?.toString();
|
||||
@@ -113,6 +139,21 @@ class CarteBulle {
|
||||
bool get isArretMaladie => typeCode == 'arret_maladie_am';
|
||||
bool get isCongeAm => typeCode == 'conge_am';
|
||||
bool get isSoinEnfant => typeCode == 'soin_enfant';
|
||||
bool get isAbsenceEnfant =>
|
||||
typeCode == 'absence_enfant' || typeCode == 'absence_enfant_modif';
|
||||
|
||||
/// Info seule (`response_mode: none`) : créée déjà `traitee`, mais reste
|
||||
/// pertinente dans la file tant que la période n’est pas finie.
|
||||
bool get restePleineDansFile {
|
||||
if (statut == CarteStatut.ouverte) return true;
|
||||
if (statut != CarteStatut.traitee) return false;
|
||||
if (!periodeEncoreActive) return false;
|
||||
// Arrêt maladie ack : opaque jusqu’à date_fin.
|
||||
if (isArretMaladie) return true;
|
||||
// Absence (et autres infos) : pas d’atténuation tant que période active.
|
||||
if (responseMode == CarteResponseMode.none) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Icône dessin (haut droite) selon le type de bulle.
|
||||
String? get iconAsset {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/// Configuration UI d'un type de carte — catalogue front (#175).
|
||||
/// Les types émissibles viennent de l'API ; ce fichier affine libellés / hints.
|
||||
enum CartePeriodeMode {
|
||||
/// Plage date_debut → date_fin.
|
||||
range,
|
||||
|
||||
/// Une seule date (début = fin).
|
||||
singleDay,
|
||||
}
|
||||
|
||||
class CarteSousTypeUi {
|
||||
final String code;
|
||||
final String label;
|
||||
|
||||
const CarteSousTypeUi({required this.code, required this.label});
|
||||
}
|
||||
|
||||
class CarteTypeUi {
|
||||
final String typeCode;
|
||||
final String label;
|
||||
final String motifHint;
|
||||
final bool motifRequired;
|
||||
final CartePeriodeMode periodeMode;
|
||||
final List<CarteSousTypeUi> sousTypes;
|
||||
|
||||
const CarteTypeUi({
|
||||
required this.typeCode,
|
||||
required this.label,
|
||||
this.motifHint = 'Motif (optionnel)',
|
||||
this.motifRequired = false,
|
||||
this.periodeMode = CartePeriodeMode.range,
|
||||
this.sousTypes = const [],
|
||||
});
|
||||
|
||||
static const _catalog = <String, CarteTypeUi>{
|
||||
'absence_enfant': CarteTypeUi(
|
||||
typeCode: 'absence_enfant',
|
||||
label: 'Notification d’absence',
|
||||
motifHint: 'Motif de l’absence (optionnel)',
|
||||
),
|
||||
// absence_enfant_modif : hors scope cartes — via agenda uniquement.
|
||||
'conge_am': CarteTypeUi(
|
||||
typeCode: 'conge_am',
|
||||
label: 'Demande de congés',
|
||||
motifHint: 'Précisions (optionnel)',
|
||||
),
|
||||
'arret_maladie_am': CarteTypeUi(
|
||||
typeCode: 'arret_maladie_am',
|
||||
label: 'Arrêt maladie',
|
||||
motifHint: 'Précisions (optionnel)',
|
||||
),
|
||||
// Point d’extension #soins — visible dès que le back l’émet.
|
||||
'soin_enfant': CarteTypeUi(
|
||||
typeCode: 'soin_enfant',
|
||||
label: 'Soin / bobo',
|
||||
motifHint: 'Décrivez le soin ou le bobo',
|
||||
motifRequired: true,
|
||||
periodeMode: CartePeriodeMode.singleDay,
|
||||
sousTypes: [
|
||||
CarteSousTypeUi(code: 'temperature', label: 'Température'),
|
||||
CarteSousTypeUi(code: 'bobo', label: 'Bobo'),
|
||||
CarteSousTypeUi(code: 'medicament', label: 'Médicament'),
|
||||
CarteSousTypeUi(code: 'autre', label: 'Autre'),
|
||||
],
|
||||
),
|
||||
};
|
||||
|
||||
/// Fusionne un type API avec le catalogue ; fallback générique sinon.
|
||||
static CarteTypeUi resolve({
|
||||
required String typeCode,
|
||||
String? apiTitre,
|
||||
}) {
|
||||
final known = _catalog[typeCode];
|
||||
if (known != null) return known;
|
||||
final titre = (apiTitre ?? '').trim();
|
||||
return CarteTypeUi(
|
||||
typeCode: typeCode,
|
||||
label: titre.isNotEmpty ? titre : typeCode,
|
||||
motifHint: 'Motif (optionnel)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Type SYSTEM émissible renvoyé par GET /cards/types.
|
||||
class CarteTypeEmissible {
|
||||
final String code;
|
||||
final String titre;
|
||||
final String? responseMode;
|
||||
final CarteTypeUi ui;
|
||||
|
||||
const CarteTypeEmissible({
|
||||
required this.code,
|
||||
required this.titre,
|
||||
this.responseMode,
|
||||
required this.ui,
|
||||
});
|
||||
|
||||
factory CarteTypeEmissible.fromJson(Map<String, dynamic> json) {
|
||||
final code = (json['code'] ?? '').toString();
|
||||
final titre = (json['titre'] ?? code).toString();
|
||||
return CarteTypeEmissible(
|
||||
code: code,
|
||||
titre: titre,
|
||||
responseMode: json['response_mode']?.toString(),
|
||||
ui: CarteTypeUi.resolve(typeCode: code, apiTitre: titre),
|
||||
);
|
||||
}
|
||||
|
||||
String get label => ui.label;
|
||||
}
|
||||
@@ -3,14 +3,15 @@ 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';
|
||||
|
||||
/// Dashboard assistante maternelle – coquille 3 colonnes quotidien (#169).
|
||||
/// Colonne gauche : sélecteur de couple enfant–parent(s) (#170).
|
||||
/// Métier cartes / blog / messagerie : tickets C/D/E.
|
||||
/// Colonne gauche : sélecteur couple (#170) + feed bulles (#174).
|
||||
/// Création « Ajouter une carte » : modale générique (#175, réutilisée #176).
|
||||
class AmDashboardScreen extends StatefulWidget {
|
||||
const AmDashboardScreen({super.key});
|
||||
|
||||
@@ -138,6 +139,7 @@ class _AmDashboardScreenState extends State<AmDashboardScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Colonne gauche : bandeau couple AM (#170) puis flux de cartes (#174 / #175).
|
||||
class _LeftColumn extends StatelessWidget {
|
||||
final List<CoupleGarde> couples;
|
||||
final String? selectedCoupleId;
|
||||
@@ -172,12 +174,9 @@ 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 #174).',
|
||||
icon: Icons.style_outlined,
|
||||
Expanded(
|
||||
child: CartesFeed(
|
||||
placementId: selectedCoupleId,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -115,7 +115,6 @@ class _ParentDashboardScreenState extends State<ParentDashboardScreen> {
|
||||
error: _couplesError,
|
||||
onRetry: _loadCouples,
|
||||
onCoupleSelected: _selectCouple,
|
||||
onAjouterBulle: () => _soon('Ajouter une bulle'),
|
||||
),
|
||||
centerColumn: const QuotidienColumnPlaceholder(
|
||||
title: 'Blog',
|
||||
@@ -138,7 +137,7 @@ class _ParentDashboardScreenState extends State<ParentDashboardScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Colonne gauche : bandeau couple (#167) puis flux de cartes (#173).
|
||||
/// Colonne gauche : bandeau couple (#167) puis flux de cartes (#173 / #175).
|
||||
class _LeftColumn extends StatelessWidget {
|
||||
final List<CoupleGarde> couples;
|
||||
final String? selectedCoupleId;
|
||||
@@ -146,7 +145,6 @@ class _LeftColumn extends StatelessWidget {
|
||||
final String? error;
|
||||
final VoidCallback onRetry;
|
||||
final ValueChanged<CoupleGarde> onCoupleSelected;
|
||||
final VoidCallback onAjouterBulle;
|
||||
|
||||
const _LeftColumn({
|
||||
required this.couples,
|
||||
@@ -155,7 +153,6 @@ class _LeftColumn extends StatelessWidget {
|
||||
required this.error,
|
||||
required this.onRetry,
|
||||
required this.onCoupleSelected,
|
||||
required this.onAjouterBulle,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -177,7 +174,6 @@ class _LeftColumn extends StatelessWidget {
|
||||
Expanded(
|
||||
child: CartesFeed(
|
||||
placementId: selectedCoupleId,
|
||||
onAjouterBulle: onAjouterBulle,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -2,11 +2,14 @@ import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:p_tits_pas/models/carte_bulle.dart';
|
||||
import 'package:p_tits_pas/models/carte_type_ui.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.
|
||||
/// Accès API Cartes (file d'attention) — tickets #173 / #175 / #194.
|
||||
/// - GET /cards?placementId=
|
||||
/// - GET /cards/types
|
||||
/// - POST /cards
|
||||
/// - POST /cards/:id/respond
|
||||
class CardsService {
|
||||
static Future<Map<String, String>> _headers() async {
|
||||
@@ -57,6 +60,60 @@ class CardsService {
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Types SYSTEM émissibles pour le rôle connecté.
|
||||
static Future<List<CarteTypeEmissible>> listerTypes() async {
|
||||
final response = await http.get(
|
||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.cards}/types'),
|
||||
headers: await _headers(),
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception(
|
||||
_extractError(response.body, 'Erreur chargement des types de cartes'),
|
||||
);
|
||||
}
|
||||
final decoded = jsonDecode(response.body);
|
||||
final list = decoded is List
|
||||
? decoded
|
||||
: (decoded is Map ? decoded['items'] as List? : null) ?? const [];
|
||||
return list
|
||||
.whereType<Map>()
|
||||
.map((e) => CarteTypeEmissible.fromJson(Map<String, dynamic>.from(e)))
|
||||
.where((t) => t.code.isNotEmpty)
|
||||
// Modif d’absence = agenda, pas la modale cartes (#175).
|
||||
.where((t) => t.code != 'absence_enfant_modif')
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Créer une bulle (+ événement agenda côté back).
|
||||
static Future<CarteBulle> creer({
|
||||
required String typeCode,
|
||||
required String idPlacement,
|
||||
required String dateDebut,
|
||||
required String dateFin,
|
||||
String? motif,
|
||||
}) async {
|
||||
final response = await http.post(
|
||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.cards}'),
|
||||
headers: await _headers(),
|
||||
body: jsonEncode({
|
||||
'type_code': typeCode,
|
||||
'id_placement': idPlacement,
|
||||
'date_debut': dateDebut,
|
||||
'date_fin': dateFin,
|
||||
if (motif != null && motif.trim().isNotEmpty) 'motif': motif.trim(),
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.statusCode != 201 && response.statusCode != 200) {
|
||||
throw Exception(
|
||||
_extractError(response.body, 'Erreur lors de la création'),
|
||||
);
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(response.body);
|
||||
return CarteBulle.fromJson(Map<String, dynamic>.from(decoded as Map));
|
||||
}
|
||||
|
||||
/// Répondre : accept | refuse | ack.
|
||||
static Future<CarteBulle> repondre({
|
||||
required String cardId,
|
||||
@@ -75,7 +132,7 @@ class CardsService {
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||
throw Exception(
|
||||
_extractError(response.body, 'Erreur lors de la réponse'),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
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/models/carte_type_ui.dart';
|
||||
import 'package:p_tits_pas/services/api/cards_service.dart';
|
||||
import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart';
|
||||
|
||||
/// Ouvre la modale Material générique « Ajouter une carte » (#175).
|
||||
/// Retourne la bulle créée, ou `null` si annulé.
|
||||
Future<CarteBulle?> showAjouterCarteModal(
|
||||
BuildContext context, {
|
||||
required String placementId,
|
||||
}) {
|
||||
return showDialog<CarteBulle>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => AjouterCarteModal(placementId: placementId),
|
||||
);
|
||||
}
|
||||
|
||||
/// Modale générique : types API + catalogue UI, période (pas de motif V1).
|
||||
class AjouterCarteModal extends StatefulWidget {
|
||||
final String placementId;
|
||||
|
||||
const AjouterCarteModal({super.key, required this.placementId});
|
||||
|
||||
@override
|
||||
State<AjouterCarteModal> createState() => _AjouterCarteModalState();
|
||||
}
|
||||
|
||||
class _AjouterCarteModalState extends State<AjouterCarteModal> {
|
||||
List<CarteTypeEmissible> _types = const [];
|
||||
CarteTypeEmissible? _selected;
|
||||
String? _sousTypeCode;
|
||||
DateTime _dateDebut = DateTime.now();
|
||||
DateTime _dateFin = DateTime.now();
|
||||
bool _loadingTypes = true;
|
||||
bool _submitting = false;
|
||||
String? _error;
|
||||
|
||||
CarteTypeUi get _ui =>
|
||||
_selected?.ui ??
|
||||
CarteTypeUi.resolve(typeCode: _selected?.code ?? 'inconnu');
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final now = DateTime.now();
|
||||
_dateDebut = DateTime(now.year, now.month, now.day);
|
||||
_dateFin = _dateDebut;
|
||||
_loadTypes();
|
||||
}
|
||||
|
||||
Future<void> _loadTypes() async {
|
||||
setState(() {
|
||||
_loadingTypes = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final types = await CardsService.listerTypes();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_types = types;
|
||||
_selected = types.isNotEmpty ? types.first : null;
|
||||
_sousTypeCode = null;
|
||||
_loadingTypes = false;
|
||||
if (types.isEmpty) {
|
||||
_error = 'Aucun type de carte disponible pour votre rôle.';
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loadingTypes = false;
|
||||
_error = e.toString().replaceFirst('Exception: ', '');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String _fmtIso(DateTime d) {
|
||||
final y = d.year.toString().padLeft(4, '0');
|
||||
final m = d.month.toString().padLeft(2, '0');
|
||||
final day = d.day.toString().padLeft(2, '0');
|
||||
return '$y-$m-$day';
|
||||
}
|
||||
|
||||
String _fmtUi(DateTime d) {
|
||||
final day = d.day.toString().padLeft(2, '0');
|
||||
final m = d.month.toString().padLeft(2, '0');
|
||||
return '$day/$m/${d.year}';
|
||||
}
|
||||
|
||||
Future<void> _pickSingleDay() async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
initialDate: _dateDebut,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2040),
|
||||
helpText: 'Date',
|
||||
cancelText: 'Annuler',
|
||||
confirmText: 'Valider',
|
||||
);
|
||||
if (picked == null || !mounted) return;
|
||||
final day = DateTime(picked.year, picked.month, picked.day);
|
||||
setState(() {
|
||||
_dateDebut = day;
|
||||
_dateFin = day;
|
||||
});
|
||||
}
|
||||
|
||||
/// Plage Material (jours surlignés), en boîte compacte — pas plein écran.
|
||||
/// Astuce : le mode calendrier de [DateRangePickerDialog] prend
|
||||
/// `MediaQuery.size` comme taille de dialog ; on la bride.
|
||||
Future<void> _pickDateRange() async {
|
||||
final initialEnd =
|
||||
_dateFin.isBefore(_dateDebut) ? _dateDebut : _dateFin;
|
||||
final picked = await showDialog<DateTimeRange>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
barrierDismissible: true,
|
||||
builder: (dialogContext) {
|
||||
final base = MediaQuery.of(dialogContext);
|
||||
final theme = Theme.of(dialogContext);
|
||||
return MediaQuery(
|
||||
data: base.copyWith(size: const Size(420, 560)),
|
||||
child: Theme(
|
||||
data: theme.copyWith(
|
||||
datePickerTheme: theme.datePickerTheme.copyWith(
|
||||
rangePickerShape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
dialogTheme: theme.dialogTheme.copyWith(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: DateRangePickerDialog(
|
||||
initialDateRange: DateTimeRange(
|
||||
start: _dateDebut,
|
||||
end: initialEnd,
|
||||
),
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2040),
|
||||
currentDate: DateTime.now(),
|
||||
helpText: 'Début, puis fin (même jour : 2×)',
|
||||
saveText: 'Valider',
|
||||
cancelText: 'Annuler',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
if (picked == null || !mounted) return;
|
||||
setState(() {
|
||||
_dateDebut = DateTime(
|
||||
picked.start.year,
|
||||
picked.start.month,
|
||||
picked.start.day,
|
||||
);
|
||||
_dateFin = DateTime(
|
||||
picked.end.year,
|
||||
picked.end.month,
|
||||
picked.end.day,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
String? _sousTypeLabel() {
|
||||
final sous = _sousTypeCode;
|
||||
if (sous == null) return null;
|
||||
for (final s in _ui.sousTypes) {
|
||||
if (s.code == sous) return s.label;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// V1 : pas de texte libre ; seul un sous-type catalogue peut alimenter motif.
|
||||
String? _motifOptionnel() => _sousTypeLabel();
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (_selected == null) return;
|
||||
if (_dateFin.isBefore(_dateDebut)) {
|
||||
setState(() => _error = 'La date de fin doit être après la date de début.');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_submitting = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final created = await CardsService.creer(
|
||||
typeCode: _selected!.code,
|
||||
idPlacement: widget.placementId,
|
||||
dateDebut: _fmtIso(_dateDebut),
|
||||
dateFin: _fmtIso(
|
||||
_ui.periodeMode == CartePeriodeMode.singleDay
|
||||
? _dateDebut
|
||||
: _dateFin,
|
||||
),
|
||||
motif: _motifOptionnel(),
|
||||
);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop(created);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_submitting = false;
|
||||
_error = e.toString().replaceFirst('Exception: ', '');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(
|
||||
'Ajouter une carte',
|
||||
style: GoogleFonts.merienda(fontWeight: FontWeight.w600),
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 420,
|
||||
child: _loadingTypes
|
||||
? const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (_error != null) ...[
|
||||
Text(
|
||||
_error!,
|
||||
style: GoogleFonts.merriweather(
|
||||
fontSize: 13,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
_buildTypeSelector(),
|
||||
const SizedBox(height: 16),
|
||||
if (_ui.periodeMode == CartePeriodeMode.singleDay)
|
||||
_DateField(
|
||||
label: 'Date',
|
||||
value: _fmtUi(_dateDebut),
|
||||
onTap: _submitting ? null : _pickSingleDay,
|
||||
)
|
||||
else
|
||||
_DateField(
|
||||
label: 'Période',
|
||||
value: _dateDebut == _dateFin
|
||||
? _fmtUi(_dateDebut)
|
||||
: '${_fmtUi(_dateDebut)} → ${_fmtUi(_dateFin)}',
|
||||
onTap: _submitting ? null : _pickDateRange,
|
||||
),
|
||||
if (_ui.sousTypes.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Sous-type',
|
||||
style: GoogleFonts.merriweather(
|
||||
fontSize: 13,
|
||||
color: QuotidienTheme.ink,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final s in _ui.sousTypes)
|
||||
ChoiceChip(
|
||||
label: Text(s.label),
|
||||
selected: _sousTypeCode == s.code,
|
||||
onSelected: _submitting
|
||||
? null
|
||||
: (sel) {
|
||||
setState(() {
|
||||
_sousTypeCode = sel ? s.code : null;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _submitting ? null : () => Navigator.pop(context),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: (_submitting ||
|
||||
_loadingTypes ||
|
||||
_selected == null ||
|
||||
_types.isEmpty)
|
||||
? null
|
||||
: _submit,
|
||||
child: _submitting
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Envoyer'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTypeSelector() {
|
||||
if (_types.isEmpty) {
|
||||
return Text(
|
||||
'Aucun type disponible.',
|
||||
style: GoogleFonts.merriweather(fontSize: 13),
|
||||
);
|
||||
}
|
||||
if (_types.length == 1) {
|
||||
return Text(
|
||||
_types.first.label,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: QuotidienTheme.ink,
|
||||
),
|
||||
);
|
||||
}
|
||||
return DropdownButtonFormField<String>(
|
||||
value: _selected?.code,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Type de carte',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: [
|
||||
for (final t in _types)
|
||||
DropdownMenuItem(value: t.code, child: Text(t.label)),
|
||||
],
|
||||
onChanged: _submitting
|
||||
? null
|
||||
: (code) {
|
||||
if (code == null) return;
|
||||
CarteTypeEmissible? next;
|
||||
for (final t in _types) {
|
||||
if (t.code == code) {
|
||||
next = t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (next == null) return;
|
||||
setState(() {
|
||||
_selected = next;
|
||||
_sousTypeCode = null;
|
||||
if (next!.ui.periodeMode == CartePeriodeMode.singleDay) {
|
||||
_dateFin = _dateDebut;
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DateField extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _DateField({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: const Icon(Icons.calendar_today_outlined, size: 18),
|
||||
),
|
||||
child: Text(
|
||||
value,
|
||||
style: GoogleFonts.merriweather(fontSize: 14),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,22 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:p_tits_pas/models/card_assets.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/services/api/cards_stream_client.dart';
|
||||
import 'package:p_tits_pas/services/auth_service.dart';
|
||||
import 'package:p_tits_pas/widgets/quotidien/ajouter_carte_modal.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.
|
||||
/// Feed de bulles (file d'attention) — tickets #173 (parent) / #174 (AM).
|
||||
/// Widget partagé ; les actions dépendent du rôle + payload API.
|
||||
class CartesFeed extends StatefulWidget {
|
||||
final String? placementId;
|
||||
|
||||
/// Conservé pour override éventuel ; sinon le CTA ouvre [showAjouterCarteModal].
|
||||
final VoidCallback? onAjouterBulle;
|
||||
|
||||
const CartesFeed({
|
||||
@@ -37,15 +43,23 @@ class _CartesFeedState extends State<CartesFeed> {
|
||||
bool _fadeBottom = false;
|
||||
|
||||
CardsStreamClient? _stream;
|
||||
String? _userId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_syncScrollFades);
|
||||
unawaited(_loadUserId());
|
||||
_load();
|
||||
_startStream();
|
||||
}
|
||||
|
||||
Future<void> _loadUserId() async {
|
||||
final user = await AuthService.getCurrentUser();
|
||||
if (!mounted) return;
|
||||
_userId = user?.id;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stream?.stop();
|
||||
@@ -73,9 +87,25 @@ class _CartesFeedState extends State<CartesFeed> {
|
||||
)..start();
|
||||
}
|
||||
|
||||
/// SSE diffuse le DTO du répondant / créateur : recalcule `is_creator` local.
|
||||
CarteBulle _avecIsCreatorLocal(CarteBulle incoming, {CarteBulle? previous}) {
|
||||
if (previous != null) {
|
||||
return incoming.copyWith(isCreator: previous.isCreator);
|
||||
}
|
||||
final uid = _userId;
|
||||
if (uid != null &&
|
||||
incoming.creePar != null &&
|
||||
incoming.creePar!.isNotEmpty) {
|
||||
return incoming.copyWith(isCreator: incoming.creePar == uid);
|
||||
}
|
||||
return incoming;
|
||||
}
|
||||
|
||||
void _onStreamEvent(CardsStreamEvent event) {
|
||||
if (!mounted) return;
|
||||
if (event.type == 'heartbeat') return;
|
||||
// `response.added` puis `card.updated` : on ne traite que l’update carte.
|
||||
if (event.type == 'response.added') return;
|
||||
|
||||
if (event.type == 'card.deleted') {
|
||||
final id = event.cardId;
|
||||
@@ -90,26 +120,36 @@ class _CartesFeedState extends State<CartesFeed> {
|
||||
return;
|
||||
}
|
||||
|
||||
final card = event.card;
|
||||
if (card == null) return;
|
||||
final raw = event.card;
|
||||
if (raw == null) return;
|
||||
|
||||
final filter = widget.placementId;
|
||||
final matchesFilter = filter == null ||
|
||||
filter.isEmpty ||
|
||||
card.idPlacement == filter;
|
||||
raw.idPlacement == filter;
|
||||
|
||||
setState(() {
|
||||
if (!matchesFilter) {
|
||||
_items = _trier([
|
||||
for (final c in _items)
|
||||
if (c.id != card.id) c,
|
||||
]);
|
||||
} else if (_items.any((c) => c.id == card.id)) {
|
||||
_items = _trier([
|
||||
for (final c in _items) if (c.id == card.id) card else c,
|
||||
if (c.id != raw.id) c,
|
||||
]);
|
||||
} else {
|
||||
_items = _trier([..._items, card]);
|
||||
CarteBulle? previous;
|
||||
for (final c in _items) {
|
||||
if (c.id == raw.id) {
|
||||
previous = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
final card = _avecIsCreatorLocal(raw, previous: previous);
|
||||
if (previous != null) {
|
||||
_items = _trier([
|
||||
for (final c in _items) if (c.id == card.id) card else c,
|
||||
]);
|
||||
} else {
|
||||
_items = _trier([..._items, card]);
|
||||
}
|
||||
}
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _syncScrollFades());
|
||||
@@ -134,28 +174,30 @@ class _CartesFeedState extends State<CartesFeed> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Aligné sur le tri API : refusées → ouvertes → traitées, puis
|
||||
/// `modifie_le` DESC, `id` en départage (ordre stable entre parents).
|
||||
/// Tri stable partagé : refusées → encore actives (ouvertes / infos
|
||||
/// période en cours) → traitées passées, puis `cree_le` DESC + `id`.
|
||||
static List<CarteBulle> _trier(List<CarteBulle> items) {
|
||||
int rang(CarteStatut s) {
|
||||
switch (s) {
|
||||
int rang(CarteBulle c) {
|
||||
switch (c.statut) {
|
||||
case CarteStatut.refusee:
|
||||
return 0;
|
||||
case CarteStatut.ouverte:
|
||||
return 1;
|
||||
case CarteStatut.traitee:
|
||||
return 2;
|
||||
// Absence info / arrêt encore dans la période : même rang que
|
||||
// ouverte (sinon elles tombent en bas dès la création).
|
||||
return c.restePleineDansFile ? 1 : 2;
|
||||
}
|
||||
}
|
||||
|
||||
final out = List<CarteBulle>.from(items);
|
||||
out.sort((a, b) {
|
||||
final byStatut = rang(a.statut).compareTo(rang(b.statut));
|
||||
final byStatut = rang(a).compareTo(rang(b));
|
||||
if (byStatut != 0) return byStatut;
|
||||
final am = a.modifieLe?.millisecondsSinceEpoch ?? 0;
|
||||
final bm = b.modifieLe?.millisecondsSinceEpoch ?? 0;
|
||||
final byModif = bm.compareTo(am); // DESC
|
||||
if (byModif != 0) return byModif;
|
||||
final ac = a.creeLe?.millisecondsSinceEpoch ?? 0;
|
||||
final bc = b.creeLe?.millisecondsSinceEpoch ?? 0;
|
||||
final byCree = bc.compareTo(ac); // DESC
|
||||
if (byCree != 0) return byCree;
|
||||
return a.id.compareTo(b.id);
|
||||
});
|
||||
return out;
|
||||
@@ -334,23 +376,42 @@ class _CartesFeedState extends State<CartesFeed> {
|
||||
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()),
|
||||
_AjouterCarteCta(onTap: _onAjouterCarte),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onAjouterCarte() async {
|
||||
if (widget.onAjouterBulle != null) {
|
||||
widget.onAjouterBulle!();
|
||||
return;
|
||||
}
|
||||
final placementId = widget.placementId;
|
||||
if (placementId == null || placementId.isEmpty) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Sélectionnez un couple de garde pour ajouter une carte.',
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final created = await showAjouterCarteModal(
|
||||
context,
|
||||
placementId: placementId,
|
||||
);
|
||||
if (created != null && mounted) {
|
||||
// SSE peut précéder ; refresh pour garantir la cohérence locale.
|
||||
await _load();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_loading) {
|
||||
return const Center(
|
||||
@@ -439,7 +500,7 @@ class _CartesFeedState extends State<CartesFeed> {
|
||||
onRefresh: _load,
|
||||
child: ListView.separated(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.only(top: 4, bottom: 8),
|
||||
padding: const EdgeInsets.only(top: 4, bottom: 4),
|
||||
itemCount: _items.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
||||
itemBuilder: (context, index) {
|
||||
@@ -574,26 +635,34 @@ class _BulleTile extends StatelessWidget {
|
||||
'au $j2 ${_mois[b.month - 1]} ${b.year}';
|
||||
}
|
||||
|
||||
/// Arrêt maladie : reste opaque tant que la période n’est pas finie.
|
||||
/// Arrêt / absence info : reste opaque tant que la période n’est pas finie
|
||||
/// (même si `traitee` — absence = info seule dès la création).
|
||||
bool get _attenuee {
|
||||
if (card.isArretMaladie && card.periodeEncoreActive) return false;
|
||||
if (card.restePleineDansFile) return false;
|
||||
return card.statut == CarteStatut.traitee;
|
||||
}
|
||||
|
||||
/// Congés (Validée / Refusée) ou carte ack (Bien reçu).
|
||||
/// Statut bas de carte (centré) : Validée / Refusée / En attente / Vu / Bien reçu.
|
||||
bool get _montreStatutReponse {
|
||||
if (card.peutRepondre) return false;
|
||||
if (card.isCongeAm &&
|
||||
(card.statut == CarteStatut.traitee ||
|
||||
card.statut == CarteStatut.refusee)) {
|
||||
return true;
|
||||
if (card.isCongeAm) {
|
||||
if (card.statut == CarteStatut.ouverte) return card.isCreator;
|
||||
return card.statut == CarteStatut.traitee ||
|
||||
card.statut == CarteStatut.refusee;
|
||||
}
|
||||
// Arrêt (ack) : AM créateur → « Vu » seulement après ack parent.
|
||||
// Destinataire → « Bien reçu » une fois traité.
|
||||
return card.responseMode == CarteResponseMode.ack &&
|
||||
card.statut == CarteStatut.traitee;
|
||||
}
|
||||
|
||||
String get _libelleStatutReponse {
|
||||
if (card.responseMode == CarteResponseMode.ack) return 'Bien reçu';
|
||||
if (card.responseMode == CarteResponseMode.ack) {
|
||||
return card.isCreator ? 'Vu' : 'Bien reçu';
|
||||
}
|
||||
if (card.isCongeAm && card.statut == CarteStatut.ouverte) {
|
||||
return 'En attente de validation';
|
||||
}
|
||||
// Accordé à « Demande de congés ».
|
||||
return card.statut == CarteStatut.refusee ? 'Refusée' : 'Validée';
|
||||
}
|
||||
@@ -675,20 +744,6 @@ class _BulleTile extends StatelessWidget {
|
||||
color: encre.ink,
|
||||
),
|
||||
),
|
||||
] else if (card.statut == CarteStatut.ouverte &&
|
||||
card.isCreator &&
|
||||
(card.isCongeAm ||
|
||||
card.responseMode == CarteResponseMode.ack)) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
card.responseMode == CarteResponseMode.ack
|
||||
? 'En attente d’acquittement'
|
||||
: 'En attente de réponse',
|
||||
style: GoogleFonts.merriweather(
|
||||
fontSize: 12,
|
||||
color: QuotidienTheme.muted,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -747,7 +802,7 @@ class _BulleTile extends StatelessWidget {
|
||||
_libelleStatutReponse,
|
||||
textAlign: TextAlign.center,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: 14,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: encre.ink.withValues(alpha: 0.75),
|
||||
@@ -1026,6 +1081,45 @@ class _RefuseDialogState extends State<_RefuseDialog> {
|
||||
}
|
||||
}
|
||||
|
||||
/// CTA « Ajouter une carte » — pilule aquarelle en bas du feed (#175).
|
||||
class _AjouterCarteCta extends StatelessWidget {
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _AjouterCarteCta({required this.onTap});
|
||||
|
||||
static const _asset =
|
||||
'assets/images/cartes/btn_ajouter_carte_pilule.png';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 12, 4, 14),
|
||||
child: Center(
|
||||
child: Tooltip(
|
||||
message: 'Ajouter une carte',
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: 'Ajouter une carte',
|
||||
child: Image.asset(
|
||||
_asset,
|
||||
height: 48,
|
||||
fit: BoxFit.contain,
|
||||
filterQuality: FilterQuality.high,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bouton pilule peinte (coche / croix intégrées) — carte congés.
|
||||
class _PilulePeinteButton extends StatelessWidget {
|
||||
final String asset;
|
||||
|
||||
Reference in New Issue
Block a user