From 9ab86e7be3f24663979c7de05be4ba04a89ff7b6 Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Wed, 14 Dec 2022 22:49:52 +0100 Subject: [PATCH 1/8] Initialize recipe removal feature --- .../data/network/MealieDataSourceWrapper.kt | 5 +++++ .../mealient/data/recipes/RecipeRepo.kt | 2 ++ .../data/recipes/impl/RecipeRepoImpl.kt | 8 ++++++++ .../data/recipes/network/RecipeDataSource.kt | 2 ++ .../mealient/ui/recipes/RecipeViewHolder.kt | 9 +++++++++ .../mealient/ui/recipes/RecipesListFragment.kt | 13 +++++++++++++ .../mealient/ui/recipes/RecipesListViewModel.kt | 5 +++++ app/src/main/res/drawable/ic_delete.xml | 10 ++++++++++ app/src/main/res/layout/view_holder_recipe.xml | 17 ++++++++++++++--- app/src/main/res/values/strings.xml | 2 ++ .../datasource/v0/MealieDataSourceV0.kt | 2 ++ .../datasource/v0/MealieDataSourceV0Impl.kt | 8 ++++++++ .../mealient/datasource/v0/MealieServiceV0.kt | 5 +++++ .../datasource/v1/MealieDataSourceV1.kt | 1 + .../datasource/v1/MealieDataSourceV1Impl.kt | 8 ++++++++ .../mealient/datasource/v1/MealieServiceV1.kt | 5 +++++ 16 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 app/src/main/res/drawable/ic_delete.xml 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 5147cf0..1c75553 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 @@ -90,4 +90,9 @@ class MealieDataSourceWrapper @Inject constructor( } } } + + override suspend fun deleteRecipe(recipeSlug: String) = when (getVersion()) { + ServerVersion.V0 -> v0Source.deleteRecipe(recipeSlug) + ServerVersion.V1 -> v1Source.deleteRecipe(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 d8fdddd..77c2b07 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 @@ -19,4 +19,6 @@ interface RecipeRepo { suspend fun refreshRecipes() suspend fun updateIsRecipeFavorite(recipeSlug: String, isFavorite: Boolean): Result + + suspend fun deleteRecipe(recipeSlug: String): Result } \ 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 8a50ce0..e61f134 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 @@ -83,6 +83,14 @@ class RecipeRepoImpl @Inject constructor( logger.e(it) { "Can't update recipe's is favorite status" } } + override suspend fun deleteRecipe(recipeSlug: String): Result = runCatchingExceptCancel { + logger.v { "deleteRecipe() called with: recipeSlug = $recipeSlug" } + dataSource.deleteRecipe(recipeSlug) + // TODO update local db + }.onFailure { + logger.e(it) { "Can't delete recipe" } + } + 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/network/RecipeDataSource.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeDataSource.kt index edd9c9e..5f9b2ce 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 @@ -8,4 +8,6 @@ interface RecipeDataSource { suspend fun getFavoriteRecipes(): List suspend fun updateIsRecipeFavorite(recipeSlug: String, isFavorite: Boolean) + + suspend fun deleteRecipe(recipeSlug: String) } \ No newline at end of file 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 12496c1..a4a7a1d 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 @@ -47,6 +47,10 @@ class RecipeViewHolder @AssistedInject constructor( override val recipeSummaryEntity: RecipeSummaryEntity ) : ClickEvent() + data class DeleteClick( + override val recipeSummaryEntity: RecipeSummaryEntity + ) : ClickEvent() + } private val loadingPlaceholder by lazy { @@ -62,6 +66,7 @@ class RecipeViewHolder @AssistedInject constructor( logger.d { "bind: item clicked $entity" } clickListener(ClickEvent.RecipeClick(entity)) } + binding.favoriteIcon.isVisible = showFavoriteIcon binding.favoriteIcon.setOnClickListener { clickListener(ClickEvent.FavoriteClick(entity)) @@ -80,6 +85,10 @@ class RecipeViewHolder @AssistedInject constructor( R.string.view_holder_recipe_non_favorite_content_description } ) + + binding.deleteIcon.setOnClickListener { + clickListener(ClickEvent.DeleteClick(item)) + } } } } 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 2df441c..bb1385e 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 @@ -100,6 +100,9 @@ class RecipesListFragment : Fragment(R.layout.fragment_recipes_list) { is RecipeViewHolder.ClickEvent.RecipeClick -> { onRecipeClicked(it.recipeSummaryEntity) } + is RecipeViewHolder.ClickEvent.DeleteClick -> { + onDeleteClick(it) + } } } @@ -139,6 +142,16 @@ class RecipesListFragment : Fragment(R.layout.fragment_recipes_list) { } } + private fun onDeleteClick(event: RecipeViewHolder.ClickEvent) { + logger.v { "onDeleteClick() called with: event = $event" } + viewModel.onDeleteConfirm(event.recipeSummaryEntity).observe(viewLifecycleOwner) { + logger.d { "onDeleteClick: result is $it" } + if (it.isFailure) { + showLongToast(R.string.fragment_recipes_delete_recipe_failed) + } + } + } + private fun onFavoriteClick(event: RecipeViewHolder.ClickEvent) { logger.v { "onFavoriteClick() called with: event = $event" } viewModel.onFavoriteIconClick(event.recipeSummaryEntity).observe(viewLifecycleOwner) { 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 f120965..a8a3b4a 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 @@ -49,4 +49,9 @@ class RecipesListViewModel @Inject constructor( isFavorite = recipeSummaryEntity.isFavorite.not(), ).also { emit(it) } } + + fun onDeleteConfirm(recipeSummaryEntity: RecipeSummaryEntity) = liveData { + logger.v { "onDeleteConfirm() called with: recipeSummaryEntity = $recipeSummaryEntity" } + recipeRepo.deleteRecipe(recipeSummaryEntity.slug).also { emit(it) } + } } \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml new file mode 100644 index 0000000..fa259ee --- /dev/null +++ b/app/src/main/res/drawable/ic_delete.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 2121cfd..85ba963 100644 --- a/app/src/main/res/layout/view_holder_recipe.xml +++ b/app/src/main/res/layout/view_holder_recipe.xml @@ -38,9 +38,8 @@ app:layout_constraintDimensionRatio="2:1" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/favorite_icon" + app:layout_constraintTop_toBottomOf="@id/delete_icon" app:layout_constraintVertical_chainStyle="packed" - app:layout_goneMarginTop="@dimen/margin_medium" app:shapeAppearance="?shapeAppearanceCornerMedium" tools:srcCompat="@drawable/placeholder_recipe" /> @@ -54,6 +53,18 @@ app:layout_constraintEnd_toEndOf="@id/image" app:layout_constraintTop_toTopOf="parent" tools:srcCompat="@drawable/ic_favorite_unfilled" - tools:visibility="gone" /> + tools:visibility="visible" /> + + \ 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 bb69c91..648d79a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -50,6 +50,7 @@ unexpected response no connection Favorite status update failed + Recipe removal failed Change URL Search recipes @string/app_name @@ -60,4 +61,5 @@ Progress indicator Item is favorite Item is not favorite + Delete recipe \ No newline at end of file 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 2358c64..e9fff8d 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 @@ -46,4 +46,6 @@ interface MealieDataSourceV0 { suspend fun removeFavoriteRecipe(userId: Int, recipeSlug: String) suspend fun addFavoriteRecipe(userId: Int, recipeSlug: String) + + suspend fun deleteRecipe(slug: 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 146b9af..f6eea02 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 @@ -115,4 +115,12 @@ class MealieDataSourceV0Impl @Inject constructor( logMethod = { "addFavoriteRecipe" }, logParameters = { "userId = $userId, recipeSlug = $recipeSlug" } ) + + override suspend fun deleteRecipe( + slug: String + ): Unit = networkRequestWrapper.makeCallAndHandleUnauthorized( + block = { service.deleteRecipe(slug) }, + logMethod = { "deleteRecipe" }, + logParameters = { "slug = $slug" } + ) } 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 c3359a4..aec47d2 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 @@ -55,4 +55,9 @@ interface MealieServiceV0 { @Path("userId") userId: Int, @Path("recipeSlug") recipeSlug: String ) + + @DELETE("/api/recipes/{slug}") + suspend fun deleteRecipe( + @Path("slug") slug: String + ) } \ No newline at end of file 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 482974f..5204405 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 @@ -54,4 +54,5 @@ interface MealieDataSourceV1 { suspend fun removeFavoriteRecipe(userId: String, recipeSlug: String) suspend fun addFavoriteRecipe(userId: String, recipeSlug: String) + suspend fun deleteRecipe(slug: 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 6c0b094..7191cbd 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 @@ -126,5 +126,13 @@ class MealieDataSourceV1Impl @Inject constructor( logMethod = { "addFavoriteRecipe" }, logParameters = { "userId = $userId, recipeSlug = $recipeSlug" } ) + + override suspend fun deleteRecipe( + slug: String + ): Unit = networkRequestWrapper.makeCallAndHandleUnauthorized( + block = { service.deleteRecipe(slug) }, + logMethod = { "deleteRecipe" }, + logParameters = { "slug = $slug" } + ) } 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 d1dd7cb..6321265 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 @@ -61,4 +61,9 @@ interface MealieServiceV1 { @Path("userId") userId: String, @Path("recipeSlug") recipeSlug: String ) + + @DELETE("/api/recipes/{slug}") + suspend fun deleteRecipe( + @Path("slug") slug: String + ) } \ No newline at end of file From 822fdf86e4925b434d0c2093c53eeff781b0b210 Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Fri, 16 Dec 2022 19:42:46 +0100 Subject: [PATCH 2/8] Show confirmation dialog before deleting recipe --- .../ui/recipes/RecipesListFragment.kt | 26 ++++++++++++++++--- app/src/main/res/values/strings.xml | 4 +++ 2 files changed, 26 insertions(+), 4 deletions(-) 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 bb1385e..9dd5ba0 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 @@ -1,6 +1,7 @@ package gq.kirmanak.mealient.ui.recipes import android.annotation.SuppressLint +import android.content.DialogInterface import android.os.Bundle import android.view.View import androidx.annotation.StringRes @@ -13,6 +14,7 @@ import androidx.paging.LoadState import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.RecyclerView import by.kirich1409.viewbindingdelegate.viewBinding +import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import gq.kirmanak.mealient.R import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity @@ -144,12 +146,28 @@ class RecipesListFragment : Fragment(R.layout.fragment_recipes_list) { private fun onDeleteClick(event: RecipeViewHolder.ClickEvent) { logger.v { "onDeleteClick() called with: event = $event" } - viewModel.onDeleteConfirm(event.recipeSummaryEntity).observe(viewLifecycleOwner) { - logger.d { "onDeleteClick: result is $it" } - if (it.isFailure) { - showLongToast(R.string.fragment_recipes_delete_recipe_failed) + val entity = event.recipeSummaryEntity + val message = getString( + R.string.fragment_recipes_delete_recipe_confirm_dialog_message, entity.name + ) + val onPositiveClick = DialogInterface.OnClickListener { _, _ -> + viewModel.onDeleteConfirm(entity).observe(viewLifecycleOwner) { + logger.d { "onDeleteClick: result is $it" } + if (it.isFailure) { + showLongToast(R.string.fragment_recipes_delete_recipe_failed) + } } } + val positiveBtnResId = R.string.fragment_recipes_delete_recipe_confirm_dialog_positive_btn + val titleResId = R.string.fragment_recipes_delete_recipe_confirm_dialog_title + val negativeBtnResId = R.string.fragment_recipes_delete_recipe_confirm_dialog_negative_btn + MaterialAlertDialogBuilder(requireContext()) + .setTitle(titleResId) + .setMessage(message) + .setPositiveButton(positiveBtnResId, onPositiveClick) + .setNegativeButton(negativeBtnResId) { _, _ -> } + .show() + } private fun onFavoriteClick(event: RecipeViewHolder.ClickEvent) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 648d79a..c3502d5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -51,6 +51,10 @@ no connection Favorite status update failed Recipe removal failed + Delete recipe + Are you sure you want to delete %1$s? This cannot be undone. + Confirm + Cancel Change URL Search recipes @string/app_name From 0c532f9d7a264133e1d59b20dbcc784273018382 Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Fri, 16 Dec 2022 19:43:59 +0100 Subject: [PATCH 3/8] Add missing Russian translations --- app/src/main/res/values-ru/strings.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 2b87f60..a48b0df 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -57,4 +57,10 @@ Индикатор прогресса Добавлен в избранное Не добавлен в избранное + Не удалось удалить рецепт + Удалить рецепт + Вы уверены, что хотите удалить %1$s? Удаление необратимо. + Удалить рецепт + Подтвердить + Отмена \ No newline at end of file From 878fc8f080fb99df162dbd8e3bdc284f34241735 Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Fri, 16 Dec 2022 20:06:44 +0100 Subject: [PATCH 4/8] Replace LiveData with Flow for delete result --- .../ui/recipes/RecipesListFragment.kt | 15 ++++----- .../ui/recipes/RecipesListViewModel.kt | 31 ++++++++++++++++--- 2 files changed, 34 insertions(+), 12 deletions(-) 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 9dd5ba0..6188633 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 @@ -57,9 +57,15 @@ class RecipesListFragment : Fragment(R.layout.fragment_recipes_list) { checkedMenuItemId = R.id.recipes_list ) } - viewModel.showFavoriteIcon.observe(viewLifecycleOwner) { showFavoriteIcon -> + collectWhenViewResumed(viewModel.showFavoriteIcon) { showFavoriteIcon -> setupRecipeAdapter(showFavoriteIcon) } + collectWhenViewResumed(viewModel.deleteRecipeResult) { + logger.d { "Delete recipe result is $it" } + if (it.isFailure) { + showLongToast(R.string.fragment_recipes_delete_recipe_failed) + } + } hideKeyboardOnScroll() } @@ -151,12 +157,7 @@ class RecipesListFragment : Fragment(R.layout.fragment_recipes_list) { R.string.fragment_recipes_delete_recipe_confirm_dialog_message, entity.name ) val onPositiveClick = DialogInterface.OnClickListener { _, _ -> - viewModel.onDeleteConfirm(entity).observe(viewLifecycleOwner) { - logger.d { "onDeleteClick: result is $it" } - if (it.isFailure) { - showLongToast(R.string.fragment_recipes_delete_recipe_failed) - } - } + viewModel.onDeleteConfirm(entity) } val positiveBtnResId = R.string.fragment_recipes_delete_recipe_confirm_dialog_positive_btn val titleResId = R.string.fragment_recipes_delete_recipe_confirm_dialog_title 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 a8a3b4a..028a27c 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,9 +2,9 @@ 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.PagingData import androidx.paging.cachedIn import dagger.hilt.android.lifecycle.HiltViewModel import gq.kirmanak.mealient.data.auth.AuthRepo @@ -12,8 +12,16 @@ 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.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel @@ -23,8 +31,18 @@ class RecipesListViewModel @Inject constructor( private val logger: Logger, ) : ViewModel() { - val pagingData = recipeRepo.createPager().flow.cachedIn(viewModelScope) - val showFavoriteIcon = authRepo.isAuthorizedFlow.asLiveData() + val pagingData: Flow> = recipeRepo.createPager().flow + .cachedIn(viewModelScope) + + val showFavoriteIcon: StateFlow = authRepo.isAuthorizedFlow + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + private val _deleteRecipeResult = MutableSharedFlow>( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val deleteRecipeResult: SharedFlow> get() = _deleteRecipeResult init { authRepo.isAuthorizedFlow.valueUpdatesOnly().onEach { hasAuthorized -> @@ -50,8 +68,11 @@ class RecipesListViewModel @Inject constructor( ).also { emit(it) } } - fun onDeleteConfirm(recipeSummaryEntity: RecipeSummaryEntity) = liveData { + fun onDeleteConfirm(recipeSummaryEntity: RecipeSummaryEntity) { logger.v { "onDeleteConfirm() called with: recipeSummaryEntity = $recipeSummaryEntity" } - recipeRepo.deleteRecipe(recipeSummaryEntity.slug).also { emit(it) } + viewModelScope.launch { + val result = recipeRepo.deleteRecipe(recipeSummaryEntity.slug) + _deleteRecipeResult.emit(result) + } } } \ No newline at end of file From 28fa19c988948ebb74bda18240e45a787791adbd Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Fri, 16 Dec 2022 20:07:55 +0100 Subject: [PATCH 5/8] Fix delete icon end margin --- app/src/main/res/layout/view_holder_recipe.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/layout/view_holder_recipe.xml b/app/src/main/res/layout/view_holder_recipe.xml index 85ba963..4cff421 100644 --- a/app/src/main/res/layout/view_holder_recipe.xml +++ b/app/src/main/res/layout/view_holder_recipe.xml @@ -65,6 +65,7 @@ app:layout_constraintBottom_toTopOf="@+id/image" app:layout_constraintEnd_toStartOf="@id/favorite_icon" app:layout_constraintTop_toTopOf="parent" + app:layout_goneMarginEnd="0dp" app:srcCompat="@drawable/ic_delete" /> \ No newline at end of file From 226097d096eb0dd91dc55d215a67ca46de4985dd Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Fri, 16 Dec 2022 20:24:40 +0100 Subject: [PATCH 6/8] Remove recipe from local db when deleted --- .../mealient/data/recipes/RecipeRepo.kt | 2 +- .../mealient/data/recipes/db/RecipeStorage.kt | 2 ++ .../data/recipes/db/RecipeStorageImpl.kt | 5 +++++ .../data/recipes/impl/RecipeRepoImpl.kt | 15 ++++++++----- .../recipes/impl/RecipesRemoteMediator.kt | 6 ------ .../ui/recipes/RecipesListViewModel.kt | 2 +- .../data/recipes/impl/RecipeRepoTest.kt | 12 ++++++++--- .../recipes/impl/RecipesRemoteMediatorTest.kt | 21 ------------------- .../mealient/database/recipe/RecipeDao.kt | 3 +++ 9 files changed, 31 insertions(+), 37 deletions(-) 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 77c2b07..4fd11dd 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 @@ -20,5 +20,5 @@ interface RecipeRepo { suspend fun updateIsRecipeFavorite(recipeSlug: String, isFavorite: Boolean): Result - suspend fun deleteRecipe(recipeSlug: String): Result + suspend fun deleteRecipe(entity: RecipeSummaryEntity): 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 171d8ab..e0f44e7 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 @@ -19,4 +19,6 @@ interface RecipeStorage { suspend fun queryRecipeInfo(recipeId: String): FullRecipeEntity? suspend fun updateFavoriteRecipes(favorites: List) + + suspend fun deleteRecipe(entity: RecipeSummaryEntity) } \ 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 70fa579..b59f234 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 @@ -80,4 +80,9 @@ class RecipeStorageImpl @Inject constructor( recipeDao.setNonFavorite(favorites) } } + + override suspend fun deleteRecipe(entity: RecipeSummaryEntity) { + logger.v { "deleteRecipeBySlug() called with: entity = $entity" } + recipeDao.deleteRecipe(entity) + } } \ 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 e61f134..dadbc78 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 @@ -78,15 +78,20 @@ class RecipeRepoImpl @Inject constructor( ): Result = runCatchingExceptCancel { logger.v { "updateIsRecipeFavorite() called with: recipeSlug = $recipeSlug, isFavorite = $isFavorite" } dataSource.updateIsRecipeFavorite(recipeSlug, isFavorite) - mediator.onFavoritesChange() + val favorites = dataSource.getFavoriteRecipes() + storage.updateFavoriteRecipes(favorites) + pagingSourceFactory.invalidate() }.onFailure { logger.e(it) { "Can't update recipe's is favorite status" } } - override suspend fun deleteRecipe(recipeSlug: String): Result = runCatchingExceptCancel { - logger.v { "deleteRecipe() called with: recipeSlug = $recipeSlug" } - dataSource.deleteRecipe(recipeSlug) - // TODO update local db + override suspend fun deleteRecipe( + entity: RecipeSummaryEntity + ): Result = runCatchingExceptCancel { + logger.v { "deleteRecipe() called with: entity = $entity" } + dataSource.deleteRecipe(entity.slug) + storage.deleteRecipe(entity) + pagingSourceFactory.invalidate() }.onFailure { logger.e(it) { "Can't delete recipe" } } 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 16f206f..1cd8d56 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 @@ -83,10 +83,4 @@ class RecipesRemoteMediator @Inject constructor( 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/ui/recipes/RecipesListViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListViewModel.kt index 028a27c..7efeaad 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 @@ -71,7 +71,7 @@ class RecipesListViewModel @Inject constructor( fun onDeleteConfirm(recipeSummaryEntity: RecipeSummaryEntity) { logger.v { "onDeleteConfirm() called with: recipeSummaryEntity = $recipeSummaryEntity" } viewModelScope.launch { - val result = recipeRepo.deleteRecipe(recipeSummaryEntity.slug) + val result = recipeRepo.deleteRecipe(recipeSummaryEntity) _deleteRecipeResult.emit(result) } } 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 fe98583..7c04429 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 @@ -70,19 +70,25 @@ class RecipeRepoTest : BaseUnitTest() { @Test fun `when remove favorite recipe expect correct sequence`() = runTest { + coEvery { dataSource.getFavoriteRecipes() } returns listOf("porridge") subject.updateIsRecipeFavorite("cake", false) coVerify { dataSource.updateIsRecipeFavorite(eq("cake"), eq(false)) - remoteMediator.onFavoritesChange() + dataSource.getFavoriteRecipes() + storage.updateFavoriteRecipes(eq(listOf("porridge"))) + pagingSourceFactory.invalidate() } } @Test fun `when add favorite recipe expect correct sequence`() = runTest { + coEvery { dataSource.getFavoriteRecipes() } returns listOf("porridge", "cake") subject.updateIsRecipeFavorite("porridge", true) coVerify { dataSource.updateIsRecipeFavorite(eq("porridge"), eq(true)) - remoteMediator.onFavoritesChange() + dataSource.getFavoriteRecipes() + storage.updateFavoriteRecipes(eq(listOf("porridge", "cake"))) + pagingSourceFactory.invalidate() } } @@ -92,7 +98,7 @@ class RecipeRepoTest : BaseUnitTest() { dataSource.updateIsRecipeFavorite(any(), any()) } throws Unauthorized(IOException()) subject.updateIsRecipeFavorite("porridge", true) - coVerify(inverse = true) { remoteMediator.onFavoritesChange() } + coVerify(inverse = true) { dataSource.getFavoriteRecipes() } } @Test 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 59a742c..a85475f 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 @@ -144,27 +144,6 @@ class RecipesRemoteMediatorTest : BaseUnitTest() { 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()) 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 29d74e8..e7ef953 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 @@ -46,4 +46,7 @@ interface RecipeDao { @Query("UPDATE recipe_summaries SET is_favorite = 0 WHERE slug NOT IN (:favorites)") suspend fun setNonFavorite(favorites: List) + + @Delete + suspend fun deleteRecipe(entity: RecipeSummaryEntity) } \ No newline at end of file From 9a3a30aca24f22062d1c06dfb804a22f6d4e3272 Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Fri, 16 Dec 2022 20:49:33 +0100 Subject: [PATCH 7/8] Add repo tests --- .../data/recipes/impl/RecipeRepoTest.kt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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 7c04429..2a4fb81 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 @@ -8,9 +8,11 @@ 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.CAKE_RECIPE_SUMMARY_ENTITY import gq.kirmanak.mealient.test.RecipeImplTestData.FULL_CAKE_INFO_ENTITY import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.coVerifyOrder import io.mockk.impl.annotations.MockK import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -106,4 +108,21 @@ class RecipeRepoTest : BaseUnitTest() { subject.refreshRecipes() coVerify { remoteMediator.updateRecipes(eq(0), eq(150), eq(LoadType.REFRESH)) } } + + @Test + fun `when delete recipe expect correct sequence`() = runTest { + subject.deleteRecipe(CAKE_RECIPE_SUMMARY_ENTITY) + coVerifyOrder { + dataSource.deleteRecipe(eq("cake")) + storage.deleteRecipe(eq(CAKE_RECIPE_SUMMARY_ENTITY)) + pagingSourceFactory.invalidate() + } + } + + @Test + fun `when delete recipe remotely fails expect it isn't deleted locally`() = runTest { + coEvery { dataSource.deleteRecipe(any()) } throws Unauthorized(IOException()) + subject.deleteRecipe(CAKE_RECIPE_SUMMARY_ENTITY) + coVerify(inverse = true) { storage.deleteRecipe(any()) } + } } \ No newline at end of file From c1a292851a2f6da05deba801bfb73880a7ba0adb Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Fri, 16 Dec 2022 21:19:19 +0100 Subject: [PATCH 8/8] Add ViewModel tests --- .../ui/recipes/RecipesListViewModel.kt | 1 + .../ui/recipes/RecipesListViewModelTest.kt | 51 +++++++++++++++++-- .../ui/share/ShareRecipeViewModelTest.kt | 5 -- .../gq/kirmanak/mealient/test/BaseUnitTest.kt | 15 +++--- 4 files changed, 57 insertions(+), 15 deletions(-) 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 7efeaad..f9ddddf 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 @@ -72,6 +72,7 @@ class RecipesListViewModel @Inject constructor( logger.v { "onDeleteConfirm() called with: recipeSummaryEntity = $recipeSummaryEntity" } viewModelScope.launch { val result = recipeRepo.deleteRecipe(recipeSummaryEntity) + logger.d { "onDeleteConfirm: delete result is $result" } _deleteRecipeResult.emit(result) } } diff --git a/app/src/test/java/gq/kirmanak/mealient/ui/recipes/RecipesListViewModelTest.kt b/app/src/test/java/gq/kirmanak/mealient/ui/recipes/RecipesListViewModelTest.kt index 3d9ccbb..cb071cd 100644 --- a/app/src/test/java/gq/kirmanak/mealient/ui/recipes/RecipesListViewModelTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/ui/recipes/RecipesListViewModelTest.kt @@ -5,15 +5,23 @@ import com.google.common.truth.Truth.assertThat import gq.kirmanak.mealient.data.auth.AuthRepo import gq.kirmanak.mealient.data.recipes.RecipeRepo import gq.kirmanak.mealient.test.BaseUnitTest +import gq.kirmanak.mealient.test.RecipeImplTestData.CAKE_RECIPE_SUMMARY_ENTITY import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest +import org.junit.Before import org.junit.Test +import java.io.IOException @OptIn(ExperimentalCoroutinesApi::class) class RecipesListViewModelTest : BaseUnitTest() { @@ -24,6 +32,12 @@ class RecipesListViewModelTest : BaseUnitTest() { @MockK(relaxed = true) lateinit var recipeRepo: RecipeRepo + @Before + override fun setUp() { + super.setUp() + every { authRepo.isAuthorizedFlow } returns flowOf(true) + } + @Test fun `when authRepo isAuthorized changes to true expect that recipes are refreshed`() { every { authRepo.isAuthorizedFlow } returns flowOf(false, true) @@ -40,14 +54,12 @@ class RecipesListViewModelTest : BaseUnitTest() { @Test fun `when authRepo isAuthorized doesn't change expect that recipes are not refreshed`() { - every { authRepo.isAuthorizedFlow } returns flowOf(true) createSubject() coVerify(inverse = true) { recipeRepo.refreshRecipes() } } @Test fun `when refreshRecipeInfo succeeds expect successful result`() = runTest { - every { authRepo.isAuthorizedFlow } returns flowOf(true) val slug = "cake" coEvery { recipeRepo.refreshRecipeInfo(eq(slug)) } returns Result.success(Unit) val actual = createSubject().refreshRecipeInfo(slug).asFlow().first() @@ -56,7 +68,6 @@ class RecipesListViewModelTest : BaseUnitTest() { @Test fun `when refreshRecipeInfo succeeds expect call to repo`() = runTest { - every { authRepo.isAuthorizedFlow } returns flowOf(true) val slug = "cake" coEvery { recipeRepo.refreshRecipeInfo(eq(slug)) } returns Result.success(Unit) createSubject().refreshRecipeInfo(slug).asFlow().first() @@ -65,7 +76,6 @@ class RecipesListViewModelTest : BaseUnitTest() { @Test fun `when refreshRecipeInfo fails expect result with error`() = runTest { - every { authRepo.isAuthorizedFlow } returns flowOf(true) val slug = "cake" val result = Result.failure(RuntimeException()) coEvery { recipeRepo.refreshRecipeInfo(eq(slug)) } returns result @@ -73,5 +83,38 @@ class RecipesListViewModelTest : BaseUnitTest() { assertThat(actual).isEqualTo(result) } + @Test + fun `when delete recipe expect successful result in flow`() = runTest { + coEvery { recipeRepo.deleteRecipe(any()) } returns Result.success(Unit) + val subject = createSubject() + val results = runTestAndCollectFlow(subject.deleteRecipeResult) { + subject.onDeleteConfirm(CAKE_RECIPE_SUMMARY_ENTITY) + } + assertThat(results.single().isSuccess).isTrue() + } + + @Test + fun `when delete recipe expect failed result in flow`() = runTest { + coEvery { recipeRepo.deleteRecipe(any()) } returns Result.failure(IOException()) + val subject = createSubject() + val results = runTestAndCollectFlow(subject.deleteRecipeResult) { + subject.onDeleteConfirm(CAKE_RECIPE_SUMMARY_ENTITY) + } + assertThat(results.single().isFailure).isTrue() + } + + private inline fun TestScope.runTestAndCollectFlow( + flow: Flow, + block: () -> Unit, + ): List { + val results = mutableListOf() + val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) { + flow.toList(results) + } + block() + collectJob.cancel() + return results + } + private fun createSubject() = RecipesListViewModel(recipeRepo, authRepo, logger) } \ No newline at end of file diff --git a/app/src/test/java/gq/kirmanak/mealient/ui/share/ShareRecipeViewModelTest.kt b/app/src/test/java/gq/kirmanak/mealient/ui/share/ShareRecipeViewModelTest.kt index 4046062..5767628 100644 --- a/app/src/test/java/gq/kirmanak/mealient/ui/share/ShareRecipeViewModelTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/ui/share/ShareRecipeViewModelTest.kt @@ -15,9 +15,7 @@ import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.runTest import org.junit.Before -import org.junit.Rule import org.junit.Test -import org.junit.rules.Timeout @OptIn(ExperimentalCoroutinesApi::class) class ShareRecipeViewModelTest : BaseUnitTest() { @@ -27,9 +25,6 @@ class ShareRecipeViewModelTest : BaseUnitTest() { lateinit var subject: ShareRecipeViewModel - @get:Rule - val timeoutRule: Timeout = Timeout.seconds(5) - @Before override fun setUp() { super.setUp() 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 aabb40c..50bc7b0 100644 --- a/testing/src/main/kotlin/gq/kirmanak/mealient/test/BaseUnitTest.kt +++ b/testing/src/main/kotlin/gq/kirmanak/mealient/test/BaseUnitTest.kt @@ -7,20 +7,23 @@ 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 import org.junit.After import org.junit.Before import org.junit.Rule +import org.junit.rules.Timeout @OptIn(ExperimentalCoroutinesApi::class) open class BaseUnitTest { - @get:Rule + @get:Rule(order = 0) val instantExecutorRule = InstantTaskExecutorRule() + @get:Rule(order = 1) + val timeoutRule: Timeout = Timeout.seconds(10) + protected val logger: Logger = FakeLogger() lateinit var dispatchers: AppDispatchers @@ -30,10 +33,10 @@ open class BaseUnitTest { 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() + override val io: CoroutineDispatcher = UnconfinedTestDispatcher() + override val main: CoroutineDispatcher = UnconfinedTestDispatcher() + override val default: CoroutineDispatcher = UnconfinedTestDispatcher() + override val unconfined: CoroutineDispatcher = UnconfinedTestDispatcher() } }