We'll be using Bloclibrary's Login example for our test, which can be found here:
https://bloclibrary.dev/#/flutterlogintutorial
In the tutorial, this is how the LoginState
is updated:
Future<void> _onSubmitted(
LoginSubmitted event,
Emitter<LoginState> emit,
) async {
if (state.isValid) {
emit(state.copyWith(status: FormzSubmissionStatus.inProgress));
try {
await _authenticationRepository.logIn(
username: state.username.value,
password: state.password.value,
);
emit(state.copyWith(status: FormzSubmissionStatus.success));
} catch (_) {
emit(state.copyWith(status: FormzSubmissionStatus.failure));
}
}
}
The _onSubmitted
method handles the LoginSubmitted
event and updates the LoginState
accordingly. If the current state is valid, it updates the state with FormzSubmissionStatus.inProgress
, then tries to log in using the AuthenticationRepository
with the username
and password
from the state. If the login is successful, it updates the state with FormzSubmissionStatus.success
, otherwise it updates the state with FormzSubmissionStatus.failure
.
BlocListener
's listener
will then fire a callback notifying the UI of any state changes. It does this by listening to the LoginBloc
state changes and shows a snackbar with an error message if the authentication fails.
class LoginForm extends StatelessWidget {
const LoginForm({super.key});
@override
Widget build(BuildContext context) {
return BlocListener<LoginBloc, LoginState>(
listener: (context, state) {
if (state.status.isFailure) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
const SnackBar(content: Text('Authentication Failure')),
);
}
},
child: Align(
alignment: const Alignment(0, -1 / 3),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_UsernameInput(),
const Padding(padding: EdgeInsets.all(12)),
_PasswordInput(),
const Padding(padding: EdgeInsets.all(12)),
_LoginButton(),
],
),
),
);
}
}
The LoginSubmitted
event found in the login_event.dart
file is added to the LoginBloc when the user submits their login credentials signaling the bloc to start the authentication process.
final class LoginSubmitted extends LoginEvent {
const LoginSubmitted();
}
The ElevatedButton
below is enabled only when the LoginState
is valid but once the LoginState
is in progress, a CircularProgressIndicator
is displayed. When the button is pressed, the LoginSubmitted
event is added to the LoginBloc
as shown at the start.
class _LoginButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<LoginBloc, LoginState>(
builder: (context, state) {
return state.status.isInProgress
? const CircularProgressIndicator()
: ElevatedButton(
key: const Key('loginForm_continue_raisedButton'),
onPressed: state.isValid
? () {
context.read<LoginBloc>().add(const LoginSubmitted());
}
: null,
child: const Text('Login'),
);
},
);
}
}
But you might not need the BlocListener
after all if you employ callbacks.
From geeks for geeks
Callback is basically a function or a method that we pass as an argument into another function or a method to perform an action. In the simplest words, we can say that Callback or VoidCallback are used while sending data from one method to another and vice-versa
Modifying our LoginSubmitted
event to use callback functions opens up a whole new way of interacting with the presentation layer.
final class LoginSubmitted extends LoginEvent {
const LoginSubmitted(this.onSuccess, this.onError);
final void Function() onSuccess;
final void Function() onError;
}
The event now has two callbacks, onSuccess
and onError
, which are called when the login process succeeds or fails respectively.
Future<void> _onSubmitted(
LoginSubmitted event,
Emitter<LoginState> emit,
) async {
if (state.isValid) {
emit(state.copyWith(status: FormzSubmissionStatus.inProgress));
try {
await _authenticationRepository.logIn(
username: state.username.value,
password: state.password.value,
);
event.onSuccess();
emit(state.copyWith(status: FormzSubmissionStatus.success));
} catch (_) {
event.onError();
emit(state.copyWith(status: FormzSubmissionStatus.failure));
}
}
}
Now, if the login is successful, it calls LoginSubmitted
's onSuccess
function and updates the state with FormzSubmissionStatus.success
and if the login fails, it calls LoginSubmitted
's onError
function and updates the state with FormzSubmissionStatus.failure
.
class _LoginButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<LoginBloc, LoginState>(
builder: (context, state) {
return state.status.isInProgress
? const CircularProgressIndicator()
: ElevatedButton(
key: const Key('loginForm_continue_raisedButton'),
onPressed: state.isValid
? () {
context.read<LoginBloc>().add(
LoginSubmitted(
onSuccess: () {
// TODO(felangel): navigate to main
},
onError: () {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
const SnackBar(
content: Text('Authentication Failure'),
),
);
},
),
);
}
: null,
child: const Text('Login'),
);
},
);
}
}
As you can see, we don't need the BlocListener
anymore since the callbacks are immediately triggered depending on the result of the login.
However, if you use cubits, the following is an approach you can take:
Let's say you have an onSubmitted
method that can be called directly from the presentation layer.
Future<void> onSubmitted() async {
if (state.isValid) {
emit(state.copyWith(status: FormzSubmissionStatus.inProgress));
try {
await _authenticationRepository.logIn(
username: state.username.value,
password: state.password.value,
);
emit(state.copyWith(status: FormzSubmissionStatus.success));
} catch (_) {
emit(state.copyWith(status: FormzSubmissionStatus.failure));
}
}
}
We can stick to the callbacks by declaring them as required parameters.
Future<void> onSubmitted({
required void Function() onSuccess,
required void Function() onError,
}) async {
if (state.isValid) {
emit(state.copyWith(status: FormzSubmissionStatus.inProgress));
try {
await _authenticationRepository.logIn(
username: state.username.value,
password: state.password.value,
);
onSuccess();
emit(state.copyWith(status: FormzSubmissionStatus.success));
} catch (_) {
onError();
emit(state.copyWith(status: FormzSubmissionStatus.failure));
}
}
}
Or we can return the status like this:
Future<FormzSubmissionStatus> onSubmitted() async {
if (!state.isValid) {
return FormzSubmissionStatus.failure;
}
emit(state.copyWith(status: FormzSubmissionStatus.inProgress));
try {
await _authenticationRepository.logIn(
username: state.username.value,
password: state.password.value,
);
emit(state.copyWith(status: FormzSubmissionStatus.success));
return FormzSubmissionStatus.success;
} catch (_) {
emit(state.copyWith(status: FormzSubmissionStatus.failure));
return FormzSubmissionStatus.failure;
}
}
In this case, when the login form is submitted the method returns a FormzSubmissionStatus
indicating the status of the form submission. If the form is not valid, it returns FormzSubmissionStatus.failure
. Otherwise, we attempt to log in using the AuthenticationRepository
. If the login is successful, the method returns FormzSubmissionStatus.success
else it returns FormzSubmissionStatus.failure
.
And in the _LoginButton
we call the method, taking note of the async operation going on. Since we're accessing the context across an async gap we have to make sure to check whether it's available using the mounted
property on the context.
class _LoginButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<LoginCubit, LoginState>(
builder: (context, state) {
return state.status.isInProgress
? const CircularProgressIndicator()
: ElevatedButton(
key: const Key('loginForm_continue_raisedButton'),
onPressed: state.isValid
? () async {
final status =
await context.read<LoginCubit>().onSubmitted();
if (status.isFailure && context.mounted) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
const SnackBar(
content: Text('Authentication Failure'),
),
);
}
}
: null,
child: const Text('Login'),
);
},
);
}
}
Why you might want to do this:
More flexible: You can define different behaviours for success and error cases directly when dispatching the
LoginSubmitted
event, without needing to listen to the Bloc's state changes.Immediate feedback: The callbacks are invoked as soon as the login attempt succeeds or fails, which might be slightly earlier than when the new state is emitted and processed by the listeners.
Disadvantages of this approach:
More complex: The additional callbacks can make the code harder to understand and maintain, especially if the behaviour defined in the callback is complex.
Potential for bugs: If the callbacks modify the state of the app, it could lead to bugs or inconsistencies, since state changes would be happening outside of the Bloc's control.
While callbacks can be useful in certain scenarios, they are generally not recommended in Blocs or Cubits and this is just a showcase of what's possible for fun.
Reasons I don't recommend this:
State Management: Blocs and Cubits are designed to manage state and notify listeners when that state changes. If you're using callbacks to communicate state changes, you're bypassing the Bloc's state management, which can lead to inconsistencies and bugs.
Code Complexity: Callbacks can make the code more complex and harder to understand, especially if they're used to modify the state of the app.
Testability: Blocs and Cubits are easy to test because you can simply provide a series of events and then assert on the resulting states. If you're using callbacks, you'll need to mock them in your tests, which can make the tests more complex and harder to maintain
If you do decide to use callbacks in your Blocs, here are some best practices to follow:
Don't Modify State: The callbacks should not modify the state of the app. Instead, they should be used to perform side effects (like navigation) or to provide additional information that doesn't fit into the state.
Keep Them Simple: The callbacks should be simple and not contain complex logic. If you find yourself writing complex callbacks, consider moving that logic into the Bloc itself.
Handle Errors: If the callbacks can throw exceptions, make sure to handle them properly to avoid crashing your app.
Document: Make sure to document what the callbacks do and when they're called, especially if other developers will be using your Blocs.
That's all I have for today but look forward to my next post on encapsulating BlocListeners and making your widget tree cleaner.