Merge pull request #83 from kirmanak/improve-onboarding

Improve onboarding experience
This commit is contained in:
Kirill Kamakin
2022-11-05 12:28:06 +01:00
committed by GitHub
13 changed files with 344 additions and 74 deletions

View File

@@ -122,6 +122,7 @@ dependencies {
testImplementation(libs.robolectric)
testImplementation(libs.androidx.test.junit)
testImplementation(libs.androidx.coreTesting)
testImplementation(libs.google.truth)

View File

@@ -0,0 +1,23 @@
package gq.kirmanak.mealient.extensions
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
fun <T> Flow<T>.valueUpdatesOnly(): Flow<T> = when (this) {
is ValueUpdateOnlyFlowImpl<T> -> this
else -> ValueUpdateOnlyFlowImpl(this)
}
private class ValueUpdateOnlyFlowImpl<T>(private val upstream: Flow<T>) : Flow<T> {
override suspend fun collect(collector: FlowCollector<T>) {
var previousValue: T? = null
upstream.collect { value ->
if (previousValue != null && previousValue != value) {
collector.emit(value)
}
previousValue = value
}
}
}

View File

@@ -1,16 +1,27 @@
package gq.kirmanak.mealient.extensions
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CoroutineScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.launch
fun <T> Fragment.collectWhenViewResumed(
flow: Flow<T>,
collector: FlowCollector<T>,
) = launchWhenViewResumed { flow.collect(collector) }
fun <T> Fragment.collectWhenViewResumed(flow: Flow<T>, collector: FlowCollector<T>) {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
flow.collect(collector)
}
}
}
fun Fragment.launchWhenViewResumed(
block: suspend CoroutineScope.() -> Unit,
) = viewLifecycleOwner.lifecycleScope.launchWhenResumed(block)
fun Fragment.showLongToast(@StringRes text: Int) = showLongToast(getString(text))
fun Fragment.showLongToast(text: String) = showToast(text, Toast.LENGTH_LONG)
private fun Fragment.showToast(text: String, length: Int): Boolean {
return context?.let { Toast.makeText(it, text, length).show() } != null
}

View File

@@ -1,15 +1,40 @@
package gq.kirmanak.mealient.ui.recipes
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.cachedIn
import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.extensions.valueUpdatesOnly
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
@HiltViewModel
class RecipeViewModel @Inject constructor(recipeRepo: RecipeRepo) : ViewModel() {
class RecipeViewModel @Inject constructor(
recipeRepo: RecipeRepo,
authRepo: AuthRepo,
private val logger: Logger,
) : ViewModel() {
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)
}.launchIn(viewModelScope)
}
fun onAuthorizationChangeHandled() {
logger.v { "onAuthorizationSuccessHandled() called" }
_isAuthorized.postValue(null)
}
}

View File

