I am building a todo list app. When a user clicks on the add icon, a dialog appears allowing them to add a task to the list. After clicking "confirm" on the dialog the click event should invoke a callback that delegates error handling to the viewmodel. When the viewmodel determines an error, it updates a MutableStateFlow that is collected in the view. Once the view detects an error, it should display an error inside the open dialog. I'm trying to find a way to close the dialog after "confirm" is pressed without preventing the dialog from being opened in the first place (by pressing add). So far when verification passes and the dialog is currently open I want to close the dialog so I set
if (!verificationFailed && isDialogOpen) isDialogOpen = false
But since verificationFailed starts as false, when the add icon is pressed that conditional is true which prevents the dialog from appearing in the first place.
MainActivity
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import com.example.todopractice.ui.theme.ToDoPracticeTheme
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
class MainActivity : ComponentActivity() {
private val vm by viewModels<MainViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ToDoPracticeTheme {
var isDialogOpen by rememberSaveable { mutableStateOf(false) }
val tasks by vm.tasks.collectAsState()
val verificationFailed by vm.inputError.collectAsState()
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
AppBar(
{
isDialogOpen = true
vm.clearError()
Log.d("TRACE", "+ icon was clicked, isDialogOpen=$isDialogOpen")
}
)
}) { innerPadding ->
//}
LazyColumn {
Log.d("TRACE", "$tasks")
items(tasks) {
TaskRow(it)
}
}
if (!verificationFailed && isDialogOpen) isDialogOpen = false
if (isDialogOpen) InsertTaskDialog(
onNegativeClick = {
isDialogOpen = false
vm.clearError() // Uncomment if you add this function
},
onPositiveClick = {
vm.onSubmitTask(it)
},
verificationFailed
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppBar(onOpenDialogClick: ()->Unit) {
TopAppBar(
title = { Text("ToDo") },
actions = {
IconButton(onClick = { onOpenDialogClick() }) {
Icon(imageVector = Icons.Default.Add, contentDescription = "Open Dialog")
}
}
)
}
@Composable
fun TaskRow(task: Task) {
Row {
Text(text = task.name)
}
}
@Composable
fun InsertTaskDialog(
onNegativeClick: ()->Unit,
onPositiveClick: (Task)->Unit,
verificationFailed: Boolean) {
var nameInput by rememberSaveable { mutableStateOf("") }
AlertDialog(
onDismissRequest = onNegativeClick,
confirmButton = {
Button(onClick = {
onPositiveClick(Task(nameInput,false))
}) {
Text(text = "Confirm")
} },
modifier = Modifier,
dismissButton = {
Button(onClick = { onNegativeClick() }) {
Text(text = "Cancel")
} },
title = { Text(text = "New Task") },
text = {
Column {
OutlinedTextField(
value = nameInput,
onValueChange = { nameInput = it },
label = { Text(text = "Name") }
)
if (verificationFailed) {
Spacer(modifier = Modifier.height(8.dp))
Text(text = "Name is required", color = Color.Red)
}
}
}
)
}
}
MainViewModel
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
data class Task(val name: String, val isComplete: Boolean)
class MainViewModel: ViewModel() {
private val _tasks = MutableStateFlow<List<Task>>(listOf())
val tasks = _tasks.asStateFlow()
private val _inputError = MutableStateFlow<Boolean>(false)
val inputError = _inputError.asStateFlow()
fun onSubmitTask(task: Task) {
if (task.name.isEmpty()) {
_inputError.update { true }
} else {
_inputError.update { false }
_tasks.update {
it + task
}
}
}
fun clearError() {
_inputError.update { false }
}
}
I know I could avoid this by checking for an error inside my composable but I'd like tp place that logic in my viewmodel to make it testable.
I tried using a combination of booleans for error detection and dialog visibility.
I would delegate the isDialogOpen
variable into the MainViewModel
as well:
var _isDialogOpen = MutableStateFlow(false)
Then, add the following functions to your ViewModel:
class MainViewModel: ViewModel() {
private val _tasks = MutableStateFlow<List<Task>>(listOf())
val tasks = _tasks.asStateFlow()
private val _inputError = MutableStateFlow(false)
val inputError = _inputError.asStateFlow()
private var _isDialogOpen = MutableStateFlow(false)
val isDialogOpen = _isDialogOpen.asStateFlow()
fun onSubmitTask(task: Task) {
if (task.name.isEmpty()) {
_inputError.update { true }
} else {
_inputError.update { false }
_tasks.update {
it + task
}
closeDialog()
}
}
fun showDialog() {
_inputError.update { false }
_isDialogOpen.update { true }
}
fun closeDialog() {
_isDialogOpen.update { false }
}
}
Then, in your Composable, call the functions as follows:
Surface {
val isDialogOpen by vm.isDialogOpen.collectAsStateWithLifecycle()
val tasks by vm.tasks.collectAsStateWithLifecycle()
val verificationFailed by vm.inputError.collectAsStateWithLifecycle()
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
AppBar(
{
vm.showDialog()
Log.d("TRACE", "+ icon was clicked, isDialogOpen=$isDialogOpen")
}
)
}) { innerPadding ->
//}
LazyColumn(
modifier = Modifier.padding(innerPadding),
) {
items(tasks) {
TaskRow(it)
}
}
if (isDialogOpen) {
InsertTaskDialog(
onNegativeClick = {
vm.closeDialog()
},
onPositiveClick = {
vm.onSubmitTask(it)
},
verificationFailed
)
}
}
}
Notes:
collectAsStateWithLifecycle()
instead of only collectAsState()
innerPadding
param from the Scaffold
to its first child as shown in my code sample.if (!verificationFailed && isDialogOpen) isDialogOpen = false
, you should use a LaunchedEffect
for that.