diff --git a/dogfooding/lib/core/model/environment.dart b/dogfooding/lib/core/model/environment.dart index 26e42e93d..28acf2570 100644 --- a/dogfooding/lib/core/model/environment.dart +++ b/dogfooding/lib/core/model/environment.dart @@ -70,6 +70,9 @@ enum Environment { final List aliases; final List baseUrls; + /// Whether this is a Pronto environment. + bool get isPronto => envName == 'pronto'; + String? getJoinUrl({required String callId, String? callType}) { switch (this) { case Environment.pronto: diff --git a/dogfooding/lib/core/repos/user_auth_repository.dart b/dogfooding/lib/core/repos/user_auth_repository.dart index 3bddab75e..dacfa09ce 100644 --- a/dogfooding/lib/core/repos/user_auth_repository.dart +++ b/dogfooding/lib/core/repos/user_auth_repository.dart @@ -17,13 +17,14 @@ class UserAuthRepository { Future login() async { final response = await videoClient.connect(); - return response.fold( - success: (success) { - return UserCredentials(token: success.data, userInfo: currentUser); - }, - failure: (failure) { - throw failure.error; - }, + if (response is Failure) { + await videoClient.disconnect(); + throw response.error; + } + + return UserCredentials( + token: (response as Success).data, + userInfo: currentUser, ); } diff --git a/dogfooding/lib/di/injector.dart b/dogfooding/lib/di/injector.dart index 5299b0c9d..2e35c9c77 100644 --- a/dogfooding/lib/di/injector.dart +++ b/dogfooding/lib/di/injector.dart @@ -26,11 +26,15 @@ class AppInjector { // Register dependencies static Future init({Environment? forceEnvironment}) async { - // Google sign in - locator.registerSingletonAsync(() async { - await GoogleSignIn.instance.initialize(hostedDomain: 'getstream.io'); - return GoogleSignIn.instance; - }); + // Google sign in. On web we use Firebase Auth's popup flow instead of the + // google_sign_in plugin (see LoginScreen), so we only initialize the + // plugin on native platforms. + if (!kIsWeb) { + locator.registerSingletonAsync(() async { + await GoogleSignIn.instance.initialize(hostedDomain: 'getstream.io'); + return GoogleSignIn.instance; + }); + } // App Preferences final prefs = await SharedPreferences.getInstance(); diff --git a/dogfooding/lib/screens/login_screen.dart b/dogfooding/lib/screens/login_screen.dart index 6ff35471e..c4ca1caea 100644 --- a/dogfooding/lib/screens/login_screen.dart +++ b/dogfooding/lib/screens/login_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:math' as math; +import 'package:firebase_auth/firebase_auth.dart' as fb; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; @@ -28,48 +29,120 @@ class LoginScreen extends StatefulWidget { class _LoginScreenState extends State { final _appPreferences = locator(); - final _emailController = TextEditingController(); + final _usernameController = TextEditingController(); - Future _loginWithGoogle() async { - final googleService = locator(); + StreamSubscription? _googleAuthSubscription; + bool _isLoggingIn = false; + + @override + void initState() { + super.initState(); + unawaited(_initGoogleSignIn()); + } + + /// Subscribes to Google auth events so the result of the mobile/macOS + /// `authenticate()` call is handled. Web uses Firebase Auth's popup flow + /// instead (see [_loginWithGoogleWeb]), so there is no google_sign_in plugin + /// to subscribe to there. + Future _initGoogleSignIn() async { + if (kIsWeb) return; + + final googleService = await locator.getAsync(); + _googleAuthSubscription = googleService.authenticationEvents.listen( + _handleGoogleAuthEvent, + onError: (Object e) { + debugPrint('Google auth event error: $e'); + _showSnackBar('Google sign-in failed: $e'); + }, + ); + } + + void _handleGoogleAuthEvent(GoogleSignInAuthenticationEvent event) { + if (event is! GoogleSignInAuthenticationEventSignIn) return; + + final googleUser = event.user; + unawaited( + _completeGoogleLogin( + email: googleUser.email, + name: googleUser.displayName ?? '', + image: googleUser.photoUrl, + ), + ); + } - GoogleSignInAccount? googleUser; + /// Triggers an interactive Google sign-in from the button tap. + Future _loginWithGoogle() async { try { - googleUser = await googleService.attemptLightweightAuthentication(); + if (kIsWeb) { + await _loginWithGoogleWeb(); + return; + } + + final googleService = await locator.getAsync(); + if (googleService.supportsAuthenticate()) { + // Result delivered via authenticationEvents -> _handleGoogleAuthEvent. + await googleService.authenticate(); + } } catch (e) { - debugPrint('Google lightweight auth failed: $e'); + debugPrint('Google sign-in failed: $e'); + _showSnackBar('Google sign-in failed: $e'); } + } - if (googleUser == null && googleService.supportsAuthenticate()) { - try { - googleUser = await googleService.authenticate(); - } catch (e) { - return debugPrint('Google sign-in failed: $e'); + /// Web Google sign-in via Firebase Auth's popup flow. + Future _loginWithGoogleWeb() async { + try { + final provider = fb.GoogleAuthProvider() + // Hints Google to restrict the account chooser to the Stream domain. + ..setCustomParameters(const {'hd': 'getstream.io'}); + + final credential = + await fb.FirebaseAuth.instance.signInWithPopup(provider); + final user = credential.user; + if (user == null) { + debugPrint('Google sign-in returned no user'); + _showSnackBar('Google sign-in failed: no user returned.'); + return; } + + await _completeGoogleLogin( + email: user.email, + name: user.displayName ?? '', + image: user.photoURL, + ); + } catch (e) { + debugPrint('Google sign-in failed: $e'); + _showSnackBar('Google sign-in failed: $e'); } + } - if (googleUser == null) { - return debugPrint('Google login cancelled or unavailable'); + Future _completeGoogleLogin({ + required String? email, + required String name, + String? image, + }) async { + if (email == null || email.isEmpty) { + debugPrint('Google sign-in returned no email'); + _showSnackBar('Google sign-in failed: no email address returned.'); + return; } final userInfo = UserInfo( - role: 'admin', - id: createValidId(googleUser.email), - name: googleUser.displayName ?? '', - image: googleUser.photoUrl, + id: createValidId(email), + name: name, + image: image, ); - return _login(User(info: userInfo), _appPreferences.environment); + await _login(User(info: userInfo), _appPreferences.environment); } - Future _loginWithEmail() async { - final email = _emailController.text; - if (email.isEmpty) return debugPrint('Email is empty'); + Future _loginWithUsername() async { + final username = _usernameController.text; + if (username.isEmpty) return debugPrint('Username is empty'); final userInfo = UserInfo( - role: 'admin', - id: createValidId(email), - name: email, + id: createValidId(username), + name: username, ); return _login(User(info: userInfo), _appPreferences.environment); @@ -78,7 +151,6 @@ class _LoginScreenState extends State { Future _loginAsGuest() async { final userId = randomId(size: 6); final userInfo = UserInfo( - role: 'admin', id: userId, name: userId, image: @@ -92,6 +164,9 @@ class _LoginScreenState extends State { } Future _login(User user, Environment environment) async { + if (_isLoggingIn) return; + _isLoggingIn = true; + if (mounted) unawaited(showLoadingIndicator(context)); // Register StreamVideo client with the user. @@ -100,21 +175,27 @@ class _LoginScreenState extends State { try { await authController.login(user, environment); } catch (e, _) { - if (mounted) { - hideLoadingIndicator(context); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - duration: const Duration(seconds: 20), - content: Text('Error: $e'), - ), - ); - } + if (mounted) hideLoadingIndicator(context); + _showSnackBar('Error: $e'); + } finally { + _isLoggingIn = false; } } + void _showSnackBar(String message) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + duration: const Duration(seconds: 20), + content: Text(message), + ), + ); + } + @override void dispose() { - _emailController.dispose(); + unawaited(_googleAuthSubscription?.cancel()); + _usernameController.dispose(); super.dispose(); } @@ -131,6 +212,8 @@ class _LoginScreenState extends State { if (!kIsProd) EnvironmentSwitcher( currentEnvironment: _appPreferences.environment, + // Rebuild so the Google button visibility tracks the environment. + onEnvironmentChanged: (_) => setState(() {}), ), ], ), @@ -162,12 +245,12 @@ class _LoginScreenState extends State { Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: TextField( - controller: _emailController, + controller: _usernameController, style: theme.textTheme.bodyMedium?.apply( color: Colors.white, ), decoration: const InputDecoration( - labelText: 'Enter Email', + labelText: 'Enter Username', isDense: true, border: OutlineInputBorder(), ), @@ -177,12 +260,12 @@ class _LoginScreenState extends State { Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: StreamButton.active( - label: 'Sign up with email', + label: 'Sign up with username', icon: const Icon( - Icons.email_outlined, + Icons.person_outline, color: Colors.white, ), - onPressed: _loginWithEmail, + onPressed: _loginWithUsername, ), ), const SizedBox(height: 16), @@ -203,11 +286,15 @@ class _LoginScreenState extends State { ], ), ), - const SizedBox(height: 16), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: GoogleLoginButton(onPressed: _loginWithGoogle), - ), + // Google (Stream employee) sign-in is only available on + // Pronto environments. + if (_appPreferences.environment.isPronto) ...[ + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: GoogleLoginButton(onPressed: _loginWithGoogle), + ), + ], const SizedBox(height: 16), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), diff --git a/dogfooding/lib/widgets/environment_switcher.dart b/dogfooding/lib/widgets/environment_switcher.dart index 7299aacbf..070b66543 100644 --- a/dogfooding/lib/widgets/environment_switcher.dart +++ b/dogfooding/lib/widgets/environment_switcher.dart @@ -7,10 +7,17 @@ import '../di/injector.dart'; import '../theme/app_palette.dart'; class EnvironmentSwitcher extends StatefulWidget { - const EnvironmentSwitcher({super.key, required this.currentEnvironment}); + const EnvironmentSwitcher({ + super.key, + required this.currentEnvironment, + this.onEnvironmentChanged, + }); final Environment currentEnvironment; + /// Called after the user selects a different environment. + final ValueChanged? onEnvironmentChanged; + @override State createState() => _EnvironmentSwitcherState(); } @@ -84,6 +91,8 @@ class _EnvironmentSwitcherState extends State { setState(() { selectedEnvironment = env; }); + + widget.onEnvironmentChanged?.call(env); }, child: Container( width: 100, diff --git a/dogfooding/web/index.html b/dogfooding/web/index.html index cb0f84ae7..d66caa627 100644 --- a/dogfooding/web/index.html +++ b/dogfooding/web/index.html @@ -27,11 +27,6 @@ - - - - @@ -62,21 +57,6 @@ }); - - - -