Merge pull request #118 from kirmanak/favorite-recipes
Add favorite icon to recipes
This commit is contained in:
@@ -64,4 +64,30 @@ class MealieDataSourceWrapper @Inject constructor(
|
|||||||
ServerVersion.V1 -> v1Source.parseRecipeFromURL(parseRecipeURLInfo.toV1Request())
|
ServerVersion.V1 -> v1Source.parseRecipeFromURL(parseRecipeURLInfo.toV1Request())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getFavoriteRecipes(): List<String> = when (getVersion()) {
|
||||||
|
ServerVersion.V0 -> v0Source.requestUserInfo().favoriteRecipes
|
||||||
|
ServerVersion.V1 -> v1Source.requestUserInfo().favoriteRecipes
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateIsRecipeFavorite(
|
||||||
|
recipeSlug: String,
|
||||||
|
isFavorite: Boolean
|
||||||
|
) = when (getVersion()) {
|
||||||
|
ServerVersion.V0 -> {
|
||||||
|
val userId = v0Source.requestUserInfo().id
|
||||||
|
if (isFavorite) {
|
||||||
|
v0Source.addFavoriteRecipe(userId, recipeSlug)
|
||||||
|
} else {
|
||||||
|
v0Source.removeFavoriteRecipe(userId, recipeSlug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ServerVersion.V1 -> {
|
||||||
|
val userId = v1Source.requestUserInfo().id
|
||||||
|
if (isFavorite) {
|
||||||
|
v1Source.addFavoriteRecipe(userId, recipeSlug)
|
||||||
|
} else {
|
||||||
|
v1Source.removeFavoriteRecipe(userId, recipeSlug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -17,4 +17,6 @@ interface RecipeRepo {
|
|||||||
fun updateNameQuery(name: String?)
|
fun updateNameQuery(name: String?)
|
||||||
|
|
||||||
suspend fun refreshRecipes()
|
suspend fun refreshRecipes()
|
||||||
|
|
||||||
|
suspend fun updateIsRecipeFavorite(recipeSlug: String, isFavorite: Boolean): Result<Unit>
|
||||||
}
|
}
|
||||||
@@ -2,20 +2,21 @@ package gq.kirmanak.mealient.data.recipes.db
|
|||||||
|
|
||||||
import androidx.paging.PagingSource
|
import androidx.paging.PagingSource
|
||||||
import gq.kirmanak.mealient.data.recipes.network.FullRecipeInfo
|
import gq.kirmanak.mealient.data.recipes.network.FullRecipeInfo
|
||||||
import gq.kirmanak.mealient.data.recipes.network.RecipeSummaryInfo
|
|
||||||
import gq.kirmanak.mealient.database.recipe.entity.FullRecipeEntity
|
import gq.kirmanak.mealient.database.recipe.entity.FullRecipeEntity
|
||||||
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
|
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
|
||||||
|
|
||||||
interface RecipeStorage {
|
interface RecipeStorage {
|
||||||
suspend fun saveRecipes(recipes: List<RecipeSummaryInfo>)
|
suspend fun saveRecipes(recipes: List<RecipeSummaryEntity>)
|
||||||
|
|
||||||
fun queryRecipes(query: String?): PagingSource<Int, RecipeSummaryEntity>
|
fun queryRecipes(query: String?): PagingSource<Int, RecipeSummaryEntity>
|
||||||
|
|
||||||
suspend fun refreshAll(recipes: List<RecipeSummaryInfo>)
|
suspend fun refreshAll(recipes: List<RecipeSummaryEntity>)
|
||||||
|
|
||||||
suspend fun clearAllLocalData()
|
suspend fun clearAllLocalData()
|
||||||
|
|
||||||
suspend fun saveRecipeInfo(recipe: FullRecipeInfo)
|
suspend fun saveRecipeInfo(recipe: FullRecipeInfo)
|
||||||
|
|
||||||
suspend fun queryRecipeInfo(recipeId: String): FullRecipeEntity?
|
suspend fun queryRecipeInfo(recipeId: String): FullRecipeEntity?
|
||||||
|
|
||||||
|
suspend fun updateFavoriteRecipes(favorites: List<String>)
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,6 @@ package gq.kirmanak.mealient.data.recipes.db
|
|||||||
import androidx.paging.PagingSource
|
import androidx.paging.PagingSource
|
||||||
import androidx.room.withTransaction
|
import androidx.room.withTransaction
|
||||||
import gq.kirmanak.mealient.data.recipes.network.FullRecipeInfo
|
import gq.kirmanak.mealient.data.recipes.network.FullRecipeInfo
|
||||||
import gq.kirmanak.mealient.data.recipes.network.RecipeSummaryInfo
|
|
||||||
import gq.kirmanak.mealient.database.AppDb
|
import gq.kirmanak.mealient.database.AppDb
|
||||||
import gq.kirmanak.mealient.database.recipe.RecipeDao
|
import gq.kirmanak.mealient.database.recipe.RecipeDao
|
||||||
import gq.kirmanak.mealient.database.recipe.entity.FullRecipeEntity
|
import gq.kirmanak.mealient.database.recipe.entity.FullRecipeEntity
|
||||||
@@ -11,7 +10,6 @@ import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
|
|||||||
import gq.kirmanak.mealient.extensions.toRecipeEntity
|
import gq.kirmanak.mealient.extensions.toRecipeEntity
|
||||||
import gq.kirmanak.mealient.extensions.toRecipeIngredientEntity
|
import gq.kirmanak.mealient.extensions.toRecipeIngredientEntity
|
||||||
import gq.kirmanak.mealient.extensions.toRecipeInstructionEntity
|
import gq.kirmanak.mealient.extensions.toRecipeInstructionEntity
|
||||||
import gq.kirmanak.mealient.extensions.toRecipeSummaryEntity
|
|
||||||
import gq.kirmanak.mealient.logging.Logger
|
import gq.kirmanak.mealient.logging.Logger
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -23,11 +21,9 @@ class RecipeStorageImpl @Inject constructor(
|
|||||||
) : RecipeStorage {
|
) : RecipeStorage {
|
||||||
private val recipeDao: RecipeDao by lazy { db.recipeDao() }
|
private val recipeDao: RecipeDao by lazy { db.recipeDao() }
|
||||||
|
|
||||||
override suspend fun saveRecipes(recipes: List<RecipeSummaryInfo>) {
|
override suspend fun saveRecipes(recipes: List<RecipeSummaryEntity>) {
|
||||||
logger.v { "saveRecipes() called with $recipes" }
|
logger.v { "saveRecipes() called with $recipes" }
|
||||||
val entities = recipes.map { it.toRecipeSummaryEntity() }
|
db.withTransaction { recipeDao.insertRecipes(recipes) }
|
||||||
logger.v { "saveRecipes: entities = $entities" }
|
|
||||||
db.withTransaction { recipeDao.insertRecipes(entities) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun queryRecipes(query: String?): PagingSource<Int, RecipeSummaryEntity> {
|
override fun queryRecipes(query: String?): PagingSource<Int, RecipeSummaryEntity> {
|
||||||
@@ -36,7 +32,7 @@ class RecipeStorageImpl @Inject constructor(
|
|||||||
else recipeDao.queryRecipesByPages(query)
|
else recipeDao.queryRecipesByPages(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun refreshAll(recipes: List<RecipeSummaryInfo>) {
|
override suspend fun refreshAll(recipes: List<RecipeSummaryEntity>) {
|
||||||
logger.v { "refreshAll() called with: recipes = $recipes" }
|
logger.v { "refreshAll() called with: recipes = $recipes" }
|
||||||
db.withTransaction {
|
db.withTransaction {
|
||||||
recipeDao.removeAllRecipes()
|
recipeDao.removeAllRecipes()
|
||||||
@@ -76,4 +72,12 @@ class RecipeStorageImpl @Inject constructor(
|
|||||||
logger.v { "queryRecipeInfo() returned: $fullRecipeInfo" }
|
logger.v { "queryRecipeInfo() returned: $fullRecipeInfo" }
|
||||||
return fullRecipeInfo
|
return fullRecipeInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun updateFavoriteRecipes(favorites: List<String>) {
|
||||||
|
logger.v { "updateFavoriteRecipes() called with: favorites = $favorites" }
|
||||||
|
db.withTransaction {
|
||||||
|
recipeDao.setFavorite(favorites)
|
||||||
|
recipeDao.setNonFavorite(favorites)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -66,12 +66,23 @@ class RecipeRepoImpl @Inject constructor(
|
|||||||
override suspend fun refreshRecipes() {
|
override suspend fun refreshRecipes() {
|
||||||
logger.v { "refreshRecipes() called" }
|
logger.v { "refreshRecipes() called" }
|
||||||
runCatchingExceptCancel {
|
runCatchingExceptCancel {
|
||||||
storage.refreshAll(dataSource.requestRecipes(0, INITIAL_LOAD_PAGE_SIZE))
|
mediator.updateRecipes(0, INITIAL_LOAD_PAGE_SIZE)
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
logger.e(it) { "Can't refresh recipes" }
|
logger.e(it) { "Can't refresh recipes" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun updateIsRecipeFavorite(
|
||||||
|
recipeSlug: String,
|
||||||
|
isFavorite: Boolean,
|
||||||
|
): Result<Unit> = runCatchingExceptCancel {
|
||||||
|
logger.v { "updateIsRecipeFavorite() called with: recipeSlug = $recipeSlug, isFavorite = $isFavorite" }
|
||||||
|
dataSource.updateIsRecipeFavorite(recipeSlug, isFavorite)
|
||||||
|
mediator.onFavoritesChange()
|
||||||
|
}.onFailure {
|
||||||
|
logger.e(it) { "Can't update recipe's is favorite status" }
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -4,11 +4,16 @@ import androidx.annotation.VisibleForTesting
|
|||||||
import androidx.paging.*
|
import androidx.paging.*
|
||||||
import androidx.paging.LoadType.PREPEND
|
import androidx.paging.LoadType.PREPEND
|
||||||
import androidx.paging.LoadType.REFRESH
|
import androidx.paging.LoadType.REFRESH
|
||||||
|
import gq.kirmanak.mealient.architecture.configuration.AppDispatchers
|
||||||
import gq.kirmanak.mealient.data.recipes.db.RecipeStorage
|
import gq.kirmanak.mealient.data.recipes.db.RecipeStorage
|
||||||
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
|
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
|
||||||
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
|
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
|
||||||
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
|
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
|
||||||
|
import gq.kirmanak.mealient.extensions.toRecipeSummaryEntity
|
||||||
import gq.kirmanak.mealient.logging.Logger
|
import gq.kirmanak.mealient.logging.Logger
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -19,14 +24,14 @@ class RecipesRemoteMediator @Inject constructor(
|
|||||||
private val network: RecipeDataSource,
|
private val network: RecipeDataSource,
|
||||||
private val pagingSourceFactory: RecipePagingSourceFactory,
|
private val pagingSourceFactory: RecipePagingSourceFactory,
|
||||||
private val logger: Logger,
|
private val logger: Logger,
|
||||||
|
private val dispatchers: AppDispatchers,
|
||||||
) : RemoteMediator<Int, RecipeSummaryEntity>() {
|
) : RemoteMediator<Int, RecipeSummaryEntity>() {
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
var lastRequestEnd: Int = 0
|
var lastRequestEnd: Int = 0
|
||||||
|
|
||||||
override suspend fun load(
|
override suspend fun load(
|
||||||
loadType: LoadType,
|
loadType: LoadType, state: PagingState<Int, RecipeSummaryEntity>
|
||||||
state: PagingState<Int, RecipeSummaryEntity>
|
|
||||||
): MediatorResult {
|
): MediatorResult {
|
||||||
logger.v { "load() called with: lastRequestEnd = $lastRequestEnd, loadType = $loadType, state = $state" }
|
logger.v { "load() called with: lastRequestEnd = $lastRequestEnd, loadType = $loadType, state = $state" }
|
||||||
|
|
||||||
@@ -39,10 +44,7 @@ class RecipesRemoteMediator @Inject constructor(
|
|||||||
val limit = if (loadType == REFRESH) state.config.initialLoadSize else state.config.pageSize
|
val limit = if (loadType == REFRESH) state.config.initialLoadSize else state.config.pageSize
|
||||||
|
|
||||||
val count: Int = runCatchingExceptCancel {
|
val count: Int = runCatchingExceptCancel {
|
||||||
val recipes = network.requestRecipes(start, limit)
|
updateRecipes(start, limit, loadType)
|
||||||
if (loadType == REFRESH) storage.refreshAll(recipes)
|
|
||||||
else storage.saveRecipes(recipes)
|
|
||||||
recipes.size
|
|
||||||
}.getOrElse {
|
}.getOrElse {
|
||||||
logger.e(it) { "load: can't load recipes" }
|
logger.e(it) { "load: can't load recipes" }
|
||||||
return MediatorResult.Error(it)
|
return MediatorResult.Error(it)
|
||||||
@@ -58,4 +60,33 @@ class RecipesRemoteMediator @Inject constructor(
|
|||||||
lastRequestEnd = start + count
|
lastRequestEnd = start + count
|
||||||
return MediatorResult.Success(endOfPaginationReached = count < limit)
|
return MediatorResult.Success(endOfPaginationReached = count < limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun updateRecipes(
|
||||||
|
start: Int,
|
||||||
|
limit: Int,
|
||||||
|
loadType: LoadType = REFRESH,
|
||||||
|
): Int = coroutineScope {
|
||||||
|
logger.v { "updateRecipes() called with: start = $start, limit = $limit, loadType = $loadType" }
|
||||||
|
val deferredRecipes = async { network.requestRecipes(start, limit) }
|
||||||
|
val favorites = runCatchingExceptCancel {
|
||||||
|
network.getFavoriteRecipes()
|
||||||
|
}.getOrDefault(emptyList()).toHashSet()
|
||||||
|
val recipes = deferredRecipes.await()
|
||||||
|
val entities = withContext(dispatchers.default) {
|
||||||
|
recipes.map { recipe ->
|
||||||
|
val isFavorite = favorites.contains(recipe.slug)
|
||||||
|
recipe.toRecipeSummaryEntity(isFavorite)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (loadType == REFRESH) storage.refreshAll(entities)
|
||||||
|
else storage.saveRecipes(entities)
|
||||||
|
recipes.size
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun onFavoritesChange() {
|
||||||
|
logger.v { "onFavoritesChange() called" }
|
||||||
|
val favorites = network.getFavoriteRecipes()
|
||||||
|
storage.updateFavoriteRecipes(favorites)
|
||||||
|
pagingSourceFactory.invalidate()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -4,4 +4,8 @@ interface RecipeDataSource {
|
|||||||
suspend fun requestRecipes(start: Int, limit: Int): List<RecipeSummaryInfo>
|
suspend fun requestRecipes(start: Int, limit: Int): List<RecipeSummaryInfo>
|
||||||
|
|
||||||
suspend fun requestRecipeInfo(slug: String): FullRecipeInfo
|
suspend fun requestRecipeInfo(slug: String): FullRecipeInfo
|
||||||
|
|
||||||
|
suspend fun getFavoriteRecipes(): List<String>
|
||||||
|
|
||||||
|
suspend fun updateIsRecipeFavorite(recipeSlug: String, isFavorite: Boolean)
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,7 @@ fun GetRecipeSummaryResponseV1.toRecipeSummaryInfo() = RecipeSummaryInfo(
|
|||||||
imageId = remoteId,
|
imageId = remoteId,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun RecipeSummaryInfo.toRecipeSummaryEntity() = RecipeSummaryEntity(
|
fun RecipeSummaryInfo.toRecipeSummaryEntity(isFavorite: Boolean) = RecipeSummaryEntity(
|
||||||
remoteId = remoteId,
|
remoteId = remoteId,
|
||||||
name = name,
|
name = name,
|
||||||
slug = slug,
|
slug = slug,
|
||||||
@@ -88,6 +88,7 @@ fun RecipeSummaryInfo.toRecipeSummaryEntity() = RecipeSummaryEntity(
|
|||||||
dateAdded = dateAdded,
|
dateAdded = dateAdded,
|
||||||
dateUpdated = dateUpdated,
|
dateUpdated = dateUpdated,
|
||||||
imageId = imageId,
|
imageId = imageId,
|
||||||
|
isFavorite = isFavorite,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun VersionResponseV0.toVersionInfo() = VersionInfo(version)
|
fun VersionResponseV0.toVersionInfo() = VersionInfo(version)
|
||||||
|
|||||||
@@ -1,32 +1,51 @@
|
|||||||
package gq.kirmanak.mealient.ui.recipes
|
package gq.kirmanak.mealient.ui.recipes
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
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
|
||||||
import gq.kirmanak.mealient.extensions.resources
|
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.Singleton
|
|
||||||
|
|
||||||
class RecipeViewHolder private constructor(
|
class RecipeViewHolder @AssistedInject constructor(
|
||||||
private val logger: Logger,
|
private val logger: Logger,
|
||||||
private val binding: ViewHolderRecipeBinding,
|
@Assisted private val binding: ViewHolderRecipeBinding,
|
||||||
private val recipeImageLoader: RecipeImageLoader,
|
private val recipeImageLoader: RecipeImageLoader,
|
||||||
private val clickListener: (RecipeSummaryEntity) -> Unit,
|
@Assisted private val showFavoriteIcon: Boolean,
|
||||||
|
@Assisted private val clickListener: (ClickEvent) -> Unit,
|
||||||
) : RecyclerView.ViewHolder(binding.root) {
|
) : RecyclerView.ViewHolder(binding.root) {
|
||||||
|
|
||||||
@Singleton
|
@FragmentScoped
|
||||||
class Factory @Inject constructor(
|
@AssistedFactory
|
||||||
private val logger: Logger,
|
interface Factory {
|
||||||
) {
|
|
||||||
|
|
||||||
fun build(
|
fun build(
|
||||||
recipeImageLoader: RecipeImageLoader,
|
showFavoriteIcon: Boolean,
|
||||||
binding: ViewHolderRecipeBinding,
|
binding: ViewHolderRecipeBinding,
|
||||||
clickListener: (RecipeSummaryEntity) -> Unit,
|
clickListener: (ClickEvent) -> Unit,
|
||||||
) = RecipeViewHolder(logger, binding, recipeImageLoader, clickListener)
|
): RecipeViewHolder
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class ClickEvent {
|
||||||
|
|
||||||
|
abstract val recipeSummaryEntity: RecipeSummaryEntity
|
||||||
|
|
||||||
|
data class FavoriteClick(
|
||||||
|
override val recipeSummaryEntity: RecipeSummaryEntity
|
||||||
|
) : ClickEvent()
|
||||||
|
|
||||||
|
data class RecipeClick(
|
||||||
|
override val recipeSummaryEntity: RecipeSummaryEntity
|
||||||
|
) : ClickEvent()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,8 +60,30 @@ 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.isVisible = showFavoriteIcon
|
||||||
|
binding.favoriteIcon.setOnClickListener {
|
||||||
|
clickListener(ClickEvent.FavoriteClick(entity))
|
||||||
|
}
|
||||||
|
binding.favoriteIcon.setImageResource(
|
||||||
|
if (item.isFavorite) {
|
||||||
|
R.drawable.ic_favorite_filled
|
||||||
|
} else {
|
||||||
|
R.drawable.ic_favorite_unfilled
|
||||||
|
}
|
||||||
|
)
|
||||||
|
binding.favoriteIcon.setContentDescription(
|
||||||
|
if (item.isFavorite) {
|
||||||
|
R.string.view_holder_recipe_favorite_content_description
|
||||||
|
} else {
|
||||||
|
R.string.view_holder_recipe_non_favorite_content_description
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun View.setContentDescription(@StringRes resId: Int) {
|
||||||
|
contentDescription = context.getString(resId)
|
||||||
|
}
|
||||||
@@ -55,7 +55,9 @@ class RecipesListFragment : Fragment(R.layout.fragment_recipes_list) {
|
|||||||
checkedMenuItemId = R.id.recipes_list
|
checkedMenuItemId = R.id.recipes_list
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
setupRecipeAdapter()
|
viewModel.showFavoriteIcon.observe(viewLifecycleOwner) { showFavoriteIcon ->
|
||||||
|
setupRecipeAdapter(showFavoriteIcon)
|
||||||
|
}
|
||||||
hideKeyboardOnScroll()
|
hideKeyboardOnScroll()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,10 +89,19 @@ class RecipesListFragment : Fragment(R.layout.fragment_recipes_list) {
|
|||||||
return findNavController().currentDestination?.id != R.id.recipesListFragment
|
return findNavController().currentDestination?.id != R.id.recipesListFragment
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupRecipeAdapter() {
|
private fun setupRecipeAdapter(showFavoriteIcon: Boolean) {
|
||||||
logger.v { "setupRecipeAdapter() called" }
|
logger.v { "setupRecipeAdapter() called" }
|
||||||
|
|
||||||
val recipesAdapter = recipePagingAdapterFactory.build { onRecipeClicked(it) }
|
val recipesAdapter = recipePagingAdapterFactory.build(showFavoriteIcon) {
|
||||||
|
when (it) {
|
||||||
|
is RecipeViewHolder.ClickEvent.FavoriteClick -> {
|
||||||
|
onFavoriteClick(it)
|
||||||
|
}
|
||||||
|
is RecipeViewHolder.ClickEvent.RecipeClick -> {
|
||||||
|
onRecipeClicked(it.recipeSummaryEntity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
with(binding.recipes) {
|
with(binding.recipes) {
|
||||||
adapter = recipesAdapter
|
adapter = recipesAdapter
|
||||||
@@ -128,6 +139,16 @@ class RecipesListFragment : Fragment(R.layout.fragment_recipes_list) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onFavoriteClick(event: RecipeViewHolder.ClickEvent) {
|
||||||
|
logger.v { "onFavoriteClick() called with: event = $event" }
|
||||||
|
viewModel.onFavoriteIconClick(event.recipeSummaryEntity).observe(viewLifecycleOwner) {
|
||||||
|
logger.d { "onFavoriteClick: result is $it" }
|
||||||
|
if (it.isFailure) {
|
||||||
|
showLongToast(R.string.fragment_recipes_favorite_update_failed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun onLoadFailure(error: Throwable) {
|
private fun onLoadFailure(error: Throwable) {
|
||||||
logger.w(error) { "onLoadFailure() called" }
|
logger.w(error) { "onLoadFailure() called" }
|
||||||
val reason = error.toLoadErrorReasonText()?.let { getString(it) }
|
val reason = error.toLoadErrorReasonText()?.let { getString(it) }
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ package gq.kirmanak.mealient.ui.recipes
|
|||||||
|
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.asLiveData
|
||||||
import androidx.lifecycle.liveData
|
import androidx.lifecycle.liveData
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.paging.cachedIn
|
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
|
||||||
@@ -22,6 +24,7 @@ class RecipesListViewModel @Inject constructor(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val pagingData = recipeRepo.createPager().flow.cachedIn(viewModelScope)
|
val pagingData = recipeRepo.createPager().flow.cachedIn(viewModelScope)
|
||||||
|
val showFavoriteIcon = authRepo.isAuthorizedFlow.asLiveData()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
authRepo.isAuthorizedFlow.valueUpdatesOnly().onEach { hasAuthorized ->
|
authRepo.isAuthorizedFlow.valueUpdatesOnly().onEach { hasAuthorized ->
|
||||||
@@ -38,4 +41,12 @@ class RecipesListViewModel @Inject constructor(
|
|||||||
emit(result)
|
emit(result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onFavoriteIconClick(recipeSummaryEntity: RecipeSummaryEntity) = liveData {
|
||||||
|
logger.v { "onFavoriteIconClick() called with: recipeSummaryEntity = $recipeSummaryEntity" }
|
||||||
|
recipeRepo.updateIsRecipeFavorite(
|
||||||
|
recipeSlug = recipeSummaryEntity.slug,
|
||||||
|
isFavorite = recipeSummaryEntity.isFavorite.not(),
|
||||||
|
).also { emit(it) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -4,33 +4,29 @@ import android.view.LayoutInflater
|
|||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.paging.PagingDataAdapter
|
import androidx.paging.PagingDataAdapter
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
import dagger.hilt.android.scopes.FragmentScoped
|
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
|
|
||||||
|
|
||||||
class RecipesPagingAdapter private constructor(
|
class RecipesPagingAdapter @AssistedInject 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
|
@Assisted private val showFavoriteIcon: Boolean,
|
||||||
|
@Assisted private val clickListener: (RecipeViewHolder.ClickEvent) -> Unit
|
||||||
) : PagingDataAdapter<RecipeSummaryEntity, RecipeViewHolder>(RecipeDiffCallback) {
|
) : PagingDataAdapter<RecipeSummaryEntity, RecipeViewHolder>(RecipeDiffCallback) {
|
||||||
|
|
||||||
@FragmentScoped
|
@FragmentScoped
|
||||||
class Factory @Inject constructor(
|
@AssistedFactory
|
||||||
private val logger: Logger,
|
interface Factory {
|
||||||
private val recipeViewHolderFactory: RecipeViewHolder.Factory,
|
|
||||||
private val recipeImageLoader: RecipeImageLoader,
|
|
||||||
) {
|
|
||||||
|
|
||||||
fun build(clickListener: (RecipeSummaryEntity) -> Unit) = RecipesPagingAdapter(
|
fun build(
|
||||||
logger,
|
showFavoriteIcon: Boolean,
|
||||||
recipeImageLoader,
|
clickListener: (RecipeViewHolder.ClickEvent) -> Unit,
|
||||||
recipeViewHolderFactory,
|
): RecipesPagingAdapter
|
||||||
clickListener
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: RecipeViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: RecipeViewHolder, position: Int) {
|
||||||
@@ -43,18 +39,18 @@ 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(showFavoriteIcon, 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 == newItem
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
10
app/src/main/res/drawable/ic_favorite_filled.xml
Normal file
10
app/src/main/res/drawable/ic_favorite_filled.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="32dp"
|
||||||
|
android:height="32dp"
|
||||||
|
android:tint="?attr/colorPrimary"
|
||||||
|
android:viewportWidth="40"
|
||||||
|
android:viewportHeight="40">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M20,34.958 L18.042,33.208Q13.708,29.25 10.875,26.375Q8.042,23.5 6.354,21.229Q4.667,18.958 4,17.104Q3.333,15.25 3.333,13.333Q3.333,9.542 5.896,6.979Q8.458,4.417 12.208,4.417Q14.542,4.417 16.542,5.479Q18.542,6.542 20,8.5Q21.625,6.458 23.583,5.438Q25.542,4.417 27.792,4.417Q31.542,4.417 34.104,6.979Q36.667,9.542 36.667,13.333Q36.667,15.25 36,17.104Q35.333,18.958 33.646,21.229Q31.958,23.5 29.125,26.375Q26.292,29.25 21.958,33.208Z" />
|
||||||
|
</vector>
|
||||||
10
app/src/main/res/drawable/ic_favorite_unfilled.xml
Normal file
10
app/src/main/res/drawable/ic_favorite_unfilled.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="32dp"
|
||||||
|
android:height="32dp"
|
||||||
|
android:tint="?attr/colorPrimary"
|
||||||
|
android:viewportWidth="40"
|
||||||
|
android:viewportHeight="40">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M20,34.958 L18.042,33.208Q13.708,29.25 10.875,26.375Q8.042,23.5 6.354,21.229Q4.667,18.958 4,17.104Q3.333,15.25 3.333,13.333Q3.333,9.542 5.896,6.979Q8.458,4.417 12.208,4.417Q14.542,4.417 16.542,5.479Q18.542,6.542 20,8.5Q21.625,6.458 23.583,5.438Q25.542,4.417 27.792,4.417Q31.542,4.417 34.104,6.979Q36.667,9.542 36.667,13.333Q36.667,15.25 36,17.104Q35.333,18.958 33.646,21.229Q31.958,23.5 29.125,26.375Q26.292,29.25 21.958,33.208ZM20,31.292Q24.125,27.5 26.812,24.792Q29.5,22.083 31.062,20.062Q32.625,18.042 33.25,16.458Q33.875,14.875 33.875,13.333Q33.875,10.667 32.167,8.938Q30.458,7.208 27.792,7.208Q25.708,7.208 23.938,8.438Q22.167,9.667 21.208,11.833H18.792Q17.833,9.708 16.062,8.458Q14.292,7.208 12.208,7.208Q9.542,7.208 7.833,8.938Q6.125,10.667 6.125,13.333Q6.125,14.917 6.75,16.5Q7.375,18.083 8.938,20.125Q10.5,22.167 13.188,24.854Q15.875,27.542 20,31.292ZM20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Z" />
|
||||||
|
</vector>
|
||||||
@@ -17,8 +17,7 @@
|
|||||||
android:id="@+id/name"
|
android:id="@+id/name"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="9dp"
|
android:layout_marginVertical="@dimen/margin_small"
|
||||||
android:layout_marginBottom="5dp"
|
|
||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
android:textAppearance="?textAppearanceHeadline6"
|
android:textAppearance="?textAppearanceHeadline6"
|
||||||
@@ -32,18 +31,29 @@
|
|||||||
android:id="@+id/image"
|
android:id="@+id/image"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
android:layout_marginStart="15dp"
|
android:layout_marginHorizontal="@dimen/margin_medium"
|
||||||
android:layout_marginTop="20dp"
|
|
||||||
android:layout_marginEnd="13dp"
|
|
||||||
android:contentDescription="@string/content_description_view_holder_recipe_image"
|
android:contentDescription="@string/content_description_view_holder_recipe_image"
|
||||||
android:scaleType="centerCrop"
|
android:scaleType="centerCrop"
|
||||||
app:layout_constraintBottom_toTopOf="@+id/name"
|
app:layout_constraintBottom_toTopOf="@+id/name"
|
||||||
app:layout_constraintDimensionRatio="2:1"
|
app:layout_constraintDimensionRatio="2:1"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toBottomOf="@id/favorite_icon"
|
||||||
app:layout_constraintVertical_chainStyle="packed"
|
app:layout_constraintVertical_chainStyle="packed"
|
||||||
|
app:layout_goneMarginTop="@dimen/margin_medium"
|
||||||
app:shapeAppearance="?shapeAppearanceCornerMedium"
|
app:shapeAppearance="?shapeAppearanceCornerMedium"
|
||||||
tools:srcCompat="@drawable/placeholder_recipe" />
|
tools:srcCompat="@drawable/placeholder_recipe" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/favorite_icon"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginVertical="@dimen/margin_small"
|
||||||
|
android:contentDescription="@string/view_holder_recipe_favorite_content_description"
|
||||||
|
app:layout_constraintBottom_toTopOf="@+id/image"
|
||||||
|
app:layout_constraintEnd_toEndOf="@id/image"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
tools:srcCompat="@drawable/ic_favorite_unfilled"
|
||||||
|
tools:visibility="gone" />
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
</com.google.android.material.card.MaterialCardView>
|
</com.google.android.material.card.MaterialCardView>
|
||||||
@@ -47,6 +47,7 @@
|
|||||||
<string name="fragment_recipes_load_failure_toast_unexpected_response">неожиданный ответ</string>
|
<string name="fragment_recipes_load_failure_toast_unexpected_response">неожиданный ответ</string>
|
||||||
<string name="fragment_recipes_load_failure_toast_no_connection">нет соединения</string>
|
<string name="fragment_recipes_load_failure_toast_no_connection">нет соединения</string>
|
||||||
<string name="fragment_recipes_load_failure_toast_no_reason">Ошибка загрузки.</string>
|
<string name="fragment_recipes_load_failure_toast_no_reason">Ошибка загрузки.</string>
|
||||||
|
<string name="fragment_recipes_favorite_update_failed">Не удалось обновить статус избранного</string>
|
||||||
<string name="menu_navigation_drawer_change_url">Сменить URL</string>
|
<string name="menu_navigation_drawer_change_url">Сменить URL</string>
|
||||||
<string name="search_recipes_hint">Найти рецепты</string>
|
<string name="search_recipes_hint">Найти рецепты</string>
|
||||||
<string name="view_toolbar_navigation_icon_content_description">Открыть меню навигации</string>
|
<string name="view_toolbar_navigation_icon_content_description">Открыть меню навигации</string>
|
||||||
@@ -54,4 +55,6 @@
|
|||||||
<string name="activity_share_recipe_success_toast">Рецепт успешно сохранен.</string>
|
<string name="activity_share_recipe_success_toast">Рецепт успешно сохранен.</string>
|
||||||
<string name="activity_share_recipe_failure_toast">Что-то пошло не так.</string>
|
<string name="activity_share_recipe_failure_toast">Что-то пошло не так.</string>
|
||||||
<string name="content_description_activity_share_recipe_progress">Индикатор прогресса</string>
|
<string name="content_description_activity_share_recipe_progress">Индикатор прогресса</string>
|
||||||
|
<string name="view_holder_recipe_favorite_content_description">Добавлен в избранное</string>
|
||||||
|
<string name="view_holder_recipe_non_favorite_content_description">Не добавлен в избранное</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -49,6 +49,7 @@
|
|||||||
<string name="fragment_recipes_load_failure_toast_unauthorized">unauthorized</string>
|
<string name="fragment_recipes_load_failure_toast_unauthorized">unauthorized</string>
|
||||||
<string name="fragment_recipes_load_failure_toast_unexpected_response">unexpected response</string>
|
<string name="fragment_recipes_load_failure_toast_unexpected_response">unexpected response</string>
|
||||||
<string name="fragment_recipes_load_failure_toast_no_connection">no connection</string>
|
<string name="fragment_recipes_load_failure_toast_no_connection">no connection</string>
|
||||||
|
<string name="fragment_recipes_favorite_update_failed">Favorite status update failed</string>
|
||||||
<string name="menu_navigation_drawer_change_url">Change URL</string>
|
<string name="menu_navigation_drawer_change_url">Change URL</string>
|
||||||
<string name="search_recipes_hint">Search recipes</string>
|
<string name="search_recipes_hint">Search recipes</string>
|
||||||
<string name="menu_navigation_drawer_header" translatable="false">@string/app_name</string>
|
<string name="menu_navigation_drawer_header" translatable="false">@string/app_name</string>
|
||||||
@@ -57,4 +58,6 @@
|
|||||||
<string name="activity_share_recipe_success_toast">Recipe saved successfully.</string>
|
<string name="activity_share_recipe_success_toast">Recipe saved successfully.</string>
|
||||||
<string name="activity_share_recipe_failure_toast">Something went wrong.</string>
|
<string name="activity_share_recipe_failure_toast">Something went wrong.</string>
|
||||||
<string name="content_description_activity_share_recipe_progress">Progress indicator</string>
|
<string name="content_description_activity_share_recipe_progress">Progress indicator</string>
|
||||||
|
<string name="view_holder_recipe_favorite_content_description">Item is favorite</string>
|
||||||
|
<string name="view_holder_recipe_non_favorite_content_description">Item is not favorite</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -5,9 +5,12 @@ import gq.kirmanak.mealient.data.auth.AuthRepo
|
|||||||
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
|
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
|
||||||
import gq.kirmanak.mealient.datasource.v0.MealieDataSourceV0
|
import gq.kirmanak.mealient.datasource.v0.MealieDataSourceV0
|
||||||
import gq.kirmanak.mealient.datasource.v1.MealieDataSourceV1
|
import gq.kirmanak.mealient.datasource.v1.MealieDataSourceV1
|
||||||
|
import gq.kirmanak.mealient.test.AuthImplTestData.FAVORITE_RECIPES_LIST
|
||||||
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_AUTH_HEADER
|
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_AUTH_HEADER
|
||||||
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_SERVER_VERSION_V0
|
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_SERVER_VERSION_V0
|
||||||
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_SERVER_VERSION_V1
|
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_SERVER_VERSION_V1
|
||||||
|
import gq.kirmanak.mealient.test.AuthImplTestData.USER_INFO_V0
|
||||||
|
import gq.kirmanak.mealient.test.AuthImplTestData.USER_INFO_V1
|
||||||
import gq.kirmanak.mealient.test.BaseUnitTest
|
import gq.kirmanak.mealient.test.BaseUnitTest
|
||||||
import gq.kirmanak.mealient.test.RecipeImplTestData.PORRIDGE_ADD_RECIPE_INFO
|
import gq.kirmanak.mealient.test.RecipeImplTestData.PORRIDGE_ADD_RECIPE_INFO
|
||||||
import gq.kirmanak.mealient.test.RecipeImplTestData.PORRIDGE_ADD_RECIPE_REQUEST_V0
|
import gq.kirmanak.mealient.test.RecipeImplTestData.PORRIDGE_ADD_RECIPE_REQUEST_V0
|
||||||
@@ -36,10 +39,10 @@ class MealieDataSourceWrapperTest : BaseUnitTest() {
|
|||||||
@MockK(relaxUnitFun = true)
|
@MockK(relaxUnitFun = true)
|
||||||
lateinit var authRepo: AuthRepo
|
lateinit var authRepo: AuthRepo
|
||||||
|
|
||||||
@MockK
|
@MockK(relaxUnitFun = true)
|
||||||
lateinit var v0Source: MealieDataSourceV0
|
lateinit var v0Source: MealieDataSourceV0
|
||||||
|
|
||||||
@MockK
|
@MockK(relaxUnitFun = true)
|
||||||
lateinit var v1Source: MealieDataSourceV1
|
lateinit var v1Source: MealieDataSourceV1
|
||||||
|
|
||||||
lateinit var subject: MealieDataSourceWrapper
|
lateinit var subject: MealieDataSourceWrapper
|
||||||
@@ -48,14 +51,14 @@ class MealieDataSourceWrapperTest : BaseUnitTest() {
|
|||||||
override fun setUp() {
|
override fun setUp() {
|
||||||
super.setUp()
|
super.setUp()
|
||||||
subject = MealieDataSourceWrapper(serverInfoRepo, v0Source, v1Source)
|
subject = MealieDataSourceWrapper(serverInfoRepo, v0Source, v1Source)
|
||||||
|
coEvery { v0Source.requestUserInfo() } returns USER_INFO_V0
|
||||||
|
coEvery { v1Source.requestUserInfo() } returns USER_INFO_V1
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `when server version v1 expect requestRecipeInfo to call v1`() = runTest {
|
fun `when server version v1 expect requestRecipeInfo to call v1`() = runTest {
|
||||||
val slug = "porridge"
|
val slug = "porridge"
|
||||||
coEvery {
|
coEvery { v1Source.requestRecipeInfo(eq(slug)) } returns PORRIDGE_RECIPE_RESPONSE_V1
|
||||||
v1Source.requestRecipeInfo(eq(slug))
|
|
||||||
} returns PORRIDGE_RECIPE_RESPONSE_V1
|
|
||||||
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V1
|
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V1
|
||||||
coEvery { authRepo.getAuthHeader() } returns TEST_AUTH_HEADER
|
coEvery { authRepo.getAuthHeader() } returns TEST_AUTH_HEADER
|
||||||
|
|
||||||
@@ -157,4 +160,70 @@ class MealieDataSourceWrapperTest : BaseUnitTest() {
|
|||||||
|
|
||||||
assertThat(actual).isEqualTo(slug)
|
assertThat(actual).isEqualTo(slug)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when remove favorite recipe info with v0 expect correct sequence`() = runTest {
|
||||||
|
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V0
|
||||||
|
subject.updateIsRecipeFavorite(recipeSlug = "cake", isFavorite = false)
|
||||||
|
coVerify {
|
||||||
|
v0Source.requestUserInfo()
|
||||||
|
v0Source.removeFavoriteRecipe(eq(3), eq("cake"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when remove favorite recipe info with v1 expect correct sequence`() = runTest {
|
||||||
|
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V1
|
||||||
|
subject.updateIsRecipeFavorite(recipeSlug = "cake", isFavorite = false)
|
||||||
|
coVerify {
|
||||||
|
v1Source.requestUserInfo()
|
||||||
|
v1Source.removeFavoriteRecipe(eq("userId"), eq("cake"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when add favorite recipe info with v0 expect correct sequence`() = runTest {
|
||||||
|
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V0
|
||||||
|
subject.updateIsRecipeFavorite(recipeSlug = "cake", isFavorite = true)
|
||||||
|
coVerify {
|
||||||
|
v0Source.requestUserInfo()
|
||||||
|
v0Source.addFavoriteRecipe(eq(3), eq("cake"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when add favorite recipe info with v1 expect correct sequence`() = runTest {
|
||||||
|
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V1
|
||||||
|
subject.updateIsRecipeFavorite(recipeSlug = "cake", isFavorite = true)
|
||||||
|
coVerify {
|
||||||
|
v1Source.requestUserInfo()
|
||||||
|
v1Source.addFavoriteRecipe(eq("userId"), eq("cake"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when get favorite recipes with v1 expect correct call`() = runTest {
|
||||||
|
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V1
|
||||||
|
subject.getFavoriteRecipes()
|
||||||
|
coVerify { v1Source.requestUserInfo() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when get favorite recipes with v0 expect correct call`() = runTest {
|
||||||
|
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V0
|
||||||
|
subject.getFavoriteRecipes()
|
||||||
|
coVerify { v0Source.requestUserInfo() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when get favorite recipes with v1 expect correct result`() = runTest {
|
||||||
|
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V1
|
||||||
|
assertThat(subject.getFavoriteRecipes()).isEqualTo(FAVORITE_RECIPES_LIST)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when get favorite recipes with v0 expect correct result`() = runTest {
|
||||||
|
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V0
|
||||||
|
assertThat(subject.getFavoriteRecipes()).isEqualTo(FAVORITE_RECIPES_LIST)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -14,9 +14,7 @@ import gq.kirmanak.mealient.test.RecipeImplTestData.MIX_CAKE_RECIPE_INSTRUCTION_
|
|||||||
import gq.kirmanak.mealient.test.RecipeImplTestData.MIX_INSTRUCTION
|
import gq.kirmanak.mealient.test.RecipeImplTestData.MIX_INSTRUCTION
|
||||||
import gq.kirmanak.mealient.test.RecipeImplTestData.PORRIDGE_FULL_RECIPE_INFO
|
import gq.kirmanak.mealient.test.RecipeImplTestData.PORRIDGE_FULL_RECIPE_INFO
|
||||||
import gq.kirmanak.mealient.test.RecipeImplTestData.PORRIDGE_RECIPE_SUMMARY_ENTITY
|
import gq.kirmanak.mealient.test.RecipeImplTestData.PORRIDGE_RECIPE_SUMMARY_ENTITY
|
||||||
import gq.kirmanak.mealient.test.RecipeImplTestData.RECIPE_SUMMARY_CAKE
|
import gq.kirmanak.mealient.test.RecipeImplTestData.TEST_RECIPE_SUMMARY_ENTITIES
|
||||||
import gq.kirmanak.mealient.test.RecipeImplTestData.RECIPE_SUMMARY_PORRIDGE_V0
|
|
||||||
import gq.kirmanak.mealient.test.RecipeImplTestData.TEST_RECIPE_SUMMARIES
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -34,7 +32,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `when saveRecipes then saves recipes`() = runTest {
|
fun `when saveRecipes then saves recipes`() = runTest {
|
||||||
subject.saveRecipes(TEST_RECIPE_SUMMARIES)
|
subject.saveRecipes(TEST_RECIPE_SUMMARY_ENTITIES)
|
||||||
val actualTags = appDb.recipeDao().queryAllRecipes()
|
val actualTags = appDb.recipeDao().queryAllRecipes()
|
||||||
assertThat(actualTags).containsExactly(
|
assertThat(actualTags).containsExactly(
|
||||||
CAKE_RECIPE_SUMMARY_ENTITY,
|
CAKE_RECIPE_SUMMARY_ENTITY,
|
||||||
@@ -44,15 +42,15 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `when refreshAll then old recipes aren't preserved`() = runTest {
|
fun `when refreshAll then old recipes aren't preserved`() = runTest {
|
||||||
subject.saveRecipes(TEST_RECIPE_SUMMARIES)
|
subject.saveRecipes(TEST_RECIPE_SUMMARY_ENTITIES)
|
||||||
subject.refreshAll(listOf(RECIPE_SUMMARY_CAKE))
|
subject.refreshAll(listOf(CAKE_RECIPE_SUMMARY_ENTITY))
|
||||||
val actual = appDb.recipeDao().queryAllRecipes()
|
val actual = appDb.recipeDao().queryAllRecipes()
|
||||||
assertThat(actual).containsExactly(CAKE_RECIPE_SUMMARY_ENTITY)
|
assertThat(actual).containsExactly(CAKE_RECIPE_SUMMARY_ENTITY)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `when clearAllLocalData then recipes aren't preserved`() = runTest {
|
fun `when clearAllLocalData then recipes aren't preserved`() = runTest {
|
||||||
subject.saveRecipes(TEST_RECIPE_SUMMARIES)
|
subject.saveRecipes(TEST_RECIPE_SUMMARY_ENTITIES)
|
||||||
subject.clearAllLocalData()
|
subject.clearAllLocalData()
|
||||||
val actual = appDb.recipeDao().queryAllRecipes()
|
val actual = appDb.recipeDao().queryAllRecipes()
|
||||||
assertThat(actual).isEmpty()
|
assertThat(actual).isEmpty()
|
||||||
@@ -60,7 +58,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `when saveRecipeInfo then saves recipe info`() = runTest {
|
fun `when saveRecipeInfo then saves recipe info`() = runTest {
|
||||||
subject.saveRecipes(listOf(RECIPE_SUMMARY_CAKE))
|
subject.saveRecipes(listOf(CAKE_RECIPE_SUMMARY_ENTITY))
|
||||||
subject.saveRecipeInfo(CAKE_FULL_RECIPE_INFO)
|
subject.saveRecipeInfo(CAKE_FULL_RECIPE_INFO)
|
||||||
val actual = appDb.recipeDao().queryFullRecipeInfo("1")
|
val actual = appDb.recipeDao().queryFullRecipeInfo("1")
|
||||||
assertThat(actual).isEqualTo(FULL_CAKE_INFO_ENTITY)
|
assertThat(actual).isEqualTo(FULL_CAKE_INFO_ENTITY)
|
||||||
@@ -68,7 +66,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `when saveRecipeInfo with two then saves second`() = runTest {
|
fun `when saveRecipeInfo with two then saves second`() = runTest {
|
||||||
subject.saveRecipes(listOf(RECIPE_SUMMARY_CAKE, RECIPE_SUMMARY_PORRIDGE_V0))
|
subject.saveRecipes(TEST_RECIPE_SUMMARY_ENTITIES)
|
||||||
subject.saveRecipeInfo(CAKE_FULL_RECIPE_INFO)
|
subject.saveRecipeInfo(CAKE_FULL_RECIPE_INFO)
|
||||||
subject.saveRecipeInfo(PORRIDGE_FULL_RECIPE_INFO)
|
subject.saveRecipeInfo(PORRIDGE_FULL_RECIPE_INFO)
|
||||||
val actual = appDb.recipeDao().queryFullRecipeInfo("2")
|
val actual = appDb.recipeDao().queryFullRecipeInfo("2")
|
||||||
@@ -77,7 +75,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `when saveRecipeInfo secondly then overwrites ingredients`() = runTest {
|
fun `when saveRecipeInfo secondly then overwrites ingredients`() = runTest {
|
||||||
subject.saveRecipes(listOf(RECIPE_SUMMARY_CAKE))
|
subject.saveRecipes(listOf(CAKE_RECIPE_SUMMARY_ENTITY))
|
||||||
subject.saveRecipeInfo(CAKE_FULL_RECIPE_INFO)
|
subject.saveRecipeInfo(CAKE_FULL_RECIPE_INFO)
|
||||||
val newRecipe = CAKE_FULL_RECIPE_INFO.copy(recipeIngredients = listOf(BREAD_INGREDIENT))
|
val newRecipe = CAKE_FULL_RECIPE_INFO.copy(recipeIngredients = listOf(BREAD_INGREDIENT))
|
||||||
subject.saveRecipeInfo(newRecipe)
|
subject.saveRecipeInfo(newRecipe)
|
||||||
@@ -88,7 +86,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `when saveRecipeInfo secondly then overwrites instructions`() = runTest {
|
fun `when saveRecipeInfo secondly then overwrites instructions`() = runTest {
|
||||||
subject.saveRecipes(listOf(RECIPE_SUMMARY_CAKE))
|
subject.saveRecipes(listOf(CAKE_RECIPE_SUMMARY_ENTITY))
|
||||||
subject.saveRecipeInfo(CAKE_FULL_RECIPE_INFO)
|
subject.saveRecipeInfo(CAKE_FULL_RECIPE_INFO)
|
||||||
val newRecipe = CAKE_FULL_RECIPE_INFO.copy(recipeInstructions = listOf(MIX_INSTRUCTION))
|
val newRecipe = CAKE_FULL_RECIPE_INFO.copy(recipeInstructions = listOf(MIX_INSTRUCTION))
|
||||||
subject.saveRecipeInfo(newRecipe)
|
subject.saveRecipeInfo(newRecipe)
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
|
|||||||
import gq.kirmanak.mealient.test.HiltRobolectricTest
|
import gq.kirmanak.mealient.test.HiltRobolectricTest
|
||||||
import gq.kirmanak.mealient.test.RecipeImplTestData.CAKE_RECIPE_SUMMARY_ENTITY
|
import gq.kirmanak.mealient.test.RecipeImplTestData.CAKE_RECIPE_SUMMARY_ENTITY
|
||||||
import gq.kirmanak.mealient.test.RecipeImplTestData.PORRIDGE_RECIPE_SUMMARY_ENTITY
|
import gq.kirmanak.mealient.test.RecipeImplTestData.PORRIDGE_RECIPE_SUMMARY_ENTITY
|
||||||
import gq.kirmanak.mealient.test.RecipeImplTestData.TEST_RECIPE_SUMMARIES
|
|
||||||
import gq.kirmanak.mealient.test.RecipeImplTestData.TEST_RECIPE_SUMMARY_ENTITIES
|
import gq.kirmanak.mealient.test.RecipeImplTestData.TEST_RECIPE_SUMMARY_ENTITIES
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
@@ -27,28 +26,28 @@ class RecipePagingSourceFactoryImplTest : HiltRobolectricTest() {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `when query is ca expect cake only is returned`() = runTest {
|
fun `when query is ca expect cake only is returned`() = runTest {
|
||||||
storage.saveRecipes(TEST_RECIPE_SUMMARIES)
|
storage.saveRecipes(TEST_RECIPE_SUMMARY_ENTITIES)
|
||||||
subject.setQuery("ca")
|
subject.setQuery("ca")
|
||||||
assertThat(queryRecipes()).isEqualTo(listOf(CAKE_RECIPE_SUMMARY_ENTITY))
|
assertThat(queryRecipes()).isEqualTo(listOf(CAKE_RECIPE_SUMMARY_ENTITY))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `when query is po expect porridge only is returned`() = runTest {
|
fun `when query is po expect porridge only is returned`() = runTest {
|
||||||
storage.saveRecipes(TEST_RECIPE_SUMMARIES)
|
storage.saveRecipes(TEST_RECIPE_SUMMARY_ENTITIES)
|
||||||
subject.setQuery("po")
|
subject.setQuery("po")
|
||||||
assertThat(queryRecipes()).isEqualTo(listOf(PORRIDGE_RECIPE_SUMMARY_ENTITY))
|
assertThat(queryRecipes()).isEqualTo(listOf(PORRIDGE_RECIPE_SUMMARY_ENTITY))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `when query is e expect cake and porridge are returned`() = runTest {
|
fun `when query is e expect cake and porridge are returned`() = runTest {
|
||||||
storage.saveRecipes(TEST_RECIPE_SUMMARIES)
|
storage.saveRecipes(TEST_RECIPE_SUMMARY_ENTITIES)
|
||||||
subject.setQuery("e")
|
subject.setQuery("e")
|
||||||
assertThat(queryRecipes()).isEqualTo(TEST_RECIPE_SUMMARY_ENTITIES)
|
assertThat(queryRecipes()).isEqualTo(TEST_RECIPE_SUMMARY_ENTITIES)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `when query is null expect cake and porridge are returned`() = runTest {
|
fun `when query is null expect cake and porridge are returned`() = runTest {
|
||||||
storage.saveRecipes(TEST_RECIPE_SUMMARIES)
|
storage.saveRecipes(TEST_RECIPE_SUMMARY_ENTITIES)
|
||||||
subject.setQuery(null)
|
subject.setQuery(null)
|
||||||
assertThat(queryRecipes()).isEqualTo(TEST_RECIPE_SUMMARY_ENTITIES)
|
assertThat(queryRecipes()).isEqualTo(TEST_RECIPE_SUMMARY_ENTITIES)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package gq.kirmanak.mealient.data.recipes.impl
|
package gq.kirmanak.mealient.data.recipes.impl
|
||||||
|
|
||||||
|
import androidx.paging.LoadType
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import gq.kirmanak.mealient.data.recipes.RecipeRepo
|
import gq.kirmanak.mealient.data.recipes.RecipeRepo
|
||||||
import gq.kirmanak.mealient.data.recipes.db.RecipeStorage
|
import gq.kirmanak.mealient.data.recipes.db.RecipeStorage
|
||||||
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
|
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
|
||||||
|
import gq.kirmanak.mealient.datasource.NetworkError.Unauthorized
|
||||||
import gq.kirmanak.mealient.test.BaseUnitTest
|
import gq.kirmanak.mealient.test.BaseUnitTest
|
||||||
import gq.kirmanak.mealient.test.RecipeImplTestData.CAKE_FULL_RECIPE_INFO
|
import gq.kirmanak.mealient.test.RecipeImplTestData.CAKE_FULL_RECIPE_INFO
|
||||||
import gq.kirmanak.mealient.test.RecipeImplTestData.FULL_CAKE_INFO_ENTITY
|
import gq.kirmanak.mealient.test.RecipeImplTestData.FULL_CAKE_INFO_ENTITY
|
||||||
@@ -15,6 +17,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class RecipeRepoTest : BaseUnitTest() {
|
class RecipeRepoTest : BaseUnitTest() {
|
||||||
@@ -22,7 +25,7 @@ class RecipeRepoTest : BaseUnitTest() {
|
|||||||
@MockK(relaxUnitFun = true)
|
@MockK(relaxUnitFun = true)
|
||||||
lateinit var storage: RecipeStorage
|
lateinit var storage: RecipeStorage
|
||||||
|
|
||||||
@MockK
|
@MockK(relaxUnitFun = true)
|
||||||
lateinit var dataSource: RecipeDataSource
|
lateinit var dataSource: RecipeDataSource
|
||||||
|
|
||||||
@MockK
|
@MockK
|
||||||
@@ -64,4 +67,37 @@ class RecipeRepoTest : BaseUnitTest() {
|
|||||||
subject.updateNameQuery("query")
|
subject.updateNameQuery("query")
|
||||||
verify { pagingSourceFactory.setQuery("query") }
|
verify { pagingSourceFactory.setQuery("query") }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when remove favorite recipe expect correct sequence`() = runTest {
|
||||||
|
subject.updateIsRecipeFavorite("cake", false)
|
||||||
|
coVerify {
|
||||||
|
dataSource.updateIsRecipeFavorite(eq("cake"), eq(false))
|
||||||
|
remoteMediator.onFavoritesChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when add favorite recipe expect correct sequence`() = runTest {
|
||||||
|
subject.updateIsRecipeFavorite("porridge", true)
|
||||||
|
coVerify {
|
||||||
|
dataSource.updateIsRecipeFavorite(eq("porridge"), eq(true))
|
||||||
|
remoteMediator.onFavoritesChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when add favorite recipe fails expect no mediator call`() = runTest {
|
||||||
|
coEvery {
|
||||||
|
dataSource.updateIsRecipeFavorite(any(), any())
|
||||||
|
} throws Unauthorized(IOException())
|
||||||
|
subject.updateIsRecipeFavorite("porridge", true)
|
||||||
|
coVerify(inverse = true) { remoteMediator.onFavoritesChange() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when refresh recipes expect correct parameters`() = runTest {
|
||||||
|
subject.refreshRecipes()
|
||||||
|
coVerify { remoteMediator.updateRecipes(eq(0), eq(150), eq(LoadType.REFRESH)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@ import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
|
|||||||
import gq.kirmanak.mealient.datasource.NetworkError.Unauthorized
|
import gq.kirmanak.mealient.datasource.NetworkError.Unauthorized
|
||||||
import gq.kirmanak.mealient.test.BaseUnitTest
|
import gq.kirmanak.mealient.test.BaseUnitTest
|
||||||
import gq.kirmanak.mealient.test.RecipeImplTestData.TEST_RECIPE_SUMMARIES
|
import gq.kirmanak.mealient.test.RecipeImplTestData.TEST_RECIPE_SUMMARIES
|
||||||
|
import gq.kirmanak.mealient.test.RecipeImplTestData.TEST_RECIPE_SUMMARY_ENTITIES
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.coVerify
|
import io.mockk.coVerify
|
||||||
import io.mockk.impl.annotations.MockK
|
import io.mockk.impl.annotations.MockK
|
||||||
@@ -17,6 +18,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
@ExperimentalCoroutinesApi
|
@ExperimentalCoroutinesApi
|
||||||
@OptIn(ExperimentalPagingApi::class)
|
@OptIn(ExperimentalPagingApi::class)
|
||||||
@@ -42,7 +44,14 @@ class RecipesRemoteMediatorTest : BaseUnitTest() {
|
|||||||
@Before
|
@Before
|
||||||
override fun setUp() {
|
override fun setUp() {
|
||||||
super.setUp()
|
super.setUp()
|
||||||
subject = RecipesRemoteMediator(storage, dataSource, pagingSourceFactory, logger)
|
subject = RecipesRemoteMediator(
|
||||||
|
storage = storage,
|
||||||
|
network = dataSource,
|
||||||
|
pagingSourceFactory = pagingSourceFactory,
|
||||||
|
logger = logger,
|
||||||
|
dispatchers = dispatchers,
|
||||||
|
)
|
||||||
|
coEvery { dataSource.getFavoriteRecipes() } returns emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -70,7 +79,7 @@ class RecipesRemoteMediatorTest : BaseUnitTest() {
|
|||||||
fun `when first load with refresh successful then recipes stored`() = runTest {
|
fun `when first load with refresh successful then recipes stored`() = runTest {
|
||||||
coEvery { dataSource.requestRecipes(eq(0), eq(6)) } returns TEST_RECIPE_SUMMARIES
|
coEvery { dataSource.requestRecipes(eq(0), eq(6)) } returns TEST_RECIPE_SUMMARIES
|
||||||
subject.load(REFRESH, pagingState())
|
subject.load(REFRESH, pagingState())
|
||||||
coVerify { storage.refreshAll(eq(TEST_RECIPE_SUMMARIES)) }
|
coVerify { storage.refreshAll(eq(TEST_RECIPE_SUMMARY_ENTITIES)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -132,9 +141,35 @@ class RecipesRemoteMediatorTest : BaseUnitTest() {
|
|||||||
subject.load(REFRESH, pagingState())
|
subject.load(REFRESH, pagingState())
|
||||||
coEvery { dataSource.requestRecipes(any(), any()) } throws Unauthorized(RuntimeException())
|
coEvery { dataSource.requestRecipes(any(), any()) } throws Unauthorized(RuntimeException())
|
||||||
subject.load(APPEND, pagingState())
|
subject.load(APPEND, pagingState())
|
||||||
coVerify {
|
coVerify { storage.refreshAll(TEST_RECIPE_SUMMARY_ENTITIES) }
|
||||||
storage.refreshAll(TEST_RECIPE_SUMMARIES)
|
}
|
||||||
}
|
|
||||||
|
@Test
|
||||||
|
fun `when favorites change expect network call`() = runTest {
|
||||||
|
coEvery { dataSource.getFavoriteRecipes() } returns listOf("cake", "porridge")
|
||||||
|
subject.onFavoritesChange()
|
||||||
|
coVerify { dataSource.getFavoriteRecipes() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when favorites change expect storage update`() = runTest {
|
||||||
|
coEvery { dataSource.getFavoriteRecipes() } returns listOf("cake", "porridge")
|
||||||
|
subject.onFavoritesChange()
|
||||||
|
coVerify { storage.updateFavoriteRecipes(eq(listOf("cake", "porridge"))) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when favorites change expect factory invalidation`() = runTest {
|
||||||
|
coEvery { dataSource.getFavoriteRecipes() } returns listOf("cake", "porridge")
|
||||||
|
subject.onFavoritesChange()
|
||||||
|
coVerify { pagingSourceFactory.invalidate() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when recipe update requested but favorite fails expect non-zero updates`() = runTest {
|
||||||
|
coEvery { dataSource.getFavoriteRecipes() } throws Unauthorized(IOException())
|
||||||
|
coEvery { dataSource.requestRecipes(eq(0), eq(6)) } returns TEST_RECIPE_SUMMARIES
|
||||||
|
assertThat(subject.updateRecipes(0, 6, APPEND)).isEqualTo(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun pagingState(
|
private fun pagingState(
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ class ModelMappingsTest : BaseUnitTest() {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `when summary info to entity expect correct entity`() {
|
fun `when summary info to entity expect correct entity`() {
|
||||||
val actual = RECIPE_SUMMARY_PORRIDGE_V0.toRecipeSummaryEntity()
|
val actual = RECIPE_SUMMARY_PORRIDGE_V0.toRecipeSummaryEntity(isFavorite = false)
|
||||||
assertThat(actual).isEqualTo(PORRIDGE_RECIPE_SUMMARY_ENTITY)
|
assertThat(actual).isEqualTo(PORRIDGE_RECIPE_SUMMARY_ENTITY)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package gq.kirmanak.mealient.test
|
package gq.kirmanak.mealient.test
|
||||||
|
|
||||||
import gq.kirmanak.mealient.data.baseurl.ServerVersion
|
import gq.kirmanak.mealient.data.baseurl.ServerVersion
|
||||||
|
import gq.kirmanak.mealient.datasource.v0.models.GetUserInfoResponseV0
|
||||||
|
import gq.kirmanak.mealient.datasource.v1.models.GetUserInfoResponseV1
|
||||||
|
|
||||||
object AuthImplTestData {
|
object AuthImplTestData {
|
||||||
const val TEST_USERNAME = "TEST_USERNAME"
|
const val TEST_USERNAME = "TEST_USERNAME"
|
||||||
@@ -13,4 +15,8 @@ object AuthImplTestData {
|
|||||||
const val TEST_VERSION = "v0.5.6"
|
const val TEST_VERSION = "v0.5.6"
|
||||||
val TEST_SERVER_VERSION_V0 = ServerVersion.V0
|
val TEST_SERVER_VERSION_V0 = ServerVersion.V0
|
||||||
val TEST_SERVER_VERSION_V1 = ServerVersion.V1
|
val TEST_SERVER_VERSION_V1 = ServerVersion.V1
|
||||||
|
|
||||||
|
val FAVORITE_RECIPES_LIST = listOf("cake", "porridge")
|
||||||
|
val USER_INFO_V1 = GetUserInfoResponseV1("userId", FAVORITE_RECIPES_LIST)
|
||||||
|
val USER_INFO_V0 = GetUserInfoResponseV0(3, FAVORITE_RECIPES_LIST)
|
||||||
}
|
}
|
||||||
@@ -80,6 +80,7 @@ object RecipeImplTestData {
|
|||||||
dateAdded = LocalDate.parse("2021-11-13"),
|
dateAdded = LocalDate.parse("2021-11-13"),
|
||||||
dateUpdated = LocalDateTime.parse("2021-11-13T15:30:13"),
|
dateUpdated = LocalDateTime.parse("2021-11-13T15:30:13"),
|
||||||
imageId = "cake",
|
imageId = "cake",
|
||||||
|
isFavorite = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
val PORRIDGE_RECIPE_SUMMARY_ENTITY = RecipeSummaryEntity(
|
val PORRIDGE_RECIPE_SUMMARY_ENTITY = RecipeSummaryEntity(
|
||||||
@@ -90,6 +91,7 @@ object RecipeImplTestData {
|
|||||||
dateAdded = LocalDate.parse("2021-11-12"),
|
dateAdded = LocalDate.parse("2021-11-12"),
|
||||||
dateUpdated = LocalDateTime.parse("2021-10-13T17:35:23"),
|
dateUpdated = LocalDateTime.parse("2021-10-13T17:35:23"),
|
||||||
imageId = "porridge",
|
imageId = "porridge",
|
||||||
|
isFavorite = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
val TEST_RECIPE_SUMMARY_ENTITIES =
|
val TEST_RECIPE_SUMMARY_ENTITIES =
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package gq.kirmanak.mealient.architecture.configuration
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
|
||||||
|
interface AppDispatchers {
|
||||||
|
val io: CoroutineDispatcher
|
||||||
|
val main: CoroutineDispatcher
|
||||||
|
val default: CoroutineDispatcher
|
||||||
|
val unconfined: CoroutineDispatcher
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package gq.kirmanak.mealient.architecture.configuration
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class AppDispatchersImpl @Inject constructor() : AppDispatchers {
|
||||||
|
|
||||||
|
override val io = Dispatchers.IO
|
||||||
|
|
||||||
|
override val main = Dispatchers.Main
|
||||||
|
|
||||||
|
override val default = Dispatchers.Default
|
||||||
|
|
||||||
|
override val unconfined = Dispatchers.Unconfined
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package gq.kirmanak.mealient.architecture.configuration
|
||||||
|
|
||||||
|
import dagger.Binds
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
interface ArchitectureModule {
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
fun bindAppDispatchers(appDispatchersImpl: AppDispatchersImpl): AppDispatchers
|
||||||
|
}
|
||||||
198
database/schemas/gq.kirmanak.mealient.database.AppDb/8.json
Normal file
198
database/schemas/gq.kirmanak.mealient.database.AppDb/8.json
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 8,
|
||||||
|
"identityHash": "793673e401425db36544918dae6bf4c1",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "recipe_summaries",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`remote_id` TEXT NOT NULL, `name` TEXT NOT NULL, `slug` TEXT NOT NULL, `description` TEXT NOT NULL, `date_added` INTEGER NOT NULL, `date_updated` INTEGER NOT NULL, `image_id` TEXT, `is_favorite` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`remote_id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "remoteId",
|
||||||
|
"columnName": "remote_id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "slug",
|
||||||
|
"columnName": "slug",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "description",
|
||||||
|
"columnName": "description",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "dateAdded",
|
||||||
|
"columnName": "date_added",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "dateUpdated",
|
||||||
|
"columnName": "date_updated",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "imageId",
|
||||||
|
"columnName": "image_id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isFavorite",
|
||||||
|
"columnName": "is_favorite",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "false"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"columnNames": [
|
||||||
|
"remote_id"
|
||||||
|
],
|
||||||
|
"autoGenerate": false
|
||||||
|
},
|
||||||
|
"indices": [],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "recipe",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`remote_id` TEXT NOT NULL, `recipe_yield` TEXT NOT NULL, `disable_amounts` INTEGER NOT NULL DEFAULT true, PRIMARY KEY(`remote_id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "remoteId",
|
||||||
|
"columnName": "remote_id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "recipeYield",
|
||||||
|
"columnName": "recipe_yield",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "disableAmounts",
|
||||||
|
"columnName": "disable_amounts",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "true"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"columnNames": [
|
||||||
|
"remote_id"
|
||||||
|
],
|
||||||
|
"autoGenerate": false
|
||||||
|
},
|
||||||
|
"indices": [],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "recipe_ingredient",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`local_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `recipe_id` TEXT NOT NULL, `note` TEXT NOT NULL, `food` TEXT, `unit` TEXT, `quantity` REAL, `title` TEXT)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "localId",
|
||||||
|
"columnName": "local_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "recipeId",
|
||||||
|
"columnName": "recipe_id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "note",
|
||||||
|
"columnName": "note",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "food",
|
||||||
|
"columnName": "food",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "unit",
|
||||||
|
"columnName": "unit",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "quantity",
|
||||||
|
"columnName": "quantity",
|
||||||
|
"affinity": "REAL",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "title",
|
||||||
|
"columnName": "title",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"columnNames": [
|
||||||
|
"local_id"
|
||||||
|
],
|
||||||
|
"autoGenerate": true
|
||||||
|
},
|
||||||
|
"indices": [],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "recipe_instruction",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`local_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `recipe_id` TEXT NOT NULL, `text` TEXT NOT NULL)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "localId",
|
||||||
|
"columnName": "local_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "recipeId",
|
||||||
|
"columnName": "recipe_id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "text",
|
||||||
|
"columnName": "text",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"columnNames": [
|
||||||
|
"local_id"
|
||||||
|
],
|
||||||
|
"autoGenerate": true
|
||||||
|
},
|
||||||
|
"indices": [],
|
||||||
|
"foreignKeys": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"views": [],
|
||||||
|
"setupQueries": [
|
||||||
|
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||||
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '793673e401425db36544918dae6bf4c1')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import gq.kirmanak.mealient.database.recipe.RecipeDao
|
|||||||
import gq.kirmanak.mealient.database.recipe.entity.*
|
import gq.kirmanak.mealient.database.recipe.entity.*
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
version = 7,
|
version = 8,
|
||||||
entities = [
|
entities = [
|
||||||
RecipeSummaryEntity::class,
|
RecipeSummaryEntity::class,
|
||||||
RecipeEntity::class,
|
RecipeEntity::class,
|
||||||
@@ -20,6 +20,7 @@ import gq.kirmanak.mealient.database.recipe.entity.*
|
|||||||
AutoMigration(from = 4, to = 5, spec = AppDb.From4To5Migration::class),
|
AutoMigration(from = 4, to = 5, spec = AppDb.From4To5Migration::class),
|
||||||
AutoMigration(from = 5, to = 6, spec = AppDb.From5To6Migration::class),
|
AutoMigration(from = 5, to = 6, spec = AppDb.From5To6Migration::class),
|
||||||
AutoMigration(from = 6, to = 7),
|
AutoMigration(from = 6, to = 7),
|
||||||
|
AutoMigration(from = 7, to = 8),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@TypeConverters(RoomTypeConverters::class)
|
@TypeConverters(RoomTypeConverters::class)
|
||||||
|
|||||||
@@ -40,4 +40,10 @@ interface RecipeDao {
|
|||||||
|
|
||||||
@Query("DELETE FROM recipe_instruction WHERE recipe_id = :recipeId")
|
@Query("DELETE FROM recipe_instruction WHERE recipe_id = :recipeId")
|
||||||
suspend fun deleteRecipeInstructions(recipeId: String)
|
suspend fun deleteRecipeInstructions(recipeId: String)
|
||||||
|
|
||||||
|
@Query("UPDATE recipe_summaries SET is_favorite = 1 WHERE slug IN (:favorites)")
|
||||||
|
suspend fun setFavorite(favorites: List<String>)
|
||||||
|
|
||||||
|
@Query("UPDATE recipe_summaries SET is_favorite = 0 WHERE slug NOT IN (:favorites)")
|
||||||
|
suspend fun setNonFavorite(favorites: List<String>)
|
||||||
}
|
}
|
||||||
@@ -15,4 +15,5 @@ data class RecipeSummaryEntity(
|
|||||||
@ColumnInfo(name = "date_added") val dateAdded: LocalDate,
|
@ColumnInfo(name = "date_added") val dateAdded: LocalDate,
|
||||||
@ColumnInfo(name = "date_updated") val dateUpdated: LocalDateTime,
|
@ColumnInfo(name = "date_updated") val dateUpdated: LocalDateTime,
|
||||||
@ColumnInfo(name = "image_id") val imageId: String?,
|
@ColumnInfo(name = "image_id") val imageId: String?,
|
||||||
|
@ColumnInfo(name = "is_favorite", defaultValue = "false") val isFavorite: Boolean,
|
||||||
)
|
)
|
||||||
@@ -5,13 +5,13 @@ interface NetworkRequestWrapper {
|
|||||||
suspend fun <T> makeCall(
|
suspend fun <T> makeCall(
|
||||||
block: suspend () -> T,
|
block: suspend () -> T,
|
||||||
logMethod: () -> String,
|
logMethod: () -> String,
|
||||||
logParameters: () -> String,
|
logParameters: (() -> String)? = null,
|
||||||
): Result<T>
|
): Result<T>
|
||||||
|
|
||||||
suspend fun <T> makeCallAndHandleUnauthorized(
|
suspend fun <T> makeCallAndHandleUnauthorized(
|
||||||
block: suspend () -> T,
|
block: suspend () -> T,
|
||||||
logMethod: () -> String,
|
logMethod: () -> String,
|
||||||
logParameters: () -> String,
|
logParameters: (() -> String)? = null,
|
||||||
): T
|
): T
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -16,18 +16,40 @@ class NetworkRequestWrapperImpl @Inject constructor(
|
|||||||
override suspend fun <T> makeCall(
|
override suspend fun <T> makeCall(
|
||||||
block: suspend () -> T,
|
block: suspend () -> T,
|
||||||
logMethod: () -> String,
|
logMethod: () -> String,
|
||||||
logParameters: () -> String,
|
logParameters: (() -> String)?,
|
||||||
): Result<T> {
|
): Result<T> {
|
||||||
logger.v { "${logMethod()} called with: ${logParameters()}" }
|
logger.v {
|
||||||
|
if (logParameters == null) {
|
||||||
|
"${logMethod()} called"
|
||||||
|
} else {
|
||||||
|
"${logMethod()} called with: ${logParameters()}"
|
||||||
|
}
|
||||||
|
}
|
||||||
return runCatchingExceptCancel { block() }
|
return runCatchingExceptCancel { block() }
|
||||||
.onFailure { logger.e(it) { "${logMethod()} request failed with: ${logParameters()}" } }
|
.onFailure {
|
||||||
.onSuccess { logger.d { "${logMethod()} request succeeded with ${logParameters()}, result = $it" } }
|
logger.e(it) {
|
||||||
|
if (logParameters == null) {
|
||||||
|
"${logMethod()} request failed"
|
||||||
|
} else {
|
||||||
|
"${logMethod()} request failed with: ${logParameters()}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onSuccess {
|
||||||
|
logger.d {
|
||||||
|
if (logParameters == null) {
|
||||||
|
"${logMethod()} request succeeded, result = $it"
|
||||||
|
} else {
|
||||||
|
"${logMethod()} request succeeded with: ${logParameters()}, result = $it"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun <T> makeCallAndHandleUnauthorized(
|
override suspend fun <T> makeCallAndHandleUnauthorized(
|
||||||
block: suspend () -> T,
|
block: suspend () -> T,
|
||||||
logMethod: () -> String,
|
logMethod: () -> String,
|
||||||
logParameters: () -> String
|
logParameters: (() -> String)?
|
||||||
): T = makeCall(block, logMethod, logParameters).getOrElse {
|
): T = makeCall(block, logMethod, logParameters).getOrElse {
|
||||||
throw if (it is HttpException && it.code() in listOf(401, 403)) {
|
throw if (it is HttpException && it.code() in listOf(401, 403)) {
|
||||||
NetworkError.Unauthorized(it)
|
NetworkError.Unauthorized(it)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import gq.kirmanak.mealient.datasource.v0.models.AddRecipeRequestV0
|
|||||||
import gq.kirmanak.mealient.datasource.v0.models.CreateApiTokenRequestV0
|
import gq.kirmanak.mealient.datasource.v0.models.CreateApiTokenRequestV0
|
||||||
import gq.kirmanak.mealient.datasource.v0.models.GetRecipeResponseV0
|
import gq.kirmanak.mealient.datasource.v0.models.GetRecipeResponseV0
|
||||||
import gq.kirmanak.mealient.datasource.v0.models.GetRecipeSummaryResponseV0
|
import gq.kirmanak.mealient.datasource.v0.models.GetRecipeSummaryResponseV0
|
||||||
|
import gq.kirmanak.mealient.datasource.v0.models.GetUserInfoResponseV0
|
||||||
import gq.kirmanak.mealient.datasource.v0.models.ParseRecipeURLRequestV0
|
import gq.kirmanak.mealient.datasource.v0.models.ParseRecipeURLRequestV0
|
||||||
import gq.kirmanak.mealient.datasource.v0.models.VersionResponseV0
|
import gq.kirmanak.mealient.datasource.v0.models.VersionResponseV0
|
||||||
|
|
||||||
@@ -21,8 +22,7 @@ interface MealieDataSourceV0 {
|
|||||||
password: String,
|
password: String,
|
||||||
): String
|
): String
|
||||||
|
|
||||||
suspend fun getVersionInfo(
|
suspend fun getVersionInfo(): VersionResponseV0
|
||||||
): VersionResponseV0
|
|
||||||
|
|
||||||
suspend fun requestRecipes(
|
suspend fun requestRecipes(
|
||||||
start: Int,
|
start: Int,
|
||||||
@@ -40,4 +40,10 @@ interface MealieDataSourceV0 {
|
|||||||
suspend fun createApiToken(
|
suspend fun createApiToken(
|
||||||
request: CreateApiTokenRequestV0,
|
request: CreateApiTokenRequestV0,
|
||||||
): String
|
): String
|
||||||
|
|
||||||
|
suspend fun requestUserInfo(): GetUserInfoResponseV0
|
||||||
|
|
||||||
|
suspend fun removeFavoriteRecipe(userId: Int, recipeSlug: String)
|
||||||
|
|
||||||
|
suspend fun addFavoriteRecipe(userId: Int, recipeSlug: String)
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,7 @@ import gq.kirmanak.mealient.datasource.v0.models.CreateApiTokenRequestV0
|
|||||||
import gq.kirmanak.mealient.datasource.v0.models.ErrorDetailV0
|
import gq.kirmanak.mealient.datasource.v0.models.ErrorDetailV0
|
||||||
import gq.kirmanak.mealient.datasource.v0.models.GetRecipeResponseV0
|
import gq.kirmanak.mealient.datasource.v0.models.GetRecipeResponseV0
|
||||||
import gq.kirmanak.mealient.datasource.v0.models.GetRecipeSummaryResponseV0
|
import gq.kirmanak.mealient.datasource.v0.models.GetRecipeSummaryResponseV0
|
||||||
|
import gq.kirmanak.mealient.datasource.v0.models.GetUserInfoResponseV0
|
||||||
import gq.kirmanak.mealient.datasource.v0.models.ParseRecipeURLRequestV0
|
import gq.kirmanak.mealient.datasource.v0.models.ParseRecipeURLRequestV0
|
||||||
import gq.kirmanak.mealient.datasource.v0.models.VersionResponseV0
|
import gq.kirmanak.mealient.datasource.v0.models.VersionResponseV0
|
||||||
import kotlinx.serialization.SerializationException
|
import kotlinx.serialization.SerializationException
|
||||||
@@ -49,7 +50,6 @@ class MealieDataSourceV0Impl @Inject constructor(
|
|||||||
override suspend fun getVersionInfo(): VersionResponseV0 = networkRequestWrapper.makeCall(
|
override suspend fun getVersionInfo(): VersionResponseV0 = networkRequestWrapper.makeCall(
|
||||||
block = { service.getVersion() },
|
block = { service.getVersion() },
|
||||||
logMethod = { "getVersionInfo" },
|
logMethod = { "getVersionInfo" },
|
||||||
logParameters = { "" },
|
|
||||||
).getOrElse {
|
).getOrElse {
|
||||||
throw when (it) {
|
throw when (it) {
|
||||||
is HttpException, is SerializationException -> NetworkError.NotMealie(it)
|
is HttpException, is SerializationException -> NetworkError.NotMealie(it)
|
||||||
@@ -90,4 +90,29 @@ class MealieDataSourceV0Impl @Inject constructor(
|
|||||||
logMethod = { "createApiToken" },
|
logMethod = { "createApiToken" },
|
||||||
logParameters = { "request = $request" }
|
logParameters = { "request = $request" }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
override suspend fun requestUserInfo(): GetUserInfoResponseV0 {
|
||||||
|
return networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
|
block = { service.getUserSelfInfo() },
|
||||||
|
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" }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,4 +40,19 @@ interface MealieServiceV0 {
|
|||||||
suspend fun createApiToken(
|
suspend fun createApiToken(
|
||||||
@Body request: CreateApiTokenRequestV0,
|
@Body request: CreateApiTokenRequestV0,
|
||||||
): String
|
): String
|
||||||
|
|
||||||
|
@GET("/api/users/self")
|
||||||
|
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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package gq.kirmanak.mealient.datasource.v0.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class GetUserInfoResponseV0(
|
||||||
|
@SerialName("id") val id: Int,
|
||||||
|
@SerialName("favoriteRecipes") val favoriteRecipes: List<String> = emptyList(),
|
||||||
|
)
|
||||||
@@ -5,6 +5,7 @@ import gq.kirmanak.mealient.datasource.v1.models.CreateApiTokenResponseV1
|
|||||||
import gq.kirmanak.mealient.datasource.v1.models.CreateRecipeRequestV1
|
import gq.kirmanak.mealient.datasource.v1.models.CreateRecipeRequestV1
|
||||||
import gq.kirmanak.mealient.datasource.v1.models.GetRecipeResponseV1
|
import gq.kirmanak.mealient.datasource.v1.models.GetRecipeResponseV1
|
||||||
import gq.kirmanak.mealient.datasource.v1.models.GetRecipeSummaryResponseV1
|
import gq.kirmanak.mealient.datasource.v1.models.GetRecipeSummaryResponseV1
|
||||||
|
import gq.kirmanak.mealient.datasource.v1.models.GetUserInfoResponseV1
|
||||||
import gq.kirmanak.mealient.datasource.v1.models.ParseRecipeURLRequestV1
|
import gq.kirmanak.mealient.datasource.v1.models.ParseRecipeURLRequestV1
|
||||||
import gq.kirmanak.mealient.datasource.v1.models.UpdateRecipeRequestV1
|
import gq.kirmanak.mealient.datasource.v1.models.UpdateRecipeRequestV1
|
||||||
import gq.kirmanak.mealient.datasource.v1.models.VersionResponseV1
|
import gq.kirmanak.mealient.datasource.v1.models.VersionResponseV1
|
||||||
@@ -47,4 +48,10 @@ interface MealieDataSourceV1 {
|
|||||||
suspend fun createApiToken(
|
suspend fun createApiToken(
|
||||||
request: CreateApiTokenRequestV1,
|
request: CreateApiTokenRequestV1,
|
||||||
): CreateApiTokenResponseV1
|
): CreateApiTokenResponseV1
|
||||||
|
|
||||||
|
suspend fun requestUserInfo(): GetUserInfoResponseV1
|
||||||
|
|
||||||
|
suspend fun removeFavoriteRecipe(userId: String, recipeSlug: String)
|
||||||
|
|
||||||
|
suspend fun addFavoriteRecipe(userId: String, recipeSlug: String)
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@ import gq.kirmanak.mealient.datasource.v1.models.CreateRecipeRequestV1
|
|||||||
import gq.kirmanak.mealient.datasource.v1.models.ErrorDetailV1
|
import gq.kirmanak.mealient.datasource.v1.models.ErrorDetailV1
|
||||||
import gq.kirmanak.mealient.datasource.v1.models.GetRecipeResponseV1
|
import gq.kirmanak.mealient.datasource.v1.models.GetRecipeResponseV1
|
||||||
import gq.kirmanak.mealient.datasource.v1.models.GetRecipeSummaryResponseV1
|
import gq.kirmanak.mealient.datasource.v1.models.GetRecipeSummaryResponseV1
|
||||||
|
import gq.kirmanak.mealient.datasource.v1.models.GetUserInfoResponseV1
|
||||||
import gq.kirmanak.mealient.datasource.v1.models.ParseRecipeURLRequestV1
|
import gq.kirmanak.mealient.datasource.v1.models.ParseRecipeURLRequestV1
|
||||||
import gq.kirmanak.mealient.datasource.v1.models.UpdateRecipeRequestV1
|
import gq.kirmanak.mealient.datasource.v1.models.UpdateRecipeRequestV1
|
||||||
import gq.kirmanak.mealient.datasource.v1.models.VersionResponseV1
|
import gq.kirmanak.mealient.datasource.v1.models.VersionResponseV1
|
||||||
@@ -60,7 +61,6 @@ class MealieDataSourceV1Impl @Inject constructor(
|
|||||||
override suspend fun getVersionInfo(): VersionResponseV1 = networkRequestWrapper.makeCall(
|
override suspend fun getVersionInfo(): VersionResponseV1 = networkRequestWrapper.makeCall(
|
||||||
block = { service.getVersion() },
|
block = { service.getVersion() },
|
||||||
logMethod = { "getVersionInfo" },
|
logMethod = { "getVersionInfo" },
|
||||||
logParameters = { "" },
|
|
||||||
).getOrElse {
|
).getOrElse {
|
||||||
throw when (it) {
|
throw when (it) {
|
||||||
is HttpException, is SerializationException -> NetworkError.NotMealie(it)
|
is HttpException, is SerializationException -> NetworkError.NotMealie(it)
|
||||||
@@ -101,5 +101,30 @@ class MealieDataSourceV1Impl @Inject constructor(
|
|||||||
logMethod = { "createApiToken" },
|
logMethod = { "createApiToken" },
|
||||||
logParameters = { "request = $request" }
|
logParameters = { "request = $request" }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
override suspend fun requestUserInfo(): GetUserInfoResponseV1 {
|
||||||
|
return networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
|
block = { service.getUserSelfInfo() },
|
||||||
|
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" }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,4 +46,19 @@ interface MealieServiceV1 {
|
|||||||
suspend fun createApiToken(
|
suspend fun createApiToken(
|
||||||
@Body request: CreateApiTokenRequestV1,
|
@Body request: CreateApiTokenRequestV1,
|
||||||
): CreateApiTokenResponseV1
|
): CreateApiTokenResponseV1
|
||||||
|
|
||||||
|
@GET("/api/users/self")
|
||||||
|
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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package gq.kirmanak.mealient.datasource.v1.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class GetUserInfoResponseV1(
|
||||||
|
@SerialName("id") val id: String,
|
||||||
|
@SerialName("favoriteRecipes") val favoriteRecipes: List<String> = emptyList(),
|
||||||
|
)
|
||||||
@@ -15,6 +15,7 @@ android {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":logging"))
|
implementation(project(":logging"))
|
||||||
|
implementation(project(":architecture"))
|
||||||
|
|
||||||
implementation(libs.google.dagger.hiltAndroid)
|
implementation(libs.google.dagger.hiltAndroid)
|
||||||
kapt(libs.google.dagger.hiltCompiler)
|
kapt(libs.google.dagger.hiltCompiler)
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package gq.kirmanak.mealient.test
|
package gq.kirmanak.mealient.test
|
||||||
|
|
||||||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||||
|
import gq.kirmanak.mealient.architecture.configuration.AppDispatchers
|
||||||
import gq.kirmanak.mealient.logging.Logger
|
import gq.kirmanak.mealient.logging.Logger
|
||||||
import io.mockk.MockKAnnotations
|
import io.mockk.MockKAnnotations
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
import kotlinx.coroutines.test.resetMain
|
import kotlinx.coroutines.test.resetMain
|
||||||
import kotlinx.coroutines.test.setMain
|
import kotlinx.coroutines.test.setMain
|
||||||
@@ -20,10 +23,18 @@ open class BaseUnitTest {
|
|||||||
|
|
||||||
protected val logger: Logger = FakeLogger()
|
protected val logger: Logger = FakeLogger()
|
||||||
|
|
||||||
|
lateinit var dispatchers: AppDispatchers
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
open fun setUp() {
|
open fun setUp() {
|
||||||
MockKAnnotations.init(this)
|
MockKAnnotations.init(this)
|
||||||
Dispatchers.setMain(UnconfinedTestDispatcher())
|
Dispatchers.setMain(UnconfinedTestDispatcher())
|
||||||
|
dispatchers = object : AppDispatchers {
|
||||||
|
override val io: CoroutineDispatcher = StandardTestDispatcher()
|
||||||
|
override val main: CoroutineDispatcher = StandardTestDispatcher()
|
||||||
|
override val default: CoroutineDispatcher = StandardTestDispatcher()
|
||||||
|
override val unconfined: CoroutineDispatcher = StandardTestDispatcher()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
|
|||||||
Reference in New Issue
Block a user