Phase 1 step-by-step

Build a Compose habit tracker from zero.

Outcome: one runnable Android app where the user can add habits, mark them done, delete them, and understand how Compose state updates the screen.

Create and verify the app shell

Create an Empty Activity Compose project named HabitTracker. Run it before changing code.

app/src/main/java/com/example/habittracker/MainActivity.kt
Expected result: the default app opens on emulator or phone. If it does not run, fix SDK/emulator/Gradle before continuing.

Replace the first screen

Open MainActivity.kt. Keep the generated package line. Replace the body with a simple composable.

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable

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

@Composable
fun HabitTrackerApp() {
    Text("Habit Tracker")
}
Checkpoint: app compiles and shows “Habit Tracker”.

Add the data model

Add this below the imports. A habit needs a stable id so list actions affect the correct row.

data class Habit(
    val id: Int,
    val title: String,
    val isDone: Boolean = false
)
Nothing visual changes yet. This step prepares the screen state.

Add text input state

Use remember so Compose keeps the current text while the screen redraws.

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue

@Composable
fun HabitTrackerApp() {
    var title by remember { mutableStateOf("") }

    Column(modifier = Modifier.padding(16.dp)) {
        Text("Habit Tracker")
        OutlinedTextField(
            modifier = Modifier.fillMaxWidth(),
            value = title,
            onValueChange = { title = it },
            label = { Text("New habit") }
        )
        Button(
            enabled = title.trim().isNotEmpty(),
            onClick = { title = "" }
        ) { Text("Add habit") }
    }
}
Checkpoint: typing works, and the button disables when the field is blank.

Add list state and render rows

Now store a list of habits and render it with LazyColumn.

import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.foundation.layout.Row

@Composable
fun HabitTrackerApp() {
    var nextId by remember { mutableStateOf(1) }
    var title by remember { mutableStateOf("") }
    var habits by remember { mutableStateOf(listOf<Habit>()) }

    Column(modifier = Modifier.padding(16.dp)) {
        Text("Habit Tracker")
        OutlinedTextField(value = title, onValueChange = { title = it }, label = { Text("New habit") })
        Button(enabled = title.trim().isNotEmpty(), onClick = {
            habits = habits + Habit(nextId, title.trim())
            nextId += 1
            title = ""
        }) { Text("Add habit") }

        if (habits.isEmpty()) {
            Text("No habits yet. Add one to start.")
        } else {
            LazyColumn {
                items(habits, key = { it.id }) { habit ->
                    Card(modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) {
                        Row(modifier = Modifier.padding(12.dp)) {
                            Checkbox(checked = habit.isDone, onCheckedChange = null)
                            Text(habit.title)
                        }
                    }
                }
            }
        }
    }
}
You can add multiple habits and see them as rows.

Add complete and delete actions

Update the row so each habit can change independently.

items(habits, key = { it.id }) { habit ->
    Card(modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) {
        Row(modifier = Modifier.padding(12.dp)) {
            Checkbox(
                checked = habit.isDone,
                onCheckedChange = {
                    habits = habits.map {
                        if (it.id == habit.id) it.copy(isDone = !it.isDone) else it
                    }
                }
            )
            Text(modifier = Modifier.weight(1f), text = habit.title)
            Button(onClick = {
                habits = habits.filterNot { it.id == habit.id }
            }) { Text("Delete") }
        }
    }
}
Checkpoint: add three habits, complete the second, delete the first, and confirm the remaining rows are correct.

Refactor into small composables

Split the screen into AddHabitForm, HabitList, and HabitRow. The behavior should not change.

Refactor only after the app works. Beginners should not split code before they understand the data flow.

Final review

  • Blank habits cannot be added.
  • Each row has a stable id.
  • Empty state appears when all rows are deleted.
  • State lives in the parent screen.
  • Child rows use callbacks instead of editing hidden global data.

Common failures

Unresolved reference: weightImport Modifier and use Text(modifier = Modifier.weight(1f), ... inside Row scope.
State resets unexpectedlyMake sure title and habits use remember and are not local plain variables.
Wrong item changesUse stable id matching, not list index matching.
Composable call errorOnly call composables from another composable or from setContent.

Checkpoint quiz

Use the interactive Phase 1 quiz after completing the build. If you guess, revisit the exact step that introduced that concept.

Open quiz lab