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,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 d’abord 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user