In the previous tutorial, we built the data layer — Room entities, DAO, repository, and use cases. Now we connect it to the UI.
This tutorial builds the three main screens of our task manager app: the task list, the add task form, and the settings screen.
What We Build
UI Layer:
├── TaskListScreen + TaskListViewModel (MVI)
│ ├── Search bar
│ ├── Filter chips (All, Active, Completed)
│ ├── Task cards with priority badge + category
│ └── Swipe to delete
├── AddTaskScreen + AddTaskViewModel
│ ├── Title + description fields
│ ├── Category chips
│ ├── Priority chips
│ └── Save button with validation
└── SettingsScreen + SettingsViewModel
├── Dark mode toggle
└── Delete completed tasks
Task List Screen — State and Intents
State
// ui/screens/tasklist/TaskListState.kt
// Everything the task list screen needs to display
data class TaskListState(
val tasks: List<Task> = emptyList(),
val filter: TaskFilter = TaskFilter.ALL,
val searchQuery: String = "",
val isLoading: Boolean = true,
val error: String? = null,
val taskCount: Int = 0,
val completedCount: Int = 0
)
Intents
// ui/screens/tasklist/TaskListIntent.kt
// Every action the user can take on the task list
sealed interface TaskListIntent {
data class Search(val query: String) : TaskListIntent
data class ChangeFilter(val filter: TaskFilter) : TaskListIntent
data class ToggleTask(val taskId: Long) : TaskListIntent
data class DeleteTask(val taskId: Long) : TaskListIntent
data object DeleteCompleted : TaskListIntent
data object Retry : TaskListIntent
}
ViewModel
// ui/screens/tasklist/TaskListViewModel.kt
@HiltViewModel
class TaskListViewModel @Inject constructor(
private val getTasks: GetTasksUseCase,
private val toggleTask: ToggleTaskUseCase,
private val deleteTask: DeleteTaskUseCase,
private val repository: TaskRepository
) : ViewModel() {
private val _state = MutableStateFlow(TaskListState())
val state: StateFlow<TaskListState> = _state.asStateFlow()
private var searchJob: Job? = null
init {
observeTasks()
observeCounts()
}
fun onIntent(intent: TaskListIntent) {
when (intent) {
is TaskListIntent.Search -> {
_state.update { it.copy(searchQuery = intent.query) }
observeTasks()
}
is TaskListIntent.ChangeFilter -> {
_state.update { it.copy(filter = intent.filter) }
observeTasks()
}
is TaskListIntent.ToggleTask -> {
viewModelScope.launch { toggleTask(intent.taskId) }
}
is TaskListIntent.DeleteTask -> {
viewModelScope.launch { deleteTask(intent.taskId) }
}
is TaskListIntent.DeleteCompleted -> {
viewModelScope.launch { repository.deleteCompletedTasks() }
}
is TaskListIntent.Retry -> {
_state.update { it.copy(error = null) }
observeTasks()
}
}
}
private fun observeTasks() {
searchJob?.cancel()
searchJob = viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
try {
getTasks(
filter = _state.value.filter,
searchQuery = _state.value.searchQuery
).collect { tasks ->
_state.update { it.copy(tasks = tasks, isLoading = false, error = null) }
}
} catch (e: Exception) {
_state.update { it.copy(error = e.message, isLoading = false) }
}
}
}
private fun observeCounts() {
viewModelScope.launch {
repository.getTaskCount().collect { count ->
_state.update { it.copy(taskCount = count) }
}
}
viewModelScope.launch {
repository.getCompletedCount().collect { count ->
_state.update { it.copy(completedCount = count) }
}
}
}
}
The ViewModel pattern:
- State — one data class with everything
- onIntent() — one function that handles all user actions
- Private methods — do the actual work
Screen
// ui/screens/tasklist/TaskListScreen.kt
@Composable
fun TaskListScreen(
onAddTask: () -> Unit,
viewModel: TaskListViewModel = hiltViewModel()
) {
val state by viewModel.state.collectAsStateWithLifecycle()
Column(modifier = Modifier.fillMaxSize()) {
// Header with search
TaskListHeader(
searchQuery = state.searchQuery,
onSearchChange = { viewModel.onIntent(TaskListIntent.Search(it)) }
)
// Filter chips
FilterChips(
currentFilter = state.filter,
onFilterChange = { viewModel.onIntent(TaskListIntent.ChangeFilter(it)) }
)
// Content
when {
state.isLoading -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
state.error != null -> {
ErrorContent(
message = state.error!!,
onRetry = { viewModel.onIntent(TaskListIntent.Retry) }
)
}
state.tasks.isEmpty() -> {
EmptyContent(
filter = state.filter,
onAddTask = onAddTask
)
}
else -> {
TaskList(
tasks = state.tasks,
onToggle = { viewModel.onIntent(TaskListIntent.ToggleTask(it)) },
onDelete = { viewModel.onIntent(TaskListIntent.DeleteTask(it)) }
)
}
}
// Footer
if (state.tasks.isNotEmpty()) {
Text(
"${state.taskCount} tasks · ${state.completedCount} completed",
modifier = Modifier.padding(16.dp),
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
Reusable Components
// ui/components/TaskCard.kt
@Composable
fun TaskCard(
task: Task,
onToggle: () -> Unit,
onDelete: () -> Unit
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = if (task.isCompleted)
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
else MaterialTheme.colorScheme.surfaceVariant
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.Top
) {
// Checkbox
Checkbox(
checked = task.isCompleted,
onCheckedChange = { onToggle() }
)
Spacer(modifier = Modifier.width(12.dp))
// Content
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
// Priority badge
PriorityBadge(priority = task.priority)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = task.title,
fontWeight = FontWeight.Medium,
textDecoration = if (task.isCompleted)
TextDecoration.LineThrough else TextDecoration.None
)
}
if (task.description.isNotBlank()) {
Text(
text = task.description,
fontSize = 14.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(4.dp))
// Category chip
CategoryChip(category = task.category)
}
// Delete button
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, "Delete",
tint = MaterialTheme.colorScheme.error)
}
}
}
}
// ui/components/PriorityBadge.kt
@Composable
fun PriorityBadge(priority: Priority) {
val color = when (priority) {
Priority.HIGH -> Color(0xFFE53935)
Priority.MEDIUM -> Color(0xFFFB8C00)
Priority.LOW -> Color(0xFF43A047)
}
Box(
modifier = Modifier
.size(12.dp)
.background(color, CircleShape)
)
}
// ui/components/CategoryChip.kt
@Composable
fun CategoryChip(category: Category) {
Text(
text = category.displayName,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.background(
MaterialTheme.colorScheme.surfaceVariant,
RoundedCornerShape(4.dp)
)
.padding(horizontal = 8.dp, vertical = 2.dp)
)
}
// ui/components/FilterChips.kt
@Composable
fun FilterChips(currentFilter: TaskFilter, onFilterChange: (TaskFilter) -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
TaskFilter.entries.forEach { filter ->
FilterChip(
selected = filter == currentFilter,
onClick = { onFilterChange(filter) },
label = { Text(filter.name.lowercase().replaceFirstChar { it.uppercase() }) }
)
}
}
}
// ui/components/EmptyContent.kt
@Composable
fun EmptyContent(filter: TaskFilter, onAddTask: () -> Unit) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = when (filter) {
TaskFilter.ALL -> "No tasks yet"
TaskFilter.ACTIVE -> "No active tasks"
TaskFilter.COMPLETED -> "No completed tasks"
},
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (filter == TaskFilter.ALL) {
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = onAddTask) {
Text("Add your first task")
}
}
}
}
}
Add Task Screen
ViewModel
// ui/screens/addtask/AddTaskViewModel.kt
data class AddTaskState(
val title: String = "",
val description: String = "",
val category: Category = Category.PERSONAL,
val priority: Priority = Priority.MEDIUM,
val titleError: String? = null,
val isSaving: Boolean = false,
val isSaved: Boolean = false
)
sealed interface AddTaskIntent {
data class TitleChanged(val title: String) : AddTaskIntent
data class DescriptionChanged(val description: String) : AddTaskIntent
data class CategoryChanged(val category: Category) : AddTaskIntent
data class PriorityChanged(val priority: Priority) : AddTaskIntent
data object Save : AddTaskIntent
}
@HiltViewModel
class AddTaskViewModel @Inject constructor(
private val addTask: AddTaskUseCase
) : ViewModel() {
private val _state = MutableStateFlow(AddTaskState())
val state: StateFlow<AddTaskState> = _state.asStateFlow()
fun onIntent(intent: AddTaskIntent) {
when (intent) {
is AddTaskIntent.TitleChanged -> {
_state.update { it.copy(title = intent.title, titleError = null) }
}
is AddTaskIntent.DescriptionChanged -> {
_state.update { it.copy(description = intent.description) }
}
is AddTaskIntent.CategoryChanged -> {
_state.update { it.copy(category = intent.category) }
}
is AddTaskIntent.PriorityChanged -> {
_state.update { it.copy(priority = intent.priority) }
}
is AddTaskIntent.Save -> saveTask()
}
}
private fun saveTask() {
viewModelScope.launch {
_state.update { it.copy(isSaving = true) }
val result = addTask(
title = _state.value.title,
description = _state.value.description,
category = _state.value.category,
priority = _state.value.priority
)
result.fold(
onSuccess = {
_state.update { it.copy(isSaving = false, isSaved = true) }
},
onFailure = { error ->
_state.update {
it.copy(
isSaving = false,
titleError = error.message
)
}
}
)
}
}
}
Screen
// ui/screens/addtask/AddTaskScreen.kt
@Composable
fun AddTaskScreen(
onBack: () -> Unit,
viewModel: AddTaskViewModel = hiltViewModel()
) {
val state by viewModel.state.collectAsStateWithLifecycle()
// Navigate back when saved
LaunchedEffect(state.isSaved) {
if (state.isSaved) onBack()
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp)
) {
// Back button
TextButton(onClick = onBack) {
Text("← Cancel")
}
Text("New Task", style = MaterialTheme.typography.headlineMedium)
Spacer(modifier = Modifier.height(24.dp))
// Title
OutlinedTextField(
value = state.title,
onValueChange = { viewModel.onIntent(AddTaskIntent.TitleChanged(it)) },
label = { Text("Title") },
isError = state.titleError != null,
supportingText = state.titleError?.let { { Text(it) } },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
// Description
OutlinedTextField(
value = state.description,
onValueChange = { viewModel.onIntent(AddTaskIntent.DescriptionChanged(it)) },
label = { Text("Description (optional)") },
modifier = Modifier.fillMaxWidth(),
minLines = 3
)
Spacer(modifier = Modifier.height(24.dp))
// Category
Text("Category", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Category.entries.forEach { category ->
FilterChip(
selected = category == state.category,
onClick = { viewModel.onIntent(AddTaskIntent.CategoryChanged(category)) },
label = { Text(category.displayName) }
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// Priority
Text("Priority", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Priority.entries.forEach { priority ->
FilterChip(
selected = priority == state.priority,
onClick = { viewModel.onIntent(AddTaskIntent.PriorityChanged(priority)) },
label = {
Row(verticalAlignment = Alignment.CenterVertically) {
PriorityBadge(priority)
Spacer(modifier = Modifier.width(4.dp))
Text(priority.displayName)
}
}
)
}
}
Spacer(modifier = Modifier.weight(1f))
// Save button
Button(
onClick = { viewModel.onIntent(AddTaskIntent.Save) },
enabled = state.title.isNotBlank() && !state.isSaving,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
) {
if (state.isSaving) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text("Save Task", modifier = Modifier.padding(vertical = 4.dp))
}
}
}
}
How the Screens Connect
// ui/navigation/AppNavigation.kt
@Serializable object TaskListRoute
@Serializable object AddTaskRoute
@Serializable object SettingsRoute
@Composable
fun AppNavigation() {
val navController = rememberNavController()
Scaffold(
bottomBar = {
NavigationBar {
NavigationBarItem(
selected = true, // Simplified for tutorial
onClick = { navController.navigate(TaskListRoute) },
icon = { Icon(Icons.Default.Home, "Tasks") },
label = { Text("Tasks") }
)
NavigationBarItem(
selected = false,
onClick = { navController.navigate(AddTaskRoute) },
icon = { Icon(Icons.Default.Add, "Add") },
label = { Text("Add") }
)
NavigationBarItem(
selected = false,
onClick = { navController.navigate(SettingsRoute) },
icon = { Icon(Icons.Default.Settings, "Settings") },
label = { Text("Settings") }
)
}
}
) { padding ->
NavHost(
navController = navController,
startDestination = TaskListRoute,
modifier = Modifier.padding(padding)
) {
composable<TaskListRoute> {
TaskListScreen(
onAddTask = { navController.navigate(AddTaskRoute) }
)
}
composable<AddTaskRoute> {
AddTaskScreen(
onBack = { navController.popBackStack() }
)
}
composable<SettingsRoute> {
SettingsScreen()
}
}
}
}
Quick Summary
| Component | Pattern | What It Does |
|---|---|---|
TaskListState | MVI State | All data for the task list screen |
TaskListIntent | MVI Intent | All user actions |
TaskListViewModel | MVI ViewModel | Processes intents, updates state |
TaskListScreen | Composable | Observes state, sends intents |
TaskCard | Reusable component | Displays one task |
AddTaskViewModel | MVI ViewModel | Form validation + save |
AddTaskScreen | Composable | Form with category/priority chips |
Source Code
Related Tutorials
- Tutorial #10: MVI — the pattern we use for every screen
- Tutorial #22: Data Layer — the data layer these screens connect to
- Tutorial #8: Navigation — bottom navigation setup
- Jetpack Compose Cheat Sheet — quick reference for every component and modifier.
- Full Series: Jetpack Compose Tutorial — all 25 tutorials from zero to publishing.
What’s Next?
In the next tutorial, we will add polish — navigation transitions, swipe-to-delete animations, empty states, and dark mode. Making it feel like a real app.
See you there.