Search code examples
flutterblocflutter-bloccubitflutter-cubit

How to I handle UI async processes with a Bloc StreamSubscription?


So I have a flutter app which uses Bloc pattern. I'm still kind of new with how Blocs works compared to Cubits. But I get the general idea (events vs functions).

The problem is with an async process in the login flow, I'm unable to make my View wait for the whole async process, which involves an extra API call (inside a StreamSubscription.listen({//api call here})) after the user is authenticated with firebase.

Classes:

  • View: View containing a button triggering process
  • LoginCubit: Corresponding cubit for View class
  • AppBloc: General Bloc for the app
  • AppEvent: No need to show to keep this short. It just has the event classes definitions

So, I have a button that triggers the authentication process:

class View extends StatefulWidget {
  const View({super.key});

  @override
  State<View> createState() => _ViewState();
}

class _ViewState extends State<View> {
  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (context) => LoginCubit(),
      child: ElevatedButton(
        child: Text('Click Me'),
        onPressed: () async {
          context.loaderOverlay.show();
          await context.read<LoginCubit>().loginWithEmailAndPassword(context);
          context.loaderOverlay.hide();
        },
      ),
    );
  }
}

This View class has it's own Cubit (not bloc) to handle the onPressed callback of the ElevatedButton:

class LoginCubit extends Cubit<LogInState> {
  LoginCubit(this._authenticationRepository) : super(const LogInState());
  final AuthenticationRepository _authenticationRepository;

  Future<void> loginWithEmailAndPassword(BuildContext context) async {
    var emitNavigation = false;
    try {
       //After the following call is completed, _AppUserChanged Bloc event is triggered to set user 
       //info in AppState class

      await _authenticationRepository
          .logInWithEmailAndPassword(
              email: '[email protected]', password: 'Password')
          
        // This does not waits for extra API call of the _userSubcription stream .listen
        emit(state.copyWith(
            loginStatus: FormzStatus.submissionSuccess,
            successMessage:
                '${LocaleKeys.successSignInMessage.tr()}${context.read<AppBloc>().state.userDocument.fullName}'));
      });
    } on LogInWithEmailAndPasswordFailure catch (error) {
      // ...
    } catch (_) {
     // ...
    }
  }

}

So as one of the comment mentions, the emit with the successful message does not waits for the userDocument model to be queried and mapped. I can tell because the message has NULL as fullname or just gives an error if I apply null check to it. The async process that creates the userDocument is defined as an event on the .listen() of the StreamSubscription defined in the AppBloc class:

class AppBloc extends Bloc<AppEvent, AppState> {
  AppBloc({required AuthenticationRepository authenticationRepository})
      : _authenticationRepository = authenticationRepository,
        super(
          authenticationRepository.currentUser.isNotEmpty
              ? AppState.authenticated(
                  authenticationRepository.currentUser, null)
              : const AppState.unauthenticated(),
        ) {
    on<_AppUserChanged>(_onUserChanged);
    on<_AppUserDocumentChanged>(_onUserDocumentChanged); // Event definition
    on<AppLogoutRequested>(_onLogoutRequested);
    _userSubscription = _authenticationRepository.user.listen(
      (user) {
        add(_AppUserChanged(user));

        // -> userDocument creation event, needs the user id of the just authenticated user
        add(_AppUserDocumentChanged(user.id));
      },
    );
  }

  final AuthenticationRepository _authenticationRepository;
  late final StreamSubscription<FirebaseAuthUser> _userSubscription;

  void _onUserChanged(_AppUserChanged event, Emitter<AppState> emit) {
    emit(
      event.user.isNotEmpty
          ? AppState.authenticated(event.user, null)
          : const AppState.unauthenticated(),
    );
  }

  Future<void> _onUserDocumentChanged(
      _AppUserDocumentChanged event, Emitter<AppState> emit) async {
    if (event.userId.isEmpty) {
      const AppState.unauthenticated();
    } else {
      try {
        // -> The aysnc flow works fine until it runs the following line, when this is
       // executed, the flow continues with the emit in the LoginCubit wihout waiting for the next two lines to be executued (which I need)

        final userInfo =
            await _authenticationRepository.getUserDocumentById(event.userId);

        emit(AppState.authenticated(state.user, userInfo));
      } catch (e) {
        print(e);
      }
    }
  }

  void _onLogoutRequested(AppLogoutRequested event, Emitter<AppState> emit) {
    unawaited(_authenticationRepository.logOut());
  }

  @override
  Future<void> close() {
    _userSubscription.cancel();
    return super.close();
  }
}

Just read the couple comments detailed in this. I also tried to implement this in single event, like using the _onUserChanged event for the whoel process but it did not work as well. Any idea of how to fix this or what am I doing wrong?

Here's the actual authentication authenticationRepository method:

 Future<void> logInWithEmailAndPassword({
    required String email,
    required String password,
  }) async {
    try {
      // this triggers bloc event _AppUserChanged
      await _firebaseAuth.signInWithEmailAndPassword(
        email: email,
        password: password,
      );
    } on firebase_auth.FirebaseAuthException catch (e) {
      // ...
    } catch (_) {
      // ...
    }
  }

and the user stream getter:

  Stream<FirebaseAuthUser> get user {
    return _firebaseAuth.authStateChanges().asyncMap((firebaseUser) {
      final user =
          firebaseUser == null ? FirebaseAuthUser.empty : firebaseUser.toUser;
      _cache.write(key: userCacheKey, value: user);
      return user;
    });
  }

UPDATE

@Boseong 's answer (ideal method) worked, but there's another problem. In the scenario of having an active session and rebuilding the app, I would need to redo the same async call in order to map userDocument.


Solution

  • ideal method: use repository to sync data

    It's not recommended that use BuildContext in BLoC (or Cubit). Because BLoC is a Business Logic which is not related with render tree.

    So, try use repository to sync data, or sync data in render(widget) tree.

    await _authenticationRepository.logInWithEmailAndPassword(...);
    final user = await _authenticationRepository.user.first;
    final userDocument = await _authenticationRepository.getUserDocumentById(user.id);
    final message = LocaleKeys.successSignInMessage.tr();
    final fullName = userDocument.fullName;
    emit(state.copyWith(
        loginStatus: FormzStatus.submissionSuccess,
        successMessage: '$message$fullName',
    ));
    

    another method: wait until bloc state change

    if you want to use BuildContext in LoginCubit, wait until AppState in AppBloc changed authenticated with UserDocument property.

    sample code

    await _authenticationRepository
        .logInWithEmailAndPassword(
            email: '[email protected]', password: 'password');
    await context.read<AppBloc>.stream.firstWhere(
      (state) => state.userInfo != null,
    );
    final message = LocaleKeys.successSignInMessage.tr();
    final fullName = context.read<AppBloc>().state.userDocument.fullName;
    emit(state.copyWith(
        loginStatus: FormzStatus.submissionSuccess,
        successMessage: '$message$fullName',
    ));