KtDevLog
  • Home
  • Jetpack Compose
  • Kotlin Fundamentals
  • Android Studio
No Result
View All Result
KtDevLog
  • Home
  • Jetpack Compose
  • Kotlin Fundamentals
  • Android Studio
No Result
View All Result
KtDevLog
No Result
View All Result
android quiz app tutorial

Android Quiz App Tutorial: Build a Multiple Choice Game in Kotlin

Md Sharif Mia by Md Sharif Mia
May 15, 2026
in App Projects
0
0
Share on FacebookShare on PinterestShare on X

State management is one of those concepts that sounds abstract until you build something that actually depends on it. A quiz app makes it concrete.

Think about everything that has to stay in sync during a single quiz session: the current question index, the user’s selected answer, whether that answer was correct, the current score, the time remaining on each question, and whether the quiz is in progress, showing feedback, or on the results screen. Every user tap affects multiple pieces of state simultaneously. Get one of them wrong and the whole app breaks.

This android quiz app tutorial builds exactly that — a complete multiple-choice quiz game in Kotlin with Jetpack Compose, animated question transitions, a per-question countdown timer, instant right/wrong feedback, streak tracking, and a polished results screen. No database needed — the focus is entirely on state management logic, the part most tutorials gloss over.

Related Posts

Build a Wish List App: Room Database with Jetpack Compose

Build a Wish List App: Room Database with Jetpack Compose

May 23, 2026
expense tracker app android project

Create an Expense Tracker App Android Project (Step-by-Step)

May 14, 2026
Build a To-Do List App in Android Studio

Build a To-Do List App in Android Studio with Room

May 12, 2026
Build a Weather App in Kotlin

Build a Weather App in Kotlin with Retrofit API

May 11, 2026

Table of Contents

  • What You’ll Build
  • Step 1 — Dependencies
  • Step 2 — Question Data Model
  • Step 3 — Quiz State Model
  • Step 4 — QuizViewModel
  • Step 5 — Welcome Screen
  • Step 6 — Quiz Screen With AnimatedContent
  • Step 7 — Answer Option Buttons With Color Feedback
  • Step 8 — Countdown Timer Bar
  • Step 9 — Explanation Card
  • Step 10 — Results Screen
  • Step 11 — Wire Everything Together
  • The State Management Patterns That Make This Work
  • Frequently Asked Questions
    • State Management
      • How do I manage quiz state in an Android quiz app?
      • How do I prevent the user from selecting multiple answers?
    • Animation and UI
      • How does AnimatedContent work for question transitions?
      • How do I make answer buttons change color instantly on selection?
  • Conclusion

What You’ll Build

A fully playable quiz game with:

  • Welcome screen — topic intro with quiz metadata
  • Question screen — multiple choice answers with instant color feedback
  • Animated transitions — questions slide in as the previous one slides out
  • Countdown timer — 15 seconds per question, auto-advances on timeout
  • Score tracking — total score, current streak, and time-bonus points
  • Results screen — score percentage, grade, performance breakdown, and replay
  • Pure Compose state — no database, no network — clean state management focus

Step 1 — Dependencies

This project keeps dependencies minimal — it’s all about state logic, not library integration:

Kotlin
// app/build.gradle.kts
dependencies {
    // ViewModel + StateFlow
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")

    // Navigation Compose
    implementation("androidx.navigation:navigation-compose:2.9.0")

    // Coroutines — for the countdown timer
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
}
Kotlin

According to the official Jetpack Compose state documentation, the ViewModel + StateFlow pattern is the recommended approach for managing state that survives configuration changes — which matters enormously for a quiz app where the user mustn’t lose their progress on screen rotation.

Step 2 — Question Data Model

Kotlin
// QuizData.kt

data class Question(
    val id: Int,
    val text: String,
    val options: List<String>,
    val correctAnswerIndex: Int,
    val explanation: String = ""   // Shown after answering — great for learning apps
)

