Flutter: how to build a quiz game
Introduction
In this article, I’d like to show you how I built this example of trivia game with Flutter and the frideos package (check out these two examples to learn how it works example1, example2). It is a quite simple game but it covers various interesting arguments.
The app has four screens:
- A main page where the user choices a category and starts the game.
- A settings page where the user can select the number of questions, the type of database (local or remote), the time limit for each question and the difficulty.
- A trivia page where are displayed the questions, the score, the number of corrects, wrongs and not answered.
- A summary page that shows all the questions with the correct/wrong answers.
This is the final result:
- Part 1: Project setup
- Part 2: App architecture
- Part 3: API and JSON
- Part 4: Homepage and other screens
- Part 5: TriviaBloc
- Part 6: Animations
- Part 7: Summary page
- Conclusion
Part 1 - Project setup
1 — Create a new flutter project:
flutter create your_project_name
2 — Edit the file “pubspec.yaml” and add the http and frideos packages:
dependencies:
flutter:
sdk: flutter
http: ^0.12.0
frideos: ^0.6.0
3- Delete the content of the main.dart
file
4- Create the project structure as the following image:
Structure details
- API: here will be the dart files to handle the API of the “Open trivia database” and a mock API for local testing:
api_interface.dart
,mock_api.dart
,trivia_api.dart
. - Blocs: the place of the only BLoC of the app
trivia_bloc.dart
. - Models:
appstate.dart
,category.dart
,models.dart
,question.dart
,theme.dart
,trivia_stats.dart
. - Screens:
main_page.dart
,settings_page.dart
,summary_page.dart
,trivia_page.dart
.
Part 2 - App architecture
In my last article, I wrote about different ways to send and share data across multiple widgets and pages. In this case, we are going to use a little more advanced approach: an instance of a singleton class named appState
will be provided to the widgets tree by using an InheritedWidget provider (AppStateProvider
), this will hold the state of the app, some business logic, and the instance of the only BLoC which handles the “quiz part” of the app. So, in the end, it will be a sort of a mix between the singleton and the BLoC pattern.
Inside each widget it is possible to get the instance of the AppState
class by calling:
final appState = AppStateProvider.of<AppState>(context);
1 - main.dart
This is the entry point of the app. The class App
is a stateless widget where it is declared the instance of the AppState
class, and where, by using the AppStateProvider,
this is then provided to the widgets tree. The appState
instance will be disposed, closing all the streams, in the dispose
method of the AppStateProvider
class.
The MaterialApp
widget is wrapped inside a ValueBuilder
widget so that, every time a new theme is selected, the entire widgets tree rebuilds, updating the theme.
2 - State management
As said before, the appState
instance holds the state of the app. This class will be used for:
- Settings: current theme used, load/save it with the SharedPreferences. API implementation, mock or remote (using the API from opentdb.com). The time set for each question.
- Showing the current tab: mainpage, trivia, summary.
- Loading the questions.
- (if on remote API) Store the settings of the category, number, and difficulty of the questions.
In the constructor of the class:
_createThemes
builds the themes of the app._loadCategories
load the categories of the questions to be chosen on the main page dropdown.countdown
is aStreamedTransformed
of the frideos package of type<String, String>
, used to get from the textfield the value to set the countdown.questionsAmount
holds the number of questions to be shown during the trivia game (by default 5).- The instance of the class
TriviaBloc
is initialized, passing to it the streams the handle the countdown, the list of questions and the page to show.
Part 3 - API and JSON
To let the user can choose between a local and a remote database, I created the QuestionApi
interface with two methods and two classes that implement it: MockApi
and TriviaApi
.
abstract class QuestionsAPI {
Future<bool> getCategories(StreamedList<Category> categories);
Future<bool> getQuestions(
{StreamedList<Question> questions,
int number,
Category category,
QuestionDifficulty difficulty,
QuestionType type});
}
The MockApi
implementation is set by default (it can be changed in the settings page of the app) in the appState
:
// API
QuestionsAPI api = MockAPI();
final apiType = StreamedValue<ApiType>(initialData: ApiType.mock);
While apiType
is just an enum to handle the changing of the database on the settings page:
enum ApiType { mock, remote }
mock_api.dart:
trivia_api.dart:
1 - API selection
In the settings page the user can select which database to use through a dropdown:
ValueBuilder<ApiType>(
streamed: appState.apiType,
builder: (context, snapshot) {
return DropdownButton<ApiType>(
value: snapshot.data,
onChanged: appState.setApiType,
items: [
const DropdownMenuItem<ApiType>(
value: ApiType.mock,
child: Text(‘Demo’),
),
const DropdownMenuItem<ApiType>(
value: ApiType.remote,
child: Text(‘opentdb.com’),
),
]);
}),
Every time a new database is selected, The setApiType
method will change the implementation of the API and the categories will be updated.
void setApiType(ApiType type) {
if (apiType.value != type) {
apiType.value = type;
if (type == ApiType.mock) {
api = MockAPI();
} else {
api = TriviaAPI();
}
_loadCategories();
}
}
2 - Categories
To get the list of categories we call this URL:
https://opentdb.com/api_category.php
Extract of response:
{"trivia_categories":[{"id":9,"name":"General Knowledge"},{"id":10,"name":"Entertainment: Books"}]
So, after decoding the JSON using the jsonDecode
function of the dart:convert
library:
final jsonResponse = convert.jsonDecode(response.body);
we have this structure:
jsonResponse['trivia_categories']
: list of categoriesjsonResponse['trivia_categories'][INDEX]['id']
: id of the categoryjsonResponse['trivia_categories'][INDEX]['name']
: name of the category
So the model will be:
class Category {
Category({this.id, this.name}); factory Category.fromJson(Map<String, dynamic> json) {
return Category(id: json[‘id’], name: json[‘name’]);
} int id;
String name;
}
3 - Questions
If we call this URL:
https://opentdb.com/api.php?amount=2&difficulty=medium&type=multiple
this will be the response:
{"response_code":0,"results":[{"category":"Entertainment: Music","type":"multiple","difficulty":"medium","question":"What French artist\/band is known for playing on the midi instrument "Launchpad"?","correct_answer":"Madeon","incorrect_answers":["Daft Punk ","Disclosure","David Guetta"]},{"category":"Sports","type":"multiple","difficulty":"medium","question":"Who won the 2015 College Football Playoff (CFP) National Championship? ","correct_answer":"Ohio State Buckeyes","incorrect_answers":["Alabama Crimson Tide","Clemson Tigers","Wisconsin Badgers"]}]}
In this case, decoding the JSON, we have this structure:
jsonResponse['results']
: list of questions.jsonResponse['results'][INDEX]['category']
: the category of the question.jsonResponse['results'][INDEX]['type']
: type of question, multiple or boolean.jsonResponse['results'][INDEX]['question']
: the question.jsonResponse['results'][INDEX]['correct_answer']
: the correct answer.jsonResponse['results'][INDEX]['incorrect_answers']
: list of the incorrect answers.
Model:
class QuestionModel { QuestionModel({this.question, this.correctAnswer, this.incorrectAnswers}); factory QuestionModel.fromJson(Map<String, dynamic> json) {
return QuestionModel(
question: json[‘question’],
correctAnswer: json[‘correct_answer’],
incorrectAnswers: (json[‘incorrect_answers’] as List)
.map((answer) => answer.toString())
.toList());
} String question;
String correctAnswer;
List<String> incorrectAnswers;
}
4 - TriviaApi class
The class implements the two methods of the QuestionsApi
interface, getCategories
and getQuestions
:
- Getting the categories
In the first part, the JSON is decoded then by using the model, it is parsed obtaining a list of type Category
, finally, the result is given to categories
(a StreamedList
of type Category
used to populate the list of categories in the main page).
final jsonResponse = convert.jsonDecode(response.body);
final result = (jsonResponse[‘trivia_categories’] as List)
.map((category) => Category.fromJson(category));categories.value = [];
categories
..addAll(result)
..addElement(Category(id: 0, name: ‘Any category’));
- Getting the questions
Something similar happens for the questions, but in this case, we use a model (Question) to “convert” the original structure (QuestionModel) of the JSON to a more convenient structure to be used in the app.
final jsonResponse = convert.jsonDecode(response.body);
final result = (jsonResponse[‘results’] as List)
.map((question) => QuestionModel.fromJson(question));questions.value = result
.map((question) => Question.fromQuestionModel(question))
.toList();
5 - Question class
As said in the previous paragraph, the app uses a different structure for the questions. In this class we have four properties and two methods:
class Question {
Question({this.question, this.answers, this.correctAnswerIndex}); factory Question.fromQuestionModel(QuestionModel model) {
final List<String> answers = []
..add(model.correctAnswer)
..addAll(model.incorrectAnswers)
..shuffle(); final index = answers.indexOf(model.correctAnswer); return Question(question: model.question, answers: answers, correctAnswerIndex: index);
} String question;
List<String> answers;
int correctAnswerIndex;
int chosenAnswerIndex; bool isCorrect(String answer) {
return answers.indexOf(answer) == correctAnswerIndex;
} bool isChosen(String answer) {
return answers.indexOf(answer) == chosenAnswerIndex;
}}
In the factory, the list of answers is first populated with all the answers and then shuffled so that the order is always different. Here we even get the index of the correct answer so we can assign it to correctAnswerIndex
through the Question
constructor. The two methods are used to determine if the answer passed as a parameter is the correct one or the chosen one (they will be better explained in one of the next paragraphs).
Part 4 - Homepage and other screens
1 - HomePage widget
In theAppState
you can see a property named tabController
that is a StreamedValue
of type AppTab
(an enum), used to stream the page to show in the HomePage widget (stateless). It works in this way: every time a different AppTab
is set, the ValueBuilder
widget rebuilds the screen showing the new page.
HomePage
class:
Widget build(BuildContext context) {
final appState = AppStateProvider.of<AppState>(context);
return ValueBuilder(
streamed: appState.tabController,
builder: (context, snapshot) => Scaffold(
appBar: snapshot.data != AppTab.main ? null : AppBar(),
drawer: DrawerWidget(),
body: _switchTab(snapshot.data, appState),
),
);
}
N.B. In this case, the appBar will be displayed only on the main page.
_switchTab
method:
Widget _switchTab(AppTab tab, AppState appState) {
switch (tab) {
case AppTab.main:
return MainPage();
break;
case AppTab.trivia:
return TriviaPage();
break;
case AppTab.summary:
return SummaryPage(stats: appState.triviaBloc.stats);
break;
default:
return MainPage();
}
}
2 - SettingsPage
In the Settings page you can choose the number of questions to show, the difficulty, the amount of time for the countdown and which type of database to use. In the mainpage then you can select a category and finally start the game. For each one of these settings, I use a StreamedValue
so that the ValueBuilder
widget can refresh the page every time a new value is set.
Part 5 - TriviaBloc
The business logic of the app is in the only BLoC named TriviaBloc
. Let’s examine this class.
In the constructor we have:
TriviaBloc({this.countdownStream, this.questions, this.tabController}) {// Getting the questions from the API
questions.onChange((data) {
if (data.isNotEmpty) {
final questions = data..shuffle();
_startTrivia(questions);
}
}); countdownStream.outTransformed.listen((data) {
countdown = int.parse(data) * 1000;
});
}
Here the questions
property (a StreamedList
of type Question
) listens for changes, when a list of questions is sent to the stream the _startTrivia
method is called, starting the game.
Instead, the countdownStream
just listens for changes in the value of the countdown in the Settings page so that it can update the countdown
property used in the TriviaBloc
class.
- _startTrivia(List<Question> data)
This Method starts the game. Basically, it resets the state of the properties, set the first question to show and after one second calls the playTrivia
method.
void _startTrivia(List<Question> data) {
index = 0;
triviaState.value.questionIndex = 1; // To show the main page and summary buttons
triviaState.value.isTriviaEnd = false; // Reset the stats
stats.reset(); // To set the initial question (in this case the countdown
// bar animation won’t start).
currentQuestion.value = data.first; Timer(Duration(milliseconds: 1000), () {
// Setting this flag to true on changing the question
// the countdown bar animation starts.
triviaState.value.isTriviaPlaying = true;
// Stream the first question again with the countdown bar
// animation.
currentQuestion.value = data[index];
playTrivia();
});
}
triviaState is a StreamedValue
of type TriviaState
, a class used to handle the state of the trivia.
class TriviaState {
bool isTriviaPlaying = false;
bool isTriviaEnd = false;
bool isAnswerChosen = false;
int questionIndex = 1;
}
- playTrivia()
When this method is called, a timer periodically updates the timer and checks if the time passed is greater than the countdown setting, in this case, it cancels the timer, flags the current question as not answered and calls the _nextQuestion
method to show a new question.
void playTrivia() { timer = Timer.periodic(Duration(milliseconds: refreshTime), (Timer t) {
currentTime.value = refreshTime * t.tick; if (currentTime.value > countdown) {
currentTime.value = 0;
timer.cancel();
notAnswered(currentQuestion.value);
_nextQuestion();
} });
}
- notAnswered(Question question)
This method calls the addNoAnswer
method of the stats
instance of the TriviaStats
class for every question with no answer, in order to update the stats.
void notAnswered(Question question) {
stats.addNoAnswer(question);
}
- _nextQuestion()
In this method, the index of the questions is increased and if there are other questions in the list, then a new question is sent to the stream currentQuestion
so that the ValueBuilder
updates the page with the new question. Otherwise, the _endTriva
method is called, ending the game.
void _nextQuestion() { index ; if (index < questions.length) {
triviaState.value.questionIndex ;
currentQuestion.value = questions.value[index];
playTrivia();
} else {
_endTrivia();
}
}
- endTrivia()
Here the timer is canceled and the flag isTriviaEnd
set to true. After 1.5 seconds after the ending of the game, the summary page is shown.
void _endTrivia() { // RESET
timer.cancel();
currentTime.value = 0;
triviaState.value.isTriviaEnd = true;
triviaState.refresh();
stopTimer(); Timer(Duration(milliseconds: 1500), () {
// this is reset here to not trigger the start of the
// countdown animation while waiting for the summary page.
triviaState.value.isAnswerChosen = false; // Show the summary page after 1.5s
tabController.value = AppTab.summary; // Clear the last question so that it doesn’t appear
// in the next game
currentQuestion.value = null;
});
}
- checkAnswer(Question question, String answer)
When the user clicks on an answer, this method checks if it is correct and called the method to add a positive or a negative score to the stats. Then the timer is reset and a new question loaded.
void checkAnswer(Question question, String answer) {
if (!triviaState.value.isTriviaEnd) {
question.chosenAnswerIndex = question.answers.indexOf(answer);
if (question.isCorrect(answer)) {
stats.addCorrect(question);
} else {
stats.addWrong(question);
}
timer.cancel();
currentTime.value = 0;
_nextQuestion();
}
}
- stopTimer()
When this method is called, the time is canceled and the flag isAnswerChosen
set to true to tell the CountdownWidget
to stop the animation.
void stopTimer() {
// Stop the timer
timer.cancel(); // By setting this flag to true the countdown animation will stop
triviaState.value.isAnswerChosen = true;
triviaState.refresh();
}
- onChosenAnswer(String answer)
When an answer is chosen, the timer is canceled and the index of the answer is saved in the chosenAnswerIndex
property of the answersAnimation
instance of the AnswerAnimation
class. This index is used to put this answer last on the widgets stack to avoid it is covered by all the other answers.
void onChosenAnswer(String answer) {
chosenAnswer = answer;
stopTimer();
// Set the chosenAnswer so that the answer widget can put it last on the
// stack.
answersAnimation.value.chosenAnswerIndex =
currentQuestion.value.answers.indexOf(answer); answersAnimation.refresh();
}
AnswerAnimation
class:
class AnswerAnimation {
AnswerAnimation({this.chosenAnswerIndex, this.startPlaying});
int chosenAnswerIndex;
bool startPlaying = false;
}
- onChosenAnswerAnimationEnd()
When the animation of the answers ends, the flag isAnswerChosen
is set to false, to let the countdown animation can start again, and then called the checkAnswer
method to check if the answer is correct.
void onChosenAnwserAnimationEnd() {
// Reset the flag so that the countdown animation can start
triviaState.value.isAnswerChosen = false;
triviaState.refresh();
checkAnswer(currentQuestion.value, chosenAnswer);
}
- TriviaStats class
The methods of this class are used to assign the score. If the user selects the correct answer the score is increased by ten points and the current questions added to the corrects list so that these can be shown in the summary page, if an answer is not correct then the score is decreased by four, finally if no answer the score is decreased by two points.
class TriviaStats {
TriviaStats() {
corrects = [];
wrongs = [];
noAnswered = [];
score = 0;
}List<Question> corrects;
List<Question> wrongs;
List<Question> noAnswered;
int score;void addCorrect(Question question) {
corrects.add(question);
score = 10;
}void addWrong(Question question) {
wrongs.add(question);
score -= 4;
}void addNoAnswer(Question question) {
noAnswered.add(question);
score -= 2;
}void reset() {
corrects = [];
wrongs = [];
noAnswered = [];
score = 0;
}
}
Part 6 - Animations
In this app we have two kinds of animations: the animated bar below the answers indicates the time left to answer, and the animation played when an answer is chosen.
1 - Countdown bar animation
This is a pretty simple animation. The widget takes as a parameter the width of the bar, the duration, and the state of the game. The animation starts every time the widget rebuilds and stops if an answer is chosen.
The initial color is green and gradually it turns to red, signaling the time is about to end.
2 - Answers animation
This animation is started every time an answer is chosen. With a simple calculation of the position of the answers, each of them is progressively moved to the position of the chosen answer. To make the chosen answer remains at the top of the stack, this is swapped with the last element of the widgets list.
// Swap the last item with the chosen anwser so that it can
// be shown as the last on the stack.
final last = widgets.last;
final chosen = widgets[widget.answerAnimation.chosenAnswerIndex]; final chosenIndex = widgets.indexOf(chosen);
widgets.last = chosen;
widgets[chosenIndex] = last; return Container(
child: Stack(
children: widgets,
),
);
The color of the boxes turns to green if the answer is correct and red if it is wrong.
var newColor;if (isCorrect) {
newColor = Colors.green;
} else {
newColor = Colors.red;
}colorAnimation = ColorTween(
begin: answerBoxColor,
end: newColor,
).animate(controller);await controller.forward();
Part 7 - Summary page
1 - SummaryPage
This page takes as a parameter an instance of the TriviaStats
class, which contains the list of the corrects questions, wrongs and the ones with no answer chosen, and builds a ListView
showing each question in the right place. The current question is then passed to the SummaryAnswers
widget that builds the list of the answers.
2 - SummaryAnswers
This widget takes as a parameter the index of the question and the question itself, and builds the list of the answers. The correct answer is colored in green, while if the user chose an incorrect answer, this one is highlighted in red, showing both the correct and incorrect answers.
Conclusion
This example is far to be perfect or definitive, but it can be a good starting point to work with. For example, it can be improved by creating a stats page with the score of every game played, or a section where the user can create custom questions and categories (these can be a great exercise to make practice with databases). Hope this can be useful, feel free to propose improvements, suggestions or other.
You can find the source code in this GitHub repository.