I'm trying to achieve the following behavior on my TODO app:
see below pic for clarification:
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?
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:
selectedDate
(previously named _selectedDate
).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.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.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.