Merge pull request #93 from kirmanak/recipe-loading-experience
Refresh recipes on authorization
This commit is contained in:
@@ -15,4 +15,6 @@ interface RecipeRepo {
|
||||
suspend fun loadRecipeInfo(recipeId: String): FullRecipeEntity?
|
||||
|
||||
fun updateNameQuery(name: String?)
|
||||
|
||||
suspend fun refreshRecipes()
|
||||
}
|
||||
@@ -25,7 +25,11 @@ class RecipeRepoImpl @Inject constructor(
|
||||
|
||||
override fun createPager(): Pager<Int, RecipeSummaryEntity> {
|
||||
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(
|
||||
config = pagingConfig,
|
||||
remoteMediator = mediator,
|
||||
@@ -58,4 +62,18 @@ class RecipeRepoImpl @Inject constructor(
|
||||
logger.v { "updateNameQuery() called with: name = $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
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,10 @@ import by.kirich1409.viewbindingdelegate.viewBinding
|
||||
import com.google.android.material.shape.CornerFamily
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
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.databinding.MainActivityBinding
|
||||
import gq.kirmanak.mealient.extensions.observeOnce
|
||||
@@ -69,9 +72,9 @@ class MainActivity : AppCompatActivity(R.layout.main_activity) {
|
||||
logger.v { "onNavigationItemSelected() called with: menuItem = $menuItem" }
|
||||
menuItem.isChecked = true
|
||||
val directions = when (menuItem.itemId) {
|
||||
R.id.add_recipe -> NavGraphDirections.actionGlobalAddRecipeFragment()
|
||||
R.id.recipes_list -> NavGraphDirections.actionGlobalRecipesFragment()
|
||||
R.id.change_url -> NavGraphDirections.actionGlobalBaseURLFragment()
|
||||
R.id.add_recipe -> actionGlobalAddRecipeFragment()
|
||||
R.id.recipes_list -> actionGlobalRecipesListFragment()
|
||||
R.id.change_url -> actionGlobalBaseURLFragment()
|
||||
else -> throw IllegalArgumentException("Unknown menu item id: ${menuItem.itemId}")
|
||||
}
|
||||
navigateTo(directions)
|
||||
@@ -142,7 +145,7 @@ class MainActivity : AppCompatActivity(R.layout.main_activity) {
|
||||
logger.v { "onOptionsItemSelected() called with: item = $item" }
|
||||
val result = when (item.itemId) {
|
||||
R.id.login -> {
|
||||
navigateTo(NavGraphDirections.actionGlobalAuthenticationFragment())
|
||||
navigateTo(actionGlobalAuthenticationFragment())
|
||||
true
|
||||
}
|
||||
R.id.logout -> {
|
||||
|
||||
@@ -41,7 +41,7 @@ class MainActivityViewModel @Inject constructor(
|
||||
_startDestination.value = when {
|
||||
!disclaimerStorage.isDisclaimerAccepted() -> R.id.disclaimerFragment
|
||||
serverInfoRepo.getUrl() == null -> R.id.baseURLFragment
|
||||
else -> R.id.recipesFragment
|
||||
else -> R.id.recipesListFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty
|
||||
import gq.kirmanak.mealient.logging.Logger
|
||||
import gq.kirmanak.mealient.ui.OperationUiState
|
||||
import gq.kirmanak.mealient.ui.activity.MainActivityViewModel
|
||||
import gq.kirmanak.mealient.ui.baseurl.BaseURLFragmentDirections.Companion.actionBaseURLFragmentToRecipesListFragment
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -56,7 +57,7 @@ class BaseURLFragment : Fragment(R.layout.fragment_base_url) {
|
||||
private fun onUiStateChange(uiState: OperationUiState<Unit>) = with(binding) {
|
||||
logger.v { "onUiStateChange() called with: uiState = $uiState" }
|
||||
if (uiState.isSuccess) {
|
||||
findNavController().navigate(BaseURLFragmentDirections.actionBaseURLFragmentToRecipesFragment())
|
||||
findNavController().navigate(actionBaseURLFragmentToRecipesListFragment())
|
||||
return
|
||||
}
|
||||
urlInputLayout.error = when (val exception = uiState.exceptionOrNull) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import gq.kirmanak.mealient.R
|
||||
import gq.kirmanak.mealient.databinding.FragmentDisclaimerBinding
|
||||
import gq.kirmanak.mealient.logging.Logger
|
||||
import gq.kirmanak.mealient.ui.activity.MainActivityViewModel
|
||||
import gq.kirmanak.mealient.ui.disclaimer.DisclaimerFragmentDirections.Companion.actionDisclaimerFragmentToBaseURLFragment
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -37,7 +38,7 @@ class DisclaimerFragment : Fragment(R.layout.fragment_disclaimer) {
|
||||
|
||||
private fun navigateNext() {
|
||||
logger.v { "navigateNext() called" }
|
||||
findNavController().navigate(DisclaimerFragmentDirections.actionDisclaimerFragmentToBaseURLFragment())
|
||||
findNavController().navigate(actionDisclaimerFragmentToBaseURLFragment())
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
|
||||
@@ -15,7 +15,7 @@ import by.kirich1409.viewbindingdelegate.viewBinding
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import gq.kirmanak.mealient.R
|
||||
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.extensions.collectWhenViewResumed
|
||||
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.logging.Logger
|
||||
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 kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
@@ -31,10 +32,10 @@ import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
|
||||
@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 viewModel by viewModels<RecipeViewModel>()
|
||||
private val binding by viewBinding(FragmentRecipesListBinding::bind)
|
||||
private val viewModel by viewModels<RecipesListViewModel>()
|
||||
private val activityViewModel by activityViewModels<MainActivityViewModel>()
|
||||
|
||||
@Inject
|
||||
@@ -62,7 +63,7 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) {
|
||||
|
||||
private fun navigateToRecipeInfo(id: String) {
|
||||
logger.v { "navigateToRecipeInfo() called with: id = $id" }
|
||||
val directions = RecipesFragmentDirections.actionRecipesFragmentToRecipeInfoFragment(id)
|
||||
val directions = actionRecipesFragmentToRecipeInfoFragment(id)
|
||||
findNavController().navigate(directions)
|
||||
}
|
||||
|
||||
@@ -109,6 +110,11 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) {
|
||||
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()) {
|
||||
onLoadFailure(it)
|
||||
}
|
||||
@@ -117,15 +123,6 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) {
|
||||
logger.v { "setupRecipeAdapter: received refresh request" }
|
||||
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) {
|
||||
@@ -156,11 +153,21 @@ 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 { }
|
||||
}
|
||||
|
||||
private fun <T : Any, VH : RecyclerView.ViewHolder> PagingDataAdapter<T, VH>.sourceIsRefreshing(): Flow<Boolean> {
|
||||
return loadStateFlow.map { it.source.refresh !is LoadState.NotLoading }.valueUpdatesOnly()
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import gq.kirmanak.mealient.data.auth.AuthRepo
|
||||
@@ -12,7 +15,7 @@ import kotlinx.coroutines.flow.onEach
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class RecipeViewModel @Inject constructor(
|
||||
class RecipesListViewModel @Inject constructor(
|
||||
private val recipeRepo: RecipeRepo,
|
||||
authRepo: AuthRepo,
|
||||
private val logger: Logger,
|
||||
@@ -20,21 +23,13 @@ class RecipeViewModel @Inject constructor(
|
||||
|
||||
val pagingData = recipeRepo.createPager().flow.cachedIn(viewModelScope)
|
||||
|
||||
private val _isAuthorized = MutableLiveData<Boolean?>(null)
|
||||
val isAuthorized: LiveData<Boolean?> = _isAuthorized
|
||||
|
||||
init {
|
||||
authRepo.isAuthorizedFlow.valueUpdatesOnly().onEach {
|
||||
logger.v { "Authorization state changed to $it" }
|
||||
_isAuthorized.postValue(it)
|
||||
authRepo.isAuthorizedFlow.valueUpdatesOnly().onEach { hasAuthorized ->
|
||||
logger.v { "Authorization state changed to $hasAuthorized" }
|
||||
if (hasAuthorized) recipeRepo.refreshRecipes()
|
||||
}.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
fun onAuthorizationChangeHandled() {
|
||||
logger.v { "onAuthorizationSuccessHandled() called" }
|
||||
_isAuthorized.postValue(null)
|
||||
}
|
||||
|
||||
fun refreshRecipeInfo(recipeSlug: String): LiveData<Result<Unit>> {
|
||||
logger.v { "refreshRecipeInfo called with: recipeSlug = $recipeSlug" }
|
||||
return liveData {
|
||||
@@ -4,7 +4,7 @@
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".ui.recipes.RecipesFragment">
|
||||
tools:context=".ui.recipes.RecipesListFragment">
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/refresher"
|
||||
@@ -12,10 +12,10 @@
|
||||
tools:layout="@layout/fragment_authentication" />
|
||||
|
||||
<fragment
|
||||
android:id="@+id/recipesFragment"
|
||||
android:name="gq.kirmanak.mealient.ui.recipes.RecipesFragment"
|
||||
android:id="@+id/recipesListFragment"
|
||||
android:name="gq.kirmanak.mealient.ui.recipes.RecipesListFragment"
|
||||
android:label="fragment_recipes"
|
||||
tools:layout="@layout/fragment_recipes">
|
||||
tools:layout="@layout/fragment_recipes_list">
|
||||
<action
|
||||
android:id="@+id/action_recipesFragment_to_recipeInfoFragment"
|
||||
app:destination="@id/recipeInfoFragment" />
|
||||
@@ -49,8 +49,8 @@
|
||||
android:label="fragment_base_url"
|
||||
tools:layout="@layout/fragment_base_url">
|
||||
<action
|
||||
android:id="@+id/action_baseURLFragment_to_recipesFragment"
|
||||
app:destination="@id/recipesFragment"
|
||||
android:id="@+id/action_baseURLFragment_to_recipesListFragment"
|
||||
app:destination="@id/recipesListFragment"
|
||||
app:popUpTo="@id/nav_graph"
|
||||
app:popUpToInclusive="true" />
|
||||
</fragment>
|
||||
@@ -66,8 +66,8 @@
|
||||
app:destination="@id/authenticationFragment" />
|
||||
|
||||
<action
|
||||
android:id="@+id/action_global_recipesFragment"
|
||||
app:destination="@id/recipesFragment" />
|
||||
android:id="@+id/action_global_recipesListFragment"
|
||||
app:destination="@id/recipesListFragment" />
|
||||
|
||||
<action
|
||||
android:id="@+id/action_global_addRecipeFragment"
|
||||
|
||||
@@ -16,7 +16,7 @@ import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class RecipeViewModelTest : BaseUnitTest() {
|
||||
class RecipesListViewModelTest : BaseUnitTest() {
|
||||
|
||||
@MockK
|
||||
lateinit var authRepo: AuthRepo
|
||||
@@ -25,29 +25,24 @@ class RecipeViewModelTest : BaseUnitTest() {
|
||||
lateinit var recipeRepo: RecipeRepo
|
||||
|
||||
@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)
|
||||
assertThat(createSubject().isAuthorized.value).isTrue()
|
||||
createSubject()
|
||||
coVerify { recipeRepo.refreshRecipes() }
|
||||
}
|
||||
|
||||
@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)
|
||||
assertThat(createSubject().isAuthorized.value).isFalse()
|
||||
createSubject()
|
||||
coVerify(inverse = true) { recipeRepo.refreshRecipes() }
|
||||
}
|
||||
|
||||
@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)
|
||||
assertThat(createSubject().isAuthorized.value).isNull()
|
||||
}
|
||||
|
||||
@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()
|
||||
createSubject()
|
||||
coVerify(inverse = true) { recipeRepo.refreshRecipes() }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -78,5 +73,5 @@ class RecipeViewModelTest : BaseUnitTest() {
|
||||
assertThat(actual).isEqualTo(result)
|
||||
}
|
||||
|
||||
private fun createSubject() = RecipeViewModel(recipeRepo, authRepo, logger)
|
||||
private fun createSubject() = RecipesListViewModel(recipeRepo, authRepo, logger)
|
||||
}
|
||||
Reference in New Issue
Block a user