4d936d2ee3
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>
277 lines
8.5 KiB
Dart
277 lines
8.5 KiB
Dart
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,
|
||
);
|
||
}
|
||
}
|