diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1025875..ccca534 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -122,6 +122,7 @@ dependencies { testImplementation(libs.robolectric) testImplementation(libs.androidx.test.junit) + testImplementation(libs.androidx.coreTesting) testImplementation(libs.google.truth) diff --git a/app/src/main/java/gq/kirmanak/mealient/extensions/FlowExtensions.kt b/app/src/main/java/gq/kirmanak/mealient/extensions/FlowExtensions.kt new file mode 100644 index 0000000..eedafc0 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/extensions/FlowExtensions.kt @@ -0,0 +1,23 @@ +package gq.kirmanak.mealient.extensions + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector + +fun Flow.valueUpdatesOnly(): Flow = when (this) { + is ValueUpdateOnlyFlowImpl -> this + else -> ValueUpdateOnlyFlowImpl(this) +} + +private class ValueUpdateOnlyFlowImpl(private val upstream: Flow) : Flow { + + override suspend fun collect(collector: FlowCollector) { + var previousValue: T? = null + upstream.collect { value -> + if (previousValue != null && previousValue != value) { + collector.emit(value) + } + previousValue = value + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/extensions/FragmentExtensions.kt b/app/src/main/java/gq/kirmanak/mealient/extensions/FragmentExtensions.kt index dd4e678..4502b15 100644 --- a/app/src/main/java/gq/kirmanak/mealient/extensions/FragmentExtensions.kt +++ b/app/src/main/java/gq/kirmanak/mealient/extensions/FragmentExtensions.kt @@ -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 Fragment.collectWhenViewResumed( - flow: Flow, - collector: FlowCollector, -) = launchWhenViewResumed { flow.collect(collector) } +fun Fragment.collectWhenViewResumed(flow: Flow, collector: FlowCollector) { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { + flow.collect(collector) + } + } +} -fun Fragment.launchWhenViewResumed( - block: suspend CoroutineScope.() -> Unit, -) = viewLifecycleOwner.lifecycleScope.launchWhenResumed(block) \ No newline at end of file +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 +} diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewModel.kt index b621488..7e3a733 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewModel.kt @@ -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(null) + val isAuthorized: LiveData = _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) + } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt index d775212..b0fe05b 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt @@ -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 } -} \ No newline at end of file +} + +@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 PagingDataAdapter.refreshErrors(): Flow { + return loadStateFlow + .map { it.refresh } + .valueUpdatesOnly() + .filterIsInstance() + .map { it.error } +} + +private fun PagingDataAdapter.appendPaginationEnd(): Flow { + return loadStateFlow + .map { it.append.endOfPaginationReached } + .valueUpdatesOnly() + .filter { it } + .map { } +} diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesPagingAdapter.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesPagingAdapter.kt index c2cb7af..cde6fb9 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesPagingAdapter.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesPagingAdapter.kt @@ -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(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) } diff --git a/app/src/main/res/layout/fragment_authentication.xml b/app/src/main/res/layout/fragment_authentication.xml index 66cd7e7..2db484c 100644 --- a/app/src/main/res/layout/fragment_authentication.xml +++ b/app/src/main/res/layout/fragment_authentication.xml @@ -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"> + 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"> - +