Update favorite status on icon click

This commit is contained in:
Kirill Kamakin
2022-12-13 20:14:16 +01:00
parent 3eb99206e8
commit 2fa43f57b7
16 changed files with 170 additions and 21 deletions

View File

@@ -68,4 +68,26 @@ class MealieDataSourceWrapper @Inject constructor(
ServerVersion.V0 -> v0Source.requestUserInfo().favoriteRecipes ServerVersion.V0 -> v0Source.requestUserInfo().favoriteRecipes
ServerVersion.V1 -> v1Source.requestUserInfo().favoriteRecipes ServerVersion.V1 -> v1Source.requestUserInfo().favoriteRecipes
} }
override suspend fun removeFavoriteRecipe(recipeSlug: String) = when (getVersion()) {
ServerVersion.V0 -> {
val userId = v0Source.requestUserInfo().id
v0Source.removeFavoriteRecipe(userId, recipeSlug)
}
ServerVersion.V1 -> {
val userId = v1Source.requestUserInfo().id
v1Source.removeFavoriteRecipe(userId, recipeSlug)
}
}
override suspend fun addFavoriteRecipe(recipeSlug: String) = when (getVersion()) {
ServerVersion.V0 -> {
val userId = v0Source.requestUserInfo().id
v0Source.addFavoriteRecipe(userId, recipeSlug)
}
ServerVersion.V1 -> {
val userId = v1Source.requestUserInfo().id
v1Source.addFavoriteRecipe(userId, recipeSlug)
}
}
} }

View File

@@ -17,4 +17,8 @@ interface RecipeRepo {
fun updateNameQuery(name: String?) fun updateNameQuery(name: String?)
suspend fun refreshRecipes() suspend fun refreshRecipes()
suspend fun removeFavoriteRecipe(recipeSlug: String)
suspend fun addFavoriteRecipe(recipeSlug: String)
} }

View File