// Sample quiz — Kotlin knowledge test
val kotlinQuiz = listOf(
    Question(
        id = 1,
        text = "Which keyword in Kotlin makes a variable immutable?",
        options = listOf("var", "val", "const", "final"),
        correctAnswerIndex = 1,
        explanation = "val declares a read-only (immutable) variable. Its reference cannot be reassigned."
    ),
    Question(
        id = 2,
        text = "What does the ?: operator do in Kotlin?",
        options = listOf("Safe call", "Elvis operator", "Not-null assertion", "Spread operator"),
        correctAnswerIndex = 1,
        explanation = "The Elvis operator ?: provides a default value when the left-hand expression is null."
    ),
    Question(
        id = 3,
        text = "Which Kotlin class cannot be subclassed?",
        options = listOf("abstract class", "open class", "sealed class", "final class"),
        correctAnswerIndex = 2,
        explanation = "Sealed classes restrict inheritance to the same file, not the entire hierarchy."
    ),
    Question(
        id = 4,
        text = "What does 'data class' auto-generate in Kotlin?",
        options = listOf("toString() only", "equals(), hashCode(), toString(), copy()", "Serializable only", "Nothing"),
        correctAnswerIndex = 1,
        explanation = "Data classes auto-generate equals(), hashCode(), toString(), copy(), and componentN() functions."
    ),
    Question(
        id = 5,
        text = "Which collection function in Kotlin transforms each element?",
        options = listOf("filter()", "map()", "reduce()", "forEach()"),
        correctAnswerIndex = 1,
        explanation = "map() transforms each element of a collection and returns a new collection of the same size."
    ),
    Question(
        id = 6,
        text = "What is the default visibility modifier in Kotlin?",
        options = listOf("private", "protected", "internal", "public"),
        correctAnswerIndex = 3,
        explanation = "public is the default visibility in Kotlin — declarations are visible everywhere."
    ),
    Question(
        id = 7,
        text = "How do you declare a nullable String in Kotlin?",
        options = listOf("String?", "nullable String", "String!", "Optional<String>"),
        correctAnswerIndex = 0,
        explanation = "Appending ? to any type makes it nullable. String? can hold a String value or null."
    ),
    Question(
        id = 8,
        text = "Which scope function returns the receiver object itself?",
        options = listOf("let", "run", "apply", "also"),
        correctAnswerIndex = 2,
        explanation = "apply returns the context object (receiver). also returns the context object too, but uses 'it' instead of 'this'."
    ),
    Question(
        id = 9,
        text = "What is 'suspend' used for in Kotlin?",
        options = listOf("Stopping a thread", "Marking a coroutine function", "Making a variable final", "Pausing the UI"),
        correctAnswerIndex = 1,
        explanation = "suspend marks a function that can be paused and resumed — it can only be called from a coroutine or another suspend function."
    ),
    Question(
        id = 10,
        text = "Which function lets you execute code only if an object is non-null?",
        options = listOf("?.let { }", "?? { }", "ifNotNull { }", "safe { }"),
        correctAnswerIndex = 0,
        explanation = "?.let { } is a safe call combined with the let scope function — the block runs only if the object is non-null."
    )
)
Kotlin

Ten questions about Kotlin itself — perfectly aligned with KtDevLog’s audience. Each question has an explanation field that shows after answering — a learning feature that no other beginner quiz tutorial includes.

Step 3 — Quiz State Model

This is the most important design decision in the entire app. Define every piece of state the quiz needs in a single data class:

Kotlin
// QuizState.kt

enum class QuizScreen { WELCOME, QUIZ, RESULTS }

enum class AnswerState { NONE, CORRECT, WRONG }

data class QuizState(
    val screen: QuizScreen           = QuizScreen.WELCOME,
    val questions: List<Question>    = kotlinQuiz.shuffled(),   // Shuffled for replay variety
    val currentIndex: Int            = 0,
    val selectedAnswerIndex: Int?    = null,                    // null = not yet answered
    val answerState: AnswerState     = AnswerState.NONE,
    val score: Int                   = 0,
    val streak: Int                  = 0,                       // Consecutive correct answers
    val maxStreak: Int               = 0,
    val timeRemainingSeconds: Int    = 15,
    val isTimerRunning: Boolean      = true
) {
    val currentQuestion: Question
        get() = questions[currentIndex]

    val isLastQuestion: Boolean
        get() = currentIndex == questions.size - 1

    val progressFraction: Float
        get() = (currentIndex + 1).toFloat() / questions.size.toFloat()

    val scorePercentage: Int
        get() = (score * 100) / questions.size

    val grade: String
        get() = when {
            scorePercentage >= 90 -> "A+"
            scorePercentage >= 80 -> "A"
            scorePercentage >= 70 -> "B"
            scorePercentage >= 60 -> "C"
            else                  -> "D"
        }
}
Kotlin

