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())
}
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?)
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 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.RecipeSummaryEntity
interface RecipeStorage {
suspend fun saveRecipes(recipes: List<RecipeSummaryInfo>)
suspend fun saveRecipes(recipes: List<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 saveRecipeInfo(recipe: FullRecipeInfo)
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.room.withTransaction
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.recipe.RecipeDao
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.toRecipeIngredientEntity
import gq.kirmanak.mealient.extensions.toRecipeInstructionEntity
import gq.kirmanak.mealient.extensions.toRecipeSummaryEntity
import gq.kirmanak.mealient.logging.Logger
import javax.inject.Inject
import javax.inject.Singleton
@@ -23,11 +21,9 @@ class RecipeStorageImpl @Inject constructor(
) : RecipeStorage {
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" }
val entities = recipes.map { it.toRecipeSummaryEntity() }
logger.v { "saveRecipes: entities = $entities" }
db.withTransaction { recipeDao.insertRecipes(entities) }
db.withTransaction { recipeDao.insertRecipes(recipes) }
}
override fun queryRecipes(query: String?): PagingSource<Int, RecipeSummaryEntity> {
@@ -36,7 +32,7 @@ class RecipeStorageImpl @Inject constructor(
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" }
db.withTransaction {
recipeDao.removeAllRecipes()
@@ -76,4 +72,12 @@ class RecipeStorageImpl @Inject constructor(
logger.v { "queryRecipeInfo() returned: $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() {
logger.v { "refreshRecipes() called" }
runCatchingExceptCancel {
storage.refreshAll(dataSource.requestRecipes(0, INITIAL_LOAD_PAGE_SIZE))
mediator.updateRecipes(0, INITIAL_LOAD_PAGE_SIZE)
}.onFailure {
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 {
private const val LOAD_PAGE_SIZE = 50
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.LoadType.PREPEND
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.network.RecipeDataSource
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
import gq.kirmanak.mealient.extensions.toRecipeSummaryEntity
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.Singleton
@@ -19,14 +24,14 @@ class RecipesRemoteMediator @Inject constructor(
private val network: RecipeDataSource,
private val pagingSourceFactory: RecipePagingSourceFactory,
private val logger: Logger,
private val dispatchers: AppDispatchers,
) : RemoteMediator<Int, RecipeSummaryEntity>() {
@VisibleForTesting
var lastRequestEnd: Int = 0
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, RecipeSummaryEntity>
loadType: LoadType, state: PagingState<Int, RecipeSummaryEntity>
): MediatorResult {
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 count: Int = runCatchingExceptCancel {
val recipes = network.requestRecipes(start, limit)
if (loadType == REFRESH) storage.refreshAll(recipes)
else storage.saveRecipes(recipes)
recipes.size
updateRecipes(start, limit, loadType)
}.getOrElse {
logger.e(it) { "load: can't load recipes" }
return MediatorResult.Error(it)
@@ -58,4 +60,33 @@ class RecipesRemoteMediator @Inject constructor(
lastRequestEnd = start + count
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 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,
)
fun RecipeSummaryInfo.toRecipeSummaryEntity() = RecipeSummaryEntity(
fun RecipeSummaryInfo.toRecipeSummaryEntity(isFavorite: Boolean) = RecipeSummaryEntity(
remoteId = remoteId,
name = name,
slug = slug,
@@ -88,6 +88,7 @@ fun RecipeSummaryInfo.toRecipeSummaryEntity() = RecipeSummaryEntity(
dateAdded = dateAdded,
dateUpdated = dateUpdated,
imageId = imageId,
isFavorite = isFavorite,
)
fun VersionResponseV0.toVersionInfo() = VersionInfo(version)

View File

@@ -1,32 +1,51 @@
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 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.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.databinding.ViewHolderRecipeBinding
import gq.kirmanak.mealient.extensions.resources
import gq.kirmanak.mealient.logging.Logger
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 binding: ViewHolderRecipeBinding,
@Assisted private val binding: ViewHolderRecipeBinding,
private val recipeImageLoader: RecipeImageLoader,
private val clickListener: (RecipeSummaryEntity) -> Unit,
@Assisted private val showFavoriteIcon: Boolean,
@Assisted private val clickListener: (ClickEvent) -> Unit,
) : RecyclerView.ViewHolder(binding.root) {
@Singleton
class Factory @Inject constructor(
private val logger: Logger,
) {
@FragmentScoped
@AssistedFactory
interface Factory {
fun build(
recipeImageLoader: RecipeImageLoader,
showFavoriteIcon: Boolean,
binding: ViewHolderRecipeBinding,
clickListener: (RecipeSummaryEntity) -> Unit,
) = RecipeViewHolder(logger, binding, recipeImageLoader, clickListener)
clickListener: (ClickEvent) -> Unit,
): 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 ->
binding.root.setOnClickListener {
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
)
}
setupRecipeAdapter()
viewModel.showFavoriteIcon.observe(viewLifecycleOwner) { showFavoriteIcon ->
setupRecipeAdapter(showFavoriteIcon)
}
hideKeyboardOnScroll()
}
@@ -87,10 +89,19 @@ class RecipesListFragment : Fragment(R.layout.fragment_recipes_list) {
return findNavController().currentDestination?.id != R.id.recipesListFragment
}
private fun setupRecipeAdapter() {
private fun setupRecipeAdapter(showFavoriteIcon: Boolean) {
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) {
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) {
logger.w(error) { "onLoadFailure() called" }
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.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.liveData
import androidx.lifecycle.viewModelScope
import androidx.paging.cachedIn
import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.auth.AuthRepo
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.logging.Logger
import kotlinx.coroutines.flow.launchIn
@@ -22,6 +24,7 @@ class RecipesListViewModel @Inject constructor(
) : ViewModel() {
val pagingData = recipeRepo.createPager().flow.cachedIn(viewModelScope)
val showFavoriteIcon = authRepo.isAuthorizedFlow.asLiveData()
init {
authRepo.isAuthorizedFlow.valueUpdatesOnly().onEach { hasAuthorized ->
@@ -38,4 +41,12 @@ class RecipesListViewModel @Inject constructor(
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 androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.scopes.FragmentScoped
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.databinding.ViewHolderRecipeBinding
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 recipeImageLoader: RecipeImageLoader,
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) {
@FragmentScoped
class Factory @Inject constructor(
private val logger: Logger,
private val recipeViewHolderFactory: RecipeViewHolder.Factory,
private val recipeImageLoader: RecipeImageLoader,
) {
@AssistedFactory
interface Factory {
fun build(clickListener: (RecipeSummaryEntity) -> Unit) = RecipesPagingAdapter(
logger,
recipeImageLoader,
recipeViewHolderFactory,
clickListener
)
fun build(
showFavoriteIcon: Boolean,
clickListener: (RecipeViewHolder.ClickEvent) -> Unit,
): RecipesPagingAdapter
}
override fun onBindViewHolder(holder: RecipeViewHolder, position: Int) {
@@ -43,18 +39,18 @@ class RecipesPagingAdapter private constructor(
logger.v { "onCreateViewHolder() called with: parent = $parent, viewType = $viewType" }
val inflater = LayoutInflater.from(parent.context)
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>() {
override fun areItemsTheSame(
oldItem: RecipeSummaryEntity,
newItem: RecipeSummaryEntity
newItem: RecipeSummaryEntity,
): Boolean = oldItem.remoteId == newItem.remoteId
override fun areContentsTheSame(
oldItem: RecipeSummaryEntity,
newItem: RecipeSummaryEntity
): Boolean = oldItem.name == newItem.name && oldItem.slug == newItem.slug
newItem: RecipeSummaryEntity,
): 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:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="9dp"
android:layout_marginBottom="5dp"
android:layout_marginVertical="@dimen/margin_small"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?textAppearanceHeadline6"
@@ -32,18 +31,29 @@
android:id="@+id/image"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="15dp"
android:layout_marginTop="20dp"
android:layout_marginEnd="13dp"
android:layout_marginHorizontal="@dimen/margin_medium"
android:contentDescription="@string/content_description_view_holder_recipe_image"
android:scaleType="centerCrop"
app:layout_constraintBottom_toTopOf="@+id/name"
app:layout_constraintDimensionRatio="2:1"
app:layout_constraintEnd_toEndOf="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_goneMarginTop="@dimen/margin_medium"
app:shapeAppearance="?shapeAppearanceCornerMedium"
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>
</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_no_connection">нет соединения</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="search_recipes_hint">Найти рецепты</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_failure_toast">Что-то пошло не так.</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>

View File

@@ -49,6 +49,7 @@
<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_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="search_recipes_hint">Search recipes</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_failure_toast">Something went wrong.</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>

View File

@@ -5,9 +5,12 @@ import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.datasource.v0.MealieDataSourceV0
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_SERVER_VERSION_V0
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.RecipeImplTestData.PORRIDGE_ADD_RECIPE_INFO
import gq.kirmanak.mealient.test.RecipeImplTestData.PORRIDGE_ADD_RECIPE_REQUEST_V0
@@ -36,10 +39,10 @@ class MealieDataSourceWrapperTest : BaseUnitTest() {
@MockK(relaxUnitFun = true)
lateinit var authRepo: AuthRepo
@MockK
@MockK(relaxUnitFun = true)
lateinit var v0Source: MealieDataSourceV0
@MockK
@MockK(relaxUnitFun = true)
lateinit var v1Source: MealieDataSourceV1
lateinit var subject: MealieDataSourceWrapper
@@ -48,14 +51,14 @@ class MealieDataSourceWrapperTest : BaseUnitTest() {
override fun setUp() {
super.setUp()
subject = MealieDataSourceWrapper(serverInfoRepo, v0Source, v1Source)
coEvery { v0Source.requestUserInfo() } returns USER_INFO_V0
coEvery { v1Source.requestUserInfo() } returns USER_INFO_V1
}
@Test
fun `when server version v1 expect requestRecipeInfo to call v1`() = runTest {
val slug = "porridge"
coEvery {
v1Source.requestRecipeInfo(eq(slug))
} returns PORRIDGE_RECIPE_RESPONSE_V1
coEvery { v1Source.requestRecipeInfo(eq(slug)) } returns PORRIDGE_RECIPE_RESPONSE_V1
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V1
coEvery { authRepo.getAuthHeader() } returns TEST_AUTH_HEADER
@@ -157,4 +160,70 @@ class MealieDataSourceWrapperTest : BaseUnitTest() {
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.PORRIDGE_FULL_RECIPE_INFO
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.RECIPE_SUMMARY_PORRIDGE_V0
import gq.kirmanak.mealient.test.RecipeImplTestData.TEST_RECIPE_SUMMARIES
import gq.kirmanak.mealient.test.RecipeImplTestData.TEST_RECIPE_SUMMARY_ENTITIES
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -34,7 +32,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
@Test
fun `when saveRecipes then saves recipes`() = runTest {
subject.saveRecipes(TEST_RECIPE_SUMMARIES)
subject.saveRecipes(TEST_RECIPE_SUMMARY_ENTITIES)
val actualTags = appDb.recipeDao().queryAllRecipes()
assertThat(actualTags).containsExactly(
CAKE_RECIPE_SUMMARY_ENTITY,
@@ -44,15 +42,15 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
@Test
fun `when refreshAll then old recipes aren't preserved`() = runTest {
subject.saveRecipes(TEST_RECIPE_SUMMARIES)
subject.refreshAll(listOf(RECIPE_SUMMARY_CAKE))
subject.saveRecipes(TEST_RECIPE_SUMMARY_ENTITIES)
subject.refreshAll(listOf(CAKE_RECIPE_SUMMARY_ENTITY))
val actual = appDb.recipeDao().queryAllRecipes()
assertThat(actual).containsExactly(CAKE_RECIPE_SUMMARY_ENTITY)
}
@Test
fun `when clearAllLocalData then recipes aren't preserved`() = runTest {
subject.saveRecipes(TEST_RECIPE_SUMMARIES)
subject.saveRecipes(TEST_RECIPE_SUMMARY_ENTITIES)
subject.clearAllLocalData()
val actual = appDb.recipeDao().queryAllRecipes()
assertThat(actual).isEmpty()
@@ -60,7 +58,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
@Test
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)
val actual = appDb.recipeDao().queryFullRecipeInfo("1")
assertThat(actual).isEqualTo(FULL_CAKE_INFO_ENTITY)
@@ -68,7 +66,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
@Test
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(PORRIDGE_FULL_RECIPE_INFO)
val actual = appDb.recipeDao().queryFullRecipeInfo("2")
@@ -77,7 +75,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
@Test
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)
val newRecipe = CAKE_FULL_RECIPE_INFO.copy(recipeIngredients = listOf(BREAD_INGREDIENT))
subject.saveRecipeInfo(newRecipe)
@@ -88,7 +86,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
@Test
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)
val newRecipe = CAKE_FULL_RECIPE_INFO.copy(recipeInstructions = listOf(MIX_INSTRUCTION))
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.RecipeImplTestData.CAKE_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 kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
@@ -27,28 +26,28 @@ class RecipePagingSourceFactoryImplTest : HiltRobolectricTest() {
@Test
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")
assertThat(queryRecipes()).isEqualTo(listOf(CAKE_RECIPE_SUMMARY_ENTITY))
}
@Test
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")
assertThat(queryRecipes()).isEqualTo(listOf(PORRIDGE_RECIPE_SUMMARY_ENTITY))
}
@Test
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")
assertThat(queryRecipes()).isEqualTo(TEST_RECIPE_SUMMARY_ENTITIES)
}
@Test
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)
assertThat(queryRecipes()).isEqualTo(TEST_RECIPE_SUMMARY_ENTITIES)
}

View File

@@ -1,9 +1,11 @@
package gq.kirmanak.mealient.data.recipes.impl
import androidx.paging.LoadType
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.data.recipes.db.RecipeStorage
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.RecipeImplTestData.CAKE_FULL_RECIPE_INFO
import gq.kirmanak.mealient.test.RecipeImplTestData.FULL_CAKE_INFO_ENTITY
@@ -15,6 +17,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import java.io.IOException
@OptIn(ExperimentalCoroutinesApi::class)
class RecipeRepoTest : BaseUnitTest() {
@@ -22,7 +25,7 @@ class RecipeRepoTest : BaseUnitTest() {
@MockK(relaxUnitFun = true)
lateinit var storage: RecipeStorage
@MockK
@MockK(relaxUnitFun = true)
lateinit var dataSource: RecipeDataSource
@MockK
@@ -64,4 +67,37 @@ class RecipeRepoTest : BaseUnitTest() {
subject.updateNameQuery("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.test.BaseUnitTest
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.coVerify
import io.mockk.impl.annotations.MockK
@@ -17,6 +18,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import java.io.IOException
@ExperimentalCoroutinesApi
@OptIn(ExperimentalPagingApi::class)
@@ -42,7 +44,14 @@ class RecipesRemoteMediatorTest : BaseUnitTest() {
@Before
override fun 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
@@ -70,7 +79,7 @@ class RecipesRemoteMediatorTest : BaseUnitTest() {
fun `when first load with refresh successful then recipes stored`() = runTest {
coEvery { dataSource.requestRecipes(eq(0), eq(6)) } returns TEST_RECIPE_SUMMARIES
subject.load(REFRESH, pagingState())
coVerify { storage.refreshAll(eq(TEST_RECIPE_SUMMARIES)) }
coVerify { storage.refreshAll(eq(TEST_RECIPE_SUMMARY_ENTITIES)) }
}
@Test
@@ -132,9 +141,35 @@ class RecipesRemoteMediatorTest : BaseUnitTest() {
subject.load(REFRESH, pagingState())
coEvery { dataSource.requestRecipes(any(), any()) } throws Unauthorized(RuntimeException())
subject.load(APPEND, pagingState())
coVerify {
storage.refreshAll(TEST_RECIPE_SUMMARIES)
coVerify { storage.refreshAll(TEST_RECIPE_SUMMARY_ENTITIES) }
}
@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(

View File

@@ -76,7 +76,7 @@ class ModelMappingsTest : BaseUnitTest() {
@Test
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)
}

View File

@@ -1,6 +1,8 @@
package gq.kirmanak.mealient.test
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 {
const val TEST_USERNAME = "TEST_USERNAME"
@@ -13,4 +15,8 @@ object AuthImplTestData {
const val TEST_VERSION = "v0.5.6"
val TEST_SERVER_VERSION_V0 = ServerVersion.V0
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"),
dateUpdated = LocalDateTime.parse("2021-11-13T15:30:13"),
imageId = "cake",
isFavorite = false,
)
val PORRIDGE_RECIPE_SUMMARY_ENTITY = RecipeSummaryEntity(
@@ -90,6 +91,7 @@ object RecipeImplTestData {
dateAdded = LocalDate.parse("2021-11-12"),
dateUpdated = LocalDateTime.parse("2021-10-13T17:35:23"),
imageId = "porridge",
isFavorite = false,
)
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.*
@Database(
version = 7,
version = 8,
entities = [
RecipeSummaryEntity::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 = 5, to = 6, spec = AppDb.From5To6Migration::class),
AutoMigration(from = 6, to = 7),
AutoMigration(from = 7, to = 8),
]
)
@TypeConverters(RoomTypeConverters::class)

View File

@@ -40,4 +40,10 @@ interface RecipeDao {
@Query("DELETE FROM recipe_instruction WHERE recipe_id = :recipeId")
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_updated") val dateUpdated: LocalDateTime,
@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(
block: suspend () -> T,
logMethod: () -> String,
logParameters: () -> String,
logParameters: (() -> String)? = null,
): Result<T>
suspend fun <T> makeCallAndHandleUnauthorized(
block: suspend () -> T,
logMethod: () -> String,
logParameters: () -> String,
logParameters: (() -> String)? = null,
): T
}

View File

@@ -16,18 +16,40 @@ class NetworkRequestWrapperImpl @Inject constructor(
override suspend fun <T> makeCall(
block: suspend () -> T,
logMethod: () -> String,
logParameters: () -> String,
logParameters: (() -> String)?,
): Result<T> {
logger.v { "${logMethod()} called with: ${logParameters()}" }
logger.v {
if (logParameters == null) {
"${logMethod()} called"
} else {
"${logMethod()} called with: ${logParameters()}"
}
}
return runCatchingExceptCancel { block() }
.onFailure { logger.e(it) { "${logMethod()} request failed with: ${logParameters()}" } }
.onSuccess { logger.d { "${logMethod()} request succeeded with ${logParameters()}, result = $it" } }
.onFailure {
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(
block: suspend () -> T,
logMethod: () -> String,
logParameters: () -> String
logParameters: (() -> String)?
): T = makeCall(block, logMethod, logParameters).getOrElse {
throw if (it is HttpException && it.code() in listOf(401, 403)) {
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.GetRecipeResponseV0
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.VersionResponseV0
@@ -21,8 +22,7 @@ interface MealieDataSourceV0 {
password: String,
): String
suspend fun getVersionInfo(
): VersionResponseV0
suspend fun getVersionInfo(): VersionResponseV0
suspend fun requestRecipes(
start: Int,
@@ -40,4 +40,10 @@ interface MealieDataSourceV0 {
suspend fun createApiToken(
request: CreateApiTokenRequestV0,
): 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.GetRecipeResponseV0
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.VersionResponseV0
import kotlinx.serialization.SerializationException
@@ -49,7 +50,6 @@ class MealieDataSourceV0Impl @Inject constructor(
override suspend fun getVersionInfo(): VersionResponseV0 = networkRequestWrapper.makeCall(
block = { service.getVersion() },
logMethod = { "getVersionInfo" },
logParameters = { "" },
).getOrElse {
throw when (it) {
is HttpException, is SerializationException -> NetworkError.NotMealie(it)
@@ -90,4 +90,29 @@ class MealieDataSourceV0Impl @Inject constructor(
logMethod = { "createApiToken" },
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(
@Body request: CreateApiTokenRequestV0,
): 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.GetRecipeResponseV1
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.UpdateRecipeRequestV1
import gq.kirmanak.mealient.datasource.v1.models.VersionResponseV1
@@ -47,4 +48,10 @@ interface MealieDataSourceV1 {
suspend fun createApiToken(
request: CreateApiTokenRequestV1,
): 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.GetRecipeResponseV1
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.UpdateRecipeRequestV1
import gq.kirmanak.mealient.datasource.v1.models.VersionResponseV1
@@ -60,7 +61,6 @@ class MealieDataSourceV1Impl @Inject constructor(
override suspend fun getVersionInfo(): VersionResponseV1 = networkRequestWrapper.makeCall(
block = { service.getVersion() },
logMethod = { "getVersionInfo" },
logParameters = { "" },
).getOrElse {
throw when (it) {
is HttpException, is SerializationException -> NetworkError.NotMealie(it)
@@ -101,5 +101,30 @@ class MealieDataSourceV1Impl @Inject constructor(
logMethod = { "createApiToken" },
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(
@Body request: CreateApiTokenRequestV1,
): 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 {
implementation(project(":logging"))
implementation(project(":architecture"))
implementation(libs.google.dagger.hiltAndroid)
kapt(libs.google.dagger.hiltCompiler)

View File

@@ -1,10 +1,13 @@
package gq.kirmanak.mealient.test
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import gq.kirmanak.mealient.architecture.configuration.AppDispatchers
import gq.kirmanak.mealient.logging.Logger
import io.mockk.MockKAnnotations
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
@@ -20,10 +23,18 @@ open class BaseUnitTest {
protected val logger: Logger = FakeLogger()
lateinit var dispatchers: AppDispatchers
@Before
open fun setUp() {
MockKAnnotations.init(this)
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