(Feat) Migration PocketBase

This commit is contained in:
2026-07-18 11:51:32 +02:00
parent 1d3ee69c6d
commit e483fd9070
15 changed files with 522 additions and 453 deletions
+3 -10
View File
@@ -1,10 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'pocketbase_config.dart';
import 'screens/auth_gate.dart';
import 'supabase_config.dart';
import 'theme.dart';
Future<void> main() async {
@@ -19,14 +18,8 @@ Future<void> main() async {
DeviceOrientation.portraitDown,
]);
// Connexion au backend Supabase (partagé avec l'app client).
if (SupabaseConfig.estConfigure) {
await Supabase.initialize(
url: SupabaseConfig.url,
// Accepte la clé « anon public » (ou « publishable ») du projet.
publishableKey: SupabaseConfig.anonKey,
);
}
// Connexion au backend PocketBase (Raspberry Pi), avec session persistée.
await initPocketBase();
runApp(const MonApp());
}
+1 -1
View File
@@ -1,7 +1,7 @@
/// Un client fidélité. Le [code] est ce qui est encodé dans son QR code :
/// scanner le QR permet de retrouver le compte.
class Client {
final String id; // uuid Supabase
final String id; // id PocketBase
/// Code unique du compte (encodé dans le QR), ex : « FID-3F9K2A ».
final String code;
+5 -3
View File
@@ -41,12 +41,14 @@ class Mouvement {
factory Mouvement.fromMap(Map<String, dynamic> map) {
return Mouvement(
id: map['id'].toString(),
clientId: map['client_id'].toString(),
type: (map['type'] as String?) ?? TypeMouvement.ajustement,
clientId: (map['client'] ?? '').toString(),
type: (map['type'] as String?)?.isNotEmpty == true
? map['type'] as String
: TypeMouvement.ajustement,
montantEuros: (map['montant_euros'] as num?)?.toDouble(),
points: (map['points'] as num?)?.toDouble() ?? 0,
libelle: (map['libelle'] as String?) ?? '',
date: DateTime.tryParse(map['created_at']?.toString() ?? '')?.toLocal() ??
date: DateTime.tryParse(map['created']?.toString() ?? '')?.toLocal() ??
DateTime.fromMillisecondsSinceEpoch(0),
);
}
+35
View File
@@ -0,0 +1,35 @@
import 'package:pocketbase/pocketbase.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Connexion au backend PocketBase (auto-hébergé sur le Raspberry Pi).
class PocketBaseConfig {
PocketBaseConfig._();
/// URL du serveur PocketBase, exposé publiquement en HTTPS via Tailscale
/// Funnel (sur le Raspberry). Accessible de partout (4G, autre WiFi…).
/// Accès local direct (dans le bar) possible via http://192.168.1.32:8090
/// mais on garde l'URL publique partout pour que ça marche en tout lieu.
static const String url = 'https://db.tailb756e1.ts.net';
static const _cleAuth = 'pb_auth';
}
/// Client PocketBase global (initialisé par [initPocketBase]).
late final PocketBase pb;
/// Initialise le client PocketBase avec une session persistée (SharedPreferences),
/// pour rester connecté d'une ouverture de l'app à l'autre.
Future<void> initPocketBase() async {
final prefs = await SharedPreferences.getInstance();
final store = AsyncAuthStore(
save: (data) async => prefs.setString(PocketBaseConfig._cleAuth, data),
initial: prefs.getString(PocketBaseConfig._cleAuth),
clear: () async => prefs.remove(PocketBaseConfig._cleAuth),
);
pb = PocketBase(PocketBaseConfig.url, authStore: store);
}
/// Vrai si l'utilisateur connecté fait partie du personnel du magasin.
bool get estStaff =>
pb.authStore.isValid &&
(pb.authStore.record?.getBoolValue('is_staff') ?? false);
+6 -42
View File
@@ -1,35 +1,30 @@
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../pocketbase_config.dart';
import '../services/client_repository.dart';
import '../services/recompense_repository.dart';
import '../services/reglages.dart';
import '../supabase_config.dart';
import 'home_shell.dart';
import 'staff_login_screen.dart';
/// Aiguille entre l'écran de connexion (staff non connecté) et l'app.
/// Écoute l'état d'authentification Supabase en continu.
/// Réagit aux changements de session PocketBase.
class AuthGate extends StatelessWidget {
const AuthGate({super.key});
@override
Widget build(BuildContext context) {
if (!SupabaseConfig.estConfigure) return const _EcranNonConfigure();
return StreamBuilder<AuthState>(
stream: supabase.auth.onAuthStateChange,
return StreamBuilder(
stream: pb.authStore.onChange,
builder: (context, snapshot) {
final session = supabase.auth.currentSession;
if (session == null) return const StaffLoginScreen();
if (!estStaff) return const StaffLoginScreen();
return const _AppChargee();
},
);
}
}
/// Charge les données (réglages, clients, récompenses) après connexion, puis
/// affiche l'app. Recharge si le compte connecté change.
/// Charge les données (réglages, clients, récompenses) après connexion.
class _AppChargee extends StatefulWidget {
const _AppChargee();
@@ -67,34 +62,3 @@ class _AppChargeeState extends State<_AppChargee> {
);
}
}
class _EcranNonConfigure extends StatelessWidget {
const _EcranNonConfigure();
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: const [
Icon(Icons.cloud_off, size: 56, color: Colors.grey),
SizedBox(height: 16),
Text('Supabase non configuré',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
SizedBox(height: 8),
Text(
'Renseignez l\'URL et la clé anon dans lib/supabase_config.dart.\n'
'Voir supabase/SETUP.md',
textAlign: TextAlign.center,
style: TextStyle(color: Color(0xFF6B6B72)),
),
],
),
),
),
);
}
}
+23
View File
@@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
import '../models/client.dart';
import '../models/mouvement.dart';
import '../models/recompense.dart';
import '../pocketbase_config.dart';
import '../services/client_repository.dart';
import '../services/recompense_repository.dart';
import '../services/reglages.dart';
@@ -26,11 +27,33 @@ class _ClientDetailScreenState extends State<ClientDetailScreen> {
final _repo = ClientRepository.instance;
List<Mouvement> _mouvements = [];
bool _chargeMouvements = true;
Future<void> Function()? _desabonner;
@override
void initState() {
super.initState();
_rechargerMouvements();
_sabonnerMouvements();
}
@override
void dispose() {
_desabonner?.call();
super.dispose();
}
/// Historique en temps réel : si un autre appareil ajoute/retire un mouvement
/// pour ce client, la liste se met à jour toute seule.
Future<void> _sabonnerMouvements() async {
try {
_desabonner = await pb.collection('mouvements').subscribe('*', (e) {
final rec = e.record;
if (rec != null && rec.data['client'] != widget.clientId) return;
_rechargerMouvements();
});
} catch (_) {
// Temps réel indisponible : on garde le rafraîchissement manuel.
}
}
Future<void> _rechargerMouvements() async {
+106 -4
View File
@@ -1,8 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../pocketbase_config.dart';
import '../services/client_repository.dart';
import '../services/reglages.dart';
import '../supabase_config.dart';
import '../theme.dart';
import '../utils/format.dart';
@@ -19,6 +20,8 @@ class _ReglagesScreenState extends State<ReglagesScreen> {
final _reglages = Reglages.instance;
late final TextEditingController _ratioCtrl;
late final TextEditingController _nomCtrl;
bool? _serveurOk; // null = en cours de vérification
bool _verifEnCours = false;
@override
void initState() {
@@ -27,6 +30,24 @@ class _ReglagesScreenState extends State<ReglagesScreen> {
text: _formaterRatio(_reglages.eurosParPoint),
);
_nomCtrl = TextEditingController(text: _reglages.nomMagasin);
_verifierServeur();
}
Future<void> _verifierServeur() async {
setState(() => _verifEnCours = true);
bool ok;
try {
await pb.health.check();
ok = true;
} catch (_) {
ok = false;
}
if (mounted) {
setState(() {
_serveurOk = ok;
_verifEnCours = false;
});
}
}
@override
@@ -155,12 +176,15 @@ class _ReglagesScreenState extends State<ReglagesScreen> {
),
),
const SizedBox(height: 24),
_titre('Serveur'),
_carteServeur(),
const SizedBox(height: 24),
_titre('À propos'),
const Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Text(
'Données partagées via Supabase avec l\'app client. '
'Données hébergées sur le serveur PocketBase du magasin. '
'Le scan se fait avec la caméra ; la scanette externe sera '
'branchée dans une prochaine version.',
style: TextStyle(color: AppTheme.grisTexte, height: 1.4),
@@ -197,7 +221,10 @@ class _ReglagesScreenState extends State<ReglagesScreen> {
);
}
String? _emailConnecte() => supabase.auth.currentUser?.email;
String? _emailConnecte() {
final email = pb.authStore.record?.getStringValue('email');
return (email == null || email.isEmpty) ? null : email;
}
Future<void> _deconnexion() async {
final ok = await showDialog<bool>(
@@ -217,7 +244,7 @@ class _ReglagesScreenState extends State<ReglagesScreen> {
],
),
);
if (ok == true) await supabase.auth.signOut();
if (ok == true) pb.authStore.clear();
}
Widget _apercus() {
@@ -255,6 +282,81 @@ class _ReglagesScreenState extends State<ReglagesScreen> {
);
}
Widget _carteServeur() {
final syncOk = ClientRepository.instance.tempsReelActif;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.dns_outlined,
size: 18, color: AppTheme.grisTexte),
const SizedBox(width: 8),
Expanded(
child: Text(
PocketBaseConfig.url,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
],
),
const Divider(height: 22),
Row(
children: [
Expanded(
child: _verifEnCours
? _ligneStatut(null, 'Vérification…')
: _ligneStatut(_serveurOk, 'Connecté au serveur',
koTexte: 'Serveur injoignable'),
),
if (_verifEnCours)
const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
IconButton(
tooltip: 'Revérifier',
icon: const Icon(Icons.refresh),
onPressed: _verifierServeur,
),
],
),
const SizedBox(height: 8),
_ligneStatut(syncOk, 'Synchronisation temps réel active',
koTexte: 'Temps réel inactif'),
],
),
),
);
}
Widget _ligneStatut(bool? ok, String texte, {String? koTexte}) {
final couleur = ok == null
? AppTheme.grisTexte
: (ok ? Colors.green.shade600 : Colors.red.shade600);
return Row(
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(color: couleur, shape: BoxShape.circle),
),
const SizedBox(width: 8),
Expanded(
child: Text(
(ok == false && koTexte != null) ? koTexte : texte,
style: TextStyle(color: couleur, fontWeight: FontWeight.w600),
),
),
],
);
}
Widget _titre(String texte) {
return Padding(
padding: const EdgeInsets.fromLTRB(4, 0, 4, 10),
+13 -11
View File
@@ -1,11 +1,11 @@
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:pocketbase/pocketbase.dart';
import '../supabase_config.dart';
import '../pocketbase_config.dart';
import '../theme.dart';
/// Connexion du personnel du magasin (staff). Les comptes staff sont créés dans
/// Supabase (voir SETUP.md) — pas d'inscription libre ici.
/// PocketBase (champ is_staff = true) — pas d'inscription libre ici.
class StaffLoginScreen extends StatefulWidget {
const StaffLoginScreen({super.key});
@@ -35,22 +35,24 @@ class _StaffLoginScreenState extends State<StaffLoginScreen> {
_erreur = null;
});
try {
await supabase.auth.signInWithPassword(
email: _emailCtrl.text.trim(),
password: _mdpCtrl.text,
);
await pb.collection('users').authWithPassword(
_emailCtrl.text.trim(),
_mdpCtrl.text,
);
// Vérifie que ce compte est bien autorisé (staff).
final estStaff = await supabase.rpc('est_staff') as bool? ?? false;
if (!estStaff) {
await supabase.auth.signOut();
pb.authStore.clear();
if (mounted) {
setState(() => _erreur =
'Ce compte n\'est pas autorisé pour la tablette (staff).');
}
}
// Si staff : l'AuthGate bascule automatiquement vers l'app.
} on AuthException catch (e) {
if (mounted) setState(() => _erreur = e.message);
} on ClientException catch (e) {
if (mounted) {
setState(() => _erreur = 'Identifiants incorrects');
}
debugPrint('Login error: ${e.response}');
} catch (e) {
if (mounted) setState(() => _erreur = 'Erreur : $e');
} finally {
+103 -63
View File
@@ -1,9 +1,12 @@
import 'dart:math';
import 'package:flutter/foundation.dart';
import '../config.dart';
import '../models/client.dart';
import '../models/mouvement.dart';
import '../models/recompense.dart';
import '../supabase_config.dart';
import '../pocketbase_config.dart';
import '../utils/format.dart';
import 'reglages.dart';
@@ -15,9 +18,10 @@ class SoldeInsuffisant implements Exception {
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.
/// 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._();
@@ -25,11 +29,13 @@ class ClientRepository extends ChangeNotifier {
final List<Client> _clients = [];
bool _charge = false;
bool _enCours = false;
bool _abonne = false;
String? _erreur;
List<Client> get clients => List.unmodifiable(_clients);
bool get charge => _charge;
bool get enCours => _enCours;
bool get tempsReelActif => _abonne;
String? get erreur => _erreur;
Future<void> charger() async {
@@ -37,11 +43,12 @@ class ClientRepository extends ChangeNotifier {
_erreur = null;
notifyListeners();
try {
final rows = await supabase.from('clients').select().order('nom');
final rows = await pb.collection('clients').getFullList(sort: 'nom');
_clients
..clear()
..addAll((rows as List).map((e) => Client.fromMap(e)));
..addAll(rows.map((r) => Client.fromMap(r.toJson())));
_charge = true;
await _sabonner();
} catch (e) {
_erreur = e.toString();
} finally {
@@ -50,6 +57,34 @@ class ClientRepository extends ChangeNotifier {
}
}
/// Abonnement temps réel : la liste et les soldes se synchronisent en direct
/// entre tous les appareils (autre tablette, app client…).
Future<void> _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;
@@ -64,7 +99,6 @@ class ClientRepository extends ChangeNotifier {
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;
@@ -77,36 +111,41 @@ class ClientRepository extends ChangeNotifier {
.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.
/// Crée un client « au comptoir » avec un code unique généré par l'app.
Future<Client> creer({
required String nom,
String? prenom,
String? telephone,
}) async {
final row = await supabase
.from('clients')
.insert({
// 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),
})
.select()
.single();
final cree = Client.fromMap(row);
_clients.add(cree);
_trier();
notifyListeners();
return cree;
'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');
}
/// 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({
await pb.collection('clients').update(c.id, body: {
'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] =
@@ -116,35 +155,29 @@ class ClientRepository extends ChangeNotifier {
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);
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é.
/// 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,
await pb.collection('mouvements').create(body: {
'client': clientId,
'type': TypeMouvement.facture,
'montant_euros': montantEuros,
'points': gagnes,
'libelle': 'Facture ${euro(montantEuros)}',
});
_majSoldeMemoire(clientId, gagnes);
await _ecrireSolde(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,
@@ -154,70 +187,77 @@ class ClientRepository extends ChangeNotifier {
if (client.points < recompense.coutPoints) {
throw SoldeInsuffisant(recompense.coutPoints - client.points);
}
await supabase.from('mouvements').insert({
'client_id': clientId,
await pb.collection('mouvements').create(body: {
'client': clientId,
'type': TypeMouvement.recompense,
'points': -recompense.coutPoints,
'libelle': recompense.nom,
});
_majSoldeMemoire(clientId, -recompense.coutPoints);
await _ecrireSolde(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,
await pb.collection('mouvements').create(body: {
'client': clientId,
'type': TypeMouvement.ajustement,
'points': points,
'libelle': libelle,
});
_majSoldeMemoire(clientId, points);
await _ecrireSolde(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).
/// Corrige le montant d'une facture (recalcule les points au ratio courant).
Future<void> modifierFacture({
required Mouvement mouvement,
required double nouveauMontant,
}) async {
final nouveauxPoints = Reglages.instance.pointsPour(nouveauMontant);
await supabase.from('mouvements').update({
await pb.collection('mouvements').update(mouvement.id, body: {
'montant_euros': nouveauMontant,
'points': nouveauxPoints,
'libelle': 'Facture ${euro(nouveauMontant)}',
}).eq('id', mouvement.id);
_majSoldeMemoire(mouvement.clientId, nouveauxPoints - mouvement.points);
});
await _ecrireSolde(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);
await pb.collection('mouvements').delete(mouvement.id);
await _ecrireSolde(mouvement.clientId, -mouvement.points);
}
void _majSoldeMemoire(String clientId, double delta) {
Future<List<Mouvement>> 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<void> _ecrireSolde(String clientId, double delta) async {
final i = _clients.indexWhere((c) => c.id == clientId);
if (i != -1) {
_clients[i] = _clients[i].copyWith(points: _clients[i].points + delta);
}
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()));
}
+43 -15
View File
@@ -1,19 +1,19 @@
import 'package:flutter/foundation.dart';
import '../models/recompense.dart';
import '../supabase_config.dart';
import '../pocketbase_config.dart';
/// Source de vérité du catalogue de récompenses (table `recompenses` Supabase).
/// Source de vérité du catalogue de récompenses (collection `recompenses`).
class RecompenseRepository extends ChangeNotifier {
RecompenseRepository._();
static final RecompenseRepository instance = RecompenseRepository._();
final List<Recompense> _recompenses = [];
bool _charge = false;
bool _abonne = 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));
@@ -25,25 +25,53 @@ class RecompenseRepository extends ChangeNotifier {
Future<void> charger() async {
try {
final rows =
await supabase.from('recompenses').select().order('cout_points');
await pb.collection('recompenses').getFullList(sort: 'cout_points');
_recompenses
..clear()
..addAll((rows as List).map((e) => Recompense.fromMap(e)));
..addAll(rows.map((r) => Recompense.fromMap(r.toJson())));
_charge = true;
notifyListeners();
await _sabonner();
} catch (_) {
// Ignore si non connecté ; sera rechargé après connexion.
// Ignoré si non connecté ; rechargé après connexion.
}
}
/// Abonnement temps réel au catalogue (synchronisé entre appareils).
Future<void> _sabonner() async {
if (_abonne) return;
_abonne = true;
try {
await pb.collection('recompenses').subscribe('*', (e) {
final rec = e.record;
if (rec == null) return;
if (e.action == 'delete') {
_recompenses.removeWhere((x) => x.id == rec.id);
} else {
final r = Recompense.fromMap(rec.toJson());
final i = _recompenses.indexWhere((x) => x.id == r.id);
if (i == -1) {
_recompenses.add(r);
} else {
_recompenses[i] = r;
}
_trier();
}
notifyListeners();
});
} catch (_) {
_abonne = false;
}
}
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);
final rec = await pb.collection('recompenses').create(body: {
'nom': nom.trim(),
'cout_points': coutPoints,
'actif': true,
});
final cree = Recompense.fromMap(rec.toJson());
_recompenses.add(cree);
_trier();
notifyListeners();
@@ -51,11 +79,11 @@ class RecompenseRepository extends ChangeNotifier {
}
Future<void> modifier(Recompense r) async {
await supabase.from('recompenses').update({
await pb.collection('recompenses').update(r.id, body: {
'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();
@@ -63,7 +91,7 @@ class RecompenseRepository extends ChangeNotifier {
}
Future<void> supprimer(String id) async {
await supabase.from('recompenses').delete().eq('id', id);
await pb.collection('recompenses').delete(id);
_recompenses.removeWhere((e) => e.id == id);
notifyListeners();
}
+36 -19
View File
@@ -1,55 +1,72 @@
import 'package:flutter/foundation.dart';
import '../config.dart';
import '../supabase_config.dart';
import '../pocketbase_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.
/// Réglages globaux du magasin (unique enregistrement de la collection
/// `reglages` sur PocketBase). [ChangeNotifier] pour rafraîchir les écrans.
class Reglages extends ChangeNotifier {
Reglages._();
static final Reglages instance = Reglages._();
String? _recordId;
bool _abonne = false;
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() ??
final rows = await pb.collection('reglages').getFullList();
if (rows.isNotEmpty) {
final r = rows.first;
_recordId = r.id;
_eurosParPoint = (r.toJson()['euros_par_point'] as num?)?.toDouble() ??
Config.eurosParPointParDefaut;
_nomMagasin = (row['nom_magasin'] as String?) ?? '';
_nomMagasin = (r.toJson()['nom_magasin'] as String?) ?? '';
notifyListeners();
await _sabonner();
}
} catch (_) {
// Réglages par défaut si non connecté / hors-ligne.
// Valeurs par défaut si non connecté / serveur injoignable.
}
}
/// Abonnement temps réel : un changement de ratio/nom se propage aux appareils.
Future<void> _sabonner() async {
if (_abonne) return;
_abonne = true;
try {
await pb.collection('reglages').subscribe('*', (e) {
final rec = e.record;
if (rec == null) return;
_recordId = rec.id;
_eurosParPoint = (rec.toJson()['euros_par_point'] as num?)?.toDouble() ??
_eurosParPoint;
_nomMagasin = (rec.toJson()['nom_magasin'] as String?) ?? _nomMagasin;
notifyListeners();
});
} catch (_) {
_abonne = false;
}
}
/// 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);
if (valeur <= 0 || _recordId == null) return;
await pb.collection('reglages').update(_recordId!, body: {'euros_par_point': valeur});
_eurosParPoint = valeur;
notifyListeners();
}
Future<void> definirNomMagasin(String valeur) async {
if (_recordId == null) return;
final v = valeur.trim();
await supabase.from('reglages').update({'nom_magasin': v}).eq('id', 1);
await pb.collection('reglages').update(_recordId!, body: {'nom_magasin': v});
_nomMagasin = v;
notifyListeners();
}
-22
View File
@@ -1,22 +0,0 @@
import 'package:supabase_flutter/supabase_flutter.dart';
/// Configuration de connexion au backend Supabase (partagé avec l'app client).
///
/// ⚠️ À remplir avec les valeurs de VOTRE projet Supabase :
/// Project Settings → API → « Project URL » et « anon public ».
/// Voir le guide : supabase/SETUP.md
///
/// La clé « anon » est publique et peut vivre dans l'app : la sécurité est
/// assurée par les règles RLS définies dans supabase/schema.sql.
class SupabaseConfig {
SupabaseConfig._();
static const String url = 'https://zfhcnzbggpdvgvosrkgp.supabase.co';
static const String anonKey = 'sb_publishable_4R3-_q1Zy_usliTff01Ilg_jhFlIdPH';
static bool get estConfigure =>
!url.contains('VOTRE-PROJET') && !anonKey.contains('VOTRE_CLE');
}
/// Raccourci vers le client Supabase (une fois [Supabase.initialize] appelé).
SupabaseClient get supabase => Supabase.instance.client;