Initial commit — app fidélité magasin (tablette)

App Flutter de programme de fidélité : login staff, clients (nom/prénom),
scan QR, factures (€→points), récompenses, réglages. Backend Supabase
(schéma SQL + RLS anti-triche dans supabase/). Icône incluse.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 21:37:06 +02:00
commit 1d3ee69c6d
60 changed files with 4468 additions and 0 deletions
+220
View File
@@ -0,0 +1,220 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/recompense.dart';
import '../services/recompense_repository.dart';
import '../theme.dart';
import '../utils/format.dart';
/// Onglet 3 : catalogue des récompenses (ce qu'un client peut obtenir avec ses
/// points). Créer / modifier / activer / supprimer.
class RecompensesScreen extends StatefulWidget {
const RecompensesScreen({super.key});
@override
State<RecompensesScreen> createState() => _RecompensesScreenState();
}
class _RecompensesScreenState extends State<RecompensesScreen> {
final _repo = RecompenseRepository.instance;
Future<void> _editer([Recompense? existante]) async {
final nomCtrl = TextEditingController(text: existante?.nom ?? '');
final coutCtrl = TextEditingController(
text: existante == null ? '' : points(existante.coutPoints)
.replaceAll(RegExp(r'\s?pts?$'), ''));
final formKey = GlobalKey<FormState>();
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(existante == null ? 'Nouvelle récompense' : 'Modifier'),
content: Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: nomCtrl,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Nom (ex : Café offert)',
),
validator: (v) =>
(v == null || v.trim().isEmpty) ? 'Nom obligatoire' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: coutCtrl,
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]')),
],
decoration: const InputDecoration(
labelText: 'Coût en points',
suffixText: 'pts',
),
validator: (v) {
final n = double.tryParse((v ?? '').replaceAll(',', '.'));
if (n == null || n <= 0) return 'Coût invalide';
return null;
},
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () {
if (formKey.currentState!.validate()) Navigator.of(ctx).pop(true);
},
child: const Text('Enregistrer'),
),
],
),
);
if (ok != true) return;
final cout = double.parse(coutCtrl.text.replaceAll(',', '.'));
if (existante == null) {
await _repo.creer(nom: nomCtrl.text, coutPoints: cout);
} else {
await _repo.modifier(
existante.copyWith(nom: nomCtrl.text.trim(), coutPoints: cout));
}
}
Future<void> _supprimer(Recompense r) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Supprimer ?'),
content: Text('Supprimer la récompense « ${r.nom} » ?'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Annuler'),
),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: Colors.red),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Supprimer'),
),
],
),
);
if (ok == true) await _repo.supprimer(r.id);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Récompenses')),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _editer(),
icon: const Icon(Icons.add),
label: const Text('Récompense'),
),
body: AnimatedBuilder(
animation: _repo,
builder: (context, _) {
final liste = _repo.recompenses;
if (liste.isEmpty) return _vide();
return ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 96),
itemCount: liste.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (context, i) => _carte(liste[i]),
);
},
),
);
}
Widget _carte(Recompense r) {
return Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 6, 6, 6),
child: Row(
children: [
CircleAvatar(
backgroundColor: r.actif
? AppTheme.accent.withValues(alpha: 0.14)
: Colors.grey.withValues(alpha: 0.14),
child: Icon(Icons.card_giftcard,
color: r.actif ? AppTheme.accent : Colors.grey),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(r.nom,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: r.actif ? null : AppTheme.grisTexte,
)),
const SizedBox(height: 2),
Text(points(r.coutPoints),
style: const TextStyle(
color: AppTheme.grisTexte, fontSize: 13)),
],
),
),
Switch(
value: r.actif,
onChanged: (v) => _repo.modifier(r.copyWith(actif: v)),
),
PopupMenuButton<String>(
onSelected: (v) {
if (v == 'modifier') _editer(r);
if (v == 'supprimer') _supprimer(r);
},
itemBuilder: (_) => const [
PopupMenuItem(value: 'modifier', child: Text('Modifier')),
PopupMenuItem(value: 'supprimer', child: Text('Supprimer')),
],
),
],
),
),
);
}
Widget _vide() {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.card_giftcard_outlined,
size: 64, color: AppTheme.grisTexte),
const SizedBox(height: 16),
const Text('Aucune récompense',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
const SizedBox(height: 8),
const Text(
'Créez des récompenses (ex : « Café offert » = 10 pts) que les clients pourront obtenir avec leurs points.',
textAlign: TextAlign.center,
style: TextStyle(color: AppTheme.grisTexte),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () => _editer(),
icon: const Icon(Icons.add),
label: const Text('Créer une récompense'),
),
],
),
),
);
}
}