import 'dart:math'; import 'package:flutter/foundation.dart'; import '../config.dart'; import '../models/client.dart'; import '../models/mouvement.dart'; import '../models/recompense.dart'; import '../pocketbase_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 à PocketBase. /// Comme PocketBase n'a pas de trigger, le solde de points est maintenu par /// l'app : à chaque mouvement, on crée l'enregistrement ET on met à jour le /// solde du client. Seul le staff (la tablette) écrit → pas de concurrence. class ClientRepository extends ChangeNotifier { ClientRepository._(); static final ClientRepository instance = ClientRepository._(); final List _clients = []; bool _charge = false; bool _enCours = false; bool _abonne = false; String? _erreur; List get clients => List.unmodifiable(_clients); bool get charge => _charge; bool get enCours => _enCours; bool get tempsReelActif => _abonne; String? get erreur => _erreur; Future charger() async { _enCours = true; _erreur = null; notifyListeners(); try { final rows = await pb.collection('clients').getFullList(sort: 'nom'); _clients ..clear() ..addAll(rows.map((r) => Client.fromMap(r.toJson()))); _charge = true; await _sabonner(); } catch (e) { _erreur = e.toString(); } finally { _enCours = false; notifyListeners(); } } /// Abonnement temps réel : la liste et les soldes se synchronisent en direct /// entre tous les appareils (autre tablette, app client…). Future _sabonner() async { if (_abonne) return; _abonne = true; try { await pb.collection('clients').subscribe('*', (e) { final rec = e.record; if (rec == null) return; if (e.action == 'delete') { _clients.removeWhere((x) => x.id == rec.id); } else { final c = Client.fromMap(rec.toJson()); final i = _clients.indexWhere((x) => x.id == c.id); if (i == -1) { _clients.add(c); } else { _clients[i] = c; } _trier(); } notifyListeners(); }); } catch (_) { _abonne = false; // on réessaiera au prochain chargement } } 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; } List 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 » avec un code unique généré par l'app. Future creer({ required String nom, String? prenom, String? telephone, }) async { // Quelques essais en cas de collision (peu probable) sur le code. Object? derniereErreur; for (var essai = 0; essai < 8; essai++) { try { final rec = await pb.collection('clients').create(body: { 'code': _genererCode(), 'nom': nom.trim(), 'prenom': _ouNull(prenom), 'telephone': _ouNull(telephone), 'points': 0, }); final cree = Client.fromMap(rec.toJson()); _clients.add(cree); _trier(); notifyListeners(); return cree; } catch (e) { derniereErreur = e; // probable conflit d'index unique → on régénère } } throw Exception('Création du client impossible : $derniereErreur'); } Future modifier(Client c) async { await pb.collection('clients').update(c.id, body: { 'nom': c.nom.trim(), 'prenom': _ouNull(c.prenom), 'telephone': _ouNull(c.telephone), }); 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(); } Future supprimer(String id) async { await pb.collection('clients').delete(id); _clients.removeWhere((e) => e.id == id); notifyListeners(); } /// Ajoute une facture : crédite les points correspondant au montant dépensé. Future ajouterFacture({ required String clientId, required double montantEuros, }) async { final gagnes = Reglages.instance.pointsPour(montantEuros); await pb.collection('mouvements').create(body: { 'client': clientId, 'type': TypeMouvement.facture, 'montant_euros': montantEuros, 'points': gagnes, 'libelle': 'Facture ${euro(montantEuros)}', }); await _ecrireSolde(clientId, gagnes); return gagnes; } Future 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 pb.collection('mouvements').create(body: { 'client': clientId, 'type': TypeMouvement.recompense, 'points': -recompense.coutPoints, 'libelle': recompense.nom, }); await _ecrireSolde(clientId, -recompense.coutPoints); } Future ajuster({ required String clientId, required double points, required String libelle, }) async { await pb.collection('mouvements').create(body: { 'client': clientId, 'type': TypeMouvement.ajustement, 'points': points, 'libelle': libelle, }); await _ecrireSolde(clientId, points); } /// Corrige le montant d'une facture (recalcule les points au ratio courant). Future modifierFacture({ required Mouvement mouvement, required double nouveauMontant, }) async { final nouveauxPoints = Reglages.instance.pointsPour(nouveauMontant); await pb.collection('mouvements').update(mouvement.id, body: { 'montant_euros': nouveauMontant, 'points': nouveauxPoints, 'libelle': 'Facture ${euro(nouveauMontant)}', }); await _ecrireSolde(mouvement.clientId, nouveauxPoints - mouvement.points); } Future supprimerMouvement(Mouvement mouvement) async { await pb.collection('mouvements').delete(mouvement.id); await _ecrireSolde(mouvement.clientId, -mouvement.points); } Future> mouvements(String clientId) async { final rows = await pb.collection('mouvements').getFullList( filter: 'client = "$clientId"', sort: '-created', ); return rows.map((r) => Mouvement.fromMap(r.toJson())).toList(); } /// Applique un delta au solde du client : met à jour la base ET la mémoire. Future _ecrireSolde(String clientId, double delta) async { final i = _clients.indexWhere((c) => c.id == clientId); final actuel = i != -1 ? _clients[i].points : 0.0; final nouveau = actuel + delta; await pb.collection('clients').update(clientId, body: {'points': nouveau}); if (i != -1) _clients[i] = _clients[i].copyWith(points: nouveau); notifyListeners(); } String _genererCode() { const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // sans I, O, 0, 1 final rnd = Random(); final suffixe = List.generate(6, (_) => alphabet[rnd.nextInt(alphabet.length)]).join(); return '${Config.prefixeCode}$suffixe'; } String? _ouNull(String? v) => (v == null || v.trim().isEmpty) ? null : v.trim(); void _trier() => _clients .sort((a, b) => a.nom.toLowerCase().compareTo(b.nom.toLowerCase())); }