Merge pull request #93 from kirmanak/recipe-loading-experience

Refresh recipes on authorization
This commit is contained in:
Kirill Kamakin
2022-11-13 15:49:00 +01:00
committed by GitHub
11 changed files with 84 additions and 62 deletions

View File

@@ -15,4 +15,6 @@ interface RecipeRepo {
suspend fun loadRecipeInfo(recipeId: String): FullRecipeEntity? suspend fun loadRecipeInfo(recipeId: String): FullRecipeEntity?
fun updateNameQuery(name: String?) fun updateNameQuery(name: String?)
suspend fun refreshRecipes()
} }

View File

@@ -25,7 +25,11 @@ class RecipeRepoImpl @Inject constructor(
override fun createPager(): Pager<Int, RecipeSummaryEntity> { override fun createPager(): Pager<Int, RecipeSummaryEntity> {
logger.v { "createPager() called" } logger.v { "createPager() called" }
val pagingConfig = PagingConfig(pageSize = 5, enablePlaceholders = true) val pagingConfig = PagingConfig(
pageSize = LOAD_PAGE_SIZE,
enablePlaceholders = true,
initialLoadSize = INITIAL_LOAD_PAGE_SIZE,
)
return Pager( return Pager(
config = pagingConfig, config = pagingConfig,
remoteMediator = mediator, remoteMediator = mediator,
@@ -58,4 +62,18 @@ class RecipeRepoImpl @Inject constructor(
logger.v { "updateNameQuery() called with: name = $name" } logger.v { "updateNameQuery() called with: name = $name" }
pagingSourceFactory.setQuery(name) pagingSourceFactory.setQuery(name)
} }
override suspend fun refreshRecipes() {
logger.v { "refreshRecipes() called" }
runCatchingExceptCancel {
storage.refreshAll(dataSource.requestRecipes(0, INITIAL_LOAD_PAGE_SIZE))
}.onFailure {
logger.e(it) { "Can't refresh recipes" }
}
}
companion object {
private const val LOAD_PAGE_SIZE = 50
private const val INITIAL_LOAD_PAGE_SIZE = LOAD_PAGE_SIZE * 3
}
} }

View File

@@ -16,7 +16,10 @@ import by.kirich1409.viewbindingdelegate.viewBinding
import com.google.android.material.shape.CornerFamily import com.google.android.material.shape.CornerFamily
import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.MaterialShapeDrawable
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.NavGraphDirections import gq.kirmanak.mealient.NavGraphDirections.Companion.actionGlobalAddRecipeFragment
import gq.kirmanak.mealient.NavGraphDirections.Companion.actionGlobalAuthenticationFragment
import gq.kirmanak.mealient.NavGraphDirections.Companion.actionGlobalBaseURLFragment
import gq.kirmanak.mealient.NavGraphDirections.Companion.actionGlobalRecipesListFragment
import gq.kirmanak.mealient.R import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.databinding.MainActivityBinding import gq.kirmanak.mealient.databinding.MainActivityBinding
import gq.kirmanak.mealient.extensions.observeOnce import gq.kirmanak.mealient.extensions.observeOnce
@@ -69,9 +72,9 @@ class MainActivity : AppCompatActivity(R.layout.main_activity) {
logger.v { "onNavigationItemSelected() called with: menuItem = $menuItem" } logger.v { "onNavigationItemSelected() called with: menuItem = $menuItem" }
menuItem.isChecked = true menuItem.isChecked = true
val directions = when (menuItem.itemId) { val directions = when (menuItem.itemId) {
R.id.add_recipe -> NavGraphDirections.actionGlobalAddRecipeFragment() R.id.add_recipe -> actionGlobalAddRecipeFragment()
R.id.recipes_list -> NavGraphDirections.actionGlobalRecipesFragment() R.id.recipes_list -> actionGlobalRecipesListFragment()
R.id.change_url -> NavGraphDirections.actionGlobalBaseURLFragment() R.id.change_url -> actionGlobalBaseURLFragment()
else -> throw IllegalArgumentException("Unknown menu item id: ${menuItem.itemId}") else -> throw IllegalArgumentException("Unknown menu item id: ${menuItem.itemId}")
} }
navigateTo(directions) navigateTo(directions)
@@ -142,7 +145,7 @@ class MainActivity : AppCompatActivity(R.layout.main_activity) {
logger.v { "onOptionsItemSelected() called with: item = $item" } logger.v { "onOptionsItemSelected() called with: item = $item" }
val result = when (item.itemId) { val result = when (item.itemId) {
R.id.login -> { R.id.login -> {
navigateTo(NavGraphDirections.actionGlobalAuthenticationFragment()) navigateTo(actionGlobalAuthenticationFragment())
true true
} }
R.id.logout -> { R.id.logout -> {

View File

@@ -41,7 +41,7 @@ class MainActivityViewModel @Inject constructor(
_startDestination.value = when { _startDestination.value = when {
!disclaimerStorage.isDisclaimerAccepted() -> R.id.disclaimerFragment !disclaimerStorage.isDisclaimerAccepted() -> R.id.disclaimerFragment
serverInfoRepo.getUrl() == null -> R.id.baseURLFragment serverInfoRepo.getUrl() == null -> R.id.baseURLFragment
else -> R.id.recipesFragment else -> R.id.recipesListFragment
} }
} }
} }

View File

@@ -15,6 +15,7 @@ import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.OperationUiState import gq.kirmanak.mealient.ui.OperationUiState
import gq.kirmanak.mealient.ui.activity.MainActivityViewModel import gq.kirmanak.mealient.ui.activity.MainActivityViewModel
import gq.kirmanak.mealient.ui.baseurl.BaseURLFragmentDirections.Companion.actionBaseURLFragmentToRecipesListFragment
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@@ -56,7 +57,7 @@ class BaseURLFragment : Fragment(R.layout.fragment_base_url) {
private fun onUiStateChange(uiState: OperationUiState<Unit>) = with(binding) { private fun onUiStateChange(uiState: OperationUiState<Unit>) = with(binding) {
logger.v { "onUiStateChange() called with: uiState = $uiState" } logger.v { "onUiStateChange() called with: uiState = $uiState" }
if (uiState.isSuccess) { if (uiState.isSuccess) {
findNavController().navigate(BaseURLFragmentDirections.actionBaseURLFragmentToRecipesFragment()) findNavController().navigate(actionBaseURLFragmentToRecipesListFragment())
return return
} }
urlInputLayout.error = when (val exception = uiState.exceptionOrNull) { urlInputLayout.error = when (val exception = uiState.exceptionOrNull) {

View File

@@ -12,6 +12,7 @@ import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.databinding.FragmentDisclaimerBinding import gq.kirmanak.mealient.databinding.FragmentDisclaimerBinding
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.activity.MainActivityViewModel import gq.kirmanak.mealient.ui.activity.MainActivityViewModel
import gq.kirmanak.mealient.ui.disclaimer.DisclaimerFragmentDirections.Companion.actionDisclaimerFragmentToBaseURLFragment
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@@ -37,7 +38,7 @@ class DisclaimerFragment : Fragment(R.layout.fragment_disclaimer) {
private fun navigateNext() { private fun navigateNext() {
logger.v { "navigateNext() called" } logger.v { "navigateNext() called" }
findNavController().navigate(DisclaimerFragmentDirections.actionDisclaimerFragmentToBaseURLFragment()) findNavController().navigate(actionDisclaimerFragmentToBaseURLFragment())
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

View File

@@ -15,7 +15,7 @@ import by.kirich1409.viewbindingdelegate.viewBinding
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.databinding.FragmentRecipesBinding import gq.kirmanak.mealient.databinding.FragmentRecipesListBinding
import gq.kirmanak.mealient.datasource.NetworkError import gq.kirmanak.mealient.datasource.NetworkError
import gq.kirmanak.mealient.extensions.collectWhenViewResumed import gq.kirmanak.mealient.extensions.collectWhenViewResumed
import gq.kirmanak.mealient.extensions.refreshRequestFlow import gq.kirmanak.mealient.extensions.refreshRequestFlow
@@ -23,6 +23,7 @@ import gq.kirmanak.mealient.extensions.showLongToast
import gq.kirmanak.mealient.extensions.valueUpdatesOnly import gq.kirmanak.mealient.extensions.valueUpdatesOnly
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.activity.MainActivityViewModel import gq.kirmanak.mealient.ui.activity.MainActivityViewModel
import gq.kirmanak.mealient.ui.recipes.RecipesListFragmentDirections.Companion.actionRecipesFragmentToRecipeInfoFragment
import gq.kirmanak.mealient.ui.recipes.images.RecipePreloaderFactory import gq.kirmanak.mealient.ui.recipes.images.RecipePreloaderFactory
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
@@ -31,10 +32,10 @@ import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class RecipesFragment : Fragment(R.layout.fragment_recipes) { class RecipesListFragment : Fragment(R.layout.fragment_recipes_list) {
private val binding by viewBinding(FragmentRecipesBinding::bind) private val binding by viewBinding(FragmentRecipesListBinding::bind)
private val viewModel by viewModels<RecipeViewModel>() private val viewModel by viewModels<RecipesListViewModel>()
private val activityViewModel by activityViewModels<MainActivityViewModel>() private val activityViewModel by activityViewModels<MainActivityViewModel>()
@Inject @Inject
@@ -62,7 +63,7 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) {
private fun navigateToRecipeInfo(id: String) { private fun navigateToRecipeInfo(id: String) {
logger.v { "navigateToRecipeInfo() called with: id = $id" } logger.v { "navigateToRecipeInfo() called with: id = $id" }
val directions = RecipesFragmentDirections.actionRecipesFragmentToRecipeInfoFragment(id) val directions = actionRecipesFragmentToRecipeInfoFragment(id)
findNavController().navigate(directions) findNavController().navigate(directions)
} }
@@ -109,6 +110,11 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) {
showLongToast(R.string.fragment_recipes_last_page_loaded_toast) showLongToast(R.string.fragment_recipes_last_page_loaded_toast)
} }
collectWhenViewResumed(recipesAdapter.sourceIsRefreshing()) { disableSwipeRefresh ->
logger.v { "setupRecipeAdapter: changing refresher enabled state to ${!disableSwipeRefresh}" }
binding.refresher.isEnabled = !disableSwipeRefresh
}
collectWhenViewResumed(recipesAdapter.refreshErrors()) { collectWhenViewResumed(recipesAdapter.refreshErrors()) {
onLoadFailure(it) onLoadFailure(it)
} }
@@ -117,15 +123,6 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) {
logger.v { "setupRecipeAdapter: received refresh request" } logger.v { "setupRecipeAdapter: received refresh request" }
recipesAdapter.refresh() recipesAdapter.refresh()
} }
viewModel.isAuthorized.observe(viewLifecycleOwner) { isAuthorized ->
logger.v { "setupRecipeAdapter: isAuthorized changed to $isAuthorized" }
if (isAuthorized != null) {
if (isAuthorized) recipesAdapter.refresh()
// else is ignored to avoid the removal of the non-public recipes
viewModel.onAuthorizationChangeHandled()
}
}
} }
private fun onLoadFailure(error: Throwable) { private fun onLoadFailure(error: Throwable) {
@@ -156,11 +153,21 @@ private fun Throwable.toLoadErrorReasonText(): Int? = when (this) {
} }
private fun <T : Any, VH : RecyclerView.ViewHolder> PagingDataAdapter<T, VH>.refreshErrors(): Flow<Throwable> { 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 } .map { it.error }
} }
private fun <T : Any, VH : RecyclerView.ViewHolder> PagingDataAdapter<T, VH>.appendPaginationEnd(): Flow<Unit> { 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 { } .map { }
} }
private fun <T : Any, VH : RecyclerView.ViewHolder> PagingDataAdapter<T, VH>.sourceIsRefreshing(): Flow<Boolean> {
return loadStateFlow.map { it.source.refresh !is LoadState.NotLoading }.valueUpdatesOnly()
}

View File

@@ -1,6 +1,9 @@
package gq.kirmanak.mealient.ui.recipes package gq.kirmanak.mealient.ui.recipes
import androidx.lifecycle.* import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.liveData
import androidx.lifecycle.viewModelScope
import androidx.paging.cachedIn import androidx.paging.cachedIn
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.auth.AuthRepo import gq.kirmanak.mealient.data.auth.AuthRepo
@@ -12,7 +15,7 @@ import kotlinx.coroutines.flow.onEach
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class RecipeViewModel @Inject constructor( class RecipesListViewModel @Inject constructor(
private val recipeRepo: RecipeRepo, private val recipeRepo: RecipeRepo,
authRepo: AuthRepo, authRepo: AuthRepo,
private val logger: Logger, private val logger: Logger,
@@ -20,21 +23,13 @@ class RecipeViewModel @Inject constructor(
val pagingData = recipeRepo.createPager().flow.cachedIn(viewModelScope) val pagingData = recipeRepo.createPager().flow.cachedIn(viewModelScope)
private val _isAuthorized = MutableLiveData<Boolean?>(null)
val isAuthorized: LiveData<Boolean?> = _isAuthorized
init { init {
authRepo.isAuthorizedFlow.valueUpdatesOnly().onEach { authRepo.isAuthorizedFlow.valueUpdatesOnly().onEach { hasAuthorized ->
logger.v { "Authorization state changed to $it" } logger.v { "Authorization state changed to $hasAuthorized" }
_isAuthorized.postValue(it) if (hasAuthorized) recipeRepo.refreshRecipes()
}.launchIn(viewModelScope) }.launchIn(viewModelScope)
} }
fun onAuthorizationChangeHandled() {
logger.v { "onAuthorizationSuccessHandled() called" }
_isAuthorized.postValue(null)
}
fun refreshRecipeInfo(recipeSlug: String): LiveData<Result<Unit>> { fun refreshRecipeInfo(recipeSlug: String): LiveData<Result<Unit>> {
logger.v { "refreshRecipeInfo called with: recipeSlug = $recipeSlug" } logger.v { "refreshRecipeInfo called with: recipeSlug = $recipeSlug" }
return liveData { return liveData {

View File

@@ -4,7 +4,7 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
tools:context=".ui.recipes.RecipesFragment"> tools:context=".ui.recipes.RecipesListFragment">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/refresher" android:id="@+id/refresher"

View File

@@ -12,10 +12,10 @@
tools:layout="@layout/fragment_authentication" /> tools:layout="@layout/fragment_authentication" />
<fragment <fragment
android:id="@+id/recipesFragment" android:id="@+id/recipesListFragment"
android:name="gq.kirmanak.mealient.ui.recipes.RecipesFragment" android:name="gq.kirmanak.mealient.ui.recipes.RecipesListFragment"
android:label="fragment_recipes" android:label="fragment_recipes"
tools:layout="@layout/fragment_recipes"> tools:layout="@layout/fragment_recipes_list">
<action <action
android:id="@+id/action_recipesFragment_to_recipeInfoFragment" android:id="@+id/action_recipesFragment_to_recipeInfoFragment"
app:destination="@id/recipeInfoFragment" /> app:destination="@id/recipeInfoFragment" />
@@ -49,8 +49,8 @@
android:label="fragment_base_url" android:label="fragment_base_url"
tools:layout="@layout/fragment_base_url"> tools:layout="@layout/fragment_base_url">
<action <action
android:id="@+id/action_baseURLFragment_to_recipesFragment" android:id="@+id/action_baseURLFragment_to_recipesListFragment"
app:destination="@id/recipesFragment" app:destination="@id/recipesListFragment"
app:popUpTo="@id/nav_graph" app:popUpTo="@id/nav_graph"
app:popUpToInclusive="true" /> app:popUpToInclusive="true" />
</fragment> </fragment>
@@ -66,8 +66,8 @@
app:destination="@id/authenticationFragment" /> app:destination="@id/authenticationFragment" />
<action <action
android:id="@+id/action_global_recipesFragment" android:id="@+id/action_global_recipesListFragment"
app:destination="@id/recipesFragment" /> app:destination="@id/recipesListFragment" />
<action <action
android:id="@+id/action_global_addRecipeFragment" android:id="@+id/action_global_addRecipeFragment"

View File

@@ -16,7 +16,7 @@ import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class RecipeViewModelTest : BaseUnitTest() { class RecipesListViewModelTest : BaseUnitTest() {
@MockK @MockK
lateinit var authRepo: AuthRepo lateinit var authRepo: AuthRepo
@@ -25,29 +25,24 @@ class RecipeViewModelTest : BaseUnitTest() {
lateinit var recipeRepo: RecipeRepo lateinit var recipeRepo: RecipeRepo
@Test @Test
fun `when authRepo isAuthorized changes to true expect isAuthorized update`() { fun `when authRepo isAuthorized changes to true expect that recipes are refreshed`() {
every { authRepo.isAuthorizedFlow } returns flowOf(false, true) every { authRepo.isAuthorizedFlow } returns flowOf(false, true)
assertThat(createSubject().isAuthorized.value).isTrue() createSubject()
coVerify { recipeRepo.refreshRecipes() }
} }
@Test @Test
fun `when authRepo isAuthorized changes to false expect isAuthorized update`() { fun `when authRepo isAuthorized changes to false expect that recipes are not refreshed`() {
every { authRepo.isAuthorizedFlow } returns flowOf(true, false) every { authRepo.isAuthorizedFlow } returns flowOf(true, false)
assertThat(createSubject().isAuthorized.value).isFalse() createSubject()
coVerify(inverse = true) { recipeRepo.refreshRecipes() }
} }
@Test @Test
fun `when authRepo isAuthorized doesn't change expect isAuthorized null`() { fun `when authRepo isAuthorized doesn't change expect that recipes are not refreshed`() {
every { authRepo.isAuthorizedFlow } returns flowOf(true) every { authRepo.isAuthorizedFlow } returns flowOf(true)
assertThat(createSubject().isAuthorized.value).isNull() createSubject()
} coVerify(inverse = true) { recipeRepo.refreshRecipes() }
@Test
fun `when isAuthorization change is handled expect isAuthorized null`() {
every { authRepo.isAuthorizedFlow } returns flowOf(true, false)
val subject = createSubject()
subject.onAuthorizationChangeHandled()
assertThat(subject.isAuthorized.value).isNull()
} }
@Test @Test
@@ -78,5 +73,5 @@ class RecipeViewModelTest : BaseUnitTest() {
assertThat(actual).isEqualTo(result) assertThat(actual).isEqualTo(result)
} }
private fun createSubject() = RecipeViewModel(recipeRepo, authRepo, logger) private fun createSubject() = RecipesListViewModel(recipeRepo, authRepo, logger)
} }