I'm relatively new to Android development. While designing the application, I encountered some problems. Everything I found did not quite cover my needs. I would like to listen to your pieces of advice. So, okeeey let's go!
The main screen displays a list of dishes for a specific category. I would like the icon on the items I added to the database to update. The same behavior should work when clicking on the add or remove element button.
Initially, the list is MealModel
. When you click on the addOrRemove button, mapping occurs to the database model into MealEntity
, where the isSaved
property changes to the opposite. Next, the list of MealEntity
is displayed in the second fragment of saved dishes. But on the main screen the list shows MealModel
with old values.
Models:
data class MealModel(
val id: String,
val name: String,
val image: String,
val isSaved: Boolean = false,
)
@Entity(tableName = "saved_meals")
data class MealEntity(
@PrimaryKey
val id: String,
val name: String,
val image: String,
val isSaved: Boolean
)
Resource via State:
sealed class Resource<T>(
val data: T? = null,
val message: String? = null,
) {
class Success<T>(data: T) : Resource<T>(data)
class Error<T>(message: String?, data: T? = null) : Resource<T>(data, message)
class Loading<T> : Resource<T>()
}
ViewModel:
@HiltViewModel
class HomeViewModel @Inject constructor(
private val getMealsUseCase: GetMealsUseCase,
private val isMealInSavedUseCase: IsMealInSavedUseCase,
private val addOrRemoveMealUseCase: AddOrRemoveMealUseCase,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) : ViewModel() {
private val _meals = MutableStateFlow<Resource<List<MealModel>>>(Resource.Loading())
val meals: StateFlow<Resource<List<MealModel>>> = _meals.asStateFlow()
fun getMeals() {
getMealsUseCase.invoke(category = category)
.onEach { resource ->
when (resource) {
is Resource.Loading -> {
_meals.value = Resource.Loading()
}
is Resource.Success -> {
if (resource.data != null) {
_meals.value = Resource.Success(resource.data)
}
}
is Resource.Error -> {
_meals.value = Resource.Error(resource.message)
}
}
}
.launchIn(viewModelScope)
}
fun savedIconClicked(meal: MealModel) {
viewModelScope.launch(dispatcher) {
addOrRemoveMealUseCase.addOrRemoveMeal(meal)
}
}
}
Fragment:
@AndroidEntryPoint
class HomeFragment : Fragment() {
private lateinit var binding: FragmentHomeBinding
private val viewModel: HomeViewModel by viewModels()
private val mealAdapter: MealAdapter by lazy { MealAdapter(::onMealClick, ::onSaveIconClicked) }
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentHomeBinding.inflate(inflater, container, false)
val view = binding.root
collectMeals()
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.rvMeals.adapter = mealAdapter
binding.rvMeals.itemAnimator = null // so that the element does not blink when updating
}
private fun collectMeals() = binding.apply {
viewModel.getMeals()
collectLatestLifecycleFlow(viewModel.meals) { resource ->
when (resource) {
is Resource.Loading -> {
*changind UI*
}
is Resource.Success -> {
*changind UI*
mealAdapter.submitList(resource.data)
}
is Resource.Error -> {
*changind UI*
}
}
}
}
private fun onMealClick(mealId: Int) {
val action = HomeFragmentDirections.actionHomeFragmentToDetailsFragment(mealId = mealId)
findNavController().navigate(action)
}
private fun onSaveIconClicked(meal: MealModel) {
viewModel.savedIconClicked(meal)
}
fun <T> Fragment.collectLatestLifecycleFlow(flow: Flow<T>, collect: suspend (T) -> Unit) {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
flow.collectLatest(collect)
}
}
}
Use Cases:
I get the category from the Api as TheMealDB.com when I click on a specific dish category, which in the code is simply a String
.
class GetMealsUseCase @Inject constructor(
private val mealRepository: MealRepository
) {
operator fun invoke(category: String): Flow<Resource<List<MealModel>>> = flow {
try {
emit(Resource.Loading())
val meals = mealRepository.getMeals(category = category)
emit(Resource.Success(data = meals))
} catch (e: IOException) {
emit(Resource.Error(message = e.message))
} catch (e: HttpException) {
emit(Resource.Error(message = e.message))
}
}
}
Clicking the save button checks if the item is in the database. Next, either the element is added or removed.
class AddOrRemoveMealUseCase @Inject constructor(
private val mealRepository: MealRepository,
private val mealMapper: MealMapper,
private val isMealInSavedUseCase: IsMealInSavedUseCase
) {
suspend fun addOrRemoveMeal(meal: MealModel) {
if (isMealInSavedUseCase.isMealInSaved(meal.id)) {
mealRepository.removeFromSavedMeals(mealMapper.mapMealModelToMealEntity(meal.copy(isSaved = false)))
} else {
mealRepository.addToSavedMeals(mealMapper.mapMealModelToMealEntity(meal.copy(isSaved = true)))
}
}
}
Checks by mealId
whether the MealEntity
is in the database.
class IsMealInSavedUseCase @Inject constructor(
private val mealRepository: MealRepository
) {
suspend fun isMealInSaved(mealId: String): Boolean = mealRepository.isMealInSaved(mealId)
}
Mapper:
class MealMapper {
fun mapMealModelToMealEntity(domainModel: MealModel) = MealEntity(
id = domainModel.id,
name = domainModel.name,
image = domainModel.image,
isSaved = domainModel.isSaved
)
...*other maps*
}
After searching for an answer, I realized that I could do something like this:
val index = _meals.value.data!!.indexOf(item)
val items = _meals.value.data!!.toMutableList()
items[index] = items[index].copy(isSaved = item.isSaved.not())
_meals.value.data = items
The truth is that the problem arises in Resource
. The data
is val
due to which I cannot assign a new list. If you make a var
everything works according to the logs.
And now inside the function getMeals()
, when the state is successful, it looks like this:
is Resource.Success -> {
if (resource.data != null) {
_meals.value = Resource.Success(resource.data!!)
}
for (item in resource.data!!) {
if (isMealInSavedUseCase.isMealInSaved(item.id)) {
val index = _meals.value.data!!.indexOf(item)
val items = _meals.value.data!!.toMutableList()
items[index] = items[index].copy(isSaved = item.isSaved.not())
_meals.value.data = items
}
}
}
But, firstly, changing data
in Resource
is probably not particularly advisable. Secondly, when requesting again, the list will be updated again with MealModel
elements with the isSaved = false
. That is, we will not notice any attempts to change the added element. And thirdly, I don’t really understand how dynamically the add icon should be updated. Working with list elements is in the adapter, but we cannot add LiveData
there, which will store a specific icon.
Finally, for understanding, I’ll show you what a list element looks like:
I see two relevant issues in your code.
The first one is that you collect a flow in the view model (with launchIn
). Don't do that, flows should only be transformed in the view model, not collected. Instead, replace getMeals
and the properties with something like this:
private val category: MutableStateFlow<String?> = MutableStateFlow(null)
val meals: StateFlow<Resource<List<MealModel>>> = category
.flatMapLatest { category ->
if (category != null) getMealsUseCase.invoke(category = category)
else flowOf()
}
.map(::processResource)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = Resource.Loading(),
)
fun selectCategory(category: String) {
this.category.value = category
}
private fun processResource(resource: Resource<List<MealModel>>): Resource<List<MealModel>> {
// ...
}
The important thing is that I made everything that the getMealsUseCase
flow dependes on to a flow itself. That's only the category, which, for my example, I assumed is a String. You can replace its type by whatever you want, though. The getMealsUseCase
flow can now be built upon it by using flatMapLatest
. That replaces the underlying flow by a new flow. The new flow can access the underlying flow's value here (the category). Then that new flow's content is transformed by calling map
. I just put the placeholder function processResource
here, we'll come back to that later. And finally, the resulting flow is converted to a StateFlow. From the outside, the meals
property now lookes exactly like before.
Instead of calling getMeals
from the UI you now call selectCategory
. This will update the view model's category
flow which in turn will update the meals
flow that the UI collects.
As you probably have already realized, most of the parts of the old getMeals
function are now missing. And with that we are coming to the second issue. What you previously did was trying to mutate the previous values instead of creating new ones. StateFlows work best if they only contain immutable types. Your reluctance to make the data
property a var
is dead on, you should change that back to val
. With the new code above there is no need for mutating the data
property, instead we just have to fill the processResource
function that I had left empty before. As you can see it receives a resource parameter and expects another resource as a return value. Please note that there are no flows involved: This function is called from a flow transformation, but it itself is independent of any flows.
Now, I'm not entirely sure what it is exactly that this transformation should do. My best guess would be something like this:
private fun processResource(resource: Resource<List<MealModel>>): Resource<List<MealModel>> =
when (resource) {
is Resource.Loading -> Resource.Loading()
is Resource.Success -> {
if (resource.data != null) {
val data = resource.data
.map { item ->
if (isMealInSavedUseCase.isMealInSaved(item.id))
item.copy(isSaved = item.isSaved.not())
else
item
}
Resource.Success(data)
} else
TODO("implement here whatever the result should be when resource.data == null")
}
is Resource.Error -> Resource.Error(resource.message)
}
In the Success case, the data list is map
ped to a new list that contains all items that have their isSaved
property inverted when isMealInSaved
is true. This might not be exactly what you want; as I already mentioned, I didn't really understand what your original code tried to to, especially when the data is null. You can take this as an example and go from here, though.
As a closing note, please don't use the !!
operator, it will crash your app. If you see the need for that, then there's most likely something else wrong in your code (as seen here).