@@ -2,21 +2,31 @@ package gq.kirmanak.mealient.ui.recipes
import android.os.Bundle
import android.view.View
import androidx.annotation.StringRes
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.paging.LoadState
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.RecyclerView
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.datasource.NetworkError
import gq.kirmanak.mealient.extensions.collectWhenViewResumed
import gq.kirmanak.mealient.extensions.refreshRequestFlow
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.images.RecipeImageLoader
import gq.kirmanak.mealient.ui.recipes.images.RecipePreloaderFactory
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.map
import javax.inject.Inject
@AndroidEntryPoint
@@ -29,9 +39,6 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) {
@Inject
lateinit var logger: Logger
@Inject
lateinit var recipeImageLoader: RecipeImageLoader
@Inject
lateinit var recipePagingAdapterFactory: RecipesPagingAdapter.Factory
@@ -51,34 +58,64 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) {
logger.v { "navigateToRecipeInfo() called with: recipeSummaryEntity = $recipeSummaryEntity" }
findNavController().navigate(
RecipesFragmentDirections.actionRecipesFragmentToRecipeInfoFragment(
recipeSlug = recipeSummaryEntity.slug,
recipeId = recipeSummaryEntity.remoteId
recipeSlug = recipeSummaryEntity.slug, recipeId = recipeSummaryEntity.remoteId
)
)
}
private fun setupRecipeAdapter() {
logger.v { "setupRecipeAdapter() called" }
val recipesAdapter = recipePagingAdapterFactory.build(
recipeImageLoader = recipeImageLoader,
clickListener = ::navigateToRecipeInfo
)
val recipesAdapter = recipePagingAdapterFactory.build { navigateToRecipeInfo(it) }
with(binding.recipes) {
adapter = recipesAdapter
addOnScrollListener(recipePreloaderFactory.create(recipesAdapter))
}
collectWhenViewResumed(viewModel.pagingData) {
logger.v { "setupRecipeAdapter: received data update" }
recipesAdapter.submitData(lifecycle, it)
}
collectWhenViewResumed(recipesAdapter.onPagesUpdatedFlow) {
logger.v { "setupRecipeAdapter: pages updated" }
binding.refresher.isRefreshing = false
}
collectWhenViewResumed(recipesAdapter.appendPaginationEnd()) {
logger.v { "onPaginationEnd() called" }
showLongToast(R.string.fragment_recipes_last_page_loaded_toast)
}
collectWhenViewResumed(recipesAdapter.refreshErrors()) {
onLoadFailure(it)
}
collectWhenViewResumed(binding.refresher.refreshRequestFlow(logger)) {
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) {
logger.w(error) { "onLoadFailure() called" }
val reason = error.toLoadErrorReasonText()?.let { getString(it) }
val toastText = if (reason == null) {
getString(R.string.fragment_recipes_load_failure_toast_no_reason)
} else {
getString(R.string.fragment_recipes_load_failure_toast, reason)
}
showLongToast(toastText)
}
override fun onDestroyView() {
@@ -87,4 +124,28 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) {
// Prevent RV leaking through mObservers list in adapter
binding.recipes.adapter = null
}
}
}
@StringRes
private fun Throwable.toLoadErrorReasonText(): Int? = when (this) {
is NetworkError.Unauthorized -> R.string.fragment_recipes_load_failure_toast_unauthorized
is NetworkError.NoServerConnection -> R.string.fragment_recipes_load_failure_toast_no_connection
is NetworkError.NotMealie, is NetworkError.MalformedUrl -> R.string.fragment_recipes_load_failure_toast_unexpected_response
else -> null
}
private fun <T : Any, VH : RecyclerView.ViewHolder> PagingDataAdapter<T, VH>.refreshErrors(): Flow<Throwable> {
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 }
.map { }
}

View File

