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:
2026-07-17 21:28:33 +02:00
commit 4d936d2ee3
53 changed files with 3852 additions and 0 deletions
+703
View File
@@ -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)),
],
),
),
],
),
);
}
}