Merge pull request #83 from kirmanak/improve-onboarding
Improve onboarding experience
This commit is contained in:
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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() {
|
||||||
@@ -87,4 +124,28 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) {
|
|||||||
// Prevent RV leaking through mObservers list in adapter
|
// Prevent RV leaking through mObservers list in adapter
|
||||||
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 { }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user