(Feat) Add google

This commit is contained in:
2026-07-18 13:11:03 +02:00
parent 01a0f1029f
commit 8dd5873f25
20 changed files with 596 additions and 379 deletions
+4 -8
View File
@@ -1,9 +1,8 @@
import 'package:flutter/material.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 {
@@ -11,11 +10,8 @@ Future<void> main() async {
await initializeDateFormatting('fr_FR', null);
if (SupabaseConfig.estConfigure) {
await Supabase.initialize(
url: SupabaseConfig.url,
publishableKey: SupabaseConfig.anonKey,
);
if (PbConfig.estConfigure) {
await initPocketBase();
}
runApp(const MonApp());
@@ -27,7 +23,7 @@ class MonApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Ma fidélité',
title: 'Le Trévériennais',
debugShowCheckedModeBanner: false,
theme: AppTheme.clair(),
darkTheme: AppTheme.sombre(),
+1 -1
View File
@@ -1,6 +1,6 @@
/// Le compte fidélité du client connecté. Le [code] est encodé dans son QR.
class Client {
final String id; // uuid Supabase
final String id; // id PocketBase
final String code; // ex : « FID-3F9K2A »
final String nom;
final String? prenom;
+1 -1
View File
@@ -32,7 +32,7 @@ class Mouvement {
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),
);
}
+43
View File
@@ -0,0 +1,43 @@
import 'package:pocketbase/pocketbase.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Configuration de connexion au backend PocketBase (LE MÊME que l'app magasin).
///
/// L'URL pointe vers l'instance PocketBase auto-hébergée. La session (token
/// d'auth) est persistée localement via [SharedPreferences] pour rester
/// connecté entre deux lancements de l'app.
class PbConfig {
PbConfig._();
static const String url = 'https://db.tailb756e1.ts.net';
static bool get estConfigure => !url.contains('VOTRE-URL');
}
/// ID client OAuth « Web » du projet Google Cloud. Passé à google_sign_in comme
/// `serverClientId` → l'ID token renvoyé a cette audience, que le hook serveur
/// PocketBase (`/api/google-native-auth`) vérifie.
const String googleWebClientId =
'831733439319-45cergb7cf8r728fja4m0kcvu2r7fbj9.apps.googleusercontent.com';
/// Client PocketBase global (initialisé une fois via [initPocketBase]).
late final PocketBase pb;
/// Initialise le client PocketBase avec une auth store persistée sur disque.
/// À appeler au démarrage, avant `runApp`.
Future<void> initPocketBase() async {
final prefs = await SharedPreferences.getInstance();
final store = AsyncAuthStore(
save: (String data) async => prefs.setString('pb_auth', data),
clear: () async => prefs.remove('pb_auth'),
initial: prefs.getString('pb_auth'),
);
pb = PocketBase(PbConfig.url, authStore: store);
}
/// Extrait un message lisible d'une erreur PocketBase (sinon [parDefaut]).
String messageErreurPb(ClientException e, String parDefaut) {
final data = e.response['message'];
if (data is String && data.trim().isNotEmpty) return data;
return parDefaut;
}
+10 -10
View File
@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:pocketbase/pocketbase.dart';
import '../pocketbase_config.dart';
import '../services/session_client.dart';
import '../supabase_config.dart';
import 'complete_profile_screen.dart';
import 'home_client_screen.dart';
import 'welcome_screen.dart';
@@ -13,14 +13,14 @@ class AuthGate extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (!SupabaseConfig.estConfigure) return const _EcranNonConfigure();
if (!PbConfig.estConfigure) return const _EcranNonConfigure();
return StreamBuilder<AuthState>(
stream: supabase.auth.onAuthStateChange,
return StreamBuilder<AuthStoreEvent>(
stream: pb.authStore.onChange,
builder: (context, snapshot) {
final session = supabase.auth.currentSession;
if (session == null) return const WelcomeScreen();
return _SessionChargee(key: ValueKey(session.user.id));
if (!pb.authStore.isValid) return const WelcomeScreen();
final uid = pb.authStore.record?.id ?? '';
return _SessionChargee(key: ValueKey(uid));
},
);
}
@@ -82,11 +82,11 @@ class _EcranNonConfigure extends StatelessWidget {
children: [
Icon(Icons.cloud_off, size: 56, color: Colors.grey),
SizedBox(height: 16),
Text('Supabase non configuré',
Text('Backend 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.',
'Renseignez l\'URL PocketBase dans lib/pocketbase_config.dart.',
textAlign: TextAlign.center,
style: TextStyle(color: Color(0xFF6B6B72)),
),
+2 -2
View File
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import '../pocketbase_config.dart';
import '../services/session_client.dart';
import '../supabase_config.dart';
import '../theme.dart';
/// Affiché quand le compte est connecté mais n'a pas encore de fiche fidélité
@@ -61,7 +61,7 @@ class _CompleteProfileScreenState extends State<CompleteProfileScreen> {
IconButton(
tooltip: 'Se déconnecter',
icon: const Icon(Icons.logout),
onPressed: () => supabase.auth.signOut(),
onPressed: () => pb.authStore.clear(),
),
],
),
+2 -2
View File
@@ -3,8 +3,8 @@ import 'package:qr_flutter/qr_flutter.dart';
import '../models/mouvement.dart';
import '../models/recompense.dart';
import '../pocketbase_config.dart';
import '../services/session_client.dart';
import '../supabase_config.dart';
import '../theme.dart';
import '../utils/format.dart';
@@ -58,7 +58,7 @@ class _HomeClientScreenState extends State<HomeClientScreen>
);
if (ok == true) {
_session.vider();
await supabase.auth.signOut();
pb.authStore.clear();
}
}
+11 -8
View File
@@ -1,7 +1,7 @@
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';
/// Connexion d'un client existant (email + mot de passe).
class LoginScreen extends StatefulWidget {
@@ -33,14 +33,17 @@ class _LoginScreenState extends State<LoginScreen> {
_erreur = null;
});
try {
await supabase.auth.signInWithPassword(
email: _emailCtrl.text.trim(),
password: _mdpCtrl.text,
);
await pb.collection('users').authWithPassword(
_emailCtrl.text.trim(),
_mdpCtrl.text,
);
// L'AuthGate prend le relais automatiquement (pop de cet écran).
if (mounted) Navigator.of(context).pop();
} on AuthException catch (e) {
if (mounted) setState(() => _erreur = e.message);
} on ClientException catch (e) {
if (mounted) {
setState(() =>
_erreur = messageErreurPb(e, 'Email ou mot de passe incorrect.'));
}
} catch (e) {
if (mounted) setState(() => _erreur = 'Erreur : $e');
} finally {
+23 -40
View File
@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:pocketbase/pocketbase.dart';
import '../pocketbase_config.dart';
import '../services/session_client.dart';
import '../supabase_config.dart';
import '../theme.dart';
/// Création d'un compte client : email + mot de passe + profil (nom, téléphone).
@@ -42,27 +42,29 @@ class _SignupScreenState extends State<SignupScreen> {
_erreur = null;
});
try {
final res = await supabase.auth.signUp(
email: _emailCtrl.text.trim(),
password: _mdpCtrl.text,
);
final email = _emailCtrl.text.trim();
// Création du compte, puis connexion immédiate (pas de confirmation
// d'email requise). On ne transmet PAS is_staff → compte client par défaut.
await pb.collection('users').create(body: {
'email': email,
'password': _mdpCtrl.text,
'passwordConfirm': _mdpCtrl.text,
});
await pb.collection('users').authWithPassword(email, _mdpCtrl.text);
if (res.session != null) {
// Connecté directement (confirmation d'email désactivée) → on crée le
// profil fidélité. L'AuthGate basculera ensuite vers l'accueil.
await SessionClient.instance.creerProfil(
nom: _nomCtrl.text,
prenom: _prenomCtrl.text,
telephone: _telCtrl.text,
);
if (mounted) Navigator.of(context).pop();
} else {
// Confirmation d'email requise : le profil sera finalisé à la 1re
// connexion (après validation du mail).
if (mounted) await _popupVerifierEmail();
// Compte connecté → on crée le profil fidélité (nom + téléphone + code).
// L'AuthGate basculera ensuite automatiquement vers l'accueil.
await SessionClient.instance.creerProfil(
nom: _nomCtrl.text,
prenom: _prenomCtrl.text,
telephone: _telCtrl.text,
);
if (mounted) Navigator.of(context).pop();
} on ClientException catch (e) {
if (mounted) {
setState(() => _erreur =
messageErreurPb(e, 'Impossible de créer le compte.'));
}
} on AuthException catch (e) {
if (mounted) setState(() => _erreur = e.message);
} catch (e) {
if (mounted) setState(() => _erreur = 'Erreur : $e');
} finally {
@@ -70,25 +72,6 @@ class _SignupScreenState extends State<SignupScreen> {
}
}
Future<void> _popupVerifierEmail() async {
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Vérifiez votre email'),
content: const Text(
'Un email de confirmation vous a été envoyé. Validez-le puis '
'connectez-vous pour finaliser votre carte de fidélité.'),
actions: [
FilledButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('OK'),
),
],
),
);
if (mounted) Navigator.of(context).pop(); // retour à l'accueil
}
@override
Widget build(BuildContext context) {
return Scaffold(
+3
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../theme.dart';
import '../widgets/oauth_boutons.dart';
import 'login_screen.dart';
import 'signup_screen.dart';
@@ -57,6 +58,8 @@ class WelcomeScreen extends StatelessWidget {
),
child: const Text('J\'ai déjà un compte'),
),
const SizedBox(height: 16),
const OAuthBoutons(),
const SizedBox(height: 12),
],
),
+89 -40
View File
@@ -1,9 +1,13 @@
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:pocketbase/pocketbase.dart';
import '../models/client.dart';
import '../models/mouvement.dart';
import '../models/recompense.dart';
import '../supabase_config.dart';
import '../pocketbase_config.dart';
/// Données du client connecté (son compte, ses points, son historique) + le
/// contexte magasin (nom, récompenses proposées). Source de vérité côté client.
@@ -34,47 +38,46 @@ class SessionClient extends ChangeNotifier {
_erreur = null;
notifyListeners();
try {
final uid = supabase.auth.currentUser?.id;
final uid = pb.authStore.record?.id;
if (uid == null) {
_monClient = null;
return;
}
final row = await supabase
.from('clients')
.select()
.eq('user_id', uid)
.maybeSingle();
_monClient = row == null ? null : Client.fromMap(row);
// Contexte magasin (lisible par tout compte connecté).
final reglages = await supabase
.from('reglages')
.select('euros_par_point, nom_magasin')
.eq('id', 1)
.maybeSingle();
if (reglages != null) {
_eurosParPoint =
(reglages['euros_par_point'] as num?)?.toDouble() ?? 0;
_nomMagasin = (reglages['nom_magasin'] as String?) ?? '';
// Fiche fidélité du compte (une par utilisateur). Absente → profil à créer.
try {
final row = await pb
.collection('clients')
.getFirstListItem('user = "$uid"');
_monClient = Client.fromMap(row.toJson());
} on ClientException catch (e) {
if (e.statusCode == 404) {
_monClient = null;
} else {
rethrow;
}
}
final recs = await supabase
.from('recompenses')
.select()
.eq('actif', true)
.order('cout_points');
_recompenses =
(recs as List).map((e) => Recompense.fromMap(e)).toList();
// Contexte magasin (lisible par tout compte connecté) : ligne unique.
final reglages = await pb.collection('reglages').getList(perPage: 1);
if (reglages.items.isNotEmpty) {
final r = reglages.items.first;
_eurosParPoint = (r.data['euros_par_point'] as num?)?.toDouble() ?? 0;
_nomMagasin = (r.data['nom_magasin'] as String?) ?? '';
}
final recs = await pb.collection('recompenses').getFullList(
filter: 'actif = true',
sort: 'cout_points',
);
_recompenses = recs.map((e) => Recompense.fromMap(e.toJson())).toList();
if (_monClient != null) {
final mvts = await supabase
.from('mouvements')
.select()
.eq('client_id', _monClient!.id)
.order('created_at', ascending: false);
_mouvements =
(mvts as List).map((e) => Mouvement.fromMap(e)).toList();
final mvts = await pb.collection('mouvements').getFullList(
filter: 'client = "${_monClient!.id}"',
sort: '-created',
);
_mouvements = mvts.map((e) => Mouvement.fromMap(e.toJson())).toList();
} else {
_mouvements = [];
}
@@ -86,26 +89,72 @@ class SessionClient extends ChangeNotifier {
}
}
/// Crée le profil fidélité du compte connecté (nom + téléphone). Le code et le
/// QR sont générés côté base. Utilisé à l'inscription / finalisation de profil.
/// Crée le profil fidélité du compte connecté (nom + téléphone). Le code de
/// fidélité (encodé dans le QR) est généré ici, côté client, car le backend
/// n'en fournit pas. Utilisé à l'inscription / finalisation de profil.
Future<void> creerProfil({
required String nom,
String? prenom,
String? telephone,
}) async {
final uid = supabase.auth.currentUser?.id;
final uid = pb.authStore.record?.id;
if (uid == null) throw Exception('Non connecté');
String? ouNull(String? v) =>
(v == null || v.trim().isEmpty) ? null : v.trim();
await supabase.from('clients').insert({
'user_id': uid,
await pb.collection('clients').create(body: {
'user': uid,
'code': _genererCode(),
'nom': nom.trim(),
'prenom': ouNull(prenom),
'telephone': ouNull(telephone),
if (ouNull(prenom) != null) 'prenom': ouNull(prenom),
if (ouNull(telephone) != null) 'telephone': ouNull(telephone),
});
await charger();
}
bool _googleInit = false;
/// Connexion / inscription native avec Google (sélecteur de compte in-app).
///
/// Récupère l'ID token Google via Credential Manager, l'envoie au hook
/// PocketBase `/api/google-native-auth` qui vérifie le token et renvoie une
/// session PocketBase. Un nouveau compte n'a pas encore de fiche fidélité →
/// l'AuthGate affichera l'écran « Finaliser mon profil ».
Future<void> connexionGoogleNative() async {
if (!_googleInit) {
await GoogleSignIn.instance
.initialize(serverClientId: googleWebClientId);
_googleInit = true;
}
final account = await GoogleSignIn.instance.authenticate();
final idToken = account.authentication.idToken;
if (idToken == null) {
throw Exception('Jeton Google introuvable');
}
final reponse = await pb.send(
'/api/google-native-auth',
method: 'POST',
body: {'idToken': idToken},
);
final map = reponse as Map<String, dynamic>;
pb.authStore.save(
map['token'] as String,
RecordModel.fromJson(map['record'] as Map<String, dynamic>),
);
}
/// Génère un code de fidélité lisible, ex. « FID-3F9K2A ».
static String _genererCode() {
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // sans I,O,0,1
final rnd = Random.secure();
final suffixe = List.generate(
6,
(_) => alphabet[rnd.nextInt(alphabet.length)],
).join();
return 'FID-$suffixe';
}
void vider() {
_monClient = null;
_mouvements = [];
-19
View File
@@ -1,19 +0,0 @@
import 'package:supabase_flutter/supabase_flutter.dart';
/// Configuration de connexion au backend Supabase (LE MÊME que l'app magasin).
///
/// ⚠️ Renseigner les valeurs de VOTRE projet Supabase :
/// Project Settings → API → « Project URL » et « anon public ».
/// Voir le guide : ../app_fideliter/supabase/SETUP.md
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;
+124
View File
@@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../services/session_client.dart';
import '../theme.dart';
/// Logo « G » officiel de Google (SVG des guidelines de marque). Ne pas altérer
/// (couleurs / proportions) sous peine de non-conformité (validation Play Store).
const String _googleGSvg = '''
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
<path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/>
<path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/>
<path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"/>
<path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"/>
</svg>
''';
/// Bouton officiel « Se connecter avec Google » (connexion native, sélecteur de
/// compte in-app), conforme aux guidelines de marque Google. Un compte inconnu
/// est créé côté serveur puis dirigé vers l'écran « Finaliser mon profil ».
class OAuthBoutons extends StatefulWidget {
const OAuthBoutons({super.key});
@override
State<OAuthBoutons> createState() => _OAuthBoutonsState();
}
class _OAuthBoutonsState extends State<OAuthBoutons> {
bool _enCours = false;
Future<void> _google() async {
setState(() => _enCours = true);
try {
await SessionClient.instance.connexionGoogleNative();
// Succès : l'AuthGate bascule automatiquement (ce widget est démonté).
} catch (e) {
if (mounted) {
setState(() => _enCours = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Connexion Google annulée ou échouée.')),
);
}
}
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Row(
children: [
const Expanded(child: Divider()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text('ou',
style: TextStyle(color: AppTheme.grisTexte, fontSize: 13)),
),
const Expanded(child: Divider()),
],
),
const SizedBox(height: 12),
_BoutonGoogle(enCours: _enCours, onPressed: _enCours ? null : _google),
],
);
}
}
/// Rendu conforme charte Google : fond blanc, bordure #747775, logo G 18dp,
/// libellé Roboto Medium #1F1F1F.
class _BoutonGoogle extends StatelessWidget {
const _BoutonGoogle({required this.enCours, required this.onPressed});
final bool enCours;
final VoidCallback? onPressed;
static const Color _texte = Color(0xFF1F1F1F);
static const Color _bordure = Color(0xFF747775);
@override
Widget build(BuildContext context) {
return Material(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: onPressed,
child: Ink(
height: 52,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: _bordure),
),
child: Center(
child: enCours
? const SizedBox(
height: 22,
width: 22,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
SvgPicture.string(_googleGSvg, width: 18, height: 18),
const SizedBox(width: 12),
const Text(
'Se connecter avec Google',
style: TextStyle(
color: _texte,
fontSize: 15,
fontWeight: FontWeight.w500,
fontFamily: 'Roboto',
),
),
],
),
),
),
),
);
}
}