27 Commits

Author SHA1 Message Date
SaidSoighiri94 3681ed5e44 merge: v1 student polish 2026-07-09 09:33:15 +02:00
SaidSoighiri94 cbc67c6c2f chore(frontend): update Flutter lockfile 2026-07-09 09:32:27 +02:00
SaidSoighiri94 eb3094f3a6 style(frontend): harmonize student demo labels 2026-07-08 14:44:09 +02:00
SaidSoighiri94 245a6905a3 merge: student flow stabilization 2026-07-08 11:52:51 +02:00
SaidSoighiri94 8c710b6e3c docs: record student flow stabilization 2026-07-08 11:21:44 +02:00
SaidSoighiri94 9c74de53c4 merge: frontend restitution api 2026-07-08 10:52:28 +02:00
SaidSoighiri94 78a0c1305e feat(frontend): connect return flow to api 2026-07-07 14:10:12 +02:00
SaidSoighiri94 d83aa7dd75 merge: demo catalogue fixtures 2026-07-07 14:05:37 +02:00
SaidSoighiri94 e46ec29543 feat(db): enrich demo catalogue fixtures 2026-07-07 13:47:55 +02:00
SaidSoighiri94 98ba359bc5 merge: frontend borrow api 2026-07-07 13:38:17 +02:00
SaidSoighiri94 7aba1a930d feat(frontend): connect borrow flow to api 2026-07-07 13:22:53 +02:00
SaidSoighiri94 27171562de merge: catalogue detail api 2026-07-07 13:14:20 +02:00
SaidSoighiri94 9564dd1ade feat(materiel): connect catalogue and detail to api 2026-07-07 13:07:47 +02:00
SaidSoighiri94 90d5a65f29 feat(frontend): add restitution result screen (completes student flow) 2026-07-07 10:08:33 +02:00
SaidSoighiri94 e9d67e78f5 feat(frontend): add return checklist screen with shared checklist widgets 2026-07-07 09:45:15 +02:00
SaidSoighiri94 648ea21155 feat(frontend): add restitution selection screen 2026-07-06 15:45:09 +02:00
SaidSoighiri94 0f72c021ba feat(frontend): add emprunt confirmation screen (completes borrow flow) 2026-07-06 15:18:48 +02:00
SaidSoighiri94 9941ae97dc feat(frontend): add departure checklist screen 2026-07-06 15:02:31 +02:00
SaidSoighiri94 b124d7a775 feat(frontend): add material detail screen 2026-07-06 12:44:43 +02:00
SaidSoighiri94 84bd2cc629 feat(frontend): add material catalogue screen with search and filters 2026-07-06 11:44:43 +02:00
SaidSoighiri94 97a1ad9e84 feat(frontend): add back button in top bar (in-app navigation) 2026-07-06 11:37:25 +02:00
SaidSoighiri94 97faf3e9ee feat(frontend): add student identification screen as entry point 2026-07-06 11:25:40 +02:00
SaidSoighiri94 e492220af4 feat(frontend): add ENSUP brand corners to home screen 2026-07-06 11:05:53 +02:00
SaidSoighiri94 b2c999026f chore(docs): remove ENSUP charter PDF, reference validated mockup instead 2026-07-06 10:51:52 +02:00
SaidSoighiri94 d12302e8a6 feat(frontend): scaffold Flutter Web app with ENSUP home screen 2026-07-06 10:37:21 +02:00
SaidSoighiri94 be7d095fc3 feat(backend): add emprunt restitution with automatic anomaly detection 2026-07-06 10:10:09 +02:00
SaidSoighiri94 3215a3c11f feat(backend): expose POST /api/emprunts with departure checklist 2026-07-06 09:46:03 +02:00
60 changed files with 4901 additions and 23 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "tsc -p tsconfig.build.json",
"start": "node dist/app.js",
"dev": "nodemon --exec ts-node src/app.ts",
"dev": "nodemon --exec ts-node --files src/app.ts",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio",
+190 -6
View File
@@ -51,14 +51,175 @@ function daysFromNow(n: number): Date {
return d;
}
async function main(): Promise<void> {
// Idempotence : on ne recharge pas si déjà présent.
const witness = await prisma.utilisateur.findUnique({ where: { email: WITNESS_EMAIL } });
if (witness) {
console.log('[fixtures] deja chargees — rien a faire');
return;
interface DemoCategories {
ordinateur: number;
video: number;
tablette: number;
camera: number;
audio: number;
cable: number;
}
interface DemoMateriel {
nom: string;
marque: string;
modele: string;
reference: string;
numeroInventaire: string;
numeroSerie: string;
etatGeneral: string;
categorieId: number;
accessoires?: string[];
}
async function assurerAccessoire(nom: string, description: string): Promise<number> {
const existant = await prisma.accessoire.findFirst({ where: { nom } });
if (existant) {
return existant.id;
}
const accessoire = await prisma.accessoire.create({ data: { nom, description } });
return accessoire.id;
}
async function assurerMaterielDemo(data: DemoMateriel, campusId: number): Promise<void> {
let materiel = await prisma.materiel.findFirst({ where: { reference: data.reference } });
if (!materiel) {
materiel = await prisma.materiel.create({
data: {
nom: data.nom,
marque: data.marque,
modele: data.modele,
reference: data.reference,
numeroInventaire: data.numeroInventaire,
numeroSerie: data.numeroSerie,
statut: 'DISPONIBLE',
etatGeneral: data.etatGeneral,
categorieId: data.categorieId,
campusId,
},
});
}
for (const nomAccessoire of data.accessoires ?? []) {
const accessoireId = await assurerAccessoire(nomAccessoire, `Accessoire ${nomAccessoire}.`);
const liaison = await prisma.materielAccessoire.findFirst({
where: { materielId: materiel.id, accessoireId },
});
if (!liaison) {
await prisma.materielAccessoire.create({
data: {
materielId: materiel.id,
accessoireId,
quantiteAttendue: 1,
obligatoire: true,
},
});
}
}
}
async function assurerCatalogueDemo(campusId: number, categories: DemoCategories): Promise<void> {
const materiels: DemoMateriel[] = [
{
nom: 'PC Lenovo ThinkPad E14',
marque: 'Lenovo',
modele: 'ThinkPad E14',
reference: 'REF-PC-003',
numeroInventaire: 'INV-PC-003',
numeroSerie: 'SN-LENOVO-003',
etatGeneral: 'Bon etat',
categorieId: categories.ordinateur,
accessoires: ['Chargeur', 'Souris', 'Housse'],
},
{
nom: 'MacBook Air M2',
marque: 'Apple',
modele: 'MacBook Air 13',
reference: 'REF-PC-004',
numeroInventaire: 'INV-PC-004',
numeroSerie: 'SN-MBA-004',
etatGeneral: 'Tres bon etat',
categorieId: categories.ordinateur,
accessoires: ['Chargeur USB-C', 'Housse'],
},
{
nom: 'Microsoft Surface Pro',
marque: 'Microsoft',
modele: 'Surface Pro 9',
reference: 'REF-TAB-002',
numeroInventaire: 'INV-TAB-002',
numeroSerie: 'SN-SURFACE-002',
etatGeneral: 'Bon etat',
categorieId: categories.tablette,
accessoires: ['Clavier Surface', 'Stylet'],
},
{
nom: 'Vidéoprojecteur Epson EB-X49',
marque: 'Epson',
modele: 'EB-X49',
reference: 'REF-VP-002',
numeroInventaire: 'INV-VP-002',
numeroSerie: 'SN-EPSON-002',
etatGeneral: 'Bon etat',
categorieId: categories.video,
accessoires: ['Telecommande', 'Cable HDMI'],
},
{
nom: 'Caméra Canon EOS M50',
marque: 'Canon',
modele: 'EOS M50',
reference: 'REF-CAM-002',
numeroInventaire: 'INV-CAM-002',
numeroSerie: 'SN-CANON-002',
etatGeneral: 'Bon etat',
categorieId: categories.camera,
accessoires: ['Batterie', 'Carte SD', 'Housse'],
},
{
nom: 'Casque Jabra Evolve',
marque: 'Jabra',
modele: 'Evolve 40',
reference: 'REF-AUD-002',
numeroInventaire: 'INV-AUD-002',
numeroSerie: 'SN-JABRA-002',
etatGeneral: 'Bon etat',
categorieId: categories.audio,
},
{
nom: 'Kit câbles HDMI / USB-C',
marque: 'ENSUP',
modele: 'Kit connectique',
reference: 'REF-CAB-001',
numeroInventaire: 'INV-CAB-001',
numeroSerie: 'SN-CABLE-001',
etatGeneral: 'Bon etat',
categorieId: categories.cable,
accessoires: ['Cable HDMI', 'Adaptateur USB-C'],
},
{
nom: 'Enceinte Bluetooth JBL',
marque: 'JBL',
modele: 'Flip 6',
reference: 'REF-AUD-003',
numeroInventaire: 'INV-AUD-003',
numeroSerie: 'SN-JBL-003',
etatGeneral: 'Bon etat',
categorieId: categories.audio,
accessoires: ['Cable USB-C'],
},
];
for (const materiel of materiels) {
await assurerMaterielDemo(materiel, campusId);
}
console.log(`[fixtures] ${materiels.length} materiels de catalogue assures`);
}
async function main(): Promise<void> {
// Références (doivent exister via le seed).
const roleEtudiant = required(
await prisma.role.findUnique({ where: { code: 'ETUDIANT' } }),
@@ -100,6 +261,28 @@ async function main(): Promise<void> {
await prisma.categorieMateriel.findFirst({ where: { nom: 'Périphérique audio' } }),
'Categorie Périphérique audio manquante',
);
const catCable = required(
await prisma.categorieMateriel.findFirst({ where: { nom: 'Câble / Adaptateur' } }),
'Categorie Câble / Adaptateur manquante',
);
const demoCategories: DemoCategories = {
ordinateur: catOrdi.id,
video: catVideo.id,
tablette: catTablette.id,
camera: catCamera.id,
audio: catAudio.id,
cable: catCable.id,
};
// Idempotence : si les fixtures de base existent déjà, on assure seulement
// les matériels de catalogue ajoutés après coup.
const witness = await prisma.utilisateur.findUnique({ where: { email: WITNESS_EMAIL } });
if (witness) {
await assurerCatalogueDemo(campus.id, demoCategories);
console.log('[fixtures] deja chargees — catalogue demo assure');
return;
}
// Utilisateurs : 3 étudiants (2 info @ensitech.eu, 1 autre @ensup.eu) + 1 responsable.
const marie = await prisma.utilisateur.create({
@@ -280,6 +463,7 @@ async function main(): Promise<void> {
{ materielId: camera.id, accessoireId: chargeur.id, quantiteAttendue: 1, obligatoire: true },
],
});
await assurerCatalogueDemo(campus.id, demoCategories);
// Emprunt 1 — EN_COURS (Marie / PC Dell) + checklist de départ.
const empruntEnCours = await prisma.emprunt.create({
+3 -1
View File
@@ -10,7 +10,9 @@ import { apiRouter } from './routes';
const app = express();
app.use(cors({ origin: env.frontendUrl }));
// En développement, on accepte toute origine locale (le front Flutter tourne sur
// un port variable) ; en production, seule l'origine du frontend est autorisée.
app.use(cors({ origin: env.nodeEnv === 'development' ? true : env.frontendUrl }));
app.use(express.json());
app.use(requestLogger);
@@ -1,7 +1,15 @@
import { Request, Response } from 'express';
import { AppError } from '../errors/app-error';
import { listerMesEmpruntsEnCours } from '../services/emprunt.service';
import { toEmpruntResponse } from '../dtos/emprunt.dto';
import {
creerEmprunt,
listerMesEmpruntsEnCours,
restituerEmprunt,
} from '../services/emprunt.service';
import {
parseCreerEmpruntRequest,
parseRestituerEmpruntRequest,
toEmpruntResponse,
} from '../dtos/emprunt.dto';
export async function getMesEmprunts(req: Request, res: Response): Promise<void> {
const user = req.user;
@@ -12,3 +20,30 @@ export async function getMesEmprunts(req: Request, res: Response): Promise<void>
const emprunts = await listerMesEmpruntsEnCours(user.id);
res.json({ data: emprunts.map(toEmpruntResponse) });
}
export async function postEmprunt(req: Request, res: Response): Promise<void> {
const user = req.user;
if (!user) {
throw new AppError(401, 'Authentification requise');
}
const request = parseCreerEmpruntRequest(req.body);
const emprunt = await creerEmprunt(user.id, user.campusId, request);
res.status(201).json({ data: toEmpruntResponse(emprunt) });
}
export async function postRestitution(req: Request, res: Response): Promise<void> {
const user = req.user;
if (!user) {
throw new AppError(401, 'Authentification requise');
}
const empruntId = Number(req.params.id);
if (!Number.isInteger(empruntId) || empruntId <= 0) {
throw new AppError(400, 'Identifiant emprunt invalide');
}
const request = parseRestituerEmpruntRequest(req.body);
const emprunt = await restituerEmprunt(user.id, empruntId, request);
res.json({ data: toEmpruntResponse(emprunt) });
}
@@ -1,7 +1,7 @@
import { Request, Response } from 'express';
import { AppError } from '../errors/app-error';
import { listerCatalogue } from '../services/materiel.service';
import { toMaterielResponse } from '../dtos/materiel.dto';
import { listerCatalogue, obtenirDetailMateriel } from '../services/materiel.service';
import { toMaterielDetailResponse, toMaterielResponse } from '../dtos/materiel.dto';
function parseCategorieId(value: unknown): number | undefined {
if (value === undefined) {
@@ -16,6 +16,18 @@ function parseCategorieId(value: unknown): number | undefined {
return parsed;
}
function parseId(value: unknown): number {
if (typeof value !== 'string') {
throw new AppError(400, 'id invalide');
}
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new AppError(400, 'id invalide');
}
return parsed;
}
export async function getCatalogue(req: Request, res: Response): Promise<void> {
const user = req.user;
if (!user) {
@@ -28,3 +40,13 @@ export async function getCatalogue(req: Request, res: Response): Promise<void> {
const materiels = await listerCatalogue(user.campusId, { categorieId, recherche });
res.json({ data: materiels.map(toMaterielResponse) });
}
export async function getMaterielDetail(req: Request, res: Response): Promise<void> {
const user = req.user;
if (!user) {
throw new AppError(401, 'Authentification requise');
}
const materiel = await obtenirDetailMateriel(parseId(req.params.id), user.campusId);
res.json({ data: toMaterielDetailResponse(materiel) });
}
+124
View File
@@ -1,5 +1,7 @@
import { EmpruntAvecMateriel } from '../repositories/emprunt.repository';
import { MaterielResponse, toMaterielResponse } from './materiel.dto';
import { AppError } from '../errors/app-error';
import { ETAT_CHECKLIST_ELEMENT, MODE_IDENTIFICATION } from '../models/enums';
export interface EmpruntResponse {
id: number;
@@ -20,3 +22,125 @@ export function toEmpruntResponse(emprunt: EmpruntAvecMateriel): EmpruntResponse
materiel: toMaterielResponse(emprunt.materiel),
};
}
export interface ChecklistElementRequest {
accessoireId?: number;
nomElement: string;
etat: string;
quantiteConstatee: number;
commentaire?: string;
}
export interface CreerEmpruntRequest {
materielId: number;
posteEmpruntId: number;
modeIdentification: string;
commentaireDepart?: string;
elements: ChecklistElementRequest[];
}
function asObject(value: unknown, message: string): Record<string, unknown> {
if (typeof value !== 'object' || value === null) {
throw new AppError(400, message);
}
return value as Record<string, unknown>;
}
function asPositiveInt(value: unknown, champ: string): number {
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
throw new AppError(400, `${champ} doit etre un entier positif`);
}
return value;
}
function asIntOrZero(value: unknown, champ: string): number {
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
throw new AppError(400, `${champ} doit etre un entier positif ou nul`);
}
return value;
}
function asEnum<T extends string>(value: unknown, autorisees: readonly T[], champ: string): T {
if (typeof value !== 'string' || !(autorisees as readonly string[]).includes(value)) {
throw new AppError(400, `${champ} invalide`);
}
return value as T;
}
function parseElement(value: unknown, index: number): ChecklistElementRequest {
const obj = asObject(value, `elements[${index}] invalide`);
if (typeof obj.nomElement !== 'string' || obj.nomElement.trim() === '') {
throw new AppError(400, `elements[${index}].nomElement requis`);
}
const element: ChecklistElementRequest = {
nomElement: obj.nomElement,
etat: asEnum(obj.etat, ETAT_CHECKLIST_ELEMENT, `elements[${index}].etat`),
quantiteConstatee: asIntOrZero(obj.quantiteConstatee, `elements[${index}].quantiteConstatee`),
};
if (obj.accessoireId !== undefined) {
element.accessoireId = asPositiveInt(obj.accessoireId, `elements[${index}].accessoireId`);
}
if (obj.commentaire !== undefined) {
if (typeof obj.commentaire !== 'string') {
throw new AppError(400, `elements[${index}].commentaire invalide`);
}
element.commentaire = obj.commentaire;
}
return element;
}
export function parseCreerEmpruntRequest(body: unknown): CreerEmpruntRequest {
const obj = asObject(body, 'Corps de requete invalide');
if (!Array.isArray(obj.elements) || obj.elements.length === 0) {
throw new AppError(400, 'La checklist de depart est obligatoire (RG11)');
}
const request: CreerEmpruntRequest = {
materielId: asPositiveInt(obj.materielId, 'materielId'),
posteEmpruntId: asPositiveInt(obj.posteEmpruntId, 'posteEmpruntId'),
modeIdentification: asEnum(obj.modeIdentification, MODE_IDENTIFICATION, 'modeIdentification'),
elements: obj.elements.map((element, index) => parseElement(element, index)),
};
if (obj.commentaireDepart !== undefined) {
if (typeof obj.commentaireDepart !== 'string') {
throw new AppError(400, 'commentaireDepart invalide');
}
request.commentaireDepart = obj.commentaireDepart;
}
return request;
}
export interface RestituerEmpruntRequest {
modeIdentification: string;
commentaireRetour?: string;
elements: ChecklistElementRequest[];
}
export function parseRestituerEmpruntRequest(body: unknown): RestituerEmpruntRequest {
const obj = asObject(body, 'Corps de requete invalide');
if (!Array.isArray(obj.elements) || obj.elements.length === 0) {
throw new AppError(400, 'La checklist de retour est obligatoire (RG16)');
}
const request: RestituerEmpruntRequest = {
modeIdentification: asEnum(obj.modeIdentification, MODE_IDENTIFICATION, 'modeIdentification'),
elements: obj.elements.map((element, index) => parseElement(element, index)),
};
if (obj.commentaireRetour !== undefined) {
if (typeof obj.commentaireRetour !== 'string') {
throw new AppError(400, 'commentaireRetour invalide');
}
request.commentaireRetour = obj.commentaireRetour;
}
return request;
}
+22 -1
View File
@@ -1,4 +1,4 @@
import { MaterielAvecCategorie } from '../repositories/materiel.repository';
import { MaterielAvecCategorie, MaterielDetail } from '../repositories/materiel.repository';
export interface CategorieResponse {
id: number;
@@ -16,6 +16,15 @@ export interface MaterielResponse {
categorie: CategorieResponse;
}
export interface MaterielDetailResponse extends MaterielResponse {
campus: {
id: number;
nom: string;
ville: string;
};
accessoires: string[];
}
export function toMaterielResponse(materiel: MaterielAvecCategorie): MaterielResponse {
return {
id: materiel.id,
@@ -31,3 +40,15 @@ export function toMaterielResponse(materiel: MaterielAvecCategorie): MaterielRes
},
};
}
export function toMaterielDetailResponse(materiel: MaterielDetail): MaterielDetailResponse {
return {
...toMaterielResponse(materiel),
campus: {
id: materiel.campus.id,
nom: materiel.campus.nom,
ville: materiel.campus.ville,
},
accessoires: materiel.accessoires.map((liaison) => liaison.accessoire.nom),
};
}
@@ -1,11 +1,16 @@
import { Prisma } from '@prisma/client';
import { prisma } from '../db/prisma';
import { StatutEmprunt } from '../models/enums';
import { AppError } from '../errors/app-error';
export type EmpruntAvecMateriel = Prisma.EmpruntGetPayload<{
include: { materiel: { include: { categorie: true } } };
}>;
export type EmpruntAvecChecklists = Prisma.EmpruntGetPayload<{
include: { checklists: { include: { elements: true } } };
}>;
const STATUTS_EN_COURS: StatutEmprunt[] = ['EN_COURS', 'EN_RETARD'];
export function findEnCoursParUtilisateur(utilisateurId: number): Promise<EmpruntAvecMateriel[]> {
@@ -18,3 +23,173 @@ export function findEnCoursParUtilisateur(utilisateurId: number): Promise<Emprun
orderBy: { dateRetourPrevue: 'asc' },
});
}
export function findByIdAvecChecklists(id: number): Promise<EmpruntAvecChecklists | null> {
return prisma.emprunt.findUnique({
where: { id },
include: { checklists: { include: { elements: true } } },
});
}
interface ChecklistElementData {
accessoireId?: number;
nomElement: string;
etat: string;
quantiteConstatee: number;
commentaire?: string;
}
export interface CreerEmpruntData {
utilisateurId: number;
materielId: number;
campusId: number;
sallePretId: number;
posteEmpruntId: number;
dateRetourPrevue: Date;
modeIdentification: string;
commentaireDepart?: string;
elements: ChecklistElementData[];
}
/* Création atomique : réservation du matériel (DISPONIBLE -> EMPRUNTE), emprunt,
checklist de départ et ses éléments. Si une étape échoue, tout est annulé. */
export function creerEmpruntComplet(data: CreerEmpruntData): Promise<EmpruntAvecMateriel> {
return prisma.$transaction(async (tx) => {
// Réservation atomique : empêche un double emprunt du même matériel (RG07).
const reservation = await tx.materiel.updateMany({
where: { id: data.materielId, statut: 'DISPONIBLE' },
data: { statut: 'EMPRUNTE' },
});
if (reservation.count === 0) {
throw new AppError(409, 'Materiel non disponible');
}
const emprunt = await tx.emprunt.create({
data: {
utilisateurId: data.utilisateurId,
materielId: data.materielId,
campusId: data.campusId,
sallePretId: data.sallePretId,
posteEmpruntId: data.posteEmpruntId,
dateEmprunt: new Date(),
dateRetourPrevue: data.dateRetourPrevue,
statut: 'EN_COURS',
modeIdentificationEmprunt: data.modeIdentification,
commentaireDepart: data.commentaireDepart ?? null,
},
});
await tx.checklist.create({
data: {
empruntId: emprunt.id,
utilisateurId: data.utilisateurId,
type: 'DEPART',
dateVerification: new Date(),
elements: {
create: data.elements.map((element) => ({
accessoireId: element.accessoireId ?? null,
nomElement: element.nomElement,
etat: element.etat,
quantiteConstatee: element.quantiteConstatee,
commentaire: element.commentaire ?? null,
})),
},
},
});
return tx.emprunt.findUniqueOrThrow({
where: { id: emprunt.id },
include: { materiel: { include: { categorie: true } } },
});
});
}
export interface RestituerEmpruntData {
empruntId: number;
utilisateurId: number;
materielId: number;
statutEmprunt: string;
statutMateriel: string;
modeIdentification: string;
commentaireRetour?: string;
elements: ChecklistElementData[];
anomalie?: {
type: string;
description: string;
responsablesIds: number[];
};
}
/* Restitution atomique : checklist de retour, mise à jour de l'emprunt et du
matériel, et — si non conforme — création de l'anomalie et des notifications. */
export function restituerEmpruntComplet(data: RestituerEmpruntData): Promise<EmpruntAvecMateriel> {
return prisma.$transaction(async (tx) => {
const maintenant = new Date();
await tx.checklist.create({
data: {
empruntId: data.empruntId,
utilisateurId: data.utilisateurId,
type: 'RETOUR',
dateVerification: maintenant,
elements: {
create: data.elements.map((element) => ({
accessoireId: element.accessoireId ?? null,
nomElement: element.nomElement,
etat: element.etat,
quantiteConstatee: element.quantiteConstatee,
commentaire: element.commentaire ?? null,
})),
},
},
});
await tx.emprunt.update({
where: { id: data.empruntId },
data: {
statut: data.statutEmprunt,
dateRetourReelle: maintenant,
modeIdentificationRetour: data.modeIdentification,
commentaireRetour: data.commentaireRetour ?? null,
},
});
await tx.materiel.update({
where: { id: data.materielId },
data: { statut: data.statutMateriel },
});
if (data.anomalie) {
const infoAnomalie = data.anomalie;
const anomalie = await tx.anomalie.create({
data: {
empruntId: data.empruntId,
materielId: data.materielId,
utilisateurId: data.utilisateurId,
type: infoAnomalie.type,
description: infoAnomalie.description,
statut: 'DETECTEE',
detecteeAutomatiquement: true,
dateDetection: maintenant,
},
});
if (infoAnomalie.responsablesIds.length > 0) {
await tx.notification.createMany({
data: infoAnomalie.responsablesIds.map((responsableId) => ({
utilisateurId: responsableId,
anomalieId: anomalie.id,
titre: 'Nouvelle anomalie detectee',
message: infoAnomalie.description,
type: 'ANOMALIE',
})),
});
}
}
return tx.emprunt.findUniqueOrThrow({
where: { id: data.empruntId },
include: { materiel: { include: { categorie: true } } },
});
});
}
@@ -1,7 +1,14 @@
import { Prisma } from '@prisma/client';
import { Materiel, Prisma } from '@prisma/client';
import { prisma } from '../db/prisma';
export type MaterielAvecCategorie = Prisma.MaterielGetPayload<{ include: { categorie: true } }>;
export type MaterielDetail = Prisma.MaterielGetPayload<{
include: {
categorie: true;
campus: true;
accessoires: { include: { accessoire: true } };
};
}>;
export interface CatalogueFiltres {
categorieId?: number;
@@ -35,3 +42,21 @@ export function findDisponiblesParCampus(
orderBy: { nom: 'asc' },
});
}
export function findById(id: number): Promise<Materiel | null> {
return prisma.materiel.findUnique({ where: { id } });
}
export function findDetailParCampus(id: number, campusId: number): Promise<MaterielDetail | null> {
return prisma.materiel.findFirst({
where: { id, campusId, actif: true },
include: {
categorie: true,
campus: true,
accessoires: {
include: { accessoire: true },
orderBy: { accessoire: { nom: 'asc' } },
},
},
});
}
@@ -0,0 +1,11 @@
import { Prisma } from '@prisma/client';
import { prisma } from '../db/prisma';
export type PosteAvecSalle = Prisma.PosteEmpruntGetPayload<{ include: { sallePret: true } }>;
export function findById(id: number): Promise<PosteAvecSalle | null> {
return prisma.posteEmprunt.findUnique({
where: { id },
include: { sallePret: true },
});
}
@@ -20,3 +20,10 @@ export function findById(id: number): Promise<UtilisateurProfil | null> {
include: { role: true, campus: true },
});
}
export function findResponsablesParCampus(campusId: number): Promise<{ id: number }[]> {
return prisma.utilisateur.findMany({
where: { campusId, actif: true, role: { code: 'RESPONSABLE' } },
select: { id: true },
});
}
+5 -2
View File
@@ -1,6 +1,9 @@
import { Router } from 'express';
import { getMesEmprunts } from '../controllers/emprunt.controller';
import { getMesEmprunts, postEmprunt, postRestitution } from '../controllers/emprunt.controller';
export const empruntRoutes: Router = Router();
empruntRoutes.post('/', postEmprunt);
empruntRoutes.post('/:id/restitution', postRestitution);
empruntRoutes.get('/', getMesEmprunts);
export const mesEmpruntsRoutes: Router = Router();
mesEmpruntsRoutes.get('/', getMesEmprunts);
+3 -2
View File
@@ -2,7 +2,7 @@ import { Router } from 'express';
import { currentUser } from '../middlewares/current-user';
import { authRoutes } from './auth.routes';
import { materielRoutes } from './materiel.routes';
import { empruntRoutes } from './emprunt.routes';
import { empruntRoutes, mesEmpruntsRoutes } from './emprunt.routes';
export const apiRouter: Router = Router();
@@ -11,4 +11,5 @@ apiRouter.use('/auth', authRoutes);
// Le reste exige un utilisateur identifié.
apiRouter.use('/materiels', currentUser, materielRoutes);
apiRouter.use('/mes-emprunts', currentUser, empruntRoutes);
apiRouter.use('/emprunts', currentUser, empruntRoutes);
apiRouter.use('/mes-emprunts', currentUser, mesEmpruntsRoutes);
+2 -1
View File
@@ -1,6 +1,7 @@
import { Router } from 'express';
import { getCatalogue } from '../controllers/materiel.controller';
import { getCatalogue, getMaterielDetail } from '../controllers/materiel.controller';
export const materielRoutes: Router = Router();
materielRoutes.get('/', getCatalogue);
materielRoutes.get('/:id', getMaterielDetail);
+178 -3
View File
@@ -1,7 +1,182 @@
import { findEnCoursParUtilisateur, EmpruntAvecMateriel } from '../repositories/emprunt.repository';
import { AppError } from '../errors/app-error';
import {
CreerEmpruntRequest,
RestituerEmpruntRequest,
ChecklistElementRequest,
} from '../dtos/emprunt.dto';
import {
findEnCoursParUtilisateur,
creerEmpruntComplet,
findByIdAvecChecklists,
restituerEmpruntComplet,
EmpruntAvecMateriel,
RestituerEmpruntData,
} from '../repositories/emprunt.repository';
import { findById as findMaterielById } from '../repositories/materiel.repository';
import { findById as findPosteById } from '../repositories/poste-emprunt.repository';
import { findResponsablesParCampus } from '../repositories/utilisateur.repository';
import { StatutEmprunt } from '../models/enums';
/* RG15 : un étudiant ne consulte que ses propres emprunts, et uniquement ceux
non encore restitués (en cours ou en retard). */
const DUREE_EMPRUNT_JOURS = 14;
const STATUTS_RESTITUABLES: readonly string[] = ['EN_COURS', 'EN_RETARD'];
/* RG15 : un étudiant ne consulte que ses propres emprunts non restitués. */
export function listerMesEmpruntsEnCours(utilisateurId: number): Promise<EmpruntAvecMateriel[]> {
return findEnCoursParUtilisateur(utilisateurId);
}
/* RG07/RG10/RG11/RG12 : création d'un emprunt avec sa checklist de départ.
Les contrôles d'éligibilité (campus, disponibilité) sont faits ici ; l'écriture
atomique est déléguée au repository. */
export async function creerEmprunt(
utilisateurId: number,
campusId: number,
request: CreerEmpruntRequest,
): Promise<EmpruntAvecMateriel> {
const materiel = await findMaterielById(request.materielId);
if (!materiel) {
throw new AppError(404, 'Materiel introuvable');
}
if (materiel.campusId !== campusId) {
throw new AppError(403, 'Materiel rattache a un autre campus');
}
if (materiel.statut !== 'DISPONIBLE') {
throw new AppError(409, 'Materiel non disponible');
}
const poste = await findPosteById(request.posteEmpruntId);
if (!poste) {
throw new AppError(400, 'Poste introuvable');
}
if (poste.sallePret.campusId !== campusId) {
throw new AppError(403, 'Poste rattache a un autre campus');
}
const dateRetourPrevue = new Date();
dateRetourPrevue.setDate(dateRetourPrevue.getDate() + DUREE_EMPRUNT_JOURS);
return creerEmpruntComplet({
utilisateurId,
materielId: request.materielId,
campusId,
sallePretId: poste.sallePretId,
posteEmpruntId: request.posteEmpruntId,
dateRetourPrevue,
modeIdentification: request.modeIdentification,
commentaireDepart: request.commentaireDepart,
elements: request.elements,
});
}
interface ElementDepart {
accessoireId: number | null;
nomElement: string;
etat: string;
}
interface ResultatComparaison {
conforme: boolean;
auMoinsUnDeteriore: boolean;
details: string[];
}
function trouverRetour(
depart: ElementDepart,
retour: ChecklistElementRequest[],
): ChecklistElementRequest | undefined {
if (depart.accessoireId !== null) {
const parId = retour.find((element) => element.accessoireId === depart.accessoireId);
if (parId) {
return parId;
}
}
return retour.find((element) => element.nomElement === depart.nomElement);
}
/* RG17 : compare la checklist de départ à celle de retour. Un élément présent au
départ mais absent ou détérioré au retour rend la restitution non conforme (RG20). */
function comparerChecklists(
depart: ElementDepart[],
retour: ChecklistElementRequest[],
): ResultatComparaison {
const details: string[] = [];
let auMoinsUnDeteriore = false;
for (const elementDepart of depart) {
if (elementDepart.etat !== 'PRESENT') {
continue;
}
const elementRetour = trouverRetour(elementDepart, retour);
if (!elementRetour || elementRetour.etat === 'ABSENT') {
details.push(`${elementDepart.nomElement} manquant au retour`);
} else if (elementRetour.etat === 'DETERIORE') {
details.push(`${elementDepart.nomElement} deteriore au retour`);
auMoinsUnDeteriore = true;
}
}
return { conforme: details.length === 0, auMoinsUnDeteriore, details };
}
/* RG14-RG20 : restitution d'un emprunt avec comparaison des checklists, clôture
ou passage en non conforme, et création automatique d'anomalie + notifications. */
export async function restituerEmprunt(
utilisateurId: number,
empruntId: number,
request: RestituerEmpruntRequest,
): Promise<EmpruntAvecMateriel> {
const emprunt = await findByIdAvecChecklists(empruntId);
if (!emprunt) {
throw new AppError(404, 'Emprunt introuvable');
}
if (emprunt.utilisateurId !== utilisateurId) {
throw new AppError(403, 'Cet emprunt ne vous appartient pas');
}
if (!STATUTS_RESTITUABLES.includes(emprunt.statut)) {
throw new AppError(409, 'Emprunt non restituable');
}
const checklistDepart = emprunt.checklists.find((checklist) => checklist.type === 'DEPART');
if (!checklistDepart) {
throw new AppError(409, 'Checklist de depart introuvable');
}
const comparaison = comparerChecklists(
checklistDepart.elements.map((element) => ({
accessoireId: element.accessoireId,
nomElement: element.nomElement,
etat: element.etat,
})),
request.elements,
);
const statutEmprunt: StatutEmprunt = comparaison.conforme ? 'CLOTURE' : 'RETOUR_NON_CONFORME';
let statutMateriel = 'DISPONIBLE';
if (!comparaison.conforme) {
statutMateriel = comparaison.auMoinsUnDeteriore ? 'DETERIORE' : 'NON_CONFORME';
}
const donnees: RestituerEmpruntData = {
empruntId,
utilisateurId,
materielId: emprunt.materielId,
statutEmprunt,
statutMateriel,
modeIdentification: request.modeIdentification,
commentaireRetour: request.commentaireRetour,
elements: request.elements,
};
if (!comparaison.conforme) {
const responsables = await findResponsablesParCampus(emprunt.campusId);
donnees.anomalie = {
type: 'RETOUR_NON_CONFORME',
description: `Retour non conforme : ${comparaison.details.join(', ')}.`,
responsablesIds: responsables.map((responsable) => responsable.id),
};
}
return restituerEmpruntComplet(donnees);
}
@@ -1,8 +1,11 @@
import {
findDisponiblesParCampus,
findDetailParCampus,
CatalogueFiltres,
MaterielDetail,
MaterielAvecCategorie,
} from '../repositories/materiel.repository';
import { AppError } from '../errors/app-error';
/* RG10 : après identification, seuls les matériels disponibles du campus de
l'étudiant sont affichés. */
@@ -12,3 +15,11 @@ export function listerCatalogue(
): Promise<MaterielAvecCategorie[]> {
return findDisponiblesParCampus(campusId, filtres);
}
export async function obtenirDetailMateriel(id: number, campusId: number): Promise<MaterielDetail> {
const materiel = await findDetailParCampus(id, campusId);
if (!materiel) {
throw new AppError(404, 'Materiel introuvable');
}
return materiel;
}
+45
View File
@@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
+30
View File
@@ -0,0 +1,30 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "b45fa18946ecc2d9b4009952c636ba7e2ffbb787"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
- platform: web
create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+16
View File
@@ -0,0 +1,16 @@
# eme_frontend
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
+6
View File
@@ -0,0 +1,6 @@
include: package:flutter_lints/flutter.yaml
linter:
rules:
# Convention projet (CLAUDE.md) : guillemets doubles en Dart.
prefer_single_quotes: false
+10
View File
@@ -0,0 +1,10 @@
/// Identité simulée utilisée tant que l'authentification Azure AD n'est pas branchée.
class DemoIdentity {
DemoIdentity._();
static const userName = "Lucas Martin";
static const userInitials = "LM";
static const userSubtitle = "Étudiant · B2 Informatique · Ensitech Cergy";
static const campusTag = "Saint-Christophe · Cergy";
static const loginCampusTag = "Ensitech · Cergy";
}
+21
View File
@@ -0,0 +1,21 @@
import "package:flutter/material.dart";
import "theme/ensup_theme.dart";
import "screens/login_screen.dart";
void main() {
runApp(const EmeApp());
}
class EmeApp extends StatelessWidget {
const EmeApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: "EME — Emprunt Matériel ENSUP",
debugShowCheckedModeBanner: false,
theme: buildEnsupTheme(),
home: const LoginScreen(),
);
}
}
@@ -0,0 +1,7 @@
/// Une différence constatée au retour par rapport au départ (RG20).
class AnomalieRetour {
const AnomalieRetour({required this.element, required this.etatRetour});
final String element;
final String etatRetour;
}
+52
View File
@@ -0,0 +1,52 @@
import "materiel_item.dart";
/// Un emprunt en cours de l'étudiant, affiché dans le parcours de restitution
/// et renvoyé par l'API.
class EmpruntItem {
const EmpruntItem({
required this.id,
required this.materiel,
required this.dateEmprunt,
required this.retourPrevu,
required this.statut,
});
factory EmpruntItem.fromJson(Map<String, dynamic> json) {
final dateEmprunt = DateTime.parse(json["dateEmprunt"] as String);
final retourPrevu = DateTime.parse(json["dateRetourPrevue"] as String);
return EmpruntItem(
id: json["id"] as int,
materiel: MaterielItem.fromJson(json["materiel"] as Map<String, dynamic>),
dateEmprunt: _dateHeure(dateEmprunt),
retourPrevu: _date(retourPrevu),
statut: json["statut"] as String,
);
}
final int id;
final MaterielItem materiel;
final String dateEmprunt;
final String retourPrevu;
final String statut;
EmpruntItem copyWith({MaterielItem? materiel, String? statut}) {
return EmpruntItem(
id: id,
materiel: materiel ?? this.materiel,
dateEmprunt: dateEmprunt,
retourPrevu: retourPrevu,
statut: statut ?? this.statut,
);
}
static String _deux(int valeur) => valeur.toString().padLeft(2, "0");
static String _date(DateTime date) {
return "${_deux(date.day)}/${_deux(date.month)}";
}
static String _dateHeure(DateTime date) {
return "${_date(date)} à ${_deux(date.hour)}:${_deux(date.minute)}";
}
}
@@ -0,0 +1,28 @@
import "materiel_item.dart";
/// Réponse renvoyée par l'API après création d'un emprunt.
class EmpruntResponse {
const EmpruntResponse({
required this.id,
required this.dateEmprunt,
required this.dateRetourPrevue,
required this.statut,
required this.materiel,
});
factory EmpruntResponse.fromJson(Map<String, dynamic> json) {
return EmpruntResponse(
id: json["id"] as int,
dateEmprunt: DateTime.parse(json["dateEmprunt"] as String),
dateRetourPrevue: DateTime.parse(json["dateRetourPrevue"] as String),
statut: json["statut"] as String,
materiel: MaterielItem.fromJson(json["materiel"] as Map<String, dynamic>),
);
}
final int id;
final DateTime dateEmprunt;
final DateTime dateRetourPrevue;
final String statut;
final MaterielItem materiel;
}
@@ -0,0 +1,75 @@
import "package:flutter/material.dart";
import "../demo_identity.dart";
/// Représentation d'un matériel affiché dans le catalogue et son détail
/// à partir des réponses API.
class MaterielItem {
const MaterielItem({
required this.id,
required this.nom,
required this.categorie,
required this.marque,
required this.modele,
required this.reference,
required this.campus,
required this.etatGeneral,
required this.disponible,
required this.accessoires,
required this.icon,
});
/// Construit un MaterielItem depuis la réponse JSON du catalogue.
/// Les champs non fournis par cet endpoint (accessoires, icône) sont complétés.
factory MaterielItem.fromJson(Map<String, dynamic> json) {
final categorie = (json["categorie"] as Map<String, dynamic>)["nom"] as String;
final campus = json["campus"] as Map<String, dynamic>?;
final accessoires = json["accessoires"] as List<dynamic>?;
return MaterielItem(
id: json["id"] as int,
nom: json["nom"] as String,
categorie: categorie,
marque: json["marque"] as String,
modele: json["modele"] as String,
reference: json["reference"] as String,
campus: campus == null
? DemoIdentity.campusTag
: "${campus["nom"]} · ${campus["ville"]}",
etatGeneral: json["etatGeneral"] as String,
disponible: json["statut"] == "DISPONIBLE",
accessoires: accessoires == null ? const <String>[] : accessoires.cast<String>(),
icon: iconePourCategorie(categorie),
);
}
final int id;
final String nom;
final String categorie;
final String marque;
final String modele;
final String reference;
final String campus;
final String etatGeneral;
final bool disponible;
final List<String> accessoires;
final IconData icon;
}
/// Déduit une icône à partir du nom de catégorie renvoyé par l'API.
IconData iconePourCategorie(String categorie) {
switch (categorie) {
case "Ordinateur portable":
return Icons.laptop_mac;
case "Vidéoprojecteur":
return Icons.videocam_outlined;
case "Tablette":
return Icons.tablet_mac;
case "Caméra":
return Icons.photo_camera_outlined;
case "Périphérique audio":
return Icons.headphones;
case "Câble / Adaptateur":
return Icons.cable;
default:
return Icons.inventory_2_outlined;
}
}
@@ -0,0 +1,319 @@
import "package:flutter/material.dart";
import "package:google_fonts/google_fonts.dart";
import "../demo_identity.dart";
import "../theme/ensup_colors.dart";
import "../models/materiel_item.dart";
import "../services/materiel_service.dart";
import "../widgets/ensup_top_bar.dart";
import "../widgets/brand_corners.dart";
import "../widgets/materiel_card.dart";
import "detail_screen.dart";
/// Catalogue matériel : recherche, filtres par catégorie et liste chargée
/// depuis l'API.
class CatalogueScreen extends StatefulWidget {
const CatalogueScreen({super.key});
@override
State<CatalogueScreen> createState() => _CatalogueScreenState();
}
class _CatalogueScreenState extends State<CatalogueScreen> {
late Future<List<MaterielItem>> _future;
String _recherche = "";
String _categorie = "Tous";
int? _selectionEnCoursId;
@override
void initState() {
super.initState();
_future = MaterielService.getCatalogue();
}
void _recharger() {
setState(() => _future = MaterielService.getCatalogue());
}
List<String> _categories(List<MaterielItem> materiels) {
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;
return matchTexte && matchCategorie;
}).toList();
}
Future<void> _selectionner(MaterielItem item) async {
setState(() => _selectionEnCoursId = item.id);
try {
final detail = await MaterielService.getDetail(item.id);
if (!mounted) {
return;
}
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => DetailScreen(item: detail)),
);
} catch (error) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error.toString())),
);
} finally {
if (mounted) {
setState(() => _selectionEnCoursId = null);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: EnsupColors.soft,
body: Stack(
children: [
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1280, maxHeight: 820),
child: Container(
margin: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: EnsupColors.text.withValues(alpha: 0.14),
blurRadius: 60,
offset: const Offset(0, 18),
),
],
),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
EnsupTopBar(
brandText: "Catalogue matériel",
campusTag: DemoIdentity.campusTag,
userName: DemoIdentity.userName,
userInitials: DemoIdentity.userInitials,
onBack: () => Navigator.of(context).pop(),
),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(32, 24, 32, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SearchField(
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.hasError) {
return _ErreurState(
message: snapshot.error.toString(),
onRetry: _recharger,
);
}
final materiels = snapshot.data ?? const [];
return _contenu(materiels);
},
),
),
],
),
),
),
],
),
),
),
),
const Positioned.fill(child: BrandCorners()),
],
),
);
}
Widget _contenu(List<MaterielItem> materiels) {
final resultats = _filtrer(materiels);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 8,
runSpacing: 8,
children: _categories(materiels).map((categorie) {
return _CategoryChip(
label: categorie,
selected: categorie == _categorie,
onTap: () => setState(() => _categorie = categorie),
);
}).toList(),
),
const SizedBox(height: 16),
const Divider(color: EnsupColors.line, height: 1),
const SizedBox(height: 16),
Expanded(
child: resultats.isEmpty
? const _EmptyState()
: ListView.separated(
itemCount: resultats.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (context, index) {
final item = resultats[index];
return Stack(
children: [
MaterielCard(item: item, onSelect: () => _selectionner(item)),
if (_selectionEnCoursId == item.id)
const Positioned.fill(
child: ColoredBox(
color: Color(0x66FFFFFF),
child: Center(child: CircularProgressIndicator()),
),
),
],
);
},
),
),
],
);
}
}
class _SearchField extends StatelessWidget {
const _SearchField({required this.onChanged});
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) {
return Container(
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: EnsupColors.line, width: 1.5),
),
child: Row(
children: [
const Icon(Icons.search, size: 18, color: EnsupColors.muted),
const SizedBox(width: 10),
Expanded(
child: TextField(
onChanged: onChanged,
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),
),
),
),
],
),
);
}
}
class _CategoryChip extends StatelessWidget {
const _CategoryChip({required this.label, required this.selected, required this.onTap});
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(999),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: selected ? EnsupColors.cyan : Colors.white,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: selected ? EnsupColors.cyan : EnsupColors.line, width: 1.5),
),
child: Text(
label,
style: GoogleFonts.darkerGrotesque(
fontSize: 13,
fontWeight: FontWeight.w600,
color: selected ? Colors.white : EnsupColors.muted,
),
),
),
);
}
}
class _EmptyState extends StatelessWidget {
const _EmptyState();
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
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),
),
],
),
);
}
}
class _ErreurState extends StatelessWidget {
const _ErreurState({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: [
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),
),
const SizedBox(height: 14),
OutlinedButton(
onPressed: onRetry,
child: Text(
"Réessayer",
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700),
),
),
],
),
);
}
}
@@ -0,0 +1,248 @@
import "package:flutter/material.dart";
import "package:google_fonts/google_fonts.dart";
import "../demo_identity.dart";
import "../theme/ensup_colors.dart";
import "../models/materiel_item.dart";
import "../services/emprunt_service.dart";
import "../widgets/ensup_top_bar.dart";
import "../widgets/brand_corners.dart";
import "../widgets/etat_checklist.dart";
import "confirmation_screen.dart";
/// Checklist de départ (RG11) : l'étudiant indique l'état de chaque accessoire
/// attendu avant de confirmer l'emprunt.
class ChecklistDepartScreen extends StatefulWidget {
const ChecklistDepartScreen({super.key, required this.item});
final MaterielItem item;
@override
State<ChecklistDepartScreen> createState() => _ChecklistDepartScreenState();
}
class _ChecklistDepartScreenState extends State<ChecklistDepartScreen> {
late final List<String> _elements = widget.item.accessoires.isEmpty
? <String>[widget.item.nom]
: widget.item.accessoires;
late final List<EtatChecklist> _etats = List<EtatChecklist>.filled(
_elements.length,
EtatChecklist.present,
);
final TextEditingController _commentaireController = TextEditingController();
bool _envoiEnCours = false;
@override
void dispose() {
_commentaireController.dispose();
super.dispose();
}
Future<void> _valider() async {
setState(() => _envoiEnCours = true);
final anomalies = <String>[];
for (var i = 0; i < _etats.length; i++) {
if (_etats[i] == EtatChecklist.absent) {
anomalies.add("${_elements[i]} absent au départ");
} else if (_etats[i] == EtatChecklist.deteriore) {
anomalies.add("${_elements[i]} détérioré au départ");
}
}
final note = anomalies.isEmpty ? null : anomalies.join(", ");
try {
final emprunt = await EmpruntService.creerEmprunt(
materielId: widget.item.id,
commentaireDepart: _commentaireController.text,
elements: _elements.asMap().entries.map((entry) {
return {
"nomElement": entry.value,
"etat": _etatApi(_etats[entry.key]),
"quantiteConstatee": _etats[entry.key] == EtatChecklist.absent ? 0 : 1,
};
}).toList(),
);
if (!mounted) {
return;
}
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ConfirmationScreen(emprunt: emprunt, note: note),
),
);
} catch (error) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error.toString())),
);
} finally {
if (mounted) {
setState(() => _envoiEnCours = false);
}
}
}
String _etatApi(EtatChecklist etat) {
switch (etat) {
case EtatChecklist.present:
return "PRESENT";
case EtatChecklist.absent:
return "ABSENT";
case EtatChecklist.deteriore:
return "DETERIORE";
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: EnsupColors.soft,
body: Stack(
children: [
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1280, maxHeight: 820),
child: Container(
margin: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: EnsupColors.text.withValues(alpha: 0.14),
blurRadius: 60,
offset: const Offset(0, 18),
),
],
),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
EnsupTopBar(
brandText: "Checklist de départ",
campusTag: DemoIdentity.campusTag,
userName: DemoIdentity.userName,
userInitials: DemoIdentity.userInitials,
onBack: () => Navigator.of(context).pop(),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 28),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 3, child: _colonneGauche()),
const SizedBox(width: 20),
Expanded(flex: 2, child: _colonneDroite()),
],
),
),
),
],
),
),
),
),
const Positioned.fill(child: BrandCorners()),
],
),
);
}
Widget _colonneGauche() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: EnsupColors.soft,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: EnsupColors.cyan.withValues(alpha: 0.15), width: 1.5),
),
child: Row(
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: EnsupColors.cyan.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(9),
),
child: Icon(widget.item.icon, color: EnsupColors.cyan, size: 18),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.item.nom,
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),
),
],
),
),
],
),
),
const SizedBox(height: 16),
ChecklistTableau(
accessoires: _elements,
etats: _etats,
onChanged: (index, etat) => setState(() => _etats[index] = etat),
),
],
);
}
Widget _colonneDroite() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ChecklistInstructions(
titre: "Instructions",
texte:
"Vérifiez physiquement chaque élément du kit et indiquez son état : "
"Présent, Absent ou Détérioré. Signalez tout élément manquant ou "
"endommagé pour éviter tout litige au retour.",
),
const SizedBox(height: 14),
ChecklistCommentaire(controller: _commentaireController),
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _envoiEnCours ? null : _valider,
icon: _envoiEnCours
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.check, size: 18),
label: Text(
_envoiEnCours
? "Enregistrement..."
: "Valider la checklist et confirmer l'emprunt",
textAlign: TextAlign.center,
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)),
),
),
),
],
);
}
}
@@ -0,0 +1,254 @@
import "package:flutter/material.dart";
import "package:google_fonts/google_fonts.dart";
import "../demo_identity.dart";
import "../theme/ensup_colors.dart";
import "../models/emprunt_item.dart";
import "../models/anomalie_retour.dart";
import "../services/emprunt_service.dart";
import "../widgets/ensup_top_bar.dart";
import "../widgets/brand_corners.dart";
import "../widgets/etat_checklist.dart";
import "resultat_retour_screen.dart";
/// Checklist de retour (RG16) : l'étudiant indique l'état de chaque accessoire
/// au retour, en vue de la comparaison avec la checklist de départ.
class ChecklistRetourScreen extends StatefulWidget {
const ChecklistRetourScreen({super.key, required this.emprunt});
final EmpruntItem emprunt;
@override
State<ChecklistRetourScreen> createState() => _ChecklistRetourScreenState();
}
class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
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(
_elements.length,
EtatChecklist.present,
);
final TextEditingController _commentaireController = TextEditingController();
bool _envoiEnCours = false;
@override
void dispose() {
_commentaireController.dispose();
super.dispose();
}
Future<void> _valider() async {
setState(() => _envoiEnCours = true);
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"));
} else if (_etats[i] == EtatChecklist.deteriore) {
anomalies.add(AnomalieRetour(element: _elements[i], etatRetour: "Détérioré au retour"));
}
}
try {
final resultat = await EmpruntService.restituerEmprunt(
empruntId: widget.emprunt.id,
commentaireRetour: _commentaireController.text,
elements: _elements.asMap().entries.map((entry) {
return {
"nomElement": entry.value,
"etat": _etatApi(_etats[entry.key]),
"quantiteConstatee": _etats[entry.key] == EtatChecklist.absent ? 0 : 1,
};
}).toList(),
);
if (!mounted) {
return;
}
final anomaliesAffichees = resultat.statut == "CLOTURE" ? <AnomalieRetour>[] : anomalies;
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ResultatRetourScreen(
emprunt: widget.emprunt.copyWith(statut: resultat.statut),
anomalies: anomaliesAffichees,
),
),
);
} catch (error) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error.toString())),
);
} finally {
if (mounted) {
setState(() => _envoiEnCours = false);
}
}
}
String _etatApi(EtatChecklist etat) {
switch (etat) {
case EtatChecklist.present:
return "PRESENT";
case EtatChecklist.absent:
return "ABSENT";
case EtatChecklist.deteriore:
return "DETERIORE";
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: EnsupColors.soft,
body: Stack(
children: [
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1280, maxHeight: 820),
child: Container(
margin: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: EnsupColors.text.withValues(alpha: 0.14),
blurRadius: 60,
offset: const Offset(0, 18),
),
],
),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
EnsupTopBar(
brandText: "Checklist de retour",
campusTag: DemoIdentity.campusTag,
userName: DemoIdentity.userName,
userInitials: DemoIdentity.userInitials,
onBack: () => Navigator.of(context).pop(),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 28),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 3, child: _colonneGauche()),
const SizedBox(width: 20),
Expanded(flex: 2, child: _colonneDroite()),
],
),
),
),
],
),
),
),
),
const Positioned.fill(child: BrandCorners()),
],
),
);
}
Widget _colonneGauche() {
final materiel = widget.emprunt.materiel;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: EnsupColors.soft,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: EnsupColors.teal.withValues(alpha: 0.15), width: 1.5),
),
child: Row(
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: EnsupColors.teal.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(9),
),
child: Icon(materiel.icon, color: EnsupColors.teal, size: 18),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
materiel.nom,
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w800, fontSize: 16),
),
Text(
"Emprunté le ${widget.emprunt.dateEmprunt}",
style: GoogleFonts.titilliumWeb(fontSize: 12, color: EnsupColors.muted),
),
],
),
),
],
),
),
const SizedBox(height: 16),
ChecklistTableau(
accessoires: _elements,
etats: _etats,
onChanged: (index, etat) => setState(() => _etats[index] = etat),
),
],
);
}
Widget _colonneDroite() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ChecklistInstructions(
titre: "Rappel checklist de départ",
texte:
"Au départ, tous les accessoires du kit étaient présents. "
"Indiquez leur état au retour ; toute différence sera signalée.",
),
const SizedBox(height: 14),
ChecklistCommentaire(
controller: _commentaireController,
hint: "Commentaire optionnel...",
),
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _envoiEnCours ? null : _valider,
icon: _envoiEnCours
? const SizedBox(
width: 18,
height: 18,
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: FilledButton.styleFrom(
backgroundColor: EnsupColors.blue3,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
),
),
],
);
}
}
@@ -0,0 +1,223 @@
import "package:flutter/material.dart";
import "package:google_fonts/google_fonts.dart";
import "../demo_identity.dart";
import "../theme/ensup_colors.dart";
import "../models/emprunt_response.dart";
import "../models/materiel_item.dart";
import "../widgets/ensup_top_bar.dart";
import "../widgets/brand_corners.dart";
/// Écran de succès clôturant l'emprunt : confirmation + récapitulatif.
class ConfirmationScreen extends StatelessWidget {
const ConfirmationScreen({super.key, required this.emprunt, this.note});
final EmpruntResponse emprunt;
final String? note;
MaterielItem get item => emprunt.materiel;
static String _deuxChiffres(int valeur) => valeur.toString().padLeft(2, "0");
@override
Widget build(BuildContext context) {
final dateEmprunt = emprunt.dateEmprunt;
final date =
"${_deuxChiffres(dateEmprunt.day)}/${_deuxChiffres(dateEmprunt.month)}/${dateEmprunt.year}";
final heure = "${_deuxChiffres(dateEmprunt.hour)}:${_deuxChiffres(dateEmprunt.minute)}";
return Scaffold(
backgroundColor: EnsupColors.soft,
body: Stack(
children: [
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1280, maxHeight: 820),
child: Container(
margin: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: EnsupColors.text.withValues(alpha: 0.14),
blurRadius: 60,
offset: const Offset(0, 18),
),
],
),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
const EnsupTopBar(
brandText: "EME · Confirmation",
campusTag: DemoIdentity.campusTag,
),
Expanded(
child: DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFF0FDF4), Color(0xFFF8FAFC)],
),
),
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 32),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: Column(
children: [
_boiteSucces(),
const SizedBox(height: 16),
_recapitulatif(date, heure),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () => Navigator.of(context).popUntil(
(route) => route.settings.name == "home",
),
icon: const Icon(Icons.home_outlined, size: 18),
label: Text(
"Retour à l'accueil",
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),
),
),
),
),
],
),
),
),
),
),
),
],
),
),
),
),
const Positioned.fill(child: BrandCorners()),
],
),
);
}
Widget _boiteSucces() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(36),
decoration: BoxDecoration(
gradient: const LinearGradient(colors: [Color(0xFFF0FDF4), Color(0xFFECFDF5)]),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFBBF7D0), width: 2),
),
child: Column(
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: const LinearGradient(colors: [Color(0xFF16A34A), Color(0xFF22C55E)]),
boxShadow: [
BoxShadow(
color: const Color(0xFF16A34A).withValues(alpha: 0.3),
blurRadius: 20,
offset: const Offset(0, 6),
),
],
),
child: const Icon(Icons.check, color: Colors.white, size: 36),
),
const SizedBox(height: 18),
Text(
"Emprunt enregistré !",
style: GoogleFonts.darkerGrotesque(
fontSize: 28,
fontWeight: FontWeight.w900,
color: const Color(0xFF15803D),
),
),
const SizedBox(height: 8),
Text(
"Votre emprunt a bien été enregistré. Vous pouvez récupérer le matériel.",
textAlign: TextAlign.center,
style: GoogleFonts.titilliumWeb(fontSize: 14, color: const Color(0xFF166534)),
),
],
),
);
}
Widget _recapitulatif(String date, String heure) {
final note = this.note;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: EnsupColors.line, width: 1.5),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"RÉCAPITULATIF DE L'EMPRUNT",
style: GoogleFonts.darkerGrotesque(
fontSize: 12,
fontWeight: FontWeight.w700,
color: EnsupColors.muted,
letterSpacing: 0.6,
),
),
const SizedBox(height: 10),
_rrow("Matériel", item.nom),
_rrow("Référence", item.reference),
_rrow("N° emprunt", "#${emprunt.id}"),
_rrow("Emprunteur", DemoIdentity.userName),
_rrow("Date d'emprunt", date),
_rrow("Heure", heure),
_rrow("Campus", item.campus),
if (note != null) _rrow("Note", note, valueColor: const Color(0xFFD97706)),
],
),
);
}
Widget _rrow(String cle, String valeur, {Color? valueColor}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 7),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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,
),
),
),
],
),
);
}
}
+249
View File
@@ -0,0 +1,249 @@
import "package:flutter/material.dart";
import "package:google_fonts/google_fonts.dart";
import "../demo_identity.dart";
import "../theme/ensup_colors.dart";
import "../models/materiel_item.dart";
import "../widgets/ensup_top_bar.dart";
import "../widgets/brand_corners.dart";
import "../widgets/status_pill.dart";
import "checklist_depart_screen.dart";
/// Détail d'un matériel : caractéristiques, accessoires du kit et lancement de
/// l'emprunt.
class DetailScreen extends StatelessWidget {
const DetailScreen({super.key, required this.item});
final MaterielItem item;
void _demarrer(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ChecklistDepartScreen(item: item)),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: EnsupColors.soft,
body: Stack(
children: [
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1280, maxHeight: 820),
child: Container(
margin: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: EnsupColors.text.withValues(alpha: 0.14),
blurRadius: 60,
offset: const Offset(0, 18),
),
],
),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
EnsupTopBar(
brandText: "Détail matériel",
campusTag: DemoIdentity.campusTag,
userName: DemoIdentity.userName,
userInitials: DemoIdentity.userInitials,
onBack: () => Navigator.of(context).pop(),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 28),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 3, child: _colonneGauche()),
const SizedBox(width: 20),
Expanded(flex: 2, child: _colonneDroite(context)),
],
),
),
),
],
),
),
),
),
const Positioned.fill(child: BrandCorners()),
],
),
);
}
Widget _colonneGauche() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: EnsupColors.soft,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: EnsupColors.line, width: 1.5),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.nom,
style: GoogleFonts.darkerGrotesque(
fontSize: 20,
fontWeight: FontWeight.w900,
color: EnsupColors.text,
),
),
const SizedBox(height: 12),
_row("Référence", item.reference),
_row("Catégorie", item.categorie),
_row("Marque", item.marque),
_row("Modèle", item.modele),
_row("État", item.etatGeneral, valueColor: EnsupColors.green),
_row("Campus", item.campus),
_rowStatut(),
],
),
),
if (item.accessoires.isNotEmpty) ...[
const SizedBox(height: 18),
Text(
"ACCESSOIRES INCLUS DANS LE KIT",
style: GoogleFonts.darkerGrotesque(
fontSize: 13,
fontWeight: FontWeight.w700,
color: EnsupColors.muted,
letterSpacing: 0.8,
),
),
const SizedBox(height: 10),
Wrap(
spacing: 6,
runSpacing: 6,
children: item.accessoires.map(_tag).toList(),
),
],
],
);
}
Widget _row(String cle, String valeur, {Color? valueColor}) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: EnsupColors.line)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(cle, style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.muted)),
Text(
valeur,
style: GoogleFonts.titilliumWeb(
fontSize: 13,
fontWeight: FontWeight.w600,
color: valueColor ?? EnsupColors.text,
),
),
],
),
);
}
Widget _rowStatut() {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("Statut", style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.muted)),
StatusPill(disponible: item.disponible),
],
),
);
}
Widget _tag(String label) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
decoration: BoxDecoration(
color: EnsupColors.teal.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
),
child: Text(
label,
style: GoogleFonts.darkerGrotesque(
fontSize: 13,
fontWeight: FontWeight.w600,
color: EnsupColors.teal,
),
),
);
}
Widget _colonneDroite(BuildContext context) {
return Column(
children: [
Container(
height: 180,
width: double.infinity,
decoration: BoxDecoration(
color: EnsupColors.soft,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: EnsupColors.line, width: 1.5),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(item.icon, size: 48, color: EnsupColors.muted),
const SizedBox(height: 10),
Text(
"Aperçu du matériel",
style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.muted),
),
],
),
),
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: item.disponible ? () => _demarrer(context) : null,
icon: const Icon(Icons.arrow_forward, size: 18),
label: Text(
"Démarrer l'emprunt",
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)),
),
),
),
const SizedBox(height: 10),
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: () => Navigator.of(context).pop(),
style: OutlinedButton.styleFrom(
foregroundColor: EnsupColors.text2,
side: const BorderSide(color: EnsupColors.line, width: 1.5),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
child: Text(
"Retour au catalogue",
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700, fontSize: 15),
),
),
),
],
);
}
}
+116
View File
@@ -0,0 +1,116 @@
import "package:flutter/material.dart";
import "package:google_fonts/google_fonts.dart";
import "../demo_identity.dart";
import "../theme/ensup_colors.dart";
import "../widgets/ensup_top_bar.dart";
import "../widgets/profile_card.dart";
import "../widgets/action_card.dart";
import "../widgets/brand_corners.dart";
import "catalogue_screen.dart";
import "restitution_select_screen.dart";
/// Écran d'accueil étudiant : profil + choix Emprunter / Restituer.
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: EnsupColors.soft,
body: Stack(
children: [
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1280, maxHeight: 820),
child: Container(
margin: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: EnsupColors.text.withValues(alpha: 0.14),
blurRadius: 60,
offset: const Offset(0, 18),
),
],
),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
EnsupTopBar(
campusTag: DemoIdentity.campusTag,
userName: DemoIdentity.userName,
userInitials: DemoIdentity.userInitials,
onBack: () => Navigator.of(context).pop(),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ProfileCard(
name: DemoIdentity.userName,
subtitle: DemoIdentity.userSubtitle,
initials: DemoIdentity.userInitials,
),
const SizedBox(height: 24),
Text(
"Que souhaitez-vous faire ?",
style: GoogleFonts.darkerGrotesque(
fontSize: 28,
fontWeight: FontWeight.w900,
color: EnsupColors.text,
),
),
const SizedBox(height: 18),
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: ActionCard(
icon: Icons.inventory_2_outlined,
title: "EMPRUNTER",
description:
"Accéder au catalogue et emprunter du matériel éducatif",
color: EnsupColors.cyan,
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const CatalogueScreen()),
),
),
),
const SizedBox(width: 16),
Expanded(
child: ActionCard(
icon: Icons.assignment_return_outlined,
title: "RESTITUER",
description:
"Restituer un matériel que vous avez emprunté",
color: EnsupColors.teal,
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const RestitutionSelectScreen(),
),
),
),
),
],
),
),
],
),
),
),
],
),
),
),
),
const Positioned.fill(child: BrandCorners()),
],
),
);
}
}
+179
View File
@@ -0,0 +1,179 @@
import "package:flutter/material.dart";
import "package:google_fonts/google_fonts.dart";
import "../demo_identity.dart";
import "../theme/ensup_colors.dart";
import "../widgets/ensup_top_bar.dart";
import "../widgets/identification_option.dart";
import "../widgets/brand_corners.dart";
import "home_screen.dart";
/// Écran de démarrage : identification de l'étudiant (RG01/RG02).
class LoginScreen extends StatelessWidget {
const LoginScreen({super.key});
void _identifier(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const HomeScreen(),
settings: const RouteSettings(name: "home"),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: EnsupColors.soft,
body: Stack(
children: [
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1280, maxHeight: 820),
child: Container(
margin: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: EnsupColors.text.withValues(alpha: 0.14),
blurRadius: 60,
offset: const Offset(0, 18),
),
],
),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
const EnsupTopBar(
brandText: "EME — Emprunt Matériel ENSUP",
campusTag: DemoIdentity.loginCampusTag,
),
Expanded(
child: DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFF0F6FF), Color(0xFFF8FAFC)],
),
),
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 40),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [EnsupColors.blue3, EnsupColors.cyan],
),
borderRadius: BorderRadius.circular(22),
boxShadow: [
BoxShadow(
color: EnsupColors.blue3.withValues(alpha: 0.35),
blurRadius: 24,
offset: const Offset(0, 8),
),
],
),
child: const Icon(Icons.badge_outlined, color: Colors.white, size: 34),
),
const SizedBox(height: 20),
Text(
"Bienvenue sur EME",
style: GoogleFonts.darkerGrotesque(
fontSize: 34,
fontWeight: FontWeight.w900,
color: EnsupColors.text,
),
),
const SizedBox(height: 8),
Text(
"Emprunt Matériel ENSUP · Accès réservé aux membres du groupe ENSUP",
textAlign: TextAlign.center,
style: GoogleFonts.titilliumWeb(
fontSize: 14,
color: EnsupColors.muted,
),
),
const SizedBox(height: 28),
IdentificationOption(
icon: Icons.qr_code_2,
iconColor: EnsupColors.cyan,
label: "Scanner ma carte étudiante",
description:
"Présentez votre carte étudiante dématérialisée à la douchette ou à la tablette",
onTap: () => _identifier(context),
),
const SizedBox(height: 12),
IdentificationOption(
icon: Icons.mail_outline,
iconColor: EnsupColors.teal,
label: "Connexion compte professionnel ENSUP",
description: "Utiliser mon adresse e-mail Microsoft 365",
onTap: () => _identifier(context),
),
const SizedBox(height: 20),
Row(
children: [
const Expanded(child: Divider(color: EnsupColors.line)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(
"ou",
style: GoogleFonts.titilliumWeb(
fontSize: 12,
color: EnsupColors.muted,
),
),
),
const Expanded(child: Divider(color: EnsupColors.line)),
],
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () {},
icon: const Icon(Icons.person_outline, size: 16),
label: Text(
"Accès responsable matériel",
style: GoogleFonts.darkerGrotesque(
fontWeight: FontWeight.w700,
fontSize: 15,
),
),
style: OutlinedButton.styleFrom(
foregroundColor: EnsupColors.text2,
side: const BorderSide(color: EnsupColors.line, width: 1.5),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
),
),
],
),
),
),
),
),
),
],
),
),
),
),
const Positioned.fill(child: BrandCorners()),
],
),
);
}
}
@@ -0,0 +1,290 @@
import "package:flutter/material.dart";
import "package:google_fonts/google_fonts.dart";
import "../demo_identity.dart";
import "../theme/ensup_colors.dart";
import "../models/emprunt_item.dart";
import "../services/emprunt_service.dart";
import "../services/materiel_service.dart";
import "../widgets/ensup_top_bar.dart";
import "../widgets/brand_corners.dart";
import "checklist_retour_screen.dart";
/// Sélection de l'emprunt en cours à restituer (RG15 : ses propres emprunts).
class RestitutionSelectScreen extends StatefulWidget {
const RestitutionSelectScreen({super.key});
@override
State<RestitutionSelectScreen> createState() => _RestitutionSelectScreenState();
}
class _RestitutionSelectScreenState extends State<RestitutionSelectScreen> {
late Future<List<EmpruntItem>> _future;
int? _chargementId;
@override
void initState() {
super.initState();
_future = EmpruntService.getMesEmprunts();
}
void _recharger() {
setState(() => _future = EmpruntService.getMesEmprunts());
}
Future<void> _restituer(EmpruntItem emprunt) async {
setState(() => _chargementId = emprunt.id);
try {
final detail = await MaterielService.getDetail(emprunt.materiel.id);
if (!mounted) {
return;
}
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ChecklistRetourScreen(emprunt: emprunt.copyWith(materiel: detail)),
),
);
} catch (error) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error.toString())),
);
} finally {
if (mounted) {
setState(() => _chargementId = null);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: EnsupColors.soft,
body: Stack(
children: [
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1280, maxHeight: 820),
child: Container(
margin: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: EnsupColors.text.withValues(alpha: 0.14),
blurRadius: 60,
offset: const Offset(0, 18),
),
],
),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
EnsupTopBar(
brandText: "Restitution",
campusTag: DemoIdentity.campusTag,
userName: DemoIdentity.userName,
userInitials: DemoIdentity.userInitials,
onBack: () => Navigator.of(context).pop(),
),
Expanded(
child: Padding(
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.hasError) {
return _ErreurState(
message: snapshot.error.toString(),
onRetry: _recharger,
);
}
return _contenu(snapshot.data ?? const []);
},
),
),
),
],
),
),
),
),
const Positioned.fill(child: BrandCorners()),
],
),
);
}
Widget _contenu(List<EmpruntItem> emprunts) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Sélectionner le matériel à restituer",
style: GoogleFonts.darkerGrotesque(
fontSize: 24,
fontWeight: FontWeight.w900,
color: EnsupColors.text,
),
),
const SizedBox(height: 16),
Expanded(
child: emprunts.isEmpty
? const _EmptyState()
: ListView.separated(
itemCount: emprunts.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (context, index) {
final emprunt = emprunts[index];
return _EmpruntCard(
emprunt: emprunt,
loading: _chargementId == emprunt.id,
onRestituer: () => _restituer(emprunt),
);
},
),
),
],
);
}
}
class _EmpruntCard extends StatelessWidget {
const _EmpruntCard({
required this.emprunt,
required this.loading,
required this.onRestituer,
});
final EmpruntItem emprunt;
final bool loading;
final VoidCallback onRestituer;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: EnsupColors.line, width: 1.5),
),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: EnsupColors.teal.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(10),
),
child: Icon(emprunt.materiel.icon, color: EnsupColors.teal, size: 20),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
emprunt.materiel.nom,
style: GoogleFonts.darkerGrotesque(
fontWeight: FontWeight.w700,
fontSize: 16,
color: EnsupColors.text,
),
),
const SizedBox(height: 2),
Text(
"Emprunté le ${emprunt.dateEmprunt} · Retour prévu ${emprunt.retourPrevu}",
style: GoogleFonts.titilliumWeb(fontSize: 12, color: EnsupColors.muted),
),
],
),
),
const SizedBox(width: 10),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFFFEF9C3),
borderRadius: BorderRadius.circular(999),
),
child: Text(
emprunt.statut == "EN_RETARD" ? "En retard" : "En cours",
style: GoogleFonts.darkerGrotesque(
fontSize: 13,
fontWeight: FontWeight.w700,
color: const Color(0xFFA16207),
),
),
),
const SizedBox(width: 12),
FilledButton(
onPressed: loading ? null : onRestituer,
style: FilledButton.styleFrom(
backgroundColor: EnsupColors.cyan,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
child: Text(
loading ? "Chargement..." : "Restituer",
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700, fontSize: 14),
),
),
],
),
);
}
}
class _EmptyState extends StatelessWidget {
const _EmptyState();
@override
Widget build(BuildContext context) {
return Center(
child: Text(
"Aucun emprunt en cours à restituer",
style: GoogleFonts.titilliumWeb(fontSize: 14, color: EnsupColors.muted),
),
);
}
}
class _ErreurState extends StatelessWidget {
const _ErreurState({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: [
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),
),
const SizedBox(height: 14),
OutlinedButton(
onPressed: onRetry,
child: Text(
"Réessayer",
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700),
),
),
],
),
);
}
}
@@ -0,0 +1,269 @@
import "package:flutter/material.dart";
import "package:google_fonts/google_fonts.dart";
import "../demo_identity.dart";
import "../theme/ensup_colors.dart";
import "../models/emprunt_item.dart";
import "../models/anomalie_retour.dart";
import "../widgets/ensup_top_bar.dart";
import "../widgets/brand_corners.dart";
/// Résultat de la restitution (RG17-RG20) : conforme (succès) ou non conforme
/// (anomalie détectée + notification au responsable).
class ResultatRetourScreen extends StatelessWidget {
const ResultatRetourScreen({super.key, required this.emprunt, required this.anomalies});
final EmpruntItem emprunt;
final List<AnomalieRetour> anomalies;
bool get _conforme => anomalies.isEmpty;
static String _deux(int valeur) => valeur.toString().padLeft(2, "0");
@override
Widget build(BuildContext context) {
final fond = _conforme
? const [Color(0xFFF0FDF4), Color(0xFFF8FAFC)]
: const [Color(0xFFFFF5F5), Color(0xFFF8FAFC)];
return Scaffold(
backgroundColor: EnsupColors.soft,
body: Stack(
children: [
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1280, maxHeight: 820),
child: Container(
margin: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: EnsupColors.text.withValues(alpha: 0.14),
blurRadius: 60,
offset: const Offset(0, 18),
),
],
),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
const EnsupTopBar(
brandText: "EME · Résultat du contrôle",
campusTag: DemoIdentity.campusTag,
),
Expanded(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: fond,
),
),
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 32),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: Column(
children: [
if (_conforme) _succes() else _anomalie(),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () => Navigator.of(context).popUntil(
(route) => route.settings.name == "home",
),
icon: const Icon(Icons.home_outlined, size: 18),
label: Text(
"Retour à l'accueil",
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),
),
),
),
),
],
),
),
),
),
),
),
],
),
),
),
),
const Positioned.fill(child: BrandCorners()),
],
),
);
}
Widget _succes() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(36),
decoration: BoxDecoration(
gradient: const LinearGradient(colors: [Color(0xFFF0FDF4), Color(0xFFECFDF5)]),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFBBF7D0), width: 2),
),
child: Column(
children: [
Container(
width: 72,
height: 72,
decoration: const BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(colors: [Color(0xFF16A34A), Color(0xFF22C55E)]),
),
child: const Icon(Icons.check, color: Colors.white, size: 36),
),
const SizedBox(height: 18),
Text(
"Retour conforme",
style: GoogleFonts.darkerGrotesque(
fontSize: 28,
fontWeight: FontWeight.w900,
color: const Color(0xFF15803D),
),
),
const SizedBox(height: 8),
Text(
"L'emprunt a été clôturé et le matériel est de nouveau disponible.",
textAlign: TextAlign.center,
style: GoogleFonts.titilliumWeb(fontSize: 14, color: const Color(0xFF166534)),
),
],
),
);
}
Widget _anomalie() {
final now = DateTime.now();
final date =
"${_deux(now.day)}/${_deux(now.month)}/${now.year} · ${_deux(now.hour)}:${_deux(now.minute)}";
return Column(
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: EnsupColors.red.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: EnsupColors.red.withValues(alpha: 0.2), width: 1.5),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.warning_amber_rounded, color: EnsupColors.red, size: 24),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Retour non conforme — Anomalie détectée",
style: GoogleFonts.darkerGrotesque(
fontSize: 17,
fontWeight: FontWeight.w800,
color: EnsupColors.red,
),
),
const SizedBox(height: 4),
Text(
"Une différence a été constatée avec la checklist de départ. Une anomalie "
"a été créée automatiquement et le responsable matériel a été notifié.",
style: GoogleFonts.titilliumWeb(
fontSize: 13,
color: EnsupColors.text2,
height: 1.5,
),
),
],
),
),
],
),
),
const SizedBox(height: 16),
Container(
width: double.infinity,
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: EnsupColors.line, width: 1.5),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"DÉTAIL DE L'ANOMALIE",
style: GoogleFonts.darkerGrotesque(
fontSize: 12,
fontWeight: FontWeight.w700,
color: EnsupColors.muted,
letterSpacing: 0.6,
),
),
const SizedBox(height: 10),
for (final anomalie in anomalies)
_drow(
anomalie.element,
anomalie.etatRetour,
valeurCouleur: EnsupColors.red,
),
_drow("Matériel", "${emprunt.materiel.nom} · ${emprunt.materiel.reference}"),
_drow("Emprunteur", DemoIdentity.userName),
_drow("Date détection", date),
],
),
),
const SizedBox(height: 14),
Text(
"Le responsable matériel traitera cette anomalie. Vous pouvez retourner à l'accueil.",
textAlign: TextAlign.center,
style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.muted, height: 1.6),
),
],
);
}
Widget _drow(String cle, String valeur, {Color? valeurCouleur}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 7),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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: valeurCouleur ?? EnsupColors.text,
),
),
),
],
),
);
}
}
+79
View File
@@ -0,0 +1,79 @@
import "dart:convert";
import "package:http/http.dart" as http;
/// Erreur levée par le client API (serveur injoignable ou réponse en échec).
class ApiException implements Exception {
const ApiException(this.message);
final String message;
@override
String toString() => message;
}
/// 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 {
ApiClient._();
static const String _baseUrl = "http://localhost:3000/api";
static const String _utilisateurEmail = "lucas.martin@ensitech.eu";
static Future<dynamic> get(String chemin) async {
final uri = Uri.parse("$_baseUrl$chemin");
late final http.Response reponse;
try {
reponse = await http.get(uri, headers: const {"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) {
return jsonDecode(reponse.body);
}
throw ApiException(_messageErreur(reponse));
}
static Future<dynamic> post(String chemin, Map<String, dynamic> body) async {
final uri = Uri.parse("$_baseUrl$chemin");
late final http.Response reponse;
try {
reponse = await http.post(
uri,
headers: const {
"content-type": "application/json",
"x-user-email": _utilisateurEmail,
},
body: jsonEncode(body),
);
} catch (_) {
throw const ApiException(
"Impossible de joindre le serveur. Vérifiez que le backend est démarré.",
);
}
if (reponse.statusCode >= 200 && reponse.statusCode < 300) {
return jsonDecode(reponse.body);
}
throw ApiException(_messageErreur(reponse));
}
static String _messageErreur(http.Response reponse) {
try {
final corps = jsonDecode(reponse.body);
if (corps is Map && corps["error"] is Map) {
final message = (corps["error"] as Map)["message"];
if (message is String) {
return message;
}
}
} catch (_) {
// Corps non-JSON : on retombe sur le code HTTP.
}
return "Erreur ${reponse.statusCode}";
}
}
@@ -0,0 +1,48 @@
import "../models/emprunt_response.dart";
import "../models/emprunt_item.dart";
import "api_client.dart";
/// Appels liés aux emprunts.
class EmpruntService {
EmpruntService._();
static const int posteEmpruntId = 1;
static Future<EmpruntResponse> creerEmprunt({
required int materielId,
required String commentaireDepart,
required List<Map<String, dynamic>> elements,
}) async {
final reponse = await ApiClient.post("/emprunts", {
"materielId": materielId,
"posteEmpruntId": posteEmpruntId,
"modeIdentification": "MAIL_ENSUP",
if (commentaireDepart.trim().isNotEmpty) "commentaireDepart": commentaireDepart.trim(),
"elements": elements,
});
final data = (reponse as Map<String, dynamic>)["data"] as Map<String, dynamic>;
return EmpruntResponse.fromJson(data);
}
static Future<List<EmpruntItem>> getMesEmprunts() async {
final reponse = await ApiClient.get("/mes-emprunts");
final data = (reponse as Map<String, dynamic>)["data"] as List<dynamic>;
return data.map((json) => EmpruntItem.fromJson(json as Map<String, dynamic>)).toList();
}
static Future<EmpruntResponse> restituerEmprunt({
required int empruntId,
required String commentaireRetour,
required List<Map<String, dynamic>> elements,
}) async {
final reponse = await ApiClient.post("/emprunts/$empruntId/restitution", {
"modeIdentification": "MAIL_ENSUP",
if (commentaireRetour.trim().isNotEmpty) "commentaireRetour": commentaireRetour.trim(),
"elements": elements,
});
final data = (reponse as Map<String, dynamic>)["data"] as Map<String, dynamic>;
return EmpruntResponse.fromJson(data);
}
}
@@ -0,0 +1,21 @@
import "../models/materiel_item.dart";
import "api_client.dart";
/// Appels liés au catalogue matériel.
class MaterielService {
MaterielService._();
/// Récupère le catalogue (matériels disponibles du campus de l'utilisateur).
static Future<List<MaterielItem>> getCatalogue() async {
final reponse = await ApiClient.get("/materiels");
final data = (reponse as Map<String, dynamic>)["data"] as List<dynamic>;
return data.map((json) => MaterielItem.fromJson(json as Map<String, dynamic>)).toList();
}
/// Récupère le détail d'un matériel, notamment les accessoires du kit.
static Future<MaterielItem> getDetail(int id) async {
final reponse = await ApiClient.get("/materiels/$id");
final data = (reponse as Map<String, dynamic>)["data"] as Map<String, dynamic>;
return MaterielItem.fromJson(data);
}
}
+22
View File
@@ -0,0 +1,22 @@
import "package:flutter/material.dart";
/// Couleurs de la charte graphique ENSUP.
class EnsupColors {
EnsupColors._();
static const Color cyan = Color(0xFF009CE0);
static const Color blue1 = Color(0xFF006DAC);
static const Color blue2 = Color(0xFF0090D7);
static const Color blue3 = Color(0xFF005CAA);
static const Color teal = Color(0xFF198AB4);
static const Color purple = Color(0xFF7E5AA2);
static const Color red = Color(0xFFDD1B3D);
static const Color green = Color(0xFF16A34A);
static const Color text = Color(0xFF0F172A);
static const Color text2 = Color(0xFF334155);
static const Color muted = Color(0xFF64748B);
static const Color line = Color(0xFFE2E8F0);
static const Color soft = Color(0xFFF8FAFC);
static const Color white = Color(0xFFFFFFFF);
}
+23
View File
@@ -0,0 +1,23 @@
import "package:flutter/material.dart";
import "package:google_fonts/google_fonts.dart";
import "ensup_colors.dart";
/// Thème global : couleurs ENSUP et police de corps Titillium Web.
/// Les titres utilisent Darker Grotesque, appliqué directement dans les widgets.
ThemeData buildEnsupTheme() {
final base = ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: EnsupColors.cyan,
primary: EnsupColors.cyan,
),
scaffoldBackgroundColor: EnsupColors.soft,
);
return base.copyWith(
textTheme: GoogleFonts.titilliumWebTextTheme(base.textTheme).apply(
bodyColor: EnsupColors.text,
displayColor: EnsupColors.text,
),
);
}
+76
View File
@@ -0,0 +1,76 @@
import "package:flutter/material.dart";
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 {
const ActionCard({
super.key,
required this.icon,
required this.title,
required this.description,
required this.color,
this.onTap,
});
final IconData icon;
final String title;
final String description;
final Color color;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.07),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: color.withValues(alpha: 0.2), width: 2),
),
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),
),
],
),
child: Icon(icon, color: Colors.white, size: 28),
),
const SizedBox(height: 14),
Text(
title,
style: GoogleFonts.darkerGrotesque(
fontSize: 26,
fontWeight: FontWeight.w900,
color: EnsupColors.text,
),
),
const SizedBox(height: 6),
Text(
description,
textAlign: TextAlign.center,
style: GoogleFonts.titilliumWeb(
fontSize: 13,
color: EnsupColors.muted,
height: 1.5,
),
),
],
),
),
);
}
}
@@ -0,0 +1,66 @@
import "package:flutter/material.dart";
import "../theme/ensup_colors.dart";
/// Coins décoratifs de la charte ENSUP : un L coloré en haut-gauche et en
/// bas-droit, en surimpression et non cliquables (comme dans la maquette).
class BrandCorners extends StatelessWidget {
const BrandCorners({super.key});
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: Stack(
children: [
Positioned(
top: 0,
left: 0,
child: CustomPaint(size: const Size(120, 120), painter: _TopLeftCornerPainter()),
),
Positioned(
bottom: 0,
right: 0,
child: CustomPaint(size: const Size(120, 120), painter: _BottomRightCornerPainter()),
),
],
),
);
}
}
const double _thickness = 22;
class _TopLeftCornerPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..color = EnsupColors.red;
// Barre verticale gauche (rouge) + segment haut rouge.
canvas.drawRect(Rect.fromLTWH(0, 0, _thickness, size.height), paint);
canvas.drawRect(const Rect.fromLTWH(0, 0, 28, _thickness), paint);
// Segments colorés de la barre horizontale.
paint.color = EnsupColors.purple;
canvas.drawRect(const Rect.fromLTWH(28, 0, 22, _thickness), paint);
paint.color = EnsupColors.teal;
canvas.drawRect(const Rect.fromLTWH(50, 0, 22, _thickness), paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class _BottomRightCornerPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..color = EnsupColors.cyan;
// Barre verticale droite (cyan) + segment bas cyan.
canvas.drawRect(Rect.fromLTWH(size.width - _thickness, 0, _thickness, size.height), paint);
canvas.drawRect(Rect.fromLTWH(size.width - 28, size.height - _thickness, 28, _thickness), paint);
// Segments colorés de la barre horizontale.
paint.color = EnsupColors.blue2;
canvas.drawRect(Rect.fromLTWH(size.width - 50, size.height - _thickness, 22, _thickness), paint);
paint.color = EnsupColors.blue3;
canvas.drawRect(Rect.fromLTWH(size.width - 72, size.height - _thickness, 22, _thickness), paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
+152
View File
@@ -0,0 +1,152 @@
import "package:flutter/material.dart";
import "package:google_fonts/google_fonts.dart";
import "../theme/ensup_colors.dart";
/// Barre supérieure ENSUP. L'utilisateur et le bouton retour sont optionnels
/// (absents sur l'écran d'identification).
class EnsupTopBar extends StatelessWidget {
const EnsupTopBar({
super.key,
this.brandText = "EME",
required this.campusTag,
this.userName,
this.userInitials,
this.onBack,
});
final String brandText;
final String campusTag;
final String? userName;
final String? userInitials;
final VoidCallback? onBack;
@override
Widget build(BuildContext context) {
final name = userName;
final initials = userInitials;
final onBack = this.onBack;
return Container(
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 20),
color: EnsupColors.blue3,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
if (onBack != null) ...[
_BackButton(onTap: onBack),
const SizedBox(width: 8),
],
const Icon(Icons.badge_outlined, color: Colors.white, size: 18),
const SizedBox(width: 10),
Text(
brandText,
style: GoogleFonts.darkerGrotesque(
color: Colors.white,
fontWeight: FontWeight.w900,
fontSize: 22,
letterSpacing: 0.3,
),
),
],
),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: Colors.white.withValues(alpha: 0.2)),
),
child: Text(
campusTag,
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});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.arrow_back, size: 16, color: Colors.white.withValues(alpha: 0.85)),
const SizedBox(width: 6),
Text(
"Retour",
style: GoogleFonts.titilliumWeb(
color: Colors.white.withValues(alpha: 0.85),
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
}
class _Avatar extends StatelessWidget {
const _Avatar({required this.initials});
final String initials;
@override
Widget build(BuildContext context) {
return Container(
width: 30,
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),
),
alignment: Alignment.center,
child: Text(
initials,
style: GoogleFonts.darkerGrotesque(
color: Colors.white,
fontWeight: FontWeight.w900,
fontSize: 12,
),
),
);
}
}
@@ -0,0 +1,226 @@
import "package:flutter/material.dart";
import "package:google_fonts/google_fonts.dart";
import "../theme/ensup_colors.dart";
/// État possible d'un élément de checklist (départ ou retour).
enum EtatChecklist { present, absent, deteriore }
/// Case cliquable colorée selon l'état sélectionné (vert / rouge / jaune).
class EtatBox extends StatelessWidget {
const EtatBox({
super.key,
required this.type,
required this.selected,
required this.onTap,
});
final EtatChecklist type;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
var background = Colors.white;
var border = EnsupColors.line;
Widget? contenu;
if (selected) {
switch (type) {
case EtatChecklist.present:
background = const Color(0xFFF0FDF4);
border = const Color(0xFF86EFAC);
contenu = const Icon(Icons.check, size: 16, color: Color(0xFF16A34A));
case EtatChecklist.absent:
background = const Color(0xFFFEF2F2);
border = const Color(0xFFFCA5A5);
contenu = const Icon(Icons.close, size: 16, color: EnsupColors.red);
case EtatChecklist.deteriore:
background = const Color(0xFFFFFBEB);
border = const Color(0xFFFDE68A);
contenu = const Icon(Icons.priority_high, size: 16, color: Color(0xFFD97706));
}
}
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: border, width: 2),
),
child: contenu,
),
);
}
}
/// Tableau de checklist : une ligne par accessoire avec les 3 états possibles.
class ChecklistTableau extends StatelessWidget {
const ChecklistTableau({
super.key,
required this.accessoires,
required this.etats,
required this.onChanged,
});
final List<String> accessoires;
final List<EtatChecklist> etats;
final void Function(int index, EtatChecklist etat) onChanged;
@override
Widget build(BuildContext context) {
return Column(
children: [
_entete(),
for (var i = 0; i < accessoires.length; i++) _ligne(i),
],
);
}
Widget _entete() {
return Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: EnsupColors.line, width: 2)),
),
child: Row(
children: [
Expanded(child: _texteEntete("ÉLÉMENT")),
_colonneEntete("PRÉSENT"),
_colonneEntete("ABSENT"),
_colonneEntete("DÉTÉRIORÉ"),
],
),
);
}
Widget _texteEntete(String label) {
return Text(
label,
style: GoogleFonts.darkerGrotesque(
fontSize: 12,
fontWeight: FontWeight.w700,
color: EnsupColors.muted,
letterSpacing: 0.6,
),
);
}
Widget _colonneEntete(String label) {
return SizedBox(width: 72, child: Center(child: _texteEntete(label)));
}
Widget _ligne(int index) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: EnsupColors.line)),
),
child: Row(
children: [
Expanded(
child: Text(
accessoires[index],
style: GoogleFonts.titilliumWeb(fontSize: 14, color: EnsupColors.text2),
),
),
_case(index, EtatChecklist.present),
_case(index, EtatChecklist.absent),
_case(index, EtatChecklist.deteriore),
],
),
);
}
Widget _case(int index, EtatChecklist type) {
return SizedBox(
width: 72,
child: Center(
child: EtatBox(
type: type,
selected: etats[index] == type,
onTap: () => onChanged(index, type),
),
),
);
}
}
/// Encadré d'instructions / rappel à côté d'une checklist.
class ChecklistInstructions extends StatelessWidget {
const ChecklistInstructions({super.key, required this.titre, required this.texte});
final String titre;
final String texte;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
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),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
titre.toUpperCase(),
style: GoogleFonts.darkerGrotesque(
fontSize: 12,
fontWeight: FontWeight.w700,
color: EnsupColors.muted,
letterSpacing: 0.6,
),
),
const SizedBox(height: 8),
Text(
texte,
style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.text2, height: 1.6),
),
],
),
);
}
}
/// Zone de commentaire optionnel sous une checklist.
class ChecklistCommentaire extends StatelessWidget {
const ChecklistCommentaire({
super.key,
this.controller,
this.hint = "Commentaire ou observation optionnelle...",
});
final TextEditingController? controller;
final String hint;
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
maxLines: 4,
style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.text),
decoration: InputDecoration(
hintText: hint,
hintStyle: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.muted),
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(9),
borderSide: const BorderSide(color: EnsupColors.line, width: 1.5),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(9),
borderSide: const BorderSide(color: EnsupColors.cyan, width: 1.5),
),
),
);
}
}
@@ -0,0 +1,76 @@
import "package:flutter/material.dart";
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 {
const IdentificationOption({
super.key,
required this.icon,
required this.iconColor,
required this.label,
required this.description,
this.onTap,
});
final IconData icon;
final Color iconColor;
final String label;
final String description;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: EnsupColors.line, width: 2),
),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: iconColor, size: 24),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: GoogleFonts.darkerGrotesque(
fontWeight: FontWeight.w700,
fontSize: 18,
color: EnsupColors.text,
),
),
const SizedBox(height: 3),
Text(
description,
style: GoogleFonts.titilliumWeb(
fontSize: 12,
color: EnsupColors.muted,
height: 1.4,
),
),
],
),
),
const Icon(Icons.chevron_right, color: EnsupColors.muted, size: 20),
],
),
),
);
}
}
@@ -0,0 +1,90 @@
import "package:flutter/material.dart";
import "package:google_fonts/google_fonts.dart";
import "../theme/ensup_colors.dart";
import "../models/materiel_item.dart";
import "status_pill.dart";
/// Ligne du catalogue matériel (fidèle au `.mat-card` de la maquette).
class MaterielCard extends StatelessWidget {
const MaterielCard({super.key, required this.item, this.onSelect});
final MaterielItem item;
final VoidCallback? onSelect;
@override
Widget build(BuildContext context) {
final disponible = item.disponible;
final accent = disponible ? EnsupColors.cyan : EnsupColors.muted;
return Opacity(
opacity: disponible ? 1 : 0.55,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: EnsupColors.line, width: 1.5),
),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(10),
),
child: Icon(item.icon, color: accent, size: 20),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.nom,
style: GoogleFonts.darkerGrotesque(
fontWeight: FontWeight.w700,
fontSize: 16,
color: EnsupColors.text,
),
),
const SizedBox(height: 2),
Text(
"${item.categorie} · Réf. ${item.reference} · ${item.campus}",
style: GoogleFonts.titilliumWeb(fontSize: 12, color: EnsupColors.muted),
),
],
),
),
const SizedBox(width: 10),
StatusPill(disponible: disponible),
const SizedBox(width: 12),
if (disponible)
FilledButton(
onPressed: onSelect,
style: FilledButton.styleFrom(
backgroundColor: EnsupColors.cyan,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
child: Text(
"Sélectionner",
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700, fontSize: 14),
),
)
else
Text(
"Indisponible",
style: GoogleFonts.darkerGrotesque(
fontWeight: FontWeight.w700,
fontSize: 14,
color: EnsupColors.muted,
),
),
],
),
),
);
}
}
@@ -0,0 +1,91 @@
import "package:flutter/material.dart";
import "package:google_fonts/google_fonts.dart";
import "../theme/ensup_colors.dart";
/// Carte de profil de l'étudiant identifié (accueil).
class ProfileCard extends StatelessWidget {
const ProfileCard({
super.key,
required this.name,
required this.subtitle,
required this.initials,
});
final String name;
final String subtitle;
final String initials;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
EnsupColors.cyan.withValues(alpha: 0.06),
EnsupColors.teal.withValues(alpha: 0.04),
],
),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: EnsupColors.cyan.withValues(alpha: 0.15), width: 1.5),
),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: const BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(colors: [EnsupColors.teal, EnsupColors.cyan]),
),
alignment: Alignment.center,
child: Text(
initials,
style: GoogleFonts.darkerGrotesque(
color: Colors.white,
fontWeight: FontWeight.w900,
fontSize: 18,
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: GoogleFonts.darkerGrotesque(
fontWeight: FontWeight.w800,
fontSize: 20,
color: EnsupColors.text,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: GoogleFonts.titilliumWeb(fontSize: 12, color: EnsupColors.muted),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
decoration: BoxDecoration(
color: EnsupColors.green.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(999),
),
child: Text(
"Connecté",
style: GoogleFonts.titilliumWeb(
fontSize: 12,
fontWeight: FontWeight.w600,
color: EnsupColors.green,
),
),
),
],
),
);
}
}
+29
View File
@@ -0,0 +1,29 @@
import "package:flutter/material.dart";
import "package:google_fonts/google_fonts.dart";
/// Pastille de statut d'un matériel (disponible / emprunté).
class StatusPill extends StatelessWidget {
const StatusPill({super.key, required this.disponible});
final bool disponible;
@override
Widget build(BuildContext context) {
final label = disponible ? "Disponible" : "Emprunté";
final background = disponible ? const Color(0xFFDCFCE7) : const Color(0xFFFEF9C3);
final foreground = disponible ? const Color(0xFF15803D) : const Color(0xFFA16207);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(color: background, borderRadius: BorderRadius.circular(999)),
child: Text(
label,
style: GoogleFonts.darkerGrotesque(
fontSize: 14,
fontWeight: FontWeight.w700,
color: foreground,
),
),
);
}
}
+373
View File
@@ -0,0 +1,373 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.1"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
cupertino_icons:
dependency: "direct main"
description:
name: cupertino_icons
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
url: "https://pub.dev"
source: hosted
version: "1.0.9"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
google_fonts:
dependency: "direct main"
description:
name: google_fonts
sha256: "4e9391085e524954a51e3625b7c9c7e9851dc3f376603208bb45c24b9a66255d"
url: "https://pub.dev"
source: hosted
version: "8.1.0"
http:
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
jni:
dependency: transitive
description:
name: jni
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
url: "https://pub.dev"
source: hosted
version: "1.0.0"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
url: "https://pub.dev"
source: hosted
version: "2.1.6"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.dev"
source: hosted
version: "2.3.1"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4"
url: "https://pub.dev"
source: hosted
version: "2.5.1"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
url: "https://pub.dev"
source: hosted
version: "2.2.2"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.2.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev"
source: hosted
version: "15.2.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
sdks:
dart: ">=3.10.0 <4.0.0"
flutter: ">=3.38.0"
+91
View File
@@ -0,0 +1,91 @@
name: eme_frontend
description: "A new Flutter project."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
environment:
sdk: ^3.10.0
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
google_fonts: ^8.1.0
http: ^1.6.0
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/to/asset-from-package
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package
+11
View File
@@ -0,0 +1,11 @@
import "package:flutter_test/flutter_test.dart";
import "package:eme_frontend/main.dart";
void main() {
testWidgets("L'écran de démarrage affiche l'identification", (tester) async {
await tester.pumpWidget(const EmeApp());
expect(find.text("Bienvenue sur EME"), findsOneWidget);
expect(find.text("Scanner ma carte étudiante"), findsOneWidget);
});
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 917 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+38
View File
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html>
<head>
<!--
If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from.
The path provided below has to start and end with a slash "/" in order for
it to work correctly.
For more details:
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
This is a placeholder for base href that will be replaced by the value of
the `--base-href` argument provided to `flutter build`.
-->
<base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter project.">
<!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="eme_frontend">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<title>eme_frontend</title>
<link rel="manifest" href="manifest.json">
</head>
<body>
<script src="flutter_bootstrap.js" async></script>
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
{
"name": "eme_frontend",
"short_name": "eme_frontend",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "A new Flutter project.",
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [
{
"src": "icons/Icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icons/Icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "icons/Icon-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "icons/Icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+98 -1
View File
@@ -149,6 +149,49 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y
- Préventif : clés/certificats (`*.pem`, `*.key`, `*.crt`, `*.cert`, `*.pfx`, `*.p12`) pour Azure AD/TLS à venir ; dumps de base (`*.bak`, `*.dump`) ; `**/coverage/` ; `*.tsbuildinfo`.
- Vérifié : aucun secret n'était déjà suivi ; les vrais `.env` restent ignorés ; `.env.example` (placeholders) reste versionné.
### Étape 19 — Block 4 : création d'emprunt (POST /api/emprunts)
- Écriture transactionnelle : réservation atomique du matériel (`updateMany where statut=DISPONIBLE`), création de l'emprunt (`EN_COURS`), de la checklist de départ et de ses éléments. Rollback global si une étape échoue.
- Règles : RG07 (matériel disponible), RG10 (matériel du campus de l'étudiant), RG11 (checklist obligatoire non vide), RG12 (matériel -> `EMPRUNTE`, horodatage).
- Validation d'entrée manuelle (`CreerEmpruntRequest`, `parseCreerEmpruntRequest`) : aucune dépendance ajoutée. Durée d'emprunt par défaut : +14 jours.
- Contrôle du poste (existe + même campus, RG05). Routage : `POST /api/emprunts` et `GET /api/mes-emprunts` sur des routeurs séparés.
- Vérifié au runtime : 401 sans auth, 400 checklist vide, 404 matériel inconnu, 201 création (matériel -> `EMPRUNTE`, +14j), 409 doublon (réservation atomique).
### Étape 20 — Block 4 : restitution + anomalie automatique (POST /api/emprunts/:id/restitution)
- Comparaison automatique départ/retour (RG17) : un élément présent au départ mais absent/détérioré au retour rend la restitution non conforme (RG20). Appariement par `accessoireId` sinon par `nomElement`.
- Restitution atomique : checklist de retour + mise à jour emprunt/matériel + (si non conforme) anomalie `DETECTEE` + notifications.
- RG15 (propriétaire), statut restituable, RG18 (conforme -> `CLOTURE` + matériel `DISPONIBLE`), RG19 (non conforme -> `RETOUR_NON_CONFORME` + matériel `DETERIORE`/`NON_CONFORME`).
- RG24 : notification à tous les responsables (`RESPONSABLE`) du campus de l'emprunt.
- Vérifié au runtime : 404/403/409 ; restitution conforme (CLOTURE + DISPONIBLE) ; non conforme (RETOUR_NON_CONFORME + NON_CONFORME + anomalie + notification à Karim).
- **Block 4 (API Étudiant) terminé : 6/6 endpoints.**
### Étape 21 — Frontend : setup Flutter Web + écran d'accueil ENSUP
- Projet Flutter Web créé (`eme-frontend/`), `google_fonts` ajouté (Darker Grotesque pour les titres, Titillium Web pour le corps).
- Thème ENSUP (`theme/ensup_colors.dart`, `theme/ensup_theme.dart`) : couleurs de la charte graphique.
- Composants réutilisables : `EnsupTopBar`, `ProfileCard`, `ActionCard`.
- Écran d'accueil (`screens/home_screen.dart`) fidèle à la maquette : topbar bleu foncé, carte profil, deux cartes Emprunter/Restituer. Données statiques (pas encore branché à l'API).
- Convention `CLAUDE.md` respectée : guillemets doubles en Dart (règle lint `prefer_single_quotes` désactivée en conséquence).
- `flutter analyze` : aucun problème. App lancée dans Chrome (http://localhost:5000).
### Étape 22 — Suppression du PDF de charte graphique
- PDF de charte ENSUP retiré du dépôt (redondant : la maquette validée applique la charte, et les couleurs/typos sont dans `CONTEXT.md` §7).
- Références mises à jour dans `CLAUDE.md` et `CONTEXT.md` pour pointer vers la maquette.
- Le PDF reste récupérable dans l'historique Git si besoin (logo officiel source).
### Étape 23 — Frontend : écran d'identification (écran de démarrage)
- Correction : l'écran de démarrage est l'identification (`s-login` de la maquette), pas l'accueil étudiant. Logo, "Bienvenue sur EME", deux options (scanner carte / compte ENSUP Microsoft 365), bouton accès responsable.
- `EnsupTopBar` rendue réutilisable (utilisateur optionnel, marque paramétrable) ; nouveau widget `IdentificationOption` ; coins ENSUP aussi sur cet écran.
- Navigation identification -> accueil étudiant (`HomeScreen`).
### Étape 24 — Frontend : écran catalogue matériel
- Écran catalogue (`s-catalogue`) : recherche **fonctionnelle** + filtres par catégorie (chips) + liste de matériels avec statut (Disponible/Emprunté) et état vide.
- Nouveaux composants : `MaterielCard`, `StatusPill`, modèle `MaterielItem`. Données statiques (4 matériels de la maquette).
- Navigation : Accueil (Emprunter) -> Catalogue ; bouton retour vers l'accueil. « Sélectionner » -> placeholder (détail à venir).
### Étape 25 — Frontend : écran détail matériel
- Écran détail (`s-detail`) : caractéristiques (référence, catégorie, marque, modèle, état, campus, statut) + accessoires du kit (tags) + aperçu + boutons « Démarrer l'emprunt » / « Retour au catalogue ».
- Modèle `MaterielItem` enrichi (marque, modèle, état, accessoires) ; données statiques mises à jour.
- Navigation : Catalogue (Sélectionner) -> Détail ; « Démarrer l'emprunt » -> placeholder (checklist à venir).
---
## Dette technique en attente
@@ -164,4 +207,58 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y
---
*Dernière mise à jour : 2026-06-22 — Durcissement .gitignore (sécurité secrets).*
### Étape 26 — Connexion frontend ↔ API (catalogue)
- Package `http` ajouté. Couche `services/api_client.dart` : URL de base, en-tête d'auth simulée (`x-user-email: lucas.martin@ensitech.eu`), gestion des erreurs (serveur injoignable, message `{error:{message}}`).
- `services/materiel_service.dart` (`getCatalogue`) + `MaterielItem.fromJson` (icône déduite de la catégorie, champs absents complétés).
- Écran catalogue branché via `FutureBuilder` : chargement / données / erreur + bouton Réessayer. Filtres de catégorie déduits des vraies données.
- CORS backend assoupli en développement (`origin: true` si `NODE_ENV=development`), strict en production.
- Testé en réel côté backend : Docker SQL Server OK, backend compilé OK, `/health`, `/api/auth/me`, `/api/materiels` OK, 401 sans `x-user-email` OK.
### Étape 27 — Connexion frontend ↔ API (détail matériel)
- Correctif runtime backend : `npm run dev` lance maintenant `ts-node --files`, sinon l'augmentation `Express.Request.user` n'était pas chargée par `ts-node` malgré un build TypeScript vert.
- Endpoint `GET /api/materiels/:id` ajouté : détail filtré par campus utilisateur, avec catégorie, campus et accessoires du kit.
- Frontend : `MaterielItem` porte maintenant l'`id`, `MaterielService.getDetail(id)` consomme le nouvel endpoint, et le catalogue charge le détail réel avant de naviguer vers l'écran détail.
- Testé en réel côté backend : `/api/materiels/1` renvoie le PC avec accessoires (`Chargeur`, `Souris`) ; `/api/materiels/abc` renvoie 400.
- Limite environnement : `flutter analyze`, `flutter analyze --no-pub` et `dart format` restent bloqués jusqu'au timeout dans le sandbox. À relancer dans un terminal Flutter local.
### Étape 28 — Connexion frontend ↔ API (création d'emprunt)
- Branche dédiée `feat/frontend-emprunt-api` créée après merge de `feat/catalogue-detail-api` dans `develop`.
- `ApiClient.post` ajouté et `EmpruntService.creerEmprunt` consomme `POST /api/emprunts` avec l'auth simulée.
- Checklist de départ branchée sur l'API : construction du payload (`materielId`, `posteEmpruntId`, `modeIdentification`, commentaire, éléments), état de chargement, affichage d'erreur via snackbar.
- Un matériel sans accessoire génère un élément de checklist portant le nom du matériel, afin de respecter RG11 côté backend (checklist obligatoire non vide).
- Confirmation alimentée par la réponse API (`id`, `dateEmprunt`, matériel renvoyé).
- Testé sans mutation de données : `POST /api/emprunts` avec checklist vide renvoie bien 400 `La checklist de depart est obligatoire (RG11)`.
### Étape 29 — Données de démonstration catalogue
- Branche dédiée `feat/demo-catalogue-fixtures` créée après merge de `feat/frontend-emprunt-api` dans `develop`.
- `prisma/fixtures.ts` enrichi avec un catalogue de démonstration idempotent : si les fixtures de base existent déjà, le script assure seulement les matériels de catalogue manquants.
- 8 matériels disponibles ajoutés : Lenovo ThinkPad, MacBook Air, Surface Pro, vidéoprojecteur Epson, caméra Canon, casque Jabra, kit câbles HDMI/USB-C, enceinte JBL.
- Accessoires associés assurés sans doublon logique par recherche de nom/référence avant création.
- Vérifié en réel : `npm run fixtures` OK ; `GET /api/materiels` pour Lucas renvoie 10 matériels disponibles.
### Étape 30 — Connexion frontend ↔ API (restitution)
- Branche dédiée `feat/frontend-restitution-api` créée après merge de `feat/demo-catalogue-fixtures` dans `develop`.
- `EmpruntItem.fromJson` ajouté pour mapper `GET /api/mes-emprunts`.
- `EmpruntService.getMesEmprunts()` et `EmpruntService.restituerEmprunt()` ajoutés.
- Écran de sélection restitution branché sur les vrais emprunts en cours, avec chargement, erreur, état vide et chargement du détail matériel avant la checklist.
- Checklist retour branchée sur `POST /api/emprunts/:id/restitution`, avec commentaire optionnel, état de chargement et erreurs via snackbar.
- Le résultat affiché se base sur le statut renvoyé par le backend (`CLOTURE` => conforme, sinon anomalie), afin de rester cohérent avec la comparaison serveur.
- Vérifié sans mutation : `GET /api/mes-emprunts` renvoie les emprunts de Lucas ; restitution d'un identifiant inexistant renvoie 404.
### Étape 31 — Stabilisation parcours étudiant complet
- Branche dédiée `feat/student-flow-stabilization` créée après merge de `feat/frontend-restitution-api` dans `develop`.
- Docker SQL Server relancé puis tests runtime faits avec le backend compilé.
- Flux conforme validé en réel : catalogue -> détail -> création emprunt -> apparition dans `mes-emprunts` -> restitution conforme -> statut `CLOTURE` -> matériel de nouveau visible dans le catalogue.
- Flux non conforme validé en réel : création emprunt -> restitution avec élément absent -> statut `RETOUR_NON_CONFORME` -> matériel retiré du catalogue disponible.
- Aucun correctif code nécessaire après ces tests ; backend `build` et `lint` restent verts.
### Étape 32 — Polish V1 étudiant
- Branche dédiée `feat/v1-student-polish` créée après merge de `feat/student-flow-stabilization` dans `develop`.
- Identité simulée et campus MVP centralisés dans `lib/demo_identity.dart` tant que l'auth Azure AD n'est pas branchée.
- Libellés UI harmonisés sur le contexte réel du MVP : `Saint-Christophe · Cergy` / `Ensitech Cergy` au lieu de `Paris · Ensitech`.
- Commentaire obsolète du modèle `MaterielItem` corrigé : les données viennent maintenant de l'API.
- `TODO.md` mis à jour : les parcours emprunt et restitution étudiant sont maintenant branchés API.
---
*Dernière mise à jour : 2026-07-08 — Parcours étudiant complet testé et libellés V1 harmonisés.*