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,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()));
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/recompense.dart';
|
||||
import '../supabase_config.dart';
|
||||
|
||||
/// Source de vérité du catalogue de récompenses (table `recompenses` Supabase).
|
||||
class RecompenseRepository extends ChangeNotifier {
|
||||
RecompenseRepository._();
|
||||
static final RecompenseRepository instance = RecompenseRepository._();
|
||||
|
||||
final List<Recompense> _recompenses = [];
|
||||
bool _charge = false;
|
||||
|
||||
List<Recompense> get recompenses => List.unmodifiable(_recompenses);
|
||||
|
||||
/// Uniquement les récompenses proposées (actives), triées par coût croissant.
|
||||
List<Recompense> get actives {
|
||||
final l = _recompenses.where((r) => r.actif).toList()
|
||||
..sort((a, b) => a.coutPoints.compareTo(b.coutPoints));
|
||||
return l;
|
||||
}
|
||||
|
||||
bool get charge => _charge;
|
||||
|
||||
Future<void> charger() async {
|
||||
try {
|
||||
final rows =
|
||||
await supabase.from('recompenses').select().order('cout_points');
|
||||
_recompenses
|
||||
..clear()
|
||||
..addAll((rows as List).map((e) => Recompense.fromMap(e)));
|
||||
_charge = true;
|
||||
notifyListeners();
|
||||
} catch (_) {
|
||||
// Ignore si non connecté ; sera rechargé après connexion.
|
||||
}
|
||||
}
|
||||
|
||||
Future<Recompense> creer(
|
||||
{required String nom, required double coutPoints}) async {
|
||||
final row = await supabase
|
||||
.from('recompenses')
|
||||
.insert({'nom': nom.trim(), 'cout_points': coutPoints, 'actif': true})
|
||||
.select()
|
||||
.single();
|
||||
final cree = Recompense.fromMap(row);
|
||||
_recompenses.add(cree);
|
||||
_trier();
|
||||
notifyListeners();
|
||||
return cree;
|
||||
}
|
||||
|
||||
Future<void> modifier(Recompense r) async {
|
||||
await supabase.from('recompenses').update({
|
||||
'nom': r.nom.trim(),
|
||||
'cout_points': r.coutPoints,
|
||||
'actif': r.actif,
|
||||
}).eq('id', r.id);
|
||||
final i = _recompenses.indexWhere((e) => e.id == r.id);
|
||||
if (i != -1) _recompenses[i] = r;
|
||||
_trier();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> supprimer(String id) async {
|
||||
await supabase.from('recompenses').delete().eq('id', id);
|
||||
_recompenses.removeWhere((e) => e.id == id);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _trier() =>
|
||||
_recompenses.sort((a, b) => a.coutPoints.compareTo(b.coutPoints));
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../config.dart';
|
||||
import '../supabase_config.dart';
|
||||
|
||||
/// Réglages globaux du magasin (ligne unique `reglages` id=1 sur Supabase).
|
||||
/// Étend [ChangeNotifier] pour que les écrans se rafraîchissent au changement.
|
||||
class Reglages extends ChangeNotifier {
|
||||
Reglages._();
|
||||
static final Reglages instance = Reglages._();
|
||||
|
||||
double _eurosParPoint = Config.eurosParPointParDefaut;
|
||||
String _nomMagasin = '';
|
||||
|
||||
/// Nombre d'euros à dépenser pour gagner 1 point.
|
||||
double get eurosParPoint => _eurosParPoint;
|
||||
|
||||
/// Nom du magasin, affiché sur la carte / le QR du client.
|
||||
String get nomMagasin => _nomMagasin;
|
||||
|
||||
Future<void> charger() async {
|
||||
try {
|
||||
final row = await supabase
|
||||
.from('reglages')
|
||||
.select('euros_par_point, nom_magasin')
|
||||
.eq('id', 1)
|
||||
.maybeSingle();
|
||||
if (row != null) {
|
||||
_eurosParPoint = (row['euros_par_point'] as num?)?.toDouble() ??
|
||||
Config.eurosParPointParDefaut;
|
||||
_nomMagasin = (row['nom_magasin'] as String?) ?? '';
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (_) {
|
||||
// Réglages par défaut si non connecté / hors-ligne.
|
||||
}
|
||||
}
|
||||
|
||||
/// Points gagnés pour un montant donné, selon le ratio courant.
|
||||
double pointsPour(double euros) =>
|
||||
_eurosParPoint > 0 ? euros / _eurosParPoint : 0;
|
||||
|
||||
Future<void> definirEurosParPoint(double valeur) async {
|
||||
if (valeur <= 0) return;
|
||||
await supabase.from('reglages').update({'euros_par_point': valeur}).eq('id', 1);
|
||||
_eurosParPoint = valeur;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> definirNomMagasin(String valeur) async {
|
||||
final v = valeur.trim();
|
||||
await supabase.from('reglages').update({'nom_magasin': v}).eq('id', 1);
|
||||
_nomMagasin = v;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user