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.”
Create the package structure
Start from the Phase 2 catalogue or a new task app. Create packages before moving code.
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
)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
}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>
}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") }
)
}
}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)
}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 step | Create entity, DAO, database, mapper, repository implementation. |
|---|---|
| Network step | Create DTO, API interface, mapper, failure handling, retry path. |
| Offline-first step | Read cache, refresh remote, update cache, expose stable model. |
Common failures
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.