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