import "package:flutter/material.dart"; import "package:google_fonts/google_fonts.dart"; import "../services/auth_service.dart"; import "../services/responsable_service.dart"; import "../theme/ensup_colors.dart"; import "../utils/file_download.dart"; import "../widgets/app_message.dart"; const _workspaceBackground = Color(0xFFF3F6F9); const _orange = Color(0xFFD97706); class ResponsableScreen extends StatefulWidget { const ResponsableScreen({super.key}); @override State createState() => _ResponsableScreenState(); } class _ResponsableScreenState extends State { late Future<_ResponsableData> _future = _charger(); int _onglet = 0; bool _transitionEnCours = false; bool _decisionEnCours = false; bool _exportEnCours = false; Future<_ResponsableData> _charger() async { final dashboard = await ResponsableService.dashboard(); final ecarts = await ResponsableService.ecarts(); final emprunts = await ResponsableService.emprunts(); final materiels = await ResponsableService.materiels(); final anomalies = await ResponsableService.anomalies(); final notifications = await ResponsableService.notifications(); final historique = await ResponsableService.historique(); return _ResponsableData( dashboard: dashboard, ecarts: ecarts, emprunts: emprunts, materiels: materiels, anomalies: anomalies, notifications: notifications, historique: historique, ); } void _rafraichir() { setState(() => _future = _charger()); } Future _quitter() async { try { await AuthService.signOut(); if (mounted) { Navigator.of(context).pop(); } } catch (error) { if (!mounted) return; AppMessage.erreur(context, error); } } Future _changerStatut(Map anomalie) async { final prochain = _prochainStatut(anomalie["statut"] as String?); if (prochain == null || _transitionEnCours) { return; } setState(() => _transitionEnCours = true); try { await ResponsableService.changerStatutAnomalie( anomalie["id"] as int, prochain, ); if (!mounted) return; AppMessage.succes( context, "L'anomalie est maintenant au statut ${_statusLabel(prochain)}.", titre: "Statut mis à jour", ); _rafraichir(); } catch (error) { if (!mounted) return; AppMessage.erreur(context, error); } finally { if (mounted) { setState(() => _transitionEnCours = false); } } } Future _examinerEcart(Map ecart) async { if (_decisionEnCours) return; final decision = await showDialog<_DecisionEcart>( context: context, builder: (context) => _EcartDecisionDialog(ecart: ecart), ); if (decision == null || !mounted) { return; } setState(() => _decisionEnCours = true); try { await ResponsableService.deciderEcart( empruntId: ecart["id"] as int, decision: decision.decision, observation: decision.observation, statutMateriel: decision.statutMateriel, ); if (!mounted) return; AppMessage.succes( context, decision.decision == "CONFIRMER" ? "Le changement a été confirmé et l'anomalie a été créée." : "Le changement a été refusé.", titre: "Décision enregistrée", ); _rafraichir(); } catch (error) { if (!mounted) return; AppMessage.erreur(context, error); } finally { if (mounted) { setState(() => _decisionEnCours = false); } } } Future _marquerNotificationsLues() async { try { await ResponsableService.marquerNotificationsLues(); _rafraichir(); } catch (error) { if (!mounted) return; AppMessage.erreur(context, error); } } Future _exporterHistorique() async { if (_exportEnCours) return; setState(() => _exportEnCours = true); try { final file = await ResponsableService.exporterHistorique(); downloadFile( bytes: file.bytes, fileName: file.fileName, contentType: file.contentType, ); if (!mounted) return; AppMessage.succes( context, "Le fichier d'historique est disponible dans vos téléchargements.", titre: "Export CSV téléchargé", ); } catch (error) { if (!mounted) return; AppMessage.erreur(context, error, titre: "Export impossible"); } finally { if (mounted) { setState(() => _exportEnCours = false); } } } String? _prochainStatut(String? statut) { return switch (statut) { "DETECTEE" => "EN_COURS_TRAITEMENT", "EN_COURS_TRAITEMENT" => "RESOLUE", "RESOLUE" => "CLOTUREE", _ => null, }; } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: _workspaceBackground, body: SafeArea( child: Column( children: [ _ResponsableTopBar(onExit: _quitter, onRefresh: _rafraichir), Expanded( child: FutureBuilder<_ResponsableData>( future: _future, builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const _ChargementWorkspace(); } if (snapshot.hasError) { return _Erreur( message: snapshot.error.toString(), onRetry: _rafraichir, ); } return _contenu(snapshot.data!); }, ), ), ], ), ), ); } Widget _contenu(_ResponsableData data) { final kpis = Map.from(data.dashboard["kpis"] as Map); final anomalies = _repartition(kpis["anomalies"]); final anomaliesActives = _statutCount(anomalies, "DETECTEE") + _statutCount(anomalies, "EN_COURS_TRAITEMENT"); final notificationsNonLues = kpis["notificationsNonLues"] as int? ?? 0; final ecartsEnAttente = data.ecarts.length; final vues = [ _VueDashboard( data: data, onNavigate: (index) => setState(() => _onglet = index), onRefresh: _rafraichir, ), _VueListe( titre: "Écarts à confirmer", description: "Contrôlez les changements déclarés avant toute modification de l’état officiel.", icon: Icons.fact_check_outlined, items: data.ecarts, builder: _ecartTile, searchableText: (item) { final etudiant = Map.from(item["etudiant"] as Map); final materiel = Map.from(item["materiel"] as Map); return "${item["typeEcart"]} ${materiel["nom"]} ${materiel["reference"]} ${etudiant["prenom"]} ${etudiant["nom"]}"; }, ), _VueListe( titre: "Emprunts", description: "Suivez les prêts en cours, les retards et les retours clôturés.", icon: Icons.assignment_outlined, items: data.emprunts, builder: _empruntTile, searchableText: (item) { final etudiant = Map.from(item["etudiant"] as Map); final materiel = Map.from(item["materiel"] as Map); return "${materiel["nom"]} ${materiel["reference"]} ${etudiant["prenom"]} ${etudiant["nom"]} ${item["statut"]}"; }, ), _VueListe( titre: "Stock matériel", description: "Consultez la disponibilité et l’état du parc de votre campus.", icon: Icons.inventory_2_outlined, items: data.materiels, builder: _materielTile, searchableText: (item) { final categorie = Map.from(item["categorie"] as Map); return "${item["nom"]} ${item["reference"]} ${item["statut"]} ${categorie["nom"]}"; }, ), _VueListe( titre: "Anomalies", description: "Priorisez et faites avancer les incidents détectés lors des retours.", icon: Icons.report_problem_outlined, items: data.anomalies, builder: _anomalieTile, searchableText: (item) { final materiel = Map.from(item["materiel"] as Map); return "${item["type"]} ${item["description"]} ${item["statut"]} ${materiel["nom"]}"; }, ), _VueListe( titre: "Notifications", description: "Centralisez les alertes liées aux opérations et aux anomalies.", icon: Icons.notifications_none_outlined, items: data.notifications, builder: _notificationTile, action: OutlinedButton.icon( onPressed: _marquerNotificationsLues, icon: const Icon(Icons.done_all, size: 16), label: const Text("Tout marquer lu"), ), searchableText: (item) => "${item["titre"]} ${item["message"]}", ), _VueListe( titre: "Historique", description: "Retrouvez la trace complète des actions réalisées sur le campus.", icon: Icons.history, items: data.historique, builder: _historiqueTile, action: OutlinedButton.icon( onPressed: _exportEnCours ? null : _exporterHistorique, icon: _exportEnCours ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.download_outlined, size: 16), label: Text(_exportEnCours ? "Export en cours" : "Export CSV"), ), searchableText: (item) => "${item["action"]} ${item["description"]}", ), ]; return LayoutBuilder( builder: (context, constraints) { final compact = constraints.maxWidth < 900; return Row( children: [ if (!compact) _ResponsableSidebar( index: _onglet, ecartsEnAttente: ecartsEnAttente, anomaliesActives: anomaliesActives, notificationsNonLues: notificationsNonLues, onChanged: (index) => setState(() => _onglet = index), onExit: _quitter, ), Expanded( child: Column( children: [ if (compact) _NavigationCompacte( index: _onglet, onChanged: (index) => setState(() => _onglet = index), ), Expanded(child: vues[_onglet]), ], ), ), ], ); }, ); } Widget _empruntTile(Map item) { final etudiant = Map.from(item["etudiant"] as Map); final materiel = Map.from(item["materiel"] as Map); return _InfoTile( icon: Icons.assignment_outlined, title: "${materiel["nom"]}", subtitle: "${etudiant["prenom"]} ${etudiant["nom"]} · retour prévu ${_date(item["dateRetourPrevue"])}", meta: "Emprunt #${item["id"]}", trailing: _StatusBadge("${item["statut"]}"), ); } Widget _ecartTile(Map item) { final etudiant = Map.from(item["etudiant"] as Map); final materiel = Map.from(item["materiel"] as Map); final type = item["typeEcart"] == "DEPART" ? "Départ" : "Retour"; return _InfoTile( icon: Icons.fact_check_outlined, title: "$type · ${materiel["nom"]}", subtitle: "${etudiant["prenom"]} ${etudiant["nom"]} · ${materiel["reference"]}", meta: "Emprunt #${item["id"]}", trailing: FilledButton.icon( onPressed: _decisionEnCours ? null : () => _examinerEcart(item), icon: const Icon(Icons.visibility_outlined, size: 16), label: const Text("Examiner"), style: FilledButton.styleFrom( backgroundColor: EnsupColors.blue3, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(7)), ), ), ); } Widget _materielTile(Map item) { final categorie = Map.from(item["categorie"] as Map); return _InfoTile( icon: Icons.inventory_2_outlined, title: "${item["nom"]}", subtitle: "${item["reference"]} · ${categorie["nom"]} · ${item["etatGeneral"]}", meta: "${item["marque"]} ${item["modele"]}", trailing: _StatusBadge("${item["statut"]}"), ); } Widget _anomalieTile(Map item) { final materiel = Map.from(item["materiel"] as Map); final prochain = _prochainStatut(item["statut"] as String?); return _InfoTile( icon: Icons.report_problem_outlined, title: "${item["type"]} · ${materiel["nom"]}", subtitle: "${item["description"]}", meta: "Détectée le ${_date(item["dateDetection"])}", trailing: _AnomalieAction( statut: "${item["statut"]}", prochainStatut: prochain, disabled: _transitionEnCours, onPressed: () => _changerStatut(item), ), ); } Widget _notificationTile(Map item) { return _InfoTile( icon: item["lu"] == true ? Icons.notifications_none : Icons.notifications_active_outlined, title: "${item["titre"]}", subtitle: "${item["message"]}", meta: _date(item["dateCreation"]), trailing: _StatusBadge(item["lu"] == true ? "LUE" : "NON_LUE"), ); } Widget _historiqueTile(Map item) { final utilisateur = Map.from(item["utilisateur"] as Map); return _InfoTile( icon: Icons.history, title: _actionLabel("${item["action"]}"), subtitle: "${item["description"]}", meta: "${_date(item["dateAction"])} · ${utilisateur["prenom"]} ${utilisateur["nom"]}", trailing: const Icon(Icons.chevron_right, color: EnsupColors.muted), ); } String _date(Object? value) { if (value == null) return "-"; final parsed = DateTime.tryParse(value.toString()); if (parsed == null) return value.toString(); return "${parsed.day.toString().padLeft(2, "0")}/" "${parsed.month.toString().padLeft(2, "0")}/${parsed.year}"; } } String _statusLabel(String statut) { return switch (statut) { "DISPONIBLE" => "Disponible", "RESERVE" => "Réservé", "EMPRUNTE" => "Emprunté", "NON_CONFORME" => "Non conforme", "DETERIORE" => "Détérioré", "MAINTENANCE" => "Maintenance", "INDISPONIBLE" => "Indisponible", "EN_COURS" => "En cours", "EN_ATTENTE_VALIDATION_DEPART" => "Écart départ en attente", "EN_RETARD" => "En retard", "EN_ATTENTE_VALIDATION_RETOUR" => "Écart retour en attente", "CLOTURE" => "Clôturé", "RETOUR_NON_CONFORME" => "Retour non conforme", "ANNULE" => "Annulé", "DETECTEE" => "À traiter", "EN_COURS_TRAITEMENT" => "En traitement", "RESOLUE" => "Résolue", "CLOTUREE" => "Clôturée", "LUE" => "Lue", "NON_LUE" => "Non lue", "PRESENT" => "Présent", "ABSENT" => "Absent", _ => statut.replaceAll("_", " ").toLowerCase(), }; } String _actionLabel(String action) { return switch (action) { "CREATION_EMPRUNT" => "Création d'emprunt", "CREATION_ANOMALIE" => "Création d'anomalie", "TRAITEMENT_ANOMALIE" => "Traitement d'anomalie", "TENTATIVE_MODIFICATION_RETOUR" => "Tentative de modification au retour", "ECART_DEPART_SIGNALE" => "Écart signalé au départ", "ECART_RETOUR_SIGNALE" => "Écart signalé au retour", "ECART_DEPART_CONFIRMER" => "Écart de départ confirmé", "ECART_DEPART_REFUSER" => "Écart de départ refusé", "ECART_RETOUR_CONFIRMER" => "Écart de retour confirmé", "ECART_RETOUR_REFUSER" => "Écart de retour refusé", "EMPRUNT_AUTOMATIQUE" => "Emprunt automatique", "RESTITUTION_AUTOMATIQUE" => "Restitution automatique", _ => action.replaceAll("_", " ").toLowerCase(), }; } Color _statusColor(String statut) { return switch (statut) { "DISPONIBLE" || "CLOTURE" || "RESOLUE" || "CLOTUREE" || "LUE" => EnsupColors.green, "EN_RETARD" || "RETOUR_NON_CONFORME" || "NON_CONFORME" || "DETECTEE" || "NON_LUE" => EnsupColors.red, "EN_COURS" || "EN_COURS_TRAITEMENT" || "EMPRUNTE" => EnsupColors.cyan, "RESERVE" || "EN_ATTENTE_VALIDATION_DEPART" || "EN_ATTENTE_VALIDATION_RETOUR" => _orange, "MAINTENANCE" || "INDISPONIBLE" || "DETERIORE" => EnsupColors.purple, _ => EnsupColors.blue3, }; } class _DecisionEcart { const _DecisionEcart({ required this.decision, required this.observation, this.statutMateriel, }); final String decision; final String observation; final String? statutMateriel; } class _EcartDecisionDialog extends StatefulWidget { const _EcartDecisionDialog({required this.ecart}); final Map ecart; @override State<_EcartDecisionDialog> createState() => _EcartDecisionDialogState(); } class _EcartDecisionDialogState extends State<_EcartDecisionDialog> { final TextEditingController _observationController = TextEditingController(); String _statutMateriel = "NON_CONFORME"; bool get _estRetour => widget.ecart["typeEcart"] == "RETOUR"; @override void dispose() { _observationController.dispose(); super.dispose(); } List> _elements(String type) { final checklists = widget.ecart["checklists"] as List? ?? const []; for (final checklistValue in checklists) { final checklist = Map.from(checklistValue as Map); if (checklist["type"] == type) { return (checklist["elements"] as List? ?? const []) .map((element) => Map.from(element as Map)) .toList(); } } return const []; } String _etat(Map element) { final quantite = element["quantiteConstatee"] as int? ?? 0; return "${_statusLabel("${element["etat"]}")} · quantité $quantite"; } List _comparaisons() { final depart = _elements("DEPART"); if (!_estRetour) { return depart .map( (element) => _EcartComparisonRow( nom: "${element["nomElement"]}", reference: "Présent · état de référence", declaration: _etat(element), ), ) .toList(); } final retour = _elements("RETOUR"); final lignes = []; for (final elementDepart in depart) { Map? elementRetour; for (final candidat in retour) { if (candidat["nomElement"] == elementDepart["nomElement"]) { elementRetour = candidat; break; } } if (elementRetour == null || elementRetour["etat"] != elementDepart["etat"] || elementRetour["quantiteConstatee"] != elementDepart["quantiteConstatee"]) { lignes.add( _EcartComparisonRow( nom: "${elementDepart["nomElement"]}", reference: _etat(elementDepart), declaration: elementRetour == null ? "Élément non renseigné" : _etat(elementRetour), ), ); } } return lignes; } void _terminer(String decision) { Navigator.of(context).pop( _DecisionEcart( decision: decision, observation: _observationController.text, statutMateriel: _estRetour && decision == "CONFIRMER" ? _statutMateriel : null, ), ); } @override Widget build(BuildContext context) { final etudiant = Map.from(widget.ecart["etudiant"] as Map); final materiel = Map.from(widget.ecart["materiel"] as Map); final type = _estRetour ? "retour" : "départ"; return Dialog( insetPadding: const EdgeInsets.all(20), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 720, maxHeight: 760), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.fromLTRB(24, 22, 16, 18), child: Row( children: [ Container( width: 42, height: 42, decoration: BoxDecoration( color: _orange.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), child: const Icon( Icons.fact_check_outlined, color: _orange, ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( "Contrôle de $type", style: GoogleFonts.darkerGrotesque( fontSize: 22, fontWeight: FontWeight.w900, color: EnsupColors.text, ), ), Text( "${materiel["nom"]} · ${etudiant["prenom"]} ${etudiant["nom"]}", style: GoogleFonts.titilliumWeb( fontSize: 13, color: EnsupColors.muted, ), ), ], ), ), IconButton( tooltip: "Fermer", onPressed: () => Navigator.of(context).pop(), icon: const Icon(Icons.close), ), ], ), ), const Divider(height: 1, color: EnsupColors.line), Expanded( child: SingleChildScrollView( padding: const EdgeInsets.all(24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( _estRetour ? "Différences avec l’état validé au départ" : "Différences avec l’état de référence", style: GoogleFonts.darkerGrotesque( fontSize: 16, fontWeight: FontWeight.w800, ), ), const SizedBox(height: 10), ..._comparaisons(), const SizedBox(height: 20), if (_estRetour) ...[ DropdownButtonFormField( initialValue: _statutMateriel, decoration: const InputDecoration( labelText: "État final si le changement est confirmé", border: OutlineInputBorder(), ), items: const [ DropdownMenuItem( value: "DISPONIBLE", child: Text("Disponible"), ), DropdownMenuItem( value: "NON_CONFORME", child: Text("Non conforme"), ), DropdownMenuItem( value: "DETERIORE", child: Text("Détérioré"), ), DropdownMenuItem( value: "MAINTENANCE", child: Text("Maintenance"), ), DropdownMenuItem( value: "INDISPONIBLE", child: Text("Indisponible"), ), ], onChanged: (value) { if (value != null) { setState(() => _statutMateriel = value); } }, ), const SizedBox(height: 14), ], TextField( controller: _observationController, minLines: 2, maxLines: 4, decoration: const InputDecoration( labelText: "Observation", hintText: "Constat effectué lors du contrôle physique", border: OutlineInputBorder(), ), ), ], ), ), ), const Divider(height: 1, color: EnsupColors.line), Padding( padding: const EdgeInsets.all(18), child: Wrap( alignment: WrapAlignment.end, spacing: 10, runSpacing: 10, children: [ OutlinedButton.icon( onPressed: () => _terminer("REFUSER"), icon: const Icon(Icons.close, size: 18), label: const Text("Refuser le changement"), ), FilledButton.icon( onPressed: () => _terminer("CONFIRMER"), icon: const Icon(Icons.check, size: 18), label: const Text("Confirmer le changement"), style: FilledButton.styleFrom( backgroundColor: EnsupColors.blue3, ), ), ], ), ), ], ), ), ); } } class _EcartComparisonRow extends StatelessWidget { const _EcartComparisonRow({ required this.nom, required this.reference, required this.declaration, }); final String nom; final String reference; final String declaration; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(vertical: 12), decoration: const BoxDecoration( border: Border(bottom: BorderSide(color: EnsupColors.line)), ), child: LayoutBuilder( builder: (context, constraints) { final compact = constraints.maxWidth < 520; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( nom, style: GoogleFonts.darkerGrotesque( fontSize: 15, fontWeight: FontWeight.w800, ), ), const SizedBox(height: 5), if (compact) Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( reference, style: GoogleFonts.titilliumWeb( fontSize: 13, color: EnsupColors.text2, ), ), const SizedBox(height: 4), Text( declaration, style: GoogleFonts.titilliumWeb( fontSize: 13, fontWeight: FontWeight.w700, color: _orange, ), ), ], ) else Row( children: [ Expanded( child: Text( reference, style: GoogleFonts.titilliumWeb( fontSize: 13, color: EnsupColors.text2, ), ), ), const Icon(Icons.arrow_forward, size: 16), Expanded( child: Text( declaration, textAlign: TextAlign.right, style: GoogleFonts.titilliumWeb( fontSize: 13, fontWeight: FontWeight.w700, color: _orange, ), ), ), ], ), ], ); }, ), ); } } Map _repartition(Object? value) { if (value is! Map) return {}; return Map.from(value); } int _statutCount(Map repartition, String statut) { final parStatut = repartition["parStatut"]; if (parStatut is! Map) return 0; final value = parStatut[statut]; return value is int ? value : 0; } String _dateLongue(DateTime date) { const jours = [ "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche", ]; const mois = [ "janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", "septembre", "octobre", "novembre", "décembre", ]; return "${jours[date.weekday - 1]} ${date.day} ${mois[date.month - 1]} ${date.year}"; } String _dateHeure(Object? value) { final date = DateTime.tryParse("$value")?.toLocal(); if (date == null) return "-"; final jour = date.day.toString().padLeft(2, "0"); final mois = date.month.toString().padLeft(2, "0"); final heure = date.hour.toString().padLeft(2, "0"); final minute = date.minute.toString().padLeft(2, "0"); return "$jour/$mois · $heure:$minute"; } class _ResponsableTopBar extends StatelessWidget { const _ResponsableTopBar({required this.onExit, required this.onRefresh}); final VoidCallback onExit; final VoidCallback onRefresh; @override Widget build(BuildContext context) { final profile = AuthService.currentProfile; final campusTag = profile?.campusTag ?? "Ensitech · Cergy"; final initials = profile?.initials ?? "KB"; final userName = profile?.fullName ?? "Karim Benali"; final roleLabel = profile?.roleLabel ?? "Responsable matériel"; return Container( height: 68, padding: const EdgeInsets.symmetric(horizontal: 20), decoration: const BoxDecoration( color: Colors.white, border: Border(bottom: BorderSide(color: EnsupColors.line)), ), child: LayoutBuilder( builder: (context, constraints) { final showCampus = constraints.maxWidth >= 650; final showName = constraints.maxWidth >= 820; return Row( children: [ Container( width: 38, height: 38, decoration: BoxDecoration( color: EnsupColors.blue3, borderRadius: BorderRadius.circular(8), ), child: const Icon( Icons.grid_view_rounded, color: Colors.white, size: 19, ), ), const SizedBox(width: 12), Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( "EME", style: GoogleFonts.darkerGrotesque( fontSize: 21, height: 0.95, fontWeight: FontWeight.w900, color: EnsupColors.text, ), ), Text( "Emprunt matériel", style: GoogleFonts.titilliumWeb( fontSize: 11, color: EnsupColors.muted, ), ), ], ), const Spacer(), if (showCampus) ...[ Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 7, ), decoration: BoxDecoration( color: _workspaceBackground, borderRadius: BorderRadius.circular(6), border: Border.all(color: EnsupColors.line), ), child: Row( children: [ const Icon( Icons.location_on_outlined, size: 15, color: EnsupColors.blue3, ), const SizedBox(width: 6), Text( campusTag, style: GoogleFonts.titilliumWeb( fontSize: 12, fontWeight: FontWeight.w600, color: EnsupColors.text2, ), ), ], ), ), const SizedBox(width: 10), ], Tooltip( message: "Actualiser les données", child: IconButton( onPressed: onRefresh, icon: const Icon(Icons.refresh_rounded), color: EnsupColors.text2, ), ), const SizedBox(width: 4), Container(width: 1, height: 28, color: EnsupColors.line), const SizedBox(width: 12), Container( width: 34, height: 34, alignment: Alignment.center, decoration: const BoxDecoration( color: EnsupColors.purple, shape: BoxShape.circle, ), child: Text( initials, style: GoogleFonts.darkerGrotesque( color: Colors.white, fontSize: 13, fontWeight: FontWeight.w900, ), ), ), if (showName) ...[ const SizedBox(width: 9), Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( userName, style: GoogleFonts.titilliumWeb( fontSize: 13, fontWeight: FontWeight.w700, color: EnsupColors.text, ), ), Text( roleLabel, style: GoogleFonts.titilliumWeb( fontSize: 11, color: EnsupColors.muted, ), ), ], ), ], const SizedBox(width: 4), Tooltip( message: "Quitter l’espace responsable", child: IconButton( onPressed: onExit, icon: const Icon(Icons.logout_rounded), color: EnsupColors.muted, ), ), ], ); }, ), ); } } class _ResponsableSidebar extends StatelessWidget { const _ResponsableSidebar({ required this.index, required this.ecartsEnAttente, required this.anomaliesActives, required this.notificationsNonLues, required this.onChanged, required this.onExit, }); final int index; final int ecartsEnAttente; final int anomaliesActives; final int notificationsNonLues; final ValueChanged onChanged; final VoidCallback onExit; static const _destinations = [ ("Vue d’ensemble", Icons.space_dashboard_outlined), ("Écarts à confirmer", Icons.fact_check_outlined), ("Emprunts", Icons.assignment_outlined), ("Stock matériel", Icons.inventory_2_outlined), ("Anomalies", Icons.report_problem_outlined), ("Notifications", Icons.notifications_none_outlined), ("Historique", Icons.history), ]; @override Widget build(BuildContext context) { return Container( width: 244, color: EnsupColors.blue3, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.fromLTRB(20, 24, 20, 10), child: Text( "SUPERVISION", style: GoogleFonts.titilliumWeb( fontSize: 11, fontWeight: FontWeight.w700, color: Colors.white.withValues(alpha: 0.58), ), ), ), ...List.generate(_destinations.length, (itemIndex) { final destination = _destinations[itemIndex]; final badge = switch (itemIndex) { 1 => ecartsEnAttente, 4 => anomaliesActives, 5 => notificationsNonLues, _ => 0, }; return _SidebarItem( label: destination.$1, icon: destination.$2, selected: itemIndex == index, badge: badge, alert: itemIndex == 1 || itemIndex == 4, onTap: () => onChanged(itemIndex), ); }), const Spacer(), Padding( padding: const EdgeInsets.all(16), child: Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.white.withValues(alpha: 0.12)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Icon( Icons.verified_user_outlined, size: 17, color: Colors.white, ), const SizedBox(width: 8), Expanded( child: Text( "Session responsable", style: GoogleFonts.titilliumWeb( color: Colors.white, fontSize: 12, fontWeight: FontWeight.w700, ), ), ), ], ), const SizedBox(height: 4), Text( "Accès limité au campus actif", style: GoogleFonts.titilliumWeb( color: Colors.white.withValues(alpha: 0.62), fontSize: 11, ), ), ], ), ), ), Padding( padding: const EdgeInsets.fromLTRB(12, 0, 12, 18), child: TextButton.icon( onPressed: onExit, style: TextButton.styleFrom( foregroundColor: Colors.white.withValues(alpha: 0.78), alignment: Alignment.centerLeft, padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 12, ), ), icon: const Icon(Icons.logout_rounded, size: 18), label: const Text("Quitter l’espace"), ), ), ], ), ); } } class _SidebarItem extends StatelessWidget { const _SidebarItem({ required this.label, required this.icon, required this.selected, required this.badge, required this.alert, required this.onTap, }); final String label; final IconData icon; final bool selected; final int badge; final bool alert; final VoidCallback onTap; @override Widget build(BuildContext context) { final foreground = selected ? Colors.white : Colors.white.withValues(alpha: 0.72); return Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 3), child: Material( color: selected ? Colors.white.withValues(alpha: 0.14) : Colors.transparent, borderRadius: BorderRadius.circular(7), child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(7), child: Container( height: 46, padding: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration( border: selected ? const Border( left: BorderSide(color: EnsupColors.cyan, width: 3), ) : null, ), child: Row( children: [ Icon(icon, size: 19, color: foreground), const SizedBox(width: 12), Expanded( child: Text( label, style: GoogleFonts.titilliumWeb( fontSize: 13, fontWeight: selected ? FontWeight.w700 : FontWeight.w600, color: foreground, ), ), ), if (badge > 0) Container( constraints: const BoxConstraints(minWidth: 22), padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2, ), alignment: Alignment.center, decoration: BoxDecoration( color: alert ? EnsupColors.red : EnsupColors.cyan, borderRadius: BorderRadius.circular(10), ), child: Text( "$badge", style: const TextStyle( color: Colors.white, fontSize: 11, fontWeight: FontWeight.w700, ), ), ), ], ), ), ), ), ); } } class _NavigationCompacte extends StatelessWidget { const _NavigationCompacte({required this.index, required this.onChanged}); final int index; final ValueChanged onChanged; static const _destinations = [ ("Vue d’ensemble", Icons.space_dashboard_outlined), ("Écarts", Icons.fact_check_outlined), ("Emprunts", Icons.assignment_outlined), ("Stock", Icons.inventory_2_outlined), ("Anomalies", Icons.report_problem_outlined), ("Notifications", Icons.notifications_none_outlined), ("Historique", Icons.history), ]; @override Widget build(BuildContext context) { return Container( height: 66, decoration: const BoxDecoration( color: Colors.white, border: Border(bottom: BorderSide(color: EnsupColors.line)), ), child: ListView.separated( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), scrollDirection: Axis.horizontal, itemCount: _destinations.length, separatorBuilder: (_, _) => const SizedBox(width: 6), itemBuilder: (context, itemIndex) { final selected = itemIndex == index; final destination = _destinations[itemIndex]; return Material( color: selected ? EnsupColors.cyan.withValues(alpha: 0.1) : Colors.transparent, borderRadius: BorderRadius.circular(7), child: InkWell( onTap: () => onChanged(itemIndex), borderRadius: BorderRadius.circular(7), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( children: [ Icon( destination.$2, size: 18, color: selected ? EnsupColors.blue3 : EnsupColors.muted, ), const SizedBox(width: 7), Text( destination.$1, style: GoogleFonts.titilliumWeb( fontSize: 12, fontWeight: FontWeight.w700, color: selected ? EnsupColors.blue3 : EnsupColors.muted, ), ), ], ), ), ), ); }, ), ); } } class _ChargementWorkspace extends StatelessWidget { const _ChargementWorkspace(); @override Widget build(BuildContext context) { return const Center( child: SizedBox( width: 34, height: 34, child: CircularProgressIndicator(strokeWidth: 3), ), ); } } class _ResponsableData { const _ResponsableData({ required this.dashboard, required this.ecarts, required this.emprunts, required this.materiels, required this.anomalies, required this.notifications, required this.historique, }); final Map dashboard; final List> ecarts; final List> emprunts; final List> materiels; final List> anomalies; final List> notifications; final List> historique; } class _VueDashboard extends StatelessWidget { const _VueDashboard({ required this.data, required this.onNavigate, required this.onRefresh, }); final _ResponsableData data; final ValueChanged onNavigate; final VoidCallback onRefresh; @override Widget build(BuildContext context) { final kpis = Map.from(data.dashboard["kpis"] as Map); final materiels = _repartition(kpis["materiels"]); final emprunts = _repartition(kpis["emprunts"]); final anomalies = _repartition(kpis["anomalies"]); final activite = (data.dashboard["activiteRecente"] as List? ?? const []) .whereType() .map((item) => Map.from(item)) .toList(); final totalMateriels = materiels["total"] as int? ?? 0; final disponibles = _statutCount(materiels, "DISPONIBLE"); final empruntsEnCours = _statutCount(emprunts, "EN_COURS"); final retards = _statutCount(emprunts, "EN_RETARD"); final anomaliesActives = _statutCount(anomalies, "DETECTEE") + _statutCount(anomalies, "EN_COURS_TRAITEMENT"); final notificationsNonLues = kpis["notificationsNonLues"] as int? ?? 0; final ecartsEnAttente = data.ecarts.length; final indisponibles = [ "NON_CONFORME", "DETERIORE", "MAINTENANCE", "INDISPONIBLE", ].fold(0, (total, statut) => total + _statutCount(materiels, statut)); return SingleChildScrollView( padding: const EdgeInsets.all(28), child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 1440), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _DashboardHeader(onRefresh: onRefresh), const SizedBox(height: 24), LayoutBuilder( builder: (context, constraints) { final columns = constraints.maxWidth >= 1120 ? 4 : constraints.maxWidth >= 560 ? 2 : 1; const spacing = 14.0; final width = (constraints.maxWidth - spacing * (columns - 1)) / columns; return Wrap( spacing: spacing, runSpacing: spacing, children: [ _KpiCard( width: width, label: "Matériels disponibles", value: "$disponibles", detail: "sur $totalMateriels au total", icon: Icons.inventory_2_outlined, color: EnsupColors.cyan, ), _KpiCard( width: width, label: "Emprunts en cours", value: "$empruntsEnCours", detail: retards > 0 ? "$retards en retard à surveiller" : "Aucun retard signalé", icon: Icons.assignment_outlined, color: retards > 0 ? _orange : EnsupColors.teal, ), _KpiCard( width: width, label: "Anomalies actives", value: "$anomaliesActives", detail: anomaliesActives > 0 ? "Traitement requis" : "Aucune anomalie active", icon: Icons.report_problem_outlined, color: anomaliesActives > 0 ? EnsupColors.red : EnsupColors.green, ), _KpiCard( width: width, label: "Notifications non lues", value: "$notificationsNonLues", detail: notificationsNonLues > 0 ? "Nouvelles informations" : "Vous êtes à jour", icon: Icons.notifications_none_outlined, color: EnsupColors.purple, ), ], ); }, ), const SizedBox(height: 20), LayoutBuilder( builder: (context, constraints) { final activity = _ActivityPanel( items: activite, onSeeAll: () => onNavigate(6), ); final priorities = _PrioritiesPanel( ecarts: ecartsEnAttente, retards: retards, anomalies: anomaliesActives, notifications: notificationsNonLues, indisponibles: indisponibles, disponibles: disponibles, totalMateriels: totalMateriels, onNavigate: onNavigate, ); if (constraints.maxWidth < 980) { return Column( children: [ activity, const SizedBox(height: 16), priorities, ], ); } return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(flex: 2, child: activity), const SizedBox(width: 16), Expanded(child: priorities), ], ); }, ), ], ), ), ), ); } } class _DashboardHeader extends StatelessWidget { const _DashboardHeader({required this.onRefresh}); final VoidCallback onRefresh; @override Widget build(BuildContext context) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( "Vue d’ensemble", style: GoogleFonts.darkerGrotesque( fontSize: 32, height: 1, fontWeight: FontWeight.w900, color: EnsupColors.text, ), ), const SizedBox(height: 7), Text( "La situation opérationnelle du campus en un coup d’œil · ${_dateLongue(DateTime.now())}", style: GoogleFonts.titilliumWeb( fontSize: 14, color: EnsupColors.muted, ), ), ], ), ), const SizedBox(width: 16), OutlinedButton.icon( onPressed: onRefresh, icon: const Icon(Icons.refresh_rounded, size: 17), label: const Text("Actualiser"), ), ], ); } } class _KpiCard extends StatelessWidget { const _KpiCard({ required this.width, required this.label, required this.value, required this.detail, required this.icon, required this.color, }); final double width; final String label; final String value; final String detail; final IconData icon; final Color color; @override Widget build(BuildContext context) { return Container( width: width, height: 132, padding: const EdgeInsets.all(18), decoration: BoxDecoration( color: Colors.white, border: Border.all(color: EnsupColors.line), borderRadius: BorderRadius.circular(8), boxShadow: [ BoxShadow( color: EnsupColors.text.withValues(alpha: 0.045), blurRadius: 14, offset: const Offset(0, 4), ), ], ), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: GoogleFonts.titilliumWeb( fontSize: 13, fontWeight: FontWeight.w600, color: EnsupColors.muted, ), ), Text( value, style: GoogleFonts.darkerGrotesque( fontSize: 36, height: 0.9, fontWeight: FontWeight.w900, color: EnsupColors.text, ), ), Text( detail, maxLines: 1, overflow: TextOverflow.ellipsis, style: GoogleFonts.titilliumWeb( fontSize: 11, color: color, fontWeight: FontWeight.w600, ), ), ], ), ), const SizedBox(width: 10), Container( width: 44, height: 44, decoration: BoxDecoration( color: color.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), child: Icon(icon, color: color, size: 22), ), ], ), ); } } class _ActivityPanel extends StatelessWidget { const _ActivityPanel({required this.items, required this.onSeeAll}); final List> items; final VoidCallback onSeeAll; @override Widget build(BuildContext context) { return _DashboardPanel( title: "Activité récente", subtitle: "Dernières opérations enregistrées sur le campus", action: TextButton( onPressed: onSeeAll, child: const Text("Voir l’historique"), ), child: items.isEmpty ? const _PanelEmptyState( icon: Icons.history_toggle_off_outlined, message: "Aucune activité récente", ) : Column( children: List.generate(items.take(6).length, (index) { final item = items[index]; return Column( children: [ _ActivityRow(item: item), if (index < items.take(6).length - 1) const Divider(height: 1, color: EnsupColors.line), ], ); }), ), ); } } class _ActivityRow extends StatelessWidget { const _ActivityRow({required this.item}); final Map item; @override Widget build(BuildContext context) { final utilisateur = _repartition(item["utilisateur"]); final materiel = _repartition(item["materiel"]); final action = "${item["action"] ?? ""}"; final isAlert = action == "CREATION_ANOMALIE"; final title = materiel.isNotEmpty ? "${materiel["nom"]}" : _actionLabel(action); final userName = utilisateur.isEmpty ? "Système" : "${utilisateur["prenom"]} ${utilisateur["nom"]}"; return Padding( padding: const EdgeInsets.symmetric(vertical: 13), child: Row( children: [ Container( width: 38, height: 38, decoration: BoxDecoration( color: (isAlert ? EnsupColors.red : EnsupColors.cyan).withValues( alpha: 0.09, ), borderRadius: BorderRadius.circular(8), ), child: Icon( isAlert ? Icons.report_problem_outlined : Icons.swap_horiz_rounded, size: 19, color: isAlert ? EnsupColors.red : EnsupColors.blue3, ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, maxLines: 1, overflow: TextOverflow.ellipsis, style: GoogleFonts.titilliumWeb( fontSize: 13, fontWeight: FontWeight.w700, color: EnsupColors.text, ), ), Text( "${item["description"] ?? _actionLabel(action)} · $userName", maxLines: 1, overflow: TextOverflow.ellipsis, style: GoogleFonts.titilliumWeb( fontSize: 12, color: EnsupColors.muted, ), ), ], ), ), const SizedBox(width: 12), Text( _dateHeure(item["dateAction"]), style: GoogleFonts.titilliumWeb( fontSize: 11, color: EnsupColors.muted, ), ), ], ), ); } } class _PrioritiesPanel extends StatelessWidget { const _PrioritiesPanel({ required this.ecarts, required this.retards, required this.anomalies, required this.notifications, required this.indisponibles, required this.disponibles, required this.totalMateriels, required this.onNavigate, }); final int ecarts; final int retards; final int anomalies; final int notifications; final int indisponibles; final int disponibles; final int totalMateriels; final ValueChanged onNavigate; @override Widget build(BuildContext context) { final availability = totalMateriels == 0 ? 0.0 : disponibles / totalMateriels; return _DashboardPanel( title: "Points d’attention", subtitle: "Éléments qui nécessitent votre suivi", child: Column( children: [ _PriorityRow( label: "Écarts à confirmer", value: ecarts, icon: Icons.fact_check_outlined, color: EnsupColors.red, onTap: () => onNavigate(1), ), const Divider(height: 1, color: EnsupColors.line), _PriorityRow( label: "Emprunts en retard", value: retards, icon: Icons.schedule_outlined, color: _orange, onTap: () => onNavigate(2), ), const Divider(height: 1, color: EnsupColors.line), _PriorityRow( label: "Anomalies actives", value: anomalies, icon: Icons.report_problem_outlined, color: EnsupColors.red, onTap: () => onNavigate(4), ), const Divider(height: 1, color: EnsupColors.line), _PriorityRow( label: "Notifications non lues", value: notifications, icon: Icons.notifications_none_outlined, color: EnsupColors.purple, onTap: () => onNavigate(5), ), const Divider(height: 1, color: EnsupColors.line), _PriorityRow( label: "Matériels indisponibles", value: indisponibles, icon: Icons.build_outlined, color: EnsupColors.teal, onTap: () => onNavigate(3), ), const SizedBox(height: 20), Row( children: [ Text( "Disponibilité du parc", style: GoogleFonts.titilliumWeb( fontSize: 12, fontWeight: FontWeight.w700, color: EnsupColors.text2, ), ), const Spacer(), Text( "${(availability * 100).round()} %", style: GoogleFonts.darkerGrotesque( fontSize: 17, fontWeight: FontWeight.w900, color: EnsupColors.blue3, ), ), ], ), const SizedBox(height: 7), ClipRRect( borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( value: availability, minHeight: 7, backgroundColor: EnsupColors.line, color: EnsupColors.cyan, ), ), ], ), ); } } class _PriorityRow extends StatelessWidget { const _PriorityRow({ required this.label, required this.value, required this.icon, required this.color, required this.onTap, }); final String label; final int value; final IconData icon; final Color color; final VoidCallback onTap; @override Widget build(BuildContext context) { return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(6), child: Padding( padding: const EdgeInsets.symmetric(vertical: 11), child: Row( children: [ Icon(icon, size: 18, color: color), const SizedBox(width: 10), Expanded( child: Text( label, style: GoogleFonts.titilliumWeb( fontSize: 12, fontWeight: FontWeight.w600, color: EnsupColors.text2, ), ), ), Text( "$value", style: GoogleFonts.darkerGrotesque( fontSize: 20, fontWeight: FontWeight.w900, color: value > 0 ? color : EnsupColors.muted, ), ), const SizedBox(width: 5), const Icon( Icons.chevron_right_rounded, size: 18, color: EnsupColors.muted, ), ], ), ), ); } } class _DashboardPanel extends StatelessWidget { const _DashboardPanel({ required this.title, required this.subtitle, required this.child, this.action, }); final String title; final String subtitle; final Widget child; final Widget? action; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(8), border: Border.all(color: EnsupColors.line), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: GoogleFonts.darkerGrotesque( fontSize: 21, fontWeight: FontWeight.w900, color: EnsupColors.text, ), ), Text( subtitle, style: GoogleFonts.titilliumWeb( fontSize: 12, color: EnsupColors.muted, ), ), ], ), ), ?action, ], ), const SizedBox(height: 12), child, ], ), ); } } class _PanelEmptyState extends StatelessWidget { const _PanelEmptyState({required this.icon, required this.message}); final IconData icon; final String message; @override Widget build(BuildContext context) { return SizedBox( height: 180, child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 32, color: EnsupColors.muted), const SizedBox(height: 8), Text( message, style: GoogleFonts.titilliumWeb(color: EnsupColors.muted), ), ], ), ), ); } } class _VueListe extends StatefulWidget { const _VueListe({ required this.titre, required this.description, required this.icon, required this.items, required this.builder, required this.searchableText, this.action, }); final String titre; final String description; final IconData icon; final List> items; final Widget Function(Map) builder; final String Function(Map) searchableText; final Widget? action; @override State<_VueListe> createState() => _VueListeState(); } class _VueListeState extends State<_VueListe> { String _recherche = ""; @override Widget build(BuildContext context) { final terme = _recherche.trim().toLowerCase(); final resultats = terme.isEmpty ? widget.items : widget.items .where( (item) => widget.searchableText(item).toLowerCase().contains(terme), ) .toList(); return ColoredBox( color: _workspaceBackground, child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 1440), child: SizedBox( width: double.infinity, child: Padding( padding: const EdgeInsets.all(28), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ LayoutBuilder( builder: (context, constraints) { final compact = constraints.maxWidth < 620; final title = Row( children: [ Container( width: 42, height: 42, decoration: BoxDecoration( color: EnsupColors.cyan.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), child: Icon( widget.icon, color: EnsupColors.blue3, size: 21, ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( widget.titre, style: GoogleFonts.darkerGrotesque( fontSize: 29, height: 1, fontWeight: FontWeight.w900, color: EnsupColors.text, ), ), Text( widget.description, maxLines: 2, overflow: TextOverflow.ellipsis, style: GoogleFonts.titilliumWeb( fontSize: 13, color: EnsupColors.muted, ), ), ], ), ), ], ); if (compact && widget.action != null) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ title, const SizedBox(height: 14), Align( alignment: Alignment.centerLeft, child: widget.action!, ), ], ); } return Row( children: [ Expanded(child: title), if (widget.action != null) ...[ const SizedBox(width: 16), widget.action!, ], ], ); }, ), const SizedBox(height: 22), Container( height: 46, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(8), border: Border.all(color: EnsupColors.line), boxShadow: [ BoxShadow( color: EnsupColors.text.withValues(alpha: 0.035), blurRadius: 8, offset: const Offset(0, 2), ), ], ), child: TextField( onChanged: (value) => setState(() => _recherche = value), decoration: InputDecoration( hintText: "Rechercher dans ${widget.titre.toLowerCase()}...", prefixIcon: const Icon( Icons.search_rounded, size: 20, color: EnsupColors.muted, ), suffixIcon: Padding( padding: const EdgeInsets.only(right: 10), child: Center( widthFactor: 1, child: Text( "${resultats.length} résultat${resultats.length > 1 ? "s" : ""}", style: GoogleFonts.titilliumWeb( fontSize: 11, color: EnsupColors.muted, ), ), ), ), isDense: true, border: InputBorder.none, contentPadding: const EdgeInsets.symmetric( vertical: 13, ), ), ), ), const SizedBox(height: 14), Expanded( child: resultats.isEmpty ? const _EmptyState() : ListView.separated( itemCount: resultats.length, separatorBuilder: (_, _) => const SizedBox(height: 8), itemBuilder: (_, index) => widget.builder(resultats[index]), ), ), ], ), ), ), ), ), ); } } class _InfoTile extends StatelessWidget { const _InfoTile({ required this.icon, required this.title, required this.subtitle, required this.trailing, this.meta, }); final IconData icon; final String title; final String subtitle; final Widget trailing; final String? meta; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Colors.white, border: Border.all(color: EnsupColors.line), borderRadius: BorderRadius.circular(8), boxShadow: [ BoxShadow( color: EnsupColors.text.withValues(alpha: 0.035), blurRadius: 10, offset: const Offset(0, 3), ), ], ), child: LayoutBuilder( builder: (context, constraints) { final compact = constraints.maxWidth < 620; final information = Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 40, height: 40, decoration: BoxDecoration( color: EnsupColors.teal.withValues(alpha: 0.09), borderRadius: BorderRadius.circular(8), ), child: Icon(icon, size: 20, color: EnsupColors.teal), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, maxLines: compact ? 2 : 1, overflow: TextOverflow.ellipsis, style: GoogleFonts.darkerGrotesque( fontSize: 19, height: 1.05, fontWeight: FontWeight.w800, color: EnsupColors.text, ), ), const SizedBox(height: 3), Text( subtitle, maxLines: 2, overflow: TextOverflow.ellipsis, style: GoogleFonts.titilliumWeb( fontSize: 13, color: EnsupColors.text2, ), ), if (meta != null) ...[ const SizedBox(height: 4), Text( meta!, maxLines: 1, overflow: TextOverflow.ellipsis, style: GoogleFonts.titilliumWeb( fontSize: 11, color: EnsupColors.muted, ), ), ], ], ), ), ], ); if (compact) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ information, const SizedBox(height: 12), Align(alignment: Alignment.centerRight, child: trailing), ], ); } return Row( children: [ Expanded(child: information), const SizedBox(width: 16), trailing, ], ); }, ), ); } } class _AnomalieAction extends StatelessWidget { const _AnomalieAction({ required this.statut, required this.prochainStatut, required this.disabled, required this.onPressed, }); final String statut; final String? prochainStatut; final bool disabled; final VoidCallback onPressed; @override Widget build(BuildContext context) { final prochain = prochainStatut; return Wrap( spacing: 8, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, alignment: WrapAlignment.end, children: [ _StatusBadge(statut), if (prochain != null) FilledButton.icon( onPressed: disabled ? null : onPressed, icon: const Icon(Icons.arrow_forward_rounded, size: 16), label: Text(_statusLabel(prochain)), ), ], ); } } class _StatusBadge extends StatelessWidget { const _StatusBadge(this.statut); final String statut; @override Widget build(BuildContext context) { final color = _statusColor(statut); return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: color.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(999), border: Border.all(color: color.withValues(alpha: 0.18)), ), child: Text( _statusLabel(statut), style: TextStyle( fontSize: 12, fontWeight: FontWeight.w700, color: color, ), ), ); } } class _EmptyState extends StatelessWidget { const _EmptyState(); @override Widget build(BuildContext context) { return Center( child: Text( "Aucun résultat", style: GoogleFonts.titilliumWeb(color: EnsupColors.muted), ), ); } } class _Erreur extends StatelessWidget { const _Erreur({required this.message, required this.onRetry}); final String message; final VoidCallback onRetry; @override Widget build(BuildContext context) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text(message, textAlign: TextAlign.center), const SizedBox(height: 12), OutlinedButton.icon( onPressed: onRetry, icon: const Icon(Icons.refresh), label: const Text("Réessayer"), ), ], ), ); } }