@@ -72,6 +72,24 @@ class RecipeRepoImpl @Inject constructor(
} }
} }
override suspend fun removeFavoriteRecipe(recipeSlug: String) {
logger.v { "removeFavoriteRecipe() called with: recipeSlug = $recipeSlug" }
runCatchingExceptCancel {
dataSource.removeFavoriteRecipe(recipeSlug)
}.onFailure {
logger.e(it) { "Can't remove a favorite recipe" }
}
}
override suspend fun addFavoriteRecipe(recipeSlug: String) {
logger.v { "addFavoriteRecipe() called with: recipeSlug = $recipeSlug" }
runCatchingExceptCancel {
dataSource.addFavoriteRecipe(recipeSlug)
}.onFailure {
logger.e(it) { "Can't add a favorite recipe" }
}
}
companion object { companion object {
private const val LOAD_PAGE_SIZE = 50 private const val LOAD_PAGE_SIZE = 50
private const val INITIAL_LOAD_PAGE_SIZE = LOAD_PAGE_SIZE * 3 private const val INITIAL_LOAD_PAGE_SIZE = LOAD_PAGE_SIZE * 3

View File

@@ -6,4 +6,8 @@ interface RecipeDataSource {
suspend fun requestRecipeInfo(slug: String): FullRecipeInfo suspend fun requestRecipeInfo(slug: String): FullRecipeInfo
suspend fun getFavoriteRecipes(): List<String> suspend fun getFavoriteRecipes(): List<String>
suspend fun removeFavoriteRecipe(recipeSlug: String)
suspend fun addFavoriteRecipe(recipeSlug: String)
} }

View File

@@ -3,6 +3,7 @@ package gq.kirmanak.mealient.ui.recipes
import android.view.View import android.view.View
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.scopes.FragmentScoped
import gq.kirmanak.mealient.R import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.databinding.ViewHolderRecipeBinding import gq.kirmanak.mealient.databinding.ViewHolderRecipeBinding
@@ -10,28 +11,41 @@ import gq.kirmanak.mealient.extensions.resources
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.recipes.images.RecipeImageLoader import gq.kirmanak.mealient.ui.recipes.images.RecipeImageLoader
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
class RecipeViewHolder private constructor( class RecipeViewHolder private constructor(
private val logger: Logger, private val logger: Logger,
private val binding: ViewHolderRecipeBinding, private val binding: ViewHolderRecipeBinding,
private val recipeImageLoader: RecipeImageLoader, private val recipeImageLoader: RecipeImageLoader,
private val clickListener: (RecipeSummaryEntity) -> Unit, private val clickListener: (ClickEvent) -> Unit,
) : RecyclerView.ViewHolder(binding.root) { ) : RecyclerView.ViewHolder(binding.root) {
@Singleton @FragmentScoped
class Factory @Inject constructor( class Factory @Inject constructor(
private val recipeImageLoader: RecipeImageLoader,
private val logger: Logger, private val logger: Logger,
) { ) {
fun build( fun build(
recipeImageLoader: RecipeImageLoader,
binding: ViewHolderRecipeBinding, binding: ViewHolderRecipeBinding,
clickListener: (RecipeSummaryEntity) -> Unit, clickListener: (ClickEvent) -> Unit,
) = RecipeViewHolder(logger, binding, recipeImageLoader, clickListener) ) = RecipeViewHolder(logger, binding, recipeImageLoader, clickListener)
} }
sealed class ClickEvent {
abstract val recipeSummaryEntity: RecipeSummaryEntity
data class FavoriteClick(
override val recipeSummaryEntity: RecipeSummaryEntity
) : ClickEvent()
data class RecipeClick(
override val recipeSummaryEntity: RecipeSummaryEntity
) : ClickEvent()
}
private val loadingPlaceholder by lazy { private val loadingPlaceholder by lazy {
binding.resources.getString(R.string.view_holder_recipe_text_placeholder) binding.resources.getString(R.string.view_holder_recipe_text_placeholder)
} }
@@ -43,7 +57,10 @@ class RecipeViewHolder private constructor(
item?.let { entity -> item?.let { entity ->
binding.root.setOnClickListener { binding.root.setOnClickListener {
logger.d { "bind: item clicked $entity" } logger.d { "bind: item clicked $entity" }
clickListener(entity) clickListener(ClickEvent.RecipeClick(entity))
}
binding.favoriteIcon.setOnClickListener {
clickListener(ClickEvent.FavoriteClick(entity))
} }
binding.favoriteIcon.setImageResource( binding.favoriteIcon.setImageResource(
if (item.isFavorite) { if (item.isFavorite) {

View File

@@ -90,7 +90,16 @@ class RecipesListFragment : Fragment(R.layout.fragment_recipes_list) {
private fun setupRecipeAdapter() { private fun setupRecipeAdapter() {
logger.v { "setupRecipeAdapter() called" } logger.v { "setupRecipeAdapter() called" }
val recipesAdapter = recipePagingAdapterFactory.build { onRecipeClicked(it) } val recipesAdapter = recipePagingAdapterFactory.build {
when (it) {
is RecipeViewHolder.ClickEvent.FavoriteClick -> {
viewModel.onFavoriteIconClick(it.recipeSummaryEntity)
}
is RecipeViewHolder.ClickEvent.RecipeClick -> {
onRecipeClicked(it.recipeSummaryEntity)
}
}
}
with(binding.recipes) { with(binding.recipes) {
adapter = recipesAdapter adapter = recipesAdapter

View File

@@ -8,10 +8,12 @@ import androidx.paging.cachedIn
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.auth.AuthRepo import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.recipes.RecipeRepo import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.extensions.valueUpdatesOnly import gq.kirmanak.mealient.extensions.valueUpdatesOnly
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -38,4 +40,15 @@ class RecipesListViewModel @Inject constructor(
emit(result) emit(result)
} }
} }
fun onFavoriteIconClick(recipeSummaryEntity: RecipeSummaryEntity) {
logger.v { "onFavoriteIconClick() called with: recipeSummaryEntity = $recipeSummaryEntity" }
viewModelScope.launch {
if (recipeSummaryEntity.isFavorite) {
recipeRepo.removeFavoriteRecipe(recipeSummaryEntity.slug)
} else {
recipeRepo.addFavoriteRecipe(recipeSummaryEntity.slug)
}
}
}
} }

View File

@@ -8,28 +8,22 @@ import dagger.hilt.android.scopes.FragmentScoped
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.databinding.ViewHolderRecipeBinding import gq.kirmanak.mealient.databinding.ViewHolderRecipeBinding
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.recipes.images.RecipeImageLoader
import javax.inject.Inject import javax.inject.Inject
class RecipesPagingAdapter private constructor( class RecipesPagingAdapter private constructor(
private val logger: Logger, private val logger: Logger,
private val recipeImageLoader: RecipeImageLoader,
private val recipeViewHolderFactory: RecipeViewHolder.Factory, private val recipeViewHolderFactory: RecipeViewHolder.Factory,
private val clickListener: (RecipeSummaryEntity) -> Unit private val clickListener: (RecipeViewHolder.ClickEvent) -> Unit
) : PagingDataAdapter<RecipeSummaryEntity, RecipeViewHolder>(RecipeDiffCallback) { ) : PagingDataAdapter<RecipeSummaryEntity, RecipeViewHolder>(RecipeDiffCallback) {
@FragmentScoped @FragmentScoped
class Factory @Inject constructor( class Factory @Inject constructor(
private val logger: Logger, private val logger: Logger,
private val recipeViewHolderFactory: RecipeViewHolder.Factory, private val recipeViewHolderFactory: RecipeViewHolder.Factory,
private val recipeImageLoader: RecipeImageLoader,
) { ) {
fun build(clickListener: (RecipeSummaryEntity) -> Unit) = RecipesPagingAdapter( fun build(clickListener: (RecipeViewHolder.ClickEvent) -> Unit) = RecipesPagingAdapter(
logger, logger, recipeViewHolderFactory, clickListener
recipeImageLoader,
recipeViewHolderFactory,
clickListener
) )
} }
@@ -43,18 +37,16 @@ class RecipesPagingAdapter private constructor(
logger.v { "onCreateViewHolder() called with: parent = $parent, viewType = $viewType" } logger.v { "onCreateViewHolder() called with: parent = $parent, viewType = $viewType" }
val inflater = LayoutInflater.from(parent.context) val inflater = LayoutInflater.from(parent.context)
val binding = ViewHolderRecipeBinding.inflate(inflater, parent, false) val binding = ViewHolderRecipeBinding.inflate(inflater, parent, false)
return recipeViewHolderFactory.build(recipeImageLoader, binding, clickListener) return recipeViewHolderFactory.build(binding, clickListener)
} }
private object RecipeDiffCallback : DiffUtil.ItemCallback<RecipeSummaryEntity>() { private object RecipeDiffCallback : DiffUtil.ItemCallback<RecipeSummaryEntity>() {
override fun areItemsTheSame( override fun areItemsTheSame(
oldItem: RecipeSummaryEntity, oldItem: RecipeSummaryEntity, newItem: RecipeSummaryEntity
newItem: RecipeSummaryEntity
): Boolean = oldItem.remoteId == newItem.remoteId ): Boolean = oldItem.remoteId == newItem.remoteId
override fun areContentsTheSame( override fun areContentsTheSame(
oldItem: RecipeSummaryEntity, oldItem: RecipeSummaryEntity, newItem: RecipeSummaryEntity
newItem: RecipeSummaryEntity
): Boolean = oldItem.name == newItem.name && oldItem.slug == newItem.slug ): Boolean = oldItem.name == newItem.name && oldItem.slug == newItem.slug
} }
} }

View File

@@ -42,4 +42,8 @@ interface MealieDataSourceV0 {
): String ): String
suspend fun requestUserInfo(): GetUserInfoResponseV0 suspend fun requestUserInfo(): GetUserInfoResponseV0
suspend fun removeFavoriteRecipe(userId: Int, recipeSlug: String)
suspend fun addFavoriteRecipe(userId: Int, recipeSlug: String)
} }

View File

@@ -97,4 +97,22 @@ class MealieDataSourceV0Impl @Inject constructor(
logMethod = { "requestUserInfo" }, logMethod = { "requestUserInfo" },
) )
} }
override suspend fun removeFavoriteRecipe(
userId: Int,
recipeSlug: String
): Unit = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.removeFavoriteRecipe(userId, recipeSlug) },
logMethod = { "removeFavoriteRecipe" },
logParameters = { "userId = $userId, recipeSlug = $recipeSlug" }
)
override suspend fun addFavoriteRecipe(
userId: Int,
recipeSlug: String
): Unit = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.addFavoriteRecipe(userId, recipeSlug) },
logMethod = { "addFavoriteRecipe" },
logParameters = { "userId = $userId, recipeSlug = $recipeSlug" }
)
} }

