Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

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.

like image 215
Borislav Nanovski Avatar asked Mar 09 '20 08:03

Borislav Nanovski


2 Answers

    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
like image 153
Federick Jonathan Avatar answered Oct 17 '22 20:10

Federick Jonathan


@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() );
like image 4
Hitesh Surani Avatar answered Oct 17 '22 20:10

Hitesh Surani