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.robolectric)
testImplementation(libs.androidx.test.junit) testImplementation(libs.androidx.test.junit)
testImplementation(libs.androidx.coreTesting)
testImplementation(libs.google.truth) 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 package gq.kirmanak.mealient.extensions
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CoroutineScope import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.launch
fun <T> Fragment.collectWhenViewResumed( fun <T> Fragment.collectWhenViewResumed(flow: Flow<T>, collector: FlowCollector<T>) {
flow: Flow<T>, viewLifecycleOwner.lifecycleScope.launch {
collector: FlowCollector<T>, viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
) = launchWhenViewResumed { flow.collect(collector) } flow.collect(collector)
}
}
}
fun Fragment.launchWhenViewResumed( fun Fragment.showLongToast(@StringRes text: Int) = showLongToast(getString(text))
block: suspend CoroutineScope.() -> Unit,
) = viewLifecycleOwner.lifecycleScope.launchWhenResumed(block) 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 package gq.kirmanak.mealient.ui.recipes
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.recipes.RecipeRepo 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 import javax.inject.Inject
@HiltViewModel @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) 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.os.Bundle
import android.view.View import android.view.View
import androidx.annotation.StringRes
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.paging.LoadState
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.RecyclerView
import by.kirich1409.viewbindingdelegate.viewBinding 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.FragmentRecipesBinding
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
import gq.kirmanak.mealient.extensions.showLongToast
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.images.RecipeImageLoader
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.filter
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@@ -29,9 +39,6 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) {
@Inject @Inject
lateinit var logger: Logger lateinit var logger: Logger
@Inject
lateinit var recipeImageLoader: RecipeImageLoader
@Inject @Inject
lateinit var recipePagingAdapterFactory: RecipesPagingAdapter.Factory lateinit var recipePagingAdapterFactory: RecipesPagingAdapter.Factory
@@ -51,34 +58,64 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) {
logger.v { "navigateToRecipeInfo() called with: recipeSummaryEntity = $recipeSummaryEntity" } logger.v { "navigateToRecipeInfo() called with: recipeSummaryEntity = $recipeSummaryEntity" }
findNavController().navigate( findNavController().navigate(
RecipesFragmentDirections.actionRecipesFragmentToRecipeInfoFragment( RecipesFragmentDirections.actionRecipesFragmentToRecipeInfoFragment(
recipeSlug = recipeSummaryEntity.slug, recipeSlug = recipeSummaryEntity.slug, recipeId = recipeSummaryEntity.remoteId
recipeId = recipeSummaryEntity.remoteId
) )
) )
} }
private fun setupRecipeAdapter() { private fun setupRecipeAdapter() {
logger.v { "setupRecipeAdapter() called" } logger.v { "setupRecipeAdapter() called" }
val recipesAdapter = recipePagingAdapterFactory.build(
recipeImageLoader = recipeImageLoader, val recipesAdapter = recipePagingAdapterFactory.build { navigateToRecipeInfo(it) }
clickListener = ::navigateToRecipeInfo
)
with(binding.recipes) { with(binding.recipes) {
adapter = recipesAdapter adapter = recipesAdapter
addOnScrollListener(recipePreloaderFactory.create(recipesAdapter)) addOnScrollListener(recipePreloaderFactory.create(recipesAdapter))
} }
collectWhenViewResumed(viewModel.pagingData) { collectWhenViewResumed(viewModel.pagingData) {
logger.v { "setupRecipeAdapter: received data update" } logger.v { "setupRecipeAdapter: received data update" }
recipesAdapter.submitData(lifecycle, it) recipesAdapter.submitData(lifecycle, it)
} }
collectWhenViewResumed(recipesAdapter.onPagesUpdatedFlow) { collectWhenViewResumed(recipesAdapter.onPagesUpdatedFlow) {
logger.v { "setupRecipeAdapter: pages updated" } logger.v { "setupRecipeAdapter: pages updated" }
binding.refresher.isRefreshing = false 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)) { collectWhenViewResumed(binding.refresher.refreshRequestFlow(logger)) {
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) {
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() { override fun onDestroyView() {
@@ -88,3 +125,27 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) {
binding.recipes.adapter = null 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 android.view.ViewGroup
import androidx.paging.PagingDataAdapter import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import dagger.hilt.android.scopes.FragmentScoped
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.databinding.ViewHolderRecipeBinding import gq.kirmanak.mealient.databinding.ViewHolderRecipeBinding
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.recipes.images.RecipeImageLoader import gq.kirmanak.mealient.ui.recipes.images.RecipeImageLoader
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
class RecipesPagingAdapter private constructor( class RecipesPagingAdapter private constructor(
private val logger: Logger, private val logger: Logger,
@@ -18,19 +18,23 @@ class RecipesPagingAdapter private constructor(
private val clickListener: (RecipeSummaryEntity) -> Unit private val clickListener: (RecipeSummaryEntity) -> Unit
) : PagingDataAdapter<RecipeSummaryEntity, RecipeViewHolder>(RecipeDiffCallback) { ) : PagingDataAdapter<RecipeSummaryEntity, RecipeViewHolder>(RecipeDiffCallback) {
@Singleton @FragmentScoped
class Factory @Inject constructor( class Factory @Inject constructor(
private val logger: Logger, private val logger: Logger,
private val recipeViewHolderFactory: RecipeViewHolder.Factory, private val recipeViewHolderFactory: RecipeViewHolder.Factory,
private val recipeImageLoader: RecipeImageLoader,
) { ) {
fun build( fun build(clickListener: (RecipeSummaryEntity) -> Unit) = RecipesPagingAdapter(
recipeImageLoader: RecipeImageLoader, logger,
clickListener: (RecipeSummaryEntity) -> Unit, recipeImageLoader,
) = RecipesPagingAdapter(logger, recipeImageLoader, recipeViewHolderFactory, clickListener) recipeViewHolderFactory,
clickListener
)
} }
override fun onBindViewHolder(holder: RecipeViewHolder, position: Int) { override fun onBindViewHolder(holder: RecipeViewHolder, position: Int) {
logger.d { "onBindViewHolder() called with: holder = $holder, position = $position" }
val item = getItem(position) val item = getItem(position)
holder.bind(item) holder.bind(item)
} }

View File

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

View File

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

View File

@@ -38,4 +38,13 @@
<string name="fragment_add_recipe_save_error">Что-то пошло не так</string> <string name="fragment_add_recipe_save_error">Что-то пошло не так</string>
<string name="fragment_add_recipe_save_success">Рецепт сохранен успешно</string> <string name="fragment_add_recipe_save_success">Рецепт сохранен успешно</string>
<string name="fragment_add_recipe_clear_button">Очистить</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> </resources>

View File

@@ -42,4 +42,13 @@
<string name="fragment_add_recipe_save_error">Something went wrong</string> <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_save_success">Saved recipe successfully</string>
<string name="fragment_add_recipe_clear_button">Clear</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> </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" splashScreen = "1.0.0"
# https://developer.android.com/jetpack/androidx/releases/lifecycle # https://developer.android.com/jetpack/androidx/releases/lifecycle
lifecycle = "2.5.1" lifecycle = "2.5.1"
# https://developer.android.com/jetpack/androidx/releases/arch-core
coreTesting = "2.1.0"
# https://github.com/square/retrofit/tags # https://github.com/square/retrofit/tags
retrofit = "2.9.0" retrofit = "2.9.0"
# https://github.com/JakeWharton/retrofit2-kotlinx-serialization-converter/tags # 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-constraintLayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "contraintLayout" }
androidx-swipeRefreshLayout = { group = "androidx.swiperefreshlayout", name = "swiperefreshlayout", version.ref = "swipeRefreshLayout" } androidx-swipeRefreshLayout = { group = "androidx.swiperefreshlayout", name = "swiperefreshlayout", version.ref = "swipeRefreshLayout" }
androidx-splashScreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashScreen" } 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-runtimeKtx = { group = "androidx.paging", name = "paging-runtime-ktx", version.ref = "paging" }
androidx-paging-commonKtx = { group = "androidx.paging", name = "paging-common-ktx", version.ref = "paging" } androidx-paging-commonKtx = { group = "androidx.paging", name = "paging-common-ktx", version.ref = "paging" }