View File

@@ -43,4 +43,16 @@ interface MealieServiceV0 {
@GET("/api/users/self") @GET("/api/users/self")
suspend fun getUserSelfInfo(): GetUserInfoResponseV0 suspend fun getUserSelfInfo(): GetUserInfoResponseV0
@DELETE("/api/users/{userId}/favorites/{recipeSlug}")
suspend fun removeFavoriteRecipe(
@Path("userId") userId: Int,
@Path("recipeSlug") recipeSlug: String
)
@POST("/api/users/{userId}/favorites/{recipeSlug}")
suspend fun addFavoriteRecipe(
@Path("userId") userId: Int,
@Path("recipeSlug") recipeSlug: String
)
} }

View File

@@ -5,5 +5,6 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
data class GetUserInfoResponseV0( data class GetUserInfoResponseV0(
@SerialName("id") val id: Int,
@SerialName("favoriteRecipes") val favoriteRecipes: List<String> = emptyList(), @SerialName("favoriteRecipes") val favoriteRecipes: List<String> = emptyList(),
) )

View File

@@ -50,4 +50,8 @@ interface MealieDataSourceV1 {
): CreateApiTokenResponseV1 ): CreateApiTokenResponseV1
suspend fun requestUserInfo(): GetUserInfoResponseV1 suspend fun requestUserInfo(): GetUserInfoResponseV1
suspend fun removeFavoriteRecipe(userId: String, recipeSlug: String)
suspend fun addFavoriteRecipe(userId: String, recipeSlug: String)
} }

