Search code examples
flutterblocflutter-blocflutter-state

Flutter Bloc conflicting states


I'm trying to build a login activity using Bloc, with the help of the tutorials available at https://bloclibrary.dev/. I've successfully combined the Form Validation and Login Flow into a working solution, but things took a messy turn when adding a button to toggle password visibility.

Figured I'd follow the same format that the validations and login state had (widget's onPressed triggers an event, bloc processes it and changes state to update view), but because states are mutually exclusive, toggling the password visibility causes other information (like validation errors, or the loading indicator) to disappear, because the state that they require in order to be shown is no longer the active one.

I assume one way to avoid this is to have a separate Bloc to handle just the password toggle, but I think that involves nesting a second BlocBuilder in my view, not to mention implementing another set of Bloc+Events+States, which sounds like it might make the code harder to understand/navigate as things get more complex. Is this how Bloc is meant to be used, or is there a cleaner approach that works better here to avoid this?

class LoginForm extends StatefulWidget {
  @override
  State<LoginForm> createState() => _LoginFormState();
}

class _LoginFormState extends State<LoginForm> {
  final _usernameController = TextEditingController();
  final _passwordController = TextEditingController();

  @override
  Widget build(BuildContext context) {

    _onLoginButtonPressed() {
      BlocProvider.of<LoginBloc>(context).add(
        LoginButtonPressed(
          username: _usernameController.text,
          password: _passwordController.text,
        ),
      );
    }

    _onShowPasswordButtonPressed() {
      BlocProvider.of<LoginBloc>(context).add(
        LoginShowPasswordButtonPressed(),
      );
    }

    return BlocListener<LoginBloc, LoginState>(
      listener: (context, state) {
        if (state is LoginFailure) {
          Scaffold.of(context).showSnackBar(
            SnackBar(
              content: Text('${state.error}'),
              backgroundColor: Colors.red,
            ),
          );
        }
      },
      child: BlocBuilder<LoginBloc, LoginState>(
        builder: (context, state) {
          return Form(
            child: Padding(
              padding: const EdgeInsets.all(32.0),
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  TextFormField(
                    decoration: InputDecoration(labelText: 'Username', prefixIcon: Icon(Icons.person)),
                    controller: _usernameController,
                    autovalidate: true,
                    validator: (_) {
                      return state is LoginValidationError ? state.usernameError : null;
                    },
                  ),
                  TextFormField(
                    decoration: InputDecoration(
                      labelText: 'Password',
                      prefixIcon: Icon(Icons.lock_outline),
                      suffixIcon: IconButton(
                        icon: Icon(
                          state is! DisplayPassword ? Icons.visibility : Icons.visibility_off,
                          color: ColorUtils.primaryColor,
                        ),
                        onPressed: () {
                          _onShowPasswordButtonPressed();
                        },
                      ),
                    ),
                    controller: _passwordController,
                    obscureText: state is! DisplayPassword ? true : false,
                    autovalidate: true,
                    validator: (_) {
                      return state is LoginValidationError ? state.passwordError : null;
                    },
                  ),
                  Container(height: 30),
                  ButtonTheme(
                    minWidth: double.infinity,
                    height: 50,
                    child: RaisedButton(
                      color: ColorUtils.primaryColor,
                      textColor: Colors.white,
                      onPressed: state is! LoginLoading ? _onLoginButtonPressed : null,
                      child: Text('LOGIN'),
                    ),
                  ),
                  Container(
                    child: state is LoginLoading
                      ? CircularProgressIndicator()
                      : null,
                  ),
                ],
              ),
            ),
          );
        },
      ),
    );
  }
}
class LoginBloc extends Bloc<LoginEvent, LoginState> {
  final UserRepository userRepository;
  final AuthenticationBloc authenticationBloc;
  bool isShowingPassword = false;

  LoginBloc({
    @required this.userRepository,
    @required this.authenticationBloc,
  })  : assert(userRepository != null),
      assert(authenticationBloc != null);

  LoginState get initialState => LoginInitial();

  @override
  Stream<LoginState> mapEventToState(LoginEvent event) async* {

    if (event is LoginShowPasswordButtonPressed) {
      isShowingPassword = !isShowingPassword;
      yield isShowingPassword ?  DisplayPassword() : LoginInitial();
    }

    if (event is LoginButtonPressed) {
      if (!_isUsernameValid(event.username) || !_isPasswordValid(event.password)) {
        yield LoginValidationError(
          usernameError: _isUsernameValid(event.username) ? null : "(test) validation failed",
          passwordError: _isPasswordValid(event.password) ? null : "(test) validation failed",
        );  //TODO update this so fields are validated for multiple conditions (field is required, minimum char size, etc) and the appropriate one is shown to user
      }
      else {
        yield LoginLoading();

        final response = await userRepository.authenticate(
          username: event.username,
          password: event.password,
        );

        if (response.ok != null) {
          authenticationBloc.add(LoggedIn(user: response.ok));
        }
        else {
          yield LoginFailure(error: response.error.message);
        }
      }
    }
  }

  bool _isUsernameValid(String username) {
    return username.length >= 4;
  }

  bool _isPasswordValid(String password) {
    return password.length >= 4;
  }
}
abstract class LoginEvent extends Equatable {
  const LoginEvent();

  @override
  List<Object> get props => [];
}

class LoginButtonPressed extends LoginEvent {
  final String username;
  final String password;

  const LoginButtonPressed({
    @required this.username,
    @required this.password,
  });

  @override
  List<Object> get props => [username, password];

  @override
  String toString() =>
    'LoginButtonPressed { username: $username, password: $password }';
}

class LoginShowPasswordButtonPressed extends LoginEvent {}
abstract class LoginState extends Equatable {
  const LoginState();

  @override
  List<Object> get props => [];
}

class LoginInitial extends LoginState {}

class LoginLoading extends LoginState {}

class LoginValidationError extends LoginState {
  final String usernameError;
  final String passwordError;

  const LoginValidationError({@required this.usernameError, @required this.passwordError});

  @override
  List<Object> get props => [usernameError, passwordError];
}

class DisplayPassword extends LoginState {}

class LoginFailure extends LoginState {
  final String error;

  const LoginFailure({@required this.error});

  @override
  List<Object> get props => [error];

  @override
  String toString() => 'LoginFailure { error: $error }';
}

Solution

  • Yup, you are not supposed to have this. // class DisplayPassword extends LoginState {}

    And yes, if you want to go pure BLoC it's the correct way to go imo. In this case, because the only state you want to hold is a single bool value, you can go with a simpler approach with the BLoC structure. What I mean is, you don't need to make complete set, event class, state class, bloc class but instead just the bloc class. And on top of that, you can separate the bloc folder into 2 kinds.

    bloc
     - full
        - login_bloc.dart
        - login_event.dart
        - login_state.dart
     - single
        - password_visibility_bloc.dart
    
    class PasswordVisibilityBloc extends Bloc<bool, bool> {
      @override
      bool get initialState => false;
    
      @override
      Stream<bool> mapEventToState(
        bool event,
      ) async* {
        yield !event;
      }
    }