Bloc 8.0 Tutorial for Flutter: Getting Started
Learn how to build a Wordle clone app in Flutter using one of the most robust state management libraries: Bloc 8.0. By Alejandro Ulate Fallas.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Bloc 8.0 Tutorial for Flutter: Getting Started
25 mins
Handling Game State Changes
Now that you’ve brushed up on some of the core concepts, it’s time you dive into coding.
If you’ve used the plugin before, you might remember the new Events API introduced in v7.2.0. The motivation behind this change was predictability, but it also gave extra simplicity to event handling.
Open lib/presentation/bloc/game_event.dart and look at the different events you’ll process in GameBloc
:
-
LetterKeyPressed: An event performed when the user taps a key on the onscreen keyboard,
PlingoKeyboard
. - GameStarted: An event triggered when a new game starts, either by opening the app or tapping Play Again when you win or lose the game.
- GameFinished: This event happens when the player guesses the correct word and wins or reaches the max attempts to guess and loses the game.
You’ll start by adding GameStarted
. Open lib/presentation/bloc/game_bloc.dart and replace the constructor in line 40 with the following:
GameBloc(this._statsRepository)
: super(GameState(
guesses: emptyGuesses(),
)) {
on<GameStarted>(_onGameStarted);
}
This code lets GameBloc
process GameStarted
events. At this point, a compilation error should be showing in your editor since _onGameStarted
isn’t yet defined. Fix that by replacing // TODO: Add logic for GameStarted
with this code:
void _onGameStarted(
GameStarted event,
Emitter<GameState> emit,
) {
print('Game has started!');
final puzzle = nextPuzzle(puzzles);
final guesses = emptyGuesses();
emit(GameState(
guesses: guesses,
puzzle: puzzle,
));
}
This function defines the logic that should happen when a game starts. First, nextPuzzle
defines a random puzzle from the list of available puzzles. Then, emptyGuesses
generates a multidimensional array that handles the letters and words guessed in the form of a 5×5 matrix of String
s. Finally, you emit a new GameState
with that information, which also updates the game’s UI.
Right now, GameBloc
is ready to handle the start of a game, but that event isn’t added to GameBloc
in the app. You’ll now write a few lines of code for that. Open lib/app/app.dart and replace the line below // TODO: Start game here!
with:
GameBloc(ctx.read<GameStatsRepository>())..add(GameStarted()),
..
is the cascade operator that allows you to make a sequence of operations on the same object, i.e., GameBloc
. The above code takes care of starting a game whenever the app starts.
Now, open lib/presentation/pages/main_page.dart, and replace the definition of onPressed
below // TODO: Restart game here!
with the following code:
onPressed: () => context.read<GameBloc>().add(const GameStarted()),
This onPressed
gets called when the player wants to play again at the end of a game.
Build and run the project, and you’ll see no difference yet. However, check the debug console, and you’ll see that _onGameStarted
got called because the print statement you added before is showing.
Recognizing Key Presses
Now that the game has started, you’ll tackle the LetterKeyPressed
event. Open game_bloc.dart
again, and add the event handler on the constructor. The end result should look like this:
GameBloc(this._statsRepository)
: super(GameState(
guesses: emptyGuesses(),
)) {
on<GameStarted>(_onGameStarted);
on<LetterKeyPressed>(_onLetterKeyPressed);
}
Now, replace // TODO: Add logic for LetterKeyPressed
with the following code:
Future<void> _onLetterKeyPressed(
LetterKeyPressed event,
Emitter<GameState> emit,
) async {
final guesses = addLetterToGuesses(state.guesses, event.letter);
emit(state.copyWith(
guesses: guesses,
));
// TODO: check if the game ended.
}
With this code, every time you receive a LetterKeyPressed
event, you add the letter to the current guesses by checking for the first empty slot and setting its value as equal to the letter pressed — this is what addLetterToGuesses
does. Finally, you emit a new state with the new guess list and this, in turn, updates the UI via provider
‘s helper, context.watch()
.
Like before, you need to add the event for GameBloc
to process it. Open lib/presentation/widgets/plingo_key.dart and replace onTap
with the following code:
onTap: () => context.read<GameBloc>().add(LetterKeyPressed(letter)),
Remember to add the corresponding imports for flutter_bloc
and GameBloc
at the top.
Build and run the app. Use the keyboard to type a couple of words like CRAVE, LINGO or MUMMY. This will give you a couple of hints about what the five-letter word is, and it might look like this:
Great job! You’ve added a second event to the game. But, there’s one caveat to the current implementation: concurrency. You’ll tackle this problem next.
Transforming Events
To make this bug more noticeable, open game_bloc.dart and change _onLetterKeyPressed
to emit a state in the future. Don’t forget to add the corresponding import 'dart:math';
at the top of the file. Here’s what the end result should look like:
Future<void> _onLetterKeyPressed(
LetterKeyPressed event,
Emitter<GameState> emit,
) async {
final guesses = addLetterToGuesses(state.guesses, event.letter);
final randGenerator = Random();
final shouldHold = randGenerator.nextBool();
await Future.delayed(Duration(seconds: shouldHold ? 2 : 0), () {
emit(state.copyWith(
guesses: guesses,
));
});
// TODO: check if the game ended.
}
Build and run again. Use the keyboard to type CRAZY.
Notice the bug now? Bloc
s treat events concurrently now instead of doing it sequentially. This means that if an event takes too long to complete, another one might be able to override the changes, leading to unexpected behaviors like the one in your app.
Bloc
s let you define the way to handle events by setting transformer
in the event handler definition on the constructor.
Normally, defining your own transformer function could become difficult to maintain, so you should only do it when required. Luckily, a companion library called bloc_concurrency
contains a set of opinionated transformer functions that allow you to handle events the way you want. Here’s a small list of the ones included in bloc_concurrency
:
- concurrent(): Process events concurrently.
- sequential(): Process events sequentially.
- droppable(): Ignore any events added while an event is processing.
- restartable(): Process only the latest event and cancel previous event handlers.
For this project, bloc_concurrency
is already added to the dependencies
in your pubspec.yaml
, so you can jump straight in using that. Open game_bloc.dart
and add the following import statement at the top of the file:
import 'package:bloc_concurrency/bloc_concurrency.dart';
Then, change the corresponding event handler for LetterKeyPressed
in the constructor and replace it with the following:
on<LetterKeyPressed>(_onLetterKeyPressed, transformer: sequential());
Using sequential()
allows you to handle events in a sequence, which helps you avoid events from overriding one another.
Build and run the app again, and try to type CRAZY again.
As you can see, the events are now processed in the order you add them, despite some of them taking longer than their predecessors.
Now, it’s time you finish adding the last event that GameBloc
processes. Add the event handler for GameFinished
. It should look like this:
on<GameFinished>(_onGameFinished);
Then, replace // TODO: Add logic for GameFinished
with the following code:
// 1
Future<void> _onGameFinished(
GameFinished event,
Emitter<GameState> emit,
) async {
// 2
await _statsRepository.addGameFinished(hasWon: event.hasWon);
// 3
emit(state.copyWith(
status: event.hasWon ? GameStatus.success : GameStatus.failure,
));
}
Here’s a quick overview of the code you wrote:
- Notice that you’re using
Future
as a return value for_onGameFinished
sinceBloc
supports this return type. - Then,
_statsRepository.addGameFinished
interacts with local storage for updating the different statistics. - Finally, you emit a new state with the corresponding game result: success for winning or failure for losing.
As a final step, replace _onLetterKeyPressed
with the following code:
Future<void> _onLetterKeyPressed(
LetterKeyPressed event,
Emitter<GameState> emit,
) async {
final puzzle = state.puzzle;
final guesses = addLetterToGuesses(state.guesses, event.letter);
// 1
emit(state.copyWith(
guesses: guesses,
));
// 2
final words = guesses
.map((guess) => guess.join())
.where((word) => word.isNotEmpty)
.toList();
final hasWon = words.contains(puzzle);
final hasMaxAttempts = words.length == kMaxGuesses &&
words.every((word) => word.length == kWordLength);
if (hasWon || hasMaxAttempts) {
add(GameFinished(hasWon: hasWon));
}
}
With this code, you’re doing two things:
-
emit
is no longer wrapped with aFuture
like it was before. - Determining if the game ended after the guesses have updated. You do this by checking if the user guessed the word puzzle — Plingo’s win condition — or if the user reached the max number of attempts without guessing the correct word. If the game meets either condition, then you add a new
GameFinished
event.
Build and run the app again, and try to win the game. If you lose, just tap Play Again to give it another try. You’ll now see a winning message when you guess the puzzle or a losing message if you reach the max attempts without guessing correctly. Here’s what they look like: