(Feat) Migration PocketBase
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
# CLAUDE.md — App Fidélité (tablette magasin)
|
||||
|
||||
Guide pour travailler sur ce projet. À lire avant toute modification.
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Application **Flutter** de **programme de fidélité** pour un magasin, sur
|
||||
**tablette Android en portrait** (caisse). Chaque client a une carte = un **QR
|
||||
code** ; le staff scanne, ajoute des **factures** (montant € → **points**), et le
|
||||
client échange ses points contre des **récompenses**.
|
||||
|
||||
Fait partie d'un écosystème de **deux apps** partageant le même backend :
|
||||
|
||||
- **`app_fideliter`** (CE repo) : app **tablette / staff**.
|
||||
- **`app_fideliter_client`** (repo séparé) : app **client** (compte, points, QR).
|
||||
|
||||
App sœur `gestion_prix_produit` (autre projet) : même stack et mêmes conventions,
|
||||
reprises ici. Thème identique mais **accent vert émeraude** (au lieu du bleu) pour
|
||||
distinguer les apps sur une même tablette.
|
||||
|
||||
## Stack technique
|
||||
|
||||
- **Flutter** (Material 3), Dart. Cible : Android tablette, portrait verrouillé.
|
||||
- **PocketBase** comme backend (auto-hébergé sur un **Raspberry Pi**) :
|
||||
auth + base SQLite + API REST + **temps réel (SSE)**.
|
||||
- Packages clés :
|
||||
- `pocketbase` — client backend (auth, CRUD, réaltime)
|
||||
- `shared_preferences` — persistance de la session (rester connecté)
|
||||
- `mobile_scanner` — scan du QR client (caméra ; scanette externe à venir)
|
||||
- `qr_flutter` — affichage/génération du QR d'un client
|
||||
- `intl` — formatage € et dates (locale `fr_FR`)
|
||||
- Icône via `flutter_launcher_icons` (source : `assets/icon/`).
|
||||
|
||||
## Backend PocketBase
|
||||
|
||||
- **URL serveur** : `https://db.tailb756e1.ts.net` (définie dans
|
||||
[`lib/pocketbase_config.dart`](../lib/pocketbase_config.dart)). Le Raspberry est
|
||||
exposé **publiquement en HTTPS via Tailscale Funnel** (gratuit) → accessible de
|
||||
partout (4G, autre WiFi…), pas seulement sur le LAN. En local, le serveur reste
|
||||
aussi joignable en `http://192.168.1.32:8090`. Tailscale est installé **sur le
|
||||
Pi uniquement** ; les clients n'installent que l'app. Le Pi doit rester allumé
|
||||
+ connecté à Internet.
|
||||
- **Compte staff** : `mathew.simon2004@gmail.com` (mot de passe défini au setup).
|
||||
Un compte est « staff » si son champ `is_staff = true` dans la collection `users`.
|
||||
- **Superuser** (admin PocketBase) : géré via l'UI `http://192.168.1.32:8090/_/`.
|
||||
|
||||
### Collections
|
||||
|
||||
| Collection | Type | Champs principaux | Accès (règles) |
|
||||
|---|---|---|---|
|
||||
| `users` | auth | `is_staff` (bool) + email/password | chacun voit son compte ; `is_staff` non modifiable par l'utilisateur |
|
||||
| `clients` | base | `user` (relation, option.), `code` (unique), `nom`, `prenom`, `telephone`, `points` | staff = tout ; client = sa fiche uniquement |
|
||||
| `mouvements` | base | `client` (relation), `type` (facture/recompense/ajustement), `montant_euros`, `points`, `libelle`, `created` | staff écrit ; client lit les siens (anti-triche) |
|
||||
| `recompenses` | base | `nom`, `cout_points`, `actif` | tout connecté lit ; staff modifie |
|
||||
| `reglages` | base | `euros_par_point`, `nom_magasin` (1 seule ligne) | tout connecté lit ; staff modifie |
|
||||
|
||||
### Points de vigilance backend
|
||||
|
||||
- **Pas de trigger PocketBase** : le solde `clients.points` est maintenu **par
|
||||
l'app** (`ClientRepository._ecrireSolde`), qui crée le mouvement PUIS met à jour
|
||||
le solde. Comme seul le staff écrit, pas de souci de concurrence.
|
||||
- **Code client** (`FID-XXXXXX`) généré **côté app** (`ClientRepository._genererCode`),
|
||||
unicité garantie par un index unique + réessais.
|
||||
- Créer/modifier des collections : voir l'admin UI, ou l'API superuser
|
||||
(`POST /api/collections/_superusers/auth-with-password` puis `/api/collections`).
|
||||
|
||||
## Architecture de l'app
|
||||
|
||||
- **Singletons** `.instance` pour les services/repositories.
|
||||
- **Repositories** `ChangeNotifier` = source de vérité, écoutés via `AnimatedBuilder`.
|
||||
- **Temps réel** : chaque repository s'abonne (`pb.collection(...).subscribe`) et
|
||||
met à jour sa liste en mémoire → l'UI se synchronise entre tous les appareils.
|
||||
- **Auth** : `AuthGate` écoute `pb.authStore.onChange`. Session persistée via
|
||||
`AsyncAuthStore` (SharedPreferences) → on reste connecté.
|
||||
|
||||
### Structure
|
||||
|
||||
```text
|
||||
lib/
|
||||
├── main.dart # init (locale fr, PocketBase) + thème
|
||||
├── config.dart # constantes (ratio défaut, préfixe code)
|
||||
├── pocketbase_config.dart # URL serveur, client `pb`, helper `estStaff`
|
||||
├── theme.dart # thème noir & blanc + accent vert émeraude
|
||||
├── models/ # client, mouvement, recompense (fromMap PocketBase)
|
||||
├── services/
|
||||
│ ├── reglages.dart # ratio €/point + nom magasin (+ temps réel)
|
||||
│ ├── client_repository.dart # clients + factures + récompenses + points (+ temps réel)
|
||||
│ └── recompense_repository.dart# catalogue (+ temps réel)
|
||||
├── screens/
|
||||
│ ├── auth_gate.dart # login staff ↔ app selon la session
|
||||
│ ├── staff_login_screen.dart
|
||||
│ ├── home_shell.dart # 4 onglets
|
||||
│ ├── clients_screen.dart # liste + recherche + création
|
||||
│ ├── client_edit_screen.dart # Nom / Prénom / Téléphone
|
||||
│ ├── client_detail_screen.dart # solde, facture, récompense, historique (édit/suppr, temps réel)
|
||||
│ ├── scan_screen.dart # scan QR → fiche client (_ouvrirParCode)
|
||||
│ ├── recompenses_screen.dart
|
||||
│ └── reglages_screen.dart # ratio, nom magasin, état serveur, déconnexion
|
||||
├── widgets/ # pastille_points, qr_client
|
||||
└── utils/format.dart # euro(), points(), dateHeure() (fr_FR)
|
||||
```
|
||||
|
||||
## Règles métier
|
||||
|
||||
- **Points gagnés = montant facture ÷ `euros_par_point`** (fractionnaires, ex.
|
||||
12 € à 10 €/pt = 1,2 pt).
|
||||
- Changer le ratio n'affecte **que les nouvelles factures** (pas de recalcul
|
||||
rétroactif — volontaire).
|
||||
- Éditer une facture recalcule ses points au **ratio courant** et réajuste le solde.
|
||||
- Récompenses : gagner **et** dépenser des points (débit à l'utilisation).
|
||||
|
||||
## Conventions de code
|
||||
|
||||
- **Tout en français** : noms de variables, méthodes, commentaires, libellés UI.
|
||||
- Reprendre le style existant (singletons, `ChangeNotifier`, `copyWith`, `fromMap`).
|
||||
- Modèles : `fromMap(record.toJson())` où `record` est un `RecordModel` PocketBase.
|
||||
Les relations sont des **id** (string) ; `created` est une date ISO (string).
|
||||
|
||||
## Commandes
|
||||
|
||||
```bash
|
||||
flutter pub get
|
||||
flutter analyze # doit rester à 0 problème
|
||||
flutter test
|
||||
flutter build apk --release
|
||||
# Installer en gardant les données (session conservée) :
|
||||
adb -s <DEVICE_ID> install -r build/app/outputs/flutter-apk/app-release.apk
|
||||
adb devices # lister les appareils branchés
|
||||
```
|
||||
|
||||
Icône : `dart run flutter_launcher_icons` après modif de `assets/icon/`.
|
||||
|
||||
## Roadmap / à faire
|
||||
|
||||
- **Scanette USB externe** : se comporte comme un clavier → brancher un champ
|
||||
caché qui appelle `scan_screen.dart` → `_ouvrirParCode` (déjà factorisé).
|
||||
- **Accès externe** au serveur (hors LAN) : IP/domaine public + **HTTPS** (puis
|
||||
retirer `usesCleartextTraffic`).
|
||||
- Impression du QR (carte physique), bonus de bienvenue / paliers, statistiques.
|
||||
- App client (`app_fideliter_client`) : à aligner sur ce backend PocketBase.
|
||||
+3
-10
@@ -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,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;
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
+6
-262
@@ -1,38 +1,6 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
app_links:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: app_links
|
||||
sha256: "9d3c82f634c7f5b5c752f7ee46b67724246043f5e1d5fc1b433dd5b38d780dbe"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.2.0"
|
||||
app_links_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: app_links_linux
|
||||
sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
app_links_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: app_links_platform_interface
|
||||
sha256: "78a18580eecac98108d1eef52a7db668bc317714f5205e616973363326efe333"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.3"
|
||||
app_links_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: app_links_web
|
||||
sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -105,22 +73,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
convert:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: convert
|
||||
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -129,14 +81,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.9"
|
||||
dart_jsonwebtoken:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dart_jsonwebtoken
|
||||
sha256: ad84e60181696513d04d5f2078e0bbc20365b911f46f647797317414bdc88fbe
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.1"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -192,30 +136,6 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
functions_client:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: functions_client
|
||||
sha256: "5b17b8dcf5ae1cd6a6428e7a4ca8b569477297a7f158955b5407dda23baeaa5b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.3"
|
||||
gotrue:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: gotrue
|
||||
sha256: "54e8abe49c8596234c503f4b64d07022c7913a6475c7a85c1f1eacc1a93af166"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.24.0"
|
||||
gtk:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: gtk
|
||||
sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -256,14 +176,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.12.0"
|
||||
jwt_decode:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jwt_decode
|
||||
sha256: d2e9f68c052b2225130977429d30f187aa1981d789c76ad104a32243cfdebfbb
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.1"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -296,14 +208,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -328,14 +232,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: mime
|
||||
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
mobile_scanner:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -344,14 +240,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.2.0"
|
||||
passkeys_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: passkeys_platform_interface
|
||||
sha256: "9610bd136b3382500390912ddd8517ee99505228b1af7b507b9c907f7e99a47d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.8.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -408,14 +296,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
pointycastle:
|
||||
dependency: transitive
|
||||
pocketbase:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: pointycastle
|
||||
sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5"
|
||||
name: pocketbase
|
||||
sha256: f70353ea9c583fabe4165880e054504c5036c428f59c0fc588d64bb19218df05
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
version: "0.22.0"
|
||||
posix:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -424,14 +312,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.5.0"
|
||||
postgrest:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: postgrest
|
||||
sha256: "801f6659cffbb6372d38cb4deb0cd892cb0cb38d75e0686ff24f4a724e9f4999"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.4"
|
||||
qr:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -448,32 +328,8 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
realtime_client:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: realtime_client
|
||||
sha256: "3261ba98af41fccfdbb6c66299b5bd6871907e98bb41bce9d55e0fb91287ed85"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.9.1"
|
||||
retry:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: retry
|
||||
sha256: "822e118d5b3aafed083109c72d5f484c6dc66707885e07c0fbcb8b986bba7efc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
rxdart:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: rxdart
|
||||
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.28.0"
|
||||
shared_preferences:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
|
||||
@@ -549,14 +405,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
storage_client:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: storage_client
|
||||
sha256: "2cc5432c312c4a08687bcce795aff377d006e2892097c7f88d57512885bb4b10"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.9"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -573,22 +421,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
supabase:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: supabase
|
||||
sha256: d425df9330ce5f215a0ee708b084899d89031e8a8f3e096f46c38ea37df9fd7d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.3"
|
||||
supabase_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: supabase_flutter
|
||||
sha256: "262c7eda45997a308ac0311be82fe8e9134526a18125fe5e0cf0a58e9cd16cb6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.15.3"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -613,70 +445,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
url_launcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher
|
||||
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.2"
|
||||
url_launcher_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.32"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_ios
|
||||
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.4.1"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_linux
|
||||
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
url_launcher_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_macos
|
||||
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.5"
|
||||
url_launcher_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_platform_interface
|
||||
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
url_launcher_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_web
|
||||
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_windows
|
||||
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.5"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -701,22 +469,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
web_socket:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web_socket
|
||||
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
web_socket_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web_socket_channel
|
||||
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -741,14 +493,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
yet_another_json_isolate:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: yet_another_json_isolate
|
||||
sha256: eaa26beb5990b25a49d942374fd5a0c5aa67a837e03b14b4c26134aaa1ed01a9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
sdks:
|
||||
dart: ">=3.12.2 <4.0.0"
|
||||
flutter: ">=3.44.0"
|
||||
|
||||
+2
-1
@@ -36,7 +36,8 @@ dependencies:
|
||||
cupertino_icons: ^1.0.8
|
||||
mobile_scanner: ^7.2.0 # scan du QR code client (caméra en attendant la scanette)
|
||||
qr_flutter: ^4.1.0 # génération du QR code d'un client (affichage / impression)
|
||||
supabase_flutter: ^2.8.0 # backend : auth staff + base de données partagée
|
||||
pocketbase: ^0.22.0 # backend : auth staff + base de données (Raspberry Pi)
|
||||
shared_preferences: ^2.5.5 # persistance de la session (rester connecté)
|
||||
intl: ^0.20.3 # formatage € et dates (locale fr_FR)
|
||||
|
||||
dev_dependencies:
|
||||
|
||||
Reference in New Issue
Block a user