Use Compose to draw the list of recipes (#187)

* Add paging-compose dependency

* Move progress indicator to separate module

* Introduce color scheme preview

* Move loading helper to UI module

* Move helper composables to UI module

* Rearrange shopping lists module

* Add LazyPagingColumnPullRefresh Composable

* Add BaseComposeFragment

* Add pagingDataRecipeState

* Add showFavoriteIcon to recipe state

* Disable unused placeholders

* Make "Try again" button optional

* Fix example email

* Wrap recipe info into a Scaffold

* Add dialog to confirm deletion

* Add RecipeItem Composable

* Add RecipeListError Composable

* Add RecipeList Composable

* Replace recipes list Views with Compose

* Update UI test

* Remove application from ViewModel
This commit is contained in:
Kirill Kamakin
2023-11-23 07:23:30 +01:00
committed by GitHub
parent 4301c623c9
commit f6f44c7592
72 changed files with 935 additions and 1131 deletions

View File

@@ -120,18 +120,11 @@ dependencies {
androidTestImplementation(libs.google.dagger.hiltAndroidTesting)
implementation(libs.androidx.paging.runtimeKtx)
implementation(libs.androidx.paging.compose)
testImplementation(libs.androidx.paging.commonKtx)
implementation(libs.jetbrains.kotlinx.datetime)
implementation(libs.bumptech.glide.glide)
implementation(libs.bumptech.glide.okhttp3)
implementation(libs.bumptech.glide.recyclerview) {
// Excludes the support library because it's already included by Glide.
isTransitive = false
}
ksp(libs.bumptech.glide.ksp)
implementation(libs.kirich1409.viewBinding)
implementation(libs.androidx.datastore.preferences)
@@ -158,6 +151,7 @@ dependencies {
androidTestImplementation(libs.junit)
androidTestImplementation(libs.androidx.test.junit)
androidTestImplementation(libs.kaspersky.kaspresso)
androidTestImplementation(libs.kaspersky.kaspresso.compose)
androidTestImplementation(libs.okhttp3.mockwebserver)
androidTestImplementation(libs.androidx.test.core)
androidTestImplementation(libs.androidx.test.rules)

View File

@@ -1,6 +1,8 @@
package gq.kirmanak.mealient
import androidx.test.ext.junit.rules.activityScenarioRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import com.kaspersky.components.composesupport.config.withComposeSupport
import com.kaspersky.kaspresso.kaspresso.Kaspresso
import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
import dagger.hilt.android.testing.HiltAndroidRule
import gq.kirmanak.mealient.ui.activity.MainActivity
@@ -9,13 +11,15 @@ import org.junit.After
import org.junit.Before
import org.junit.Rule
abstract class BaseTestCase : TestCase() {
abstract class BaseTestCase : TestCase(
kaspressoBuilder = Kaspresso.Builder.withComposeSupport(),
) {
@get:Rule(order = 0)
var hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val mainActivityRule = activityScenarioRule<MainActivity>()
val mainActivityRule = createAndroidComposeRule<MainActivity>()
lateinit var mockWebServer: MockWebServer

View File

@@ -4,6 +4,7 @@ import dagger.hilt.android.testing.HiltAndroidTest
import gq.kirmanak.mealient.screen.BaseUrlScreen
import gq.kirmanak.mealient.screen.DisclaimerScreen
import gq.kirmanak.mealient.screen.RecipesListScreen
import io.github.kakaocup.kakao.common.utilities.getResourceString
import org.junit.Before
import org.junit.Test
@@ -65,10 +66,10 @@ class FirstSetUpTest : BaseTestCase() {
}
step("Check that empty list of recipes is shown") {
RecipesListScreen {
emptyListText {
isVisible()
hasText(R.string.fragment_recipes_list_no_recipes)
RecipesListScreen(mainActivityRule).apply {
errorText {
assertIsDisplayed()
assertTextEquals(getResourceString(R.string.fragment_recipes_load_failure_toast_no_reason))
}
}
}

View File

@@ -1,13 +1,20 @@
package gq.kirmanak.mealient.screen
import com.kaspersky.kaspresso.screens.KScreen
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.ui.recipes.RecipesListFragment
import io.github.kakaocup.kakao.text.KTextView
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.printToLog
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import org.junit.rules.TestRule
object RecipesListScreen : KScreen<RecipesListScreen>() {
override val layoutId: Int = R.layout.fragment_recipes_list
override val viewClass: Class<*> = RecipesListFragment::class.java
class RecipesListScreen<R : TestRule, A : ComponentActivity>(
semanticsProvider: AndroidComposeTestRule<R, A>,
) : ComposeScreen<RecipesListScreen<R, A>>(semanticsProvider) {
val emptyListText = KTextView { withId(R.id.empty_list_text) }
init {
semanticsProvider.onRoot(useUnmergedTree = true).printToLog("RecipesListScreen")
}
val errorText: KNode = child { hasTestTag("empty-list-error-text") }
}

View File

@@ -28,7 +28,7 @@ class RecipeRepoImpl @Inject constructor(
logger.v { "createPager() called" }
val pagingConfig = PagingConfig(
pageSize = LOAD_PAGE_SIZE,
enablePlaceholders = true,
enablePlaceholders = false,
initialLoadSize = INITIAL_LOAD_PAGE_SIZE,
)
return Pager(

View File

@@ -1,21 +0,0 @@
package gq.kirmanak.mealient.di
import com.bumptech.glide.load.model.ModelLoaderFactory
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.logging.Logger
import okhttp3.OkHttpClient
import java.io.InputStream
@EntryPoint
@InstallIn(SingletonComponent::class)
interface GlideModuleEntryPoint {
fun provideLogger(): Logger
fun provideOkHttp(): OkHttpClient
fun provideRecipeLoaderFactory(): ModelLoaderFactory<RecipeSummaryEntity, InputStream>
}

View File

@@ -1,20 +1,13 @@
package gq.kirmanak.mealient.di
import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.request.RequestOptions
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.network.MealieDataSourceWrapper
import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.data.recipes.impl.*
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.ui.recipes.images.RecipeModelLoaderFactory
import java.io.InputStream
@Module
@InstallIn(SingletonComponent::class)
@@ -29,16 +22,6 @@ interface RecipeModule {
@Binds
fun bindImageUrlProvider(recipeImageUrlProviderImpl: RecipeImageUrlProviderImpl): RecipeImageUrlProvider
@Binds
fun bindModelLoaderFactory(recipeModelLoaderFactory: RecipeModelLoaderFactory): ModelLoaderFactory<RecipeSummaryEntity, InputStream>
@Binds
fun bindRecipePagingSourceFactory(recipePagingSourceFactoryImpl: RecipePagingSourceFactoryImpl): RecipePagingSourceFactory
companion object {
@Provides
fun provideGlideRequestOptions(): RequestOptions = RequestOptions.centerCropTransform()
.placeholder(R.drawable.placeholder_recipe)
}
}

View File

@@ -1,25 +0,0 @@
package gq.kirmanak.mealient.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.android.components.FragmentComponent
import dagger.hilt.android.scopes.FragmentScoped
import gq.kirmanak.mealient.ui.recipes.images.RecipeImageLoader
import gq.kirmanak.mealient.ui.recipes.images.RecipeImageLoaderImpl
import gq.kirmanak.mealient.ui.recipes.images.RecipePreloaderFactory
import gq.kirmanak.mealient.ui.recipes.images.RecipePreloaderFactoryImpl
@Module
@InstallIn(FragmentComponent::class)
interface UiModule {
@Binds
@FragmentScoped
fun provideRecipeImageLoader(recipeImageLoaderImpl: RecipeImageLoaderImpl): RecipeImageLoader
@Binds
@FragmentScoped
fun bindRecipePreloaderFactory(recipePreloaderFactoryImpl: RecipePreloaderFactoryImpl): RecipePreloaderFactory
}

View File

@@ -1,47 +0,0 @@
package gq.kirmanak.mealient.ui
import android.content.Context
import com.bumptech.glide.Glide
import com.bumptech.glide.Registry
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader
import com.bumptech.glide.load.model.GlideUrl
import com.bumptech.glide.module.AppGlideModule
import dagger.hilt.android.EntryPointAccessors.fromApplication
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.di.GlideModuleEntryPoint
import gq.kirmanak.mealient.logging.Logger
import java.io.InputStream
@GlideModule
class MealieGlideModule : AppGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
super.registerComponents(context, glide, registry)
getLogger(context).v { "registerComponents() called with: context = $context, glide = $glide, registry = $registry" }
replaceOkHttp(context, registry)
appendRecipeLoader(registry, context)
}
private fun appendRecipeLoader(registry: Registry, context: Context) {
getLogger(context).v { "appendRecipeLoader() called with: registry = $registry, context = $context" }
registry.append(
RecipeSummaryEntity::class.java,
InputStream::class.java,
getEntryPoint(context).provideRecipeLoaderFactory(),
)
}
private fun replaceOkHttp(context: Context, registry: Registry) {
getLogger(context).v { "replaceOkHttp() called with: context = $context, registry = $registry" }
val okHttp = getEntryPoint(context).provideOkHttp()
registry.replace(
GlideUrl::class.java, InputStream::class.java, OkHttpUrlLoader.Factory(okHttp)
)
}
private fun getEntryPoint(context: Context): GlideModuleEntryPoint =
fromApplication(context, GlideModuleEntryPoint::class.java)
private fun getLogger(context: Context): Logger = getEntryPoint(context).provideLogger()
}

View File

@@ -1,98 +0,0 @@
package gq.kirmanak.mealient.ui.recipes
import android.view.View
import androidx.annotation.StringRes
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.scopes.FragmentScoped
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.databinding.ViewHolderRecipeBinding
import gq.kirmanak.mealient.extensions.resources
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.recipes.images.RecipeImageLoader
class RecipeViewHolder @AssistedInject constructor(
private val logger: Logger,
@Assisted private val binding: ViewHolderRecipeBinding,
private val recipeImageLoader: RecipeImageLoader,
@Assisted private val showFavoriteIcon: Boolean,
@Assisted private val clickListener: (ClickEvent) -> Unit,
) : RecyclerView.ViewHolder(binding.root) {
@FragmentScoped
@AssistedFactory
interface Factory {
fun build(
showFavoriteIcon: Boolean,
binding: ViewHolderRecipeBinding,
clickListener: (ClickEvent) -> Unit,
): RecipeViewHolder
}
sealed class ClickEvent {
abstract val recipeSummaryEntity: RecipeSummaryEntity
data class FavoriteClick(
override val recipeSummaryEntity: RecipeSummaryEntity
) : ClickEvent()
data class RecipeClick(
override val recipeSummaryEntity: RecipeSummaryEntity
) : ClickEvent()
data class DeleteClick(
override val recipeSummaryEntity: RecipeSummaryEntity
) : ClickEvent()
}
private val loadingPlaceholder by lazy {
binding.resources.getString(R.string.view_holder_recipe_text_placeholder)
}
fun bind(item: RecipeSummaryEntity?) {
logger.v { "bind() called with: item = $item" }
binding.name.text = item?.name ?: loadingPlaceholder
recipeImageLoader.loadRecipeImage(binding.image, item)
item?.let { entity ->
binding.root.setOnClickListener {
logger.d { "bind: item clicked $entity" }
clickListener(ClickEvent.RecipeClick(entity))
}
binding.favoriteIcon.isVisible = showFavoriteIcon
binding.favoriteIcon.setOnClickListener {
clickListener(ClickEvent.FavoriteClick(entity))
}
binding.favoriteIcon.setImageResource(
if (item.isFavorite) {
R.drawable.ic_favorite_filled
} else {
R.drawable.ic_favorite_unfilled
}
)
binding.favoriteIcon.setContentDescription(
if (item.isFavorite) {
R.string.view_holder_recipe_favorite_content_description
} else {
R.string.view_holder_recipe_non_favorite_content_description
}
)
binding.deleteIcon.setOnClickListener {
clickListener(ClickEvent.DeleteClick(item))
}
}
}
}
private fun View.setContentDescription(@StringRes resId: Int) {
contentDescription = context.getString(resId)
}

View File

@@ -1,235 +0,0 @@
package gq.kirmanak.mealient.ui.recipes
import android.annotation.SuppressLint
import android.content.DialogInterface
import android.os.Bundle
import android.view.View
import androidx.annotation.StringRes
import androidx.core.view.isVisible
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 com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.architecture.valueUpdatesOnly
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.databinding.FragmentRecipesListBinding
import gq.kirmanak.mealient.datasource.NetworkError
import gq.kirmanak.mealient.extensions.*
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.CheckableMenuItem
import gq.kirmanak.mealient.ui.activity.MainActivityViewModel
import gq.kirmanak.mealient.ui.recipes.RecipesListFragmentDirections.Companion.actionRecipesFragmentToRecipeInfoFragment
import gq.kirmanak.mealient.ui.recipes.images.RecipePreloaderFactory
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.map
import javax.inject.Inject
@AndroidEntryPoint
class RecipesListFragment : Fragment(R.layout.fragment_recipes_list) {
private val binding by viewBinding(FragmentRecipesListBinding::bind)
private val viewModel by viewModels<RecipesListViewModel>()
private val activityViewModel by activityViewModels<MainActivityViewModel>()
@Inject
lateinit var logger: Logger
@Inject
lateinit var recipePagingAdapterFactory: RecipesPagingAdapter.Factory
@Inject
lateinit var recipePreloaderFactory: RecipePreloaderFactory
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
logger.v { "onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState" }
activityViewModel.updateUiState {
it.copy(
navigationVisible = true,
searchVisible = true,
checkedMenuItem = CheckableMenuItem.RecipesList,
)
}
collectWhenViewResumed(viewModel.showFavoriteIcon) { showFavoriteIcon ->
setupRecipeAdapter(showFavoriteIcon)
}
collectWhenViewResumed(viewModel.deleteRecipeResult) {
logger.d { "Delete recipe result is $it" }
if (it.isFailure) {
showLongToast(R.string.fragment_recipes_delete_recipe_failed)
}
}
hideKeyboardOnScroll()
}
@SuppressLint("ClickableViewAccessibility")
private fun hideKeyboardOnScroll() {
binding.recipes.setOnTouchListener { _, _ ->
activityViewModel.clearSearchViewFocus()
false
}
}
private fun navigateToRecipeInfo(id: String) {
logger.v { "navigateToRecipeInfo() called with: id = $id" }
val directions = actionRecipesFragmentToRecipeInfoFragment(id)
binding.root.hideKeyboard()
findNavController().navigate(directions)
}
private fun onRecipeClicked(recipe: RecipeSummaryEntity) {
logger.v { "onRecipeClicked() called with: recipe = $recipe" }
binding.progress.isVisible = true
viewModel.refreshRecipeInfo(recipe.slug).observe(viewLifecycleOwner) {
binding.progress.isVisible = false
if (!isNavigatingSomewhere()) navigateToRecipeInfo(recipe.remoteId)
}
}
private fun isNavigatingSomewhere(): Boolean {
logger.v { "isNavigatingSomewhere() called" }
return findNavController().currentDestination?.id != R.id.recipesListFragment
}
private fun setupRecipeAdapter(showFavoriteIcon: Boolean) {
logger.v { "setupRecipeAdapter() called" }
val recipesAdapter = recipePagingAdapterFactory.build(showFavoriteIcon) {
when (it) {
is RecipeViewHolder.ClickEvent.FavoriteClick -> {
onFavoriteClick(it)
}
is RecipeViewHolder.ClickEvent.RecipeClick -> {
onRecipeClicked(it.recipeSummaryEntity)
}
is RecipeViewHolder.ClickEvent.DeleteClick -> {
onDeleteClick(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
binding.emptyListText.isVisible = recipesAdapter.itemCount == 0
}
collectWhenViewResumed(recipesAdapter.appendPaginationEnd()) {
logger.v { "onPaginationEnd() called" }
showLongToast(R.string.fragment_recipes_last_page_loaded_toast)
}
collectWhenViewResumed(recipesAdapter.sourceIsRefreshing()) { disableSwipeRefresh ->
logger.v { "setupRecipeAdapter: changing refresher enabled state to ${!disableSwipeRefresh}" }
binding.refresher.isEnabled = !disableSwipeRefresh
}
collectWhenViewResumed(recipesAdapter.refreshErrors()) {
onLoadFailure(it)
}
collectWhenViewResumed(binding.refresher.refreshRequestFlow(logger)) {
logger.v { "setupRecipeAdapter: received refresh request" }
recipesAdapter.refresh()
}
}
private fun onDeleteClick(event: RecipeViewHolder.ClickEvent) {
logger.v { "onDeleteClick() called with: event = $event" }
val entity = event.recipeSummaryEntity
val message = getString(
R.string.fragment_recipes_delete_recipe_confirm_dialog_message, entity.name
)
val onPositiveClick = DialogInterface.OnClickListener { _, _ ->
viewModel.onDeleteConfirm(entity)
}
val positiveBtnResId = R.string.fragment_recipes_delete_recipe_confirm_dialog_positive_btn
val titleResId = R.string.fragment_recipes_delete_recipe_confirm_dialog_title
val negativeBtnResId = R.string.fragment_recipes_delete_recipe_confirm_dialog_negative_btn
MaterialAlertDialogBuilder(requireContext())
.setTitle(titleResId)
.setMessage(message)
.setPositiveButton(positiveBtnResId, onPositiveClick)
.setNegativeButton(negativeBtnResId) { _, _ -> }
.show()
}
private fun onFavoriteClick(event: RecipeViewHolder.ClickEvent) {
logger.v { "onFavoriteClick() called with: event = $event" }
viewModel.onFavoriteIconClick(event.recipeSummaryEntity).observe(viewLifecycleOwner) {
logger.d { "onFavoriteClick: result is $it" }
if (it.isFailure) {
showLongToast(R.string.fragment_recipes_favorite_update_failed)
} else {
val name = event.recipeSummaryEntity.name
val isFavorite = it.getOrThrow()
val message = if (isFavorite) {
getString(R.string.fragment_recipes_favorite_added, name)
} else {
getString(R.string.fragment_recipes_favorite_removed, name)
}
showLongToast(message)
}
}
}
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() {
super.onDestroyView()
logger.v { "onDestroyView() called" }
// 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 { }
}
private fun <T : Any, VH : RecyclerView.ViewHolder> PagingDataAdapter<T, VH>.sourceIsRefreshing(): Flow<Boolean> {
return loadStateFlow.map { it.source.refresh !is LoadState.NotLoading }.valueUpdatesOnly()
}

View File

@@ -1,56 +0,0 @@
package gq.kirmanak.mealient.ui.recipes
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
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
class RecipesPagingAdapter @AssistedInject constructor(
private val logger: Logger,
private val recipeViewHolderFactory: RecipeViewHolder.Factory,
@Assisted private val showFavoriteIcon: Boolean,
@Assisted private val clickListener: (RecipeViewHolder.ClickEvent) -> Unit
) : PagingDataAdapter<RecipeSummaryEntity, RecipeViewHolder>(RecipeDiffCallback) {
@FragmentScoped
@AssistedFactory
interface Factory {
fun build(
showFavoriteIcon: Boolean,
clickListener: (RecipeViewHolder.ClickEvent) -> Unit,
): RecipesPagingAdapter
}
override fun onBindViewHolder(holder: RecipeViewHolder, position: Int) {
logger.d { "onBindViewHolder() called with: holder = $holder, position = $position" }
val item = getItem(position)
holder.bind(item)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecipeViewHolder {
logger.v { "onCreateViewHolder() called with: parent = $parent, viewType = $viewType" }
val inflater = LayoutInflater.from(parent.context)
val binding = ViewHolderRecipeBinding.inflate(inflater, parent, false)
return recipeViewHolderFactory.build(showFavoriteIcon, binding, clickListener)
}
private object RecipeDiffCallback : DiffUtil.ItemCallback<RecipeSummaryEntity>() {
override fun areItemsTheSame(
oldItem: RecipeSummaryEntity,
newItem: RecipeSummaryEntity,
): Boolean = oldItem.remoteId == newItem.remoteId
override fun areContentsTheSame(
oldItem: RecipeSummaryEntity,
newItem: RecipeSummaryEntity,
): Boolean = oldItem == newItem
}
}

View File

@@ -1,9 +0,0 @@
package gq.kirmanak.mealient.ui.recipes.images
import android.widget.ImageView
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
interface RecipeImageLoader {
fun loadRecipeImage(view: ImageView, recipe: RecipeSummaryEntity?)
}

View File

@@ -1,23 +0,0 @@
package gq.kirmanak.mealient.ui.recipes.images
import android.widget.ImageView
import androidx.fragment.app.Fragment
import com.bumptech.glide.Glide
import com.bumptech.glide.request.RequestOptions
import dagger.hilt.android.scopes.FragmentScoped
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.logging.Logger
import javax.inject.Inject
@FragmentScoped
class RecipeImageLoaderImpl @Inject constructor(
private val fragment: Fragment,
private val requestOptions: RequestOptions,
private val logger: Logger,
) : RecipeImageLoader {
override fun loadRecipeImage(view: ImageView, recipe: RecipeSummaryEntity?) {
logger.v { "loadRecipeImage() called with: view = $view, recipe = $recipe" }
Glide.with(fragment).load(recipe).apply(requestOptions).into(view)
}
}

View File

@@ -1,45 +0,0 @@
package gq.kirmanak.mealient.ui.recipes.images
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.model.GlideUrl
import com.bumptech.glide.load.model.ModelCache
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.stream.BaseGlideUrlLoader
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import gq.kirmanak.mealient.data.recipes.impl.RecipeImageUrlProvider
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.runBlocking
import java.io.InputStream
class RecipeModelLoader @AssistedInject constructor(
private val recipeImageUrlProvider: RecipeImageUrlProvider,
private val logger: Logger,
@Assisted concreteLoader: ModelLoader<GlideUrl, InputStream>,
@Assisted cache: ModelCache<RecipeSummaryEntity, GlideUrl>,
) : BaseGlideUrlLoader<RecipeSummaryEntity>(concreteLoader, cache) {
@AssistedFactory
interface Factory {
fun build(
concreteLoader: ModelLoader<GlideUrl, InputStream>,
cache: ModelCache<RecipeSummaryEntity, GlideUrl>,
): RecipeModelLoader
}
override fun handles(model: RecipeSummaryEntity): Boolean = true
override fun getUrl(
model: RecipeSummaryEntity?,
width: Int,
height: Int,
options: Options?
): String? {
logger.v { "getUrl() called with: model = $model, width = $width, height = $height, options = $options" }
return runBlocking { recipeImageUrlProvider.generateImageUrl(model?.imageId) }
}
}

View File

@@ -1,27 +0,0 @@
package gq.kirmanak.mealient.ui.recipes.images
import com.bumptech.glide.load.model.*
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.logging.Logger
import java.io.InputStream
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RecipeModelLoaderFactory @Inject constructor(
private val recipeModelLoaderFactory: RecipeModelLoader.Factory,
private val logger: Logger,
) : ModelLoaderFactory<RecipeSummaryEntity, InputStream> {
private val cache = ModelCache<RecipeSummaryEntity, GlideUrl>()
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<RecipeSummaryEntity, InputStream> {
logger.v { "build() called with: multiFactory = $multiFactory" }
val concreteLoader = multiFactory.build(GlideUrl::class.java, InputStream::class.java)
return recipeModelLoaderFactory.build(concreteLoader, cache)
}
override fun teardown() {
// Do nothing
}
}

View File

@@ -1,37 +0,0 @@
package gq.kirmanak.mealient.ui.recipes.images
import androidx.fragment.app.Fragment
import androidx.paging.PagingDataAdapter
import com.bumptech.glide.Glide
import com.bumptech.glide.ListPreloader
import com.bumptech.glide.RequestBuilder
import com.bumptech.glide.request.RequestOptions
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.logging.Logger
class RecipePreloadModelProvider @AssistedInject constructor(
@Assisted private val adapter: PagingDataAdapter<RecipeSummaryEntity, *>,
private val fragment: Fragment,
private val requestOptions: RequestOptions,
private val logger: Logger,
) : ListPreloader.PreloadModelProvider<RecipeSummaryEntity> {
override fun getPreloadItems(position: Int): List<RecipeSummaryEntity> {
logger.v { "getPreloadItems() called with: position = $position" }
return adapter.peek(position)?.let { listOf(it) } ?: emptyList()
}
override fun getPreloadRequestBuilder(item: RecipeSummaryEntity): RequestBuilder<*> {
logger.v { "getPreloadRequestBuilder() called with: item = $item" }
return Glide.with(fragment).load(item).apply(requestOptions)
}
@AssistedFactory
interface Factory {
fun create(adapter: PagingDataAdapter<RecipeSummaryEntity, *>): RecipePreloadModelProvider
}
}

View File

@@ -1,10 +0,0 @@
package gq.kirmanak.mealient.ui.recipes.images
import androidx.paging.PagingDataAdapter
import com.bumptech.glide.integration.recyclerview.RecyclerViewPreloader
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
interface RecipePreloaderFactory {
fun create(adapter: PagingDataAdapter<RecipeSummaryEntity, *>): RecyclerViewPreloader<RecipeSummaryEntity>
}

View File

@@ -1,33 +0,0 @@
package gq.kirmanak.mealient.ui.recipes.images
import androidx.fragment.app.Fragment
import androidx.paging.PagingDataAdapter
import com.bumptech.glide.integration.recyclerview.RecyclerViewPreloader
import com.bumptech.glide.util.ViewPreloadSizeProvider
import dagger.hilt.android.scopes.FragmentScoped
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import javax.inject.Inject
@FragmentScoped
class RecipePreloaderFactoryImpl @Inject constructor(
private val recipePreloadModelProvider: RecipePreloadModelProvider.Factory,
private val fragment: Fragment,
) : RecipePreloaderFactory {
override fun create(
adapter: PagingDataAdapter<RecipeSummaryEntity, *>,
): RecyclerViewPreloader<RecipeSummaryEntity> {
val preloadSizeProvider = ViewPreloadSizeProvider<RecipeSummaryEntity>()
val preloadModelProvider = recipePreloadModelProvider.create(adapter)
return RecyclerViewPreloader(
fragment,
preloadModelProvider,
preloadSizeProvider,
MAX_PRELOAD
)
}
companion object {
const val MAX_PRELOAD = 10
}
}

View File

@@ -15,11 +15,9 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.Dimens
@Composable
@@ -59,7 +57,6 @@ internal fun HeaderSection(
.padding(horizontal = Dimens.Small),
text = title,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
)
}
@@ -69,20 +66,7 @@ internal fun HeaderSection(
.padding(horizontal = Dimens.Small),
text = description,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
@Preview
@Composable
private fun HeaderSectionPreview() {
AppTheme {
HeaderSection(
imageUrl = null,
title = "Recipe name",
description = "Recipe description",
)
}
}

View File

@@ -18,10 +18,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.database.recipe.entity.RecipeIngredientEntity
import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.Dimens
@Composable
@@ -36,7 +34,6 @@ internal fun IngredientsSection(
.padding(horizontal = Dimens.Large),
text = stringResource(id = R.string.fragment_recipe_info_ingredients_header),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Card(
@@ -74,12 +71,9 @@ private fun IngredientListItem(
.padding(horizontal = Dimens.Medium),
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Divider(
color = MaterialTheme.colorScheme.outline,
)
Divider()
}
Row(
@@ -96,26 +90,14 @@ private fun IngredientListItem(
Text(
text = item.display,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
if (item.note.isNotBlank()) {
Text(
text = item.note,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Preview
@Composable
private fun IngredientsSectionPreview() {
AppTheme {
IngredientsSection(
ingredients = INGREDIENTS,
)
}
}

View File

@@ -12,11 +12,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.database.recipe.entity.RecipeIngredientEntity
import gq.kirmanak.mealient.database.recipe.entity.RecipeInstructionEntity
import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.Dimens
@Composable
@@ -31,7 +29,6 @@ internal fun InstructionsSection(
.padding(horizontal = Dimens.Large),
text = stringResource(id = R.string.fragment_recipe_info_instructions_header),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
var stepCount = 0
@@ -62,7 +59,6 @@ private fun InstructionListItem(
.padding(horizontal = Dimens.Medium),
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
@@ -82,37 +78,22 @@ private fun InstructionListItem(
index + 1
),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = item.text.trim(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
if (ingredients.isNotEmpty()) {
Divider(
color = MaterialTheme.colorScheme.outline,
)
Divider()
ingredients.forEach { ingredient ->
Text(
text = ingredient.display,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
}
}
@Preview
@Composable
private fun InstructionsSectionPreview() {
AppTheme {
InstructionsSection(
instructions = INSTRUCTIONS,
)
}
}

View File

@@ -1,45 +1,27 @@
package gq.kirmanak.mealient.ui.recipes.info
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.ComposeView
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.BaseComposeFragment
import gq.kirmanak.mealient.ui.CheckableMenuItem
import gq.kirmanak.mealient.ui.activity.MainActivityViewModel
import javax.inject.Inject
@AndroidEntryPoint
class RecipeInfoFragment : Fragment() {
class RecipeInfoFragment : BaseComposeFragment() {
private val viewModel by viewModels<RecipeInfoViewModel>()
private val activityViewModel by activityViewModels<MainActivityViewModel>()
@Inject
lateinit var logger: Logger
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
logger.v { "onCreateView() called" }
return ComposeView(requireContext()).apply {
setContent {
val uiState by viewModel.uiState.collectAsState()
AppTheme {
RecipeScreen(uiState = uiState)
}
}
}
@Composable
override fun Screen() {
val uiState by viewModel.uiState.collectAsState()
RecipeScreen(uiState = uiState)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

View File

@@ -1,77 +1,73 @@
package gq.kirmanak.mealient.ui.recipes.info
import android.content.res.Configuration.UI_MODE_NIGHT_MASK
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.Dimens
import gq.kirmanak.mealient.ui.preview.ColorSchemePreview
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun RecipeScreen(
uiState: RecipeInfoUiState,
) {
KeepScreenOn()
Column(
modifier = Modifier
.verticalScroll(
state = rememberScrollState(),
),
verticalArrangement = Arrangement.spacedBy(Dimens.Small, Alignment.Top),
) {
HeaderSection(
imageUrl = uiState.imageUrl,
title = uiState.title,
description = uiState.description,
)
if (uiState.showIngredients) {
IngredientsSection(
ingredients = uiState.recipeIngredients,
Scaffold { padding ->
Column(
modifier = Modifier
.verticalScroll(
state = rememberScrollState(),
)
.padding(padding)
.consumeWindowInsets(padding),
verticalArrangement = Arrangement.spacedBy(Dimens.Small, Alignment.Top),
) {
HeaderSection(
imageUrl = uiState.imageUrl,
title = uiState.title,
description = uiState.description,
)
}
if (uiState.showInstructions) {
InstructionsSection(
instructions = uiState.recipeInstructions,
)
if (uiState.showIngredients) {
IngredientsSection(
ingredients = uiState.recipeIngredients,
)
}
if (uiState.showInstructions) {
InstructionsSection(
instructions = uiState.recipeInstructions,
)
}
}
}
}
@Preview(showBackground = true)
@ColorSchemePreview
@Composable
private fun RecipeScreenPreview() {
AppTheme {
RecipeScreen(
uiState = RECIPE_INFO_UI_STATE
uiState = RecipeInfoUiState(
showIngredients = true,
showInstructions = true,
summaryEntity = SUMMARY_ENTITY,
recipeIngredients = INGREDIENTS,
recipeInstructions = INSTRUCTIONS,
title = "Recipe title",
description = "Recipe description",
)
)
}
}
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_MASK and UI_MODE_NIGHT_YES)
@Composable
private fun RecipeScreenNightPreview() {
AppTheme {
RecipeScreen(
uiState = RECIPE_INFO_UI_STATE
)
}
}
private val RECIPE_INFO_UI_STATE = RecipeInfoUiState(
showIngredients = true,
showInstructions = true,
summaryEntity = SUMMARY_ENTITY,
recipeIngredients = INGREDIENTS,
recipeInstructions = INSTRUCTIONS,
title = "Recipe title",
description = "Recipe description",
)

View File

@@ -0,0 +1,55 @@
package gq.kirmanak.mealient.ui.recipes.list
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import gq.kirmanak.mealient.R
@Composable
internal fun ConfirmDeleteDialog(
onDismissRequest: () -> Unit,
onConfirm: (RecipeListItemState) -> Unit,
item: RecipeListItemState,
modifier: Modifier = Modifier,
) {
AlertDialog(
modifier = modifier,
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(
onClick = {
onConfirm(item)
},
) {
Text(
text = stringResource(id = R.string.fragment_recipes_delete_recipe_confirm_dialog_positive_btn),
)
}
},
dismissButton = {
TextButton(
onClick = onDismissRequest,
) {
Text(
text = stringResource(id = R.string.fragment_recipes_delete_recipe_confirm_dialog_negative_btn),
)
}
},
title = {
Text(
text = stringResource(id = R.string.fragment_recipes_delete_recipe_confirm_dialog_title),
)
},
text = {
Text(
text = stringResource(
id = R.string.fragment_recipes_delete_recipe_confirm_dialog_message,
item.entity.name
),
)
},
)
}

View File

@@ -0,0 +1,142 @@
package gq.kirmanak.mealient.ui.recipes.list
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import coil.compose.AsyncImage
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.Dimens
import gq.kirmanak.mealient.ui.preview.ColorSchemePreview
import gq.kirmanak.mealient.ui.recipes.info.SUMMARY_ENTITY
import kotlin.random.Random
@Composable
internal fun RecipeItem(
recipe: RecipeListItemState,
onDeleteClick: () -> Unit,
onFavoriteClick: () -> Unit,
onItemClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier,
) {
Column(
modifier = Modifier
.clickable(onClick = onItemClick)
.padding(
horizontal = Dimens.Medium,
vertical = Dimens.Small,
),
verticalArrangement = Arrangement.spacedBy(Dimens.Small),
) {
RecipeHeader(
onDeleteClick = onDeleteClick,
recipe = recipe,
onFavoriteClick = onFavoriteClick
)
RecipeImage(
recipe = recipe,
)
Text(
text = recipe.entity.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.headlineSmall,
)
}
}
}
@Composable
private fun RecipeImage(
recipe: RecipeListItemState,
) {
val imageFallback = painterResource(id = R.drawable.placeholder_recipe)
AsyncImage(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(2f) // 2:1
.clip(RoundedCornerShape(Dimens.Intermediate)),
model = recipe.imageUrl,
contentDescription = stringResource(id = R.string.content_description_fragment_recipe_info_image),
placeholder = imageFallback,
error = imageFallback,
fallback = imageFallback,
contentScale = ContentScale.Crop,
)
}
@Composable
private fun RecipeHeader(
onDeleteClick: () -> Unit,
recipe: RecipeListItemState,
onFavoriteClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
IconButton(
onClick = onDeleteClick,
) {
Icon(
painter = painterResource(id = R.drawable.ic_delete),
contentDescription = stringResource(id = R.string.view_holder_recipe_delete_content_description),
)
}
if (recipe.showFavoriteIcon) {
IconButton(
onClick = onFavoriteClick,
) {
val resource = if (recipe.entity.isFavorite) {
R.drawable.ic_favorite_filled
} else {
R.drawable.ic_favorite_unfilled
}
Icon(
painter = painterResource(id = resource),
contentDescription = stringResource(id = R.string.view_holder_recipe_favorite_content_description),
)
}
}
}
}
@ColorSchemePreview
@Composable
private fun RecipeItemPreview() {
val isFavorite = Random.nextBoolean()
AppTheme {
RecipeItem(
recipe = RecipeListItemState(null, isFavorite, SUMMARY_ENTITY),
onDeleteClick = {},
onFavoriteClick = {},
onItemClick = {},
)
}
}

View File

@@ -0,0 +1,9 @@
package gq.kirmanak.mealient.ui.recipes.list
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
data class RecipeListItemState(
val imageUrl: String?,
val showFavoriteIcon: Boolean,
val entity: RecipeSummaryEntity,
)

View File

@@ -0,0 +1,12 @@
package gq.kirmanak.mealient.ui.recipes.list
internal sealed interface RecipeListSnackbar {
data class FavoriteAdded(val name: String) : RecipeListSnackbar
data class FavoriteRemoved(val name: String) : RecipeListSnackbar
data object FavoriteUpdateFailed : RecipeListSnackbar
data object DeleteFailed : RecipeListSnackbar
}

View File

@@ -0,0 +1,157 @@
package gq.kirmanak.mealient.ui.recipes.list
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.paging.LoadState
import androidx.paging.PagingData
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemContentType
import androidx.paging.compose.itemKey
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.ui.Dimens
import gq.kirmanak.mealient.ui.components.CenteredProgressIndicator
import gq.kirmanak.mealient.ui.components.LazyPagingColumnPullRefresh
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun RecipesList(
recipesFlow: Flow<PagingData<RecipeListItemState>>,
onDeleteClick: (RecipeListItemState) -> Unit,
onFavoriteClick: (RecipeListItemState) -> Unit,
onItemClick: (RecipeListItemState) -> Unit,
onSnackbarShown: () -> Unit,
snackbarMessageState: StateFlow<RecipeListSnackbar?>,
) {
val recipes: LazyPagingItems<RecipeListItemState> = recipesFlow.collectAsLazyPagingItems()
val isRefreshing = recipes.loadState.refresh is LoadState.Loading
var itemToDelete: RecipeListItemState? by remember { mutableStateOf(null) }
val snackbarHostState = remember { SnackbarHostState() }
val snackbar: RecipeListSnackbar? by snackbarMessageState.collectAsState()
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
snackbar?.message?.let { message ->
LaunchedEffect(message) {
snackbarHostState.showSnackbar(message)
onSnackbarShown()
}
} ?: run {
snackbarHostState.currentSnackbarData?.dismiss()
}
itemToDelete?.let { item ->
ConfirmDeleteDialog(
onDismissRequest = { itemToDelete = null },
onConfirm = {
onDeleteClick(item)
itemToDelete = null
},
item = item,
)
}
val innerModifier = Modifier
.padding(padding)
.consumeWindowInsets(padding)
when {
recipes.itemCount != 0 -> {
RecipesListData(
modifier = innerModifier,
recipes = recipes,
onDeleteClick = { itemToDelete = it },
onFavoriteClick = onFavoriteClick,
onItemClick = onItemClick
)
}
isRefreshing -> {
CenteredProgressIndicator(
modifier = innerModifier
)
}
else -> {
RecipesListError(
modifier = innerModifier,
recipes = recipes,
)
}
}
}
}
private val RecipeListSnackbar.message: String
@Composable
get() = when (this) {
is RecipeListSnackbar.FavoriteAdded -> {
stringResource(id = R.string.fragment_recipes_favorite_added, name)
}
is RecipeListSnackbar.FavoriteRemoved -> {
stringResource(id = R.string.fragment_recipes_favorite_removed, name)
}
is RecipeListSnackbar.FavoriteUpdateFailed -> {
stringResource(id = R.string.fragment_recipes_favorite_update_failed)
}
is RecipeListSnackbar.DeleteFailed -> {
stringResource(id = R.string.fragment_recipes_delete_recipe_failed)
}
}
@Composable
private fun RecipesListData(
modifier: Modifier,
recipes: LazyPagingItems<RecipeListItemState>,
onDeleteClick: (RecipeListItemState) -> Unit,
onFavoriteClick: (RecipeListItemState) -> Unit,
onItemClick: (RecipeListItemState) -> Unit
) {
LazyPagingColumnPullRefresh(
modifier = modifier
.fillMaxSize(),
lazyPagingItems = recipes,
verticalArrangement = Arrangement.spacedBy(Dimens.Medium),
contentPadding = PaddingValues(Dimens.Medium),
) {
items(
count = recipes.itemCount,
key = recipes.itemKey { it.entity.remoteId },
contentType = recipes.itemContentType { "recipe" },
) {
val item: RecipeListItemState? = recipes[it]
if (item != null) {
RecipeItem(
modifier = Modifier
.fillMaxWidth(),
recipe = item,
onDeleteClick = { onDeleteClick(item) },
onFavoriteClick = { onFavoriteClick(item) },
onItemClick = { onItemClick(item) },
)
}
}
}
}

View File

@@ -0,0 +1,62 @@
package gq.kirmanak.mealient.ui.recipes.list
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.paging.LoadState
import androidx.paging.PagingData
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.datasource.NetworkError
import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.Dimens
import gq.kirmanak.mealient.ui.components.EmptyListError
import gq.kirmanak.mealient.ui.preview.ColorSchemePreview
import kotlinx.coroutines.flow.emptyFlow
@Composable
internal fun RecipesListError(
recipes: LazyPagingItems<RecipeListItemState>,
modifier: Modifier
) {
val error = when (val state = recipes.loadState.refresh) {
is LoadState.Error -> getErrorMessage(state)
is LoadState.Loading,
is LoadState.NotLoading -> stringResource(id = R.string.fragment_recipes_list_no_recipes)
}
EmptyListError(
modifier = modifier
.fillMaxSize()
.padding(Dimens.Large),
text = error,
)
}
@Composable
private fun getErrorMessage(state: LoadState.Error): String {
val reason = when (state.error) {
is NetworkError.Unauthorized -> stringResource(R.string.fragment_recipes_load_failure_toast_unauthorized)
is NetworkError.NoServerConnection -> stringResource(R.string.fragment_recipes_load_failure_toast_no_connection)
is NetworkError.NotMealie, is NetworkError.MalformedUrl -> stringResource(id = R.string.fragment_recipes_load_failure_toast_unexpected_response)
else -> null
}
return if (reason == null) {
stringResource(R.string.fragment_recipes_load_failure_toast_no_reason)
} else {
stringResource(R.string.fragment_recipes_load_failure_toast, reason)
}
}
@ColorSchemePreview
@Composable
private fun RecipesListErrorPreview() {
AppTheme {
RecipesListError(
recipes = emptyFlow<PagingData<RecipeListItemState>>().collectAsLazyPagingItems(),
modifier = Modifier.fillMaxSize()
)
}
}

View File

@@ -0,0 +1,66 @@
package gq.kirmanak.mealient.ui.recipes.list
import android.os.Bundle
import android.view.View
import androidx.compose.runtime.Composable
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.extensions.hideKeyboard
import gq.kirmanak.mealient.ui.BaseComposeFragment
import gq.kirmanak.mealient.ui.CheckableMenuItem
import gq.kirmanak.mealient.ui.activity.MainActivityViewModel
@AndroidEntryPoint
internal class RecipesListFragment : BaseComposeFragment() {
private val viewModel by viewModels<RecipesListViewModel>()
private val activityViewModel by activityViewModels<MainActivityViewModel>()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
activityViewModel.updateUiState {
it.copy(
navigationVisible = true,
searchVisible = true,
checkedMenuItem = CheckableMenuItem.RecipesList,
)
}
}
@Composable
override fun Screen() = RecipesList(
recipesFlow = viewModel.pagingDataRecipeState,
onDeleteClick = { viewModel.onDeleteConfirm(it.entity) },
onFavoriteClick = { onFavoriteButtonClicked(it.entity) },
onItemClick = { onRecipeClicked(it.entity) },
onSnackbarShown = { viewModel.onSnackbarShown() },
snackbarMessageState = viewModel.snackbarState,
)
private fun onFavoriteButtonClicked(recipe: RecipeSummaryEntity) {
viewModel.onFavoriteIconClick(recipe)
}
private fun onRecipeClicked(recipe: RecipeSummaryEntity) {
viewModel.refreshRecipeInfo(recipe.slug).observe(viewLifecycleOwner) {
if (!isNavigatingSomewhere()) navigateToRecipeInfo(recipe.remoteId)
}
}
private fun isNavigatingSomewhere(): Boolean {
logger.v { "isNavigatingSomewhere() called" }
return findNavController().currentDestination?.id != R.id.recipesListFragment
}
private fun navigateToRecipeInfo(id: String) {
logger.v { "navigateToRecipeInfo() called with: id = $id" }
requireView().hideKeyboard()
findNavController().navigate(
RecipesListFragmentDirections.actionRecipesFragmentToRecipeInfoFragment(id)
)
}
}

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.ui.recipes
package gq.kirmanak.mealient.ui.recipes.list
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
@@ -6,18 +6,23 @@ import androidx.lifecycle.liveData
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import androidx.paging.map
import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.architecture.valueUpdatesOnly
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.data.recipes.impl.RecipeImageUrlProvider
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
@@ -25,17 +30,30 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class RecipesListViewModel @Inject constructor(
internal class RecipesListViewModel @Inject constructor(
private val recipeRepo: RecipeRepo,
authRepo: AuthRepo,
private val logger: Logger,
private val recipeImageUrlProvider: RecipeImageUrlProvider,
authRepo: AuthRepo,
) : ViewModel() {
val pagingData: Flow<PagingData<RecipeSummaryEntity>> = recipeRepo.createPager().flow
.cachedIn(viewModelScope)
private val pagingData: Flow<PagingData<RecipeSummaryEntity>> =
recipeRepo.createPager().flow.cachedIn(viewModelScope)
val showFavoriteIcon: StateFlow<Boolean> = authRepo.isAuthorizedFlow
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
private val showFavoriteIcon: StateFlow<Boolean> =
authRepo.isAuthorizedFlow.stateIn(viewModelScope, SharingStarted.Eagerly, false)
val pagingDataRecipeState: Flow<PagingData<RecipeListItemState>> =
pagingData.combine(showFavoriteIcon) { data, showFavorite ->
data.map { item ->
val imageUrl = recipeImageUrlProvider.generateImageUrl(item.imageId)
RecipeListItemState(
imageUrl = imageUrl,
showFavoriteIcon = showFavorite,
entity = item,
)
}
}
private val _deleteRecipeResult = MutableSharedFlow<Result<Unit>>(
replay = 0,
@@ -44,6 +62,9 @@ class RecipesListViewModel @Inject constructor(
)
val deleteRecipeResult: SharedFlow<Result<Unit>> get() = _deleteRecipeResult
private val _snackbarState = MutableStateFlow<RecipeListSnackbar?>(null)
val snackbarState get() = _snackbarState.asStateFlow()
init {
authRepo.isAuthorizedFlow.valueUpdatesOnly().onEach { hasAuthorized ->
logger.v { "Authorization state changed to $hasAuthorized" }
@@ -60,12 +81,27 @@ class RecipesListViewModel @Inject constructor(
}
}
fun onFavoriteIconClick(recipeSummaryEntity: RecipeSummaryEntity) = liveData {
fun onFavoriteIconClick(recipeSummaryEntity: RecipeSummaryEntity) {
logger.v { "onFavoriteIconClick() called with: recipeSummaryEntity = $recipeSummaryEntity" }
recipeRepo.updateIsRecipeFavorite(
recipeSlug = recipeSummaryEntity.slug,
isFavorite = recipeSummaryEntity.isFavorite.not(),
).also { emit(it) }
viewModelScope.launch {
val result = recipeRepo.updateIsRecipeFavorite(
recipeSlug = recipeSummaryEntity.slug,
isFavorite = recipeSummaryEntity.isFavorite.not(),
)
_snackbarState.value = result.fold(
onSuccess = { isFavorite ->
val name = recipeSummaryEntity.name
if (isFavorite) {
RecipeListSnackbar.FavoriteAdded(name)
} else {
RecipeListSnackbar.FavoriteRemoved(name)
}
},
onFailure = {
RecipeListSnackbar.FavoriteUpdateFailed
}
)
}
}
fun onDeleteConfirm(recipeSummaryEntity: RecipeSummaryEntity) {
@@ -74,6 +110,14 @@ class RecipesListViewModel @Inject constructor(
val result = recipeRepo.deleteRecipe(recipeSummaryEntity)
logger.d { "onDeleteConfirm: delete result is $result" }
_deleteRecipeResult.emit(result)
_snackbarState.value = result.fold(
onSuccess = { null },
onFailure = { RecipeListSnackbar.DeleteFailed },
)
}
}
fun onSnackbarShown() {
_snackbarState.value = null
}
}

View File

@@ -1,48 +0,0 @@
<?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.recipes.RecipesListFragment">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/refresher"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recipes"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:itemCount="10"
tools:listitem="@layout/view_holder_recipe" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<TextView
android:id="@+id/empty_list_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/fragment_recipes_list_no_recipes"
android:textAppearance="?textAppearanceDisplaySmall"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.3" />
<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" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -1,71 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView 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"
style="?materialCardViewFilledStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginVertical="@dimen/margin_medium"
android:layout_marginStart="@dimen/margin_medium"
android:layout_marginEnd="@dimen/margin_medium">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginVertical="@dimen/margin_small"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?textAppearanceHeadline6"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@+id/image"
app:layout_constraintStart_toStartOf="@+id/image"
app:layout_constraintTop_toBottomOf="@+id/image"
tools:text="A delicious cake" />
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/image"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginHorizontal="@dimen/margin_medium"
android:contentDescription="@string/content_description_view_holder_recipe_image"
android:scaleType="centerCrop"
app:layout_constraintBottom_toTopOf="@+id/name"
app:layout_constraintDimensionRatio="2:1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/delete_icon"
app:layout_constraintVertical_chainStyle="packed"
app:shapeAppearance="?shapeAppearanceCornerMedium"
tools:srcCompat="@drawable/placeholder_recipe" />
<ImageView
android:id="@+id/favorite_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginVertical="@dimen/margin_small"
android:contentDescription="@string/view_holder_recipe_favorite_content_description"
app:layout_constraintBottom_toTopOf="@+id/image"
app:layout_constraintEnd_toEndOf="@id/image"
app:layout_constraintTop_toTopOf="parent"
tools:srcCompat="@drawable/ic_favorite_unfilled"
tools:visibility="visible" />
<ImageView
android:id="@+id/delete_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_medium"
android:layout_marginVertical="@dimen/margin_small"
android:contentDescription="@string/view_holder_recipe_delete_content_description"
app:layout_constraintBottom_toTopOf="@+id/image"
app:layout_constraintEnd_toStartOf="@id/favorite_icon"
app:layout_constraintTop_toTopOf="parent"
app:layout_goneMarginEnd="0dp"
app:srcCompat="@drawable/ic_delete" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>

View File

@@ -13,9 +13,7 @@
<fragment
android:id="@+id/recipesListFragment"
android:name="gq.kirmanak.mealient.ui.recipes.RecipesListFragment"
android:label="fragment_recipes"
tools:layout="@layout/fragment_recipes_list">
android:name="gq.kirmanak.mealient.ui.recipes.list.RecipesListFragment">
<action
android:id="@+id/action_recipesFragment_to_recipeInfoFragment"
app:destination="@id/recipeInfoFragment"
@@ -69,7 +67,7 @@
<fragment
android:id="@+id/shoppingListsFragment"
android:name="gq.kirmanak.mealient.shopping_lists.ui.ShoppingListsFragment" />
android:name="gq.kirmanak.mealient.shopping_lists.ui.list.ShoppingListsFragment" />
<action
android:id="@+id/action_global_authenticationFragment"

View File

@@ -43,7 +43,7 @@
<string name="fragment_add_recipe_save_success">Rezept erfolgreich gespeichert</string>
<string name="fragment_add_recipe_clear_button">Klar</string>
<string name="fragment_base_url_url_input_helper_text">Beispiel: demo.mealie.io</string>
<string name="fragment_authentication_email_input_helper_text">Beispiel: changeme@email.com</string>
<string name="fragment_authentication_email_input_helper_text">Beispiel: changeme@example.com</string>
<string name="fragment_authentication_password_input_helper_text">Beispiel: Demo</string>
<string name="fragment_recipes_last_page_loaded_toast">Zuletzt geladene Seite</string>
<string name="fragment_recipes_load_failure_toast" comment="EXAMPLE: Load error: unauthorized.">Ladefehler: %1$s.</string>

View File

@@ -43,7 +43,7 @@
<string name="fragment_add_recipe_save_success">Receta guardada con éxito</string>
<string name="fragment_add_recipe_clear_button">Limpiar</string>
<string name="fragment_base_url_url_input_helper_text">Ejemplo: demo.mealie.io</string>
<string name="fragment_authentication_email_input_helper_text">Ejemplo: changeme@email.com</string>
<string name="fragment_authentication_email_input_helper_text">Ejemplo: changeme@example.com</string>
<string name="fragment_authentication_password_input_helper_text">Ejemplo: demo</string>
<string name="fragment_recipes_last_page_loaded_toast">Última página cargada</string>
<string name="fragment_recipes_load_failure_toast" comment="EXAMPLE: Load error: unauthorized.">Error al cargar: %1$s.</string>

View File

@@ -43,7 +43,7 @@
<string name="fragment_add_recipe_save_success">Sauvegarde réussie de la recette</string>
<string name="fragment_add_recipe_clear_button">Clair</string>
<string name="fragment_base_url_url_input_helper_text">Exemple : demo.mealie.io</string>
<string name="fragment_authentication_email_input_helper_text">Exemple : changeme@email.com</string>
<string name="fragment_authentication_email_input_helper_text">Exemple : changeme@example.com</string>
<string name="fragment_authentication_password_input_helper_text">Exemple : démo</string>
<string name="fragment_recipes_last_page_loaded_toast">Dernière page chargée</string>
<string name="fragment_recipes_load_failure_toast" comment="EXAMPLE: Load error: unauthorized.">Erreur de chargement : %1$s.</string>

View File

@@ -43,7 +43,7 @@
<string name="fragment_add_recipe_save_success">Recept succesvol opgeslagen</string>
<string name="fragment_add_recipe_clear_button">Duidelijk</string>
<string name="fragment_base_url_url_input_helper_text">Voorbeeld: demo.mealie.io</string>
<string name="fragment_authentication_email_input_helper_text">Voorbeeld: changeme@email.com</string>
<string name="fragment_authentication_email_input_helper_text">Voorbeeld: changeme@example.com</string>
<string name="fragment_authentication_password_input_helper_text">Voorbeeld: demo</string>
<string name="fragment_recipes_last_page_loaded_toast">Laatste pagina geladen</string>
<string name="fragment_recipes_load_failure_toast" comment="EXAMPLE: Load error: unauthorized.">Fout bij laden: %1$s.</string>

View File

@@ -43,7 +43,7 @@
<string name="fragment_add_recipe_save_success">Receita guardada com sucesso</string>
<string name="fragment_add_recipe_clear_button">Limpo</string>
<string name="fragment_base_url_url_input_helper_text">Exemplo: demo.mealie.io</string>
<string name="fragment_authentication_email_input_helper_text">Exemplo: changeme@email.com</string>
<string name="fragment_authentication_email_input_helper_text">Exemplo: changeme@example.com</string>
<string name="fragment_authentication_password_input_helper_text">Exemplo: demo</string>
<string name="fragment_recipes_last_page_loaded_toast">Última página carregada</string>
<string name="fragment_recipes_load_failure_toast" comment="EXAMPLE: Load error: unauthorized.">Erro de carregamento: %1$s.</string>

View File

@@ -43,7 +43,7 @@
<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_email_input_helper_text">Пример: changeme@example.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" comment="EXAMPLE: Load error: unauthorized.">Ошибка загрузки: %1$s.</string>

View File

@@ -45,7 +45,7 @@
<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_email_input_helper_text">Example: changeme@example.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>

View File

@@ -4,8 +4,10 @@ import androidx.lifecycle.asFlow
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.data.recipes.impl.RecipeImageUrlProvider
import gq.kirmanak.mealient.database.CAKE_RECIPE_SUMMARY_ENTITY
import gq.kirmanak.mealient.test.BaseUnitTest
import gq.kirmanak.mealient.ui.recipes.list.RecipesListViewModel
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
@@ -32,6 +34,9 @@ class RecipesListViewModelTest : BaseUnitTest() {
@MockK(relaxed = true)
lateinit var recipeRepo: RecipeRepo
@MockK(relaxed = true)
lateinit var recipeImageUrlProvider: RecipeImageUrlProvider
@Before
override fun setUp() {
super.setUp()
@@ -116,5 +121,10 @@ class RecipesListViewModelTest : BaseUnitTest() {
return results
}
private fun createSubject() = RecipesListViewModel(recipeRepo, authRepo, logger)
private fun createSubject() = RecipesListViewModel(
recipeRepo = recipeRepo,
logger = logger,
recipeImageUrlProvider = recipeImageUrlProvider,
authRepo = authRepo,
)
}