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
+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);
}
}