Flutter BlocListener executed only once even after event gets re-fired
Asked Answered
G

2

10

I am implementing Reso Coder's clean architecture in flutter. I followed his guides in dividing the project to layers and using dependency injection. In one of the cases I want to have the following scenario: An administrator user logs in, sees data on their home screen, edits it and by pressing a button, saves the data to the local db (sqflite). Upon saving the data I want to show a Snackbar with some sort of text "Settings saved!" for example. Here's my code (parts):

class AdministratorPage extends StatefulWidget {
  @override
  _AdministratorPageState createState() => _AdministratorPageState();
}

class _AdministratorPageState extends State<AdministratorPage> {
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).backgroundColor,
        centerTitle: true,
        leading: Container(),
        title: Text(AppLocalizations.of(context).translate('adminHomeScreen')),
      ),
      body: SingleChildScrollView(
        child: buildBody(context),
      ),
    );
  }

  BlocProvider<SettingsBloc> buildBody(BuildContext context) {
    return BlocProvider(
      create: (_) => serviceLocator<SettingsBloc>(),
      child: BlocListener<SettingsBloc, SettingsState>(
        listener: (context, state) {
          if (state is SettingsUpdatedState) {
            Scaffold.of(context).showSnackBar(
              SnackBar(
                content: Text(
                    AppLocalizations.of(context).translate('settingsUpdated')),
                backgroundColor: Colors.blue,
              ),
            );
          }
        },
        child: Column(
          children: <Widget>[
            SizedBox(
              height: 20.0,
            ),
            AdministratorInput(),
            SizedBox(
              width: double.infinity,
              child: RaisedButton(
                child: Text('LOG OUT'),
                onPressed: () {
                  serviceLocator<AuthenticationBloc>().add(LoggedOutEvent());
                  Routes.sailor(Routes.loginScreen);
                },
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Here's the AdministratorInput widget:

class AdministratorInput extends StatefulWidget {
  @override
  _AdministratorInputState createState() => _AdministratorInputState();
}

class _AdministratorInputState extends State<AdministratorInput> {
  String serverAddress;
  String daysBack;
  final serverAddressController = TextEditingController();
  final daysBackController = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Padding(
        padding: const EdgeInsets.all(10.0),
        child: BlocBuilder<SettingsBloc, SettingsState>(
          builder: (context, state) {
            if (state is SettingsInitialState) {
              BlocProvider.of<SettingsBloc>(context)
                  .add(SettingsPageLoadedEvent());
            } else if (state is SettingsFetchedState) {
              serverAddressController.text =
                  serverAddress = state.settings.serverAddress;
              daysBackController.text =
                  daysBack = state.settings.daysBack.toString();
            }

            return Column(
              children: <Widget>[
                Container(
                  child: Row(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: <Widget>[
                      Text(AppLocalizations.of(context)
                          .translate('serverAddress')),
                    ],
                  ),
                ),
                Container(
                  height: 40.0,
                  child: TextField(
                    controller: serverAddressController,
                    decoration: InputDecoration(
                      border: OutlineInputBorder(),
                    ),
                    onChanged: (value) {
                      serverAddress = value;
                    },
                  ),
                ),
                SizedBox(
                  height: 5.0,
                ),
                // Days Back Text Field
                Container(
                  child: Row(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: <Widget>[
                      Text(AppLocalizations.of(context).translate('daysBack')),
                    ],
                  ),
                ),
                Container(
                  height: 40.0,
                  child: TextField(
                    controller: daysBackController,
                    decoration: InputDecoration(
                      border: OutlineInputBorder(),
                    ),
                    onChanged: (value) {
                      daysBack = value;
                    },
                  ),
                ),
                SizedBox(
                  width: double.infinity,
                  child: RaisedButton(
                    child: Text('SAVE CHANGES'),
                    onPressed: updatePressed,
                  ),
                ),
                SizedBox(
                  width: double.infinity,
                  child: RaisedButton(
                    child: Text('REFRESH'),
                    onPressed: refreshPressed,
                  ),
                ),
              ],
            );
          },
        ),
      ),
    );
  }

  void updatePressed() {
    BlocProvider.of<SettingsBloc>(context).add(
      SettingsUpdateButtonPressedEvent(
        settings: SettingsAggregate(
          serverAddress: serverAddress,
          daysBack: int.parse(daysBack),
        ),
      ),
    );
  }

  void refreshPressed() {
    BlocProvider.of<SettingsBloc>(context).add(
      SettingsRefreshButtonPressedEvent(),
    );
  }
}

The SettingsBloc is a standard bloc pattern with events and states and a mapper method. It is being injected using get_it package. Here's how is instantiated:

serviceLocator.registerFactory(
    () => SettingsBloc(
      pullUsersFromServerCommand: serviceLocator(),
      getSettingsQuery: serviceLocator(),
      updateSettingsCommand: serviceLocator(),
    ),
  );

All instances of the commands and query for the constructor of the bloc are instantiated properly the same way.

Here's the bloc:

class SettingsBloc extends Bloc<SettingsEvent, SettingsState> {
  final PullUsersFromServerCommand pullUsersFromServerCommand;
  final UpdateSettingsCommand updateSettingsCommand;
  final GetSettingsQuery getSettingsQuery;

  SettingsBloc({
    @required PullUsersFromServerCommand pullUsersFromServerCommand,
    @required UpdateSettingsCommand updateSettingsCommand,
    @required GetSettingsQuery getSettingsQuery,
  })  : assert(pullUsersFromServerCommand != null),
        assert(updateSettingsCommand != null),
        assert(getSettingsQuery != null),
        pullUsersFromServerCommand = pullUsersFromServerCommand,
        updateSettingsCommand = updateSettingsCommand,
        getSettingsQuery = getSettingsQuery;

  @override
  SettingsState get initialState => SettingsInitialState();

  @override
  Stream<SettingsState> mapEventToState(SettingsEvent event) async* {
    if (event is SettingsPageLoadedEvent) {
      final getSettingsEither = await getSettingsQuery(NoQueryParams());

      yield* getSettingsEither.fold((failure) async* {
        yield SettingsFetchedFailureState(error: "settingsDatabaseError");
      }, (result) async* {
        if (result != null) {
          yield SettingsFetchedState(settings: result);
        } else {
          yield SettingsFetchedFailureState(
              error: "settingsFetchFromDatabaseError");
        }
      });
    } else if (event is SettingsUpdateButtonPressedEvent) {
      final updateSettingsEither = await updateSettingsCommand(
          UpdateSettingsParams(settingsAggregate: event.settings));

      yield* updateSettingsEither.fold((failure) async* {
        yield SettingsUpdatedFailureState(error: "settingsDatabaseError");
      }, (result) async* {
        if (result != null) {
          yield SettingsUpdatedState();
        } else {
          yield SettingsUpdatedFailureState(
              error: "settingsUpdateToDatabaseError");
        }
      });
    } else if (event is SettingsRefreshButtonPressedEvent) {
      final pullUsersFromServerEither =
          await pullUsersFromServerCommand(NoCommandParams());

      yield* pullUsersFromServerEither.fold((failure) async* {
        yield SettingsRefreshedFailureState(
            error: "settingsRefreshDatabaseError");
      }, (result) async* {
        if (result != null) {
          yield SettingsUpdatedState();
        } else {
          yield SettingsRefreshedFailureState(error: "settingsRefreshedError");
        }
      });
    }
  }
}

The first time I enter this screen everything works perfect. The data is fetched from the database, loaded on screen and if I change it and press SAVE, it shows the snackbar. My problem is if I want to edit the data again while staying on that screen. I edit it again, therefore fire the changing event, the bloc gets it, calls the proper command below and the data is saved in the database. Then the state of the bloc is changed in attempt to tell the UI, "hey, I have a new state, get use of it". But the BlocListener never gets called again.

How should I achieve the behavior I desire?

EDIT: I am adding another bloc I am using earlier in the App where I log in users. The Login Page utilizes that bloc and upon wrong username or password, I am showing a snackbar, clearing the input fields and leaving the page ready for more. If I try again with wrong credentials, I can see the snackbar again.

Here is the LoginBloc:

class LoginBloc extends Bloc<LoginEvent, LoginState> {
  final AuthenticateUserCommand authenticateUserCommand;
  final AuthenticationBloc authenticationBloc;

  LoginBloc({
    @required AuthenticateUserCommand authenticateUserCommand,
    @required AuthenticationBloc authenticationBloc,
  })  : assert(authenticateUserCommand != null),
        assert(authenticationBloc != null),
        authenticateUserCommand = authenticateUserCommand,
        authenticationBloc = authenticationBloc;

  @override
  LoginState get initialState => LoginInitialState();

  @override
  Stream<LoginState> mapEventToState(LoginEvent event) async* {
    if (event is LoginButtonPressedEvent) {
      yield LoginLoadingState();

      final authenticateUserEither = await authenticateUserCommand(
          AuthenticateUserParams(
              username: event.username, password: event.password));

      yield* authenticateUserEither.fold((failure) async* {
        yield LoginFailureState(error: "loginDatabaseError");
      }, (result) async* {
        if (result != null) {
          authenticationBloc.add(LoggedInEvent(token: result));
          yield LoginLoggedInState(result);
        } else {
          yield LoginFailureState(error: "loginUsernamePasswordError");
        }
      });
    }
  }
}

The Event and State classes here extend Equatable. And since it was working according to the expectations, I did it the same way in the Settings Page (where it failed). From the UI I raise the LoginButtonPressedEvent as many times as I want and the BlocListener gets called respectively.

Gemina answered 9/3, 2020 at 8:4 Comment(2)
Where's your bloc file, it's the most important partAnguish
Yes! Sorry - I've added it.Gemina
A
17
    else if (event is SettingsUpdateButtonPressedEvent) {
      final updateSettingsEither = await updateSettingsCommand(
          UpdateSettingsParams(settingsAggregate: event.settings));

      yield* updateSettingsEither.fold((failure) async* {
        yield SettingsUpdatedFailureState(error: "settingsDatabaseError");
      }, (result) async* {
        if (result != null) {
          //
          // this part is the problem.
          yield SettingsUpdatedState();
        } else {
          yield SettingsUpdatedFailureState(
              error: "settingsUpdateToDatabaseError");
        }
      });

In general, you should use Equatable if you want to optimize your code to reduce the number of rebuilds. You should not use Equatable if you want the same state back-to-back to trigger multiple transitions.

The source: when-to-use-equatable

How it works with flutter_bloc is you can't yield the same state. Yes, the above function before yield the state is working fine when you emit the event, but the yield itself doesn't get called.

So basically what happens with your bloc is,

  1. Current state is SettingsFetchedState(settings: result)
  2. You emit SettingsUpdateButtonPressedEvent()
  3. Bloc yield SettingsUpdatedState()
  4. State changes from SettingsFetchedState(settings: result) to SettingsUpdatedState()
  5. Current state is SettingsUpdatedState()
  6. BlocListener listens to state changes from SettingsFetchedState(settings: result) to SettingsUpdatedState()
  7. You emit SettingsUpdateButtonPressedEvent()
  8. Bloc doesn't yield SettingsUpdatedState(), it is ignored because the equality comparison returns true)
  9. BlocListener does nothing because there is no state changes.

How to fix this? I am not confident enough to give suggestion based on my current knowledge, so maybe try what the quote says You should not use Equatable if you want the same state back-to-back to trigger multiple transitions.

EDIT :

LoginBloc works simply because it yield different state for each event. I think you don't notice but it yield LoginLoadingState() before yield either LoginLoggedInState(result) or LoginFailureState(error: "loginUsernamePasswordError")

  1. Current state is LoginInitialState()
  2. Emit event
  3. Yield LoginLoadingState()
  4. State changes from LoginInitialState() to LoginLoadingState()
  5. Yield either LoginLoggedInState() or LoginFailurestate()
  6. State changes from LoginLoadingState() to either LoginLoggedInState() or LoginFailurestate()
  7. Back to step 2 for every event
Anguish answered 9/3, 2020 at 8:51 Comment(7)
This is a very good direction! I tried it and it worked! But before I mark your answer as true, I'd like to share some more code and expand my question. I have another page (the login page) where I did exactly the same: LoginBloc with states that extend Equatable and the actios are similar - press LOGIN, if no match display ERROR snackbar and stay on the same page of course. If another false attempt is made, show the snackbar again. And doing it just the same way as I did it above with the Settings - it worked. That is why I was confused when it didn't work the first time.Gemina
I edited the original post with one more bloc to have a look atGemina
You mean... because I am alternating them? Loading -> Error -> Loading -> Error... And in the second case I only "shoot" Done -> Done -> Done...?Gemina
Yup, that basically what happensAnguish
Thanks a lot! That explains everything!Gemina
@FederickJonathan You saved me once more with this answer. Thanks for helping so much. CheersHoelscher
Not sure what's the best practise but in my use case, where I had a UI with switches that the on/off state was always producing the same state, my solution was not to extend Equatable so that the same state updated the UI every time the switch was toggled.Altheta
I
6

@Federick Jonathan already given enough explain about the problem but I would like to do addon in this.

First things: It is the standard behaviour of Equatable, Event listeners got called when state changes occurred. If you yield the same state every time then nothing going to happen.

Let's discussed all possible solutions.

  1. Remove Equatable from bloc then every event trigger when state change.

  2. Define start and end state for the state. For example, Create first state as StartDataUpdate and second as EndDataUpdate.

Refer below code

yield StartDataUpdate();
//Here... Please specified data changes related to operation.
yield EndDataUpdate();
  Stream<ReportsState> setupState({required ReportsState state}) async* {
    yield StartReportsState();
    yield state;
    yield EndReportsState();
  }

Using:

 yield* setupState( state: NavigationState() );
Ithaca answered 28/7, 2020 at 10:1 Comment(2)
Yeah thats the easiest way just i used an temp state that contains nothing i yield that state before the state who get yielded countinously so you can use equatble with same state yielded .thank youCelin
Thanks for this comment! I was able to continuously refresh the state by making a state that does nothing but switches states.Bridle

© 2022 - 2024 — McMap. All rights reserved.