Merge pull request #85 from kirmanak/info-loading
Improve recipe info loading experience
This commit is contained in:
@@ -9,5 +9,7 @@ interface RecipeRepo {
|
||||
|
||||
suspend fun clearLocalData()
|
||||
|
||||
suspend fun loadRecipeInfo(recipeId: String, recipeSlug: String): FullRecipeEntity
|
||||
suspend fun refreshRecipeInfo(recipeSlug: String): Result<Unit>
|
||||
|
||||
suspend fun loadRecipeInfo(recipeId: String): FullRecipeEntity?
|
||||
}
|
||||
@@ -17,5 +17,5 @@ interface RecipeStorage {
|
||||
|
||||
suspend fun saveRecipeInfo(recipe: FullRecipeInfo)
|
||||
|
||||
suspend fun queryRecipeInfo(recipeId: String): FullRecipeEntity
|
||||
suspend fun queryRecipeInfo(recipeId: String): FullRecipeEntity?
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<Unit> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<Result<Unit>> {
|
||||
logger.v { "refreshRecipeInfo called with: recipeSlug = $recipeSlug" }
|
||||
return liveData {
|
||||
val result = recipeRepo.refreshRecipeInfo(recipeSlug)
|
||||
logger.v { "refreshRecipeInfo: emitting $result" }
|
||||
emit(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <T : Any, VH : RecyclerView.ViewHolder> PagingDataAdapter<T, VH>.refreshErrors(): Flow<Throwable> {
|
||||
return loadStateFlow
|
||||
.map { it.refresh }
|
||||
.valueUpdatesOnly()
|
||||
.filterIsInstance<LoadState.Error>()
|
||||
return loadStateFlow.map { it.refresh }.valueUpdatesOnly().filterIsInstance<LoadState.Error>()
|
||||
.map { it.error }
|
||||
}
|
||||
|
||||
private fun <T : Any, VH : RecyclerView.ViewHolder> PagingDataAdapter<T, VH>.appendPaginationEnd(): Flow<Unit> {
|
||||
return loadStateFlow
|
||||
.map { it.append.endOfPaginationReached }
|
||||
.valueUpdatesOnly()
|
||||
.filter { it }
|
||||
return loadStateFlow.map { it.append.endOfPaginationReached }.valueUpdatesOnly().filter { it }
|
||||
.map { }
|
||||
}
|
||||
|
||||
@@ -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<RecipeInfoFragmentArgs>()
|
||||
private val viewModel by viewModels<RecipeInfoViewModel>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<RecipeInfoUiState> 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<RecipeInfoUiState> = 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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -19,4 +19,10 @@
|
||||
tools:listitem="@layout/view_holder_recipe" />
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
|
||||
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
android:id="@+id/progress"
|
||||
style="@style/IndeterminateProgress"
|
||||
android:layout_width="wrap_content"
|
||||
tools:visibility="visible" />
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
@@ -31,9 +31,6 @@
|
||||
android:name="gq.kirmanak.mealient.ui.recipes.info.RecipeInfoFragment"
|
||||
android:label="RecipeInfoFragment"
|
||||
tools:layout="@layout/fragment_recipe_info">
|
||||
<argument
|
||||
android:name="recipe_slug"
|
||||
app:argType="string" />
|
||||
<argument
|
||||
android:name="recipe_id"
|
||||
app:argType="string" />
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<Unit>(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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user