10 Commits

Author SHA1 Message Date
SaidSoighiri94 f7ce98343e merge: student premium workspace 2026-07-23 10:30:03 +02:00
SaidSoighiri94 669ed85924 docs: record student workspace polish 2026-07-23 10:29:47 +02:00
SaidSoighiri94 695b0d3965 style(frontend): polish student workspace 2026-07-23 10:27:27 +02:00
SaidSoighiri94 1c8ed77ebf fix(frontend): correct responsable brand label 2026-07-23 09:56:19 +02:00
SaidSoighiri94 1f0a120ecb merge: responsable history csv export 2026-07-23 09:42:27 +02:00
SaidSoighiri94 cb3597874d docs: record responsable csv export 2026-07-23 09:42:14 +02:00
SaidSoighiri94 c85fab8ca3 feat(frontend): download responsable history csv 2026-07-23 09:42:01 +02:00
SaidSoighiri94 95c9080777 merge: responsable premium dashboard 2026-07-23 09:25:47 +02:00
SaidSoighiri94 6ac94becfb docs: record responsable workspace redesign 2026-07-23 09:25:30 +02:00
SaidSoighiri94 ef74fd49bc style(frontend): redesign responsable workspace 2026-07-22 17:26:00 +02:00
17 changed files with 2790 additions and 769 deletions
+96 -24
View File
@@ -35,15 +35,18 @@ class _CatalogueScreenState extends State<CatalogueScreen> {
}
List<String> _categories(List<MaterielItem> materiels) {
final distinctes = materiels.map((m) => m.categorie).toSet().toList()..sort();
final distinctes = materiels.map((m) => m.categorie).toSet().toList()
..sort();
return ["Tous", ...distinctes];
}
List<MaterielItem> _filtrer(List<MaterielItem> materiels) {
final terme = _recherche.trim().toLowerCase();
return materiels.where((materiel) {
final matchTexte = terme.isEmpty || materiel.nom.toLowerCase().contains(terme);
final matchCategorie = _categorie == "Tous" || materiel.categorie == _categorie;
final matchTexte =
terme.isEmpty || materiel.nom.toLowerCase().contains(terme);
final matchCategorie =
_categorie == "Tous" || materiel.categorie == _categorie;
return matchTexte && matchCategorie;
}).toList();
}
@@ -55,16 +58,16 @@ class _CatalogueScreenState extends State<CatalogueScreen> {
if (!mounted) {
return;
}
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => DetailScreen(item: detail)),
);
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => DetailScreen(item: detail)));
} catch (error) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error.toString())),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.toString())));
} finally {
if (mounted) {
setState(() => _selectionEnCoursId = null);
@@ -106,20 +109,42 @@ class _CatalogueScreenState extends State<CatalogueScreen> {
),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(32, 24, 32, 8),
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Matériel disponible",
style: GoogleFonts.darkerGrotesque(
fontSize: 28,
height: 1,
fontWeight: FontWeight.w900,
color: EnsupColors.text,
),
),
const SizedBox(height: 5),
Text(
"Recherchez un équipement puis vérifiez son contenu avant lemprunt.",
style: GoogleFonts.titilliumWeb(
fontSize: 13,
color: EnsupColors.muted,
),
),
const SizedBox(height: 18),
_SearchField(
onChanged: (value) => setState(() => _recherche = value),
onChanged: (value) =>
setState(() => _recherche = value),
),
const SizedBox(height: 14),
Expanded(
child: FutureBuilder<List<MaterielItem>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
if (snapshot.connectionState ==
ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (snapshot.hasError) {
return _ErreurState(
@@ -165,8 +190,25 @@ class _CatalogueScreenState extends State<CatalogueScreen> {
}).toList(),
),
const SizedBox(height: 16),
const Divider(color: EnsupColors.line, height: 1),
const SizedBox(height: 16),
Row(
children: [
Text(
"${resultats.length} équipement${resultats.length > 1 ? "s" : ""}",
style: GoogleFonts.darkerGrotesque(
fontSize: 16,
fontWeight: FontWeight.w800,
color: EnsupColors.text2,
),
),
const Expanded(
child: Padding(
padding: EdgeInsets.only(left: 14),
child: Divider(color: EnsupColors.line, height: 1),
),
),
],
),
const SizedBox(height: 12),
Expanded(
child: resultats.isEmpty
? const _EmptyState()
@@ -177,7 +219,10 @@ class _CatalogueScreenState extends State<CatalogueScreen> {
final item = resultats[index];
return Stack(
children: [
MaterielCard(item: item, onSelect: () => _selectionner(item)),
MaterielCard(
item: item,
onSelect: () => _selectionner(item),
),
if (_selectionEnCoursId == item.id)
const Positioned.fill(
child: ColoredBox(
@@ -217,12 +262,18 @@ class _SearchField extends StatelessWidget {
Expanded(
child: TextField(
onChanged: onChanged,
style: GoogleFonts.titilliumWeb(fontSize: 14, color: EnsupColors.text),
style: GoogleFonts.titilliumWeb(
fontSize: 14,
color: EnsupColors.text,
),
decoration: InputDecoration(
isCollapsed: true,
border: InputBorder.none,
hintText: "Rechercher un matériel...",
hintStyle: GoogleFonts.titilliumWeb(fontSize: 14, color: EnsupColors.muted),
hintStyle: GoogleFonts.titilliumWeb(
fontSize: 14,
color: EnsupColors.muted,
),
),
),
),
@@ -233,7 +284,11 @@ class _SearchField extends StatelessWidget {
}
class _CategoryChip extends StatelessWidget {
const _CategoryChip({required this.label, required this.selected, required this.onTap});
const _CategoryChip({
required this.label,
required this.selected,
required this.onTap,
});
final String label;
final bool selected;
@@ -249,7 +304,10 @@ class _CategoryChip extends StatelessWidget {
decoration: BoxDecoration(
color: selected ? EnsupColors.cyan : Colors.white,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: selected ? EnsupColors.cyan : EnsupColors.line, width: 1.5),
border: Border.all(
color: selected ? EnsupColors.cyan : EnsupColors.line,
width: 1.5,
),
),
child: Text(
label,
@@ -273,11 +331,18 @@ class _EmptyState extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.inventory_2_outlined, size: 40, color: EnsupColors.muted),
const Icon(
Icons.inventory_2_outlined,
size: 40,
color: EnsupColors.muted,
),
const SizedBox(height: 10),
Text(
"Aucun matériel ne correspond",
style: GoogleFonts.titilliumWeb(fontSize: 14, color: EnsupColors.muted),
style: GoogleFonts.titilliumWeb(
fontSize: 14,
color: EnsupColors.muted,
),
),
],
),
@@ -297,12 +362,19 @@ class _ErreurState extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.cloud_off_outlined, size: 40, color: EnsupColors.muted),
const Icon(
Icons.cloud_off_outlined,
size: 40,
color: EnsupColors.muted,
),
const SizedBox(height: 12),
Text(
message,
textAlign: TextAlign.center,
style: GoogleFonts.titilliumWeb(fontSize: 14, color: EnsupColors.text2),
style: GoogleFonts.titilliumWeb(
fontSize: 14,
color: EnsupColors.text2,
),
),
const SizedBox(height: 14),
OutlinedButton(
@@ -58,7 +58,9 @@ class _ChecklistDepartScreenState extends State<ChecklistDepartScreen> {
return {
"nomElement": entry.value,
"etat": _etatApi(_etats[entry.key]),
"quantiteConstatee": _etats[entry.key] == EtatChecklist.absent ? 0 : 1,
"quantiteConstatee": _etats[entry.key] == EtatChecklist.absent
? 0
: 1,
};
}).toList(),
);
@@ -75,9 +77,9 @@ class _ChecklistDepartScreenState extends State<ChecklistDepartScreen> {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error.toString())),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.toString())));
} finally {
if (mounted) {
setState(() => _envoiEnCours = false);
@@ -130,14 +132,31 @@ class _ChecklistDepartScreenState extends State<ChecklistDepartScreen> {
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 28),
child: Row(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 26,
),
child: LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth < 780) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_colonneGauche(),
const SizedBox(height: 22),
_colonneDroite(),
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 3, child: _colonneGauche()),
const SizedBox(width: 20),
Expanded(flex: 2, child: _colonneDroite()),
],
);
},
),
),
),
@@ -161,7 +180,10 @@ class _ChecklistDepartScreenState extends State<ChecklistDepartScreen> {
decoration: BoxDecoration(
color: EnsupColors.soft,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: EnsupColors.cyan.withValues(alpha: 0.15), width: 1.5),
border: Border.all(
color: EnsupColors.cyan.withValues(alpha: 0.15),
width: 1.5,
),
),
child: Row(
children: [
@@ -172,7 +194,11 @@ class _ChecklistDepartScreenState extends State<ChecklistDepartScreen> {
color: EnsupColors.cyan.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(9),
),
child: Icon(widget.item.icon, color: EnsupColors.cyan, size: 18),
child: Icon(
widget.item.icon,
color: EnsupColors.cyan,
size: 18,
),
),
const SizedBox(width: 12),
Expanded(
@@ -181,11 +207,17 @@ class _ChecklistDepartScreenState extends State<ChecklistDepartScreen> {
children: [
Text(
widget.item.nom,
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w800, fontSize: 16),
style: GoogleFonts.darkerGrotesque(
fontWeight: FontWeight.w800,
fontSize: 16,
),
),
Text(
"Vérifiez chaque élément physiquement avant de valider",
style: GoogleFonts.titilliumWeb(fontSize: 12, color: EnsupColors.muted),
style: GoogleFonts.titilliumWeb(
fontSize: 12,
color: EnsupColors.muted,
),
),
],
),
@@ -225,7 +257,10 @@ class _ChecklistDepartScreenState extends State<ChecklistDepartScreen> {
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.check, size: 18),
label: Text(
@@ -233,12 +268,17 @@ class _ChecklistDepartScreenState extends State<ChecklistDepartScreen> {
? "Enregistrement..."
: "Valider la checklist et confirmer l'emprunt",
textAlign: TextAlign.center,
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700, fontSize: 15),
style: GoogleFonts.darkerGrotesque(
fontWeight: FontWeight.w700,
fontSize: 15,
),
),
style: FilledButton.styleFrom(
backgroundColor: EnsupColors.blue3,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
),
),
@@ -22,7 +22,8 @@ class ChecklistRetourScreen extends StatefulWidget {
}
class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
late final List<String> _elements = widget.emprunt.materiel.accessoires.isEmpty
late final List<String> _elements =
widget.emprunt.materiel.accessoires.isEmpty
? <String>[widget.emprunt.materiel.nom]
: widget.emprunt.materiel.accessoires;
late final List<EtatChecklist> _etats = List<EtatChecklist>.filled(
@@ -44,9 +45,16 @@ class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
final anomalies = <AnomalieRetour>[];
for (var i = 0; i < _etats.length; i++) {
if (_etats[i] == EtatChecklist.absent) {
anomalies.add(AnomalieRetour(element: _elements[i], etatRetour: "Absent au retour"));
anomalies.add(
AnomalieRetour(element: _elements[i], etatRetour: "Absent au retour"),
);
} else if (_etats[i] == EtatChecklist.deteriore) {
anomalies.add(AnomalieRetour(element: _elements[i], etatRetour: "Détérioré au retour"));
anomalies.add(
AnomalieRetour(
element: _elements[i],
etatRetour: "Détérioré au retour",
),
);
}
}
@@ -58,7 +66,9 @@ class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
return {
"nomElement": entry.value,
"etat": _etatApi(_etats[entry.key]),
"quantiteConstatee": _etats[entry.key] == EtatChecklist.absent ? 0 : 1,
"quantiteConstatee": _etats[entry.key] == EtatChecklist.absent
? 0
: 1,
};
}).toList(),
);
@@ -67,7 +77,9 @@ class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
return;
}
final anomaliesAffichees = resultat.statut == "CLOTURE" ? <AnomalieRetour>[] : anomalies;
final anomaliesAffichees = resultat.statut == "CLOTURE"
? <AnomalieRetour>[]
: anomalies;
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ResultatRetourScreen(
@@ -80,9 +92,9 @@ class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error.toString())),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.toString())));
} finally {
if (mounted) {
setState(() => _envoiEnCours = false);
@@ -135,14 +147,31 @@ class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 28),
child: Row(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 26,
),
child: LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth < 780) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_colonneGauche(),
const SizedBox(height: 22),
_colonneDroite(),
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 3, child: _colonneGauche()),
const SizedBox(width: 20),
Expanded(flex: 2, child: _colonneDroite()),
],
);
},
),
),
),
@@ -168,7 +197,10 @@ class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
decoration: BoxDecoration(
color: EnsupColors.soft,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: EnsupColors.teal.withValues(alpha: 0.15), width: 1.5),
border: Border.all(
color: EnsupColors.teal.withValues(alpha: 0.15),
width: 1.5,
),
),
child: Row(
children: [
@@ -188,11 +220,17 @@ class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
children: [
Text(
materiel.nom,
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w800, fontSize: 16),
style: GoogleFonts.darkerGrotesque(
fontWeight: FontWeight.w800,
fontSize: 16,
),
),
Text(
"Emprunté le ${widget.emprunt.dateEmprunt}",
style: GoogleFonts.titilliumWeb(fontSize: 12, color: EnsupColors.muted),
style: GoogleFonts.titilliumWeb(
fontSize: 12,
color: EnsupColors.muted,
),
),
],
),
@@ -234,17 +272,25 @@ class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.check, size: 18),
label: Text(
_envoiEnCours ? "Enregistrement..." : "Valider le retour",
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700, fontSize: 16),
style: GoogleFonts.darkerGrotesque(
fontWeight: FontWeight.w700,
fontSize: 16,
),
),
style: FilledButton.styleFrom(
backgroundColor: EnsupColors.blue3,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
),
),
+101 -16
View File
@@ -55,14 +55,34 @@ class DetailScreen extends StatelessWidget {
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 28),
child: Row(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 26,
),
child: LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth < 760) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_colonneGauche(),
const SizedBox(height: 20),
_colonneDroite(context),
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 3, child: _colonneGauche()),
const SizedBox(width: 20),
Expanded(flex: 2, child: _colonneDroite(context)),
Expanded(
flex: 2,
child: _colonneDroite(context),
),
],
);
},
),
),
),
@@ -84,17 +104,35 @@ class DetailScreen extends StatelessWidget {
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: EnsupColors.soft,
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: EnsupColors.line, width: 1.5),
boxShadow: [
BoxShadow(
color: EnsupColors.text.withValues(alpha: 0.04),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: EnsupColors.cyan.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(item.icon, color: EnsupColors.blue3, size: 21),
),
const SizedBox(height: 12),
Text(
item.nom,
style: GoogleFonts.darkerGrotesque(
fontSize: 20,
fontSize: 24,
height: 1,
fontWeight: FontWeight.w900,
color: EnsupColors.text,
),
@@ -139,17 +177,27 @@ class DetailScreen extends StatelessWidget {
border: Border(bottom: BorderSide(color: EnsupColors.line)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(cle, style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.muted)),
Text(
cle,
style: GoogleFonts.titilliumWeb(
fontSize: 13,
color: EnsupColors.muted,
),
),
const SizedBox(width: 16),
Expanded(
child: Text(
valeur,
textAlign: TextAlign.right,
style: GoogleFonts.titilliumWeb(
fontSize: 13,
fontWeight: FontWeight.w600,
color: valueColor ?? EnsupColors.text,
),
),
),
],
),
);
@@ -161,7 +209,13 @@ class DetailScreen extends StatelessWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("Statut", style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.muted)),
Text(
"Statut",
style: GoogleFonts.titilliumWeb(
fontSize: 13,
color: EnsupColors.muted,
),
),
StatusPill(disponible: item.disponible),
],
),
@@ -193,18 +247,39 @@ class DetailScreen extends StatelessWidget {
height: 180,
width: double.infinity,
decoration: BoxDecoration(
color: EnsupColors.soft,
color: EnsupColors.cyan.withValues(alpha: 0.045),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: EnsupColors.line, width: 1.5),
border: Border.all(
color: EnsupColors.cyan.withValues(alpha: 0.18),
width: 1.5,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(item.icon, size: 48, color: EnsupColors.muted),
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: EnsupColors.blue3.withValues(alpha: 0.1),
blurRadius: 14,
offset: const Offset(0, 4),
),
],
),
child: Icon(item.icon, size: 36, color: EnsupColors.blue3),
),
const SizedBox(height: 10),
Text(
"Aperçu du matériel",
style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.muted),
style: GoogleFonts.titilliumWeb(
fontSize: 13,
color: EnsupColors.muted,
),
),
],
),
@@ -217,12 +292,17 @@ class DetailScreen extends StatelessWidget {
icon: const Icon(Icons.arrow_forward, size: 18),
label: Text(
"Démarrer l'emprunt",
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700, fontSize: 16),
style: GoogleFonts.darkerGrotesque(
fontWeight: FontWeight.w700,
fontSize: 16,
),
),
style: FilledButton.styleFrom(
backgroundColor: EnsupColors.cyan,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
),
),
@@ -235,11 +315,16 @@ class DetailScreen extends StatelessWidget {
foregroundColor: EnsupColors.text2,
side: const BorderSide(color: EnsupColors.line, width: 1.5),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: Text(
"Retour au catalogue",
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700, fontSize: 15),
style: GoogleFonts.darkerGrotesque(
fontWeight: FontWeight.w700,
fontSize: 15,
),
),
),
),
+56 -19
View File
@@ -46,7 +46,13 @@ class HomeScreen extends StatelessWidget {
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 28),
padding: const EdgeInsets.symmetric(
horizontal: 28,
vertical: 26,
),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1040),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -55,51 +61,82 @@ class HomeScreen extends StatelessWidget {
subtitle: DemoIdentity.userSubtitle,
initials: DemoIdentity.userInitials,
),
const SizedBox(height: 24),
const SizedBox(height: 28),
Text(
"Que souhaitez-vous faire ?",
style: GoogleFonts.darkerGrotesque(
fontSize: 28,
fontSize: 30,
height: 1,
fontWeight: FontWeight.w900,
color: EnsupColors.text,
),
),
const SizedBox(height: 18),
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: ActionCard(
const SizedBox(height: 6),
Text(
"Choisissez lopération à réaliser sur ce poste.",
style: GoogleFonts.titilliumWeb(
fontSize: 14,
color: EnsupColors.muted,
),
),
const SizedBox(height: 20),
LayoutBuilder(
builder: (context, constraints) {
final emprunter = ActionCard(
icon: Icons.inventory_2_outlined,
title: "EMPRUNTER",
description:
"Accéder au catalogue et emprunter du matériel éducatif",
"Consulter les équipements disponibles et démarrer un nouvel emprunt.",
color: EnsupColors.cyan,
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const CatalogueScreen()),
MaterialPageRoute(
builder: (_) =>
const CatalogueScreen(),
),
),
),
const SizedBox(width: 16),
Expanded(
child: ActionCard(
);
final restituer = ActionCard(
icon: Icons.assignment_return_outlined,
title: "RESTITUER",
description:
"Restituer un matériel que vous avez emprunté",
"Sélectionner un emprunt en cours et contrôler le matériel rendu.",
color: EnsupColors.teal,
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const RestitutionSelectScreen(),
builder: (_) =>
const RestitutionSelectScreen(),
),
),
);
if (constraints.maxWidth < 720) {
return Column(
crossAxisAlignment:
CrossAxisAlignment.stretch,
children: [
emprunter,
const SizedBox(height: 14),
restituer,
],
);
}
return IntrinsicHeight(
child: Row(
crossAxisAlignment:
CrossAxisAlignment.stretch,
children: [
Expanded(child: emprunter),
const SizedBox(width: 16),
Expanded(child: restituer),
],
),
);
},
),
],
),
),
],
),
),
),
File diff suppressed because it is too large Load Diff
@@ -14,7 +14,8 @@ class RestitutionSelectScreen extends StatefulWidget {
const RestitutionSelectScreen({super.key});
@override
State<RestitutionSelectScreen> createState() => _RestitutionSelectScreenState();
State<RestitutionSelectScreen> createState() =>
_RestitutionSelectScreenState();
}
class _RestitutionSelectScreenState extends State<RestitutionSelectScreen> {
@@ -40,16 +41,18 @@ class _RestitutionSelectScreenState extends State<RestitutionSelectScreen> {
}
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ChecklistRetourScreen(emprunt: emprunt.copyWith(materiel: detail)),
builder: (_) => ChecklistRetourScreen(
emprunt: emprunt.copyWith(materiel: detail),
),
),
);
} catch (error) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error.toString())),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.toString())));
} finally {
if (mounted) {
setState(() => _chargementId = null);
@@ -91,12 +94,18 @@ class _RestitutionSelectScreenState extends State<RestitutionSelectScreen> {
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 28),
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 28,
),
child: FutureBuilder<List<EmpruntItem>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
if (snapshot.connectionState ==
ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (snapshot.hasError) {
return _ErreurState(
@@ -128,12 +137,21 @@ class _RestitutionSelectScreenState extends State<RestitutionSelectScreen> {
Text(
"Sélectionner le matériel à restituer",
style: GoogleFonts.darkerGrotesque(
fontSize: 24,
fontSize: 28,
height: 1,
fontWeight: FontWeight.w900,
color: EnsupColors.text,
),
),
const SizedBox(height: 16),
const SizedBox(height: 6),
Text(
"${emprunts.length} emprunt${emprunts.length > 1 ? "s" : ""} en cours associé${emprunts.length > 1 ? "s" : ""} à votre compte.",
style: GoogleFonts.titilliumWeb(
fontSize: 13,
color: EnsupColors.muted,
),
),
const SizedBox(height: 18),
Expanded(
child: emprunts.isEmpty
? const _EmptyState()
@@ -174,17 +192,31 @@ class _EmpruntCard extends StatelessWidget {
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: EnsupColors.line, width: 1.5),
boxShadow: [
BoxShadow(
color: EnsupColors.text.withValues(alpha: 0.035),
blurRadius: 10,
offset: const Offset(0, 3),
),
child: Row(
],
),
child: LayoutBuilder(
builder: (context, constraints) {
final compact = constraints.maxWidth < 650;
final information = Row(
children: [
Container(
width: 44,
height: 44,
width: 46,
height: 46,
decoration: BoxDecoration(
color: EnsupColors.teal.withValues(alpha: 0.08),
color: EnsupColors.teal.withValues(alpha: 0.09),
borderRadius: BorderRadius.circular(10),
),
child: Icon(emprunt.materiel.icon, color: EnsupColors.teal, size: 20),
child: Icon(
emprunt.materiel.icon,
color: EnsupColors.teal,
size: 21,
),
),
const SizedBox(width: 14),
Expanded(
@@ -193,25 +225,45 @@ class _EmpruntCard extends StatelessWidget {
children: [
Text(
emprunt.materiel.nom,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.darkerGrotesque(
fontWeight: FontWeight.w700,
fontSize: 16,
fontWeight: FontWeight.w800,
fontSize: 17,
height: 1.05,
color: EnsupColors.text,
),
),
const SizedBox(height: 2),
const SizedBox(height: 4),
Text(
"Emprunté le ${emprunt.dateEmprunt} · Retour prévu ${emprunt.retourPrevu}",
style: GoogleFonts.titilliumWeb(fontSize: 12, color: EnsupColors.muted),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.titilliumWeb(
fontSize: 12,
color: EnsupColors.muted,
),
),
],
),
),
const SizedBox(width: 10),
],
);
final actions = Wrap(
spacing: 10,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
alignment: WrapAlignment.end,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
padding: const EdgeInsets.symmetric(
horizontal: 11,
vertical: 5,
),
decoration: BoxDecoration(
color: const Color(0xFFFEF9C3),
color: emprunt.statut == "EN_RETARD"
? EnsupColors.red.withValues(alpha: 0.09)
: const Color(0xFFFEF9C3),
borderRadius: BorderRadius.circular(999),
),
child: Text(
@@ -219,24 +271,64 @@ class _EmpruntCard extends StatelessWidget {
style: GoogleFonts.darkerGrotesque(
fontSize: 13,
fontWeight: FontWeight.w700,
color: const Color(0xFFA16207),
color: emprunt.statut == "EN_RETARD"
? EnsupColors.red
: const Color(0xFFA16207),
),
),
),
const SizedBox(width: 12),
FilledButton(
FilledButton.icon(
onPressed: loading ? null : onRestituer,
style: FilledButton.styleFrom(
backgroundColor: EnsupColors.cyan,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
padding: const EdgeInsets.symmetric(
horizontal: 15,
vertical: 11,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(9),
),
),
icon: loading
? const SizedBox(
width: 15,
height: 15,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.assignment_return_outlined, size: 16),
label: Text(
loading ? "Chargement" : "Restituer",
style: GoogleFonts.darkerGrotesque(
fontWeight: FontWeight.w700,
fontSize: 14,
),
child: Text(
loading ? "Chargement..." : "Restituer",
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700, fontSize: 14),
),
),
],
);
if (compact) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
information,
const SizedBox(height: 14),
Align(alignment: Alignment.centerRight, child: actions),
],
);
}
return Row(
children: [
Expanded(child: information),
const SizedBox(width: 16),
actions,
],
);
},
),
);
}
@@ -268,12 +360,19 @@ class _ErreurState extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.cloud_off_outlined, size: 40, color: EnsupColors.muted),
const Icon(
Icons.cloud_off_outlined,
size: 40,
color: EnsupColors.muted,
),
const SizedBox(height: 12),
Text(
message,
textAlign: TextAlign.center,
style: GoogleFonts.titilliumWeb(fontSize: 14, color: EnsupColors.text2),
style: GoogleFonts.titilliumWeb(
fontSize: 14,
color: EnsupColors.text2,
),
),
const SizedBox(height: 14),
OutlinedButton(
+55
View File
@@ -1,4 +1,5 @@
import "dart:convert";
import "dart:typed_data";
import "package:http/http.dart" as http;
/// Erreur levée par le client API (serveur injoignable ou réponse en échec).
@@ -11,6 +12,18 @@ class ApiException implements Exception {
String toString() => message;
}
class ApiFile {
const ApiFile({
required this.bytes,
required this.fileName,
required this.contentType,
});
final Uint8List bytes;
final String fileName;
final String contentType;
}
/// Point d'accès unique au backend : centralise l'URL de base, l'en-tête
/// d'authentification (simulée) et la gestion des erreurs.
class ApiClient {
@@ -44,6 +57,37 @@ class ApiClient {
throw ApiException(_messageErreur(reponse));
}
static Future<ApiFile> getFile(
String chemin, {
required String fallbackFileName,
String utilisateurEmail = _utilisateurEmail,
}) async {
final uri = Uri.parse("$_baseUrl$chemin");
late final http.Response reponse;
try {
reponse = await http.get(
uri,
headers: {"x-user-email": utilisateurEmail},
);
} catch (_) {
throw const ApiException(
"Impossible de joindre le serveur. Vérifiez que le backend est démarré.",
);
}
if (reponse.statusCode < 200 || reponse.statusCode >= 300) {
throw ApiException(_messageErreur(reponse));
}
return ApiFile(
bytes: reponse.bodyBytes,
fileName: _fileName(reponse, fallbackFileName),
contentType:
reponse.headers["content-type"] ?? "application/octet-stream",
);
}
static Future<dynamic> post(
String chemin,
Map<String, dynamic> body, {
@@ -116,4 +160,15 @@ class ApiClient {
}
return "Erreur ${reponse.statusCode}";
}
static String _fileName(http.Response reponse, String fallback) {
final disposition = reponse.headers["content-disposition"];
if (disposition == null) return fallback;
final match = RegExp(
"filename=\"?([^\";]+)\"?",
caseSensitive: false,
).firstMatch(disposition);
return match?.group(1) ?? fallback;
}
}
@@ -29,6 +29,12 @@ class ResponsableService {
static Future<List<Map<String, dynamic>>> historique() =>
_liste("/responsable/historique");
static Future<ApiFile> exporterHistorique() => ApiClient.getFile(
"/responsable/historique/export.csv",
fallbackFileName: "historique-responsable.csv",
utilisateurEmail: _email,
);
static Future<int> marquerNotificationsLues() async {
final reponse = await ApiClient.patch(
"/responsable/notifications/lu-toutes",
+21
View File
@@ -0,0 +1,21 @@
// ignore_for_file: avoid_web_libraries_in_flutter, deprecated_member_use
import "dart:html" as html;
import "dart:typed_data";
void downloadFile({
required Uint8List bytes,
required String fileName,
required String contentType,
}) {
final blob = html.Blob([bytes], contentType);
final url = html.Url.createObjectUrlFromBlob(blob);
final anchor = html.AnchorElement(href: url)
..download = fileName
..style.display = "none";
html.document.body?.children.add(anchor);
anchor.click();
anchor.remove();
html.Url.revokeObjectUrl(url);
}
+77 -26
View File
@@ -3,7 +3,7 @@ import "package:google_fonts/google_fonts.dart";
import "../theme/ensup_colors.dart";
/// Grande carte d'action de l'accueil (Emprunter / Restituer).
class ActionCard extends StatelessWidget {
class ActionCard extends StatefulWidget {
const ActionCard({
super.key,
required this.icon,
@@ -19,58 +19,109 @@ class ActionCard extends StatelessWidget {
final Color color;
final VoidCallback? onTap;
@override
State<ActionCard> createState() => _ActionCardState();
}
class _ActionCardState extends State<ActionCard> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
final color = widget.color;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
cursor: SystemMouseCursors.click,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
constraints: const BoxConstraints(minHeight: 230),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.07),
color: _hovered
? color.withValues(alpha: 0.1)
: color.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: color.withValues(alpha: 0.2), width: 2),
border: Border.all(
color: color.withValues(alpha: _hovered ? 0.55 : 0.22),
width: _hovered ? 2 : 1.5,
),
child: Column(
children: [
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
gradient: LinearGradient(colors: [color, color.withValues(alpha: 0.75)]),
borderRadius: BorderRadius.circular(18),
boxShadow: [
BoxShadow(
color: color.withValues(alpha: 0.35),
blurRadius: 16,
offset: const Offset(0, 4),
color: color.withValues(alpha: _hovered ? 0.16 : 0.06),
blurRadius: _hovered ? 22 : 10,
offset: Offset(0, _hovered ? 8 : 3),
),
],
),
child: Icon(icon, color: Colors.white, size: 28),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: widget.onTap,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 180),
width: 60,
height: 60,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(18),
boxShadow: [
BoxShadow(
color: color.withValues(alpha: 0.3),
blurRadius: _hovered ? 18 : 12,
offset: const Offset(0, 5),
),
const SizedBox(height: 14),
],
),
child: Icon(widget.icon, color: Colors.white, size: 28),
),
const SizedBox(height: 16),
Text(
title,
widget.title,
textAlign: TextAlign.center,
style: GoogleFonts.darkerGrotesque(
fontSize: 26,
fontSize: 25,
height: 1,
fontWeight: FontWeight.w900,
color: EnsupColors.text,
),
),
const SizedBox(height: 6),
const SizedBox(height: 8),
Text(
description,
widget.description,
textAlign: TextAlign.center,
style: GoogleFonts.titilliumWeb(
fontSize: 13,
color: EnsupColors.muted,
height: 1.5,
height: 1.45,
),
),
const SizedBox(height: 16),
Container(
width: 30,
height: 30,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
shape: BoxShape.circle,
),
child: Icon(
Icons.arrow_forward_rounded,
size: 17,
color: color,
),
),
],
),
),
),
),
),
);
}
}
+71 -42
View File
@@ -27,96 +27,122 @@ class EnsupTopBar extends StatelessWidget {
final onBack = this.onBack;
return Container(
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 20),
height: 60,
padding: const EdgeInsets.symmetric(horizontal: 18),
color: EnsupColors.blue3,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
child: LayoutBuilder(
builder: (context, constraints) {
final compact = constraints.maxWidth < 620;
final showCampus = constraints.maxWidth >= 520;
final showName = constraints.maxWidth >= 760;
return Row(
children: [
if (onBack != null) ...[
_BackButton(onTap: onBack),
const SizedBox(width: 8),
_BackButton(onTap: onBack, compact: compact),
SizedBox(width: compact ? 5 : 8),
],
const Icon(Icons.badge_outlined, color: Colors.white, size: 18),
const SizedBox(width: 10),
Text(
const SizedBox(width: 9),
Expanded(
child: Text(
brandText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.darkerGrotesque(
color: Colors.white,
fontWeight: FontWeight.w900,
fontSize: 22,
letterSpacing: 0.3,
fontSize: compact ? 18 : 21,
),
),
],
),
Row(
children: [
const SizedBox(width: 10),
if (showCampus)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
padding: const EdgeInsets.symmetric(
horizontal: 11,
vertical: 5,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
color: Colors.white.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: Colors.white.withValues(alpha: 0.2)),
border: Border.all(
color: Colors.white.withValues(alpha: 0.2),
),
),
child: Text(
campusTag,
style: GoogleFonts.titilliumWeb(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
),
if (name != null && initials != null) ...[
const SizedBox(width: 10),
_Avatar(initials: initials),
if (showName) ...[
const SizedBox(width: 8),
Text(
name,
style: GoogleFonts.titilliumWeb(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
if (name != null && initials != null) ...[
const SizedBox(width: 12),
_Avatar(initials: initials),
const SizedBox(width: 8),
Text(
name,
style: GoogleFonts.titilliumWeb(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
],
],
),
],
);
},
),
);
}
}
class _BackButton extends StatelessWidget {
const _BackButton({required this.onTap});
const _BackButton({required this.onTap, required this.compact});
final VoidCallback onTap;
final bool compact;
@override
Widget build(BuildContext context) {
return InkWell(
return Tooltip(
message: "Retour",
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
borderRadius: BorderRadius.circular(7),
child: Container(
height: 34,
padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 10),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(7),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.arrow_back, size: 16, color: Colors.white.withValues(alpha: 0.85)),
Icon(
Icons.arrow_back_rounded,
size: 17,
color: Colors.white.withValues(alpha: 0.9),
),
if (!compact) ...[
const SizedBox(width: 6),
Text(
"Retour",
style: GoogleFonts.titilliumWeb(
color: Colors.white.withValues(alpha: 0.85),
fontSize: 13,
color: Colors.white.withValues(alpha: 0.9),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
],
),
),
),
);
@@ -135,8 +161,11 @@ class _Avatar extends StatelessWidget {
height: 30,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: const LinearGradient(colors: [EnsupColors.teal, EnsupColors.cyan]),
border: Border.all(color: Colors.white.withValues(alpha: 0.3), width: 2),
color: EnsupColors.purple,
border: Border.all(
color: Colors.white.withValues(alpha: 0.3),
width: 2,
),
),
alignment: Alignment.center,
child: Text(
+73 -23
View File
@@ -37,7 +37,11 @@ class EtatBox extends StatelessWidget {
case EtatChecklist.deteriore:
background = const Color(0xFFFFFBEB);
border = const Color(0xFFFDE68A);
contenu = const Icon(Icons.priority_high, size: 16, color: Color(0xFFD97706));
contenu = const Icon(
Icons.priority_high,
size: 16,
color: Color(0xFFD97706),
);
}
}
@@ -73,15 +77,36 @@ class ChecklistTableau extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
_entete(),
for (var i = 0; i < accessoires.length; i++) _ligne(i),
return LayoutBuilder(
builder: (context, constraints) {
final columnWidth = constraints.maxWidth < 520 ? 58.0 : 72.0;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: EnsupColors.line, width: 1.5),
boxShadow: [
BoxShadow(
color: EnsupColors.text.withValues(alpha: 0.035),
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
child: Column(
children: [
_entete(columnWidth),
for (var i = 0; i < accessoires.length; i++)
_ligne(i, columnWidth),
],
),
);
},
);
}
Widget _entete() {
Widget _entete(double columnWidth) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: const BoxDecoration(
@@ -90,9 +115,9 @@ class ChecklistTableau extends StatelessWidget {
child: Row(
children: [
Expanded(child: _texteEntete("ÉLÉMENT")),
_colonneEntete("PRÉSENT"),
_colonneEntete("ABSENT"),
_colonneEntete("DÉTÉRIORÉ"),
_colonneEntete("PRÉSENT", columnWidth),
_colonneEntete("ABSENT", columnWidth),
_colonneEntete("DÉTÉRIORÉ", columnWidth),
],
),
);
@@ -110,11 +135,16 @@ class ChecklistTableau extends StatelessWidget {
);
}
Widget _colonneEntete(String label) {
return SizedBox(width: 72, child: Center(child: _texteEntete(label)));
Widget _colonneEntete(String label, double width) {
return SizedBox(
width: width,
child: Center(
child: FittedBox(fit: BoxFit.scaleDown, child: _texteEntete(label)),
),
);
}
Widget _ligne(int index) {
Widget _ligne(int index, double columnWidth) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: const BoxDecoration(
@@ -125,20 +155,23 @@ class ChecklistTableau extends StatelessWidget {
Expanded(
child: Text(
accessoires[index],
style: GoogleFonts.titilliumWeb(fontSize: 14, color: EnsupColors.text2),
style: GoogleFonts.titilliumWeb(
fontSize: 14,
color: EnsupColors.text2,
),
),
_case(index, EtatChecklist.present),
_case(index, EtatChecklist.absent),
_case(index, EtatChecklist.deteriore),
),
_case(index, EtatChecklist.present, columnWidth),
_case(index, EtatChecklist.absent, columnWidth),
_case(index, EtatChecklist.deteriore, columnWidth),
],
),
);
}
Widget _case(int index, EtatChecklist type) {
Widget _case(int index, EtatChecklist type, double width) {
return SizedBox(
width: 72,
width: width,
child: Center(
child: EtatBox(
type: type,
@@ -152,7 +185,11 @@ class ChecklistTableau extends StatelessWidget {
/// Encadré d'instructions / rappel à côté d'une checklist.
class ChecklistInstructions extends StatelessWidget {
const ChecklistInstructions({super.key, required this.titre, required this.texte});
const ChecklistInstructions({
super.key,
required this.titre,
required this.texte,
});
final String titre;
final String texte;
@@ -164,7 +201,10 @@ class ChecklistInstructions extends StatelessWidget {
decoration: BoxDecoration(
color: EnsupColors.blue2.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: EnsupColors.blue2.withValues(alpha: 0.15), width: 1.5),
border: Border.all(
color: EnsupColors.blue2.withValues(alpha: 0.15),
width: 1.5,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -181,7 +221,11 @@ class ChecklistInstructions extends StatelessWidget {
const SizedBox(height: 8),
Text(
texte,
style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.text2, height: 1.6),
style: GoogleFonts.titilliumWeb(
fontSize: 13,
color: EnsupColors.text2,
height: 1.6,
),
),
],
),
@@ -208,10 +252,16 @@ class ChecklistCommentaire extends StatelessWidget {
style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.text),
decoration: InputDecoration(
hintText: hint,
hintStyle: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.muted),
hintStyle: GoogleFonts.titilliumWeb(
fontSize: 13,
color: EnsupColors.muted,
),
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
contentPadding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(9),
borderSide: const BorderSide(color: EnsupColors.line, width: 1.5),
@@ -3,7 +3,7 @@ import "package:google_fonts/google_fonts.dart";
import "../theme/ensup_colors.dart";
/// Option d'identification de l'écran de connexion (QR ou mail ENSUP).
class IdentificationOption extends StatelessWidget {
class IdentificationOption extends StatefulWidget {
const IdentificationOption({
super.key,
required this.icon,
@@ -19,58 +19,104 @@ class IdentificationOption extends StatelessWidget {
final String description;
final VoidCallback? onTap;
@override
State<IdentificationOption> createState() => _IdentificationOptionState();
}
class _IdentificationOptionState extends State<IdentificationOption> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
cursor: SystemMouseCursors.click,
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: EnsupColors.line, width: 2),
border: Border.all(
color: _hovered ? widget.iconColor : EnsupColors.line,
width: 1.5,
),
boxShadow: [
BoxShadow(
color: widget.iconColor.withValues(
alpha: _hovered ? 0.12 : 0.035,
),
blurRadius: _hovered ? 16 : 8,
offset: const Offset(0, 4),
),
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: widget.onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 17),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.1),
color: widget.iconColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: iconColor, size: 24),
child: Icon(widget.icon, color: widget.iconColor, size: 24),
),
const SizedBox(width: 16),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
widget.label,
style: GoogleFonts.darkerGrotesque(
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w800,
fontSize: 18,
height: 1.05,
color: EnsupColors.text,
),
),
const SizedBox(height: 3),
const SizedBox(height: 4),
Text(
description,
widget.description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.titilliumWeb(
fontSize: 12,
color: EnsupColors.muted,
height: 1.4,
height: 1.35,
),
),
],
),
),
const Icon(Icons.chevron_right, color: EnsupColors.muted, size: 20),
const SizedBox(width: 8),
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: widget.iconColor.withValues(alpha: 0.08),
shape: BoxShape.circle,
),
child: Icon(
Icons.chevron_right_rounded,
color: widget.iconColor,
size: 19,
),
),
],
),
),
),
),
),
);
}
}
+94 -17
View File
@@ -24,17 +24,28 @@ class MaterielCard extends StatelessWidget {
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: EnsupColors.line, width: 1.5),
boxShadow: [
BoxShadow(
color: EnsupColors.text.withValues(alpha: 0.035),
blurRadius: 10,
offset: const Offset(0, 3),
),
child: Row(
],
),
child: LayoutBuilder(
builder: (context, constraints) {
final compact = constraints.maxWidth < 620;
final information = Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 44,
height: 44,
width: 46,
height: 46,
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.08),
color: accent.withValues(alpha: 0.09),
borderRadius: BorderRadius.circular(10),
),
child: Icon(item.icon, color: accent, size: 20),
child: Icon(item.icon, color: accent, size: 21),
),
const SizedBox(width: 14),
Expanded(
@@ -43,34 +54,79 @@ class MaterielCard extends StatelessWidget {
children: [
Text(
item.nom,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.darkerGrotesque(
fontWeight: FontWeight.w700,
fontSize: 16,
fontWeight: FontWeight.w800,
fontSize: 17,
height: 1.05,
color: EnsupColors.text,
),
),
const SizedBox(height: 2),
const SizedBox(height: 4),
Text(
"${item.categorie} · Réf. ${item.reference} · ${item.campus}",
style: GoogleFonts.titilliumWeb(fontSize: 12, color: EnsupColors.muted),
"${item.categorie} · Réf. ${item.reference}",
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.titilliumWeb(
fontSize: 12,
color: EnsupColors.muted,
),
),
const SizedBox(height: 3),
Row(
children: [
const Icon(
Icons.location_on_outlined,
size: 13,
color: EnsupColors.muted,
),
const SizedBox(width: 4),
Expanded(
child: Text(
item.campus,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.titilliumWeb(
fontSize: 11,
color: EnsupColors.muted,
),
),
),
],
),
],
),
),
const SizedBox(width: 10),
],
);
final actions = Wrap(
spacing: 10,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
alignment: WrapAlignment.end,
children: [
StatusPill(disponible: disponible),
const SizedBox(width: 12),
if (disponible)
FilledButton(
FilledButton.icon(
onPressed: onSelect,
style: FilledButton.styleFrom(
backgroundColor: EnsupColors.cyan,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
padding: const EdgeInsets.symmetric(
horizontal: 15,
vertical: 11,
),
child: Text(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(9),
),
),
icon: const Icon(Icons.arrow_forward_rounded, size: 16),
label: Text(
"Sélectionner",
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700, fontSize: 14),
style: GoogleFonts.darkerGrotesque(
fontWeight: FontWeight.w700,
fontSize: 14,
),
),
)
else
@@ -83,6 +139,27 @@ class MaterielCard extends StatelessWidget {
),
),
],
);
if (compact) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
information,
const SizedBox(height: 14),
Align(alignment: Alignment.centerRight, child: actions),
],
);
}
return Row(
children: [
Expanded(child: information),
const SizedBox(width: 16),
actions,
],
);
},
),
),
);
+69 -23
View File
@@ -18,25 +18,41 @@ class ProfileCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
EnsupColors.cyan.withValues(alpha: 0.06),
EnsupColors.teal.withValues(alpha: 0.04),
color: EnsupColors.cyan.withValues(alpha: 0.055),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: EnsupColors.cyan.withValues(alpha: 0.15),
width: 1.5,
),
boxShadow: [
BoxShadow(
color: EnsupColors.blue3.withValues(alpha: 0.045),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: EnsupColors.cyan.withValues(alpha: 0.15), width: 1.5),
),
child: Row(
child: LayoutBuilder(
builder: (context, constraints) {
final showStatus = constraints.maxWidth >= 430;
return Row(
children: [
Container(
width: 48,
height: 48,
decoration: const BoxDecoration(
width: 50,
height: 50,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(colors: [EnsupColors.teal, EnsupColors.cyan]),
color: EnsupColors.teal,
border: Border.all(color: Colors.white, width: 3),
boxShadow: [
BoxShadow(
color: EnsupColors.teal.withValues(alpha: 0.22),
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
alignment: Alignment.center,
child: Text(
@@ -55,36 +71,66 @@ class ProfileCard extends StatelessWidget {
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.darkerGrotesque(
fontWeight: FontWeight.w800,
fontSize: 20,
height: 1,
color: EnsupColors.text,
),
),
const SizedBox(height: 2),
const SizedBox(height: 4),
Text(
subtitle,
style: GoogleFonts.titilliumWeb(fontSize: 12, color: EnsupColors.muted),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.titilliumWeb(
fontSize: 12,
color: EnsupColors.muted,
),
),
],
),
),
if (showStatus) ...[
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
decoration: BoxDecoration(
color: EnsupColors.green.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(999),
padding: const EdgeInsets.symmetric(
horizontal: 11,
vertical: 6,
),
child: Text(
"Connecté",
decoration: BoxDecoration(
color: EnsupColors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: EnsupColors.green.withValues(alpha: 0.18),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.check_circle,
size: 13,
color: EnsupColors.green,
),
const SizedBox(width: 5),
Text(
"Identifié",
style: GoogleFonts.titilliumWeb(
fontSize: 12,
fontWeight: FontWeight.w600,
fontSize: 11,
fontWeight: FontWeight.w700,
color: EnsupColors.green,
),
),
],
),
),
],
],
);
},
),
);
}
+30 -3
View File
@@ -36,10 +36,10 @@ l'historique complet, y compris des étapes devenues obsolètes après brancheme
| Infrastructure | Terminé | Docker Compose SQL Server + Adminer, monorepo, backend Node/TS, frontend Flutter Web. |
| Base de données | Terminé pour la V1 | Schéma Prisma 16 entités, migrations, seeds et fixtures de démonstration. |
| API étudiant | Terminé pour la V1 | Catalogue, détail matériel, création d'emprunt, mes emprunts, restitution, anomalie automatique. |
| Frontend étudiant | Fonctionnel pour la V1 | Parcours emprunt et restitution branchés sur l'API et testés en réel ; conservation de l'action avant identification à finaliser. |
| Frontend étudiant | Fonctionnel pour la V1 | Parcours API complet, ordre identification puis choix validé, interface premium et responsive fidèle à la maquette. |
| Authentification | Simulée | `x-user-email` côté backend et identité démo côté frontend ; Azure AD reste à faire. |
| API responsable | Terminé pour la V1 | Dashboard, emprunts, stock, anomalies, notifications, historique et export CSV, avec contrôle du rôle et du campus. |
| Frontend responsable | Fonctionnel pour la V1 | Dashboard et vues métier branchés sur l'API ; téléchargement CSV dans le navigateur à finaliser. |
| Frontend responsable | Fonctionnel pour la V1 | Espace de supervision responsive avec navigation dédiée, dashboard métier, vues API et export CSV opérationnel. |
| Tests automatisés | Non démarré | Tests backend/frontend/E2E à ajouter ; tests runtime manuels effectués. |
| Documentation | Partielle | README principal et documents de conception présents ; OpenAPI, guides utilisateur et captures restent à produire. |
@@ -415,6 +415,33 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y
- Limites restantes explicitées : authentification Azure AD, téléchargement CSV frontend, tests automatisés, OpenAPI et guides utilisateur.
- Commandes de vérification actualisées : build et lint backend verts, analyse Dart directe sans erreur.
### Étape 44 — Refonte premium de l'espace responsable
- Branche dédiée `style/responsable-premium-dashboard` créée depuis `develop`.
- L'espace responsable adopte une structure de supervision distincte du parcours étudiant : barre supérieure métier, navigation latérale sur ordinateur et navigation compacte sur petit écran.
- Dashboard enrichi avec disponibilités, emprunts en cours, retards, anomalies actives, notifications non lues, activité récente et taux de disponibilité du parc.
- Accès directs ajoutés depuis les points d'attention vers les vues emprunts, stock, anomalies et notifications.
- Les listes métier partagent désormais une hiérarchie visuelle, une recherche et des lignes responsives cohérentes avec la charte ENSUP.
- Aucun changement backend ni métier ; rendu validé manuellement dans le navigateur et `dart analyze` sans erreur.
### Étape 45 — Téléchargement CSV de l'historique responsable
- Branche dédiée `feat/responsable-historique-export` créée depuis `develop`.
- Client HTTP étendu pour récupérer un fichier binaire avec son type de contenu et son nom.
- Le bouton `Export CSV` appelle désormais l'endpoint responsable avec l'identité simulée de Karim et déclenche le téléchargement navigateur.
- Un état de chargement bloque les doubles clics et les erreurs API sont affichées dans l'interface.
- Endpoint vérifié au runtime : réponse `200`, type `text/csv`, nom `historique-responsable.csv` et contenu non vide.
- Vérification statique : `dart analyze` sans erreur.
### Étape 46 — Refonte premium de l'espace étudiant
- L'ordre actuel du parcours est confirmé : identification, puis choix `Emprunter` ou `Restituer`.
- Branche dédiée `style/student-premium-workspace` créée depuis `develop`.
- Charte et maquette conservées : couleurs ENSUP, typographies, barre supérieure, coins de marque et structure des écrans.
- Barre supérieure rendue responsive, avec priorité donnée au titre et à l'identité sur les petites largeurs.
- Accueil rééquilibré avec profil identifié, hiérarchie renforcée et cartes d'action adaptatives avec états de survol.
- Catalogue, détail matériel et sélection de restitution enrichis sans modifier leurs données ni leurs actions.
- Détail et checklists passent automatiquement de deux colonnes à une colonne sur écran étroit ; tableau de contrôle et lignes de matériel évitent les débordements.
- Écrans de confirmation et de résultat conservés fonctionnellement et harmonisés via les composants partagés.
- Vérification statique : `dart analyze` sans erreur.
---
*Dernière mise à jour : 2026-07-22Synthèse synchronisée avec l'état réel du projet après finalisation du premier parcours responsable.*
*Dernière mise à jour : 2026-07-23Espace étudiant premium et responsive, fidèle à la maquette ENSUP.*