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 createState() => _ProduitEditScreenState(); } class _ProduitEditScreenState extends State { final _repo = ProduitRepository.instance; final _formKey = GlobalKey(); 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 _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 _changerPhoto() async { FocusScope.of(context).unfocus(); final action = await showModalBottomSheet( 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 _supprimer() async { final ok = await showDialog( 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( 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( 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)), ], ), ), ], ), ); } }