Putting all quiz state in one data class has three major advantages that most tutorials miss explaining.

First — data class gives you copy() for free. Every state update is _state.update { it.copy(score = it.score + 1) } — immutable, predictable, no mutations. Second — derived properties like currentQuestion, isLastQuestion, progressFraction, grade — these live as computed properties on the data class rather than as separate StateFlows or ViewModel functions. Compute once per state change, always consistent. Third — the entire quiz state can be passed as a single parameter wherever it’s needed, keeping composable signatures clean.

Step 4 — QuizViewModel

Kotlin
// QuizViewModel.kt
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

class QuizViewModel : ViewModel() {

    private val _state = MutableStateFlow(QuizState())
    val state: StateFlow<QuizState> = _state

    private var timerJob: Job? = null

    // ── Start Quiz ──────────────────────────────────────────────
    fun startQuiz() {
        _state.update { it.copy(screen = QuizScreen.QUIZ) }
        startTimer()
    }

    // ── Handle Answer Selection ─────────────────────────────────
    fun selectAnswer(selectedIndex: Int) {
        val current = _state.value
        if (current.answerState != AnswerState.NONE) return  // Already answered
        if (!current.isTimerRunning) return                   // Timer expired

        timerJob?.cancel()

        val isCorrect = selectedIndex == current.currentQuestion.correctAnswerIndex
        val timeBonus  = if (isCorrect) current.timeRemainingSeconds / 3 else 0
        val newScore   = if (isCorrect) current.score + 1 + timeBonus else current.score
        val newStreak  = if (isCorrect) current.streak + 1 else 0

        _state.update {
            it.copy(
                selectedAnswerIndex = selectedIndex,
                answerState = if (isCorrect) AnswerState.CORRECT else AnswerState.WRONG,
                score       = newScore,
                streak      = newStreak,
                maxStreak   = maxOf(it.maxStreak, newStreak),
                isTimerRunning = false
            )
        }

        // Auto-advance after showing feedback
        viewModelScope.launch {
            delay(1_800)    // Show correct/wrong for 1.8 seconds
            moveToNext()
        }
    }

    // ── Timer Expired — No Answer Selected ─────────────────────
    private fun onTimerExpired() {
        _state.update {
            it.copy(
                answerState    = AnswerState.WRONG,     // No answer = wrong
                streak         = 0,
                isTimerRunning = false
            )
        }
        viewModelScope.launch {
            delay(1_800)
            moveToNext()
        }
    }

    // ── Move to Next Question or Results ────────────────────────
    private fun moveToNext() {
        val current = _state.value
        if (current.isLastQuestion) {
            _state.update { it.copy(screen = QuizScreen.RESULTS) }
        } else {
            _state.update {
                it.copy(
                    currentIndex        = it.currentIndex + 1,
                    selectedAnswerIndex = null,
                    answerState         = AnswerState.NONE,
                    timeRemainingSeconds = 15,
                    isTimerRunning      = true
                )
            }
            startTimer()
        }
    }

    // ── Countdown Timer ─────────────────────────────────────────
    private fun startTimer() {
        timerJob?.cancel()
        timerJob = viewModelScope.launch {
            repeat(15) { second ->
                delay(1_000)
                val remaining = 14 - second
                _state.update { it.copy(timeRemainingSeconds = remaining) }
                if (remaining == 0) {
                    onTimerExpired()
                    return@launch
                }
            }
        }
    }

    // ── Restart Quiz ────────────────────────────────────────────
    fun restartQuiz() {
        timerJob?.cancel()
        _state.value = QuizState()  // Full reset to initial state
    }

    override fun onCleared() {
        super.onCleared()
        timerJob?.cancel()
    }
}
Kotlin

Three architectural decisions in this ViewModel worth understanding:

