Phase 3 complete track

Turn the catalogue app into production-shaped Android code.

This phase moves from “the app works” to “the app survives real change.” The target is an API-backed task manager pattern with explicit UI state, repository boundaries, cache thinking, coroutine usage, and tests.

Compose screen
ViewModel
Repository
API + database
Step 1

Use this production file structure

app/src/main/java/com/example/tasks/
ui/TaskListScreen.kt
ui/TaskListViewModel.kt
data/TaskRepository.kt
data/TaskApi.kt
data/TaskDao.kt
data/TaskEntity.kt
model/Task.kt
app/src/test/java/com/example/tasks/
TaskListViewModelTest.kt

Do not create a domain layer by default. Add it only when business rules are reused or complicated enough to justify another boundary.

Step 2

Dependencies to add

dependencies {
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")

    // Add Room, Retrofit/Ktor, and Hilt when the beginner flow works.
    // Do not add libraries before you know what boundary they serve.
}
Step 3

Define UI state first

data class Task(
    val id: Int,
    val title: String,
    val isDone: Boolean
)

sealed interface TaskListUiState {
    data object Loading : TaskListUiState
    data class Success(val tasks: List<Task>) : TaskListUiState
    data object Empty : TaskListUiState
    data class Error(val message: String) : TaskListUiState
}

UI state is the contract between ViewModel and screen. If a state is possible in production, represent it explicitly.

Step 4

Create a repository boundary

interface TaskRepository {
    suspend fun refreshTasks(): Result<List<Task>>
    suspend fun toggleTask(id: Int): Result<Task>
}

class OfflineFirstTaskRepository(
    private val api: TaskApi,
    private val dao: TaskDao
) : TaskRepository {
    override suspend fun refreshTasks(): Result<List<Task>> {
        return runCatching {
            val remoteTasks = api.fetchTasks().map { it.toEntity() }
            dao.replaceAll(remoteTasks)
            dao.getAll().map { it.toModel() }
        }.recoverCatching {
            val cached = dao.getAll().map { it.toModel() }
            if (cached.isEmpty()) throw it else cached
        }
    }
}

The repository owns data decisions. The UI should not know whether data came from cache or network unless the product needs to display that distinction.

Step 5

Expose state from a ViewModel

class TaskListViewModel(
    private val repository: TaskRepository
) : ViewModel() {
    private val _uiState = MutableStateFlow<TaskListUiState>(TaskListUiState.Loading)
    val uiState: StateFlow<TaskListUiState> = _uiState.asStateFlow()

    fun load() {
        viewModelScope.launch {
            _uiState.value = TaskListUiState.Loading
            val result = repository.refreshTasks()
            _uiState.value = result.fold(
                onSuccess = { tasks ->
                    if (tasks.isEmpty()) TaskListUiState.Empty else TaskListUiState.Success(tasks)
                },
                onFailure = { error ->
                    TaskListUiState.Error(error.message ?: "Unable to load tasks")
                }
            )
        }
    }
}
Step 6

Test behavior, not implementation detail

class FakeTaskRepository : TaskRepository {
    var result: Result<List<Task>> = Result.success(emptyList())

    override suspend fun refreshTasks(): Result<List<Task>> = result
    override suspend fun toggleTask(id: Int): Result<Task> =
        Result.success(Task(id, "Fake", true))
}

@Test
fun loadShowsSuccessWhenRepositoryReturnsTasks() = runTest {
    val repository = FakeTaskRepository().apply {
        result = Result.success(listOf(Task(1, "Ship release", false)))
    }
    val viewModel = TaskListViewModel(repository)

    viewModel.load()

    assertTrue(viewModel.uiState.value is TaskListUiState.Success)
}
A test that only checks “method was called” is weak. Prefer tests that prove the user-visible state is correct.
Step 7

Common production mistakes

Repository returns DTOsMap network DTOs into app models before exposing them to UI.
ViewModel knows RetrofitThe ViewModel should depend on repository behavior, not transport details.
No error stateEvery production network path needs visible failure behavior.
Untested state transitionsTest loading, success, empty, error, and retry behavior.