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) androidTestImplementation(libs.google.dagger.hiltAndroidTesting)
implementation(libs.androidx.paging.runtimeKtx) implementation(libs.androidx.paging.runtimeKtx)
implementation(libs.androidx.paging.compose)
testImplementation(libs.androidx.paging.commonKtx) testImplementation(libs.androidx.paging.commonKtx)
implementation(libs.jetbrains.kotlinx.datetime) 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.kirich1409.viewBinding)
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
@@ -158,6 +151,7 @@ dependencies {
androidTestImplementation(libs.junit) androidTestImplementation(libs.junit)
androidTestImplementation(libs.androidx.test.junit) androidTestImplementation(libs.androidx.test.junit)
androidTestImplementation(libs.kaspersky.kaspresso) androidTestImplementation(libs.kaspersky.kaspresso)
androidTestImplementation(libs.kaspersky.kaspresso.compose)
androidTestImplementation(libs.okhttp3.mockwebserver) androidTestImplementation(libs.okhttp3.mockwebserver)
androidTestImplementation(libs.androidx.test.core) androidTestImplementation(libs.androidx.test.core)
androidTestImplementation(libs.androidx.test.rules) androidTestImplementation(libs.androidx.test.rules)

View File

@@ -1,6 +1,8 @@
package gq.kirmanak.mealient 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 com.kaspersky.kaspresso.testcases.api.testcase.TestCase
import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidRule
import gq.kirmanak.mealient.ui.activity.MainActivity import gq.kirmanak.mealient.ui.activity.MainActivity
@@ -9,13 +11,15 @@ import org.junit.After
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
abstract class BaseTestCase : TestCase() { abstract class BaseTestCase : TestCase(
kaspressoBuilder = Kaspresso.Builder.withComposeSupport(),
) {
@get:Rule(order = 0) @get:Rule(order = 0)
var hiltRule = HiltAndroidRule(this) var hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1) @get:Rule(order = 1)
val mainActivityRule = activityScenarioRule<MainActivity>() val mainActivityRule = createAndroidComposeRule<MainActivity>()
lateinit var mockWebServer: MockWebServer 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.BaseUrlScreen
import gq.kirmanak.mealient.screen.DisclaimerScreen import gq.kirmanak.mealient.screen.DisclaimerScreen
import gq.kirmanak.mealient.screen.RecipesListScreen import gq.kirmanak.mealient.screen.RecipesListScreen
import io.github.kakaocup.kakao.common.utilities.getResourceString
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
@@ -65,10 +66,10 @@ class FirstSetUpTest : BaseTestCase() {
} }
step("Check that empty list of recipes is shown") { step("Check that empty list of recipes is shown") {
RecipesListScreen { RecipesListScreen(mainActivityRule).apply {
emptyListText { errorText {
isVisible() assertIsDisplayed()
hasText(R.string.fragment_recipes_list_no_recipes) assertTextEquals(getResourceString(R.string.fragment_recipes_load_failure_toast_no_reason))
} }
} }
} }

View File

@@ -1,13 +1,20 @@
package gq.kirmanak.mealient.screen package gq.kirmanak.mealient.screen
import com.kaspersky.kaspresso.screens.KScreen import androidx.activity.ComponentActivity
import gq.kirmanak.mealient.R import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import gq.kirmanak.mealient.ui.recipes.RecipesListFragment import androidx.compose.ui.test.onRoot
import io.github.kakaocup.kakao.text.KTextView 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>() { class RecipesListScreen<R : TestRule, A : ComponentActivity>(
override val layoutId: Int = R.layout.fragment_recipes_list semanticsProvider: AndroidComposeTestRule<R, A>,
override val viewClass: Class<*> = RecipesListFragment::class.java ) : 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" } logger.v { "createPager() called" }
val pagingConfig = PagingConfig( val pagingConfig = PagingConfig(
pageSize = LOAD_PAGE_SIZE, pageSize = LOAD_PAGE_SIZE,
enablePlaceholders = true, enablePlaceholders = false,
initialLoadSize = INITIAL_LOAD_PAGE_SIZE, initialLoadSize = INITIAL_LOAD_PAGE_SIZE,
) )
return Pager( 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 package gq.kirmanak.mealient.di
import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.request.RequestOptions
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.network.MealieDataSourceWrapper import gq.kirmanak.mealient.data.network.MealieDataSourceWrapper
import gq.kirmanak.mealient.data.recipes.RecipeRepo import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.data.recipes.impl.* import gq.kirmanak.mealient.data.recipes.impl.*
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource 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 @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
@@ -29,16 +22,6 @@ interface RecipeModule {
@Binds @Binds
fun bindImageUrlProvider(recipeImageUrlProviderImpl: RecipeImageUrlProviderImpl): RecipeImageUrlProvider fun bindImageUrlProvider(recipeImageUrlProviderImpl: RecipeImageUrlProviderImpl): RecipeImageUrlProvider
@Binds
fun bindModelLoaderFactory(recipeModelLoaderFactory: RecipeModelLoaderFactory): ModelLoaderFactory<RecipeSummaryEntity, InputStream>
@Binds @Binds
fun bindRecipePagingSourceFactory(recipePagingSourceFactoryImpl: RecipePagingSourceFactoryImpl): RecipePagingSourceFactory 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.layout.ContentScale
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import gq.kirmanak.mealient.R import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.Dimens import gq.kirmanak.mealient.ui.Dimens
@Composable @Composable
@@ -59,7 +57,6 @@ internal fun HeaderSection(
.padding(horizontal = Dimens.Small), .padding(horizontal = Dimens.Small),
text = title, text = title,
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
) )
} }
@@ -69,20 +66,7 @@ internal fun HeaderSection(
.padding(horizontal = Dimens.Small), .padding(horizontal = Dimens.Small),
text = description, text = description,
style = MaterialTheme.typography.bodyLarge, 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import gq.kirmanak.mealient.R import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.database.recipe.entity.RecipeIngredientEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeIngredientEntity
import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.Dimens import gq.kirmanak.mealient.ui.Dimens
@Composable @Composable
@@ -36,7 +34,6 @@ internal fun IngredientsSection(
.padding(horizontal = Dimens.Large), .padding(horizontal = Dimens.Large),
text = stringResource(id = R.string.fragment_recipe_info_ingredients_header), text = stringResource(id = R.string.fragment_recipe_info_ingredients_header),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
) )
Card( Card(
@@ -74,12 +71,9 @@ private fun IngredientListItem(
.padding(horizontal = Dimens.Medium), .padding(horizontal = Dimens.Medium),
text = title, text = title,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
) )
Divider( Divider()
color = MaterialTheme.colorScheme.outline,
)
} }
Row( Row(
@@ -96,26 +90,14 @@ private fun IngredientListItem(
Text( Text(
text = item.display, text = item.display,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
) )
if (item.note.isNotBlank()) { if (item.note.isNotBlank()) {
Text( Text(
text = item.note, text = item.note,
style = MaterialTheme.typography.bodySmall, 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import gq.kirmanak.mealient.R import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.database.recipe.entity.RecipeIngredientEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeIngredientEntity
import gq.kirmanak.mealient.database.recipe.entity.RecipeInstructionEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeInstructionEntity
import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.Dimens import gq.kirmanak.mealient.ui.Dimens
@Composable @Composable
@@ -31,7 +29,6 @@ internal fun InstructionsSection(
.padding(horizontal = Dimens.Large), .padding(horizontal = Dimens.Large),
text = stringResource(id = R.string.fragment_recipe_info_instructions_header), text = stringResource(id = R.string.fragment_recipe_info_instructions_header),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
) )
var stepCount = 0 var stepCount = 0
@@ -62,7 +59,6 @@ private fun InstructionListItem(
.padding(horizontal = Dimens.Medium), .padding(horizontal = Dimens.Medium),
text = title, text = title,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
) )
} }
@@ -82,37 +78,22 @@ private fun InstructionListItem(
index + 1 index + 1
), ),
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
) )
Text( Text(
text = item.text.trim(), text = item.text.trim(),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
) )
if (ingredients.isNotEmpty()) { if (ingredients.isNotEmpty()) {
Divider( Divider()
color = MaterialTheme.colorScheme.outline,
)
ingredients.forEach { ingredient -> ingredients.forEach { ingredient ->
Text( Text(
text = ingredient.display, text = ingredient.display,
style = MaterialTheme.typography.bodySmall, 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 package gq.kirmanak.mealient.ui.recipes.info
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.activityViewModels
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.ui.BaseComposeFragment
import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.CheckableMenuItem import gq.kirmanak.mealient.ui.CheckableMenuItem
import gq.kirmanak.mealient.ui.activity.MainActivityViewModel import gq.kirmanak.mealient.ui.activity.MainActivityViewModel
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class RecipeInfoFragment : Fragment() { class RecipeInfoFragment : BaseComposeFragment() {
private val viewModel by viewModels<RecipeInfoViewModel>() private val viewModel by viewModels<RecipeInfoViewModel>()
private val activityViewModel by activityViewModels<MainActivityViewModel>() private val activityViewModel by activityViewModels<MainActivityViewModel>()
@Inject @Composable
lateinit var logger: Logger override fun Screen() {
val uiState by viewModel.uiState.collectAsState()
override fun onCreateView( RecipeScreen(uiState = uiState)
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)
}
}
}
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

View File

@@ -1,77 +1,73 @@
package gq.kirmanak.mealient.ui.recipes.info 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.Arrangement
import androidx.compose.foundation.layout.Column 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.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import gq.kirmanak.mealient.ui.AppTheme import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.Dimens import gq.kirmanak.mealient.ui.Dimens
import gq.kirmanak.mealient.ui.preview.ColorSchemePreview
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun RecipeScreen( fun RecipeScreen(
uiState: RecipeInfoUiState, uiState: RecipeInfoUiState,
) { ) {
KeepScreenOn() KeepScreenOn()
Column( Scaffold { padding ->
modifier = Modifier Column(
.verticalScroll( modifier = Modifier
state = rememberScrollState(), .verticalScroll(
), state = rememberScrollState(),
verticalArrangement = Arrangement.spacedBy(Dimens.Small, Alignment.Top), )
) { .padding(padding)
HeaderSection( .consumeWindowInsets(padding),
imageUrl = uiState.imageUrl, verticalArrangement = Arrangement.spacedBy(Dimens.Small, Alignment.Top),
title = uiState.title, ) {
description = uiState.description, HeaderSection(
) imageUrl = uiState.imageUrl,
title = uiState.title,
if (uiState.showIngredients) { description = uiState.description,
IngredientsSection(
ingredients = uiState.recipeIngredients,
) )
}
if (uiState.showInstructions) { if (uiState.showIngredients) {
InstructionsSection( IngredientsSection(
instructions = uiState.recipeInstructions, ingredients = uiState.recipeIngredients,
) )
}
if (uiState.showInstructions) {
InstructionsSection(
instructions = uiState.recipeInstructions,
)
}
} }
} }
} }
@Preview(showBackground = true) @ColorSchemePreview
@Composable @Composable
private fun RecipeScreenPreview() { private fun RecipeScreenPreview() {
AppTheme { AppTheme {
RecipeScreen( 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.LiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@@ -6,18 +6,23 @@ import androidx.lifecycle.liveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData import androidx.paging.PagingData
import androidx.paging.cachedIn import androidx.paging.cachedIn
import androidx.paging.map
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.architecture.valueUpdatesOnly import gq.kirmanak.mealient.architecture.valueUpdatesOnly
import gq.kirmanak.mealient.data.auth.AuthRepo 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.data.recipes.impl.RecipeImageUrlProvider
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -25,17 +30,30 @@ import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class RecipesListViewModel @Inject constructor( internal class RecipesListViewModel @Inject constructor(
private val recipeRepo: RecipeRepo, private val recipeRepo: RecipeRepo,
authRepo: AuthRepo,
private val logger: Logger, private val logger: Logger,
private val recipeImageUrlProvider: RecipeImageUrlProvider,
authRepo: AuthRepo,
) : ViewModel() { ) : ViewModel() {
val pagingData: Flow<PagingData<RecipeSummaryEntity>> = recipeRepo.createPager().flow private val pagingData: Flow<PagingData<RecipeSummaryEntity>> =
.cachedIn(viewModelScope) recipeRepo.createPager().flow.cachedIn(viewModelScope)
val showFavoriteIcon: StateFlow<Boolean> = authRepo.isAuthorizedFlow private val showFavoriteIcon: StateFlow<Boolean> =
.stateIn(viewModelScope, SharingStarted.Eagerly, false) 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>>( private val _deleteRecipeResult = MutableSharedFlow<Result<Unit>>(
replay = 0, replay = 0,
@@ -44,6 +62,9 @@ class RecipesListViewModel @Inject constructor(
) )
val deleteRecipeResult: SharedFlow<Result<Unit>> get() = _deleteRecipeResult val deleteRecipeResult: SharedFlow<Result<Unit>> get() = _deleteRecipeResult
private val _snackbarState = MutableStateFlow<RecipeListSnackbar?>(null)
val snackbarState get() = _snackbarState.asStateFlow()
init { init {
authRepo.isAuthorizedFlow.valueUpdatesOnly().onEach { hasAuthorized -> authRepo.isAuthorizedFlow.valueUpdatesOnly().onEach { hasAuthorized ->
logger.v { "Authorization state changed to $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" } logger.v { "onFavoriteIconClick() called with: recipeSummaryEntity = $recipeSummaryEntity" }
recipeRepo.updateIsRecipeFavorite( viewModelScope.launch {
recipeSlug = recipeSummaryEntity.slug, val result = recipeRepo.updateIsRecipeFavorite(
isFavorite = recipeSummaryEntity.isFavorite.not(), recipeSlug = recipeSummaryEntity.slug,
).also { emit(it) } 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) { fun onDeleteConfirm(recipeSummaryEntity: RecipeSummaryEntity) {
@@ -74,6 +110,14 @@ class RecipesListViewModel @Inject constructor(
val result = recipeRepo.deleteRecipe(recipeSummaryEntity) val result = recipeRepo.deleteRecipe(recipeSummaryEntity)
logger.d { "onDeleteConfirm: delete result is $result" } logger.d { "onDeleteConfirm: delete result is $result" }
_deleteRecipeResult.emit(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 <fragment
android:id="@+id/recipesListFragment" android:id="@+id/recipesListFragment"
android:name="gq.kirmanak.mealient.ui.recipes.RecipesListFragment" android:name="gq.kirmanak.mealient.ui.recipes.list.RecipesListFragment">
android:label="fragment_recipes"
tools:layout="@layout/fragment_recipes_list">
<action <action
android:id="@+id/action_recipesFragment_to_recipeInfoFragment" android:id="@+id/action_recipesFragment_to_recipeInfoFragment"
app:destination="@id/recipeInfoFragment" app:destination="@id/recipeInfoFragment"
@@ -69,7 +67,7 @@
<fragment <fragment
android:id="@+id/shoppingListsFragment" android:id="@+id/shoppingListsFragment"
android:name="gq.kirmanak.mealient.shopping_lists.ui.ShoppingListsFragment" /> android:name="gq.kirmanak.mealient.shopping_lists.ui.list.ShoppingListsFragment" />
<action <action
android:id="@+id/action_global_authenticationFragment" 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_save_success">Rezept erfolgreich gespeichert</string>
<string name="fragment_add_recipe_clear_button">Klar</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_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_authentication_password_input_helper_text">Beispiel: Demo</string>
<string name="fragment_recipes_last_page_loaded_toast">Zuletzt geladene Seite</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> <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_save_success">Receta guardada con éxito</string>
<string name="fragment_add_recipe_clear_button">Limpiar</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_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_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_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> <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_save_success">Sauvegarde réussie de la recette</string>
<string name="fragment_add_recipe_clear_button">Clair</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_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_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_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> <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_save_success">Recept succesvol opgeslagen</string>
<string name="fragment_add_recipe_clear_button">Duidelijk</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_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_authentication_password_input_helper_text">Voorbeeld: demo</string>
<string name="fragment_recipes_last_page_loaded_toast">Laatste pagina geladen</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> <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_save_success">Receita guardada com sucesso</string>
<string name="fragment_add_recipe_clear_button">Limpo</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_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_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_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> <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_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_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_authentication_password_input_helper_text">Пример: demo</string>
<string name="fragment_recipes_last_page_loaded_toast">Последняя страница</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> <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_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_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_authentication_password_input_helper_text">Example: demo</string>
<string name="fragment_recipes_last_page_loaded_toast">Last page loaded</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" 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 com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.auth.AuthRepo 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.data.recipes.impl.RecipeImageUrlProvider
import gq.kirmanak.mealient.database.CAKE_RECIPE_SUMMARY_ENTITY import gq.kirmanak.mealient.database.CAKE_RECIPE_SUMMARY_ENTITY
import gq.kirmanak.mealient.test.BaseUnitTest import gq.kirmanak.mealient.test.BaseUnitTest
import gq.kirmanak.mealient.ui.recipes.list.RecipesListViewModel
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.every import io.mockk.every
@@ -32,6 +34,9 @@ class RecipesListViewModelTest : BaseUnitTest() {
@MockK(relaxed = true) @MockK(relaxed = true)
lateinit var recipeRepo: RecipeRepo lateinit var recipeRepo: RecipeRepo
@MockK(relaxed = true)
lateinit var recipeImageUrlProvider: RecipeImageUrlProvider
@Before @Before
override fun setUp() { override fun setUp() {
super.setUp() super.setUp()
@@ -116,5 +121,10 @@ class RecipesListViewModelTest : BaseUnitTest() {
return results return results
} }
private fun createSubject() = RecipesListViewModel(recipeRepo, authRepo, logger) private fun createSubject() = RecipesListViewModel(
recipeRepo = recipeRepo,
logger = logger,
recipeImageUrlProvider = recipeImageUrlProvider,
authRepo = authRepo,
)
} }

View File

@@ -8,8 +8,6 @@ import gq.kirmanak.mealient.shopping_lists.network.ShoppingListsDataSource
import gq.kirmanak.mealient.shopping_lists.network.ShoppingListsDataSourceImpl import gq.kirmanak.mealient.shopping_lists.network.ShoppingListsDataSourceImpl
import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsRepo import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsRepo
import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsRepoImpl import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsRepoImpl
import gq.kirmanak.mealient.shopping_lists.util.LoadingHelperFactory
import gq.kirmanak.mealient.shopping_lists.util.LoadingHelperFactoryImpl
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
@@ -20,7 +18,4 @@ interface ShoppingListsModule {
@Binds @Binds
fun bindShoppingListsRepo(impl: ShoppingListsRepoImpl): ShoppingListsRepo fun bindShoppingListsRepo(impl: ShoppingListsRepoImpl): ShoppingListsRepo
@Binds
fun bindLoadingHelperFactory(impl: LoadingHelperFactoryImpl): LoadingHelperFactory
} }

View File

@@ -1,31 +0,0 @@
package gq.kirmanak.mealient.shopping_lists.ui.composables
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
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
@Composable
fun CenteredText(
text: String,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(text = text)
}
}
@Preview
@Composable
fun PreviewCenteredText() {
AppTheme {
CenteredText(text = "Hello World")
}
}

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.shopping_lists.ui package gq.kirmanak.mealient.shopping_lists.ui.details
import gq.kirmanak.mealient.datasource.models.GetFoodResponse import gq.kirmanak.mealient.datasource.models.GetFoodResponse
import gq.kirmanak.mealient.datasource.models.GetShoppingListResponse import gq.kirmanak.mealient.datasource.models.GetShoppingListResponse

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.shopping_lists.ui package gq.kirmanak.mealient.shopping_lists.ui.details
import gq.kirmanak.mealient.datasource.models.GetShoppingListItemResponse import gq.kirmanak.mealient.datasource.models.GetShoppingListItemResponse

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.shopping_lists.ui package gq.kirmanak.mealient.shopping_lists.ui.details
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -49,7 +49,6 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.Destination
import gq.kirmanak.mealient.datasource.models.GetFoodResponse import gq.kirmanak.mealient.datasource.models.GetFoodResponse
@@ -57,11 +56,14 @@ import gq.kirmanak.mealient.datasource.models.GetShoppingListItemRecipeReference
import gq.kirmanak.mealient.datasource.models.GetShoppingListItemResponse import gq.kirmanak.mealient.datasource.models.GetShoppingListItemResponse
import gq.kirmanak.mealient.datasource.models.GetUnitResponse import gq.kirmanak.mealient.datasource.models.GetUnitResponse
import gq.kirmanak.mealient.shopping_list.R import gq.kirmanak.mealient.shopping_list.R
import gq.kirmanak.mealient.shopping_lists.ui.composables.LazyColumnWithLoadingState import gq.kirmanak.mealient.shopping_lists.ui.composables.getErrorMessage
import gq.kirmanak.mealient.shopping_lists.util.data
import gq.kirmanak.mealient.shopping_lists.util.map
import gq.kirmanak.mealient.ui.AppTheme import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.Dimens import gq.kirmanak.mealient.ui.Dimens
import gq.kirmanak.mealient.ui.components.LazyColumnWithLoadingState
import gq.kirmanak.mealient.ui.preview.ColorSchemePreview
import gq.kirmanak.mealient.ui.util.data
import gq.kirmanak.mealient.ui.util.error
import gq.kirmanak.mealient.ui.util.map
import kotlinx.coroutines.android.awaitFrame import kotlinx.coroutines.android.awaitFrame
import java.text.DecimalFormat import java.text.DecimalFormat
@@ -77,7 +79,7 @@ data class ShoppingListNavArgs(
internal fun ShoppingListScreen( internal fun ShoppingListScreen(
shoppingListViewModel: ShoppingListViewModel = hiltViewModel(), shoppingListViewModel: ShoppingListViewModel = hiltViewModel(),
) { ) {
val loadingState = shoppingListViewModel.loadingState.collectAsState().value val loadingState by shoppingListViewModel.loadingState.collectAsState()
val defaultEmptyListError = stringResource( val defaultEmptyListError = stringResource(
R.string.shopping_list_screen_empty_list, R.string.shopping_list_screen_empty_list,
loadingState.data?.name.orEmpty() loadingState.data?.name.orEmpty()
@@ -85,6 +87,8 @@ internal fun ShoppingListScreen(
LazyColumnWithLoadingState( LazyColumnWithLoadingState(
loadingState = loadingState.map { it.items }, loadingState = loadingState.map { it.items },
emptyListError = loadingState.error?.let { getErrorMessage(it) } ?: defaultEmptyListError,
retryButtonText = stringResource(id = R.string.shopping_lists_screen_empty_button_refresh),
contentPadding = PaddingValues( contentPadding = PaddingValues(
start = Dimens.Medium, start = Dimens.Medium,
end = Dimens.Medium, end = Dimens.Medium,
@@ -92,10 +96,9 @@ internal fun ShoppingListScreen(
bottom = Dimens.Large * 4, bottom = Dimens.Large * 4,
), ),
verticalArrangement = Arrangement.spacedBy(Dimens.Medium), verticalArrangement = Arrangement.spacedBy(Dimens.Medium),
defaultEmptyListError = defaultEmptyListError, snackbarText = shoppingListViewModel.errorToShowInSnackbar?.let { getErrorMessage(error = it) },
errorToShowInSnackbar = shoppingListViewModel.errorToShowInSnackbar,
onRefresh = shoppingListViewModel::refreshShoppingList,
onSnackbarShown = shoppingListViewModel::onSnackbarShown, onSnackbarShown = shoppingListViewModel::onSnackbarShown,
onRefresh = shoppingListViewModel::refreshShoppingList,
floatingActionButton = { floatingActionButton = {
FloatingActionButton(onClick = shoppingListViewModel::onAddItemClicked) { FloatingActionButton(onClick = shoppingListViewModel::onAddItemClicked) {
Icon( Icon(
@@ -120,7 +123,12 @@ internal fun ShoppingListScreen(
ShoppingListItemEditor( ShoppingListItemEditor(
state = state, state = state,
onEditCancelled = { shoppingListViewModel.onEditCancel(itemState) }, onEditCancelled = { shoppingListViewModel.onEditCancel(itemState) },
onEditConfirmed = { shoppingListViewModel.onEditConfirm(itemState, state) } onEditConfirmed = {
shoppingListViewModel.onEditConfirm(
itemState,
state
)
}
) )
} else { } else {
ShoppingListItem( ShoppingListItem(
@@ -439,7 +447,7 @@ class ShoppingListItemEditorState(
var unitsExpanded: Boolean by mutableStateOf(false) var unitsExpanded: Boolean by mutableStateOf(false)
} }
@Preview @ColorSchemePreview
@Composable @Composable
fun ShoppingListItemEditorPreview() { fun ShoppingListItemEditorPreview() {
AppTheme { AppTheme {
@@ -453,7 +461,7 @@ fun ShoppingListItemEditorPreview() {
} }
} }
@Preview @ColorSchemePreview
@Composable @Composable
fun ShoppingListItemEditorNonFoodPreview() { fun ShoppingListItemEditorNonFoodPreview() {
AppTheme { AppTheme {
@@ -567,7 +575,7 @@ fun ShoppingListItem(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@Preview @ColorSchemePreview
fun PreviewShoppingListItemChecked() { fun PreviewShoppingListItemChecked() {
AppTheme { AppTheme {
ShoppingListItem( ShoppingListItem(
@@ -579,7 +587,7 @@ fun PreviewShoppingListItemChecked() {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@Preview @ColorSchemePreview
fun PreviewShoppingListItemUnchecked() { fun PreviewShoppingListItemUnchecked() {
AppTheme { AppTheme {
ShoppingListItem( ShoppingListItem(
@@ -591,7 +599,7 @@ fun PreviewShoppingListItemUnchecked() {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@Preview @ColorSchemePreview
fun PreviewShoppingListItemDismissed() { fun PreviewShoppingListItemDismissed() {
AppTheme { AppTheme {
ShoppingListItem( ShoppingListItem(
@@ -606,7 +614,7 @@ fun PreviewShoppingListItemDismissed() {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@Preview @ColorSchemePreview
fun PreviewShoppingListItemEditing() { fun PreviewShoppingListItemEditing() {
AppTheme { AppTheme {
ShoppingListItem( ShoppingListItem(

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.shopping_lists.ui package gq.kirmanak.mealient.shopping_lists.ui.details
import gq.kirmanak.mealient.datasource.models.GetFoodResponse import gq.kirmanak.mealient.datasource.models.GetFoodResponse
import gq.kirmanak.mealient.datasource.models.GetShoppingListItemResponse import gq.kirmanak.mealient.datasource.models.GetShoppingListItemResponse

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.shopping_lists.ui package gq.kirmanak.mealient.shopping_lists.ui.details
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -15,11 +15,11 @@ import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsAuthRepo import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsAuthRepo
import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsRepo import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsRepo
import gq.kirmanak.mealient.shopping_lists.ui.destinations.ShoppingListScreenDestination import gq.kirmanak.mealient.shopping_lists.ui.destinations.ShoppingListScreenDestination
import gq.kirmanak.mealient.shopping_lists.util.LoadingHelperFactory import gq.kirmanak.mealient.ui.util.LoadingHelperFactory
import gq.kirmanak.mealient.shopping_lists.util.LoadingState import gq.kirmanak.mealient.ui.util.LoadingState
import gq.kirmanak.mealient.shopping_lists.util.LoadingStateNoData import gq.kirmanak.mealient.ui.util.LoadingStateNoData
import gq.kirmanak.mealient.shopping_lists.util.data import gq.kirmanak.mealient.ui.util.data
import gq.kirmanak.mealient.shopping_lists.util.map import gq.kirmanak.mealient.ui.util.map
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -79,7 +79,7 @@ internal class ShoppingListViewModel @Inject constructor(
} }
} }
private suspend fun loadShoppingListData(): ShoppingListData = coroutineScope { private suspend fun loadShoppingListData(): Result<ShoppingListData> = coroutineScope {
val foodsDeferred = async { val foodsDeferred = async {
runCatchingExceptCancel { runCatchingExceptCancel {
shoppingListsRepo.getFoods() shoppingListsRepo.getFoods()
@@ -93,14 +93,18 @@ internal class ShoppingListViewModel @Inject constructor(
} }
val shoppingListDeferred = async { val shoppingListDeferred = async {
shoppingListsRepo.getShoppingList(args.shoppingListId) runCatchingExceptCancel {
shoppingListsRepo.getShoppingList(args.shoppingListId)
}
} }
ShoppingListData( shoppingListDeferred.await().map {
foods = foodsDeferred.await(), ShoppingListData(
units = unitsDeferred.await(), foods = foodsDeferred.await(),
shoppingList = shoppingListDeferred.await(), units = unitsDeferred.await(),
) shoppingList = it
)
}
} }
private suspend fun doRefresh() { private suspend fun doRefresh() {

View File

@@ -1,8 +1,9 @@
package gq.kirmanak.mealient.shopping_lists.ui package gq.kirmanak.mealient.shopping_lists.ui.list
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import com.ramcosta.composedestinations.DestinationsNavHost import com.ramcosta.composedestinations.DestinationsNavHost
import com.ramcosta.composedestinations.rememberNavHostEngine import com.ramcosta.composedestinations.rememberNavHostEngine
import gq.kirmanak.mealient.shopping_lists.ui.NavGraphs
@Composable @Composable
fun MealientApp() { fun MealientApp() {

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.shopping_lists.ui package gq.kirmanak.mealient.shopping_lists.ui.list
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.shopping_lists.ui package gq.kirmanak.mealient.shopping_lists.ui.list
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -11,21 +11,24 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootNavGraph import com.ramcosta.composedestinations.annotation.RootNavGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import gq.kirmanak.mealient.datasource.models.GetShoppingListsSummaryResponse import gq.kirmanak.mealient.datasource.models.GetShoppingListsSummaryResponse
import gq.kirmanak.mealient.shopping_list.R import gq.kirmanak.mealient.shopping_list.R
import gq.kirmanak.mealient.shopping_lists.ui.composables.LazyColumnWithLoadingState import gq.kirmanak.mealient.shopping_lists.ui.composables.getErrorMessage
import gq.kirmanak.mealient.shopping_lists.ui.destinations.ShoppingListScreenDestination import gq.kirmanak.mealient.shopping_lists.ui.destinations.ShoppingListScreenDestination
import gq.kirmanak.mealient.ui.AppTheme import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.Dimens import gq.kirmanak.mealient.ui.Dimens
import gq.kirmanak.mealient.ui.components.LazyColumnWithLoadingState
import gq.kirmanak.mealient.ui.preview.ColorSchemePreview
import gq.kirmanak.mealient.ui.util.error
@RootNavGraph(start = true) @RootNavGraph(start = true)
@Destination(start = true) @Destination(start = true)
@@ -34,31 +37,32 @@ fun ShoppingListsScreen(
navigator: DestinationsNavigator, navigator: DestinationsNavigator,
shoppingListsViewModel: ShoppingListsViewModel = hiltViewModel(), shoppingListsViewModel: ShoppingListsViewModel = hiltViewModel(),
) { ) {
val loadingState = shoppingListsViewModel.loadingState.collectAsState() val loadingState by shoppingListsViewModel.loadingState.collectAsState()
val errorToShowInSnackbar = shoppingListsViewModel.errorToShowInSnackBar val errorToShowInSnackbar = shoppingListsViewModel.errorToShowInSnackBar
LazyColumnWithLoadingState( LazyColumnWithLoadingState(
loadingState = loadingState.value, loadingState = loadingState,
errorToShowInSnackbar = errorToShowInSnackbar, emptyListError = loadingState.error?.let { getErrorMessage(it) }
?: stringResource(R.string.shopping_lists_screen_empty),
retryButtonText = stringResource(id = R.string.shopping_lists_screen_empty_button_refresh),
snackbarText = errorToShowInSnackbar?.let { getErrorMessage(error = it) },
onSnackbarShown = shoppingListsViewModel::onSnackbarShown, onSnackbarShown = shoppingListsViewModel::onSnackbarShown,
onRefresh = shoppingListsViewModel::refresh, onRefresh = shoppingListsViewModel::refresh
defaultEmptyListError = stringResource(R.string.shopping_lists_screen_empty), ) { items ->
lazyColumnContent = { items -> items(items) { shoppingList ->
items(items) { shoppingList -> ShoppingListCard(
ShoppingListCard( shoppingList = shoppingList,
shoppingList = shoppingList, onItemClick = { clickedEntity ->
onItemClick = { clickedEntity -> val shoppingListId = clickedEntity.id
val shoppingListId = clickedEntity.id navigator.navigate(ShoppingListScreenDestination(shoppingListId))
navigator.navigate(ShoppingListScreenDestination(shoppingListId)) }
} )
)
}
} }
) }
} }
@Composable @Composable
@Preview @ColorSchemePreview
private fun PreviewShoppingListCard() { private fun PreviewShoppingListCard() {
AppTheme { AppTheme {
ShoppingListCard( ShoppingListCard(

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.shopping_lists.ui package gq.kirmanak.mealient.shopping_lists.ui.list
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -8,12 +8,13 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.architecture.valueUpdatesOnly import gq.kirmanak.mealient.architecture.valueUpdatesOnly
import gq.kirmanak.mealient.datasource.models.GetShoppingListsSummaryResponse import gq.kirmanak.mealient.datasource.models.GetShoppingListsSummaryResponse
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsAuthRepo import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsAuthRepo
import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsRepo import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsRepo
import gq.kirmanak.mealient.shopping_lists.util.LoadingHelper import gq.kirmanak.mealient.ui.util.LoadingHelper
import gq.kirmanak.mealient.shopping_lists.util.LoadingHelperFactory import gq.kirmanak.mealient.ui.util.LoadingHelperFactory
import gq.kirmanak.mealient.shopping_lists.util.LoadingState import gq.kirmanak.mealient.ui.util.LoadingState
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -27,7 +28,9 @@ class ShoppingListsViewModel @Inject constructor(
) : ViewModel() { ) : ViewModel() {
private val loadingHelper: LoadingHelper<List<GetShoppingListsSummaryResponse>> = private val loadingHelper: LoadingHelper<List<GetShoppingListsSummaryResponse>> =
loadingHelperFactory.create(viewModelScope) { shoppingListsRepo.getShoppingLists() } loadingHelperFactory.create(viewModelScope) {
runCatchingExceptCancel { shoppingListsRepo.getShoppingLists() }
}
val loadingState: StateFlow<LoadingState<List<GetShoppingListsSummaryResponse>>> = val loadingState: StateFlow<LoadingState<List<GetShoppingListsSummaryResponse>>> =
loadingHelper.loadingState loadingHelper.loadingState

View File

@@ -1,8 +0,0 @@
package gq.kirmanak.mealient.shopping_lists.util
import kotlinx.coroutines.CoroutineScope
interface LoadingHelperFactory {
fun <T> create(coroutineScope: CoroutineScope, fetch: suspend () -> T): LoadingHelper<T>
}

View File

@@ -87,8 +87,8 @@ accompanistVersion = "0.32.0"
materialCompose = "1.5.4" materialCompose = "1.5.4"
# https://github.com/raamcosta/compose-destinations # https://github.com/raamcosta/compose-destinations
composeDestinations = "1.9.54" composeDestinations = "1.9.54"
# https://mvnrepository.com/artifact/androidx.hilt/hilt-navigation-compose # https://developer.android.com/jetpack/androidx/releases/hilt
hiltNavigationCompose = "1.0.0" androidxHilt = "1.1.0"
# https://github.com/ktorio/ktor/releases # https://github.com/ktorio/ktor/releases
ktor = "2.3.5" ktor = "2.3.5"
# https://github.com/coil-kt/coil/releases # https://github.com/coil-kt/coil/releases
@@ -118,7 +118,7 @@ google-dagger-hiltCompiler = { group = "com.google.dagger", name = "hilt-compile
google-dagger-hiltAndroidCompiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } google-dagger-hiltAndroidCompiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
google-dagger-hiltAndroidTesting = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" } google-dagger-hiltAndroidTesting = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" }
androidx-hilt-navigationCompose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } androidx-hilt-navigationCompose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "androidxHilt" }
google-protobuf-javalite = { group = "com.google.protobuf", name = "protobuf-javalite", version.ref = "protobuf" } google-protobuf-javalite = { group = "com.google.protobuf", name = "protobuf-javalite", version.ref = "protobuf" }
google-protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" } google-protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" }
@@ -148,6 +148,7 @@ androidx-shareTarget = { group = "androidx.sharetarget", name = "sharetarget", v
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" }
androidx-paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "paging" }
androidx-lifecycle-livedataKtx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycle" } androidx-lifecycle-livedataKtx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycle" }
androidx-lifecycle-viewmodelKtx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" } androidx-lifecycle-viewmodelKtx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
@@ -193,6 +194,7 @@ io-mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
chuckerteam-chucker = { group = "com.github.chuckerteam.chucker", name = "library", version.ref = "chucker" } chuckerteam-chucker = { group = "com.github.chuckerteam.chucker", name = "library", version.ref = "chucker" }
kaspersky-kaspresso = { group = "com.kaspersky.android-components", name = "kaspresso", version.ref = "kaspresso" } kaspersky-kaspresso = { group = "com.kaspersky.android-components", name = "kaspresso", version.ref = "kaspresso" }
kaspersky-kaspresso-compose = { group = "com.kaspersky.android-components", name = "kaspresso-compose-support", version.ref = "kaspresso" }
composeDestinations-core = { group = "io.github.raamcosta.compose-destinations", name = "core", version.ref = "composeDestinations" } composeDestinations-core = { group = "io.github.raamcosta.compose-destinations", name = "core", version.ref = "composeDestinations" }
composeDestinations-ksp = { group = "io.github.raamcosta.compose-destinations", name = "ksp", version.ref = "composeDestinations" } composeDestinations-ksp = { group = "io.github.raamcosta.compose-destinations", name = "ksp", version.ref = "composeDestinations" }

View File

@@ -11,12 +11,17 @@ android {
} }
dependencies { dependencies {
implementation(project(":logging"))
implementation(libs.google.dagger.hiltAndroid) implementation(libs.google.dagger.hiltAndroid)
kapt(libs.google.dagger.hiltCompiler) kapt(libs.google.dagger.hiltCompiler)
kaptTest(libs.google.dagger.hiltAndroidCompiler) kaptTest(libs.google.dagger.hiltAndroidCompiler)
testImplementation(libs.google.dagger.hiltAndroidTesting) testImplementation(libs.google.dagger.hiltAndroidTesting)
implementation(libs.android.material.material) implementation(libs.android.material.material)
implementation(libs.androidx.compose.material)
implementation(libs.androidx.paging.compose)
testImplementation(libs.androidx.test.junit) testImplementation(libs.androidx.test.junit)

View File

@@ -8,7 +8,7 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class ActivityUiStateControllerImpl @Inject constructor() : ActivityUiStateController { internal class ActivityUiStateControllerImpl @Inject constructor() : ActivityUiStateController {
private val uiState = MutableStateFlow(ActivityUiState()) private val uiState = MutableStateFlow(ActivityUiState())
override fun updateUiState(update: (ActivityUiState) -> ActivityUiState) { override fun updateUiState(update: (ActivityUiState) -> ActivityUiState) {

View File

@@ -0,0 +1,37 @@
package gq.kirmanak.mealient.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.ComposeView
import androidx.fragment.app.Fragment
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.logging.Logger
import javax.inject.Inject
@AndroidEntryPoint
abstract class BaseComposeFragment : Fragment() {
@Inject
lateinit var logger: Logger
final override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
logger.v { "onCreateView() called" }
return ComposeView(requireContext()).apply {
setContent {
AppTheme {
Screen()
}
}
}
}
@Composable
abstract fun Screen()
}

View File

@@ -4,11 +4,16 @@ import dagger.Binds
import dagger.Module import dagger.Module
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.ui.util.LoadingHelperFactory
import gq.kirmanak.mealient.ui.util.LoadingHelperFactoryImpl
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
interface UiModule { internal interface UiModule {
@Binds @Binds
fun bindActivityUiStateController(impl: ActivityUiStateControllerImpl): ActivityUiStateController fun bindActivityUiStateController(impl: ActivityUiStateControllerImpl): ActivityUiStateController
@Binds
fun bindLoadingHelperFactory(impl: LoadingHelperFactoryImpl): LoadingHelperFactory
} }

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.shopping_lists.ui.composables package gq.kirmanak.mealient.ui.components
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -6,8 +6,8 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import gq.kirmanak.mealient.ui.AppTheme import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.preview.ColorSchemePreview
@Composable @Composable
fun CenteredProgressIndicator( fun CenteredProgressIndicator(
@@ -21,7 +21,7 @@ fun CenteredProgressIndicator(
} }
} }
@Preview @ColorSchemePreview
@Composable @Composable
fun PreviewCenteredProgressIndicator() { fun PreviewCenteredProgressIndicator() {
AppTheme { AppTheme {

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.shopping_lists.ui.composables package gq.kirmanak.mealient.ui.components
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -8,20 +8,19 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.semantics.testTag
import gq.kirmanak.mealient.shopping_list.R
import gq.kirmanak.mealient.ui.AppTheme import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.Dimens import gq.kirmanak.mealient.ui.Dimens
import gq.kirmanak.mealient.ui.preview.ColorSchemePreview
@Composable @Composable
fun EmptyListError( fun EmptyListError(
loadError: Throwable?, text: String,
onRetry: () -> Unit,
defaultError: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onRetry: () -> Unit = {},
retryButtonText: String? = null,
) { ) {
val text = loadError?.let { getErrorMessage(it) } ?: defaultError
Box( Box(
modifier = modifier, modifier = modifier,
) { ) {
@@ -30,27 +29,31 @@ fun EmptyListError(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text( Text(
modifier = Modifier.padding(top = Dimens.Medium), modifier = Modifier
.padding(top = Dimens.Medium)
.semantics { testTag = "empty-list-error-text" },
text = text, text = text,
) )
Button( if (!retryButtonText.isNullOrBlank()) {
modifier = Modifier.padding(top = Dimens.Medium), Button(
onClick = onRetry, modifier = Modifier.padding(top = Dimens.Medium),
) { onClick = onRetry,
Text(text = stringResource(id = R.string.shopping_lists_screen_empty_button_refresh)) ) {
Text(text = retryButtonText)
}
} }
} }
} }
} }
@Composable @Composable
@Preview @ColorSchemePreview
fun PreviewEmptyListError() { fun PreviewEmptyListError() {
AppTheme { AppTheme {
EmptyListError( EmptyListError(
loadError = null, text = "No items in the list",
onRetry = {}, retryButtonText = "Try again",
defaultError = "No items in the list" onRetry = {}
) )
} }
} }

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.shopping_lists.ui.composables package gq.kirmanak.mealient.ui.components
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -8,16 +8,15 @@ import kotlinx.coroutines.launch
@Composable @Composable
fun ErrorSnackbar( fun ErrorSnackbar(
error: Throwable?, text: String?,
snackbarHostState: SnackbarHostState, snackbarHostState: SnackbarHostState,
onSnackbarShown: () -> Unit, onSnackbarShown: () -> Unit,
) { ) {
if (error == null) { if (text.isNullOrBlank()) {
snackbarHostState.currentSnackbarData?.dismiss() snackbarHostState.currentSnackbarData?.dismiss()
return return
} }
val text = getErrorMessage(error = error)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
LaunchedEffect(snackbarHostState) { LaunchedEffect(snackbarHostState) {

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.shopping_lists.ui.composables package gq.kirmanak.mealient.ui.components
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.shopping_lists.ui.composables package gq.kirmanak.mealient.ui.components
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@@ -15,22 +15,22 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import gq.kirmanak.mealient.shopping_lists.util.LoadingState import gq.kirmanak.mealient.ui.util.LoadingState
import gq.kirmanak.mealient.shopping_lists.util.LoadingStateNoData import gq.kirmanak.mealient.ui.util.LoadingStateNoData
import gq.kirmanak.mealient.shopping_lists.util.data import gq.kirmanak.mealient.ui.util.data
import gq.kirmanak.mealient.shopping_lists.util.error import gq.kirmanak.mealient.ui.util.isLoading
import gq.kirmanak.mealient.shopping_lists.util.isLoading import gq.kirmanak.mealient.ui.util.isRefreshing
import gq.kirmanak.mealient.shopping_lists.util.isRefreshing
@OptIn(ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
fun <T> LazyColumnWithLoadingState( fun <T> LazyColumnWithLoadingState(
loadingState: LoadingState<List<T>>, loadingState: LoadingState<List<T>>,
defaultEmptyListError: String, emptyListError: String,
retryButtonText: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(0.dp), contentPadding: PaddingValues = PaddingValues(0.dp),
verticalArrangement: Arrangement.Vertical = Arrangement.Top, verticalArrangement: Arrangement.Vertical = Arrangement.Top,
errorToShowInSnackbar: Throwable? = null, snackbarText: String?,
onSnackbarShown: () -> Unit = {}, onSnackbarShown: () -> Unit = {},
onRefresh: () -> Unit = {}, onRefresh: () -> Unit = {},
floatingActionButton: @Composable () -> Unit = {}, floatingActionButton: @Composable () -> Unit = {},
@@ -62,9 +62,9 @@ fun <T> LazyColumnWithLoadingState(
!loadingState.isLoading && list.isEmpty() -> { !loadingState.isLoading && list.isEmpty() -> {
EmptyListError( EmptyListError(
loadError = loadingState.error, text = emptyListError,
retryButtonText = retryButtonText,
onRetry = onRefresh, onRetry = onRefresh,
defaultError = defaultEmptyListError,
modifier = innerModifier, modifier = innerModifier,
) )
} }
@@ -80,7 +80,7 @@ fun <T> LazyColumnWithLoadingState(
) )
ErrorSnackbar( ErrorSnackbar(
error = errorToShowInSnackbar, text = snackbarText,
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState,
onSnackbarShown = onSnackbarShown, onSnackbarShown = onSnackbarShown,
) )

View File

@@ -0,0 +1,38 @@
package gq.kirmanak.mealient.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun <T : Any> LazyPagingColumnPullRefresh(
lazyPagingItems: LazyPagingItems<T>,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(0.dp),
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
lazyColumnContent: LazyListScope.() -> Unit,
) {
val isRefreshing = lazyPagingItems.loadState.refresh is LoadState.Loading
val refreshState = rememberPullRefreshState(
refreshing = isRefreshing,
onRefresh = lazyPagingItems::refresh,
)
LazyColumnPullRefresh(
modifier = modifier,
refreshState = refreshState,
isRefreshing = isRefreshing,
contentPadding = contentPadding,
verticalArrangement = verticalArrangement,
lazyColumnContent = lazyColumnContent,
)
}

View File

@@ -0,0 +1,45 @@
package gq.kirmanak.mealient.ui.preview
import android.content.res.Configuration.UI_MODE_NIGHT_MASK
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.Wallpapers
@Preview(
name = "Blue",
group = "Day",
showBackground = true,
wallpaper = Wallpapers.BLUE_DOMINATED_EXAMPLE,
)
@Preview(
name = "Red",
group = "Day",
showBackground = true,
wallpaper = Wallpapers.RED_DOMINATED_EXAMPLE,
)
@Preview(
name = "None",
group = "Day",
showBackground = true,
)
@Preview(
name = "Blue",
group = "Night",
showBackground = true,
wallpaper = Wallpapers.BLUE_DOMINATED_EXAMPLE,
uiMode = UI_MODE_NIGHT_MASK and UI_MODE_NIGHT_YES,
)
@Preview(
name = "Red",
group = "Night",
showBackground = true,
wallpaper = Wallpapers.RED_DOMINATED_EXAMPLE,
uiMode = UI_MODE_NIGHT_MASK and UI_MODE_NIGHT_YES,
)
@Preview(
name = "None",
group = "Night",
showBackground = true,
uiMode = UI_MODE_NIGHT_MASK and UI_MODE_NIGHT_YES,
)
annotation class ColorSchemePreview

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.shopping_lists.util package gq.kirmanak.mealient.ui.util
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow

View File

@@ -0,0 +1,11 @@
package gq.kirmanak.mealient.ui.util
import kotlinx.coroutines.CoroutineScope
interface LoadingHelperFactory {
fun <T> create(
coroutineScope: CoroutineScope,
fetch: suspend () -> Result<T>,
): LoadingHelper<T>
}

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.shopping_lists.util package gq.kirmanak.mealient.ui.util
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -6,12 +6,12 @@ import javax.inject.Inject
// @AssistedFactory does not currently support type parameters in the creator method. // @AssistedFactory does not currently support type parameters in the creator method.
// See https://github.com/google/dagger/issues/2279 // See https://github.com/google/dagger/issues/2279
class LoadingHelperFactoryImpl @Inject constructor( internal class LoadingHelperFactoryImpl @Inject constructor(
private val logger: Logger private val logger: Logger
) : LoadingHelperFactory { ) : LoadingHelperFactory {
override fun <T> create( override fun <T> create(
coroutineScope: CoroutineScope, coroutineScope: CoroutineScope,
fetch: suspend () -> T fetch: suspend () -> Result<T>,
): LoadingHelper<T> = LoadingHelperImpl(logger, fetch) ): LoadingHelper<T> = LoadingHelperImpl(logger, fetch)
} }

View File

@@ -1,14 +1,13 @@
package gq.kirmanak.mealient.shopping_lists.util package gq.kirmanak.mealient.ui.util
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
class LoadingHelperImpl<T>( internal class LoadingHelperImpl<T>(
private val logger: Logger, private val logger: Logger,
private val fetch: suspend () -> T, private val fetch: suspend () -> Result<T>,
) : LoadingHelper<T> { ) : LoadingHelper<T> {
private val _loadingState = MutableStateFlow<LoadingState<T>>(LoadingStateNoData.InitialLoad) private val _loadingState = MutableStateFlow<LoadingState<T>>(LoadingStateNoData.InitialLoad)
@@ -22,7 +21,7 @@ class LoadingHelperImpl<T>(
is LoadingStateNoData -> LoadingStateNoData.InitialLoad is LoadingStateNoData -> LoadingStateNoData.InitialLoad
} }
} }
val result = runCatchingExceptCancel { fetch() } val result = fetch()
_loadingState.update { currentState -> _loadingState.update { currentState ->
result.fold( result.fold(
onSuccess = { data -> onSuccess = { data ->

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.shopping_lists.util package gq.kirmanak.mealient.ui.util
sealed class LoadingState<out T> sealed class LoadingState<out T>