Version initiale de Gestion Prix
App Flutter de gestion de stock/prix pour tablette Android (100% local) : scan code-barres (OpenFoodFacts), prix au kilo/litre, packs, inventaire, prix d'achat + marge, filtres de tri, étiquettes PDF, design noir & blanc. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:printing/printing.dart';
|
||||
|
||||
import '../models/produit.dart';
|
||||
import '../services/etiquette_pdf.dart';
|
||||
import '../services/produit_repository.dart';
|
||||
import '../theme.dart';
|
||||
import '../utils/format.dart';
|
||||
import '../utils/tri.dart';
|
||||
import '../widgets/produit_image.dart';
|
||||
|
||||
/// Onglet 3 : sélection de produits puis génération/impression des étiquettes.
|
||||
class EtiquettesScreen extends StatefulWidget {
|
||||
const EtiquettesScreen({super.key});
|
||||
|
||||
@override
|
||||
State<EtiquettesScreen> createState() => _EtiquettesScreenState();
|
||||
}
|
||||
|
||||
class _EtiquettesScreenState extends State<EtiquettesScreen> {
|
||||
final _repo = ProduitRepository.instance;
|
||||
final Set<String> _selection = {};
|
||||
Tri _tri = Tri.recent;
|
||||
|
||||
bool _tousSelectionnes(List<Produit> produits) =>
|
||||
produits.isNotEmpty && _selection.length == produits.length;
|
||||
|
||||
void _basculerTout(List<Produit> produits) {
|
||||
setState(() {
|
||||
if (_tousSelectionnes(produits)) {
|
||||
_selection.clear();
|
||||
} else {
|
||||
_selection
|
||||
..clear()
|
||||
..addAll(produits.map((p) => p.id));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _imprimer(List<Produit> produits) async {
|
||||
final choisis =
|
||||
produits.where((p) => _selection.contains(p.id)).toList();
|
||||
if (choisis.isEmpty) return;
|
||||
|
||||
await Printing.layoutPdf(
|
||||
name: 'etiquettes_prix',
|
||||
onLayout: (format) => EtiquettePdf.construire(choisis),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: ListenableBuilder(
|
||||
listenable: _repo,
|
||||
builder: (context, _) {
|
||||
final produits = trierProduits(_repo.produits, _tri);
|
||||
// Nettoie la sélection des produits supprimés.
|
||||
_selection.retainWhere((id) => produits.any((p) => p.id == id));
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_entete(produits),
|
||||
Expanded(
|
||||
child: produits.isEmpty
|
||||
? _vide()
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 120),
|
||||
itemCount: produits.length,
|
||||
separatorBuilder: (_, _) =>
|
||||
const SizedBox(height: 8),
|
||||
itemBuilder: (_, i) {
|
||||
final p = produits[i];
|
||||
final sel = _selection.contains(p.id);
|
||||
return _LigneSelection(
|
||||
produit: p,
|
||||
selectionne: sel,
|
||||
onTap: () => setState(() {
|
||||
sel
|
||||
? _selection.remove(p.id)
|
||||
: _selection.add(p.id);
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
floatingActionButton: ListenableBuilder(
|
||||
listenable: _repo,
|
||||
builder: (context, _) {
|
||||
final n = _selection.length;
|
||||
return FloatingActionButton.extended(
|
||||
onPressed: n == 0
|
||||
? null
|
||||
: () => _imprimer(trierProduits(_repo.produits, _tri)),
|
||||
backgroundColor: n == 0 ? Colors.grey : AppTheme.accent,
|
||||
foregroundColor: Colors.white,
|
||||
icon: const Icon(Icons.print),
|
||||
label: Text(n == 0 ? 'Imprimer' : 'Imprimer ($n)'),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _entete(List<Produit> produits) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Étiquettes',
|
||||
style: TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -1)),
|
||||
const SizedBox(height: 4),
|
||||
const Text('Sélectionne les produits à imprimer',
|
||||
style: TextStyle(color: AppTheme.grisTexte, fontSize: 14)),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Text('${_selection.length} sélectionné(s)',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
onPressed:
|
||||
produits.isEmpty ? null : () => _basculerTout(produits),
|
||||
icon: Icon(_tousSelectionnes(produits)
|
||||
? Icons.remove_done
|
||||
: Icons.done_all),
|
||||
label: Text(_tousSelectionnes(produits)
|
||||
? 'Tout désélectionner'
|
||||
: 'Tout sélectionner'),
|
||||
style: TextButton.styleFrom(foregroundColor: AppTheme.accent),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 36,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [for (final t in Tri.values) _puceTri(t)],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _puceTri(Tri t) {
|
||||
final sel = _tri == t;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(t.libelle),
|
||||
selected: sel,
|
||||
onSelected: (_) => setState(() => _tri = t),
|
||||
showCheckmark: false,
|
||||
labelStyle: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: sel ? Colors.white : AppTheme.grisTexte,
|
||||
),
|
||||
selectedColor: AppTheme.accent,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
side: BorderSide(
|
||||
color: sel
|
||||
? AppTheme.accent
|
||||
: Theme.of(context).colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _vide() {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Text(
|
||||
'Ajoute d’abord des produits pour pouvoir imprimer leurs étiquettes.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: AppTheme.grisTexte, fontSize: 15),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LigneSelection extends StatelessWidget {
|
||||
final Produit produit;
|
||||
final bool selectionne;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _LigneSelection({
|
||||
required this.produit,
|
||||
required this.selectionne,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Material(
|
||||
color: selectionne
|
||||
? AppTheme.accent.withValues(alpha: 0.08)
|
||||
: scheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: selectionne
|
||||
? AppTheme.accent
|
||||
: scheme.outlineVariant.withValues(alpha: 0.5),
|
||||
width: selectionne ? 1.6 : 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_Case(coche: selectionne),
|
||||
const SizedBox(width: 12),
|
||||
ProduitImage(url: produit.imageUrl, taille: 44, radius: 10),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(produit.titre,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 15, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(euro(produit.prix),
|
||||
style: const TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w800)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Case extends StatelessWidget {
|
||||
final bool coche;
|
||||
const _Case({required this.coche});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
width: 26,
|
||||
height: 26,
|
||||
decoration: BoxDecoration(
|
||||
color: coche ? AppTheme.accent : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: coche ? AppTheme.accent : AppTheme.grisTexte,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: coche
|
||||
? const Icon(Icons.check, size: 18, color: Colors.white)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'etiquettes_screen.dart';
|
||||
import 'produits_screen.dart';
|
||||
import 'scan_screen.dart';
|
||||
|
||||
/// Coquille principale : contient les 3 onglets et la barre de navigation.
|
||||
class HomeShell extends StatefulWidget {
|
||||
const HomeShell({super.key});
|
||||
|
||||
@override
|
||||
State<HomeShell> createState() => _HomeShellState();
|
||||
}
|
||||
|
||||
class _HomeShellState extends State<HomeShell> {
|
||||
int _index = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pages = [
|
||||
ProduitsScreen(onAllerScanner: () => setState(() => _index = 1)),
|
||||
ScanScreen(active: _index == 1),
|
||||
const EtiquettesScreen(),
|
||||
];
|
||||
return Scaffold(
|
||||
body: IndexedStack(index: _index, children: pages),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _index,
|
||||
height: 68,
|
||||
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
|
||||
onDestinationSelected: (i) => setState(() => _index = i),
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.inventory_2_outlined),
|
||||
selectedIcon: Icon(Icons.inventory_2),
|
||||
label: 'Produits',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.qr_code_scanner_outlined),
|
||||
selectedIcon: Icon(Icons.qr_code_scanner),
|
||||
label: 'Scanner',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.print_outlined),
|
||||
selectedIcon: Icon(Icons.print),
|
||||
label: 'Étiquettes',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,703 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import '../models/produit.dart';
|
||||
import '../services/photo_service.dart';
|
||||
import '../services/produit_repository.dart';
|
||||
import '../services/reglages.dart';
|
||||
import '../theme.dart';
|
||||
import '../widgets/produit_image.dart';
|
||||
|
||||
/// Écran d'ajout / modification d'un produit.
|
||||
///
|
||||
/// - [produit] fourni → mode modification.
|
||||
/// - sinon → mode création (les champs *Initial servent au scan).
|
||||
class ProduitEditScreen extends StatefulWidget {
|
||||
final Produit? produit;
|
||||
final String? codeBarresInitial;
|
||||
final String? nomInitial;
|
||||
final String? imageUrlInitial;
|
||||
final double? quantiteInitial;
|
||||
final String? uniteInitial;
|
||||
|
||||
const ProduitEditScreen({
|
||||
super.key,
|
||||
this.produit,
|
||||
this.codeBarresInitial,
|
||||
this.nomInitial,
|
||||
this.imageUrlInitial,
|
||||
this.quantiteInitial,
|
||||
this.uniteInitial,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ProduitEditScreen> createState() => _ProduitEditScreenState();
|
||||
}
|
||||
|
||||
class _ProduitEditScreenState extends State<ProduitEditScreen> {
|
||||
final _repo = ProduitRepository.instance;
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
late final TextEditingController _nom;
|
||||
late final TextEditingController _prix;
|
||||
late final TextEditingController _prixAchat;
|
||||
late final TextEditingController _quantite;
|
||||
late final TextEditingController _nbContenants;
|
||||
late final TextEditingController _stock;
|
||||
|
||||
String? _codeBarres;
|
||||
String? _imageUrl;
|
||||
late String _unite;
|
||||
bool _estPack = false;
|
||||
bool _enregistre = false;
|
||||
|
||||
bool get _modification => widget.produit != null;
|
||||
|
||||
/// Produit scanné mais absent d'OpenFoodFacts (nom à saisir à la main).
|
||||
bool get _scanNonTrouve =>
|
||||
widget.produit == null &&
|
||||
widget.codeBarresInitial != null &&
|
||||
(widget.nomInitial == null || widget.nomInitial!.trim().isEmpty);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final p = widget.produit;
|
||||
_nom = TextEditingController(text: p?.nom ?? widget.nomInitial ?? '');
|
||||
_prix = TextEditingController(text: p != null ? _fmt(p.prix) : '');
|
||||
_prixAchat = TextEditingController(
|
||||
text: (p != null && p.prixAchat > 0) ? _fmt(p.prixAchat) : '');
|
||||
final q = p?.quantite ?? widget.quantiteInitial;
|
||||
_quantite = TextEditingController(
|
||||
text: (q != null && q > 0) ? _fmtQuantite(q) : '');
|
||||
_estPack = p?.estPack ?? false;
|
||||
_nbContenants = TextEditingController(
|
||||
text: (p != null && p.estPack) ? p.nbContenants.toString() : '');
|
||||
_stock = TextEditingController(text: (p?.stock ?? 0).toString());
|
||||
// Unité : celle du produit si valide, sinon celle du scan, sinon 'g' par défaut.
|
||||
_unite = Unites.estValide(p?.unite)
|
||||
? p!.unite!
|
||||
: (Unites.estValide(widget.uniteInitial) ? widget.uniteInitial! : Unites.g);
|
||||
_codeBarres = p?.codeBarres ?? widget.codeBarresInitial;
|
||||
_imageUrl = p?.imageUrl ?? widget.imageUrlInitial;
|
||||
|
||||
// Nouveau produit sans unité connue → on re-propose la dernière unité utilisée.
|
||||
if (p == null && !Unites.estValide(widget.uniteInitial)) {
|
||||
Reglages.instance.derniereUnite().then((u) {
|
||||
if (mounted && _quantite.text.isEmpty && Unites.estValide(u)) {
|
||||
setState(() => _unite = u);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nom.dispose();
|
||||
_prix.dispose();
|
||||
_prixAchat.dispose();
|
||||
_quantite.dispose();
|
||||
_nbContenants.dispose();
|
||||
_stock.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _fmtQuantite(double v) =>
|
||||
v == v.roundToDouble() ? v.toInt().toString() : v.toString();
|
||||
|
||||
String _fmt(double v) =>
|
||||
v.toStringAsFixed(2).replaceAll('.', ',');
|
||||
|
||||
double _parse(String s) =>
|
||||
double.tryParse(s.trim().replaceAll(',', '.')) ?? 0;
|
||||
|
||||
Future<void> _enregistrer() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() => _enregistre = true);
|
||||
|
||||
final base = widget.produit;
|
||||
final q = _parse(_quantite.text);
|
||||
final nb = _estPack ? (int.tryParse(_nbContenants.text.trim()) ?? 0) : 1;
|
||||
final produit = Produit(
|
||||
id: base?.id ?? 'temp',
|
||||
codeBarres: _codeBarres,
|
||||
nom: _nom.text.trim(),
|
||||
imageUrl: _imageUrl,
|
||||
prix: _parse(_prix.text),
|
||||
prixAchat: _parse(_prixAchat.text),
|
||||
quantite: q > 0 ? q : null,
|
||||
unite: _unite,
|
||||
nbContenants: (_estPack && nb > 1) ? nb : null,
|
||||
creeLe: base?.creeLe ?? 0,
|
||||
stock: int.tryParse(_stock.text.trim()) ?? 0,
|
||||
);
|
||||
|
||||
try {
|
||||
if (_modification) {
|
||||
await _repo.modifier(produit);
|
||||
} else {
|
||||
await _repo.ajouter(produit);
|
||||
}
|
||||
// Mémorise l'unité pour accélérer la saisie du produit suivant.
|
||||
await Reglages.instance.memoriserUnite(produit.unite ?? Unites.piece);
|
||||
if (mounted) Navigator.of(context).pop(true);
|
||||
} catch (e) {
|
||||
setState(() => _enregistre = false);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Erreur : $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _changerPhoto() async {
|
||||
FocusScope.of(context).unfocus();
|
||||
final action = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_camera, color: AppTheme.accent),
|
||||
title: const Text('Prendre une photo'),
|
||||
onTap: () => Navigator.pop(ctx, 'camera'),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_library, color: AppTheme.accent),
|
||||
title: const Text('Choisir dans la galerie'),
|
||||
onTap: () => Navigator.pop(ctx, 'gallery'),
|
||||
),
|
||||
if (_imageUrl != null)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete_outline, color: Colors.red),
|
||||
title: const Text('Supprimer la photo'),
|
||||
onTap: () => Navigator.pop(ctx, 'remove'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (action == null) return;
|
||||
if (action == 'remove') {
|
||||
setState(() => _imageUrl = null);
|
||||
return;
|
||||
}
|
||||
final source =
|
||||
action == 'camera' ? ImageSource.camera : ImageSource.gallery;
|
||||
try {
|
||||
final chemin = await PhotoService.choisir(source);
|
||||
if (chemin != null && mounted) setState(() => _imageUrl = chemin);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Photo impossible : $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _supprimer() async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: const Text('Supprimer ce produit ?'),
|
||||
content: Text('« ${widget.produit!.nom} » sera retiré de la liste.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Annuler')),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: Colors.red),
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Supprimer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) {
|
||||
await _repo.supprimer(widget.produit!.id);
|
||||
if (mounted) Navigator.of(context).pop(true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_modification ? 'Modifier' : 'Nouveau produit'),
|
||||
actions: [
|
||||
if (_modification)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
color: Colors.red,
|
||||
onPressed: _supprimer,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
children: [
|
||||
Center(
|
||||
child: GestureDetector(
|
||||
onTap: _changerPhoto,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
ProduitImage(url: _imageUrl, taille: 120, radius: 20),
|
||||
Positioned(
|
||||
right: -6,
|
||||
bottom: -6,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.accent,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
),
|
||||
child: const Icon(Icons.photo_camera,
|
||||
color: Colors.white, size: 18),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Center(
|
||||
child: Text(
|
||||
_imageUrl == null ? 'Ajouter une photo' : 'Changer la photo',
|
||||
style: const TextStyle(
|
||||
color: AppTheme.accent,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13),
|
||||
),
|
||||
),
|
||||
if (_codeBarres != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Center(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.qr_code, size: 16,
|
||||
color: AppTheme.grisTexte),
|
||||
const SizedBox(width: 6),
|
||||
Text(_codeBarres!,
|
||||
style: const TextStyle(
|
||||
color: AppTheme.grisTexte,
|
||||
fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
if (_scanNonTrouve) ...[
|
||||
_banniereNonTrouve(),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
_label('Nom du produit'),
|
||||
TextFormField(
|
||||
controller: _nom,
|
||||
autofocus: !_modification && _nom.text.isEmpty,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: const InputDecoration(hintText: 'Ex : Nutella 400g'),
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().isEmpty) ? 'Nom obligatoire' : null,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_label('Prix d\'achat'),
|
||||
_champPrix(_prixAchat, obligatoire: false),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_label('Prix de vente'),
|
||||
_champPrix(_prix,
|
||||
autofocus: !_modification && _nom.text.isNotEmpty),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
_apercuMarge(),
|
||||
const SizedBox(height: 20),
|
||||
_label('Conditionnement'),
|
||||
_toggleUnitePack(),
|
||||
const SizedBox(height: 20),
|
||||
if (!_estPack)
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [_label('Quantité'), _champQuantite()],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [_label('Unité'), _selecteurUnite()],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [_label('Nombre'), _champNombre()],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [_label('Contenance'), _champQuantite()],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [_label('Unité'), _selecteurUnite()],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
_apercuPrixMesure(),
|
||||
const SizedBox(height: 24),
|
||||
_label('Inventaire (stock)'),
|
||||
_sectionStock(),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: _enregistre ? null : _enregistrer,
|
||||
icon: _enregistre
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white))
|
||||
: const Icon(Icons.check),
|
||||
label: Text(_modification ? 'Enregistrer' : 'Ajouter à la liste'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _label(String t) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8, left: 4),
|
||||
child: Text(t,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600, fontSize: 14)),
|
||||
);
|
||||
|
||||
Widget _banniereNonTrouve() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.accent.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppTheme.accent.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: AppTheme.accent, size: 20),
|
||||
SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Produit non trouvé en ligne. Saisis-le : il sera mémorisé et '
|
||||
'reconnu automatiquement aux prochains scans.',
|
||||
style: TextStyle(fontSize: 13, color: AppTheme.noir),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _champPrix(TextEditingController c,
|
||||
{bool autofocus = false, bool obligatoire = true}) {
|
||||
return TextFormField(
|
||||
controller: c,
|
||||
autofocus: autofocus,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]')),
|
||||
],
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: const InputDecoration(hintText: '0,00', suffixText: '€'),
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) return obligatoire ? 'Requis' : null;
|
||||
if (_parse(v) <= 0) return 'Invalide';
|
||||
return null;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _apercuMarge() {
|
||||
final achat = _parse(_prixAchat.text);
|
||||
final vente = _parse(_prix.text);
|
||||
if (achat <= 0 || vente <= 0) return const SizedBox.shrink();
|
||||
|
||||
final gain = vente - achat;
|
||||
final coef = vente / achat;
|
||||
final positif = coef >= 1;
|
||||
final couleur = positif ? const Color(0xFF1B873F) : Colors.red;
|
||||
final signe = gain >= 0 ? '+' : '';
|
||||
final gainTxt = '$signe${gain.toStringAsFixed(2).replaceAll('.', ',')} €';
|
||||
final apercu = Produit(id: '', nom: '', prix: vente, prixAchat: achat);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 10, left: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(positif ? Icons.trending_up : Icons.trending_down,
|
||||
color: couleur, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text('Marge ${apercu.margeLibelle} ($gainTxt)',
|
||||
style: TextStyle(color: couleur, fontWeight: FontWeight.w700)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _champQuantite() {
|
||||
return TextFormField(
|
||||
controller: _quantite,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]')),
|
||||
],
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: const InputDecoration(hintText: '0'),
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) return 'Requis';
|
||||
if (_parse(v) <= 0) return 'Invalide';
|
||||
return null;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _selecteurUnite() {
|
||||
return DropdownButtonFormField<String>(
|
||||
initialValue: _unite,
|
||||
isExpanded: true,
|
||||
items: [
|
||||
for (final u in Unites.tout)
|
||||
DropdownMenuItem(value: u, child: Text(Unites.libelle(u))),
|
||||
],
|
||||
onChanged: (v) => setState(() {
|
||||
final nouvelle = v ?? Unites.g;
|
||||
_convertirQuantite(_unite, nouvelle);
|
||||
_unite = nouvelle;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Convertit la quantité saisie quand on change d'unité dans la même
|
||||
/// dimension (ex : 1500 ml → L = 1,5 ; 1500 g → kg = 1,5).
|
||||
/// Changement de dimension (poids ↔ volume) : on garde la valeur telle quelle.
|
||||
void _convertirQuantite(String from, String to) {
|
||||
if (from == to) return;
|
||||
final q = _parse(_quantite.text);
|
||||
if (q <= 0) return;
|
||||
|
||||
const poids = {Unites.g: 1.0, Unites.kg: 1000.0}; // base : grammes
|
||||
const volume = {Unites.ml: 1.0, Unites.cl: 10.0, Unites.l: 1000.0}; // base : ml
|
||||
|
||||
double? conv;
|
||||
if (poids.containsKey(from) && poids.containsKey(to)) {
|
||||
conv = q * poids[from]! / poids[to]!;
|
||||
} else if (volume.containsKey(from) && volume.containsKey(to)) {
|
||||
conv = q * volume[from]! / volume[to]!;
|
||||
}
|
||||
if (conv != null) {
|
||||
conv = (conv * 1000).round() / 1000; // évite le bruit de virgule flottante
|
||||
_quantite.text = _fmtQuantite(conv);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _toggleUnitePack() {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: SegmentedButton<bool>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: false,
|
||||
label: Text('Unité'),
|
||||
icon: Icon(Icons.water_drop_outlined)),
|
||||
ButtonSegment(
|
||||
value: true,
|
||||
label: Text('Pack'),
|
||||
icon: Icon(Icons.inventory_2_outlined)),
|
||||
],
|
||||
selected: {_estPack},
|
||||
showSelectedIcon: false,
|
||||
onSelectionChanged: (s) => setState(() {
|
||||
_estPack = s.first;
|
||||
// En passant en Pack, on pré-remplit le nombre de contenants à 6
|
||||
// (valeur courante, modifiable) pour que l'étiquette affiche direct « 6X… ».
|
||||
if (_estPack && _nbContenants.text.trim().isEmpty) {
|
||||
_nbContenants.text = '6';
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _champNombre() {
|
||||
return TextFormField(
|
||||
controller: _nbContenants,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: const InputDecoration(hintText: '6', suffixText: '×'),
|
||||
validator: (v) {
|
||||
if (!_estPack) return null;
|
||||
if ((int.tryParse((v ?? '').trim()) ?? 0) < 2) return '≥ 2';
|
||||
return null;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _ajusterStock(int delta) {
|
||||
final cur = int.tryParse(_stock.text.trim()) ?? 0;
|
||||
final n = (cur + delta).clamp(0, 999999);
|
||||
_stock.text = n.toString();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Widget _sectionStock() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
_btnStock(Icons.remove, () => _ajusterStock(-1)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _stock,
|
||||
textAlign: TextAlign.center,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
onChanged: (_) => setState(() {}),
|
||||
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w800),
|
||||
decoration: const InputDecoration(suffixText: 'en stock'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_btnStock(Icons.add, () => _ajusterStock(1)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [for (final d in [1, 5, 10, 50]) _chipStock(d)],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _btnStock(IconData icon, VoidCallback onTap) {
|
||||
return Material(
|
||||
color: AppTheme.accent.withValues(alpha: 0.12),
|
||||
shape: const CircleBorder(),
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Icon(icon, color: AppTheme.accent, size: 22),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _chipStock(int d) {
|
||||
return ActionChip(
|
||||
label: Text('+$d',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700, color: AppTheme.accent)),
|
||||
onPressed: () => _ajusterStock(d),
|
||||
backgroundColor: AppTheme.accent.withValues(alpha: 0.08),
|
||||
side: BorderSide(color: AppTheme.accent.withValues(alpha: 0.3)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _apercuPrixMesure() {
|
||||
final q = _parse(_quantite.text);
|
||||
final nb = _estPack ? (int.tryParse(_nbContenants.text.trim()) ?? 0) : 1;
|
||||
final apercu = Produit(
|
||||
id: '',
|
||||
nom: _nom.text.trim(),
|
||||
prix: _parse(_prix.text),
|
||||
quantite: q > 0 ? q : null,
|
||||
unite: _unite,
|
||||
nbContenants: (_estPack && nb > 1) ? nb : null,
|
||||
);
|
||||
final mesure = apercu.prixMesure;
|
||||
if (mesure == null || apercu.nom.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
final txt =
|
||||
'${mesure.valeur.toStringAsFixed(2).replaceAll('.', ',')} €${mesure.suffixe}';
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(top: 14),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.accent.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.sell_outlined, size: 18, color: AppTheme.accent),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Sur l’étiquette : ${apercu.titre}',
|
||||
style: const TextStyle(
|
||||
color: AppTheme.noir, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 2),
|
||||
Text('soit $txt',
|
||||
style: const TextStyle(
|
||||
color: AppTheme.accent, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/produit.dart';
|
||||
import '../services/produit_repository.dart';
|
||||
import '../theme.dart';
|
||||
import '../utils/format.dart';
|
||||
import '../utils/tri.dart';
|
||||
import '../widgets/produit_image.dart';
|
||||
import 'produit_edit_screen.dart';
|
||||
|
||||
/// Onglet 1 : liste de tous les produits avec recherche, tri et édition.
|
||||
class ProduitsScreen extends StatefulWidget {
|
||||
/// Appelé par le bouton « Ajouter » pour basculer sur l'onglet Scanner.
|
||||
final VoidCallback onAllerScanner;
|
||||
|
||||
const ProduitsScreen({super.key, required this.onAllerScanner});
|
||||
|
||||
@override
|
||||
State<ProduitsScreen> createState() => _ProduitsScreenState();
|
||||
}
|
||||
|
||||
class _ProduitsScreenState extends State<ProduitsScreen> {
|
||||
final _repo = ProduitRepository.instance;
|
||||
String _recherche = '';
|
||||
Tri _tri = Tri.recent;
|
||||
|
||||
List<Produit> _filtrerEtTrier(List<Produit> tous) {
|
||||
final q = _recherche.trim().toLowerCase();
|
||||
final list = q.isEmpty
|
||||
? tous
|
||||
: tous
|
||||
.where((p) =>
|
||||
p.nom.toLowerCase().contains(q) ||
|
||||
(p.codeBarres?.contains(q) ?? false))
|
||||
.toList();
|
||||
return trierProduits(list, _tri);
|
||||
}
|
||||
|
||||
Future<void> _ouvrir(Produit p) async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => ProduitEditScreen(produit: p)),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: widget.onAllerScanner,
|
||||
backgroundColor: AppTheme.accent,
|
||||
foregroundColor: Colors.white,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Ajouter'),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: ListenableBuilder(
|
||||
listenable: _repo,
|
||||
builder: (context, _) {
|
||||
final produits = _filtrerEtTrier(_repo.produits);
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: _entete(_repo.produits.length)),
|
||||
if (_repo.enCours && _repo.produits.isEmpty)
|
||||
const SliverFillRemaining(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (produits.isEmpty)
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: _vide(),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 96),
|
||||
sliver: SliverList.separated(
|
||||
itemCount: produits.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||
itemBuilder: (_, i) => _ProduitCard(
|
||||
produit: produits[i],
|
||||
onTap: () => _ouvrir(produits[i]),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _entete(int total) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
const Text('Produits',
|
||||
style: TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -1)),
|
||||
const SizedBox(width: 10),
|
||||
Text('$total',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.grisTexte)),
|
||||
const Spacer(),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextField(
|
||||
onChanged: (v) => setState(() => _recherche = v),
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Rechercher un produit…',
|
||||
prefixIcon: Icon(Icons.search),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 36,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
for (final t in Tri.values) _puceTri(t),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _puceTri(Tri t) {
|
||||
final sel = _tri == t;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(t.libelle),
|
||||
selected: sel,
|
||||
onSelected: (_) => setState(() => _tri = t),
|
||||
showCheckmark: false,
|
||||
labelStyle: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: sel ? Colors.white : AppTheme.grisTexte,
|
||||
),
|
||||
selectedColor: AppTheme.accent,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
side: BorderSide(
|
||||
color: sel
|
||||
? AppTheme.accent
|
||||
: Theme.of(context).colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _vide() {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.inventory_2_outlined,
|
||||
size: 64, color: AppTheme.grisTexte),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_recherche.isEmpty
|
||||
? 'Aucun produit pour l’instant.\nScanne un code-barres pour commencer.'
|
||||
: 'Aucun résultat pour « $_recherche ».',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppTheme.grisTexte, fontSize: 15),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProduitCard extends StatelessWidget {
|
||||
final Produit produit;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ProduitCard({required this.produit, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
ProduitImage(url: produit.imageUrl),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
produit.titre,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Row(
|
||||
children: [
|
||||
_PastilleStock(stock: produit.stock),
|
||||
if (produit.codeBarres != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(produit.codeBarres!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5, color: AppTheme.grisTexte)),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(euro(produit.prix),
|
||||
style: const TextStyle(
|
||||
fontSize: 18, fontWeight: FontWeight.w800)),
|
||||
if (produit.prixMesure != null)
|
||||
Text(
|
||||
'${produit.prixMesure!.valeur.toStringAsFixed(2).replaceAll('.', ',')} €${produit.prixMesure!.suffixe}',
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5, color: AppTheme.grisTexte),
|
||||
),
|
||||
if (produit.coefficient != null)
|
||||
Text(
|
||||
produit.margeLibelle,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: produit.coefficient! >= 1
|
||||
? const Color(0xFF1B873F)
|
||||
: Colors.red,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Icon(Icons.chevron_right, color: AppTheme.grisTexte),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pastille indiquant le stock d'un produit (rouge si épuisé).
|
||||
class _PastilleStock extends StatelessWidget {
|
||||
final int stock;
|
||||
const _PastilleStock({required this.stock});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final epuise = stock <= 0;
|
||||
final couleur = epuise ? Colors.red : AppTheme.accent;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: couleur.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.inventory_2_outlined, size: 13, color: couleur),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
epuise ? 'Rupture' : '$stock en stock',
|
||||
style: TextStyle(
|
||||
fontSize: 12, fontWeight: FontWeight.w700, color: couleur),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
|
||||
import '../models/produit.dart';
|
||||
import '../services/openfoodfacts_service.dart';
|
||||
import '../services/produit_repository.dart';
|
||||
import '../theme.dart';
|
||||
import 'produit_edit_screen.dart';
|
||||
|
||||
/// Onglet 2 : scan d'un code-barres → recherche OpenFoodFacts → formulaire.
|
||||
class ScanScreen extends StatefulWidget {
|
||||
final bool active;
|
||||
const ScanScreen({super.key, required this.active});
|
||||
|
||||
@override
|
||||
State<ScanScreen> createState() => _ScanScreenState();
|
||||
}
|
||||
|
||||
class _ScanScreenState extends State<ScanScreen> {
|
||||
final _controller = MobileScannerController(
|
||||
detectionSpeed: DetectionSpeed.noDuplicates,
|
||||
formats: const [
|
||||
BarcodeFormat.ean13,
|
||||
BarcodeFormat.ean8,
|
||||
BarcodeFormat.upcA,
|
||||
BarcodeFormat.upcE,
|
||||
BarcodeFormat.code128,
|
||||
],
|
||||
);
|
||||
final _off = OpenFoodFactsService();
|
||||
final _repo = ProduitRepository.instance;
|
||||
|
||||
bool _traite = false;
|
||||
|
||||
// La caméra est démarrée/arrêtée automatiquement par le widget MobileScanner
|
||||
// quand il est monté/démonté selon l'onglet actif (autoStart). On ne fait
|
||||
// AUCUN start()/stop() manuel ici : ça provoquerait un double démarrage.
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ScanScreen old) {
|
||||
super.didUpdateWidget(old);
|
||||
// En revenant sur l'onglet Scanner, on réautorise la détection.
|
||||
if (widget.active && !old.active) _traite = false;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _onDetect(BarcodeCapture capture) async {
|
||||
if (_traite || !widget.active) return;
|
||||
final code = capture.barcodes.firstOrNull?.rawValue;
|
||||
if (code == null || code.isEmpty) return;
|
||||
|
||||
// On bloque les détections suivantes pendant le traitement (sans couper la caméra).
|
||||
setState(() => _traite = true);
|
||||
|
||||
// Déjà connu ? → on ouvre directement la fiche.
|
||||
final existant = _repo.parCodeBarres(code);
|
||||
if (existant != null) {
|
||||
await _ouvrirExistant(existant);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sinon on interroge OpenFoodFacts (avec un petit loader).
|
||||
final info = await _off.chercherParCodeBarres(code);
|
||||
if (!mounted) return;
|
||||
|
||||
final ajoute = await Navigator.of(context).push<bool>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProduitEditScreen(
|
||||
codeBarresInitial: code,
|
||||
nomInitial: info?.nom,
|
||||
imageUrlInitial: info?.imageUrl,
|
||||
quantiteInitial: info?.quantite,
|
||||
uniteInitial: info?.unite,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() => _traite = false);
|
||||
if (ajoute == true) await _popupAjoute();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _ouvrirExistant(Produit p) async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => ProduitEditScreen(produit: p)),
|
||||
);
|
||||
if (mounted) setState(() => _traite = false);
|
||||
}
|
||||
|
||||
/// Ajout manuel via le bouton « + » (sans scanner).
|
||||
Future<void> _ajouterManuel() async {
|
||||
setState(() => _traite = true); // bloque les détections pendant la saisie
|
||||
final ajoute = await Navigator.of(context).push<bool>(
|
||||
MaterialPageRoute(builder: (_) => const ProduitEditScreen()),
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() => _traite = false);
|
||||
if (ajoute == true) await _popupAjoute();
|
||||
}
|
||||
}
|
||||
|
||||
/// Pop-up de confirmation « Produit ajouté », se ferme tout seul.
|
||||
Future<void> _popupAjoute() async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (ctx) {
|
||||
final nav = Navigator.of(ctx);
|
||||
Future.delayed(const Duration(milliseconds: 1300), () {
|
||||
if (nav.canPop()) nav.pop();
|
||||
});
|
||||
return Dialog(
|
||||
shape:
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.withValues(alpha: 0.12),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.check_circle,
|
||||
color: Colors.green, size: 44),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Produit ajouté',
|
||||
style:
|
||||
TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// La caméra (et donc la demande d'autorisation) n'est construite que
|
||||
// lorsque l'onglet Scanner est réellement affiché.
|
||||
if (!widget.active) {
|
||||
return const ColoredBox(color: Colors.black);
|
||||
}
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: _traite ? null : _ajouterManuel,
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: AppTheme.noir,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Ajout manuel'),
|
||||
),
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
MobileScanner(
|
||||
controller: _controller,
|
||||
onDetect: _onDetect,
|
||||
errorBuilder: (context, error) => _erreurCamera(error),
|
||||
),
|
||||
_cadre(),
|
||||
_bandeauHaut(),
|
||||
if (_traite)
|
||||
Container(
|
||||
color: Colors.black54,
|
||||
child: const Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(color: Colors.white),
|
||||
SizedBox(height: 16),
|
||||
Text('Recherche du produit…',
|
||||
style: TextStyle(color: Colors.white, fontSize: 16)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _bandeauHaut() {
|
||||
return SafeArea(
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(top: 24),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.55),
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
),
|
||||
child: const Text(
|
||||
'Vise le code-barres du produit',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _cadre() {
|
||||
return Center(
|
||||
child: Container(
|
||||
width: 260,
|
||||
height: 170,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppTheme.accent, width: 3),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _erreurCamera(MobileScannerException error) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.no_photography_outlined,
|
||||
color: Colors.white70, size: 56),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Accès à la caméra impossible.\nAutorise la caméra dans les réglages.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.white70, fontSize: 15),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton(
|
||||
onPressed: () => _controller.start(),
|
||||
child: const Text('Réessayer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user