diff --git a/app/src/main/java/gq/kirmanak/mealient/data/network/MealieDataSourceWrapper.kt b/app/src/main/java/gq/kirmanak/mealient/data/network/MealieDataSourceWrapper.kt index 3731ca9..5147cf0 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/network/MealieDataSourceWrapper.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/network/MealieDataSourceWrapper.kt @@ -64,4 +64,30 @@ class MealieDataSourceWrapper @Inject constructor( ServerVersion.V1 -> v1Source.parseRecipeFromURL(parseRecipeURLInfo.toV1Request()) } + override suspend fun getFavoriteRecipes(): List = 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) + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/RecipeRepo.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/RecipeRepo.kt index b7c1266..d8fdddd 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/RecipeRepo.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/RecipeRepo.kt @@ -17,4 +17,6 @@ interface RecipeRepo { fun updateNameQuery(name: String?) suspend fun refreshRecipes() + + suspend fun updateIsRecipeFavorite(recipeSlug: String, isFavorite: Boolean): Result } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/db/RecipeStorage.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/db/RecipeStorage.kt index 324282d..171d8ab 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/db/RecipeStorage.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/db/RecipeStorage.kt @@ -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) + suspend fun saveRecipes(recipes: List) fun queryRecipes(query: String?): PagingSource - suspend fun refreshAll(recipes: List) + suspend fun refreshAll(recipes: List) suspend fun clearAllLocalData() suspend fun saveRecipeInfo(recipe: FullRecipeInfo) suspend fun queryRecipeInfo(recipeId: String): FullRecipeEntity? + + suspend fun updateFavoriteRecipes(favorites: List) } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/db/RecipeStorageImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/db/RecipeStorageImpl.kt index 2b25783..70fa579 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/db/RecipeStorageImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/db/RecipeStorageImpl.kt @@ -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) { + override suspend fun saveRecipes(recipes: List) { 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 { @@ -36,7 +32,7 @@ class RecipeStorageImpl @Inject constructor( else recipeDao.queryRecipesByPages(query) } - override suspend fun refreshAll(recipes: List) { + override suspend fun refreshAll(recipes: List) { 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) { + logger.v { "updateFavoriteRecipes() called with: favorites = $favorites" } + db.withTransaction { + recipeDao.setFavorite(favorites) + recipeDao.setNonFavorite(favorites) + } + } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImpl.kt index 542cd18..8a50ce0 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImpl.kt @@ -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 = 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 diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediator.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediator.kt index 54915d5..16f206f 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediator.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediator.kt @@ -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() { @VisibleForTesting var lastRequestEnd: Int = 0 override suspend fun load( - loadType: LoadType, - state: PagingState + loadType: LoadType, state: PagingState ): 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() + } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeDataSource.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeDataSource.kt index 4305eb5..edd9c9e 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeDataSource.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeDataSource.kt @@ -4,4 +4,8 @@ interface RecipeDataSource { suspend fun requestRecipes(start: Int, limit: Int): List suspend fun requestRecipeInfo(slug: String): FullRecipeInfo + + suspend fun getFavoriteRecipes(): List + + suspend fun updateIsRecipeFavorite(recipeSlug: String, isFavorite: Boolean) } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/extensions/ModelMappings.kt b/app/src/main/java/gq/kirmanak/mealient/extensions/ModelMappings.kt index 2f8263c..13eed03 100644 --- a/app/src/main/java/gq/kirmanak/mealient/extensions/ModelMappings.kt +++ b/app/src/main/java/gq/kirmanak/mealient/extensions/ModelMappings.kt @@ -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) diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewHolder.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewHolder.kt index 898bf5b..12496c1 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewHolder.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewHolder.kt @@ -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) } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListFragment.kt index 687f258..2df441c 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListFragment.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListFragment.kt @@ -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) } diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListViewModel.kt index 2536f8a..f120965 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListViewModel.kt @@ -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) } + } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesPagingAdapter.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesPagingAdapter.kt index cde6fb9..2a15df8 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesPagingAdapter.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesPagingAdapter.kt @@ -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(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() { 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 } } diff --git a/app/src/main/res/drawable/ic_favorite_filled.xml b/app/src/main/res/drawable/ic_favorite_filled.xml new file mode 100644 index 0000000..4f40d7a --- /dev/null +++ b/app/src/main/res/drawable/ic_favorite_filled.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_favorite_unfilled.xml b/app/src/main/res/drawable/ic_favorite_unfilled.xml new file mode 100644 index 0000000..c258d40 --- /dev/null +++ b/app/src/main/res/drawable/ic_favorite_unfilled.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/view_holder_recipe.xml b/app/src/main/res/layout/view_holder_recipe.xml index a4152b5..2121cfd 100644 --- a/app/src/main/res/layout/view_holder_recipe.xml +++ b/app/src/main/res/layout/view_holder_recipe.xml @@ -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" /> + + \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 3d341a7..2b87f60 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -47,6 +47,7 @@ неожиданный ответ нет соединения Ошибка загрузки. + Не удалось обновить статус избранного Сменить URL Найти рецепты Открыть меню навигации @@ -54,4 +55,6 @@ Рецепт успешно сохранен. Что-то пошло не так. Индикатор прогресса + Добавлен в избранное + Не добавлен в избранное \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c0e1fcf..bb69c91 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -49,6 +49,7 @@ unauthorized unexpected response no connection + Favorite status update failed Change URL Search recipes @string/app_name @@ -57,4 +58,6 @@ Recipe saved successfully. Something went wrong. Progress indicator + Item is favorite + Item is not favorite \ No newline at end of file diff --git a/app/src/test/java/gq/kirmanak/mealient/data/network/MealieDataSourceWrapperTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/network/MealieDataSourceWrapperTest.kt index 418933f..d1de8af 100644 --- a/app/src/test/java/gq/kirmanak/mealient/data/network/MealieDataSourceWrapperTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/data/network/MealieDataSourceWrapperTest.kt @@ -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) + } } \ No newline at end of file diff --git a/app/src/test/java/gq/kirmanak/mealient/data/recipes/db/RecipeStorageImplTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/recipes/db/RecipeStorageImplTest.kt index 0d3db50..6cfb6f0 100644 --- a/app/src/test/java/gq/kirmanak/mealient/data/recipes/db/RecipeStorageImplTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/data/recipes/db/RecipeStorageImplTest.kt @@ -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) diff --git a/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipePagingSourceFactoryImplTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipePagingSourceFactoryImplTest.kt index d9ec3b9..e5374aa 100644 --- a/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipePagingSourceFactoryImplTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipePagingSourceFactoryImplTest.kt @@ -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) } diff --git a/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoTest.kt index 20c4568..fe98583 100644 --- a/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoTest.kt @@ -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)) } + } } \ No newline at end of file diff --git a/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediatorTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediatorTest.kt index ed693f6..59a742c 100644 --- a/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediatorTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediatorTest.kt @@ -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( diff --git a/app/src/test/java/gq/kirmanak/mealient/extensions/ModelMappingsTest.kt b/app/src/test/java/gq/kirmanak/mealient/extensions/ModelMappingsTest.kt index 8b6bd81..578af8b 100644 --- a/app/src/test/java/gq/kirmanak/mealient/extensions/ModelMappingsTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/extensions/ModelMappingsTest.kt @@ -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) } diff --git a/app/src/test/java/gq/kirmanak/mealient/test/AuthImplTestData.kt b/app/src/test/java/gq/kirmanak/mealient/test/AuthImplTestData.kt index a613a84..cc8bb47 100644 --- a/app/src/test/java/gq/kirmanak/mealient/test/AuthImplTestData.kt +++ b/app/src/test/java/gq/kirmanak/mealient/test/AuthImplTestData.kt @@ -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) } \ No newline at end of file diff --git a/app/src/test/java/gq/kirmanak/mealient/test/RecipeImplTestData.kt b/app/src/test/java/gq/kirmanak/mealient/test/RecipeImplTestData.kt index 3409031..a80ff6a 100644 --- a/app/src/test/java/gq/kirmanak/mealient/test/RecipeImplTestData.kt +++ b/app/src/test/java/gq/kirmanak/mealient/test/RecipeImplTestData.kt @@ -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 = diff --git a/architecture/src/main/kotlin/gq/kirmanak/mealient/architecture/configuration/AppDispatchers.kt b/architecture/src/main/kotlin/gq/kirmanak/mealient/architecture/configuration/AppDispatchers.kt new file mode 100644 index 0000000..81cb800 --- /dev/null +++ b/architecture/src/main/kotlin/gq/kirmanak/mealient/architecture/configuration/AppDispatchers.kt @@ -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 +} \ No newline at end of file diff --git a/architecture/src/main/kotlin/gq/kirmanak/mealient/architecture/configuration/AppDispatchersImpl.kt b/architecture/src/main/kotlin/gq/kirmanak/mealient/architecture/configuration/AppDispatchersImpl.kt new file mode 100644 index 0000000..69680da --- /dev/null +++ b/architecture/src/main/kotlin/gq/kirmanak/mealient/architecture/configuration/AppDispatchersImpl.kt @@ -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 +} \ No newline at end of file diff --git a/architecture/src/main/kotlin/gq/kirmanak/mealient/architecture/configuration/ArchitectureModule.kt b/architecture/src/main/kotlin/gq/kirmanak/mealient/architecture/configuration/ArchitectureModule.kt new file mode 100644 index 0000000..2b445b4 --- /dev/null +++ b/architecture/src/main/kotlin/gq/kirmanak/mealient/architecture/configuration/ArchitectureModule.kt @@ -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 +} \ No newline at end of file diff --git a/database/schemas/gq.kirmanak.mealient.database.AppDb/8.json b/database/schemas/gq.kirmanak.mealient.database.AppDb/8.json new file mode 100644 index 0000000..735d017 --- /dev/null +++ b/database/schemas/gq.kirmanak.mealient.database.AppDb/8.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/database/src/main/kotlin/gq/kirmanak/mealient/database/AppDb.kt b/database/src/main/kotlin/gq/kirmanak/mealient/database/AppDb.kt index e801675..635e776 100644 --- a/database/src/main/kotlin/gq/kirmanak/mealient/database/AppDb.kt +++ b/database/src/main/kotlin/gq/kirmanak/mealient/database/AppDb.kt @@ -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) diff --git a/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/RecipeDao.kt b/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/RecipeDao.kt index eaea4e9..29d74e8 100644 --- a/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/RecipeDao.kt +++ b/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/RecipeDao.kt @@ -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) + + @Query("UPDATE recipe_summaries SET is_favorite = 0 WHERE slug NOT IN (:favorites)") + suspend fun setNonFavorite(favorites: List) } \ No newline at end of file diff --git a/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/entity/RecipeSummaryEntity.kt b/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/entity/RecipeSummaryEntity.kt index afbc99d..cf54bfa 100644 --- a/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/entity/RecipeSummaryEntity.kt +++ b/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/entity/RecipeSummaryEntity.kt @@ -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, ) \ No newline at end of file diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/NetworkRequestWrapper.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/NetworkRequestWrapper.kt index 8a8dd01..aaf9323 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/NetworkRequestWrapper.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/NetworkRequestWrapper.kt @@ -5,13 +5,13 @@ interface NetworkRequestWrapper { suspend fun makeCall( block: suspend () -> T, logMethod: () -> String, - logParameters: () -> String, + logParameters: (() -> String)? = null, ): Result suspend fun makeCallAndHandleUnauthorized( block: suspend () -> T, logMethod: () -> String, - logParameters: () -> String, + logParameters: (() -> String)? = null, ): T } \ No newline at end of file diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/NetworkRequestWrapperImpl.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/NetworkRequestWrapperImpl.kt index 6c2b5b2..c19f01b 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/NetworkRequestWrapperImpl.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/NetworkRequestWrapperImpl.kt @@ -16,18 +16,40 @@ class NetworkRequestWrapperImpl @Inject constructor( override suspend fun makeCall( block: suspend () -> T, logMethod: () -> String, - logParameters: () -> String, + logParameters: (() -> String)?, ): Result { - 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 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) diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieDataSourceV0.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieDataSourceV0.kt index 01aeac1..2358c64 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieDataSourceV0.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieDataSourceV0.kt @@ -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) } \ No newline at end of file diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieDataSourceV0Impl.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieDataSourceV0Impl.kt index 7d145be..146b9af 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieDataSourceV0Impl.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieDataSourceV0Impl.kt @@ -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" } + ) } diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieServiceV0.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieServiceV0.kt index a5626f0..c3359a4 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieServiceV0.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieServiceV0.kt @@ -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 + ) } \ No newline at end of file diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/models/GetUserInfoResponseV0.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/models/GetUserInfoResponseV0.kt new file mode 100644 index 0000000..005ccf1 --- /dev/null +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/models/GetUserInfoResponseV0.kt @@ -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 = emptyList(), +) diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1.kt index 332000e..482974f 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1.kt @@ -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) } \ No newline at end of file diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1Impl.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1Impl.kt index 18105f0..6c0b094 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1Impl.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1Impl.kt @@ -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" } + ) } diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieServiceV1.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieServiceV1.kt index a91ef39..d1dd7cb 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieServiceV1.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieServiceV1.kt @@ -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 + ) } \ No newline at end of file diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/models/GetUserInfoResponseV1.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/models/GetUserInfoResponseV1.kt new file mode 100644 index 0000000..ee97e60 --- /dev/null +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/models/GetUserInfoResponseV1.kt @@ -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 = emptyList(), +) diff --git a/testing/build.gradle.kts b/testing/build.gradle.kts index 4129da2..83889f8 100644 --- a/testing/build.gradle.kts +++ b/testing/build.gradle.kts @@ -15,6 +15,7 @@ android { dependencies { implementation(project(":logging")) + implementation(project(":architecture")) implementation(libs.google.dagger.hiltAndroid) kapt(libs.google.dagger.hiltCompiler) diff --git a/testing/src/main/kotlin/gq/kirmanak/mealient/test/BaseUnitTest.kt b/testing/src/main/kotlin/gq/kirmanak/mealient/test/BaseUnitTest.kt index 0526aea..aabb40c 100644 --- a/testing/src/main/kotlin/gq/kirmanak/mealient/test/BaseUnitTest.kt +++ b/testing/src/main/kotlin/gq/kirmanak/mealient/test/BaseUnitTest.kt @@ -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