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:

  1. State — one data class with everything
  2. onIntent() — one function that handles all user actions
  3. 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

ComponentPatternWhat It Does
TaskListStateMVI StateAll data for the task list screen
TaskListIntentMVI IntentAll user actions
TaskListViewModelMVI ViewModelProcesses intents, updates state
TaskListScreenComposableObserves state, sends intents
TaskCardReusable componentDisplays one task
AddTaskViewModelMVI ViewModelForm validation + save
AddTaskScreenComposableForm with category/priority chips

Source Code

View source code on GitHub →

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.