Flutter Riverpod Quiz App Tutorial | Apps From Scratch

flutter riverpod Feb 17, 2021
flutter_riverpod_quiz_app

Source Code: https://github.com/MarcusNg/flutter_riverpod_quiz

Today, I'm going to teach you how to build a quiz app with Riverpod and Flutter Hooks that fetches data from an API.

Users are presented with a question with four possible answers from a random quiz. If the correct answer is chosen, then a green border and green checkmark icon appear around the answer. If the incorrect answer is chosen, then a red border and red X icon appear around the selected answer. The correct answer to the question is also revealed.

The Next Question button moves users to the next question. When users finish their quiz, they can view their quiz results. Tapping on New Quiz will load up a new quiz for our users.

Pubspec

The first thing we’re going to do is create a new flutter project called flutter_riverpod_quiz. Inside of our pubspec.yaml file, let’s add the necessary dependencies.

dio is for sending http requests and allows us to fetch data from the quiz API.

enum_to_string lets us convert enum values to strings.

equatable makes it super easy to compare objects in Dart.

For state management, we’re using hooks_riverpod, which means we also need to have flutter_hooks.

html_character_entities is for decoding the html character entities that are sometimes present in the strings returned from the API.

meta is for dart annotations.

Quiz API

The API we’re retrieving quizzes from is the Open Trivia Database.

We can generate a URL by going to the API and choosing the number of questions, category, difficulty, and type.

https://opentdb.com/api.php?amount=5&category=9&difficulty=medium&type=multiple

When we go to the generated url, we get back a JSON that has a response code and a results list. We want to focus on an individual quiz question in this results list in order to create our Question Model schema.

Question Model

In a new folder called models, let’s add a file called question_model.dart.

Question Model contains a class Question that extends Equatable. If we look at the JSON, all of the values are Strings except for incorrect_answers, which is a List<Strings>. Let’s parse the question information we want: category, difficulty, question, correct answer, and all answers. Now answers is going to be a List<Strings> containing the correct answer and the incorrect answers.

Let’s generate our constructor and make all of these parameters @required. Add our Equatable props so we’re able to compare Questions objects with one another. And lastly we’ll add a factory constructor called fromMap that takes Map<String, dynamic>.

If map is null, then return null, but if it’s not null, then we can return a Question. By referencing the JSON blob, we’re able to easily create the Question because we know what data the API gives us.

The double question mark is a null aware operator that makes sure if the expression on the left evaluates to null, we assign an empty string or empty list depending on the situation. This is so we prevent calling any methods on null, which would result in an error.

Answers is a shuffled list of all possible answers.

And now we’re all done with our Question Model.

Base Quiz Repository and Difficulty Enum

Now make a new folder called repositories with a quiz folder. Here we’ll make two files: base_quiz_repository and quiz_repository.

Base quiz repository is an abstract class that contains the signatures of any methods quiz repository needs to implement. Since this quiz app is a simple example, we only need to have one method called getQuestions.

getQuestions returns a Future<List<Questions>> and takes in three parameters: the number of questions, the category Id, and a difficulty enum.

The number of questions is how many questions we want the API request to return.

The category id is the integer id of a category defined by Open Trivia Database. We can view all the different categories and their corresponding ids by going to the API Documentation, and going to the Category Lookup endpoint.

At the moment, there’s 24 different categories with IDs ranging from 9 to 32, inclusive.

The Difficulty enum is for defining the question difficulty. We’ll make this enum in an enums folder that has difficulty.dart. The enum has four values: any, easy, medium, and hard.

Quiz Repository and Failure Model

Inside quiz_repository, we have a QuizRepository that extends BaseQuizRepository.

QuizRepository has a dependency on Reader from Riverpod. Reader allows the QuizRepository to read other providers in the app. We’re going to use this to access our dioProvider to make HTTP requests. We’ll define our dioProvider at the top of this file.

Next we can implement getQuestions. In a try catch block, we should define the query parameters for our GET request.

type is 'multiple', so we only get multiple choice questions.

amount is the number of questions.

category is the category id.

We only want to pass in difficulty as a query parameter if it is not equal to Any.

We can make the request to the API by accessing our dioProvider with read and passing in the endpoint with the query parameters.

If the response is successful, we convert response.data to Map<String, dynamic>, grab the list of results, and then return the List<Question>. If the request is not successful or the results list is empty, we return an empty list.

For our catch block, we’re going to handle DioErrors and SocketExceptions. We’ll throw our own model called Failure, which we’ll define in our models folder.

Failure is a class that contains a String message.

To access this QuizRepository anywhere in the app, we should declare quizRepositoryProvider, providing our QuizRepository and passing in ref.read as the Reader.

Quiz State

Let’s create a folder called controllers that has a folder called quiz.

Inside we’ll make two files: quiz_state and quiz_controller

The UI will interact with the Quiz Controller to modify our Quiz State. Based on the Quiz State, our UI will react accordingly and render the appropriate widgets. If you’re familiar with Bloc, this is very similar to using a cubit where the quiz controller is our cubit.

We can breakdown the UI of our app to figure out what values our quiz state needs and how our quiz controller should modify the quiz state.

This screen has four different scenarios:

  1. The user has not chosen an answer (initial)
  2. The user has selected a correct answer (correct)
  3. The user has selected an incorrect answer (incorrect)
  4. And the user has completed the quiz (complete)

We’ll keep track of this with an enumeration called QuizStatus.

To know which answer the user selects, we need a String that stores the selected answer.

And finally we need to remember which questions the user gets right and wrong so we can show the results at the end of the quiz. In our app, we just show the number of correct questions, but we’re going to keep track of both correct and incorrect answers in case you want to expand on the project and show the actual questions to the user.

I added a getter named answered which returns a boolean based on if the quiz status is currently incorrect or correct. This will clean up our UI code as we’ll have to check for this a couple of times.

QuizState has a factory constructor called initial that returns a default state when the quiz is first loaded. Remember to add a copyWith method to easily modify values in quiz state.

Quiz Controller

Let’s move onto our QuizController. QuizController extends a StateNotifier with type QuizState and the super constructor returns the initial quiz state.

We have three methods to modify our quiz state.

First is submitAnswer for when a user taps on an answer. We check if the current state is answered and return, to prevent users from submitting answers multiple times. Our state is modified using copyWith based on if the answer is correct or incorrect.

Next is nextQuestion for when a user taps the NextQuestion or SeeResults button. The selectedAnswer is set back to an empty string and the status is updated depending on if we are at the last question or not.

Last is reset for resetting the quiz when the user taps on New Quiz.

At the top of this file, let’s provide this QuizController to our app using a StateNotifierProvider. Tacking on autoDispose will make sure the state of our provider is destroyed when it’s no longer used. This would be more beneficial if we had an app with navigation between multiple screens, but I’ll leave this in anyway.

main.dart and Quiz UI

We’re finally ready to start working on the UI of our app. I’ll be creating all of the UI inside of main.dart, but feel free to refactor each widget into its own file for better organization. I’m going to remove the counter example and import all of the necessary files and packages.

To access any provider in our app, we have to wrap our MaterialApp with ProviderScope. We’ll set debugShowCheckedModeBanner to false, the primarySwatch to Colors.yellow, and the background of any bottom sheet to Colors.transparent.

Home is set to QuizScreen, which we’ll add right now. QuizScreen is a HookWidget with a gradient background and Scaffold.

To fetch questions using our quiz repository, we can make a FutureProvider that returns a List<Questions> by calling our getQuestions method. We use ref.watch to access our quizRepositoryProvider instead of ref.read because we want our quiz repository to give us new questions whenever we refresh this provider.

As I mentioned before, there are currently 24 different categories with IDs ranging from 9 to 32, inclusive, so we can generate a random category using Random.nextInt(). And we’ll set the difficulty to Any.

We can now get this provider by doing useProvider(quizQuestionsProvider).

We’ll also create a PageController with Flutter Hooks so we can programmatically animate our PageView to the next page.

The body of our scaffold calls .when on our quizQuestionsProvider to build different UI when our future has data, is loading, or has an error.

When our future has data, we call _buildBody and pass in context, pageController, and the returned questions.

For loading, we return const Center(child: CircularProgressIndicator());

And for error, we’ll return a QuizError that checks if the error is of type Failure, and returns the error message.

Let’s write our QuizError underneath this widget. It’s a centered column with a text widget and CustomButton we use throughout our app.

When the user taps on this button we refresh our quizRepositoryProvider to make the API call again.

CustomButton takes a String title and VoidCallback onTap. It’s a styled container with a gesture detector and text widget.

We’ll define a boxShadow above as we’re going to also use it for our AnswerCard and CircularIcon widgets.

Back in our QuizScreen, we need to add a bottomSheet. We use maybeWhen because we only want to show a widget when we have a List<Question> from the API. We display the CustomButton only after a question is answered and move to the next page in our pageview when we’re not at the last question in our list.

We still have to write our _buildBody method. If no questions are found, we return a quiz error. If the current quiz status is complete, then we return QuizResults, otherwise we return our QuizQuestions.

QuizResults displays the number of questions the user got correct and has a CustomButton to start a new quiz. We also have to reset our quizController’s state.

QuizQuestions takes in the pageController, quizState, and questions list to display each question to our users. It returns a PageView.builder with the physics set to NeverScrollablePhysics to disable users from scrolling. Using the index to grab each question, we display the question number in a Text widget with the actual question underneath. Since some strings have encoded HTML character entities, we can decode them with the HTML character entities package.

Let’s add a divider and a column with children mapped to a list of AnswerCards. The onTap calls our quiz controller’s submitAnswer method passing in the question and selectedAnswer.

AnswerCard has a String answer, booleans isSelected, isCorrect, isDisplayingAnswer, and VoidCallback onTap. The build method is a GestureDetector with a child Container. The decoration has our BoxShadow we defined earlier and only displays a border if we are currently displaying the correct answer. Because we only want to display a red border around the answer a user selects, we also include an isSelected check.

The container’s child is a row widget with a Flexible Text answer and a CircularIcon that only displays after submitting an answer.

CircularIcon takes in IconData and Color. It returns a circle container with a BoxShadow and white icon.

🎉 Success 🎉

We’re now all done with our Flutter Riverpod Quiz App. Check out my YouTube channel and courses for more Flutter content.

Get notified about new courses and updates