2

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

  • when user touches a circle in the top bar a date is selected and the tasks list must change to show tasks of that date
  • when user touches a checkbox or swipes to delete a task, an event is triggered to save on DB and the UI must reflect these changes

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?

1 Answer 1

3

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.

Sign up to request clarification or add additional context in comments.

3 Comments

Thanks for your answer and suggestion on collectAsState, I'll change it as you told me. I'm glad it works, but could you explain why? If the first flow to be "triggered" is the _selectedDate, why does it trigger when a task is modified too (e.g. when I touch a checkbox) ? Shouldn't it only be triggered when I touch days in the top bar? I really can't understand how the selectedDate flow is triggered when I edit a task
I updated my answer to explain how the different flows play together. I also fixed a bug in my code and made some small changes (please compare by looking at the edit).
Your edit added a great value, now everything is clearer. Thank you

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.