if (current.answerState != AnswerState.NONE) return — the guard at the top of selectAnswer(). Without it, tapping an option while the 1.8-second feedback delay is running would apply a second answer. This one line prevents a whole class of UI bugs.

timeBonus = timeRemainingSeconds / 3 — answering in 1 second gives 5 bonus points. Answering in 10 seconds gives 1. This single line transforms a quiz into a game — speed matters.

timerJob?.cancel() in onCleared() — cancels the coroutine when the ViewModel is destroyed. Without this, the timer keeps running even after the user navigates away, causing state updates on a dead ViewModel.

Step 5 — Welcome Screen

Kotlin
@Composable
fun WelcomeScreen(onStartClick: () -> Unit) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .background(Color(0xFF1E1B4B))
            .padding(32.dp),
        verticalArrangement   = Arrangement.Center,
        horizontalAlignment   = Alignment.CenterHorizontally
    ) {
        Text(text = "🧠", fontSize = 72.sp)
        Spacer(modifier = Modifier.height(24.dp))
        Text(
            text = "Kotlin Quiz",
            fontSize   = 36.sp,
            fontWeight = FontWeight.Bold,
            color      = Color.White
        )
        Text(
            text = "Test your Kotlin knowledge",
            fontSize = 16.sp,
            color    = Color(0xFF818CF8),
            modifier = Modifier.padding(top = 8.dp, bottom = 32.dp)
        )

        // Quiz metadata chips
        Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
            InfoChip(label = "10 Questions")
            InfoChip(label = "15s per Q")
            InfoChip(label = "Time Bonus")
        }

        Spacer(modifier = Modifier.height(40.dp))

        Button(
            onClick = onStartClick,
            modifier = Modifier.fillMaxWidth().height(56.dp),
            shape  = RoundedCornerShape(16.dp),
            colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF7C3AED))
        ) {
            Text("Start Quiz", fontSize = 18.sp, fontWeight = FontWeight.Bold)
        }
    }
}

@Composable
fun InfoChip(label: String) {
    Surface(
        shape = RoundedCornerShape(20.dp),
        color = Color(0xFF312E81)
    ) {
        Text(
            text     = label,
            fontSize = 12.sp,
            color    = Color(0xFFC7D2FE),
            modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
        )
    }
}
Kotlin

Step 6 — Quiz Screen With AnimatedContent

AnimatedContent handles the question transition animation — the old question slides out to the left while the new question slides in from the right:

Kotlin
@Composable
fun QuizScreen(state: QuizState, onAnswerSelected: (Int) -> Unit) {

    Column(
        modifier = Modifier
            .fillMaxSize()
            .background(Color(0xFF1E1B4B))
            .padding(24.dp)
    ) {
        // Progress and score header
        QuizHeader(state = state)

        Spacer(modifier = Modifier.height(16.dp))

        // Countdown timer bar
        CountdownTimerBar(timeRemaining = state.timeRemainingSeconds)

        Spacer(modifier = Modifier.height(24.dp))

        // Animated question transition
        AnimatedContent(
            targetState = state.currentIndex,
            transitionSpec = {
                (slideInHorizontally { width -> width } + fadeIn())
                    .togetherWith(slideOutHorizontally { width -> -width } + fadeOut())
            },
            label = "questionTransition"
        ) { questionIndex ->
            QuestionCard(
                question       = state.questions[questionIndex],
                selectedIndex  = state.selectedAnswerIndex,
                answerState    = state.answerState,
                onOptionClick  = onAnswerSelected
            )
        }

        // Explanation — shown after answering
        if (state.answerState != AnswerState.NONE) {
            Spacer(modifier = Modifier.height(16.dp))
            ExplanationCard(
                explanation  = state.currentQuestion.explanation,
                answerState  = state.answerState
            )
        }
    }
}
Kotlin

AnimatedContent(targetState = state.currentIndex) — every time currentIndex changes (when moving to the next question), AnimatedContent triggers the transition. The content parameter receives the questionIndex at the moment of transition — so even if state updates again during the animation, the outgoing question shows the correct content.

