feat(materiel): connect catalogue and detail to api
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,7 @@ packages:
|
||||
source: hosted
|
||||
version: "8.1.0"
|
||||
http:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user