feat(frontend): module Articles complet (liste, création, modification, suppression)

- Article model (MP/PSF/PF, stock, seuil alerte)
- ArticleService : fetchAll, fetchByType, create, update, delete
- ArticleProvider : filtrage par type + recherche temps réel
- ArticlesScreen : liste, chips filtres, barre recherche, alertes stock
- ArticleFormScreen : formulaire création/édition avec validation
- Rapport PFE : section 5.6 module Articles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nabil Derouiche 2026-04-20 20:23:06 +01:00
parent 601a7d0373
commit ed21c3cb80
7 changed files with 915 additions and 1 deletions

View File

@ -555,3 +555,63 @@ Le drawer affiche le rôle de l'utilisateur connecté et met en surbrillance la
--- ---
*[Section à compléter avec captures d'écran lors de la finalisation du rapport]* *[Section à compléter avec captures d'écran lors de la finalisation du rapport]*
---
## 5.6 Module Articles
### Objectif
Le module Articles permet de gérer le **catalogue complet** des références de l'entreprise, organisé en trois catégories correspondant au cycle industriel de SUARL Rayhan :
| Type | Code | Description |
|------|------|-------------|
| Matière Première | MP | Granulés PEHD/LDPE, colorants, additifs |
| Produit Semi-Fini | PSF | Film tubulaire non découpé |
| Produit Fini | PF | Sacs Bertel, sacs poubelles, film rétractable |
### Fonctionnalités
- **Liste filtrée** : affichage de tous les articles avec filtre par type (MP / PSF / PF) et barre de recherche en temps réel
- **Indicateur d'alerte** : les articles dont le stock est inférieur au seuil minimum sont signalés en rouge avec icône d'avertissement
- **Création** : formulaire complet (référence, désignation, type, unité de mesure, prix unitaire, stock initial, seuil d'alerte)
- **Modification** : édition de tous les champs sauf la référence (clé métier immuable)
- **Archivage** : suppression logique (l'article est marqué `actif=false`, pas effacé de la base)
### Architecture technique
```
ArticleService (Dio) ArticleProvider (ChangeNotifier)
├── fetchAll() ─────► ├── _articles: List<Article>
├── fetchByType() ├── _filterType: String
├── create() ├── _search: String
├── update() └── articles (getter filtré)
└── delete()
ArticlesScreen
├── _SearchBar
├── _FilterChips (TOUS / MP / PSF / PF)
└── ListView → _ArticleCard
└── PopupMenu (Modifier / Supprimer)
ArticleFormScreen (création + édition)
├── Section Identification (ref, désignation, type)
├── Section Unité & Prix
└── Section Stock (actuel + seuil minimum)
```
### Modèle de données
```dart
class Article {
int? id;
String reference; // Clé unique (ex: MP-001)
String designation; // Libellé
String type; // MP | PSF | PF
String? uniteMesure; // kg, unité, rouleau, m
double prixUnitaire; // En TND
double stockActuel; // Quantité en stock
double stockMinimum; // Seuil d'alerte
bool actif; // Soft delete
}
```

View File

@ -4,8 +4,10 @@ import 'package:go_router/go_router.dart';
import 'providers/auth_provider.dart'; import 'providers/auth_provider.dart';
import 'providers/dashboard_provider.dart'; import 'providers/dashboard_provider.dart';
import 'providers/article_provider.dart';
import 'screens/login_screen.dart'; import 'screens/login_screen.dart';
import 'screens/dashboard_screen.dart'; import 'screens/dashboard_screen.dart';
import 'screens/articles_screen.dart';
import 'widgets/app_drawer.dart'; import 'widgets/app_drawer.dart';
void main() { void main() {
@ -14,6 +16,7 @@ void main() {
providers: [ providers: [
ChangeNotifierProvider(create: (_) => AuthProvider()), ChangeNotifierProvider(create: (_) => AuthProvider()),
ChangeNotifierProvider(create: (_) => DashboardProvider()), ChangeNotifierProvider(create: (_) => DashboardProvider()),
ChangeNotifierProvider(create: (_) => ArticleProvider()),
], ],
child: const RayhanApp(), child: const RayhanApp(),
), ),
@ -39,7 +42,7 @@ class RayhanApp extends StatelessWidget {
routes: [ routes: [
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()), GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
GoRoute(path: '/dashboard', builder: (_, __) => const DashboardScreen()), GoRoute(path: '/dashboard', builder: (_, __) => const DashboardScreen()),
GoRoute(path: '/articles', builder: (_, __) => const _PlaceholderScreen(title: 'Articles', route: '/articles')), GoRoute(path: '/articles', builder: (_, __) => const ArticlesScreen()),
GoRoute(path: '/ventes', builder: (_, __) => const _PlaceholderScreen(title: 'Ventes', route: '/ventes')), GoRoute(path: '/ventes', builder: (_, __) => const _PlaceholderScreen(title: 'Ventes', route: '/ventes')),
GoRoute(path: '/achats', builder: (_, __) => const _PlaceholderScreen(title: 'Achats', route: '/achats')), GoRoute(path: '/achats', builder: (_, __) => const _PlaceholderScreen(title: 'Achats', route: '/achats')),
GoRoute(path: '/production', builder: (_, __) => const _PlaceholderScreen(title: 'Production', route: '/production')), GoRoute(path: '/production', builder: (_, __) => const _PlaceholderScreen(title: 'Production', route: '/production')),

View File

@ -0,0 +1,57 @@
class Article {
final int? id;
final String reference;
final String designation;
final String type; // MP, PSF, PF
final String? uniteMesure;
final double prixUnitaire;
final double stockActuel;
final double stockMinimum;
final bool actif;
Article({
this.id,
required this.reference,
required this.designation,
required this.type,
this.uniteMesure,
this.prixUnitaire = 0,
this.stockActuel = 0,
this.stockMinimum = 0,
this.actif = true,
});
factory Article.fromJson(Map<String, dynamic> json) => Article(
id: json['id'],
reference: json['reference'] ?? '',
designation: json['designation'] ?? '',
type: json['type'] ?? 'MP',
uniteMesure: json['uniteMesure'],
prixUnitaire: (json['prixUnitaire'] ?? 0).toDouble(),
stockActuel: (json['stockActuel'] ?? 0).toDouble(),
stockMinimum: (json['stockMinimum'] ?? 0).toDouble(),
actif: json['actif'] ?? true,
);
Map<String, dynamic> toJson() => {
if (id != null) 'id': id,
'reference': reference,
'designation': designation,
'type': type,
'uniteMesure': uniteMesure,
'prixUnitaire': prixUnitaire,
'stockActuel': stockActuel,
'stockMinimum': stockMinimum,
'actif': actif,
};
bool get enAlerte => stockActuel <= stockMinimum;
static const Map<String, String> typeLabels = {
'MP': 'Matière Première',
'PSF': 'Produit Semi-Fini',
'PF': 'Produit Fini',
};
String get typeLabel => typeLabels[type] ?? type;
}

View File

@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import '../models/article.dart';
import '../services/article_service.dart';
class ArticleProvider extends ChangeNotifier {
List<Article> _articles = [];
bool _isLoading = false;
String? _error;
String _filterType = 'TOUS';
String _search = '';
List<Article> get articles => _filtered();
bool get isLoading => _isLoading;
String? get error => _error;
String get filterType => _filterType;
List<Article> _filtered() {
return _articles.where((a) {
final matchType = _filterType == 'TOUS' || a.type == _filterType;
final matchSearch = _search.isEmpty ||
a.reference.toLowerCase().contains(_search.toLowerCase()) ||
a.designation.toLowerCase().contains(_search.toLowerCase());
return matchType && matchSearch;
}).toList();
}
void setFilter(String type) {
_filterType = type;
notifyListeners();
}
void setSearch(String q) {
_search = q;
notifyListeners();
}
Future<void> load() async {
_isLoading = true;
_error = null;
notifyListeners();
try {
_articles = await ArticleService.fetchAll();
} catch (_) {
_error = 'Impossible de charger les articles.';
} finally {
_isLoading = false;
notifyListeners();
}
}
Future<bool> create(Article article) async {
try {
final created = await ArticleService.create(article);
_articles.add(created);
notifyListeners();
return true;
} catch (_) {
return false;
}
}
Future<bool> update(int id, Article article) async {
try {
final updated = await ArticleService.update(id, article);
final idx = _articles.indexWhere((a) => a.id == id);
if (idx != -1) _articles[idx] = updated;
notifyListeners();
return true;
} catch (_) {
return false;
}
}
Future<bool> delete(int id) async {
try {
await ArticleService.delete(id);
_articles.removeWhere((a) => a.id == id);
notifyListeners();
return true;
} catch (_) {
return false;
}
}
}

View File

@ -0,0 +1,299 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/article.dart';
import '../providers/article_provider.dart';
class ArticleFormScreen extends StatefulWidget {
final Article? article;
const ArticleFormScreen({super.key, this.article});
@override
State<ArticleFormScreen> createState() => _ArticleFormScreenState();
}
class _ArticleFormScreenState extends State<ArticleFormScreen> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _refCtrl;
late final TextEditingController _desCtrl;
late final TextEditingController _uniteCtrl;
late final TextEditingController _prixCtrl;
late final TextEditingController _stockMinCtrl;
late final TextEditingController _stockActuelCtrl;
late String _type;
bool _saving = false;
bool get _isEdit => widget.article != null;
@override
void initState() {
super.initState();
final a = widget.article;
_refCtrl = TextEditingController(text: a?.reference ?? '');
_desCtrl = TextEditingController(text: a?.designation ?? '');
_uniteCtrl = TextEditingController(text: a?.uniteMesure ?? '');
_prixCtrl = TextEditingController(
text: a != null ? a.prixUnitaire.toString() : '');
_stockMinCtrl = TextEditingController(
text: a != null ? a.stockMinimum.toString() : '');
_stockActuelCtrl = TextEditingController(
text: a != null ? a.stockActuel.toString() : '0');
_type = a?.type ?? 'MP';
}
@override
void dispose() {
_refCtrl.dispose();
_desCtrl.dispose();
_uniteCtrl.dispose();
_prixCtrl.dispose();
_stockMinCtrl.dispose();
_stockActuelCtrl.dispose();
super.dispose();
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _saving = true);
final article = Article(
id: widget.article?.id,
reference: _refCtrl.text.trim(),
designation: _desCtrl.text.trim(),
type: _type,
uniteMesure: _uniteCtrl.text.trim().isEmpty ? null : _uniteCtrl.text.trim(),
prixUnitaire: double.tryParse(_prixCtrl.text) ?? 0,
stockMinimum: double.tryParse(_stockMinCtrl.text) ?? 0,
stockActuel: double.tryParse(_stockActuelCtrl.text) ?? 0,
);
final provider = context.read<ArticleProvider>();
final ok = _isEdit
? await provider.update(article.id!, article)
: await provider.create(article);
if (mounted) {
setState(() => _saving = false);
if (ok) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(_isEdit ? 'Article modifié' : 'Article créé'),
backgroundColor: Colors.green,
));
} else {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Erreur — vérifiez les champs (référence déjà utilisée ?)'),
backgroundColor: Colors.red,
));
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F7FA),
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
title: Text(_isEdit ? 'Modifier l\'article' : 'Nouvel article',
style: const TextStyle(fontWeight: FontWeight.bold)),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Card(
title: 'Identification',
children: [
_Field(
label: 'Référence',
controller: _refCtrl,
hint: 'Ex: MP-001',
enabled: !_isEdit,
validator: (v) => v == null || v.trim().isEmpty
? 'Obligatoire'
: null,
),
const SizedBox(height: 12),
_Field(
label: 'Désignation',
controller: _desCtrl,
hint: 'Ex: Granulés PEHD',
validator: (v) => v == null || v.trim().isEmpty
? 'Obligatoire'
: null,
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
value: _type,
decoration: _inputDeco('Type d\'article'),
items: const [
DropdownMenuItem(value: 'MP', child: Text('Matière Première (MP)')),
DropdownMenuItem(value: 'PSF', child: Text('Produit Semi-Fini (PSF)')),
DropdownMenuItem(value: 'PF', child: Text('Produit Fini (PF)')),
],
onChanged: (v) => setState(() => _type = v!),
),
],
),
const SizedBox(height: 16),
_Card(
title: 'Unité & Prix',
children: [
_Field(
label: 'Unité de mesure',
controller: _uniteCtrl,
hint: 'kg, unité, rouleau, m…',
),
const SizedBox(height: 12),
_Field(
label: 'Prix unitaire (TND)',
controller: _prixCtrl,
hint: '0.000',
keyboardType: TextInputType.number,
validator: (v) {
if (v == null || v.isEmpty) return null;
if (double.tryParse(v) == null) return 'Nombre invalide';
return null;
},
),
],
),
const SizedBox(height: 16),
_Card(
title: 'Stock',
children: [
_Field(
label: 'Stock actuel',
controller: _stockActuelCtrl,
hint: '0',
keyboardType: TextInputType.number,
validator: (v) {
if (v == null || v.isEmpty) return null;
if (double.tryParse(v) == null) return 'Nombre invalide';
return null;
},
),
const SizedBox(height: 12),
_Field(
label: 'Seuil minimum (alerte)',
controller: _stockMinCtrl,
hint: '0',
keyboardType: TextInputType.number,
validator: (v) {
if (v == null || v.isEmpty) return null;
if (double.tryParse(v) == null) return 'Nombre invalide';
return null;
},
),
],
),
const SizedBox(height: 24),
SizedBox(
height: 52,
child: ElevatedButton(
onPressed: _saving ? null : _save,
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10)),
),
child: _saving
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white))
: Text(
_isEdit ? 'Enregistrer les modifications' : 'Créer l\'article',
style: const TextStyle(
fontSize: 15, fontWeight: FontWeight.w600),
),
),
),
const SizedBox(height: 32),
],
),
),
),
);
}
}
class _Card extends StatelessWidget {
final String title;
final List<Widget> children;
const _Card({required this.title, required this.children});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 8,
offset: const Offset(0, 2)),
],
),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: const TextStyle(
fontWeight: FontWeight.w700,
fontSize: 13,
color: Color(0xFF6B7280))),
const SizedBox(height: 12),
...children,
],
),
);
}
}
class _Field extends StatelessWidget {
final String label;
final TextEditingController controller;
final String? hint;
final bool enabled;
final TextInputType? keyboardType;
final String? Function(String?)? validator;
const _Field({
required this.label,
required this.controller,
this.hint,
this.enabled = true,
this.keyboardType,
this.validator,
});
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
enabled: enabled,
keyboardType: keyboardType,
decoration: _inputDeco(label).copyWith(hintText: hint),
validator: validator,
);
}
}
InputDecoration _inputDeco(String label) => InputDecoration(
labelText: label,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
filled: true,
fillColor: const Color(0xFFF8F9FA),
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
);

