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
+5
View File
@@ -0,0 +1,5 @@
/// Configuration de l'application (stockage 100% local).
class Config {
/// Taux de TVA par défaut utilisé pour calculer le prix TTC à partir du HT.
static const double tvaParDefaut = 0.20; // 20 %
}
+37
View File
@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'screens/home_shell.dart';
import 'services/produit_repository.dart';
import 'theme.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// App verrouillée en portrait (tablette).
await SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
// Chargement initial des produits depuis la base locale.
await ProduitRepository.instance.charger();
runApp(const MonApp());
}
class MonApp extends StatelessWidget {
const MonApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Gestion Prix',
debugShowCheckedModeBanner: false,
theme: AppTheme.clair(),
darkTheme: AppTheme.sombre(),
themeMode: ThemeMode.light,
home: const HomeShell(),
);
}
}
+185
View File
@@ -0,0 +1,185 @@
/// Unités de vente supportées (poids et volume).
class Unites {
static const g = 'g';
static const kg = 'kg';
static const ml = 'ml';
static const cl = 'cl';
static const l = 'L';
/// Ancienne valeur (produits d'avant) : vendu à l'unité. Plus proposée.
static const piece = 'piece';
static const tout = [g, kg, ml, cl, l];
/// Vrai si le code est une unité de mesure valide (donc pas 'piece'/null).
static bool estValide(String? code) => tout.contains(code);
static String libelle(String? code) => switch (code) {
g => 'g',
kg => 'kg',
ml => 'ml',
cl => 'cl',
l => 'L',
_ => '',
};
}
/// Un produit du stock.
class Produit {
final String id;
final String? codeBarres;
final String nom;
final String? imageUrl;
/// Prix de vente (au client).
final double prix;
/// Prix d'achat (coût). 0 = non renseigné.
final double prixAchat;
/// Contenance d'UN contenant (ex : 330 pour 330 ml, 400 pour 400 g).
final double? quantite;
/// Unité de la quantité : voir [Unites].
final String? unite;
/// Nombre de contenants pour un pack (ex : 6 pour un pack de 6 bières).
/// Null ou 1 = produit à l'unité (pas un pack).
final int? nbContenants;
/// Horodatage de création (ms) — sert au tri par ordre d'ajout.
final int creeLe;
/// Quantité en stock (inventaire).
final int stock;
const Produit({
required this.id,
this.codeBarres,
required this.nom,
this.imageUrl,
required this.prix,
this.prixAchat = 0,
this.quantite,
this.unite,
this.nbContenants,
this.creeLe = 0,
this.stock = 0,
});
bool get estPack => (nbContenants ?? 1) > 1;
/// Gain en euros (vente achat). Pertinent seulement si un prix d'achat est saisi.
double get gain => prix - prixAchat;
/// Coefficient multiplicateur (prix de vente ÷ prix d'achat), ex : 1,5.
/// Null si le prix d'achat n'est pas renseigné. Sert au tri.
double? get coefficient => prixAchat > 0 ? prix / prixAchat : null;
/// Marge affichée : le coefficient (1,5) suivi d'un « % », ex : « 1,5 % », « 2 % ».
/// Vide si le prix d'achat n'est pas renseigné.
String get margeLibelle {
final c = coefficient;
if (c == null) return '';
var s = c.toStringAsFixed(2).replaceAll('.', ',');
if (s.contains(',')) {
s = s.replaceAll(RegExp(r'0+$'), '').replaceAll(RegExp(r',$'), '');
}
return '$s %';
}
Produit copyWith({
String? id,
String? codeBarres,
String? nom,
String? imageUrl,
double? prix,
double? prixAchat,
double? quantite,
String? unite,
int? nbContenants,
int? creeLe,
int? stock,
}) {
return Produit(
id: id ?? this.id,
codeBarres: codeBarres ?? this.codeBarres,
nom: nom ?? this.nom,
imageUrl: imageUrl ?? this.imageUrl,
prix: prix ?? this.prix,
prixAchat: prixAchat ?? this.prixAchat,
quantite: quantite ?? this.quantite,
unite: unite ?? this.unite,
nbContenants: nbContenants ?? this.nbContenants,
creeLe: creeLe ?? this.creeLe,
stock: stock ?? this.stock,
);
}
/// Prix à l'unité de mesure (€/kg ou €/L) — obligation légale pour les
/// produits vendus au poids/volume. Null pour les produits à la pièce.
({double valeur, String suffixe})? get prixMesure {
final q = quantite;
if (q == null || q <= 0) return null;
// Pour un pack, on calcule sur le volume/poids TOTAL (contenance × nombre).
final total = q * (nbContenants ?? 1);
return switch (unite) {
Unites.g => (valeur: prix / (total / 1000), suffixe: '/kg'),
Unites.kg => (valeur: prix / total, suffixe: '/kg'),
Unites.ml => (valeur: prix / (total / 1000), suffixe: '/L'),
Unites.cl => (valeur: prix / (total / 100), suffixe: '/L'),
Unites.l => (valeur: prix / total, suffixe: '/L'),
_ => null,
};
}
/// Libellé compact de la quantité, ex : « 330ml », « 1kg », « 75cl »,
/// ou « 6x33cl » pour un pack. Vide si absent.
String get quantiteLibelle {
final q = quantite;
if (q == null || q <= 0 || !Unites.estValide(unite)) return '';
final n = q == q.roundToDouble()
? q.toInt().toString()
: q.toString().replaceAll('.', ',');
final base = '$n${Unites.libelle(unite)}';
return estPack ? '${nbContenants}X$base' : base;
}
/// Titre affiché (liste + étiquette) : la quantité est intégrée devant le nom,
/// ex : « 1kg Farine », « 75cl Coca-Cola ». Sinon juste le nom.
String get titre {
final ql = quantiteLibelle;
return ql.isEmpty ? nom : '$ql $nom';
}
factory Produit.fromMap(Map<String, dynamic> map) {
return Produit(
id: map['id'].toString(),
codeBarres: map['code_barres'] as String?,
nom: (map['nom'] as String?) ?? '',
imageUrl: map['image_url'] as String?,
prix: (map['prix'] as num?)?.toDouble() ?? 0,
prixAchat: (map['prix_achat'] as num?)?.toDouble() ?? 0,
quantite: (map['quantite'] as num?)?.toDouble(),
unite: map['unite'] as String?,
nbContenants: (map['nb_contenants'] as num?)?.toInt(),
creeLe: (map['cree_le'] as num?)?.toInt() ?? 0,
stock: (map['stock'] as num?)?.toInt() ?? 0,
);
}
/// Map pour insertion/mise à jour dans la base (sans l'id, généré par SQLite).
Map<String, dynamic> toInsertMap() {
return {
'code_barres': codeBarres,
'nom': nom,
'image_url': imageUrl,
'prix': prix,
'prix_achat': prixAchat,
'quantite': quantite,
'unite': unite,
'nb_contenants': nbContenants,
'stock': stock,
};
}
}
+276
View File
@@ -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 dabord 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,
);
}
}
+52
View File
@@ -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',
),
],
),
);
}
}
+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)),
],
),
),
],
),
);
}
}
+294
View File
@@ -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 linstant.\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),
),
],
),
);
}
}
+254
View File
@@ -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'),
),
],
),
),
);
}
}
+106
View File
@@ -0,0 +1,106 @@
import 'dart:typed_data';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import '../models/produit.dart';
/// Génère une planche A4 d'étiquettes de prix (nom, prix TTC, prix HT, code-barres),
/// imprimable sur une imprimante classique (ex : Canon TS4550).
class EtiquettePdf {
/// Construit le document PDF pour les [produits] sélectionnés.
static Future<Uint8List> construire(List<Produit> produits) async {
final doc = pw.Document();
doc.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
margin: const pw.EdgeInsets.all(12),
build: (context) {
return [
pw.Wrap(
spacing: 6,
runSpacing: 6,
children: produits.map(_etiquette).toList(),
),
];
},
),
);
return doc.save();
}
static pw.Widget _etiquette(Produit p) {
// 3 colonnes sur A4 (~186mm utiles / 3 ≈ 62mm).
const largeur = 185.0; // points (~65mm)
const hauteur = 110.0; // points (~39mm)
return pw.Container(
width: largeur,
height: hauteur,
padding: const pw.EdgeInsets.all(8),
decoration: pw.BoxDecoration(
border: pw.Border.all(color: PdfColors.grey400, width: 0.7),
borderRadius: pw.BorderRadius.circular(6),
),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(
p.titre,
maxLines: 2,
overflow: pw.TextOverflow.clip,
style: pw.TextStyle(fontSize: 10, fontWeight: pw.FontWeight.bold),
),
pw.Container(
width: double.infinity,
alignment: pw.Alignment.center,
child: pw.Column(
mainAxisSize: pw.MainAxisSize.min,
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
pw.Text(
_prix(p.prix),
style: pw.TextStyle(
fontSize: 22, fontWeight: pw.FontWeight.bold),
),
if (p.prixMesure != null)
pw.Text(
_prixMesure(p),
style: const pw.TextStyle(
fontSize: 9, color: PdfColors.grey700),
),
],
),
),
_codeBarres(p.codeBarres),
],
),
);
}
static pw.Widget _codeBarres(String? code) {
if (code == null || code.isEmpty) {
return pw.SizedBox(height: 22);
}
final estEan13 = code.length == 13 && int.tryParse(code) != null;
return pw.BarcodeWidget(
barcode: estEan13 ? pw.Barcode.ean13() : pw.Barcode.code128(),
data: code,
width: 150,
height: 26,
drawText: true,
textStyle: const pw.TextStyle(fontSize: 6),
);
}
static String _prix(double v) => '${v.toStringAsFixed(2).replaceAll('.', ',')} EUR';
static String _prixMesure(Produit p) {
final m = p.prixMesure!;
final unite = m.suffixe == '/kg' ? 'kg' : 'L';
return 'soit ${m.valeur.toStringAsFixed(2).replaceAll('.', ',')} EUR / $unite';
}
}
+69
View File
@@ -0,0 +1,69 @@
import 'package:sqflite/sqflite.dart';
/// Accès à la base SQLite locale (persistée sur la tablette).
class LocalDb {
LocalDb._();
static final LocalDb instance = LocalDb._();
Database? _db;
Future<Database> get database async {
return _db ??= await _ouvrir();
}
Future<Database> _ouvrir() async {
final dir = await getDatabasesPath();
final chemin = '$dir/gestion_prix.db';
return openDatabase(
chemin,
version: 6,
onCreate: (db, version) async {
await _creerTable(db);
},
onUpgrade: (db, ancienne, nouvelle) async {
if (ancienne < 2) {
// Ancien schéma (prix HT/TTC) → refonte complète.
await db.execute('DROP TABLE IF EXISTS produits');
await _creerTable(db);
}
if (ancienne < 3) {
// Ajout quantité + unité pour le prix au kilo/litre.
await db.execute('ALTER TABLE produits ADD COLUMN quantite REAL');
await db.execute('ALTER TABLE produits ADD COLUMN unite TEXT');
}
if (ancienne < 4) {
// Ajout du nombre de contenants (packs).
await db.execute('ALTER TABLE produits ADD COLUMN nb_contenants INTEGER');
}
if (ancienne < 5) {
// Ajout du stock (inventaire).
await db.execute(
'ALTER TABLE produits ADD COLUMN stock INTEGER NOT NULL DEFAULT 0');
}
if (ancienne < 6) {
// Ajout du prix d'achat (marge).
await db.execute(
'ALTER TABLE produits ADD COLUMN prix_achat REAL NOT NULL DEFAULT 0');
}
},
);
}
Future<void> _creerTable(Database db) async {
await db.execute('''
CREATE TABLE produits(
id INTEGER PRIMARY KEY AUTOINCREMENT,
code_barres TEXT UNIQUE,
nom TEXT NOT NULL,
image_url TEXT,
prix REAL NOT NULL DEFAULT 0,
prix_achat REAL NOT NULL DEFAULT 0,
quantite REAL,
unite TEXT,
nb_contenants INTEGER,
stock INTEGER NOT NULL DEFAULT 0,
cree_le INTEGER NOT NULL DEFAULT 0
)
''');
}
}
+84
View File
@@ -0,0 +1,84 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/produit.dart';
/// Résultat d'une recherche produit sur OpenFoodFacts.
class InfoProduit {
final String? nom;
final String? imageUrl;
final String? marque;
final double? quantite;
final String? unite;
const InfoProduit({
this.nom,
this.imageUrl,
this.marque,
this.quantite,
this.unite,
});
bool get estVide => (nom == null || nom!.isEmpty) && imageUrl == null;
}
/// Interroge l'API publique OpenFoodFacts pour récupérer le nom, l'image et la
/// quantité (grammage / litrage) d'un produit à partir de son code-barres.
class OpenFoodFactsService {
static const String _base = 'https://world.openfoodfacts.org/api/v2/product';
Future<InfoProduit?> chercherParCodeBarres(String codeBarres) async {
final uri = Uri.parse(
'$_base/$codeBarres.json?fields=product_name,product_name_fr,brands,'
'image_front_url,image_url,product_quantity,product_quantity_unit',
);
try {
final res = await http
.get(uri, headers: {'User-Agent': 'GestionPrixProduit/1.0'})
.timeout(const Duration(seconds: 8));
if (res.statusCode != 200) return null;
final data = jsonDecode(res.body) as Map<String, dynamic>;
// status == 1 => produit trouvé
if (data['status'] != 1) return const InfoProduit();
final p = data['product'] as Map<String, dynamic>? ?? {};
final nom = (p['product_name_fr'] as String?)?.trim();
final nomEn = (p['product_name'] as String?)?.trim();
final image = (p['image_front_url'] as String?)?.trim() ??
(p['image_url'] as String?)?.trim();
final (quantite, unite) = _quantite(p);
return InfoProduit(
nom: (nom != null && nom.isNotEmpty) ? nom : nomEn,
imageUrl: (image != null && image.isNotEmpty) ? image : null,
marque: (p['brands'] as String?)?.split(',').first.trim(),
quantite: quantite,
unite: unite,
);
} catch (_) {
// Pas de réseau ou timeout : on retourne null, l'utilisateur saisit à la main.
return null;
}
}
/// Normalise la quantité OpenFoodFacts (product_quantity + unité) vers nos unités.
(double?, String?) _quantite(Map<String, dynamic> p) {
final q = (p['product_quantity'] as num?)?.toDouble();
final u = (p['product_quantity_unit'] as String?)?.toLowerCase().trim();
if (q == null || q <= 0 || u == null) return (null, null);
return switch (u) {
'g' => (q, Unites.g),
'kg' => (q, Unites.kg),
'mg' => (q / 1000, Unites.g),
'ml' => (q, Unites.ml),
'cl' => (q, Unites.cl),
'l' => (q, Unites.l),
_ => (null, null),
};
}
}
+33
View File
@@ -0,0 +1,33 @@
import 'dart:io';
import 'package:image_picker/image_picker.dart';
import 'package:path_provider/path_provider.dart';
/// Prise et stockage local des photos de produits.
class PhotoService {
static final _picker = ImagePicker();
/// Ouvre l'appareil photo ([ImageSource.camera]) ou la galerie, copie l'image
/// choisie dans le stockage de l'app et retourne son chemin local (ou null si annulé).
static Future<String?> choisir(ImageSource source) async {
final x = await _picker.pickImage(
source: source,
maxWidth: 1200,
imageQuality: 82,
);
if (x == null) return null;
final dir = await getApplicationDocumentsDirectory();
final dossier = Directory('${dir.path}/photos');
if (!await dossier.exists()) await dossier.create(recursive: true);
final nom = 'produit_${DateTime.now().millisecondsSinceEpoch}.jpg';
final dest = '${dossier.path}/$nom';
await File(x.path).copy(dest);
return dest;
}
/// Vrai si l'url pointe vers un fichier local (photo prise), pas vers le web.
static bool estLocale(String? url) =>
url != null && url.isNotEmpty && !url.startsWith('http');
}
+91
View File
@@ -0,0 +1,91 @@
import 'package:flutter/foundation.dart';
import 'package:sqflite/sqflite.dart';
import '../models/produit.dart';
import 'local_db.dart';
/// Source de vérité des produits, partagée par tous les écrans.
/// Persistée localement dans une base SQLite (aucun serveur, fonctionne hors-ligne).
class ProduitRepository extends ChangeNotifier {
ProduitRepository._();
static final ProduitRepository instance = ProduitRepository._();
final List<Produit> _produits = [];
bool _charge = false;
bool _enCours = false;
String? _erreur;
List<Produit> get produits => List.unmodifiable(_produits);
bool get charge => _charge;
bool get enCours => _enCours;
String? get erreur => _erreur;
Future<Database> get _db async => LocalDb.instance.database;
Future<void> charger() async {
_enCours = true;
_erreur = null;
notifyListeners();
try {
final db = await _db;
final rows = await db.query('produits', orderBy: 'nom COLLATE NOCASE ASC');
_produits
..clear()
..addAll(rows.map(Produit.fromMap));
_charge = true;
} catch (e) {
_erreur = e.toString();
} finally {
_enCours = false;
notifyListeners();
}
}
Produit? parCodeBarres(String code) {
for (final p in _produits) {
if (p.codeBarres == code) return p;
}
return null;
}
Future<Produit> ajouter(Produit p) async {
final db = await _db;
final ts = DateTime.now().millisecondsSinceEpoch;
final data = p.toInsertMap()..['cree_le'] = ts;
final id = await db.insert(
'produits',
data,
conflictAlgorithm: ConflictAlgorithm.replace,
);
final cree = p.copyWith(id: id.toString(), creeLe: ts);
_produits.add(cree);
_trier();
notifyListeners();
return cree;
}
Future<void> modifier(Produit p) async {
final db = await _db;
await db.update(
'produits',
p.toInsertMap(),
where: 'id = ?',
whereArgs: [int.tryParse(p.id) ?? p.id],
);
final i = _produits.indexWhere((e) => e.id == p.id);
if (i != -1) _produits[i] = p;
_trier();
notifyListeners();
}
Future<void> supprimer(String id) async {
final db = await _db;
await db.delete('produits', where: 'id = ?', whereArgs: [int.tryParse(id) ?? id]);
_produits.removeWhere((e) => e.id == id);
notifyListeners();
}
void _trier() => _produits
.sort((a, b) => a.nom.toLowerCase().compareTo(b.nom.toLowerCase()));
}
+27
View File
@@ -0,0 +1,27 @@
import 'package:shared_preferences/shared_preferences.dart';
import '../models/produit.dart';
/// Petits réglages persistés (préférences utilisateur) pour accélérer la saisie.
class Reglages {
Reglages._();
static final Reglages instance = Reglages._();
static const _cleDerniereUnite = 'derniere_unite';
SharedPreferences? _prefs;
Future<SharedPreferences> get _p async =>
_prefs ??= await SharedPreferences.getInstance();
/// Dernière unité choisie lors d'un ajout (pour la re-proposer par défaut).
Future<String> derniereUnite() async {
final p = await _p;
return p.getString(_cleDerniereUnite) ?? Unites.g;
}
Future<void> memoriserUnite(String unite) async {
final p = await _p;
await p.setString(_cleDerniereUnite, unite);
}
}
+116
View File
@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
/// Thème noir & blanc épuré, avec une seule couleur d'accent.
/// Change [accent] pour ajuster la touche de couleur de toute l'app.
class AppTheme {
static const Color accent = Color(0xFF2F6FED); // bleu moderne
static const Color noir = Color(0xFF111114);
static const Color grisTexte = Color(0xFF6B6B72);
static ThemeData clair() {
final scheme = ColorScheme.fromSeed(
seedColor: accent,
brightness: Brightness.light,
).copyWith(
primary: noir,
onPrimary: Colors.white,
secondary: accent,
surface: Colors.white,
onSurface: noir,
);
return _base(scheme, const Color(0xFFF6F6F7));
}
static ThemeData sombre() {
final scheme = ColorScheme.fromSeed(
seedColor: accent,
brightness: Brightness.dark,
).copyWith(
primary: Colors.white,
onPrimary: noir,
secondary: accent,
);
return _base(scheme, const Color(0xFF0E0E11));
}
static ThemeData _base(ColorScheme scheme, Color fond) {
final base = ThemeData(
useMaterial3: true,
colorScheme: scheme,
scaffoldBackgroundColor: fond,
fontFamily: 'Roboto',
);
return base.copyWith(
appBarTheme: AppBarTheme(
backgroundColor: fond,
foregroundColor: scheme.onSurface,
elevation: 0,
centerTitle: false,
titleTextStyle: TextStyle(
color: scheme.onSurface,
fontSize: 24,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
),
),
cardTheme: CardThemeData(
elevation: 0,
color: scheme.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.5)),
),
margin: EdgeInsets.zero,
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
minimumSize: const Size.fromHeight(52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: scheme.surface,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: scheme.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: scheme.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: accent, width: 2),
),
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: scheme.surface,
indicatorColor: accent.withValues(alpha: 0.14),
elevation: 0,
labelTextStyle: WidgetStateProperty.resolveWith((states) {
final selected = states.contains(WidgetState.selected);
return TextStyle(
fontSize: 12,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
color: selected ? scheme.onSurface : grisTexte,
);
}),
iconTheme: WidgetStateProperty.resolveWith((states) {
final selected = states.contains(WidgetState.selected);
return IconThemeData(
color: selected ? accent : grisTexte,
);
}),
),
);
}
}
+5
View File
@@ -0,0 +1,5 @@
import 'package:intl/intl.dart';
final NumberFormat _euro = NumberFormat.currency(locale: 'fr_FR', symbol: '');
String euro(double v) => _euro.format(v);
+41
View File
@@ -0,0 +1,41 @@
import '../models/produit.dart';
/// Critères de tri partagés (page Produits et page Étiquettes).
enum Tri {
recent('Récents'),
alpha('A → Z'),
prixAsc('Prix ↑'),
prixDesc('Prix ↓'),
margeDesc('Marge ↓'),
margeAsc('Marge ↑');
const Tri(this.libelle);
final String libelle;
}
/// Retourne une copie triée de [source] selon [tri].
List<Produit> trierProduits(List<Produit> source, Tri tri) {
final list = List<Produit>.of(source);
switch (tri) {
case Tri.recent:
// Plus récemment ajouté en premier (date d'ajout, puis id).
list.sort((a, b) {
final c = b.creeLe.compareTo(a.creeLe);
if (c != 0) return c;
return (int.tryParse(b.id) ?? 0).compareTo(int.tryParse(a.id) ?? 0);
});
case Tri.alpha:
list.sort((a, b) => a.nom.toLowerCase().compareTo(b.nom.toLowerCase()));
case Tri.prixAsc:
list.sort((a, b) => a.prix.compareTo(b.prix));
case Tri.prixDesc:
list.sort((a, b) => b.prix.compareTo(a.prix));
case Tri.margeDesc:
list.sort(
(a, b) => (b.coefficient ?? -1e18).compareTo(a.coefficient ?? -1e18));
case Tri.margeAsc:
list.sort(
(a, b) => (a.coefficient ?? 1e18).compareTo(b.coefficient ?? 1e18));
}
return list;
}
+62
View File
@@ -0,0 +1,62 @@
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import '../services/photo_service.dart';
/// Vignette d'image produit : photo locale, image web (OpenFoodFacts) ou icône par défaut.
class ProduitImage extends StatelessWidget {
final String? url;
final double taille;
final double radius;
const ProduitImage({
super.key,
required this.url,
this.taille = 52,
this.radius = 12,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return ClipRRect(
borderRadius: BorderRadius.circular(radius),
child: Container(
width: taille,
height: taille,
color: scheme.surfaceContainerHighest,
child: _contenu(scheme),
),
);
}
Widget _contenu(ColorScheme scheme) {
if (url == null || url!.isEmpty) {
return Icon(Icons.image_outlined,
color: scheme.onSurfaceVariant, size: taille * 0.42);
}
if (PhotoService.estLocale(url)) {
return Image.file(
File(url!),
fit: BoxFit.cover,
errorBuilder: (_, _, _) => Icon(Icons.broken_image_outlined,
color: scheme.onSurfaceVariant, size: taille * 0.42),
);
}
return CachedNetworkImage(
imageUrl: url!,
fit: BoxFit.cover,
placeholder: (_, _) => const Center(
child: SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
errorWidget: (_, _, _) => Icon(Icons.broken_image_outlined,
color: scheme.onSurfaceVariant, size: taille * 0.42),
);
}
}