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
+623
View File
@@ -0,0 +1,623 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/client.dart';
import '../models/mouvement.dart';
import '../models/recompense.dart';
import '../services/client_repository.dart';
import '../services/recompense_repository.dart';
import '../services/reglages.dart';
import '../theme.dart';
import '../utils/format.dart';
import '../widgets/qr_client.dart';
import 'client_edit_screen.dart';
/// Fiche d'un client : solde de points, ajout de facture, utilisation de
/// récompenses, QR code et historique des mouvements.
class ClientDetailScreen extends StatefulWidget {
final String clientId;
const ClientDetailScreen({super.key, required this.clientId});
@override
State<ClientDetailScreen> createState() => _ClientDetailScreenState();
}
class _ClientDetailScreenState extends State<ClientDetailScreen> {
final _repo = ClientRepository.instance;
List<Mouvement> _mouvements = [];
bool _chargeMouvements = true;
@override
void initState() {
super.initState();
_rechargerMouvements();
}
Future<void> _rechargerMouvements() async {
final m = await _repo.mouvements(widget.clientId);
if (mounted) {
setState(() {
_mouvements = m;
_chargeMouvements = false;
});
}
}
// ---------------------------------------------------------------------------
// Actions
// ---------------------------------------------------------------------------
Future<void> _ajouterFacture() async {
final montant = await _demanderMontant();
if (montant == null) return;
final gagnes =
await _repo.ajouterFacture(clientId: widget.clientId, montantEuros: montant);
await _rechargerMouvements();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Facture ${euro(montant)} • +${points(gagnes)}'),
backgroundColor: AppTheme.accent,
),
);
}
}
Future<double?> _demanderMontant({
double? initial,
String titre = 'Ajouter une facture',
String bouton = 'Valider',
}) {
final ctrl = TextEditingController(
text: initial == null
? ''
: (initial == initial.roundToDouble()
? initial.toInt().toString()
: initial.toString())
.replaceAll('.', ','),
);
return showDialog<double>(
context: context,
builder: (ctx) {
String? erreur;
return StatefulBuilder(
builder: (ctx, setDialog) {
double? apercu() {
final v = double.tryParse(ctrl.text.replaceAll(',', '.'));
return v == null ? null : Reglages.instance.pointsPour(v);
}
final pts = apercu();
return AlertDialog(
title: Text(titre),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: ctrl,
autofocus: true,
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]')),
],
onChanged: (_) => setDialog(() => erreur = null),
decoration: InputDecoration(
labelText: 'Montant dépensé',
suffixText: '',
errorText: erreur,
),
),
const SizedBox(height: 12),
Text(
pts != null && pts > 0
? 'Rapportera ${points(pts)}'
: 'Saisissez le montant de la facture',
style: const TextStyle(
color: AppTheme.accent, fontWeight: FontWeight.w600),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () {
final v = double.tryParse(ctrl.text.replaceAll(',', '.'));
if (v == null || v <= 0) {
setDialog(() => erreur = 'Montant invalide');
return;
}
Navigator.of(ctx).pop(v);
},
child: Text(bouton),
),
],
);
},
);
},
);
}
Future<void> _utiliserRecompense(Client client) async {
final actives = RecompenseRepository.instance.actives;
if (actives.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Aucune récompense. Créez-en dans l\'onglet Récompenses.')),
);
return;
}
final choisie = await showModalBottomSheet<Recompense>(
context: context,
showDragHandle: true,
builder: (ctx) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Text('Utiliser une récompense',
style:
TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
),
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: actives.length,
itemBuilder: (ctx, i) {
final r = actives[i];
final possible = client.points >= r.coutPoints;
return ListTile(
enabled: possible,
leading: CircleAvatar(
backgroundColor: possible
? AppTheme.accent.withValues(alpha: 0.14)
: Colors.grey.withValues(alpha: 0.14),
child: Icon(Icons.card_giftcard,
color: possible ? AppTheme.accent : Colors.grey),
),
title: Text(r.nom),
subtitle: Text(points(r.coutPoints)),
trailing: possible
? const Icon(Icons.chevron_right)
: Text('Manque ${points(r.coutPoints - client.points)}',
style: const TextStyle(
fontSize: 12, color: AppTheme.grisTexte)),
onTap:
possible ? () => Navigator.of(ctx).pop(r) : null,
);
},
),
),
const SizedBox(height: 8),
],
),
);
},
);
if (choisie == null) return;
final confirme = await _confirmer(
titre: 'Confirmer la récompense',
message:
'Utiliser « ${choisie.nom} » pour ${points(choisie.coutPoints)} ?',
libelleOk: 'Confirmer',
);
if (confirme != true) return;
try {
await _repo.utiliserRecompense(
clientId: widget.clientId, recompense: choisie);
await _rechargerMouvements();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${choisie.nom}${points(choisie.coutPoints)}'),
backgroundColor: AppTheme.accent,
),
);
}
} on SoldeInsuffisant catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(e.toString())));
}
}
}
Future<void> _corrigerPoints(Client client) async {
final ctrl = TextEditingController();
final delta = await showDialog<double>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Corriger les points'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Ajoutez (+) ou retirez () des points manuellement.',
style: TextStyle(fontSize: 13, color: AppTheme.grisTexte),
),
const SizedBox(height: 12),
TextField(
controller: ctrl,
autofocus: true,
keyboardType: const TextInputType.numberWithOptions(
decimal: true, signed: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[0-9.,-]')),
],
decoration: const InputDecoration(
labelText: 'Points (ex : 5 ou -2)',
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () {
final v = double.tryParse(ctrl.text.replaceAll(',', '.'));
if (v == null || v == 0) return;
Navigator.of(ctx).pop(v);
},
child: const Text('Appliquer'),
),
],
),
);
if (delta == null) return;
await _repo.ajuster(
clientId: widget.clientId,
points: delta,
libelle: 'Correction manuelle',
);
await _rechargerMouvements();
}
Future<void> _modifier(Client client) async {
await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ClientEditScreen(client: client)),
);
if (mounted) setState(() {});
}
Future<void> _supprimer(Client client) async {
final ok = await _confirmer(
titre: 'Supprimer ce client ?',
message:
'« ${client.nomComplet} » et tout son historique seront supprimés définitivement.',
libelleOk: 'Supprimer',
danger: true,
);
if (ok != true) return;
await _repo.supprimer(widget.clientId);
if (mounted) Navigator.of(context).pop();
}
Future<bool?> _confirmer({
required String titre,
required String message,
required String libelleOk,
bool danger = false,
}) {
return showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(titre),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Annuler'),
),
FilledButton(
style: danger
? FilledButton.styleFrom(backgroundColor: Colors.red)
: null,
onPressed: () => Navigator.of(ctx).pop(true),
child: Text(libelleOk),
),
],
),
);
}
// ---------------------------------------------------------------------------
// UI
// ---------------------------------------------------------------------------
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _repo,
builder: (context, _) {
final client = _repo.parId(widget.clientId);
// Le client a pu être supprimé (pop en cours).
if (client == null) {
return const Scaffold(body: SizedBox.shrink());
}
return Scaffold(
appBar: AppBar(
title: Text(client.nomComplet),
actions: [
IconButton(
tooltip: 'QR code',
icon: const Icon(Icons.qr_code_2),
onPressed: () => afficherQrClient(context, client),
),
PopupMenuButton<String>(
onSelected: (v) {
switch (v) {
case 'modifier':
_modifier(client);
case 'corriger':
_corrigerPoints(client);
case 'supprimer':
_supprimer(client);
}
},
itemBuilder: (_) => const [
PopupMenuItem(value: 'modifier', child: Text('Modifier')),
PopupMenuItem(
value: 'corriger', child: Text('Corriger les points')),
PopupMenuItem(value: 'supprimer', child: Text('Supprimer')),
],
),
],
),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [
_enteteSolde(client),
const SizedBox(height: 16),
_boutonsActions(client),
const SizedBox(height: 24),
const Text('Historique',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
const SizedBox(height: 8),
_historique(),
],
),
);
},
);
}
Widget _enteteSolde(Client client) {
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16),
child: Column(
children: [
const Text('Solde de points',
style: TextStyle(color: AppTheme.grisTexte, fontSize: 14)),
const SizedBox(height: 6),
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
const Icon(Icons.stars_rounded,
color: AppTheme.accent, size: 34),
const SizedBox(width: 8),
Text(
pointsNombre(client.points),
style: const TextStyle(
fontSize: 44,
fontWeight: FontWeight.w800,
color: AppTheme.accent),
),
const SizedBox(width: 6),
const Padding(
padding: EdgeInsets.only(bottom: 6),
child: Text('pts',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppTheme.accent)),
),
],
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.badge_outlined,
size: 15, color: AppTheme.grisTexte),
const SizedBox(width: 6),
Text(client.code,
style: const TextStyle(
color: AppTheme.grisTexte,
fontWeight: FontWeight.w600,
letterSpacing: 1)),
if (client.telephone?.isNotEmpty == true) ...[
const SizedBox(width: 12),
const Icon(Icons.phone,
size: 15, color: AppTheme.grisTexte),
const SizedBox(width: 6),
Text(client.telephone!,
style: const TextStyle(color: AppTheme.grisTexte)),
],
],
),
],
),
),
);
}
Widget _boutonsActions(Client client) {
return Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: _ajouterFacture,
icon: const Icon(Icons.receipt_long),
label: const Text('Ajouter\nune facture', textAlign: TextAlign.center),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: AppTheme.accent,
foregroundColor: Colors.white,
),
onPressed: () => _utiliserRecompense(client),
icon: const Icon(Icons.card_giftcard),
label: const Text('Utiliser une\nrécompense',
textAlign: TextAlign.center),
),
),
],
);
}
Widget _historique() {
if (_chargeMouvements) {
return const Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator()),
);
}
if (_mouvements.isEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(
child: Text('Aucun mouvement pour l\'instant.',
style: TextStyle(color: AppTheme.grisTexte)),
),
);
}
return Column(
children: [
for (final m in _mouvements) _ligneMouvement(m),
],
);
}
Widget _ligneMouvement(Mouvement m) {
final gain = m.estGain;
final couleur = gain ? AppTheme.accent : Colors.red.shade600;
final icone = switch (m.type) {
TypeMouvement.facture => Icons.receipt_long,
TypeMouvement.recompense => Icons.card_giftcard,
_ => Icons.tune,
};
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => _optionsMouvement(m),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4),
child: Row(
children: [
CircleAvatar(
radius: 20,
backgroundColor: couleur.withValues(alpha: 0.12),
child: Icon(icone, size: 20, color: couleur),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(m.libelle,
style: const TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 2),
Text(dateHeure(m.date),
style: const TextStyle(
fontSize: 12, color: AppTheme.grisTexte)),
],
),
),
Text(
'${gain ? '+' : ''}${points(m.points.abs())}',
style: TextStyle(fontWeight: FontWeight.w700, color: couleur),
),
const SizedBox(width: 4),
const Icon(Icons.more_vert, size: 18, color: AppTheme.grisTexte),
],
),
),
);
}
/// Menu d'un mouvement : modifier (factures) ou supprimer.
Future<void> _optionsMouvement(Mouvement m) async {
final estFacture = m.type == TypeMouvement.facture;
final action = await showModalBottomSheet<String>(
context: context,
showDragHandle: true,
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
child: Text(m.libelle,
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w700)),
),
if (estFacture)
ListTile(
leading: const Icon(Icons.edit_outlined),
title: const Text('Modifier le montant'),
onTap: () => Navigator.of(ctx).pop('modifier'),
),
ListTile(
leading: Icon(Icons.delete_outline, color: Colors.red.shade600),
title: Text('Supprimer',
style: TextStyle(color: Colors.red.shade600)),
onTap: () => Navigator.of(ctx).pop('supprimer'),
),
const SizedBox(height: 8),
],
),
),
);
if (action == 'modifier') {
final montant = await _demanderMontant(
initial: m.montantEuros,
titre: 'Modifier le montant',
bouton: 'Enregistrer',
);
if (montant == null) return;
await _repo.modifierFacture(mouvement: m, nouveauMontant: montant);
await _rechargerMouvements();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Facture modifiée')),
);
}
} else if (action == 'supprimer') {
final ok = await _confirmer(
titre: 'Supprimer ce mouvement ?',
message:
'« ${m.libelle} » sera supprimé et le solde du client réajusté.',
libelleOk: 'Supprimer',
danger: true,
);
if (ok != true) return;
await _repo.supprimerMouvement(m);
await _rechargerMouvements();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Mouvement supprimé')),
);
}
}
}
}