Phase 7: Study Tasks
Combine the earlier skills in a small two-screen task app. Add, complete, inspect, and delete study tasks. Room preserves saved records. A plain completion summary communicates progress without animation.
Guided workshop: 4–5 hours
Before you start
- Packing Checklist
- Offline Lesson Catalog
- Reading Log
- Input validation and testing
Application structure and responsibilities
- Two screens: a list and details, with a Back action.
Concepts to explain: two-screen Navigation Compose · stable ID arguments · Room persistence · shared ViewModel state · pure validation and JUnit tests · derived completion progress
What you will practice
- two-screen Navigation Compose
- stable ID arguments
- Room persistence
- shared ViewModel state
- pure validation and JUnit tests
- derived completion progress
Download the complete project and run it
Each ZIP includes the application source, build scripts, Gradle wrapper, manifest, resources and README. The full solution is also readable below; no feature is left as an unfinished TODO.
- Extract the ZIP. In Android Studio, open the project folder containing
settings.gradle.kts. - Use JDK 17 or 21 and install Android SDK Platform 36 plus the Build Tools requested by Android Studio.
- Allow the first Gradle sync to download dependencies. No backend account or API key is needed.
- Select an emulator or device running API 23 or newer, then run the
appconfiguration. - Start with the expected results below. Open
README.mdandTOOLCHAIN.mdfor the exact project instructions.
.\gradlew.bat :app:assembleDebug :app:testDebugUnitTestKeep the first version small
Implementation roadmap
- 1. Run the final app and trace its two screens: One list destination and one detail destination work without an account, API key or network request.
- 2. Store tasks with IDs and an explicit completion flag: Completing a task can move its row, but its generated ID and title stay the same.
- 3. Test one small input rule before using Android: Blank and too-long input cannot create tasks; the maximum permitted title is accepted after trimming.
- 4. Use one ViewModel and one Room-backed list: Completing a task in detail also updates the list and its progress after Back.
- 5. Navigate with a stable numeric ID: A list reorder does not change the open detail identity. Back pops to the existing task list.
- Read completion from saved tasks: One completed task out of two shows 1 of 2. Deleting all rows returns to zero without dividing by zero.
- 7. Demonstrate restoration, persistence and honest limits: Saved tasks and completion survive restart; unsaved drafts are deliberately temporary. Pure title tests pass without claiming to test the full UI or Room.
Build the app step by step
Follow these steps in order. Each code window shows the relevant part of a real project file; the complete, unabridged files are in the source browser and ZIP. Run and check each small change before moving on.
Step 1: Run the final app and trace its two screens
Open the project, sync with JDK 17 and SDK Platform 36, run the local unit tests and launch on API 23+. Add one task, select its title and return with Back to tasks.
Open: README.md · app/src/main/java/com/mastercoding/phase7/MainActivity.kt
@Composable
private fun PocketTasks(model: TasksViewModel) {
val state by model.state.collectAsStateWithLifecycle()
val nav = rememberNavController()
Scaffold { insets ->
NavHost(
nav, startDestination = "tasks",
modifier = Modifier.fillMaxSize().padding(insets)
.consumeWindowInsets(insets).imePadding(),
) {
composable("tasks") {
TaskList(state, model::editDraft, model::addTask,
model::completeTask, model::deleteTask,
onOpen = { id -> nav.navigate("task/$id") { launchSingleTop = true } },
onRetry = model::retryRead)
}
composable("task/{taskId}", arguments = listOf(
navArgument("taskId") { type = NavType.LongType },Expected behavior
Step 2: Store tasks with IDs and an explicit completion flag
Read Task, TaskDao and TasksRepository together. Completion updates one row by ID; deletion also uses an ID. The query orders incomplete tasks before completed tasks, so list position is not a stable identity.
Open: app/src/main/java/com/mastercoding/phase7/TasksDatabase.kt
@Dao
interface TaskDao {
@Query("SELECT * FROM tasks ORDER BY completed ASC, id DESC")
fun observeAll(): Flow<List<Task>>
@Insert suspend fun insert(task: Task)
@Query("UPDATE tasks SET completed = :completed WHERE id = :id")
suspend fun setCompleted(id: Long, completed: Boolean)
@Query("DELETE FROM tasks WHERE id = :id")
suspend fun delete(id: Long)
}
@Database(entities = [Task::class], version = 1, exportSchema = true)
abstract class TasksDatabase : RoomDatabase() {
abstract fun tasks(): TaskDao
companion object {
@Volatile private var instance: TasksDatabase? = null
fun get(context: Context): TasksDatabase = instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(Expected behavior
Step 3: Test one small input rule before using Android
normalizedTitle trims outer whitespace and accepts 1–80 remaining characters. Invalid input returns null and the ViewModel displays a message. Read the tests before running testDebugUnitTest.
Open: app/src/main/java/com/mastercoding/phase7/TaskRules.kt · app/src/test/java/com/mastercoding/phase7/TaskRulesTest.kt · app/src/main/java/com/mastercoding/phase7/TasksViewModel.kt
// Reject long input; silently cutting a student's task would lose their words.
fun normalizedTitle(raw: String): String? = raw.trim().takeIf {
it.isNotEmpty() && it.length <= MAX_TITLE_LENGTH
}Expected behavior
Step 4: Use one ViewModel and one Room-backed list
retryRead converts database emissions into screen state. addTask, completeTask and deleteTask call the same asynchronous write helper. The Activity creates the ViewModel once above the NavHost, so both destinations see the same rows.
Open: app/src/main/java/com/mastercoding/phase7/TasksViewModel.kt · app/src/main/java/com/mastercoding/phase7/MainActivity.kt
fun deleteTask(id: Long) = write { repository.delete(id) }
private fun write(clearDraft: Boolean = false, action: suspend () -> Unit) {
if (state.value.busy) return
mutableState.update { it.copy(busy = true, writeError = null) }
viewModelScope.launch {
try {
action()
if (clearDraft) mutableState.update { it.copy(draft = "") }
} catch (error: Exception) {
if (error is CancellationException) throw error
mutableState.update { it.copy(writeError = "Change not saved. Try again.") }
} finally {
mutableState.update { it.copy(busy = false) }
}
}
}
companion object {Expected behavior
Step 5: Navigate with a stable numeric ID
The string route task/{taskId} declares NavType.LongType. A title click navigates with only the ID. The detail screen finds the current row after Room loads it, and handles an unavailable or missing row with a readable state and Back action.
Open: app/src/main/java/com/mastercoding/phase7/MainActivity.kt
onRetry = model::retryRead)
}
composable("task/{taskId}", arguments = listOf(
navArgument("taskId") { type = NavType.LongType },
)) { entry ->
val id = entry.arguments?.getLong("taskId")
TaskDetail(state.tasks.firstOrNull { it.id == id }, state,
onComplete = model::completeTask,
onBack = { nav.popBackStack() }, onRetry = model::retryRead)
}
}
}
}
@Composable
private fun TaskList(
state: TasksUiState, onDraft: (String) -> Unit, onAdd: () -> Unit,
onComplete: (Long, Boolean) -> Unit, onDelete: (Long) -> Unit, onOpen: (Long) -> Unit,
onRetry: () -> Unit,Expected behavior
Step 6: Read completion from saved tasks
Count completed rows and derive the progress fraction. Use zero for an empty list. Show a plain All done message only when a nonempty list is complete.
Open: app/src/main/java/com/mastercoding/phase7/MainActivity.kt
) {
val completed = state.tasks.count { it.completed }
val target = if (state.tasks.isEmpty()) 0f else completed.toFloat() / state.tasks.size
val progress = target
val canEdit = !state.busy && !state.loading && state.readError == null
LazyColumn(
modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
TaskStatus(state, onRetry)
OutlinedTextField(
value = state.draft, onValueChange = onDraft, label = { Text("New task") },
supportingText = { Text("Up to $MAX_TITLE_LENGTH characters") },
enabled = !state.busy, singleLine = true, modifier = Modifier.fillMaxWidth(),
)
Button(onClick = onAdd, enabled = canEdit) { Text(if (state.busy) "Saving…" else "Add task") }
Text("$completed of ${state.tasks.size} completed")Expected behavior
Step 7: Demonstrate restoration, persistence and honest limits
Rotate on detail, then close the app completely and reopen it. Confirm saved completion flags remain in Room. Run the title tests again and use the README checklist to record manual UI and persistence results.
Open: README.md · app/src/main/java/com/mastercoding/phase7/TasksDatabase.kt · app/src/main/java/com/mastercoding/phase7/TasksViewModel.kt · app/src/test/java/com/mastercoding/phase7/TaskRulesTest.kt
}
@Database(entities = [Task::class], version = 1, exportSchema = true)
abstract class TasksDatabase : RoomDatabase() {
abstract fun tasks(): TaskDao
companion object {
@Volatile private var instance: TasksDatabase? = null
fun get(context: Context): TasksDatabase = instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext, TasksDatabase::class.java, "tasks.db",
).build().also { instance = it }
}
}
}
class TasksRepository(private val dao: TaskDao) {
val tasks: Flow<List<Task>> = dao.observeAll()
suspend fun add(title: String) = dao.insert(Task(title = title))
suspend fun complete(id: Long, completed: Boolean) = dao.setCompleted(id, completed)Expected behavior
Complete source code — every file
Start with MainActivity.kt, then follow the file guide. The selector also contains the tests, manifest, resources and complete Gradle configuration. The binary Gradle wrapper JAR is included in the download.
| File | Purpose |
|---|---|
| app/src/main/java/com/mastercoding/phase7/TasksDatabase.kt | A task entity, four DAO operations, a process-wide database and a small repository. |
| app/src/main/java/com/mastercoding/phase7/TaskRules.kt | The pure 1–80 character title policy; no Android dependency is needed to understand or test it. |
| app/src/main/java/com/mastercoding/phase7/TasksViewModel.kt | One state owner for both destinations, observable reads, validation and save/complete/delete events. |
| app/src/main/java/com/mastercoding/phase7/MainActivity.kt | The complete list and detail UI, stable-ID routes, Back behavior and a plain derived progress summary. |
| app/src/test/java/com/mastercoding/phase7/TaskRulesTest.kt | Three local JVM tests covering blank input, outer whitespace and the title-length boundary. |
| README.md | Build and test commands, a six-step build, navigation explanations and observable checks. |
The complete list and detail UI, stable-ID routes, Back behavior and a plain derived progress summary.
20 complete text files. Copy the selected file with the code panel button, or download the full project above.
package com.mastercoding.phase7
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
class MainActivity : ComponentActivity() {
private val model: TasksViewModel by viewModels {
TasksViewModel.factory(TasksRepository(TasksDatabase.get(applicationContext).tasks()))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent { MaterialTheme { PocketTasks(model) } }
}
}
@Composable
private fun PocketTasks(model: TasksViewModel) {
val state by model.state.collectAsStateWithLifecycle()
val nav = rememberNavController()
Scaffold { insets ->
NavHost(
nav, startDestination = "tasks",
modifier = Modifier.fillMaxSize().padding(insets)
.consumeWindowInsets(insets).imePadding(),
) {
composable("tasks") {
TaskList(state, model::editDraft, model::addTask,
model::completeTask, model::deleteTask,
onOpen = { id -> nav.navigate("task/$id") { launchSingleTop = true } },
onRetry = model::retryRead)
}
composable("task/{taskId}", arguments = listOf(
navArgument("taskId") { type = NavType.LongType },
)) { entry ->
val id = entry.arguments?.getLong("taskId")
TaskDetail(state.tasks.firstOrNull { it.id == id }, state,
onComplete = model::completeTask,
onBack = { nav.popBackStack() }, onRetry = model::retryRead)
}
}
}
}
@Composable
private fun TaskList(
state: TasksUiState, onDraft: (String) -> Unit, onAdd: () -> Unit,
onComplete: (Long, Boolean) -> Unit, onDelete: (Long) -> Unit, onOpen: (Long) -> Unit,
onRetry: () -> Unit,
) {
val completed = state.tasks.count { it.completed }
val target = if (state.tasks.isEmpty()) 0f else completed.toFloat() / state.tasks.size
val progress = target
val canEdit = !state.busy && !state.loading && state.readError == null
LazyColumn(
modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
TaskStatus(state, onRetry)
OutlinedTextField(
value = state.draft, onValueChange = onDraft, label = { Text("New task") },
supportingText = { Text("Up to $MAX_TITLE_LENGTH characters") },
enabled = !state.busy, singleLine = true, modifier = Modifier.fillMaxWidth(),
)
Button(onClick = onAdd, enabled = canEdit) { Text(if (state.busy) "Saving…" else "Add task") }
Text("$completed of ${state.tasks.size} completed")
LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
if (state.tasks.isNotEmpty() && completed == state.tasks.size) {
Text("All done!", color = MaterialTheme.colorScheme.primary)
}
if (!state.loading && state.readError == null && state.tasks.isEmpty()) {
Text("Your list is empty. Add one small task.")
}
}
}
items(state.tasks, key = { it.id }) { task ->
Card(Modifier.fillMaxWidth()) {
Row(Modifier.padding(4.dp)) {
Checkbox(
checked = task.completed, onCheckedChange = { onComplete(task.id, it) },
enabled = canEdit,
modifier = Modifier.semantics { contentDescription = "Completed: ${task.title}" },
)
TextButton(onClick = { onOpen(task.id) }, modifier = Modifier.weight(1f)) {
Text(task.title)
}
TextButton(onClick = { onDelete(task.id) }, enabled = canEdit) { Text("Delete") }
}
}
}
}
}
@Composable
private fun TaskDetail(
task: Task?, state: TasksUiState, onComplete: (Long, Boolean) -> Unit, onBack: () -> Unit,
onRetry: () -> Unit,
) {
Column(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
TaskStatus(state, onRetry)
TextButton(onClick = onBack) { Text("Back to tasks") }
when {
state.loading -> Text("Loading task…")
state.readError != null -> Text("Task unavailable. Use Retry reading above.")
task == null -> Text("Task not found. It may have been deleted.")
else -> {
Text(task.title, style = MaterialTheme.typography.headlineSmall)
Text("Task #${task.id} · ${if (task.completed) "Completed" else "Still to do"}")
Row {
Checkbox(checked = task.completed, enabled = !state.busy,
onCheckedChange = { onComplete(task.id, it) },
modifier = Modifier.semantics { contentDescription = "Completed: ${task.title}" })
Text("Completed", Modifier.padding(top = 12.dp))
}
}
}
}
}
@Composable
private fun TaskStatus(state: TasksUiState, onRetry: () -> Unit) {
Text("Study Tasks", style = MaterialTheme.typography.headlineMedium)
if (state.loading) CircularProgressIndicator()
state.readError?.let {
Text(it, color = MaterialTheme.colorScheme.error)
TextButton(onClick = onRetry) { Text("Retry reading") }
}
state.writeError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}Definition of done
Check an item only after demonstrating it. Progress is saved in this browser.
Try the reference solution yourself
Run each action and compare the result. Record the device, action and observed outcome. For the testing phase, run the included unit tests and the Compose test separately: a JVM test does not prove on-device UI behavior.
| Action | Expected result |
|---|---|
| Submit spaces only and then an 81-character title. | Both submissions are rejected with a message and no new saved row; the draft remains editable. |
| Add two tasks, open the first, and check Completed in detail. | Back shows that task complete and the progress label reads 1 of 2 completed. |
| Complete the remaining task, then uncheck one. | All done appears at 100% and disappears when completion drops. |
| Delete every task. | A readable empty state and zero progress appear without a division-by-zero error. |
| Save and complete a task, close the app completely, and reopen it. | The same task and completion flag return from Room. |
| Rotate while viewing a task detail. | The same stable ID is shown after recreation; loading is handled before a missing-task message. |
| Run .\gradlew.bat testDebugUnitTest from the project root. | Three local JUnit tests verify the title rule without an emulator. |
Common mistakes and repairs
- A list index changes when completion reorders rows. Use the database ID for keys, routes, updates and deletes.
- Create the shared ViewModel above the NavHost; independent destination ViewModels would add unnecessary state owners here.
- Wait for loading to finish before declaring that a detail ID is missing.
- The numeric route accepts a Long, and the detail lookup handles an absent row. External deep links are not enabled in this project.
- Keep saved completion in Room and derive the displayed summary from those rows.
- The JVM tests cover title validation only. Demonstrate Room persistence and navigation with the separate manual checks.
Assessment rubric
| Criterion | Points | Evidence |
|---|---|---|
| Core behavior | 40 | All acceptance scenarios produce their expected results. |
| Clear state ownership | 30 | Trace one event from its callback to the displayed result and identify the owner of each value. |
| Boundary cases | 20 | Verify the listed invalid, empty, and restoration cases. |
| Readable implementation | 10 | Use descriptive names and small functions; describe any known limitation. |
Total: 100 points. Demonstrate the working app, trace one event through the source, and verify a boundary case.
Optional extensions after the core works
- Add a simple title search while leaving Room as the source of truth.
- Add a small edit-title screen that updates the existing ID.
- Write an instrumented Room test covering insert, complete and delete.
- As a separate exercise, replace the two string routes with typed serializable routes.
For deeper study, use the official Compose course and Android architecture guide.