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..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,5 +9,7 @@ interface RecipeRepo { suspend fun clearLocalData() - suspend fun loadRecipeInfo(recipeId: String, recipeSlug: String): FullRecipeEntity + suspend fun refreshRecipeInfo(recipeSlug: String): Result + + 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 a48691f..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,15 +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 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 7e3a733..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 @@ -1,9 +1,6 @@ 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 @@ -16,7 +13,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 +34,13 @@ class RecipeViewModel @Inject constructor( logger.v { "onAuthorizationSuccessHandled() called" } _isAuthorized.postValue(null) } + + 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) + } + } } \ 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..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 @@ -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 @@ -54,19 +55,34 @@ 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.v { "onRecipeClicked() called with: recipe = $recipe" } + binding.progress.isVisible = true + viewModel.refreshRecipeInfo(recipe.slug).observe(viewLifecycleOwner) { result -> + binding.progress.isVisible = 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" } - val recipesAdapter = recipePagingAdapterFactory.build { navigateToRecipeInfo(it) } + val recipesAdapter = recipePagingAdapterFactory.build { onRecipeClicked(it) } with(binding.recipes) { adapter = recipesAdapter @@ -135,17 +151,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/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..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 @@ -1,39 +1,33 @@ 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" } + val state = recipeRepo.loadRecipeInfo(args.recipeId)?.let { + RecipeInfoUiState( + areIngredientsVisible = it.recipeIngredients.isNotEmpty(), + areInstructionsVisible = it.recipeInstructions.isNotEmpty(), + recipeInfo = it, + ) + } ?: 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 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"> - 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() 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 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 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