androidkotlinandroid-jetpack-compose

Showing a dialog with error checking using jetpack compose


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.


Solution

  • 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: