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:
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
''');
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user