Phase 3 step-by-step

Convert the app into production-shaped architecture.

Outcome: the learner can split UI, ViewModel, repository, model, and tests without losing behavior. This is where “it works” becomes “it is maintainable.”

Screen
ViewModel
Repository
Sources

Create the package structure

Start from the Phase 2 catalogue or a new task app. Create packages before moving code.

ui/TaskListScreen.kt
ui/TaskListViewModel.kt
data/TaskRepository.kt
data/FakeTaskRepository.kt
model/Task.kt
test/TaskListViewModelTest.kt
The app should still compile after creating empty files. Move code one file at a time.

Define the model

The model is what the app trusts internally. Do not let DTOs leak into UI code.

data class Task(
    val id: Int,
    val title: String,
    val isDone: Boolean,
    val updatedAtMillis: Long
)
Checkpoint: UI code imports model.Task, not data-layer DTO classes.

Define every UI state

If users can experience it, represent it. Do not rely on null or empty strings to imply state.

sealed interface TaskListUiState {
    data object Loading : TaskListUiState
    data class Content(val tasks: List<Task>) : TaskListUiState
    data object Empty : TaskListUiState
    data class Error(val message: String) : TaskListUiState
}
The screen can switch on one state object and render predictable UI.

Create the repository contract

The repository hides where data comes from. Start with a fake implementation before adding Room or network.

interface TaskRepository {
    suspend fun loadTasks(): Result<List<Task>>
    suspend fun toggleTask(id: Int): Result<Task>
}
This is the first production boundary. The UI does not know cache, API, database, or retry details.

Add a fake repository

Fake data lets you build and test behavior without waiting for backend or database setup.

class FakeTaskRepository : TaskRepository {
    private var tasks = listOf(Task(1, "Write release checklist", false, 0))

    override suspend fun loadTasks(): Result<List<Task>> = Result.success(tasks)

    override suspend fun toggleTask(id: Int): Result<Task> {
        tasks = tasks.map { if (it.id == id) it.copy(isDone = !it.isDone) else it }
        return Result.success(tasks.first { it.id == id })
    }
}

Add ViewModel state flow

The ViewModel converts repository results into UI state.

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

    fun load() = viewModelScope.launch {
        _uiState.value = TaskListUiState.Loading
        _uiState.value = repository.loadTasks().fold(
            onSuccess = { if (it.isEmpty()) TaskListUiState.Empty else TaskListUiState.Content(it) },
            onFailure = { TaskListUiState.Error(it.message ?: "Unable to load tasks") }
        )
    }
}
Checkpoint: manually force fake success, empty, and failure results and verify the screen changes.

Write the first behavior test

Test the user-visible state transition, not whether a private method ran.

@Test
fun loadWithTasksShowsContent() = runTest {
    val repository = FakeTaskRepository()
    val viewModel = TaskListViewModel(repository)

    viewModel.load()

    assertTrue(viewModel.uiState.value is TaskListUiState.Content)
}
The test proves loading data changes the UI state to content.

Add real sources one at a time

Add Room first if offline matters. Add network next. Never add both in the same learning step.

Room stepCreate entity, DAO, database, mapper, repository implementation.
Network stepCreate DTO, API interface, mapper, failure handling, retry path.
Offline-first stepRead cache, refresh remote, update cache, expose stable model.

Common failures

ViewModel imports RetrofitMove transport details into repository/data source.
Tests are flakyUse coroutine test dispatcher and avoid real network/database in unit tests.
UI shows old dataVerify repository emits or returns updated models after mutations.
Too many layersRemove domain/use-case layer until there is real reusable business logic.

Completion gate

  • UI renders Loading, Content, Empty, and Error.
  • ViewModel depends on repository contract.
  • Repository owns data-source decisions.
  • At least three ViewModel tests pass.
  • You can explain where Room and API code will be added.
Continue to Phase 4