Your assignment: Android Basics Quiz
Create a five-question learning app with accessible answer selection, immediate explanations, a progress indicator, guarded scoring, and a restartable result screen.
Beginner · 3–4 hours of building
Before you begin
- Kotlin data classes, lists, zero-based indices, and functions
- Compose state, conditional UI, and callbacks from Projects 1–5
- Basic string resources and Android Studio local test execution
By the end, you can…
- Describe a quiz question with a data model instead of duplicating screens
- Build a single-choice group with selectable rows and RadioButton
- Separate selecting, submitting, reviewing, and advancing into explicit states
- Prevent missing selections and repeated submissions from changing the score
- Restore the current question and score after rotation and verify scoring with local tests
Required features
- Provide five local Android or Kotlin questions with four possible answers each.
- Display one question at a time, its position, and a progress indicator.
- Require a valid selection before submission. After submission, lock the selection and show the correct answer with an explanation.
- Award one point for a correct answer and never award a second point for submitting the same question again.
- Advance only after review. After question five, display a final score out of five and a Restart quiz button.
- Reset all progress on restart and preserve progress, selection, submission state, and score during rotation.
Set up the project and run the reference app
- Open: Extract the ZIP. In Android Studio choose Open, then select the project folder containing
settings.gradle.kts. Open that folder rather than just a Kotlin file. - Prepare: Use Android Studio’s bundled JDK 17 or 21 in Gradle settings. In SDK Manager install Android SDK Platform 36 and any Build Tools requested during sync.
- Sync: Allow Gradle to download the pinned dependencies. Initial setup requires internet. The app runs offline and needs no backend, login or API key.
- Run: In Device Manager create an emulator with API 23 or newer, or connect a device with USB debugging. Select the
apprun configuration and press Run. - Observe: Try the normal and boundary scenarios from Test the app. Record what changes after each action. Use Logcat to investigate a crash instead of changing several files at once.
- Practice: Copy the extracted folder to a working folder. Keep the build files, manifest, resources and
CommonUi.kt. Rebuild the application files following the steps below. Attempt each task before consulting its reference.
.\gradlew.bat :app:assembleDebug :app:testDebugUnitTestKeep the toolchain consistent
TOOLCHAIN.md for setup details.Understand the topics before coding
For each topic, connect the Kotlin concept to a visible behavior. Identify the value the screen reads and the event that can change that value.
Data-driven screens
QuizQuestion contains a stable ID, prompt resource, four option resources, a correct index, and an explanation resource. The same screen reads quizQuestions[questionIndex] for every question. Adding content therefore changes the bank rather than introducing another nearly identical composable.
A small state machine
The round has three visible stages: answer selection, submitted review, and final results. selectedIndex starts at -1, submitted locks an answered question, and finished selects the result branch. Next resets only the per-question values; Restart resets the whole quiz. Naming these stages makes transitions easier to reason about.
Index validation and scoring guards
Options use zero-based indices. canSubmitAnswer checks that the selection refers to an existing option and the question has not been submitted. scoreAfterAnswer adds a point only when that guarded selection matches correctIndex. The same guard controls the button and its event handler, so a repeated event cannot score twice.
Single-choice accessibility
The options are a selectableGroup. Each row exposes the RadioButton role and its selected state, allowing the label and radio control to behave as one choice. RadioButton has onClick = null because the row owns the click. Keeping one click target avoids two competing handlers for the same answer.
Saved progress and derived UI
The five saved values are enough to reconstruct the current question, selected answer, feedback, and score after rotation. The progress fraction is calculated from questionIndex and submitted rather than saved separately. Recomputing such values avoids a progress bar that disagrees with the current stage.
Three different lifetimes
rememberSaveable restores small supported values after Activity recreation; a ViewModel retains screen state during configuration changes. Neither replaces permanent storage. The notes project uses Room for records that must remain after closing and reopening the app.Build the app step by step
Work in this order. The code panels are focused excerpts from the complete files below. They may begin or end inside a function: use the full source browser when copying a file, including its imports. After every step, run the app and check the stated result.
Step 1. Launch the project and identify its files
Your task: Extract the download and open its settings.gradle.kts folder in Android Studio. Sync Gradle and run app on an API 23 or newer emulator. Find MainActivity.kt, QuizQuestions.kt, QuizRules.kt, and res/values/strings.xml. Trace how the activity enters QuizScreen through the shared MiniTheme and MiniScreen helpers.
Open: app/src/main/java/com/mastercoding/miniprojects/quiz/MainActivity.kt
How it works: The activity owns Android hosting, the question file owns local content, the rules file owns scoring policy, and QuizScreen connects events to UI state. This structure lets you improve content without accidentally changing how points are awarded.
import androidx.compose.ui.unit.dp
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MiniTheme {
MiniScreen(stringResource(R.string.screen_title)) {
QuizScreen()
}
}
}
}
}
@Composable
private fun QuizScreen() {
var questionIndex by rememberSaveable { mutableIntStateOf(0) }
var selectedIndex by rememberSaveable { mutableIntStateOf(-1) }
var submitted by rememberSaveable { mutableStateOf(false) }
var score by rememberSaveable { mutableIntStateOf(0) }
var finished by rememberSaveable { mutableStateOf(false) }
if (finished) {
Text(stringResource(R.string.completed), style = MaterialTheme.typography.headlineSmall)
Text(stringResource(R.string.final_score, score, quizQuestions.size))Stop and check
Step 2. Model a question and construct the local question bank
Your task: Open QuizQuestions.kt. For each question, map the prompt and options to strings.xml and identify correctIndex. Remember that index 0 means the first option. The provided one-based answer positions are 2, 1, 3, 4, and 2. Change the wording of one question without changing its correct answer, then run again.
Open: app/src/main/java/com/mastercoding/miniprojects/quiz/QuizQuestions.kt
How it works: A question is data, not a separate screen. Resource IDs connect each content field to localizable text. IDs identify questions independently of their position; correctIndex identifies an option within one question. When editing the bank, always keep the option order and answer index consistent.
package com.mastercoding.miniprojects.quiz
data class QuizQuestion(
val id: Int,
val prompt: Int,
val options: List<Int>,
val correctIndex: Int,
val explanation: Int
)
// Resource IDs keep the question bank localizable without mixing UI into scoring.
val quizQuestions = listOf(
QuizQuestion(1, R.string.question_1,
listOf(R.string.q1_a, R.string.q1_b, R.string.q1_c, R.string.q1_d),
correctIndex = 1, explanation = R.string.explanation_1),
QuizQuestion(2, R.string.question_2,
listOf(R.string.q2_a, R.string.q2_b, R.string.q2_c, R.string.q2_d),
correctIndex = 0, explanation = R.string.explanation_2),
QuizQuestion(3, R.string.question_3,
listOf(R.string.q3_a, R.string.q3_b, R.string.q3_c, R.string.q3_d),
correctIndex = 2, explanation = R.string.explanation_3),
QuizQuestion(4, R.string.question_4,
listOf(R.string.q4_a, R.string.q4_b, R.string.q4_c, R.string.q4_d),
correctIndex = 3, explanation = R.string.explanation_4),
QuizQuestion(5, R.string.question_5,
listOf(R.string.q5_a, R.string.q5_b, R.string.q5_c, R.string.q5_d),Stop and check
Step 3. Define selection and scoring rules before UI wiring
Your task: Read canSubmitAnswer and scoreAfterAnswer. Trace a missing answer (-1), an invalid option (4 for four options), a correct answer, an incorrect answer, and an already-submitted answer. Write your expected new score for each trace before running the tests. Keep the helper independent of Composable functions and Android resources.
Open: app/src/main/java/com/mastercoding/miniprojects/quiz/QuizRules.kt
How it works: Submission eligibility and correctness are different questions. A correct index must not add a point when that answer was already submitted. Guarding the scoring function protects the score even if a future UI change accidentally calls it twice.
/** An answer can be submitted only once and must refer to an existing option. */
fun canSubmitAnswer(selectedIndex: Int, optionCount: Int, submitted: Boolean): Boolean =
!submitted && selectedIndex in 0 until optionCount
fun scoreAfterAnswer(
currentScore: Int,
selectedIndex: Int,
correctIndex: Int,
optionCount: Int,
submitted: Boolean
): Int {
require(currentScore >= 0)
require(correctIndex in 0 until optionCount)
return currentScore + if (
canSubmitAnswer(selectedIndex, optionCount, submitted) && selectedIndex == correctIndex
) 1 else 0
}Stop and check
Step 4. Declare the saved quiz state and render results conditionally
Your task: Inspect questionIndex, selectedIndex, submitted, score, and finished in QuizScreen. Explain why selectedIndex uses -1 instead of defaulting to the first answer. Trace the finished branch and its early return. Answer one question, rotate the emulator, and verify that the same review state and score remain.
Open: app/src/main/java/com/mastercoding/miniprojects/quiz/MainActivity.kt
How it works: A default selected answer would allow a submission the student never chose. The explicit finished branch prevents indexing beyond the question bank when the quiz ends. Saving only the small values needed to reconstruct the UI keeps the result and current question consistent after activity recreation.
@Composable
private fun QuizScreen() {
var questionIndex by rememberSaveable { mutableIntStateOf(0) }
var selectedIndex by rememberSaveable { mutableIntStateOf(-1) }
var submitted by rememberSaveable { mutableStateOf(false) }
var score by rememberSaveable { mutableIntStateOf(0) }
var finished by rememberSaveable { mutableStateOf(false) }
if (finished) {
Text(stringResource(R.string.completed), style = MaterialTheme.typography.headlineSmall)
Text(stringResource(R.string.final_score, score, quizQuestions.size))
Text(stringResource(R.string.review_tip))
Button(
onClick = {
questionIndex = 0
selectedIndex = -1
submitted = false
score = 0
finished = false
},
modifier = Modifier.fillMaxWidth()
) { Text(stringResource(R.string.restart)) }
return
}
val question = quizQuestions[questionIndex]Stop and check
Step 5. Build a radio group with one selection owner
Your task: Find the options Column and its selectableGroup modifier. For each row, connect selected to selectedIndex == index, update selectedIndex in the row click, and set enabled to !submitted. Keep RadioButton onClick null. Test tapping both the text and the radio area; then inspect the group with TalkBack if available.
Open: app/src/main/java/com/mastercoding/miniprojects/quiz/MainActivity.kt
How it works: One selectedIndex guarantees a single answer. The row owns selection so the full labeled area is usable, while the radio indicator reflects that state. The group and RadioButton semantics expose the choice relationship to accessibility services.
)
Column(
modifier = Modifier.selectableGroup(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
question.options.forEachIndexed { index, option ->
Row(
modifier = Modifier.fillMaxWidth()
.selectable(
selected = selectedIndex == index,
enabled = !submitted,
role = Role.RadioButton,
onClick = { selectedIndex = index }
)
.padding(vertical = 8.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selectedIndex == index,
onClick = null,
enabled = !submitted
)
Text(stringResource(option), modifier = Modifier.padding(start = 12.dp))
}
}
}Stop and check
Step 6. Submit once and explain the answer
Your task: Read the Submit button event. Keep the canSubmitAnswer guard around the score update, then set submitted true. Follow the conditional review block that displays Correct or a learning message, the correct answer, and its explanation. Try a wrong answer deliberately and describe what changes and what stays fixed.
Open: app/src/main/java/com/mastercoding/miniprojects/quiz/MainActivity.kt
How it works: Selecting an option does not score it; submitting commits the decision. Feedback is derived from the selected and correct indices, so it agrees with scoring. The polite live region announces the new explanation while Next remains a separate deliberate action.
onClick = {
if (canSubmitAnswer(selectedIndex, question.options.size, submitted)) {
score = scoreAfterAnswer(
score, selectedIndex, question.correctIndex, question.options.size, submitted
)
submitted = true
}
},
enabled = canSubmitAnswer(selectedIndex, question.options.size, submitted),
modifier = Modifier.fillMaxWidth()
) { Text(stringResource(R.string.submit_answer)) }
if (submitted) {
Column(
modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(stringResource(
if (selectedIndex == question.correctIndex) R.string.correct else R.string.incorrect
))
Text(stringResource(R.string.correct_answer, stringResource(question.options[question.correctIndex])))
Text(stringResource(question.explanation))
}
Button(
onClick = {
if (questionIndex == quizQuestions.lastIndex) {Stop and check
Step 7. Advance, finish, and restart with explicit resets
Your task: Inspect the Next button. On ordinary questions, increase questionIndex and reset selectedIndex and submitted; on the last question, set finished. Inspect Restart and verify that all five saved values return to their initial values. Finish once with all correct answers and once with all incorrect answers.
Open: app/src/main/java/com/mastercoding/miniprojects/quiz/MainActivity.kt
How it works: Per-question reset must preserve the total score, while full restart must clear it. The final-question check happens before increasing the index. The result screen therefore stays within the list boundary and the next run begins with a truly empty selection.
Button(
onClick = {
if (questionIndex == quizQuestions.lastIndex) {
finished = true
} else {
questionIndex += 1
selectedIndex = -1
submitted = false
}
},
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(
if (questionIndex == quizQuestions.lastIndex) R.string.show_results else R.string.next_question
))
}
}
}Stop and check
Step 8. Run tests and check a complete learning flow
Your task: Run QuizRulesTest from its gutter icon or execute gradlew.bat :app:testDebugUnitTest. Then complete the manual checks below, including rotation before and after submitting. In your README, explain the difference between choosing an option, committing an answer, advancing, and restarting.
Open: app/src/test/java/com/mastercoding/miniprojects/quiz/QuizRulesTest.kt
How it works: Local tests verify scoring boundaries, but they cannot prove that answer labels, accessibility semantics, and the review flow are connected correctly. Combining deterministic rules tests with a short device walkthrough covers both responsibilities.
}
@Test fun correctAnswersAddOnePointOnlyBeforeSubmission() {
assertEquals(3, scoreAfterAnswer(2, 1, 1, 4, false))
assertEquals(3, scoreAfterAnswer(3, 1, 1, 4, true))
}
@Test fun incorrectAndMissingAnswersDoNotAddPoints() {
assertEquals(2, scoreAfterAnswer(2, 0, 1, 4, false))
assertEquals(2, scoreAfterAnswer(2, -1, 1, 4, false))
}
}Stop and check
Complete source files
Every text file in the ZIP is available here, including Kotlin, resources, Gradle scripts and the student README. The binary Gradle wrapper JAR is included in the ZIP. Source excerpts and downloads are generated from these same files.
| Start here | Responsibility |
|---|---|
| MainActivity.kt | The launcher Activity installs the Compose UI with setContent. |
| CommonUi.kt | Material 3 theme and reusable screen container with scrolling and system insets. |
| Application Kotlin files | State, rules, navigation or persistence introduced in the guide. |
| app/src/main/res | Images, text resources, and the launch theme. |
| app/build.gradle.kts | SDK settings and the exact libraries used by this app. |
| README.md / TOOLCHAIN.md | Student instructions and setup, usable without the website. |
Android launcher Activity, setContent entry point and screen composition. Start tracing the app here.
21 complete text files. Copy the selected file with the code panel button, or download the full project above.
package com.mastercoding.miniprojects.quiz
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material3.Button
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MiniTheme {
MiniScreen(stringResource(R.string.screen_title)) {
QuizScreen()
}
}
}
}
}
@Composable
private fun QuizScreen() {
var questionIndex by rememberSaveable { mutableIntStateOf(0) }
var selectedIndex by rememberSaveable { mutableIntStateOf(-1) }
var submitted by rememberSaveable { mutableStateOf(false) }
var score by rememberSaveable { mutableIntStateOf(0) }
var finished by rememberSaveable { mutableStateOf(false) }
if (finished) {
Text(stringResource(R.string.completed), style = MaterialTheme.typography.headlineSmall)
Text(stringResource(R.string.final_score, score, quizQuestions.size))
Text(stringResource(R.string.review_tip))
Button(
onClick = {
questionIndex = 0
selectedIndex = -1
submitted = false
score = 0
finished = false
},
modifier = Modifier.fillMaxWidth()
) { Text(stringResource(R.string.restart)) }
return
}
val question = quizQuestions[questionIndex]
Text(stringResource(R.string.progress, questionIndex + 1, quizQuestions.size))
LinearProgressIndicator(
progress = { (questionIndex + if (submitted) 1f else 0f) / quizQuestions.size },
modifier = Modifier.fillMaxWidth()
)
Text(
stringResource(question.prompt),
style = MaterialTheme.typography.titleLarge
)
Column(
modifier = Modifier.selectableGroup(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
question.options.forEachIndexed { index, option ->
Row(
modifier = Modifier.fillMaxWidth()
.selectable(
selected = selectedIndex == index,
enabled = !submitted,
role = Role.RadioButton,
onClick = { selectedIndex = index }
)
.padding(vertical = 8.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selectedIndex == index,
onClick = null,
enabled = !submitted
)
Text(stringResource(option), modifier = Modifier.padding(start = 12.dp))
}
}
}
Button(
onClick = {
if (canSubmitAnswer(selectedIndex, question.options.size, submitted)) {
score = scoreAfterAnswer(
score, selectedIndex, question.correctIndex, question.options.size, submitted
)
submitted = true
}
},
enabled = canSubmitAnswer(selectedIndex, question.options.size, submitted),
modifier = Modifier.fillMaxWidth()
) { Text(stringResource(R.string.submit_answer)) }
if (submitted) {
Column(
modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(stringResource(
if (selectedIndex == question.correctIndex) R.string.correct else R.string.incorrect
))
Text(stringResource(R.string.correct_answer, stringResource(question.options[question.correctIndex])))
Text(stringResource(question.explanation))
}
Button(
onClick = {
if (questionIndex == quizQuestions.lastIndex) {
finished = true
} else {
questionIndex += 1
selectedIndex = -1
submitted = false
}
},
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(
if (questionIndex == quizQuestions.lastIndex) R.string.show_results else R.string.next_question
))
}
}
}Test the app with concrete scenarios
Run these scenarios on your device or emulator. Write down the actual result beside the expected result. A successful Gradle build checks compilation; it does not demonstrate tapping, accessibility, keyboard behavior or rotation.
| Do this | Expected result |
|---|---|
| Open a fresh quiz without choosing an option. | No option is selected and Submit answer is disabled. |
| Tap an answer label, then choose a different one. | The selection moves so exactly one option is selected. |
| Submit a correct answer and attempt to submit again. | The explanation appears, choices and Submit lock, and only one point is awarded. |
| Submit an incorrect answer and tap Next. | The wrong answer adds no point. The next question starts with no selection or previous feedback. |
| Rotate before submitting, and rotate again while viewing submitted feedback. | The current question, selection, submission lock, and accumulated score remain consistent. |
| Answer using positions 2, 1, 3, 4, and 2, then show results. | The result is 5 / 5 and no sixth question is accessed. |
| Restart from results and run QuizRulesTest. | The UI starts clean at question one and all guard and scoring tests pass. |
Your completion checklist
Check an item only after demonstrating it. Progress is saved in this browser.
Troubleshooting: understand the cause
A question grants points more than once.
Cause: Scoring ignores submitted or the handler always increases the score.
Repair: Use canSubmitAnswer in both UI eligibility and the event, then set submitted true after the one score update.
The next question already has an answer selected.
Cause: The previous selectedIndex was not reset.
Repair: Set selectedIndex to -1 and submitted to false when advancing.
The app crashes after question five.
Cause: questionIndex is incremented beyond the list before showing results.
Repair: Check lastIndex first and switch to finished instead of reading another question.
Choosing the visually correct option earns no point.
Cause: The bank uses a one-based answer position as a zero-based correctIndex.
Repair: Use 0 for the first option and verify every edited question against its option order.
Android resource compilation reports an invalid reference for @Composable.
Cause: A string value beginning with @ is interpreted as a resource reference.
Repair: Keep the leading @ escaped as \@ in strings.xml, as shown in the provided question resources.
Submit your app and explain your decisions
- Submit the Android Studio project without build caches,
.gradleor machine-specificlocal.properties. - Include two screenshots demonstrating normal behavior and an edge case.
- Add a short README recording the device/API version, actions, observed outcomes, and any known limitation.
- Answer the questions below using function and state names from your own implementation.
- Why does the app need both selectedIndex and submitted instead of scoring every time an option is tapped?
- Which values reset on Next, which reset on Restart, and why are those two transitions different?
- If you shuffle the options, what must happen to correctIndex so scoring remains correct?
| Criterion | Points | What to demonstrate |
|---|---|---|
| Core behavior | 40 | All required actions give the expected result. |
| Validation and lifecycle | 20 | Invalid inputs, empty states, rotation and the project's persistence rules work. |
| Interface and accessibility | 20 | Readable contrast, labeled inputs, meaningful image descriptions, scrollable content and usable touch targets. |
| Code and explanation | 20 | Small named functions, clear state ownership and a correct explanation of one event-to-result trace. |
Extra challenges after the core works
- Add a review screen that stores each committed answer and explains the complete result.
- Shuffle question order once at quiz start. Save the chosen question IDs so rotation does not change the order.
- Add topic categories and calculate a separate score per topic without duplicating the question screen.
Choose one extension, write its acceptance check first, then implement it without breaking the original scenarios.