Merge pull request #118 from kirmanak/favorite-recipes

Add favorite icon to recipes
This commit is contained in:
Kirill Kamakin
2022-12-16 17:55:29 +01:00
committed by GitHub
44 changed files with 826 additions and 100 deletions

View File

@@ -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)
}
}
}
} }

View File

@@ -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>
} }

View File

@@ -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>)
} }

View File

@@ -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)
}
}
} }

View File

@@ -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

View File

@@ -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()
}
} }

View File

@@ -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)
} }

View File

@@ -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)

View File

@@ -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)
} }

View File

@@ -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) }

View File

@@ -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) }
}
} }

View File

@@ -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
} }
} }

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)
}
} }

View File

@@ -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)

View File

@@ -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)
} }

View File

@@ -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)) }
}
} }

View File

@@ -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(

View File

@@ -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)
} }

View File

@@ -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)
} }

View File

@@ -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 =

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View 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')"
]
}
}

View File

@@ -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)

View File

@@ -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>)
} }

View File

@@ -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,
) )

View File

@@ -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
} }

View File

@@ -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)

View File

@@ -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)
} }

View File

@@ -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" }
)
} }

View File

@@ -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
)
} }

View File

@@ -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(),
)

View File

@@ -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)
} }

View File

@@ -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" }
)
} }

View File

@@ -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
)
} }

View File

@@ -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(),
)

View File

@@ -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)

View File

@@ -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