Merge pull request #85 from kirmanak/info-loading

Improve recipe info loading experience
This commit is contained in:
Kirill Kamakin
2022-11-06 19:46:48 +01:00
committed by GitHub
14 changed files with 185 additions and 73 deletions

View File

@@ -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?
}

View File

@@ -17,5 +17,5 @@ interface RecipeStorage {
suspend fun saveRecipeInfo(recipe: FullRecipeInfo)
suspend fun queryRecipeInfo(recipeId: String): FullRecipeEntity
suspend fun queryRecipeInfo(recipeId: String): FullRecipeEntity?
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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)
}
}
}

View File

@@ -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 { }
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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>

View File

@@ -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" />

View File

@@ -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()

View File

@@ -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)
}

View File

@@ -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"
}
}

View File

@@ -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