Kotlin
@Composable
fun QuizHeader(state: QuizState) {
    Column {
        Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.SpaceBetween,
            verticalAlignment     = Alignment.CenterVertically
        ) {
            Text(
                text   = "${state.currentIndex + 1} / ${state.questions.size}",
                color  = Color(0xFF818CF8),
                fontSize = 14.sp
            )
            // Score display
            Text(
                text   = "Score: ${state.score}",
                color  = Color.White,
                fontSize = 14.sp,
                fontWeight = FontWeight.Bold
            )
            // Streak fire indicator
            if (state.streak >= 2) {
                Text(
                    text   = "🔥 ${state.streak}",
                    fontSize = 14.sp,
                    color  = Color(0xFFFB923C)
                )
            }
        }

        Spacer(modifier = Modifier.height(8.dp))

        // Progress bar
        LinearProgressIndicator(
            progress  = { state.progressFraction },
            modifier  = Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp)),
            color     = Color(0xFF7C3AED),
            trackColor = Color(0xFF312E81)
        )
    }
}
Kotlin

Step 7 — Answer Option Buttons With Color Feedback

This is the section that makes or breaks a quiz app’s feel. The instant colour change on selection — green for correct, red for wrong — is what makes the feedback feel immediate and satisfying:

Kotlin
@Composable
fun QuestionCard(
    question: Question,
    selectedIndex: Int?,
    answerState: AnswerState,
    onOptionClick: (Int) -> Unit
) {
    Column {
        // Question text
        Text(
            text       = question.text,
            fontSize   = 20.sp,
            fontWeight = FontWeight.Bold,
            color      = Color.White,
            lineHeight = 28.sp,
            modifier   = Modifier.padding(bottom = 24.dp)
        )

        // Answer options
        question.options.forEachIndexed { index, option ->
            AnswerOptionButton(
                text          = option,
                index         = index,
                selectedIndex = selectedIndex,
                correctIndex  = question.correctAnswerIndex,
                answerState   = answerState,
                onClick       = { onOptionClick(index) }
            )
            Spacer(modifier = Modifier.height(10.dp))
        }
    }
}

@Composable
fun AnswerOptionButton(
    text: String,
    index: Int,
    selectedIndex: Int?,
    correctIndex: Int,
    answerState: AnswerState,
    onClick: () -> Unit
) {
    val isSelected = index == selectedIndex
    val isCorrect  = index == correctIndex
    val hasAnswered = answerState != AnswerState.NONE

    // Animate background color based on answer state
    val backgroundColor by animateColorAsState(
        targetValue = when {
            !hasAnswered         -> Color(0xFF312E81)   // Unanswered — dark purple
            isCorrect            -> Color(0xFF15803D)   // Correct answer — green
            isSelected && !isCorrect -> Color(0xFFB91C1C) // Wrong selection — red
            else                 -> Color(0xFF312E81)   // Other options — dim
        },
        animationSpec = tween(durationMillis = 300),
        label = "optionBgColor"
    )

    val borderColor by animateColorAsState(
        targetValue = when {
            !hasAnswered && !isSelected -> Color(0xFF4338CA)
            !hasAnswered && isSelected  -> Color(0xFF7C3AED)
            isCorrect                   -> Color(0xFF16A34A)
            isSelected && !isCorrect    -> Color(0xFFDC2626)
            else                        -> Color(0xFF312E81)
        },
        animationSpec = tween(durationMillis = 300),
        label = "optionBorderColor"
    )

    Surface(
        modifier = Modifier
            .fillMaxWidth()
            .clickable(enabled = !hasAnswered) { onClick() },
        shape  = RoundedCornerShape(12.dp),
        color  = backgroundColor,
        border = BorderStroke(1.5.dp, borderColor)
    ) {
        Row(
            modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            // Option letter badge
            Surface(
                shape = CircleShape,
                color = borderColor.copy(alpha = 0.3f),
                modifier = Modifier.size(28.dp)
            ) {
                Box(contentAlignment = Alignment.Center) {
                    Text(
                        text = ('A' + index).toString(),
                        fontSize   = 12.sp,
                        fontWeight = FontWeight.Bold,
                        color      = Color.White
                    )
                }
            }
            Spacer(modifier = Modifier.width(12.dp))
            Text(
                text     = text,
                fontSize = 15.sp,
                color    = Color.White,
                modifier = Modifier.weight(1f)
            )
            // Result icon
            if (hasAnswered) {
                Text(
                    text = when {
                        isCorrect -> "✓"
                        isSelected && !isCorrect -> "✗"
                        else -> ""
                    },
                    fontSize   = 18.sp,
                    color      = if (isCorrect) Color(0xFF4ADE80) else Color(0xFFF87171),
                    fontWeight = FontWeight.Bold
                )
            }
        }
    }
}
Kotlin

