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:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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") }
|
||||||
}
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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?)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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?) {
|
||||||
|
|||||||
@@ -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",
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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(
|
||||||
@@ -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
|
||||||
@@ -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() {
|
||||||
@@ -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() {
|
||||||
@@ -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
|
||||||
@@ -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(
|
||||||
@@ -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
|
||||||
@@ -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>
|
|
||||||
}
|
|
||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
@@ -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 = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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) {
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -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 ->
|
||||||
@@ -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>
|
||||||
|
|
||||||
Reference in New Issue
Block a user