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:

You can see a better gif here.
  • 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 Appis 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 a StreamedTransformed 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 classTriviaBloc 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 apiTypeis 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 categories
  • jsonResponse['trivia_categories'][INDEX]['id']: id of the category
  • jsonResponse['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 &quot;Launchpad&quot;?","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 tabControllerthat 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 AppTabis 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 _nextQuestionmethod 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.