The clickable(enabled = !hasAnswered) disables all buttons once an answer is selected — critical for preventing the user from changing their mind during the 1.8-second feedback window.

animateColorAsState for both backgroundColor and borderColor makes the correct/wrong reveal feel smooth and satisfying rather than jarring. The 300ms animation gives the brain just enough time to register the transition.

Step 8 — Countdown Timer Bar

Kotlin
@Composable
fun CountdownTimerBar(timeRemaining: Int) {
    val timerColor by animateColorAsState(
        targetValue = when {
            timeRemaining > 10 -> Color(0xFF22C55E)   // Green — plenty of time
            timeRemaining > 5  -> Color(0xFFF59E0B)   // Amber — getting close
            else               -> Color(0xFFEF4444)   // Red — urgent
        },
        label = "timerColor"
    )

    Column {
        Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.SpaceBetween
        ) {
            Text(text = "⏱ Time", fontSize = 12.sp, color = Color(0xFF818CF8))
            Text(
                text = "${timeRemaining}s",
                fontSize   = 12.sp,
                fontWeight = FontWeight.Bold,
                color      = timerColor
            )
        }
        Spacer(modifier = Modifier.height(4.dp))
        LinearProgressIndicator(
            progress      = { timeRemaining / 15f },
            modifier      = Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp)),
            color         = timerColor,
            trackColor    = Color(0xFF312E81)
        )
    }
}
Kotlin

The timer bar changes colour: green above 10 seconds, amber from 6–10, red from 1–5. Combined with the number shrinking to 0, it creates urgency without any additional animation code.

Step 9 — Explanation Card

Kotlin
@Composable
fun ExplanationCard(explanation: String, answerState: AnswerState) {
    val bgColor    = if (answerState == AnswerState.CORRECT) Color(0xFF14532D) else Color(0xFF7F1D1D)
    val borderColor = if (answerState == AnswerState.CORRECT) Color(0xFF16A34A) else Color(0xFFDC2626)
    val icon       = if (answerState == AnswerState.CORRECT) "✓" else "✗"

    AnimatedVisibility(
        visible    = true,
        enter      = fadeIn(tween(400)) + slideInVertically { it / 2 }
    ) {
        Surface(
            shape  = RoundedCornerShape(12.dp),
            color  = bgColor,
            border = BorderStroke(1.dp, borderColor),
            modifier = Modifier.fillMaxWidth()
        ) {
            Row(
                modifier = Modifier.padding(14.dp),
                horizontalArrangement = Arrangement.spacedBy(10.dp)
            ) {
                Text(text = icon, fontSize = 18.sp, color = borderColor)
                Text(
                    text     = explanation,
                    fontSize = 13.sp,
                    color    = Color(0xFFD1FAE5),
                    lineHeight = 20.sp
                )
            }
        }
    }
}
Kotlin

The explanation card slides up from below when it appears — AnimatedVisibility with slideInVertically { it / 2 } gives it a subtle lift rather than a jarring snap. This is the learning feature that makes this quiz app genuinely educational, not just a game.

Step 10 — Results Screen

