From ff38ce655db741a49d04c77f1d5be18e8028aeb7 Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Sat, 5 Nov 2022 13:27:16 +0100 Subject: [PATCH 1/8] Read navigation argumens in ViewModel --- .../ui/recipes/info/RecipeInfoFragment.kt | 3 -- .../ui/recipes/info/RecipeInfoViewModel.kt | 39 ++++++++++--------- 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoFragment.kt index 4d8c0ac..b58715f 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoFragment.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoFragment.kt @@ -7,7 +7,6 @@ import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible import androidx.fragment.app.viewModels -import androidx.navigation.fragment.navArgs import by.kirich1409.viewbindingdelegate.viewBinding import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialogFragment @@ -22,7 +21,6 @@ import javax.inject.Inject class RecipeInfoFragment : BottomSheetDialogFragment() { private val binding by viewBinding(FragmentRecipeInfoBinding::bind) - private val arguments by navArgs() private val viewModel by viewModels() private val ingredientsAdapter by lazy { recipeIngredientsAdapterFactory.build() } private val instructionsAdapter by lazy { recipeInstructionsAdapterFactory.build() } @@ -58,7 +56,6 @@ class RecipeInfoFragment : BottomSheetDialogFragment() { } with(viewModel) { - loadRecipeInfo(arguments.recipeId, arguments.recipeSlug) uiState.observe(viewLifecycleOwner, ::onUiStateChange) } } diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoViewModel.kt index 932ae33..fb73554 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoViewModel.kt @@ -1,39 +1,40 @@ package gq.kirmanak.mealient.ui.recipes.info import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope +import androidx.lifecycle.liveData import dagger.hilt.android.lifecycle.HiltViewModel import gq.kirmanak.mealient.data.recipes.RecipeRepo import gq.kirmanak.mealient.datasource.runCatchingExceptCancel import gq.kirmanak.mealient.logging.Logger -import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class RecipeInfoViewModel @Inject constructor( private val recipeRepo: RecipeRepo, private val logger: Logger, + savedStateHandle: SavedStateHandle, ) : ViewModel() { - private val _uiState = MutableLiveData(RecipeInfoUiState()) - val uiState: LiveData get() = _uiState + private val args = RecipeInfoFragmentArgs.fromSavedStateHandle(savedStateHandle) - fun loadRecipeInfo(recipeId: String, recipeSlug: String) { - logger.v { "loadRecipeInfo() called with: recipeId = $recipeId, recipeSlug = $recipeSlug" } - _uiState.value = RecipeInfoUiState() - viewModelScope.launch { - runCatchingExceptCancel { recipeRepo.loadRecipeInfo(recipeId, recipeSlug) } - .onSuccess { - logger.d { "loadRecipeInfo: received recipe info = $it" } - _uiState.value = RecipeInfoUiState( - areIngredientsVisible = it.recipeIngredients.isNotEmpty(), - areInstructionsVisible = it.recipeInstructions.isNotEmpty(), - recipeInfo = it, - ) - } - .onFailure { logger.e(it) { "loadRecipeInfo: can't load recipe info" } } + val uiState: LiveData = liveData { + logger.v { "Initializing UI state with args = $args" } + emit(RecipeInfoUiState()) + runCatchingExceptCancel { + recipeRepo.loadRecipeInfo(args.recipeId, args.recipeSlug) + }.onSuccess { + logger.d { "loadRecipeInfo: received recipe info = $it" } + val newState = RecipeInfoUiState( + areIngredientsVisible = it.recipeIngredients.isNotEmpty(), + areInstructionsVisible = it.recipeInstructions.isNotEmpty(), + recipeInfo = it, + ) + emit(newState) + }.onFailure { + logger.e(it) { "loadRecipeInfo: can't load recipe info" } } } + } From cc73f687510db12c9b764831655e54e3c46dd637 Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Sat, 5 Nov 2022 14:24:59 +0100 Subject: [PATCH 2/8] Fix jumping recipe info sheet --- .../mealient/data/recipes/RecipeRepo.kt | 2 ++ .../data/recipes/impl/RecipeRepoImpl.kt | 10 ++++++ .../mealient/ui/recipes/RecipeViewModel.kt | 19 ++++++++--- .../mealient/ui/recipes/RecipesFragment.kt | 32 +++++++++++-------- .../ui/recipes/info/RecipeInfoViewModel.kt | 15 +++------ app/src/main/res/layout/fragment_recipes.xml | 6 ++++ 6 files changed, 55 insertions(+), 29 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 4cd3b1b..1c200b1 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 @@ -10,4 +10,6 @@ interface RecipeRepo { suspend fun clearLocalData() suspend fun loadRecipeInfo(recipeId: String, recipeSlug: String): FullRecipeEntity + + suspend fun loadRecipeInfoFromDb(recipeId: String, recipeSlug: String): FullRecipeEntity? } \ 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 a48691f..c6412c7 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 @@ -49,4 +49,14 @@ class RecipeRepoImpl @Inject constructor( return storage.queryRecipeInfo(recipeId) } + + override suspend fun loadRecipeInfoFromDb( + recipeId: String, + recipeSlug: String + ): FullRecipeEntity? { + logger.v { "loadRecipeInfoFromDb() called with: recipeId = $recipeId, recipeSlug = $recipeSlug" } + return runCatchingExceptCancel { storage.queryRecipeInfo(recipeId) } + .onFailure { logger.e(it) { "loadRecipeInfoFromDb failed" } } + .getOrNull() + } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewModel.kt index 7e3a733..9fe0df0 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewModel.kt @@ -1,13 +1,13 @@ package gq.kirmanak.mealient.ui.recipes -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope +import androidx.lifecycle.* 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.FullRecipeEntity +import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity +import gq.kirmanak.mealient.datasource.runCatchingExceptCancel import gq.kirmanak.mealient.extensions.valueUpdatesOnly import gq.kirmanak.mealient.logging.Logger import kotlinx.coroutines.flow.launchIn @@ -16,7 +16,7 @@ import javax.inject.Inject @HiltViewModel class RecipeViewModel @Inject constructor( - recipeRepo: RecipeRepo, + private val recipeRepo: RecipeRepo, authRepo: AuthRepo, private val logger: Logger, ) : ViewModel() { @@ -37,4 +37,13 @@ class RecipeViewModel @Inject constructor( logger.v { "onAuthorizationSuccessHandled() called" } _isAuthorized.postValue(null) } + + fun loadRecipeInfo( + summaryEntity: RecipeSummaryEntity + ): LiveData> = liveData { + val result = runCatchingExceptCancel { + recipeRepo.loadRecipeInfo(summaryEntity.remoteId, summaryEntity.slug) + } + emit(result) + } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt index b0fe05b..37c9af8 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt @@ -3,6 +3,7 @@ package gq.kirmanak.mealient.ui.recipes import android.os.Bundle import android.view.View import androidx.annotation.StringRes +import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels @@ -16,10 +17,7 @@ import gq.kirmanak.mealient.R import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity import gq.kirmanak.mealient.databinding.FragmentRecipesBinding import gq.kirmanak.mealient.datasource.NetworkError -import gq.kirmanak.mealient.extensions.collectWhenViewResumed -import gq.kirmanak.mealient.extensions.refreshRequestFlow -import gq.kirmanak.mealient.extensions.showLongToast -import gq.kirmanak.mealient.extensions.valueUpdatesOnly +import gq.kirmanak.mealient.extensions.* import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.ui.activity.MainActivityViewModel import gq.kirmanak.mealient.ui.recipes.images.RecipePreloaderFactory @@ -45,6 +43,8 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) { @Inject lateinit var recipePreloaderFactory: RecipePreloaderFactory + private var ignoreRecipeClicks = false + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) logger.v { "onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState" } @@ -63,10 +63,22 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) { ) } + private fun onRecipeClicked(recipe: RecipeSummaryEntity) { + logger.d { "onRecipeClicked() called with: recipe = $recipe" } + if (ignoreRecipeClicks) return + binding.progress.isVisible = true + ignoreRecipeClicks = true // TODO doesn't really work + viewModel.loadRecipeInfo(recipe).observeOnce(viewLifecycleOwner) { result -> + binding.progress.isVisible = false + if (result.isSuccess) navigateToRecipeInfo(recipe) + ignoreRecipeClicks = false + } + } + private fun setupRecipeAdapter() { logger.v { "setupRecipeAdapter() called" } - val recipesAdapter = recipePagingAdapterFactory.build { navigateToRecipeInfo(it) } + val recipesAdapter = recipePagingAdapterFactory.build { onRecipeClicked(it) } with(binding.recipes) { adapter = recipesAdapter @@ -135,17 +147,11 @@ private fun Throwable.toLoadErrorReasonText(): Int? = when (this) { } private fun PagingDataAdapter.refreshErrors(): Flow { - return loadStateFlow - .map { it.refresh } - .valueUpdatesOnly() - .filterIsInstance() + return loadStateFlow.map { it.refresh }.valueUpdatesOnly().filterIsInstance() .map { it.error } } private fun PagingDataAdapter.appendPaginationEnd(): Flow { - return loadStateFlow - .map { it.append.endOfPaginationReached } - .valueUpdatesOnly() - .filter { it } + return loadStateFlow.map { it.append.endOfPaginationReached }.valueUpdatesOnly().filter { it } .map { } } diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoViewModel.kt index fb73554..6abe446 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoViewModel.kt @@ -6,7 +6,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.liveData import dagger.hilt.android.lifecycle.HiltViewModel import gq.kirmanak.mealient.data.recipes.RecipeRepo -import gq.kirmanak.mealient.datasource.runCatchingExceptCancel import gq.kirmanak.mealient.logging.Logger import javax.inject.Inject @@ -21,20 +20,14 @@ class RecipeInfoViewModel @Inject constructor( val uiState: LiveData = liveData { logger.v { "Initializing UI state with args = $args" } - emit(RecipeInfoUiState()) - runCatchingExceptCancel { - recipeRepo.loadRecipeInfo(args.recipeId, args.recipeSlug) - }.onSuccess { - logger.d { "loadRecipeInfo: received recipe info = $it" } - val newState = RecipeInfoUiState( + val state = recipeRepo.loadRecipeInfoFromDb(args.recipeId, args.recipeSlug)?.let { + RecipeInfoUiState( areIngredientsVisible = it.recipeIngredients.isNotEmpty(), areInstructionsVisible = it.recipeInstructions.isNotEmpty(), recipeInfo = it, ) - emit(newState) - }.onFailure { - logger.e(it) { "loadRecipeInfo: can't load recipe info" } - } + } ?: RecipeInfoUiState() + emit(state) } } diff --git a/app/src/main/res/layout/fragment_recipes.xml b/app/src/main/res/layout/fragment_recipes.xml index 878c94e..3a9f114 100644 --- a/app/src/main/res/layout/fragment_recipes.xml +++ b/app/src/main/res/layout/fragment_recipes.xml @@ -19,4 +19,10 @@ tools:listitem="@layout/view_holder_recipe" /> + + \ No newline at end of file From 4ad3e7662ec0f0a8363f2babfa39b704f13d9878 Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Sun, 6 Nov 2022 13:46:45 +0100 Subject: [PATCH 3/8] Split loadRecipeInfo to refresh/load --- .../mealient/data/recipes/RecipeRepo.kt | 4 ++-- .../mealient/data/recipes/db/RecipeStorage.kt | 2 +- .../data/recipes/db/RecipeStorageImpl.kt | 6 ++--- .../data/recipes/impl/RecipeRepoImpl.kt | 22 +++++++------------ .../mealient/ui/recipes/RecipeViewModel.kt | 15 +++++-------- .../mealient/ui/recipes/RecipesFragment.kt | 17 ++++++-------- .../ui/recipes/info/RecipeInfoViewModel.kt | 2 +- app/src/main/res/navigation/nav_graph.xml | 3 --- 8 files changed, 27 insertions(+), 44 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 1c200b1..998043e 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 @@ -9,7 +9,7 @@ interface RecipeRepo { suspend fun clearLocalData() - suspend fun loadRecipeInfo(recipeId: String, recipeSlug: String): FullRecipeEntity + suspend fun refreshRecipeInfo(recipeSlug: String): Result - suspend fun loadRecipeInfoFromDb(recipeId: String, recipeSlug: String): FullRecipeEntity? + suspend fun loadRecipeInfo(recipeId: String): FullRecipeEntity? } \ 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 431e74a..89f9439 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 @@ -17,5 +17,5 @@ interface RecipeStorage { suspend fun saveRecipeInfo(recipe: FullRecipeInfo) - suspend fun queryRecipeInfo(recipeId: String): FullRecipeEntity + suspend fun queryRecipeInfo(recipeId: String): FullRecipeEntity? } \ 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 5b7f257..3431207 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 @@ -74,11 +74,9 @@ class RecipeStorageImpl @Inject constructor( } } - override suspend fun queryRecipeInfo(recipeId: String): FullRecipeEntity { + override suspend fun queryRecipeInfo(recipeId: String): FullRecipeEntity? { logger.v { "queryRecipeInfo() called with: recipeId = $recipeId" } - val fullRecipeInfo = checkNotNull(recipeDao.queryFullRecipeInfo(recipeId)) { - "Can't find recipe by id $recipeId in DB" - } + val fullRecipeInfo = recipeDao.queryFullRecipeInfo(recipeId) logger.v { "queryRecipeInfo() returned: $fullRecipeInfo" } return fullRecipeInfo } 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 c6412c7..4bedf65 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 @@ -38,25 +38,19 @@ class RecipeRepoImpl @Inject constructor( storage.clearAllLocalData() } - override suspend fun loadRecipeInfo(recipeId: String, recipeSlug: String): FullRecipeEntity { - logger.v { "loadRecipeInfo() called with: recipeId = $recipeId, recipeSlug = $recipeSlug" } - - runCatchingExceptCancel { + override suspend fun refreshRecipeInfo(recipeSlug: String): Result { + logger.v { "refreshRecipeInfo() called with: recipeSlug = $recipeSlug" } + return runCatchingExceptCancel { storage.saveRecipeInfo(dataSource.requestRecipeInfo(recipeSlug)) }.onFailure { logger.e(it) { "loadRecipeInfo: can't update full recipe info" } } - - return storage.queryRecipeInfo(recipeId) } - override suspend fun loadRecipeInfoFromDb( - recipeId: String, - recipeSlug: String - ): FullRecipeEntity? { - logger.v { "loadRecipeInfoFromDb() called with: recipeId = $recipeId, recipeSlug = $recipeSlug" } - return runCatchingExceptCancel { storage.queryRecipeInfo(recipeId) } - .onFailure { logger.e(it) { "loadRecipeInfoFromDb failed" } } - .getOrNull() + override suspend fun loadRecipeInfo(recipeId: String): FullRecipeEntity? { + logger.v { "loadRecipeInfo() called with: recipeId = $recipeId" } + val recipeInfo = storage.queryRecipeInfo(recipeId) + logger.v { "loadRecipeInfo() returned: $recipeInfo" } + return recipeInfo } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewModel.kt index 9fe0df0..7760d28 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewModel.kt @@ -5,9 +5,6 @@ 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.FullRecipeEntity -import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity -import gq.kirmanak.mealient.datasource.runCatchingExceptCancel import gq.kirmanak.mealient.extensions.valueUpdatesOnly import gq.kirmanak.mealient.logging.Logger import kotlinx.coroutines.flow.launchIn @@ -38,12 +35,12 @@ class RecipeViewModel @Inject constructor( _isAuthorized.postValue(null) } - fun loadRecipeInfo( - summaryEntity: RecipeSummaryEntity - ): LiveData> = liveData { - val result = runCatchingExceptCancel { - recipeRepo.loadRecipeInfo(summaryEntity.remoteId, summaryEntity.slug) + fun refreshRecipeInfo(recipeSlug: String): LiveData> { + logger.v { "refreshRecipeInfo called with: recipeSlug = $recipeSlug" } + return liveData { + val result = recipeRepo.refreshRecipeInfo(recipeSlug) + logger.v { "refreshRecipeInfo: emitting $result" } + emit(result) } - emit(result) } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt index 37c9af8..0f2260b 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt @@ -54,23 +54,20 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) { setupRecipeAdapter() } - private fun navigateToRecipeInfo(recipeSummaryEntity: RecipeSummaryEntity) { - logger.v { "navigateToRecipeInfo() called with: recipeSummaryEntity = $recipeSummaryEntity" } - findNavController().navigate( - RecipesFragmentDirections.actionRecipesFragmentToRecipeInfoFragment( - recipeSlug = recipeSummaryEntity.slug, recipeId = recipeSummaryEntity.remoteId - ) - ) + private fun navigateToRecipeInfo(id: String) { + logger.v { "navigateToRecipeInfo() called with: id = $id" } + val directions = RecipesFragmentDirections.actionRecipesFragmentToRecipeInfoFragment(id) + findNavController().navigate(directions) } private fun onRecipeClicked(recipe: RecipeSummaryEntity) { - logger.d { "onRecipeClicked() called with: recipe = $recipe" } + logger.v { "onRecipeClicked() called with: recipe = $recipe" } if (ignoreRecipeClicks) return binding.progress.isVisible = true ignoreRecipeClicks = true // TODO doesn't really work - viewModel.loadRecipeInfo(recipe).observeOnce(viewLifecycleOwner) { result -> + viewModel.refreshRecipeInfo(recipe.slug).observeOnce(viewLifecycleOwner) { result -> binding.progress.isVisible = false - if (result.isSuccess) navigateToRecipeInfo(recipe) + if (result.isSuccess) navigateToRecipeInfo(recipe.remoteId) ignoreRecipeClicks = false } } diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoViewModel.kt index 6abe446..7a23e2a 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoViewModel.kt @@ -20,7 +20,7 @@ class RecipeInfoViewModel @Inject constructor( val uiState: LiveData = liveData { logger.v { "Initializing UI state with args = $args" } - val state = recipeRepo.loadRecipeInfoFromDb(args.recipeId, args.recipeSlug)?.let { + val state = recipeRepo.loadRecipeInfo(args.recipeId)?.let { RecipeInfoUiState( areIngredientsVisible = it.recipeIngredients.isNotEmpty(), areInstructionsVisible = it.recipeInstructions.isNotEmpty(), diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml index 243f6f7..6d506bc 100644 --- a/app/src/main/res/navigation/nav_graph.xml +++ b/app/src/main/res/navigation/nav_graph.xml @@ -31,9 +31,6 @@ android:name="gq.kirmanak.mealient.ui.recipes.info.RecipeInfoFragment" android:label="RecipeInfoFragment" tools:layout="@layout/fragment_recipe_info"> - From b3ea95f732d5ff85d83b722b084b25896c1324c9 Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Sun, 6 Nov 2022 15:56:26 +0100 Subject: [PATCH 4/8] Update recipe repo --- .../mealient/data/recipes/impl/RecipeRepoTest.kt | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) 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 468b589..bc63779 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 @@ -46,28 +46,18 @@ class RecipeRepoTest { @Test fun `when loadRecipeInfo expect return value from data source`() = runTest { - coEvery { dataSource.requestRecipeInfo(eq("cake")) } returns CAKE_FULL_RECIPE_INFO coEvery { storage.queryRecipeInfo(eq("1")) } returns FULL_CAKE_INFO_ENTITY - val actual = subject.loadRecipeInfo("1", "cake") + val actual = subject.loadRecipeInfo("1") assertThat(actual).isEqualTo(FULL_CAKE_INFO_ENTITY) } @Test - fun `when loadRecipeInfo expect call to storage`() = runTest { + fun `when refreshRecipeInfo expect call to storage`() = runTest { coEvery { dataSource.requestRecipeInfo(eq("cake")) } returns CAKE_FULL_RECIPE_INFO - coEvery { storage.queryRecipeInfo(eq("1")) } returns FULL_CAKE_INFO_ENTITY - subject.loadRecipeInfo("1", "cake") + subject.refreshRecipeInfo("cake") coVerify { storage.saveRecipeInfo(eq(CAKE_FULL_RECIPE_INFO)) } } - @Test - fun `when data source fails expect loadRecipeInfo return value from storage`() = runTest { - coEvery { dataSource.requestRecipeInfo(eq("cake")) } throws RuntimeException() - coEvery { storage.queryRecipeInfo(eq("1")) } returns FULL_CAKE_INFO_ENTITY - val actual = subject.loadRecipeInfo("1", "cake") - assertThat(actual).isEqualTo(FULL_CAKE_INFO_ENTITY) - } - @Test fun `when clearLocalData expect call to storage`() = runTest { subject.clearLocalData() From 89bcde7414730c6d1f6ae42bb11155f9432d19cb Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Sun, 6 Nov 2022 16:06:03 +0100 Subject: [PATCH 5/8] Fix BuildConfig import --- .../architecture/configuration/BuildConfigurationImpl.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/architecture/src/main/kotlin/gq/kirmanak/mealient/architecture/configuration/BuildConfigurationImpl.kt b/architecture/src/main/kotlin/gq/kirmanak/mealient/architecture/configuration/BuildConfigurationImpl.kt index 378b1a8..b660cd9 100644 --- a/architecture/src/main/kotlin/gq/kirmanak/mealient/architecture/configuration/BuildConfigurationImpl.kt +++ b/architecture/src/main/kotlin/gq/kirmanak/mealient/architecture/configuration/BuildConfigurationImpl.kt @@ -1,6 +1,6 @@ package gq.kirmanak.mealient.architecture.configuration -import androidx.viewbinding.BuildConfig +import gq.kirmanak.mealient.architecture.BuildConfig import javax.inject.Inject import javax.inject.Singleton From 5ed6d44099bd782385331fd66f963f55d39b30de Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Sun, 6 Nov 2022 16:21:27 +0100 Subject: [PATCH 6/8] Fix crashing when tapping too fast --- .../mealient/ui/recipes/RecipesFragment.kt | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt index 0f2260b..1ec8d6d 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt @@ -17,7 +17,10 @@ import gq.kirmanak.mealient.R import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity import gq.kirmanak.mealient.databinding.FragmentRecipesBinding import gq.kirmanak.mealient.datasource.NetworkError -import gq.kirmanak.mealient.extensions.* +import gq.kirmanak.mealient.extensions.collectWhenViewResumed +import gq.kirmanak.mealient.extensions.refreshRequestFlow +import gq.kirmanak.mealient.extensions.showLongToast +import gq.kirmanak.mealient.extensions.valueUpdatesOnly import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.ui.activity.MainActivityViewModel import gq.kirmanak.mealient.ui.recipes.images.RecipePreloaderFactory @@ -43,8 +46,6 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) { @Inject lateinit var recipePreloaderFactory: RecipePreloaderFactory - private var ignoreRecipeClicks = false - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) logger.v { "onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState" } @@ -62,16 +63,22 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) { private fun onRecipeClicked(recipe: RecipeSummaryEntity) { logger.v { "onRecipeClicked() called with: recipe = $recipe" } - if (ignoreRecipeClicks) return binding.progress.isVisible = true - ignoreRecipeClicks = true // TODO doesn't really work - viewModel.refreshRecipeInfo(recipe.slug).observeOnce(viewLifecycleOwner) { result -> + viewModel.refreshRecipeInfo(recipe.slug).observe(viewLifecycleOwner) { result -> binding.progress.isVisible = false - if (result.isSuccess) navigateToRecipeInfo(recipe.remoteId) - ignoreRecipeClicks = false + if (result.isSuccess && !isNavigatingSomewhere()) { + navigateToRecipeInfo(recipe.remoteId) + } } } + private fun isNavigatingSomewhere(): Boolean { + logger.v { "isNavigatingSomewhere() called" } + val label = findNavController().currentDestination?.label + logger.d { "isNavigatingSomewhere: current destination is $label" } + return label != "fragment_recipes" + } + private fun setupRecipeAdapter() { logger.v { "setupRecipeAdapter() called" } From 0c87657b55282ab721733bafd5438bfcffef0cd8 Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Sun, 6 Nov 2022 19:28:22 +0100 Subject: [PATCH 7/8] Add RecipeInfoViewModel test --- .../recipes/info/RecipeInfoViewModelTest.kt | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 app/src/test/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoViewModelTest.kt diff --git a/app/src/test/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoViewModelTest.kt b/app/src/test/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoViewModelTest.kt new file mode 100644 index 0000000..df95a04 --- /dev/null +++ b/app/src/test/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoViewModelTest.kt @@ -0,0 +1,75 @@ +package gq.kirmanak.mealient.ui.recipes.info + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.asFlow +import com.google.common.truth.Truth.assertThat +import gq.kirmanak.mealient.data.recipes.RecipeRepo +import gq.kirmanak.mealient.logging.Logger +import gq.kirmanak.mealient.test.FakeLogger +import gq.kirmanak.mealient.test.RecipeImplTestData.FULL_CAKE_INFO_ENTITY +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class RecipeInfoViewModelTest { + + @get:Rule + val instantExecutorRule = InstantTaskExecutorRule() + + private val logger: Logger = FakeLogger() + + @MockK + lateinit var recipeRepo: RecipeRepo + + @Before + fun setUp() { + MockKAnnotations.init(this) + Dispatchers.setMain(UnconfinedTestDispatcher()) + + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `when recipe isn't found then UI state is empty`() = runTest { + coEvery { recipeRepo.loadRecipeInfo(eq(RECIPE_ID)) } returns null + val uiState = createSubject().uiState.asFlow().first() + assertThat(uiState).isEqualTo(RecipeInfoUiState()) + } + + @Test + fun `when recipe is found then UI state has data`() = runTest { + coEvery { recipeRepo.loadRecipeInfo(eq(RECIPE_ID)) } returns FULL_CAKE_INFO_ENTITY + val expected = RecipeInfoUiState( + areIngredientsVisible = true, + areInstructionsVisible = true, + recipeInfo = FULL_CAKE_INFO_ENTITY + ) + val actual = createSubject().uiState.asFlow().first() + assertThat(actual).isEqualTo(expected) + } + + private fun createSubject(): RecipeInfoViewModel { + val argument = RecipeInfoFragmentArgs(RECIPE_ID).toSavedStateHandle() + return RecipeInfoViewModel(recipeRepo, logger, argument) + } + + companion object { + private const val RECIPE_ID = "1" + } +} \ No newline at end of file From 3b88eb65e8f1aac1339416093d2f52cb62dc59e7 Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Sun, 6 Nov 2022 19:37:33 +0100 Subject: [PATCH 8/8] Add RecipeViewModel tests --- .../ui/recipes/RecipeViewModelTest.kt | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/app/src/test/java/gq/kirmanak/mealient/ui/recipes/RecipeViewModelTest.kt b/app/src/test/java/gq/kirmanak/mealient/ui/recipes/RecipeViewModelTest.kt index e433300..22d6fa1 100644 --- a/app/src/test/java/gq/kirmanak/mealient/ui/recipes/RecipeViewModelTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/ui/recipes/RecipeViewModelTest.kt @@ -1,19 +1,24 @@ package gq.kirmanak.mealient.ui.recipes import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.asFlow 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.logging.Logger import gq.kirmanak.mealient.test.FakeLogger import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Before @@ -71,5 +76,33 @@ class RecipeViewModelTest { assertThat(subject.isAuthorized.value).isNull() } + @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() + assertThat(actual).isEqualTo(Result.success(Unit)) + } + + @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() + coVerify { recipeRepo.refreshRecipeInfo(slug) } + } + + @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 + val actual = createSubject().refreshRecipeInfo(slug).asFlow().first() + assertThat(actual).isEqualTo(result) + } + private fun createSubject() = RecipeViewModel(recipeRepo, authRepo, logger) } \ No newline at end of file