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