Kotlin
@Composable
fun ResultsScreen(state: QuizState, onRestart: () -> Unit) {
    val percentage = state.scorePercentage

    Column(
        modifier = Modifier
            .fillMaxSize()
            .background(Color(0xFF1E1B4B))
            .padding(32.dp),
        verticalArrangement   = Arrangement.Center,
        horizontalAlignment   = Alignment.CenterHorizontally
    ) {
        // Grade circle
        Box(
            modifier = Modifier
                .size(120.dp)
                .clip(CircleShape)
                .background(
                    when {
                        percentage >= 80 -> Color(0xFF15803D)
                        percentage >= 60 -> Color(0xFFB45309)
                        else             -> Color(0xFFB91C1C)
                    }
                ),
            contentAlignment = Alignment.Center
        ) {
            Column(horizontalAlignment = Alignment.CenterHorizontally) {
                Text(
                    text       = state.grade,
                    fontSize   = 40.sp,
                    fontWeight = FontWeight.Bold,
                    color      = Color.White
                )
                Text(
                    text   = "$percentage%",
                    fontSize = 14.sp,
                    color  = Color.White.copy(alpha = 0.8f)
                )
            }
        }

        Spacer(modifier = Modifier.height(24.dp))

        Text(
            text = when {
                percentage >= 90 -> "Outstanding! 🎉"
                percentage >= 70 -> "Great Work! 👏"
                percentage >= 50 -> "Good Effort! 💪"
                else             -> "Keep Practicing! 📚"
            },
            fontSize   = 24.sp,
            fontWeight = FontWeight.Bold,
            color      = Color.White
        )

        Spacer(modifier = Modifier.height(32.dp))

        // Stats grid
        Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.SpaceEvenly
        ) {
            StatItem(label = "Score",      value = "${state.score} pts")
            StatItem(label = "Correct",    value = "${state.score.coerceAtMost(10)} / ${state.questions.size}")
            StatItem(label = "Best Streak", value = "🔥 ${state.maxStreak}")
        }

        Spacer(modifier = Modifier.height(40.dp))

        Button(
            onClick  = onRestart,
            modifier = Modifier.fillMaxWidth().height(52.dp),
            shape    = RoundedCornerShape(14.dp),
            colors   = ButtonDefaults.buttonColors(containerColor = Color(0xFF7C3AED))
        ) {
            Text("Play Again", fontSize = 16.sp, fontWeight = FontWeight.Bold)
        }
    }
}

@Composable
fun StatItem(label: String, value: String) {
    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        Text(text = value,  fontSize = 20.sp, fontWeight = FontWeight.Bold, color = Color.White)
        Text(text = label,  fontSize = 12.sp, color = Color(0xFF818CF8))
    }
}
Kotlin

Step 11 — Wire Everything Together

Kotlin
@Composable
fun QuizApp(viewModel: QuizViewModel = viewModel()) {
    val state by viewModel.state.collectAsStateWithLifecycle()

    when (state.screen) {
        QuizScreen.WELCOME -> WelcomeScreen(onStartClick = viewModel::startQuiz)
        QuizScreen.QUIZ    -> QuizScreen(
            state            = state,
            onAnswerSelected = viewModel::selectAnswer
        )
        QuizScreen.RESULTS -> ResultsScreen(
            state     = state,
            onRestart = viewModel::restartQuiz
        )
    }
}

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            MaterialTheme {
                QuizApp()
            }
        }
    }
}
Kotlin

The when (state.screen) switch on a QuizScreen enum is the clean navigation pattern for a linear flow like a quiz — no NavController needed. The three screens are entirely sequential: Welcome → Quiz → Results → Welcome (restart). Compose handles the recomposition when screen changes.

The State Management Patterns That Make This Work

Here’s the explicit breakdown of every state challenge in a quiz app and how this implementation solves each one:

“How do I prevent double-tapping an answer?” — if (current.answerState != AnswerState.NONE) return at the top of selectAnswer().

“How do I show feedback for 1.8 seconds and then move on?” — delay(1_800) inside a viewModelScope.launch after updating answer state.

“How do I auto-advance when the timer expires?” — onTimerExpired() sets answerState = WRONG and calls the same delayed moveToNext().

“How do I handle the last question?” — if (current.isLastQuestion) inside moveToNext() navigates to QuizScreen.RESULTS instead of incrementing the index.

“How do I reset everything for replay?” — _state.value = QuizState() — the no-argument constructor gives you the entire initial state in one line.

“How does screen rotation not break the quiz?” — QuizViewModel : ViewModel() — the ViewModel survives configuration changes automatically.

Frequently Asked Questions

State Management

How do I manage quiz state in an Android quiz app?

