diff --git a/lib/features/session/cubit/session_cubit.dart b/lib/features/session/cubit/session_cubit.dart index 3c41973..19fb0af 100644 --- a/lib/features/session/cubit/session_cubit.dart +++ b/lib/features/session/cubit/session_cubit.dart @@ -65,8 +65,32 @@ class SessionCubit extends AppCubit { final userCredential = await _auth.signInWithCredential(credential); _logger.d(userCredential.user?.toString()); } on FirebaseAuthException catch (e) { + emit(UnAuthenticated()); _handleException(e); } catch (e) { + await _googleSignIn.signOut(); + emit(UnAuthenticated()); + _logger.d(e.toString()); + } + } + + Future linkGoogleAccount() async { + final prevState = state; + try { + final account = await _googleSignIn.signIn(); + final authentication = await account?.authentication; + final credential = GoogleAuthProvider.credential( + accessToken: authentication?.accessToken, + idToken: authentication?.idToken, + ); + final user = _auth.currentUser; + await user?.linkWithCredential(credential); + } on FirebaseAuthException catch (e) { + emit(prevState); + await _googleSignIn.signOut(); + _handleException(e); + } catch (e) { + emit(prevState); await _googleSignIn.signOut(); _logger.d(e.toString()); } @@ -83,11 +107,40 @@ class SessionCubit extends AppCubit { password: password, ); _logger.d(userCredential.user?.toString()); + } on FirebaseAuthException catch (e) { + emit(UnAuthenticated()); + _handleException(e); + } catch (e) { + emit(UnAuthenticated()); + _logger.d(e.toString()); + } + } + + Future linkEmailPassword({ + required String email, + required String password, + String? name, + }) async { + try { + final user = _auth.currentUser; + final credential = EmailAuthProvider.credential( + email: email, + password: password, + ); + final ucredential = await user?.linkWithCredential(credential); + if (ucredential?.user != null) { + if (name != null) { + unawaited(ucredential?.user?.updateDisplayName(name)); + } + emit((state as Authenticated).copyWith(user: ucredential!.user!)); + } + return true; } on FirebaseAuthException catch (e) { _handleException(e); } catch (e) { _logger.d(e.toString()); } + return false; } Future signUpWithEmailAndPassword({ @@ -103,6 +156,20 @@ class SessionCubit extends AppCubit { ); await userCredential.user?.updateDisplayName(name); _logger.d(userCredential.user?.toString()); + } on FirebaseAuthException catch (e) { + emit(UnAuthenticated()); + _handleException(e); + } catch (e) { + emit(UnAuthenticated()); + _logger.d(e.toString()); + } + } + + Future signInAnonymously() async { + emit(Authenticating()); + try { + final userCredential = await _auth.signInAnonymously(); + _logger.d(userCredential.user?.toString()); } on FirebaseAuthException catch (e) { _handleException(e); } catch (e) { @@ -146,6 +213,8 @@ class SessionCubit extends AppCubit { 'operation-not-allowed' => 'Indicates that Email & Password accounts are not enabled.', 'weak-password' => 'The password must be 6 characters long or more.', + 'credential-already-in-use' => + 'This credential is already associated with a different user account.', (_) => 'An undefined Error happened.' }; AppSnackbar.show(message); diff --git a/lib/features/session/cubit/session_state.dart b/lib/features/session/cubit/session_state.dart index 5bf72ef..d838655 100644 --- a/lib/features/session/cubit/session_state.dart +++ b/lib/features/session/cubit/session_state.dart @@ -64,4 +64,6 @@ final class Authenticated extends SessionState { userData: userData ?? this.userData, ); } + + bool get isGuest => user.isAnonymous; } diff --git a/lib/features/session/ui/auth_page.dart b/lib/features/session/ui/auth_page.dart index a730576..67b5fa8 100644 --- a/lib/features/session/ui/auth_page.dart +++ b/lib/features/session/ui/auth_page.dart @@ -12,6 +12,8 @@ class AuthPage extends StatelessWidget { @override Widget build(BuildContext context) { + final isGuest = context.select((SessionCubit cubit) => + cubit.state is Authenticated && (cubit.state as Authenticated).isGuest); return Scaffold( body: SafeArea( child: Align( @@ -22,6 +24,7 @@ class AuthPage extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + const Spacer(), Assets.icon.appIconMonotone.svg( placeholderBuilder: (ctx) => const SizedBox(width: 48, height: 48), @@ -53,6 +56,18 @@ class AuthPage extends StatelessWidget { TextButton( onPressed: () => context.pushNamed(AppRoutes.login.name), child: _buildText(context, "Log in"), + ), + const Spacer(), + Visibility( + visible: !isGuest, + child: TextButton( + onPressed: () async { + await context.read().signInAnonymously(); + if (!context.mounted) return; + context.goNamed(AppRoutes.home.name); + }, + child: _buildText(context, "Continue as guest", true), + ), ) ], ), @@ -68,10 +83,20 @@ class AuthPage extends StatelessWidget { ); } - Text _buildText(BuildContext context, String text) { + Text _buildText(BuildContext context, String text, + [bool isUnderlined = false]) { + var style = + context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold); + if (isUnderlined) { + style = style?.copyWith( + decoration: TextDecoration.underline, + decorationColor: Colors.white, + decorationThickness: 2, + ); + } return Text( text, - style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold), + style: style, ); } } diff --git a/lib/features/session/ui/login_page.dart b/lib/features/session/ui/login_page.dart index 49d439a..aa5095d 100644 --- a/lib/features/session/ui/login_page.dart +++ b/lib/features/session/ui/login_page.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; import 'package:varanasi_mobile_app/features/session/cubit/session_cubit.dart'; +import 'package:varanasi_mobile_app/utils/app_snackbar.dart'; import 'package:varanasi_mobile_app/utils/extensions/extensions.dart'; import 'package:varanasi_mobile_app/widgets/input_field.dart'; @@ -142,15 +144,29 @@ class LoginButton extends StatelessWidget { @override Widget build(BuildContext context) { + final isGuestUser = context.select((SessionCubit cubit) => + cubit.state is Authenticated && (cubit.state as Authenticated).isGuest); return Align( alignment: Alignment.center, child: FilledButton.tonal( onPressed: isFormValid - ? () { - context.read().signInWithEmailAndPassword( - email: _emailController.text, - password: _passwordController.text, - ); + ? () async { + if (isGuestUser) { + final connected = + await context.read().linkEmailPassword( + email: _emailController.text, + password: _passwordController.text, + ); + if (connected && context.mounted && context.canPop()) { + context.pop(); + } + AppSnackbar.show("Account linked successfully."); + } else { + context.read().signInWithEmailAndPassword( + email: _emailController.text, + password: _passwordController.text, + ); + } } : null, style: FilledButton.styleFrom( @@ -159,9 +175,9 @@ class LoginButton extends StatelessWidget { horizontal: 36, ), ), - child: const Text( - 'Log in', - style: TextStyle(fontWeight: FontWeight.bold), + child: Text( + isGuestUser ? "Link" : 'Log in', + style: const TextStyle(fontWeight: FontWeight.bold), ), ), ); diff --git a/lib/features/session/ui/signup_page.dart b/lib/features/session/ui/signup_page.dart index 15f8ad5..c041fe1 100644 --- a/lib/features/session/ui/signup_page.dart +++ b/lib/features/session/ui/signup_page.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; import 'package:varanasi_mobile_app/features/session/cubit/session_cubit.dart'; +import 'package:varanasi_mobile_app/utils/app_snackbar.dart'; import 'package:varanasi_mobile_app/utils/extensions/extensions.dart'; import 'package:varanasi_mobile_app/widgets/input_field.dart'; @@ -161,18 +163,34 @@ class LoginButton extends StatelessWidget { @override Widget build(BuildContext context) { - final authenticating = context.select((SessionCubit cubit) => - cubit.state is Authenticating || cubit.state is Authenticated); + final isGuestUser = context.select((SessionCubit cubit) => + cubit.state is Authenticated && (cubit.state as Authenticated).isGuest); + final authenticating = context.select((SessionCubit cubit) { + return cubit.state is Authenticating; + }); return Align( alignment: Alignment.center, child: FilledButton.tonal( onPressed: isFormValid && !authenticating - ? () { - context.read().signUpWithEmailAndPassword( - email: emailController.text, - password: passwordController.text, - name: nameController.text, - ); + ? () async { + if (isGuestUser) { + final connected = + await context.read().linkEmailPassword( + email: emailController.text, + password: passwordController.text, + name: nameController.text, + ); + if (connected && context.mounted && context.canPop()) { + context.pop(); + } + AppSnackbar.show("Account linked successfully."); + } else { + context.read().signUpWithEmailAndPassword( + email: emailController.text, + password: passwordController.text, + name: nameController.text, + ); + } } : null, style: FilledButton.styleFrom( @@ -193,9 +211,9 @@ class LoginButton extends StatelessWidget { height: 24, child: CircularProgressIndicator(), ), - child: const Text( - 'Create Account', - style: TextStyle(fontWeight: FontWeight.bold), + child: Text( + isGuestUser ? 'Link your account' : 'Create Account', + style: const TextStyle(fontWeight: FontWeight.bold), ), ), ), diff --git a/lib/features/settings/ui/settings_page.dart b/lib/features/settings/ui/settings_page.dart index 69d3475..7e3518f 100644 --- a/lib/features/settings/ui/settings_page.dart +++ b/lib/features/settings/ui/settings_page.dart @@ -3,15 +3,18 @@ import 'package:flex_color_scheme/flex_color_scheme.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; import 'package:settings_ui/settings_ui.dart'; import 'package:varanasi_mobile_app/cubits/config/config_cubit.dart'; import 'package:varanasi_mobile_app/features/session/cubit/session_cubit.dart'; import 'package:varanasi_mobile_app/flavors.dart'; +import 'package:varanasi_mobile_app/gen/assets.gen.dart'; import 'package:varanasi_mobile_app/models/app_config.dart'; import 'package:varanasi_mobile_app/models/download_url.dart'; import 'package:varanasi_mobile_app/utils/clear_cache.dart'; import 'package:varanasi_mobile_app/utils/dialogs/app_dialog.dart'; import 'package:varanasi_mobile_app/utils/extensions/flex_scheme.dart'; +import 'package:varanasi_mobile_app/utils/routes.dart'; class SettingsPage extends StatefulWidget { const SettingsPage({super.key}); @@ -37,17 +40,55 @@ class _SettingsPageState extends State { final packageInfo = context.select( (ConfigCubit cubit) => cubit.configLoadedState.packageInfo, ); - final user = context.select( + final (user, isGuest) = context.select( (SessionCubit cubit) => switch (cubit.state) { - (Authenticated state) => state.user, - (_) => null, + (Authenticated state) => (state.user, state.isGuest), + (_) => (null, false), }, ); return Scaffold( appBar: AppBar(title: const Text("Settings"), centerTitle: true), body: SettingsList( sections: [ - SettingsSection( + _VisibileWhenSection( + visible: isGuest, + title: const Text("Account"), + tiles: [ + SettingsTile( + title: const Text("Link Email account"), + leading: const Icon(Icons.email_outlined), + onPressed: (context) { + context.pushNamed( + AppRoutes.signup.name, + queryParameters: {"forceLogin": "true"}, + ); + }, + ), + SettingsTile( + title: const Text("Link Google account"), + leading: Assets.icon.google.svg(width: 24, height: 24), + onPressed: (context) => + context.read().linkGoogleAccount(), + ), + SettingsTile( + title: const Text("Sign out"), + leading: const Icon(Icons.logout_outlined), + description: const Text("Signed in as Guest"), + onPressed: (context) { + AppDialog.showAlertDialog( + context: context, + title: "Sign out", + message: "Are you sure you want to sign out?", + onConfirm: () { + context.read().signOut(); + }, + ); + }, + ), + ], + ), + _VisibileWhenSection( + visible: !isGuest, title: const Text("Account"), tiles: [ SettingsTile( @@ -78,16 +119,17 @@ class _SettingsPageState extends State { title: const Text("Email"), value: Text(user?.email ?? ""), ), - SettingsTile( - leading: const Icon(Icons.image_outlined), - title: const Text("Avatar"), - trailing: CircleAvatar( - radius: 14, - backgroundImage: user?.photoURL == null - ? null - : NetworkImage(user?.photoURL ?? ""), + if (user?.photoURL != null) + SettingsTile( + leading: const Icon(Icons.image_outlined), + title: const Text("Avatar"), + trailing: CircleAvatar( + radius: 14, + backgroundImage: user?.photoURL == null + ? null + : NetworkImage(user?.photoURL ?? ""), + ), ), - ), SettingsTile( title: const Text("Sign out"), leading: const Icon(Icons.logout_outlined), @@ -169,11 +211,6 @@ class _SettingsPageState extends State { ); }, ), - // SettingsTile.navigation( - // title: const Text("Clear Recently Played"), - // leading: const Icon(Icons.delete_forever_outlined), - // onPressed: (_) => RecentMediaService.clearRecentMedia(), - // ), ], ), _VisibileWhenSection( diff --git a/lib/features/user-library/data/user_library_repository.dart b/lib/features/user-library/data/user_library_repository.dart index 9b78d15..cacdc58 100644 --- a/lib/features/user-library/data/user_library_repository.dart +++ b/lib/features/user-library/data/user_library_repository.dart @@ -28,15 +28,17 @@ class UserLibraryRepository with DataProviderProtocol { Future init() async {} - StreamSubscription>>? _subscription; + StreamSubscription? _subscription; void setupListeners() { - FirestoreService.getUserDocument() + _subscription = FirestoreService.getUserDocument() .collection('user-library') .snapshots() .map((event) => event.docs.map(MediaPlaylist.fromFirestore).toList()..sort()) - .pipe(_librariesStream); + .listen((event) { + _librariesStream.add(event); + }); } void dispose() { diff --git a/lib/utils/app_snackbar.dart b/lib/utils/app_snackbar.dart index 0eaac87..7a9d3c3 100644 --- a/lib/utils/app_snackbar.dart +++ b/lib/utils/app_snackbar.dart @@ -1,5 +1,6 @@ import 'package:another_flushbar/flushbar.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:varanasi_mobile_app/utils/helpers/get_app_context.dart'; class AppSnackbar { @@ -22,9 +23,11 @@ class AppSnackbar { /// /// If [context] is not provided, [appContext] is used. static void show(String message, [BuildContext? context]) { - dismiss(); - context ??= appContext; - _createFlushBar(message).show(context); + SchedulerBinding.instance.addPostFrameCallback((_) { + dismiss(); + context ??= appContext; + _createFlushBar(message).show(context!); + }); } /// Dismisses the [Flushbar] if it is visible. diff --git a/lib/utils/router.dart b/lib/utils/router.dart index 1412984..7a32f11 100644 --- a/lib/utils/router.dart +++ b/lib/utils/router.dart @@ -55,6 +55,7 @@ final routerConfig = GoRouter( refreshListenable: StreamListener(FirebaseAuth.instance.userChanges()), redirect: (context, state) { final session = context.read().state; + final forceLogin = state.uri.queryParameters['forceLogin'] == 'true'; final allowedLoggedOutRoutes = [ AppRoutes.authentication.name, AppRoutes.login.name, @@ -63,7 +64,7 @@ final routerConfig = GoRouter( final isInsideAuth = allowedLoggedOutRoutes.contains(state.matchedLocation); return switch (session) { (UnAuthenticated _) when !isInsideAuth => AppRoutes.authentication.path, - (Authenticated _) when isInsideAuth => AppRoutes.home.path, + (Authenticated _) when isInsideAuth && !forceLogin => AppRoutes.home.path, _ => null, }; }, @@ -198,12 +199,14 @@ final routerConfig = GoRouter( _pageWithBottomSheet(const AuthPage(), state.pageKey), routes: [ GoRoute( + parentNavigatorKey: rootNavigatorKey, name: AppRoutes.login.name, path: AppRoutes.login.path, pageBuilder: (_, state) => _pageWithBottomSheet(const LoginPage(), state.pageKey), ), GoRoute( + parentNavigatorKey: rootNavigatorKey, path: AppRoutes.signup.path, name: AppRoutes.signup.name, pageBuilder: (_, state) => diff --git a/lib/utils/services/recent_media_service.dart b/lib/utils/services/recent_media_service.dart index 05ae95f..4231b39 100644 --- a/lib/utils/services/recent_media_service.dart +++ b/lib/utils/services/recent_media_service.dart @@ -11,15 +11,19 @@ class RecentMediaService { static bool initialized = false; + static StreamSubscription? _subscription; + static setupListeners() { - FirestoreService.getUserDocument() + _subscription = FirestoreService.getUserDocument() .collection('recent_media') .snapshots() .map((s) => s.docs.map(MediaPlaylist.fromFirestore).toList()) - .pipe(_recentMediaSubject); + .listen((event) => _recentMediaSubject.add(event)); } - static disposeListeners() {} + static disposeListeners() { + _subscription?.cancel(); + } static final BehaviorSubject> _recentMediaSubject = BehaviorSubject>.seeded([]);