@@ -4,12 +4,12 @@ import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import dagger.hilt.android.scopes.FragmentScoped
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.databinding.ViewHolderRecipeBinding
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.recipes.images.RecipeImageLoader
import javax.inject.Inject
import javax.inject.Singleton
class RecipesPagingAdapter private constructor(
private val logger: Logger,
@@ -18,19 +18,23 @@ class RecipesPagingAdapter private constructor(
private val clickListener: (RecipeSummaryEntity) -> Unit
) : PagingDataAdapter<RecipeSummaryEntity, RecipeViewHolder>(RecipeDiffCallback) {
@Singleton
@FragmentScoped
class Factory @Inject constructor(
private val logger: Logger,
private val recipeViewHolderFactory: RecipeViewHolder.Factory,
private val recipeImageLoader: RecipeImageLoader,
) {
fun build(
recipeImageLoader: RecipeImageLoader,
clickListener: (RecipeSummaryEntity) -> Unit,
) = RecipesPagingAdapter(logger, recipeImageLoader, recipeViewHolderFactory, clickListener)
fun build(clickListener: (RecipeSummaryEntity) -> Unit) = RecipesPagingAdapter(
logger,
recipeImageLoader,
recipeViewHolderFactory,
clickListener
)
}
override fun onBindViewHolder(holder: RecipeViewHolder, position: Int) {
logger.d { "onBindViewHolder() called with: holder = $holder, position = $position" }
val item = getItem(position)
holder.bind(item)
}

View File

@@ -23,6 +23,8 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.1"
app:helperText="@string/fragment_authentication_email_input_helper_text"
app:helperTextEnabled="true"
app:layout_constraintVertical_chainStyle="packed">
<com.google.android.material.textfield.TextInputEditText
@@ -33,20 +35,22 @@
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/password_input_layout"
style="@style/SmallMarginTextInputLayoutStyle"
android:hint="@string/fragment_authentication_input_hint_password"
app:layout_constraintBottom_toTopOf="@+id/button"
app:layout_constraintEnd_toEndOf="parent"
app:endIconMode="password_toggle"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/email_input_layout">
android:id="@+id/password_input_layout"
style="@style/SmallMarginTextInputLayoutStyle"
android:hint="@string/fragment_authentication_input_hint_password"
app:layout_constraintBottom_toTopOf="@+id/button"
app:layout_constraintEnd_toEndOf="parent"
app:endIconMode="password_toggle"
app:layout_constraintStart_toStartOf="parent"
app:helperText="@string/fragment_authentication_password_input_helper_text"
app:helperTextEnabled="true"
app:layout_constraintTop_toBottomOf="@+id/email_input_layout">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/password_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/password_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<Button

View File

@@ -1,42 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.baseurl.BaseURLFragment">
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.baseurl.BaseURLFragment">
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progress"
style="@style/IndeterminateProgress"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progress"
style="@style/IndeterminateProgress"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/url_input_layout"
style="@style/SmallMarginTextInputLayoutStyle"
android:hint="@string/fragment_authentication_input_hint_url"
app:layout_constraintBottom_toTopOf="@+id/button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/url_input_layout"
style="@style/SmallMarginTextInputLayoutStyle"
android:hint="@string/fragment_authentication_input_hint_url"
app:helperText="@string/fragment_base_url_url_input_helper_text"
app:helperTextEnabled="true"
app:layout_constraintBottom_toTopOf="@+id/button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.2"
app:layout_constraintVertical_chainStyle="packed">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/url_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textUri" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/url_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textUri" />
</com.google.android.material.textfield.TextInputLayout>
<Button
android:id="@+id/button"
style="@style/SmallMarginButton"
android:text="@string/fragment_base_url_save"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/url_input_layout" />
<Button
android:id="@+id/button"
style="@style/SmallMarginButton"
android:text="@string/fragment_base_url_save"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/url_input_layout" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -38,4 +38,13 @@
<string name="fragment_add_recipe_save_error">Что-то пошло не так</string>
<string name="fragment_add_recipe_save_success">Рецепт сохранен успешно</string>
<string name="fragment_add_recipe_clear_button">Очистить</string>
<string name="fragment_base_url_url_input_helper_text">Пример: demo.mealie.io</string>
<string name="fragment_authentication_email_input_helper_text">Пример: changeme@email.com</string>
<string name="fragment_authentication_password_input_helper_text">Пример: demo</string>
<string name="fragment_recipes_last_page_loaded_toast">Последняя страница</string>
<string name="fragment_recipes_load_failure_toast">Ошибка загрузки: %1$s.</string>
<string name="fragment_recipes_load_failure_toast_unauthorized">неавторизован</string>
<string name="fragment_recipes_load_failure_toast_unexpected_response">неожиданный ответ</string>
<string name="fragment_recipes_load_failure_toast_no_connection">нет соединения</string>
<string name="fragment_recipes_load_failure_toast_no_reason">Ошибка загрузки.</string>
</resources>

View File

@@ -42,4 +42,13 @@
<string name="fragment_add_recipe_save_error">Something went wrong</string>
<string name="fragment_add_recipe_save_success">Saved recipe successfully</string>
<string name="fragment_add_recipe_clear_button">Clear</string>
<string name="fragment_base_url_url_input_helper_text">Example: demo.mealie.io</string>
<string name="fragment_authentication_email_input_helper_text">Example: changeme@email.com</string>
<string name="fragment_authentication_password_input_helper_text">Example: demo</string>
<string name="fragment_recipes_last_page_loaded_toast">Last page loaded</string>
<string name="fragment_recipes_load_failure_toast" comment="EXAMPLE: Load error: unauthorized.">Load error: %1$s.</string>
<string name="fragment_recipes_load_failure_toast_no_reason">Load failed.</string>
<string name="fragment_recipes_load_failure_toast_unauthorized">unauthorized</string>
<string name="fragment_recipes_load_failure_toast_unexpected_response">unexpected response</string>
<string name="fragment_recipes_load_failure_toast_no_connection">no connection</string>
</resources>

View File

@@ -0,0 +1,42 @@
package gq.kirmanak.mealient.extensions
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class FlowExtensionsKtTest {
@Test
fun `when flow has an update then valueUpdatesOnly sends updated value`() = runTest {
val flow = flowOf(1, 2)
assertThat(flow.valueUpdatesOnly().toList()).isEqualTo(listOf(2))
}
@Test
fun `when flow has repeated values then valueUpdatesOnly sends updated value`() = runTest {
val flow = flowOf(1, 1, 1, 2)
assertThat(flow.valueUpdatesOnly().toList()).isEqualTo(listOf(2))
}
@Test
fun `when flow has one value then valueUpdatesOnly is empty`() = runTest {
val flow = flowOf(1)
assertThat(flow.valueUpdatesOnly().toList()).isEmpty()
}
@Test
fun `when flow has two updates then valueUpdatesOnly sends both`() = runTest {
val flow = flowOf(1, 2, 1)
assertThat(flow.valueUpdatesOnly().toList()).isEqualTo(listOf(2, 1))
}
@Test
fun `when flow has three updates then valueUpdatesOnly sends all`() = runTest {
val flow = flowOf(1, 2, 1, 3)
assertThat(flow.valueUpdatesOnly().toList()).isEqualTo(listOf(2, 1, 3))
}
}

View File

@@ -0,0 +1,75 @@
package gq.kirmanak.mealient.ui.recipes
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
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.every
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
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 RecipeViewModelTest {
@MockK
lateinit var authRepo: AuthRepo
@MockK(relaxed = true)
lateinit var recipeRepo: RecipeRepo
private val logger: Logger = FakeLogger()
@get:Rule
val instantExecutorRule = InstantTaskExecutorRule()
@Before
fun setUp() {
MockKAnnotations.init(this)
Dispatchers.setMain(UnconfinedTestDispatcher())
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `when authRepo isAuthorized changes to true expect isAuthorized update`() {
every { authRepo.isAuthorizedFlow } returns flowOf(false, true)
assertThat(createSubject().isAuthorized.value).isTrue()
}
@Test
fun `when authRepo isAuthorized changes to false expect isAuthorized update`() {
every { authRepo.isAuthorizedFlow } returns flowOf(true, false)
assertThat(createSubject().isAuthorized.value).isFalse()
}
@Test
fun `when authRepo isAuthorized doesn't change expect isAuthorized null`() {
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()
}
private fun createSubject() = RecipeViewModel(recipeRepo, authRepo, logger)
}

View File

@@ -35,6 +35,8 @@ swipeRefreshLayout = "1.1.0"
splashScreen = "1.0.0"
# https://developer.android.com/jetpack/androidx/releases/lifecycle
lifecycle = "2.5.1"
# https://developer.android.com/jetpack/androidx/releases/arch-core
coreTesting = "2.1.0"
# https://github.com/square/retrofit/tags
retrofit = "2.9.0"
# https://github.com/JakeWharton/retrofit2-kotlinx-serialization-converter/tags
@@ -121,6 +123,7 @@ androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version
androidx-constraintLayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "contraintLayout" }
androidx-swipeRefreshLayout = { group = "androidx.swiperefreshlayout", name = "swiperefreshlayout", version.ref = "swipeRefreshLayout" }
androidx-splashScreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashScreen" }
androidx-coreTesting = { group = "androidx.arch.core", name = "core-testing", version.ref = "coreTesting" }
androidx-paging-runtimeKtx = { group = "androidx.paging", name = "paging-runtime-ktx", version.ref = "paging" }
androidx-paging-commonKtx = { group = "androidx.paging", name = "paging-common-ktx", version.ref = "paging" }