[UPDATE]: I created a reproduction repo here: https://gitlab.com/dantec204/flutter-app-issue-reproduction
I'm learning Flutter/Dart and so far I really love working with it, but today I ran into an issue and I can't seem to wrap my head around it.
I started using Bloc for my user authentication. Whenever the user enters his email address, an EmailChanged event should be emitted. So far so good, everything still works fine.
But for email addresses, I have this value object class:
class EmailAddress extends ValueObject<String> {
@override
final Either<Failure<String>, String> value;
factory EmailAddress(String input) {
return EmailAddress._(
validateEmailAddress(input),
);
}
const EmailAddress._(this.value);
}
Either<Failure<String>, String> validateEmailAddress(String input) {
const emailRegex = r"""^[a-zA-Z0-9.!#$%&'*\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,253}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,253}[a-zA-Z0-9])?)*$""";
if (RegExp(emailRegex).hasMatch(input)) {
return right(input);
} else {
return left(Failure(input));
}
}
Problem here is that somehow, whenever the bloc event gets emitted, this factory constructor gets executed twice. First it receives the right input value, but the second time the input value is just a blank string. And thus my state is incorrect.
This is what the bloc looks like:
@injectable
class LoginBloc extends Bloc<LoginEvent, LoginState> {
final IAuthFacade _authFacade;
LoginBloc(this._authFacade): super(LoginState()) {
on<LoginEmailChanged>((event, emit) async {
emit(
state.copyWith(emailAddress: EmailAddress(event.emailAddress))
);
});
}
}
the bloc state:
class LoginState {
final EmailAddress emailAddress;
final Password password;
final bool isSubmitting;
LoginState({
emailAddress,
password,
this.isSubmitting = false
}) : emailAddress = EmailAddress(""),
password = Password("");
LoginState copyWith({
EmailAddress? emailAddress,
Password? password,
bool? isSubmitting
}) {
return LoginState(
emailAddress: emailAddress ?? this.emailAddress,
password: password ?? this.password,
isSubmitting: isSubmitting ?? this.isSubmitting,
);
}
}
and this is the onChanged event from the TextFormField:
onChanged: (value) =>
context.read<LoginBloc>().add(LoginEmailChanged(emailAddress: value)),
abstract class LoginEvent {}
class LoginEmailChanged extends LoginEvent {
final String emailAddress;
LoginEmailChanged({ required this.emailAddress });
}
I really hope someone can help me out here because I've been looking on the internet and thinking about it for almost the entire day...
Thank you for providing the project using which I could see your problem.
I've detected what the issue is. You are looking for the text factory constructor of EmailAddress, input:
in your logs and you are seeing it printed twice in the logs when the user changes their email address by typing, for instance, a new character in the TextField.
The reason you are seeing that message printed to the screen twice is that:
First you are emitting this event:
LoginBloc(this._authFacade): super(LoginState()) {
on<LoginEmailChanged>((event, emit) async {
emit(
state.copyWith(emailAddress: EmailAddress(event.emailAddress))
);
});
which in turn calls the copyWith()
function of your state that looks like this:
LoginState copyWith({
EmailAddress? emailAddress,
Password? password,
bool? isSubmitting,
Option<Either<AuthFailure, Unit>>? authFailureOrSuccess
}) {
return LoginState(
emailAddress: emailAddress ?? this.emailAddress,
password: password ?? this.password,
isSubmitting: isSubmitting ?? this.isSubmitting,
authFailureOrSuccess: authFailureOrSuccess ?? this.authFailureOrSuccess
);
}
And this is calling the LoginState()
constructor that itself creates another copy of the EmailAddress
like so:
LoginState({
emailAddress,
password,
this.isSubmitting = false,
authFailureOrSuccess
}) : emailAddress = EmailAddress(""),
password = Password(""),
authFailureOrSuccess = none();
So even though you think you are calling the LoginState
and that should just use your incoming EmailAddress?
value, the default constructor is indeed creating another instance of EmailAddress
before it uses the one you provide to it.
The solution is to actually fix your LoginState
constructor as I've shown you here:
class LoginState {
final EmailAddress emailAddress;
final Password password;
final bool isSubmitting;
final Option<Either<AuthFailure, Unit>> authFailureOrSuccess;
LoginState({
required this.emailAddress,
required this.password,
required this.isSubmitting,
required this.authFailureOrSuccess,
});
...
Then you will have to solve the issue with this line of code which we have in your code base:
LoginBloc(this._authFacade): super(LoginState())
Since LoginState
no longer has a default state, you'll need to define one like so:
@injectable
class LoginBloc extends Bloc<LoginEvent, LoginState> {
final IAuthFacade _authFacade;
LoginBloc(this._authFacade)
: super(LoginState(
emailAddress: EmailAddress(''),
password: Password(''),
isSubmitting: false,
authFailureOrSuccess: none(),
)) {
on<LoginEmailChanged>((event, emit) async {
emit(state.copyWith(emailAddress: EmailAddress(event.emailAddress)));
});
...
And you're good to go after that!