androidandroid-jetpack-composeandroid-roomkotlin-stateflow

Android Jetpack Compose Room query depending on Flow


I'm trying to achieve the following behavior on my TODO app:

see below pic for clarification:

task list

I've tried using a Flow which maps latest selected date to the database query of tasks for that date, inside the TaskViewModel:

package com.pochopsp.dailytasks.domain

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.pochopsp.dailytasks.data.database.entity.Task
import com.pochopsp.dailytasks.data.database.dao.TaskDao
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.util.Date

class TaskViewModel(
    private val taskDao: TaskDao
): ViewModel() {

    private val _selectedDate = MutableStateFlow(Date())
    private var selectedDate: StateFlow<Date> = _selectedDate.asStateFlow()

    private val _readTasksState = MutableStateFlow(ReadTasksState())

    @OptIn(ExperimentalCoroutinesApi::class)
    private val _tasks = selectedDate.flatMapLatest {
        latestSelectedDate -> taskDao.getTasksForDate(latestSelectedDate)
    }

    val readTasksState = combine(_readTasksState, _tasks){ readtaskstate, tasks ->

        readtaskstate.copy(
            tasksForSelectedDate = tasksToDtos(tasks)
        )
    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), ReadTasksState())

    private fun tasksToDtos (tasks: List<Task>): List<TaskCardDto> {
        return tasks.map { t -> TaskCardDto(t.id, t.title, t.icon, t.done) }.toList()
    }

    fun onEvent(event: TaskEvent){
        when(event){
            is TaskEvent.DeleteTask -> {
                viewModelScope.launch {
                    taskDao.deleteById(event.id)
                }
            }
            is TaskEvent.SetDone -> {
                viewModelScope.launch {
                    taskDao.updateDoneById(event.done, event.id)
                }
            }
            is TaskEvent.SetSelectedDate -> {
                _selectedDate.value = event.selectedDate
            }
        }
    }
}

The TaskEvent.DeleteTask, TaskEvent.SetDone and TaskEvent.SetSelectedDate which you see in the TaskViewModel are all triggered by user input on the UI.

This is my MainActivity:

package com.pochopsp.dailytasks

import android.annotation.SuppressLint
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.room.Room
import com.pochopsp.dailytasks.data.database.Database
import com.pochopsp.dailytasks.domain.TaskCardDto
import com.pochopsp.dailytasks.domain.ReadTasksState
import com.pochopsp.dailytasks.domain.TaskViewModel
import com.pochopsp.dailytasks.presentation.navigation.Destinations
import com.pochopsp.dailytasks.presentation.screen.MainScreen
import com.pochopsp.dailytasks.presentation.theme.DailyTasksTheme
import kotlinx.coroutines.flow.MutableStateFlow

class MainActivity : ComponentActivity() {

    private val db by lazy {
        Room.databaseBuilder(
            applicationContext,
            Database::class.java,
            "tasks.db"
        ).fallbackToDestructiveMigration().build()
    }

    private val viewModel by viewModels<TaskViewModel>(
        factoryProducer = {
            // needed because our viewmodel has a parameter (in this case the dao interface)
            object : ViewModelProvider.Factory {
                @Suppress("UNCHECKED_CAST")
                override fun <T : ViewModel> create(modelClass: Class<T>): T {
                    return TaskViewModel(db.taskDao, db.dayDao) as T
                }
            }
        }
    )


    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            DailyTasksTheme(darkTheme = false) {
                val readTasksState by viewModel.readTasksState.collectAsState()

                val navController = rememberNavController()

                NavHost(
                    navController = navController,
                    startDestination = "main")
                {
                    composable(Destinations.Main.route) { MainScreen(state = readTasksState, onEvent = viewModel::onEvent){ navController.navigate(it.route) } }
                    // Add more destinations similarly.
                }
            }
        }
    }
}

ReadTasksState.kt:

package com.pochopsp.dailytasks.domain

import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import java.util.Date

data class ReadTasksState(
    val tasksForSelectedDate: List<TaskCardDto> = emptyList()
)

It's like the query taskDao.getTasksForDate(latestSelectedDate), which returns a Flow<List<Task>>, depends itself on a Flow, because the date which it receives in input is stored using a StateFlow<Date>.

It kind of works, but I don't think this is the best way to do this (or even a correct way to do this). Can you give me some piece of advice or suggest me a better approach?


Solution

  • It's not only OK to base the database flow on another flow, it's actually the preferred way to do.

    You have a lot of superfluous properties in your view model, but if you remove _selectedDate, selectedDate, _readTasksState and _tasks, it can be as simple as this:

    private val selectedDate = MutableStateFlow(Date())
    
    val readTasksState: StateFlow<ReadTasksState> = selectedDate
        .flatMapLatest { latestSelectedDate ->
            taskDao.getTasksForDate(latestSelectedDate)
        }
        .mapLatest { tasks ->
            ReadTasksState(
                tasksForSelectedDate = tasksToDtos(tasks),
            )
        }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), ReadTasksState())
    

    readTasksState is now a flow that is constructed as follows:

    1. It starts off with the flow selectedDate (previously named _selectedDate).
    2. Then, that flow is switched out to what taskDao.getTasksForDate returns. That is another flow that emits a new value whenever a task changes in the database for a given date. That's how flatMapLatest works: It switches from one flow to another depending on the content of the first flow.
    3. Now, the content of the database flow is transformed from a List<Task> to a ReadTasksState with mapLatest. mapLatest only changes the content, which is in contrast to flatMapLatest that switches the entire flow to a new flow.
    4. Finally, the resulting flow is made to a StateFlow.

    Now, whenever one of the date buttons is activated in the UI, selectedDate is updated (1.) which triggers flatMapLatest. That takes the changed date as input and returns another flow, the result of the DAO (2.). Then that flow is transformed (3.) and made to a StateFlow (4.).

    On the other hand, when just a task checkbox is toggled in the UI, the first flow (1.) isn't touched, the date stays the same. Also, flatMapLatest (2.) isn't re-executed, it won't switch flows again, it stays on the same flow that taskDao.getTasksForDate previously returned for the date (which is ok since that date hasn't changed). What does change, however, is that the toggled checkbox changes something in the database. And that triggers the flow we are now on to emit a new value. That triggers (3.) where a new list of tasks is received and transformed to a new ReadTasksState object. Finally (4.) follows.

    Basically, you just want readTasksState to be a transformed (mapLatest) taskDao.getTasksForDate. Since that needs a date parameter, you have to use flatMapLatest on a flow with that date.

    Btw., collecting flows in Compose should be done with collectAsStateWithLifecycle from the gradle dependency androidx.lifecycle:lifecycle-runtime-compose so Compose can automatically unsubscribe from the flow when the activity is paused etc.