Build an AI-Powered Quiz App with Compose and Gemini: Part 2 (ViewModel)
Hello there 😎,
This is the second part of this series of articles. We are building a quiz app powered by Gemini AI. If you haven't, read the first part here:
It's time to bring this game to life. Let’s setup the navigation and develop the first part of the UI layer. We’ll use compose navigation and use some helper code to make it type-safe.
Table of Contents
- Setting up the navigation
- The quiz game core: The ViewModel
– MVI Architecture
– ViewModel Properties
– The ViewModel Initialization - Quiz Game Logic
- The Process Event Function
- Wrap up
Setting up the navigation
ℹ️ In Navigation 2.8.0, safe args was made available for Navigation Compose. Please consider checking the documentation and following that approach instead.
In the utils
package, create the AppNavigation
kotlin file. In the file, create a new value class to represent the navigation routes:
In the same file, we’ll add the AppNavigation
sealed interface, where we will create a data object for each destination of our app:
- Every destination will need to provide a NavRoute
- We define the
DifficultyLevelDestination
- We define the
QuizGameDestination
- The
getNavRoute
helper function will help us build a nav route with an argument to be passed to this destination.
The Quiz Game Core: ViewModel
In the quizGame
package, create the QuizGameViewModel
class:
Our game will have 2 possible states in this screen: In Progress and Finished. We will represent those states with a sealed interface. Inside the QuizGameViewModel
class, create the GameState
sealed interface:
Each data class contain the information the quiz need in each state.
Inside the GameState, we will create a copy
helper function that will help us later when updating the view state:
This is only needed in the InProgress
state, as the Finished
state is not intended to change.
MVI Architecture
In this project we are going to use the Model-View-Intent architecture in the UI layer. We’ll need to define the State, Effect and Event (intent) definitions. Let’s start with the state:
- By default the screen will be loading
- The initial game state will be in progress
- The viewEffect will be used for one-shot events
Now, let’s define the possible one-shot events with the ViewEffect
sealed interface:
Finally, we need to define the events or intents. This are all the possible actions the composables would like to trigger in the viewmodel:
- Will be used when the user want to show the next question by skipping or because the countdown finished
- When the user chooses and answer, this event will be sent
- This event will help consume the event to avoid it being emitted more than once. Learn more about this in the docs.
ViewModel Properties
- We will expose the view state as a flow to be collected by the view.
- The questions will be stored here
- This map will be filled as the user advances in the quiz
- This property will hold the index of the currently selected question
The ViewModel Initialization
There are certain operations that we want to perform as soon as the viewModel is created. We place these in the init block. The first one is loading the quiz:
- First we get the difficulty level argument from the navigation arguments using the
savedStateHandle
object. - Using this string, we get the proper enum value.
- We launch a new coroutine that calls the
generateQuiz
suspend function using the difficulty level - If there was an issue parsing the enum value, we send an error view event
- If there was an issue getting the navigation argument, we send an error view event
Next step in the init block is to collect the quizSharedFlow
from the QuizRepository
:
- If the response is of type success, we add the new questions to the questions property
- Only if the view state is loading, we initialize the viewState with the first question as the selected question, and set the loading to false.
- If the response is of type error we log it and show an error snackbar.
Quiz Game Logic
The logic of the game is simple and it fits on a single function that we’ll name showNextQuestionOrFinish()
:
- We increase by one the selected question index
- We store in a variable whether the game finished, by checking the selected index and comparing it with the amount of questions
- If the game is not finished we update the state with the new selected question
Now, let’s focus in the game finished logic. Replace the TODO
comment with this:
- We map the questions to include only the answer the user chose or null if there was no answer
- The total correct answer are saved in a variable
- We calculate the score percentage to be used in the score screen
- The state is updated with the calculated values
The Process Event Function
To complete the MVI cycle, we just need to provide a public function to process the events (intents). To do this create the processEvent()
function:
- We make use of the function we just created when the
NextQuestion
event is sent - When an answer is selected, we first update the state to set the
selectedAnswerId
- Next, we save the selected answer in the
answersMap
property - Lastly we wait for 5 seconds and then show the next question
- The ConsumeEffect will set the effect back to null so the view doesn’t get it again on the next state update.
Wrap Up
Now the logic is in place, it’s time to make it usable and beautiful. Follow up for the third article of the series to add the UI using Jetpack Compose. Stay tuned.