Files
Mathew 4d936d2ee3 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>
2026-07-17 21:28:33 +02:00

704 lines
24 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)),
],
),
),
],
),
);
}
}