(Feat) Add google
This commit is contained in:
+4
-8
@@ -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,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;
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)),
|
||||
),
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user