View File

@ -0,0 +1,383 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../providers/article_provider.dart';
import '../models/article.dart';
import '../widgets/app_drawer.dart';
import 'article_form_screen.dart';
class ArticlesScreen extends StatefulWidget {
const ArticlesScreen({super.key});
@override
State<ArticlesScreen> createState() => _ArticlesScreenState();
}
class _ArticlesScreenState extends State<ArticlesScreen> {
final _searchController = TextEditingController();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<ArticleProvider>().load();
});
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final provider = context.watch<ArticleProvider>();
return Scaffold(
backgroundColor: const Color(0xFFF5F7FA),
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
title: const Text('Articles',
style: TextStyle(fontWeight: FontWeight.bold)),
actions: [
IconButton(
icon: const Icon(Icons.refresh_outlined),
onPressed: () => context.read<ArticleProvider>().load(),
),
],
),
drawer: const AppDrawer(currentRoute: '/articles'),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _openForm(context),
icon: const Icon(Icons.add),
label: const Text('Nouvel article'),
),
body: Column(
children: [
_SearchBar(controller: _searchController, provider: provider),
_FilterChips(provider: provider),
Expanded(child: _ArticleList(provider: provider)),
],
),
);
}
void _openForm(BuildContext context, [Article? article]) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ArticleFormScreen(article: article),
),
);
}
}
class _SearchBar extends StatelessWidget {
final TextEditingController controller;
final ArticleProvider provider;
const _SearchBar({required this.controller, required this.provider});
@override
Widget build(BuildContext context) {
return Container(
color: Colors.white,
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: TextField(
controller: controller,
onChanged: provider.setSearch,
decoration: InputDecoration(
hintText: 'Rechercher par référence ou désignation…',
prefixIcon: const Icon(Icons.search, size: 20),
suffixIcon: controller.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, size: 18),
onPressed: () {
controller.clear();
provider.setSearch('');
},
)
: null,
filled: true,
fillColor: const Color(0xFFF5F7FA),
contentPadding: const EdgeInsets.symmetric(vertical: 0, horizontal: 16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
),
),
);
}
}
class _FilterChips extends StatelessWidget {
final ArticleProvider provider;
const _FilterChips({required this.provider});
static const filters = [
('TOUS', 'Tous'),
('MP', 'Matières Premières'),
('PSF', 'Semi-Finis'),
('PF', 'Produits Finis'),
];
@override
Widget build(BuildContext context) {
return Container(
color: Colors.white,
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: filters.map((f) {
final selected = provider.filterType == f.$1;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
label: Text(f.$2),
selected: selected,
onSelected: (_) => provider.setFilter(f.$1),
selectedColor:
Theme.of(context).colorScheme.primary.withOpacity(0.15),
checkmarkColor: Theme.of(context).colorScheme.primary,
labelStyle: TextStyle(
fontSize: 12,
color: selected
? Theme.of(context).colorScheme.primary
: Colors.grey[700],
fontWeight:
selected ? FontWeight.w600 : FontWeight.normal,
),
),
);
}).toList(),
),
),
);
}
}
class _ArticleList extends StatelessWidget {
final ArticleProvider provider;
const _ArticleList({required this.provider});
@override
Widget build(BuildContext context) {
if (provider.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (provider.error != null) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, size: 48, color: Colors.grey[400]),
const SizedBox(height: 12),
Text(provider.error!, style: TextStyle(color: Colors.grey[600])),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => context.read<ArticleProvider>().load(),
child: const Text('Réessayer'),
),
],
),
);
}
if (provider.articles.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.inventory_2_outlined, size: 64, color: Colors.grey[300]),
const SizedBox(height: 16),
Text('Aucun article trouvé',
style: TextStyle(color: Colors.grey[500], fontSize: 16)),
],
),
);
}
return RefreshIndicator(
onRefresh: () => context.read<ArticleProvider>().load(),
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: provider.articles.length,
itemBuilder: (ctx, i) =>
_ArticleCard(article: provider.articles[i]),
),
);
}
}
class _ArticleCard extends StatelessWidget {
final Article article;
const _ArticleCard({required this.article});
static const typeColors = {
'MP': Color(0xFF3B82F6),
'PSF': Color(0xFF8B5CF6),
'PF': Color(0xFF10B981),
};
@override
Widget build(BuildContext context) {
final color = typeColors[article.type] ?? Colors.grey;
final priceFmt = NumberFormat.currency(
locale: 'fr_TN', symbol: 'TND', decimalDigits: 3);
return Container(
margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 8,
offset: const Offset(0, 2)),
],
border: article.enAlerte
? Border.all(color: Colors.red[300]!, width: 1.5)
: null,
),
child: ListTile(
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
leading: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: color.withOpacity(0.12),
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: Text(
article.type,
style: TextStyle(
color: color,
fontWeight: FontWeight.bold,
fontSize: 12),
),
),
),
title: Row(
children: [
Expanded(
child: Text(
article.designation,
style: const TextStyle(
fontWeight: FontWeight.w600, fontSize: 14),
),
),
if (article.enAlerte)
const Icon(Icons.warning_amber,
color: Colors.orange, size: 16),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text('Réf: ${article.reference}',
style: TextStyle(color: Colors.grey[600], fontSize: 12)),
const SizedBox(height: 2),
Row(
children: [
Text(
'Stock: ${article.stockActuel} ${article.uniteMesure ?? ''}',
style: TextStyle(
fontSize: 12,
color: article.enAlerte
? Colors.red[600]
: Colors.grey[600],
fontWeight: article.enAlerte
? FontWeight.w600
: FontWeight.normal,
),
),
const SizedBox(width: 12),
Text(
priceFmt.format(article.prixUnitaire),
style: const TextStyle(
fontSize: 12, color: Color(0xFF374151)),
),
],
),
],
),
trailing: PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, color: Colors.grey),
onSelected: (val) => _onAction(context, val),
itemBuilder: (_) => [
const PopupMenuItem(
value: 'edit',
child: Row(children: [
Icon(Icons.edit_outlined, size: 18),
SizedBox(width: 8),
Text('Modifier'),
])),
const PopupMenuItem(
value: 'delete',
child: Row(children: [
Icon(Icons.delete_outline, size: 18, color: Colors.red),
SizedBox(width: 8),
Text('Supprimer',
style: TextStyle(color: Colors.red)),
])),
],
),
),
);
}
void _onAction(BuildContext context, String action) {
if (action == 'edit') {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ArticleFormScreen(article: article)),
);
} else if (action == 'delete') {
_confirmDelete(context);
}
}
void _confirmDelete(BuildContext context) {
showDialog(
context: context,
builder: (_) => AlertDialog(
title: const Text('Supprimer l\'article ?'),
content: Text(
'Voulez-vous vraiment archiver "${article.designation}" ?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler')),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
onPressed: () async {
Navigator.pop(context);
final ok = await context
.read<ArticleProvider>()
.delete(article.id!);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(ok
? 'Article archivé'
: 'Erreur lors de la suppression'),
backgroundColor: ok ? Colors.green : Colors.red,
));
}
},
child: const Text('Supprimer',
style: TextStyle(color: Colors.white)),
),
],
),
);
}
}

View File

@ -0,0 +1,28 @@
import 'api_client.dart';
import '../models/article.dart';
class ArticleService {
static Future<List<Article>> fetchAll() async {
final res = await ApiClient.instance.get('/articles');
return (res.data as List).map((e) => Article.fromJson(e)).toList();
}
static Future<List<Article>> fetchByType(String type) async {
final res = await ApiClient.instance.get('/articles/type/$type');
return (res.data as List).map((e) => Article.fromJson(e)).toList();
}
static Future<Article> create(Article article) async {
final res = await ApiClient.instance.post('/articles', data: article.toJson());
return Article.fromJson(res.data);
}
static Future<Article> update(int id, Article article) async {
final res = await ApiClient.instance.put('/articles/$id', data: article.toJson());
return Article.fromJson(res.data);
}
static Future<void> delete(int id) async {
await ApiClient.instance.delete('/articles/$id');
}
}