Use a single data class that holds all quiz state — current question index, selected answer, score, timer, and screen. Expose it as a StateFlow<QuizState> from a ViewModel. Update state using _state.update { it.copy(...) } — the copy() method from data class creates a new immutable state object with specific fields changed. Compose automatically recomposes when the StateFlow emits a new value.

How do I prevent the user from selecting multiple answers?

Add a guard at the start of your answer selection function: if (current.answerState != AnswerState.NONE) return. This exits immediately if an answer has already been selected. In the UI, add clickable(enabled = !hasAnswered) to each option button — this both prevents click events and provides a visual disabled state. Both guards together are necessary — one prevents logic execution, the other prevents UI interaction.

Animation and UI

How does AnimatedContent work for question transitions?

AnimatedContent(targetState = currentIndex) watches the currentIndex value. When it changes, AnimatedContent triggers the transition defined in transitionSpec — sliding the old question out to the left while sliding the new one in from the right. The targetState value is passed to the content lambda, ensuring each transition displays the correct question even if state changes again during the animation.

How do I make answer buttons change color instantly on selection?

Use animateColorAsState() with the answer’s current state as the target value. Define colors for each state: unanswered, correct, wrong selection, and other options after answering. When answerState changes, animateColorAsState smoothly interpolates from the old color to the new one over the specified duration. Combining this with the AnswerState enum gives you a clean, type-safe way to drive all color logic from a single source of truth.

Conclusion

An android quiz app tutorial that focuses on state management teaches more than just quiz logic — it teaches you how to think about reactive state in any Compose app. The principles you’ve applied here — a single data class holding all UI state, derived properties for computed values, guard clauses preventing invalid state transitions, AnimatedContent for smooth screen changes, and coroutine-based timers tied to the ViewModel lifecycle — appear in production apps far beyond quiz games.

The feature that separates this from other quiz tutorials: the explanation card. Every answer teaches something. That one addition transforms a throwaway game into a genuine learning tool — and that’s the kind of thoughtful UX detail that makes a portfolio project memorable.

Add your own question sets, style the dark theme to match your personal brand, and consider wiring the score history into a Room Database for persistence across sessions.

The best quiz app is the one that makes you smarter for playing it.

Tags: android quiz app tutorial
SharePinTweet
Md Sharif Mia

Md Sharif Mia

Md Sharif Mia is a Kotlin and Android developer with hands-on experience building real-world Android applications using Kotlin, Jetpack Compose, and Firebase. He created KtDevLog to help aspiring Android developers learn through practical, step-by-step tutorials — from writing their first line of Kotlin to shipping complete apps.Through KtDevLog, Sharif shares what actually works in Android development: clean code patterns, common beginner mistakes to avoid, and project-based lessons that go beyond theory. His writing style is direct and beginner-friendly, making complex Android concepts easy to understand for developers at any stage.When he is not writing tutorials, Sharif is experimenting with new Android features, exploring Kotlin best practices, and building apps that solve everyday problems.

Related Posts

Build a Wish List App: Room Database with Jetpack Compose
App Projects

Build a Wish List App: Room Database with Jetpack Compose

May 23, 2026

If you've been building Android apps for a while, you already know that sooner...

expense tracker app android project
App Projects

Create an Expense Tracker App Android Project (Step-by-Step)

May 14, 2026

Here's a truth about Android app tutorials that most developers discover too late: the...

Build a To-Do List App in Android Studio
App Projects

Build a To-Do List App in Android Studio with Room

May 12, 2026

Every great Android developer Build a To-Do List App in Android Studio with at...

Build a Weather App in Kotlin
App Projects

Build a Weather App in Kotlin with Retrofit API

May 11, 2026

Here's the moment every Android developer remembers: the first time their app showed real...

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

  • About Us
  • Contact Us
  • Privacy Policy
  • Terms & Conditions

© Copyright 2026 KtDevLog. All Rights Reserved.

Welcome Back!

Login to your account below

Forgotten Password?

Retrieve your password

Please enter your username or email address to reset your password.

Log In
No Result
View All Result
  • Home
  • Jetpack Compose
  • Kotlin Fundamentals
  • Android Studio

© Copyright 2026 KtDevLog. All Rights Reserved.