feat(materiel): connect catalogue and detail to api

This commit is contained in:
SaidSoighiri94
2026-07-07 13:07:47 +02:00
parent 90d5a65f29
commit 9564dd1ade
15 changed files with 368 additions and 101 deletions
@@ -4,6 +4,7 @@ import "package:flutter/material.dart";
/// (données statiques pour l'instant ; sera alimenté par l'API plus tard).
class MaterielItem {
const MaterielItem({
required this.id,
required this.nom,
required this.categorie,
required this.marque,
@@ -16,6 +17,28 @@ class MaterielItem {
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 ? "Saint-Christophe · Cergy" : "${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;
@@ -27,3 +50,23 @@ class MaterielItem {
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;
}
}
+146 -93
View File
@@ -2,12 +2,14 @@ import "package:flutter/material.dart";
import "package:google_fonts/google_fonts.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 de matériels.
/// Catalogue matériel : recherche, filtres par catégorie et liste chargée
/// depuis l'API.
class CatalogueScreen extends StatefulWidget {
const CatalogueScreen({super.key});
@@ -15,82 +17,62 @@ class CatalogueScreen extends StatefulWidget {
State<CatalogueScreen> createState() => _CatalogueScreenState();
}
const _materiels = <MaterielItem>[
MaterielItem(
nom: "PC Dell Latitude 5420",
categorie: "Ordinateur",
marque: "Dell",
modele: "Latitude 5420",
reference: "ENS-PC-001",
campus: "Paris · Ensitech",
etatGeneral: "Bon état",
disponible: true,
accessoires: ["Chargeur 65W", "Souris sans fil", "Housse de protection", "Câble HDMI"],
icon: Icons.laptop_mac,
),
MaterielItem(
nom: "Vidéoprojecteur Epson EB-X51",
categorie: "Projection",
marque: "Epson",
modele: "EB-X51",
reference: "ENS-VP-003",
campus: "Paris · Ensitech",
etatGeneral: "Bon état",
disponible: true,
accessoires: ["Télécommande", "Câble HDMI", "Câble VGA"],
icon: Icons.videocam_outlined,
),
MaterielItem(
nom: "Chargeur USB-C 65W",
categorie: "Accessoire",
marque: "Anker",
modele: "PowerPort III",
reference: "ENS-CH-012",
campus: "Paris · Ensitech",
etatGeneral: "Bon état",
disponible: false,
accessoires: [],
icon: Icons.power_outlined,
),
MaterielItem(
nom: "PC Lenovo ThinkPad E14",
categorie: "Ordinateur",
marque: "Lenovo",
modele: "ThinkPad E14",
reference: "ENS-PC-004",
campus: "Paris · Ensitech",
etatGeneral: "Bon état",
disponible: true,
accessoires: ["Chargeur 65W", "Souris sans fil", "Housse de protection"],
icon: Icons.laptop_mac,
),
];
const _categories = ["Tous", "Ordinateurs", "Projection", "Câbles", "Accessoires"];
class _CatalogueScreenState extends State<CatalogueScreen> {
late Future<List<MaterielItem>> _future;
String _recherche = "";
String _categorie = "Tous";
int? _selectionEnCoursId;
List<MaterielItem> get _resultats {
@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) {
return materiels.where((materiel) {
final matchTexte = terme.isEmpty || materiel.nom.toLowerCase().contains(terme);
final matchCategorie = _categorie == "Tous" || _categorie.startsWith(materiel.categorie);
final matchCategorie = _categorie == "Tous" || materiel.categorie == _categorie;
return matchTexte && matchCategorie;
}).toList();
}
void _selectionner(BuildContext context, MaterielItem item) {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => DetailScreen(item: item)),
);
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) {
final resultats = _resultats;
return Scaffold(
backgroundColor: EnsupColors.soft,
body: Stack(
@@ -131,34 +113,23 @@ class _CatalogueScreenState extends State<CatalogueScreen> {
onChanged: (value) => setState(() => _recherche = value),
),
const SizedBox(height: 14),
Wrap(
spacing: 8,
runSpacing: 8,
children: _categories.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
? _EmptyState()
: ListView.separated(
itemCount: resultats.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (context, index) {
final item = resultats[index];
return MaterielCard(
item: item,
onSelect: () => _selectionner(context, item),
);
},
),
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);
},
),
),
],
),
@@ -174,6 +145,53 @@ class _CatalogueScreenState extends State<CatalogueScreen> {
),
);
}
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 {
@@ -235,7 +253,7 @@ class _CategoryChip extends StatelessWidget {
child: Text(
label,
style: GoogleFonts.darkerGrotesque(
fontSize: 14,
fontSize: 13,
fontWeight: FontWeight.w600,
color: selected ? Colors.white : EnsupColors.muted,
),
@@ -246,14 +264,16 @@ class _CategoryChip extends StatelessWidget {
}
class _EmptyState extends StatelessWidget {
const _EmptyState();
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.search_off, size: 40, color: EnsupColors.muted),
const SizedBox(height: 12),
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),
@@ -263,3 +283,36 @@ class _EmptyState extends StatelessWidget {
);
}
}
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),
),
),
],
),
);
}
}
@@ -8,6 +8,7 @@ import "../widgets/brand_corners.dart";
import "checklist_retour_screen.dart";
const _pcDell = MaterielItem(
id: 0,
nom: "PC Dell Latitude 5420",
categorie: "Ordinateur",
marque: "Dell",
+54
View File
@@ -0,0 +1,54 @@
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 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,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);
}
}
+1 -1
View File
@@ -108,7 +108,7 @@ packages:
source: hosted
version: "8.1.0"
http:
dependency: transitive
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
+1
View File
@@ -35,6 +35,7 @@ dependencies:
# 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: