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
+223
View File
@@ -0,0 +1,223 @@
import 'package:flutter/foundation.dart';
import '../models/client.dart';
import '../models/mouvement.dart';
import '../models/recompense.dart';
import '../supabase_config.dart';
import '../utils/format.dart';
import 'reglages.dart';
/// Erreur levée quand un client n'a pas assez de points pour une récompense.
class SoldeInsuffisant implements Exception {
final double manquant;
SoldeInsuffisant(this.manquant);
@override
String toString() => 'Solde insuffisant (${points(manquant)} manquants)';
}
/// Source de vérité des clients (côté staff), adossée à Supabase.
/// Le solde de points est tenu à jour côté base (trigger) à chaque mouvement ;
/// on reflète le delta en mémoire pour un affichage immédiat.
class ClientRepository extends ChangeNotifier {
ClientRepository._();
static final ClientRepository instance = ClientRepository._();
final List<Client> _clients = [];
bool _charge = false;
bool _enCours = false;
String? _erreur;
List<Client> get clients => List.unmodifiable(_clients);
bool get charge => _charge;
bool get enCours => _enCours;
String? get erreur => _erreur;
Future<void> charger() async {
_enCours = true;
_erreur = null;
notifyListeners();
try {
final rows = await supabase.from('clients').select().order('nom');
_clients
..clear()
..addAll((rows as List).map((e) => Client.fromMap(e)));
_charge = true;
} catch (e) {
_erreur = e.toString();
} finally {
_enCours = false;
notifyListeners();
}
}
Client? parCode(String code) {
for (final c in _clients) {
if (c.code == code) return c;
}
return null;
}
Client? parId(String id) {
for (final c in _clients) {
if (c.id == id) return c;
}
return null;
}
/// Filtre par nom, téléphone ou code (recherche insensible à la casse).
List<Client> rechercher(String requete) {
final q = requete.trim().toLowerCase();
if (q.isEmpty) return clients;
return _clients
.where((c) =>
c.nom.toLowerCase().contains(q) ||
(c.prenom?.toLowerCase().contains(q) ?? false) ||
(c.telephone?.toLowerCase().contains(q) ?? false) ||
c.code.toLowerCase().contains(q))
.toList();
}
/// Crée un client « au comptoir » (sans compte de connexion). Le code unique
/// et le QR sont générés côté base.
Future<Client> creer({
required String nom,
String? prenom,
String? telephone,
}) async {
final row = await supabase
.from('clients')
.insert({
'nom': nom.trim(),
'prenom': _ouNull(prenom),
'telephone': _ouNull(telephone),
})
.select()
.single();
final cree = Client.fromMap(row);
_clients.add(cree);
_trier();
notifyListeners();
return cree;
}
/// Modifie nom / prénom / téléphone (pas le solde : il évolue via facture).
Future<void> modifier(Client c) async {
await supabase.from('clients').update({
'nom': c.nom.trim(),
'prenom': _ouNull(c.prenom),
'telephone': _ouNull(c.telephone),
}).eq('id', c.id);
final i = _clients.indexWhere((e) => e.id == c.id);
if (i != -1) {
_clients[i] =
_clients[i].copyWith(nom: c.nom, prenom: c.prenom, telephone: c.telephone);
}
_trier();
notifyListeners();
}
/// Renvoie null si la chaîne est vide/espaces, sinon la valeur nettoyée.
String? _ouNull(String? v) =>
(v == null || v.trim().isEmpty) ? null : v.trim();
Future<void> supprimer(String id) async {
await supabase.from('clients').delete().eq('id', id);
_clients.removeWhere((e) => e.id == id);
notifyListeners();
}
/// Ajoute une facture : crédite les points correspondant au montant dépensé.
/// Retourne le nombre de points gagnés.
Future<double> ajouterFacture({
required String clientId,
required double montantEuros,
}) async {
final gagnes = Reglages.instance.pointsPour(montantEuros);
await supabase.from('mouvements').insert({
'client_id': clientId,
'type': TypeMouvement.facture,
'montant_euros': montantEuros,
'points': gagnes,
'libelle': 'Facture ${euro(montantEuros)}',
});
_majSoldeMemoire(clientId, gagnes);
return gagnes;
}
/// Utilise une récompense : débite son coût. Lève [SoldeInsuffisant] si besoin.
Future<void> utiliserRecompense({
required String clientId,
required Recompense recompense,
}) async {
final client = parId(clientId);
if (client == null) throw Exception('Client introuvable');
if (client.points < recompense.coutPoints) {
throw SoldeInsuffisant(recompense.coutPoints - client.points);
}
await supabase.from('mouvements').insert({
'client_id': clientId,
'type': TypeMouvement.recompense,
'points': -recompense.coutPoints,
'libelle': recompense.nom,
});
_majSoldeMemoire(clientId, -recompense.coutPoints);
}
/// Correction manuelle du solde (ajout ou retrait de points).
Future<void> ajuster({
required String clientId,
required double points,
required String libelle,
}) async {
await supabase.from('mouvements').insert({
'client_id': clientId,
'type': TypeMouvement.ajustement,
'points': points,
'libelle': libelle,
});
_majSoldeMemoire(clientId, points);
}
/// Historique d'un client, du plus récent au plus ancien.
Future<List<Mouvement>> mouvements(String clientId) async {
final rows = await supabase
.from('mouvements')
.select()
.eq('client_id', clientId)
.order('created_at', ascending: false);
return (rows as List).map((e) => Mouvement.fromMap(e)).toList();
}
/// Corrige le montant d'une facture existante : recalcule les points au ratio
/// courant et réajuste le solde du client (via le trigger côté base).
Future<void> modifierFacture({
required Mouvement mouvement,
required double nouveauMontant,
}) async {
final nouveauxPoints = Reglages.instance.pointsPour(nouveauMontant);
await supabase.from('mouvements').update({
'montant_euros': nouveauMontant,
'points': nouveauxPoints,
'libelle': 'Facture ${euro(nouveauMontant)}',
}).eq('id', mouvement.id);
_majSoldeMemoire(mouvement.clientId, nouveauxPoints - mouvement.points);
}
/// Supprime un mouvement (facture, récompense ou ajustement). Le solde du
/// client est réajusté automatiquement (trigger côté base).
Future<void> supprimerMouvement(Mouvement mouvement) async {
await supabase.from('mouvements').delete().eq('id', mouvement.id);
_majSoldeMemoire(mouvement.clientId, -mouvement.points);
}
void _majSoldeMemoire(String clientId, double delta) {
final i = _clients.indexWhere((c) => c.id == clientId);
if (i != -1) {
_clients[i] = _clients[i].copyWith(points: _clients[i].points + delta);
}
notifyListeners();
}
void _trier() => _clients
.sort((a, b) => a.nom.toLowerCase().compareTo(b.nom.toLowerCase()));
}