View File

@@ -108,5 +108,23 @@ class MealieDataSourceV1Impl @Inject constructor(
logMethod = { "requestUserInfo" }, logMethod = { "requestUserInfo" },
) )
} }
override suspend fun removeFavoriteRecipe(
userId: String,
recipeSlug: String
): Unit = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.removeFavoriteRecipe(userId, recipeSlug) },
logMethod = { "removeFavoriteRecipe" },
logParameters = { "userId = $userId, recipeSlug = $recipeSlug" }
)
override suspend fun addFavoriteRecipe(
userId: String,
recipeSlug: String
): Unit = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.addFavoriteRecipe(userId, recipeSlug) },
logMethod = { "addFavoriteRecipe" },
logParameters = { "userId = $userId, recipeSlug = $recipeSlug" }
)
} }

View File

@@ -49,4 +49,16 @@ interface MealieServiceV1 {
@GET("/api/users/self") @GET("/api/users/self")
suspend fun getUserSelfInfo(): GetUserInfoResponseV1 suspend fun getUserSelfInfo(): GetUserInfoResponseV1
@DELETE("/api/users/{userId}/favorites/{recipeSlug}")
suspend fun removeFavoriteRecipe(
@Path("userId") userId: String,
@Path("recipeSlug") recipeSlug: String
)
@POST("/api/users/{userId}/favorites/{recipeSlug}")
suspend fun addFavoriteRecipe(
@Path("userId") userId: String,
@Path("recipeSlug") recipeSlug: String
)
} }

View File

@@ -5,5 +5,6 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
data class GetUserInfoResponseV1( data class GetUserInfoResponseV1(
@SerialName("id") val id: String,
@SerialName("favoriteRecipes") val favoriteRecipes: List<String> = emptyList(), @SerialName("favoriteRecipes") val favoriteRecipes: List<String> = emptyList(),
) )