From f6f44c75924db6c9b1aad176088160b71c1a3c6e Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Thu, 23 Nov 2023 07:23:30 +0100 Subject: [PATCH] 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 --- app/build.gradle.kts | 10 +- .../gq/kirmanak/mealient/BaseTestCase.kt | 10 +- .../gq/kirmanak/mealient/FirstSetUpTest.kt | 9 +- .../mealient/screen/RecipesListScreen.kt | 23 +- .../data/recipes/impl/RecipeRepoImpl.kt | 2 +- .../mealient/di/GlideModuleEntryPoint.kt | 21 -- .../gq/kirmanak/mealient/di/RecipeModule.kt | 17 -- .../java/gq/kirmanak/mealient/di/UiModule.kt | 25 -- .../kirmanak/mealient/ui/MealieGlideModule.kt | 47 ---- .../mealient/ui/recipes/RecipeViewHolder.kt | 98 -------- .../ui/recipes/RecipesListFragment.kt | 235 ------------------ .../ui/recipes/RecipesPagingAdapter.kt | 56 ----- .../ui/recipes/images/RecipeImageLoader.kt | 9 - .../recipes/images/RecipeImageLoaderImpl.kt | 23 -- .../ui/recipes/images/RecipeModelLoader.kt | 45 ---- .../images/RecipeModelLoaderFactory.kt | 27 -- .../images/RecipePreloadModelProvider.kt | 37 --- .../recipes/images/RecipePreloaderFactory.kt | 10 - .../images/RecipePreloaderFactoryImpl.kt | 33 --- .../mealient/ui/recipes/info/HeaderSection.kt | 16 -- .../ui/recipes/info/IngredientsSection.kt | 20 +- .../ui/recipes/info/InstructionsSection.kt | 21 +- .../ui/recipes/info/RecipeInfoFragment.kt | 32 +-- .../mealient/ui/recipes/info/RecipeScreen.kt | 86 +++---- .../ui/recipes/list/ConfirmDeleteDialog.kt | 55 ++++ .../mealient/ui/recipes/list/RecipeItem.kt | 142 +++++++++++ .../ui/recipes/list/RecipeListItemState.kt | 9 + .../ui/recipes/list/RecipeListSnackbar.kt | 12 + .../mealient/ui/recipes/list/RecipesList.kt | 157 ++++++++++++ .../ui/recipes/list/RecipesListError.kt | 62 +++++ .../ui/recipes/list/RecipesListFragment.kt | 66 +++++ .../{ => list}/RecipesListViewModel.kt | 68 ++++- .../main/res/layout/fragment_recipes_list.xml | 48 ---- .../main/res/layout/view_holder_recipe.xml | 71 ------ app/src/main/res/navigation/nav_graph.xml | 6 +- app/src/main/res/values-de/strings.xml | 2 +- app/src/main/res/values-es/strings.xml | 2 +- app/src/main/res/values-fr/strings.xml | 2 +- app/src/main/res/values-nl/strings.xml | 2 +- app/src/main/res/values-pt/strings.xml | 2 +- app/src/main/res/values-ru/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- .../ui/recipes/RecipesListViewModelTest.kt | 12 +- .../shopping_lists/ShoppingListsModule.kt | 5 - .../ui/composables/CenteredText.kt | 31 --- .../ui/{ => details}/ShoppingListData.kt | 2 +- .../{ => details}/ShoppingListEditingState.kt | 2 +- .../ui/{ => details}/ShoppingListScreen.kt | 40 +-- .../{ => details}/ShoppingListScreenState.kt | 2 +- .../ui/{ => details}/ShoppingListViewModel.kt | 30 ++- .../ui/{ => list}/MealientApp.kt | 3 +- .../ui/{ => list}/ShoppingListsFragment.kt | 2 +- .../ui/{ => list}/ShoppingListsScreen.kt | 44 ++-- .../ui/{ => list}/ShoppingListsViewModel.kt | 13 +- .../util/LoadingHelperFactory.kt | 8 - gradle/libs.versions.toml | 8 +- ui/build.gradle.kts | 5 + .../ui/ActivityUiStateControllerImpl.kt | 2 +- .../mealient/ui/BaseComposeFragment.kt | 37 +++ .../gq/kirmanak/mealient/ui/UiModule.kt | 7 +- .../components}/CenteredProgressIndicator.kt | 6 +- .../mealient/ui/components}/EmptyListError.kt | 39 +-- .../mealient/ui/components}/ErrorSnackbar.kt | 7 +- .../ui/components}/LazyColumnPullRefresh.kt | 2 +- .../components}/LazyColumnWithLoadingState.kt | 24 +- .../components/LazyPagingColumnPullRefresh.kt | 38 +++ .../mealient/ui/preview/ColorSchemePreview.kt | 45 ++++ .../mealient/ui}/util/LoadingHelper.kt | 2 +- .../mealient/ui/util/LoadingHelperFactory.kt | 11 + .../ui}/util/LoadingHelperFactoryImpl.kt | 6 +- .../mealient/ui}/util/LoadingHelperImpl.kt | 9 +- .../mealient/ui}/util/LoadingState.kt | 2 +- 72 files changed, 935 insertions(+), 1131 deletions(-) delete mode 100644 app/src/main/java/gq/kirmanak/mealient/di/GlideModuleEntryPoint.kt delete mode 100644 app/src/main/java/gq/kirmanak/mealient/di/UiModule.kt delete mode 100644 app/src/main/java/gq/kirmanak/mealient/ui/MealieGlideModule.kt delete mode 100644 app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewHolder.kt delete mode 100644 app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListFragment.kt delete mode 100644 app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesPagingAdapter.kt delete mode 100644 app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipeImageLoader.kt delete mode 100644 app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipeImageLoaderImpl.kt delete mode 100644 app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipeModelLoader.kt delete mode 100644 app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipeModelLoaderFactory.kt delete mode 100644 app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipePreloadModelProvider.kt delete mode 100644 app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipePreloaderFactory.kt delete mode 100644 app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipePreloaderFactoryImpl.kt create mode 100644 app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/ConfirmDeleteDialog.kt create mode 100644 app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipeItem.kt create mode 100644 app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipeListItemState.kt create mode 100644 app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipeListSnackbar.kt create mode 100644 app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipesList.kt create mode 100644 app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipesListError.kt create mode 100644 app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipesListFragment.kt rename app/src/main/java/gq/kirmanak/mealient/ui/recipes/{ => list}/RecipesListViewModel.kt (53%) delete mode 100644 app/src/main/res/layout/fragment_recipes_list.xml delete mode 100644 app/src/main/res/layout/view_holder_recipe.xml delete mode 100644 features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/CenteredText.kt rename features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/{ => details}/ShoppingListData.kt (86%) rename features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/{ => details}/ShoppingListEditingState.kt (86%) rename features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/{ => details}/ShoppingListScreen.kt (95%) rename features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/{ => details}/ShoppingListScreenState.kt (96%) rename features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/{ => details}/ShoppingListViewModel.kt (93%) rename features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/{ => list}/MealientApp.kt (81%) rename features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/{ => list}/ShoppingListsFragment.kt (96%) rename features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/{ => list}/ShoppingListsScreen.kt (69%) rename features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/{ => list}/ShoppingListsViewModel.kt (82%) delete mode 100644 features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/util/LoadingHelperFactory.kt create mode 100644 ui/src/main/kotlin/gq/kirmanak/mealient/ui/BaseComposeFragment.kt rename {features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables => ui/src/main/kotlin/gq/kirmanak/mealient/ui/components}/CenteredProgressIndicator.kt (84%) rename {features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables => ui/src/main/kotlin/gq/kirmanak/mealient/ui/components}/EmptyListError.kt (51%) rename {features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables => ui/src/main/kotlin/gq/kirmanak/mealient/ui/components}/ErrorSnackbar.kt (80%) rename {features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables => ui/src/main/kotlin/gq/kirmanak/mealient/ui/components}/LazyColumnPullRefresh.kt (95%) rename {features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables => ui/src/main/kotlin/gq/kirmanak/mealient/ui/components}/LazyColumnWithLoadingState.kt (81%) create mode 100644 ui/src/main/kotlin/gq/kirmanak/mealient/ui/components/LazyPagingColumnPullRefresh.kt create mode 100644 ui/src/main/kotlin/gq/kirmanak/mealient/ui/preview/ColorSchemePreview.kt rename {features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists => ui/src/main/kotlin/gq/kirmanak/mealient/ui}/util/LoadingHelper.kt (76%) create mode 100644 ui/src/main/kotlin/gq/kirmanak/mealient/ui/util/LoadingHelperFactory.kt rename {features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists => ui/src/main/kotlin/gq/kirmanak/mealient/ui}/util/LoadingHelperFactoryImpl.kt (76%) rename {features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists => ui/src/main/kotlin/gq/kirmanak/mealient/ui}/util/LoadingHelperImpl.kt (84%) rename {features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists => ui/src/main/kotlin/gq/kirmanak/mealient/ui}/util/LoadingState.kt (97%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1f10ae5..11fafbd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -120,18 +120,11 @@ dependencies { androidTestImplementation(libs.google.dagger.hiltAndroidTesting) implementation(libs.androidx.paging.runtimeKtx) + implementation(libs.androidx.paging.compose) testImplementation(libs.androidx.paging.commonKtx) implementation(libs.jetbrains.kotlinx.datetime) - implementation(libs.bumptech.glide.glide) - implementation(libs.bumptech.glide.okhttp3) - implementation(libs.bumptech.glide.recyclerview) { - // Excludes the support library because it's already included by Glide. - isTransitive = false - } - ksp(libs.bumptech.glide.ksp) - implementation(libs.kirich1409.viewBinding) implementation(libs.androidx.datastore.preferences) @@ -158,6 +151,7 @@ dependencies { androidTestImplementation(libs.junit) androidTestImplementation(libs.androidx.test.junit) androidTestImplementation(libs.kaspersky.kaspresso) + androidTestImplementation(libs.kaspersky.kaspresso.compose) androidTestImplementation(libs.okhttp3.mockwebserver) androidTestImplementation(libs.androidx.test.core) androidTestImplementation(libs.androidx.test.rules) diff --git a/app/src/androidTest/kotlin/gq/kirmanak/mealient/BaseTestCase.kt b/app/src/androidTest/kotlin/gq/kirmanak/mealient/BaseTestCase.kt index 7025845..d57d819 100644 --- a/app/src/androidTest/kotlin/gq/kirmanak/mealient/BaseTestCase.kt +++ b/app/src/androidTest/kotlin/gq/kirmanak/mealient/BaseTestCase.kt @@ -1,6 +1,8 @@ package gq.kirmanak.mealient -import androidx.test.ext.junit.rules.activityScenarioRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import com.kaspersky.components.composesupport.config.withComposeSupport +import com.kaspersky.kaspresso.kaspresso.Kaspresso import com.kaspersky.kaspresso.testcases.api.testcase.TestCase import dagger.hilt.android.testing.HiltAndroidRule import gq.kirmanak.mealient.ui.activity.MainActivity @@ -9,13 +11,15 @@ import org.junit.After import org.junit.Before import org.junit.Rule -abstract class BaseTestCase : TestCase() { +abstract class BaseTestCase : TestCase( + kaspressoBuilder = Kaspresso.Builder.withComposeSupport(), +) { @get:Rule(order = 0) var hiltRule = HiltAndroidRule(this) @get:Rule(order = 1) - val mainActivityRule = activityScenarioRule() + val mainActivityRule = createAndroidComposeRule() lateinit var mockWebServer: MockWebServer diff --git a/app/src/androidTest/kotlin/gq/kirmanak/mealient/FirstSetUpTest.kt b/app/src/androidTest/kotlin/gq/kirmanak/mealient/FirstSetUpTest.kt index a6b5e96..eae96fe 100644 --- a/app/src/androidTest/kotlin/gq/kirmanak/mealient/FirstSetUpTest.kt +++ b/app/src/androidTest/kotlin/gq/kirmanak/mealient/FirstSetUpTest.kt @@ -4,6 +4,7 @@ import dagger.hilt.android.testing.HiltAndroidTest import gq.kirmanak.mealient.screen.BaseUrlScreen import gq.kirmanak.mealient.screen.DisclaimerScreen import gq.kirmanak.mealient.screen.RecipesListScreen +import io.github.kakaocup.kakao.common.utilities.getResourceString import org.junit.Before import org.junit.Test @@ -65,10 +66,10 @@ class FirstSetUpTest : BaseTestCase() { } step("Check that empty list of recipes is shown") { - RecipesListScreen { - emptyListText { - isVisible() - hasText(R.string.fragment_recipes_list_no_recipes) + RecipesListScreen(mainActivityRule).apply { + errorText { + assertIsDisplayed() + assertTextEquals(getResourceString(R.string.fragment_recipes_load_failure_toast_no_reason)) } } } diff --git a/app/src/androidTest/kotlin/gq/kirmanak/mealient/screen/RecipesListScreen.kt b/app/src/androidTest/kotlin/gq/kirmanak/mealient/screen/RecipesListScreen.kt index 6c2b171..285ae1c 100644 --- a/app/src/androidTest/kotlin/gq/kirmanak/mealient/screen/RecipesListScreen.kt +++ b/app/src/androidTest/kotlin/gq/kirmanak/mealient/screen/RecipesListScreen.kt @@ -1,13 +1,20 @@ package gq.kirmanak.mealient.screen -import com.kaspersky.kaspresso.screens.KScreen -import gq.kirmanak.mealient.R -import gq.kirmanak.mealient.ui.recipes.RecipesListFragment -import io.github.kakaocup.kakao.text.KTextView +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.printToLog +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import org.junit.rules.TestRule -object RecipesListScreen : KScreen() { - override val layoutId: Int = R.layout.fragment_recipes_list - override val viewClass: Class<*> = RecipesListFragment::class.java +class RecipesListScreen( + semanticsProvider: AndroidComposeTestRule, +) : ComposeScreen>(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") } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImpl.kt index 4a23750..5a9b37a 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImpl.kt @@ -28,7 +28,7 @@ class RecipeRepoImpl @Inject constructor( logger.v { "createPager() called" } val pagingConfig = PagingConfig( pageSize = LOAD_PAGE_SIZE, - enablePlaceholders = true, + enablePlaceholders = false, initialLoadSize = INITIAL_LOAD_PAGE_SIZE, ) return Pager( diff --git a/app/src/main/java/gq/kirmanak/mealient/di/GlideModuleEntryPoint.kt b/app/src/main/java/gq/kirmanak/mealient/di/GlideModuleEntryPoint.kt deleted file mode 100644 index 7a4ab32..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/di/GlideModuleEntryPoint.kt +++ /dev/null @@ -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 -} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/di/RecipeModule.kt b/app/src/main/java/gq/kirmanak/mealient/di/RecipeModule.kt index b8b176d..7c0156f 100644 --- a/app/src/main/java/gq/kirmanak/mealient/di/RecipeModule.kt +++ b/app/src/main/java/gq/kirmanak/mealient/di/RecipeModule.kt @@ -1,20 +1,13 @@ package gq.kirmanak.mealient.di -import com.bumptech.glide.load.model.ModelLoaderFactory -import com.bumptech.glide.request.RequestOptions import dagger.Binds import dagger.Module -import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import gq.kirmanak.mealient.R import gq.kirmanak.mealient.data.network.MealieDataSourceWrapper import gq.kirmanak.mealient.data.recipes.RecipeRepo import gq.kirmanak.mealient.data.recipes.impl.* import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource -import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity -import gq.kirmanak.mealient.ui.recipes.images.RecipeModelLoaderFactory -import java.io.InputStream @Module @InstallIn(SingletonComponent::class) @@ -29,16 +22,6 @@ interface RecipeModule { @Binds fun bindImageUrlProvider(recipeImageUrlProviderImpl: RecipeImageUrlProviderImpl): RecipeImageUrlProvider - @Binds - fun bindModelLoaderFactory(recipeModelLoaderFactory: RecipeModelLoaderFactory): ModelLoaderFactory - @Binds fun bindRecipePagingSourceFactory(recipePagingSourceFactoryImpl: RecipePagingSourceFactoryImpl): RecipePagingSourceFactory - - companion object { - - @Provides - fun provideGlideRequestOptions(): RequestOptions = RequestOptions.centerCropTransform() - .placeholder(R.drawable.placeholder_recipe) - } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/di/UiModule.kt b/app/src/main/java/gq/kirmanak/mealient/di/UiModule.kt deleted file mode 100644 index 54f20dd..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/di/UiModule.kt +++ /dev/null @@ -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 - -} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/MealieGlideModule.kt b/app/src/main/java/gq/kirmanak/mealient/ui/MealieGlideModule.kt deleted file mode 100644 index e8f1b54..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/ui/MealieGlideModule.kt +++ /dev/null @@ -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() -} diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewHolder.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewHolder.kt deleted file mode 100644 index a4a7a1d..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewHolder.kt +++ /dev/null @@ -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) -} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListFragment.kt deleted file mode 100644 index e17bdaa..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListFragment.kt +++ /dev/null @@ -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() - private val activityViewModel by activityViewModels() - - @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 PagingDataAdapter.refreshErrors(): Flow { - return loadStateFlow.map { it.refresh }.valueUpdatesOnly().filterIsInstance() - .map { it.error } -} - -private fun PagingDataAdapter.appendPaginationEnd(): Flow { - return loadStateFlow.map { it.append.endOfPaginationReached }.valueUpdatesOnly().filter { it } - .map { } -} - -private fun PagingDataAdapter.sourceIsRefreshing(): Flow { - return loadStateFlow.map { it.source.refresh !is LoadState.NotLoading }.valueUpdatesOnly() -} diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesPagingAdapter.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesPagingAdapter.kt deleted file mode 100644 index 2a15df8..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesPagingAdapter.kt +++ /dev/null @@ -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(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() { - override fun areItemsTheSame( - oldItem: RecipeSummaryEntity, - newItem: RecipeSummaryEntity, - ): Boolean = oldItem.remoteId == newItem.remoteId - - override fun areContentsTheSame( - oldItem: RecipeSummaryEntity, - newItem: RecipeSummaryEntity, - ): Boolean = oldItem == newItem - } -} diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipeImageLoader.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipeImageLoader.kt deleted file mode 100644 index f338454..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipeImageLoader.kt +++ /dev/null @@ -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?) -} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipeImageLoaderImpl.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipeImageLoaderImpl.kt deleted file mode 100644 index 2c70caf..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipeImageLoaderImpl.kt +++ /dev/null @@ -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) - } -} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipeModelLoader.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipeModelLoader.kt deleted file mode 100644 index e390897..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipeModelLoader.kt +++ /dev/null @@ -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, - @Assisted cache: ModelCache, -) : BaseGlideUrlLoader(concreteLoader, cache) { - - @AssistedFactory - interface Factory { - - fun build( - concreteLoader: ModelLoader, - cache: ModelCache, - ): 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) } - } -} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipeModelLoaderFactory.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipeModelLoaderFactory.kt deleted file mode 100644 index d10b309..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipeModelLoaderFactory.kt +++ /dev/null @@ -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 { - - private val cache = ModelCache() - - override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader { - 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 - } -} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipePreloadModelProvider.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipePreloadModelProvider.kt deleted file mode 100644 index 0679415..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipePreloadModelProvider.kt +++ /dev/null @@ -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, - private val fragment: Fragment, - private val requestOptions: RequestOptions, - private val logger: Logger, -) : ListPreloader.PreloadModelProvider { - - override fun getPreloadItems(position: Int): List { - 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): RecipePreloadModelProvider - } -} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipePreloaderFactory.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipePreloaderFactory.kt deleted file mode 100644 index 9e94d3a..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipePreloaderFactory.kt +++ /dev/null @@ -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): RecyclerViewPreloader -} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipePreloaderFactoryImpl.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipePreloaderFactoryImpl.kt deleted file mode 100644 index 34481a1..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipePreloaderFactoryImpl.kt +++ /dev/null @@ -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, - ): RecyclerViewPreloader { - val preloadSizeProvider = ViewPreloadSizeProvider() - val preloadModelProvider = recipePreloadModelProvider.create(adapter) - return RecyclerViewPreloader( - fragment, - preloadModelProvider, - preloadSizeProvider, - MAX_PRELOAD - ) - } - - companion object { - const val MAX_PRELOAD = 10 - } -} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/HeaderSection.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/HeaderSection.kt index d909c86..b97ff94 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/HeaderSection.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/HeaderSection.kt @@ -15,11 +15,9 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import gq.kirmanak.mealient.R -import gq.kirmanak.mealient.ui.AppTheme import gq.kirmanak.mealient.ui.Dimens @Composable @@ -59,7 +57,6 @@ internal fun HeaderSection( .padding(horizontal = Dimens.Small), text = title, style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, ) } @@ -69,20 +66,7 @@ internal fun HeaderSection( .padding(horizontal = Dimens.Small), text = description, style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, ) } } -} - -@Preview -@Composable -private fun HeaderSectionPreview() { - AppTheme { - HeaderSection( - imageUrl = null, - title = "Recipe name", - description = "Recipe description", - ) - } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/IngredientsSection.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/IngredientsSection.kt index 783583d..c347fd8 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/IngredientsSection.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/IngredientsSection.kt @@ -18,10 +18,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview import gq.kirmanak.mealient.R import gq.kirmanak.mealient.database.recipe.entity.RecipeIngredientEntity -import gq.kirmanak.mealient.ui.AppTheme import gq.kirmanak.mealient.ui.Dimens @Composable @@ -36,7 +34,6 @@ internal fun IngredientsSection( .padding(horizontal = Dimens.Large), text = stringResource(id = R.string.fragment_recipe_info_ingredients_header), style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, ) Card( @@ -74,12 +71,9 @@ private fun IngredientListItem( .padding(horizontal = Dimens.Medium), text = title, style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, ) - Divider( - color = MaterialTheme.colorScheme.outline, - ) + Divider() } Row( @@ -96,26 +90,14 @@ private fun IngredientListItem( Text( text = item.display, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, ) if (item.note.isNotBlank()) { Text( text = item.note, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } -} - -@Preview -@Composable -private fun IngredientsSectionPreview() { - AppTheme { - IngredientsSection( - ingredients = INGREDIENTS, - ) - } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/InstructionsSection.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/InstructionsSection.kt index e67b865..6e644bb 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/InstructionsSection.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/InstructionsSection.kt @@ -12,11 +12,9 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview import gq.kirmanak.mealient.R import gq.kirmanak.mealient.database.recipe.entity.RecipeIngredientEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeInstructionEntity -import gq.kirmanak.mealient.ui.AppTheme import gq.kirmanak.mealient.ui.Dimens @Composable @@ -31,7 +29,6 @@ internal fun InstructionsSection( .padding(horizontal = Dimens.Large), text = stringResource(id = R.string.fragment_recipe_info_instructions_header), style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, ) var stepCount = 0 @@ -62,7 +59,6 @@ private fun InstructionListItem( .padding(horizontal = Dimens.Medium), text = title, style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, ) } @@ -82,37 +78,22 @@ private fun InstructionListItem( index + 1 ), style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurface, ) Text( text = item.text.trim(), style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, ) if (ingredients.isNotEmpty()) { - Divider( - color = MaterialTheme.colorScheme.outline, - ) + Divider() ingredients.forEach { ingredient -> Text( text = ingredient.display, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, ) } } } } -} - -@Preview -@Composable -private fun InstructionsSectionPreview() { - AppTheme { - InstructionsSection( - instructions = INSTRUCTIONS, - ) - } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoFragment.kt index 1dcd07c..183d93e 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoFragment.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoFragment.kt @@ -1,45 +1,27 @@ package gq.kirmanak.mealient.ui.recipes.info import android.os.Bundle -import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup +import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.ui.platform.ComposeView -import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import dagger.hilt.android.AndroidEntryPoint -import gq.kirmanak.mealient.logging.Logger -import gq.kirmanak.mealient.ui.AppTheme +import gq.kirmanak.mealient.ui.BaseComposeFragment import gq.kirmanak.mealient.ui.CheckableMenuItem import gq.kirmanak.mealient.ui.activity.MainActivityViewModel -import javax.inject.Inject @AndroidEntryPoint -class RecipeInfoFragment : Fragment() { +class RecipeInfoFragment : BaseComposeFragment() { private val viewModel by viewModels() private val activityViewModel by activityViewModels() - @Inject - lateinit var logger: Logger - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - logger.v { "onCreateView() called" } - return ComposeView(requireContext()).apply { - setContent { - val uiState by viewModel.uiState.collectAsState() - AppTheme { - RecipeScreen(uiState = uiState) - } - } - } + @Composable + override fun Screen() { + val uiState by viewModel.uiState.collectAsState() + RecipeScreen(uiState = uiState) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeScreen.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeScreen.kt index 7c92b7f..5777e86 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeScreen.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeScreen.kt @@ -1,77 +1,73 @@ package gq.kirmanak.mealient.ui.recipes.info -import android.content.res.Configuration.UI_MODE_NIGHT_MASK -import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview import gq.kirmanak.mealient.ui.AppTheme import gq.kirmanak.mealient.ui.Dimens +import gq.kirmanak.mealient.ui.preview.ColorSchemePreview +@OptIn(ExperimentalLayoutApi::class) @Composable fun RecipeScreen( uiState: RecipeInfoUiState, ) { KeepScreenOn() - Column( - modifier = Modifier - .verticalScroll( - state = rememberScrollState(), - ), - verticalArrangement = Arrangement.spacedBy(Dimens.Small, Alignment.Top), - ) { - HeaderSection( - imageUrl = uiState.imageUrl, - title = uiState.title, - description = uiState.description, - ) - - if (uiState.showIngredients) { - IngredientsSection( - ingredients = uiState.recipeIngredients, + Scaffold { padding -> + Column( + modifier = Modifier + .verticalScroll( + state = rememberScrollState(), + ) + .padding(padding) + .consumeWindowInsets(padding), + verticalArrangement = Arrangement.spacedBy(Dimens.Small, Alignment.Top), + ) { + HeaderSection( + imageUrl = uiState.imageUrl, + title = uiState.title, + description = uiState.description, ) - } - if (uiState.showInstructions) { - InstructionsSection( - instructions = uiState.recipeInstructions, - ) + if (uiState.showIngredients) { + IngredientsSection( + ingredients = uiState.recipeIngredients, + ) + } + + if (uiState.showInstructions) { + InstructionsSection( + instructions = uiState.recipeInstructions, + ) + } } } } -@Preview(showBackground = true) +@ColorSchemePreview @Composable private fun RecipeScreenPreview() { AppTheme { RecipeScreen( - uiState = RECIPE_INFO_UI_STATE + uiState = RecipeInfoUiState( + showIngredients = true, + showInstructions = true, + summaryEntity = SUMMARY_ENTITY, + recipeIngredients = INGREDIENTS, + recipeInstructions = INSTRUCTIONS, + title = "Recipe title", + description = "Recipe description", + ) ) } } -@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_MASK and UI_MODE_NIGHT_YES) -@Composable -private fun RecipeScreenNightPreview() { - AppTheme { - RecipeScreen( - uiState = RECIPE_INFO_UI_STATE - ) - } -} - -private val RECIPE_INFO_UI_STATE = RecipeInfoUiState( - showIngredients = true, - showInstructions = true, - summaryEntity = SUMMARY_ENTITY, - recipeIngredients = INGREDIENTS, - recipeInstructions = INSTRUCTIONS, - title = "Recipe title", - description = "Recipe description", -) diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/ConfirmDeleteDialog.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/ConfirmDeleteDialog.kt new file mode 100644 index 0000000..73bf5dd --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/ConfirmDeleteDialog.kt @@ -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 + ), + ) + }, + ) +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipeItem.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipeItem.kt new file mode 100644 index 0000000..a46515a --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipeItem.kt @@ -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 = {}, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipeListItemState.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipeListItemState.kt new file mode 100644 index 0000000..704572e --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipeListItemState.kt @@ -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, +) diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipeListSnackbar.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipeListSnackbar.kt new file mode 100644 index 0000000..e0ae477 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipeListSnackbar.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipesList.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipesList.kt new file mode 100644 index 0000000..d35ad32 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipesList.kt @@ -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>, + onDeleteClick: (RecipeListItemState) -> Unit, + onFavoriteClick: (RecipeListItemState) -> Unit, + onItemClick: (RecipeListItemState) -> Unit, + onSnackbarShown: () -> Unit, + snackbarMessageState: StateFlow, +) { + val recipes: LazyPagingItems = 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, + 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) }, + ) + } + } + } +} + diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipesListError.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipesListError.kt new file mode 100644 index 0000000..d8f237c --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipesListError.kt @@ -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, + 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>().collectAsLazyPagingItems(), + modifier = Modifier.fillMaxSize() + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipesListFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipesListFragment.kt new file mode 100644 index 0000000..dd7789c --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipesListFragment.kt @@ -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() + private val activityViewModel by activityViewModels() + + 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) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipesListViewModel.kt similarity index 53% rename from app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListViewModel.kt rename to app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipesListViewModel.kt index 092bcea..d47f815 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/list/RecipesListViewModel.kt @@ -1,4 +1,4 @@ -package gq.kirmanak.mealient.ui.recipes +package gq.kirmanak.mealient.ui.recipes.list import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel @@ -6,18 +6,23 @@ import androidx.lifecycle.liveData import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn +import androidx.paging.map import dagger.hilt.android.lifecycle.HiltViewModel import gq.kirmanak.mealient.architecture.valueUpdatesOnly import gq.kirmanak.mealient.data.auth.AuthRepo import gq.kirmanak.mealient.data.recipes.RecipeRepo +import gq.kirmanak.mealient.data.recipes.impl.RecipeImageUrlProvider import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity import gq.kirmanak.mealient.logging.Logger import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn @@ -25,17 +30,30 @@ import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class RecipesListViewModel @Inject constructor( +internal class RecipesListViewModel @Inject constructor( private val recipeRepo: RecipeRepo, - authRepo: AuthRepo, private val logger: Logger, + private val recipeImageUrlProvider: RecipeImageUrlProvider, + authRepo: AuthRepo, ) : ViewModel() { - val pagingData: Flow> = recipeRepo.createPager().flow - .cachedIn(viewModelScope) + private val pagingData: Flow> = + recipeRepo.createPager().flow.cachedIn(viewModelScope) - val showFavoriteIcon: StateFlow = authRepo.isAuthorizedFlow - .stateIn(viewModelScope, SharingStarted.Eagerly, false) + private val showFavoriteIcon: StateFlow = + authRepo.isAuthorizedFlow.stateIn(viewModelScope, SharingStarted.Eagerly, false) + + val pagingDataRecipeState: Flow> = + 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>( replay = 0, @@ -44,6 +62,9 @@ class RecipesListViewModel @Inject constructor( ) val deleteRecipeResult: SharedFlow> get() = _deleteRecipeResult + private val _snackbarState = MutableStateFlow(null) + val snackbarState get() = _snackbarState.asStateFlow() + init { authRepo.isAuthorizedFlow.valueUpdatesOnly().onEach { hasAuthorized -> logger.v { "Authorization state changed to $hasAuthorized" } @@ -60,12 +81,27 @@ class RecipesListViewModel @Inject constructor( } } - fun onFavoriteIconClick(recipeSummaryEntity: RecipeSummaryEntity) = liveData { + fun onFavoriteIconClick(recipeSummaryEntity: RecipeSummaryEntity) { logger.v { "onFavoriteIconClick() called with: recipeSummaryEntity = $recipeSummaryEntity" } - recipeRepo.updateIsRecipeFavorite( - recipeSlug = recipeSummaryEntity.slug, - isFavorite = recipeSummaryEntity.isFavorite.not(), - ).also { emit(it) } + viewModelScope.launch { + val result = recipeRepo.updateIsRecipeFavorite( + recipeSlug = recipeSummaryEntity.slug, + isFavorite = recipeSummaryEntity.isFavorite.not(), + ) + _snackbarState.value = result.fold( + onSuccess = { isFavorite -> + val name = recipeSummaryEntity.name + if (isFavorite) { + RecipeListSnackbar.FavoriteAdded(name) + } else { + RecipeListSnackbar.FavoriteRemoved(name) + } + }, + onFailure = { + RecipeListSnackbar.FavoriteUpdateFailed + } + ) + } } fun onDeleteConfirm(recipeSummaryEntity: RecipeSummaryEntity) { @@ -74,6 +110,14 @@ class RecipesListViewModel @Inject constructor( val result = recipeRepo.deleteRecipe(recipeSummaryEntity) logger.d { "onDeleteConfirm: delete result is $result" } _deleteRecipeResult.emit(result) + _snackbarState.value = result.fold( + onSuccess = { null }, + onFailure = { RecipeListSnackbar.DeleteFailed }, + ) } } + + fun onSnackbarShown() { + _snackbarState.value = null + } } \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_recipes_list.xml b/app/src/main/res/layout/fragment_recipes_list.xml deleted file mode 100644 index 5738142..0000000 --- a/app/src/main/res/layout/fragment_recipes_list.xml +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/view_holder_recipe.xml b/app/src/main/res/layout/view_holder_recipe.xml deleted file mode 100644 index 0b46443..0000000 --- a/app/src/main/res/layout/view_holder_recipe.xml +++ /dev/null @@ -1,71 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml index 7e6b484..64ac933 100644 --- a/app/src/main/res/navigation/nav_graph.xml +++ b/app/src/main/res/navigation/nav_graph.xml @@ -13,9 +13,7 @@ + android:name="gq.kirmanak.mealient.ui.recipes.list.RecipesListFragment"> + android:name="gq.kirmanak.mealient.shopping_lists.ui.list.ShoppingListsFragment" /> Rezept erfolgreich gespeichert Klar Beispiel: demo.mealie.io - Beispiel: changeme@email.com + Beispiel: changeme@example.com Beispiel: Demo Zuletzt geladene Seite Ladefehler: %1$s. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 5207eac..6cd4e0f 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -43,7 +43,7 @@ Receta guardada con éxito Limpiar Ejemplo: demo.mealie.io - Ejemplo: changeme@email.com + Ejemplo: changeme@example.com Ejemplo: demo Última página cargada Error al cargar: %1$s. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 5e454e8..287764b 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -43,7 +43,7 @@ Sauvegarde réussie de la recette Clair Exemple : demo.mealie.io - Exemple : changeme@email.com + Exemple : changeme@example.com Exemple : démo Dernière page chargée Erreur de chargement : %1$s. diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index e80c748..e628b6a 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -43,7 +43,7 @@ Recept succesvol opgeslagen Duidelijk Voorbeeld: demo.mealie.io - Voorbeeld: changeme@email.com + Voorbeeld: changeme@example.com Voorbeeld: demo Laatste pagina geladen Fout bij laden: %1$s. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 7e6b942..fba41f7 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -43,7 +43,7 @@ Receita guardada com sucesso Limpo Exemplo: demo.mealie.io - Exemplo: changeme@email.com + Exemplo: changeme@example.com Exemplo: demo Última página carregada Erro de carregamento: %1$s. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 82eedf6..f4a3db0 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -43,7 +43,7 @@ Рецепт сохранен успешно Очистить Пример: demo.mealie.io - Пример: changeme@email.com + Пример: changeme@example.com Пример: demo Последняя страница Ошибка загрузки: %1$s. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dfe7fc5..d3e8e64 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -45,7 +45,7 @@ Saved recipe successfully Clear Example: demo.mealie.io - Example: changeme@email.com + Example: changeme@example.com Example: demo Last page loaded Load error: %1$s. diff --git a/app/src/test/java/gq/kirmanak/mealient/ui/recipes/RecipesListViewModelTest.kt b/app/src/test/java/gq/kirmanak/mealient/ui/recipes/RecipesListViewModelTest.kt index 4b9dcea..33e20e5 100644 --- a/app/src/test/java/gq/kirmanak/mealient/ui/recipes/RecipesListViewModelTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/ui/recipes/RecipesListViewModelTest.kt @@ -4,8 +4,10 @@ import androidx.lifecycle.asFlow import com.google.common.truth.Truth.assertThat import gq.kirmanak.mealient.data.auth.AuthRepo import gq.kirmanak.mealient.data.recipes.RecipeRepo +import gq.kirmanak.mealient.data.recipes.impl.RecipeImageUrlProvider import gq.kirmanak.mealient.database.CAKE_RECIPE_SUMMARY_ENTITY import gq.kirmanak.mealient.test.BaseUnitTest +import gq.kirmanak.mealient.ui.recipes.list.RecipesListViewModel import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -32,6 +34,9 @@ class RecipesListViewModelTest : BaseUnitTest() { @MockK(relaxed = true) lateinit var recipeRepo: RecipeRepo + @MockK(relaxed = true) + lateinit var recipeImageUrlProvider: RecipeImageUrlProvider + @Before override fun setUp() { super.setUp() @@ -116,5 +121,10 @@ class RecipesListViewModelTest : BaseUnitTest() { return results } - private fun createSubject() = RecipesListViewModel(recipeRepo, authRepo, logger) + private fun createSubject() = RecipesListViewModel( + recipeRepo = recipeRepo, + logger = logger, + recipeImageUrlProvider = recipeImageUrlProvider, + authRepo = authRepo, + ) } \ No newline at end of file diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ShoppingListsModule.kt b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ShoppingListsModule.kt index d13567d..a593726 100644 --- a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ShoppingListsModule.kt +++ b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ShoppingListsModule.kt @@ -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.repo.ShoppingListsRepo 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 @InstallIn(SingletonComponent::class) @@ -20,7 +18,4 @@ interface ShoppingListsModule { @Binds fun bindShoppingListsRepo(impl: ShoppingListsRepoImpl): ShoppingListsRepo - - @Binds - fun bindLoadingHelperFactory(impl: LoadingHelperFactoryImpl): LoadingHelperFactory } \ No newline at end of file diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/CenteredText.kt b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/CenteredText.kt deleted file mode 100644 index 8e4edf2..0000000 --- a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/CenteredText.kt +++ /dev/null @@ -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") - } -} \ No newline at end of file diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListData.kt b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/details/ShoppingListData.kt similarity index 86% rename from features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListData.kt rename to features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/details/ShoppingListData.kt index b2e636e..dbe3052 100644 --- a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListData.kt +++ b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/details/ShoppingListData.kt @@ -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.GetShoppingListResponse diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListEditingState.kt b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/details/ShoppingListEditingState.kt similarity index 86% rename from features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListEditingState.kt rename to features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/details/ShoppingListEditingState.kt index 840dec0..3f2a775 100644 --- a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListEditingState.kt +++ b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/details/ShoppingListEditingState.kt @@ -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 diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListScreen.kt b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/details/ShoppingListScreen.kt similarity index 95% rename from features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListScreen.kt rename to features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/details/ShoppingListScreen.kt index 41dff48..6709559 100644 --- a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListScreen.kt +++ b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/details/ShoppingListScreen.kt @@ -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.foundation.background @@ -49,7 +49,6 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.navigation.compose.hiltViewModel import com.ramcosta.composedestinations.annotation.Destination 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.GetUnitResponse import gq.kirmanak.mealient.shopping_list.R -import gq.kirmanak.mealient.shopping_lists.ui.composables.LazyColumnWithLoadingState -import gq.kirmanak.mealient.shopping_lists.util.data -import gq.kirmanak.mealient.shopping_lists.util.map +import gq.kirmanak.mealient.shopping_lists.ui.composables.getErrorMessage import gq.kirmanak.mealient.ui.AppTheme 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 java.text.DecimalFormat @@ -77,7 +79,7 @@ data class ShoppingListNavArgs( internal fun ShoppingListScreen( shoppingListViewModel: ShoppingListViewModel = hiltViewModel(), ) { - val loadingState = shoppingListViewModel.loadingState.collectAsState().value + val loadingState by shoppingListViewModel.loadingState.collectAsState() val defaultEmptyListError = stringResource( R.string.shopping_list_screen_empty_list, loadingState.data?.name.orEmpty() @@ -85,6 +87,8 @@ internal fun ShoppingListScreen( LazyColumnWithLoadingState( 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( start = Dimens.Medium, end = Dimens.Medium, @@ -92,10 +96,9 @@ internal fun ShoppingListScreen( bottom = Dimens.Large * 4, ), verticalArrangement = Arrangement.spacedBy(Dimens.Medium), - defaultEmptyListError = defaultEmptyListError, - errorToShowInSnackbar = shoppingListViewModel.errorToShowInSnackbar, - onRefresh = shoppingListViewModel::refreshShoppingList, + snackbarText = shoppingListViewModel.errorToShowInSnackbar?.let { getErrorMessage(error = it) }, onSnackbarShown = shoppingListViewModel::onSnackbarShown, + onRefresh = shoppingListViewModel::refreshShoppingList, floatingActionButton = { FloatingActionButton(onClick = shoppingListViewModel::onAddItemClicked) { Icon( @@ -120,7 +123,12 @@ internal fun ShoppingListScreen( ShoppingListItemEditor( state = state, onEditCancelled = { shoppingListViewModel.onEditCancel(itemState) }, - onEditConfirmed = { shoppingListViewModel.onEditConfirm(itemState, state) } + onEditConfirmed = { + shoppingListViewModel.onEditConfirm( + itemState, + state + ) + } ) } else { ShoppingListItem( @@ -439,7 +447,7 @@ class ShoppingListItemEditorState( var unitsExpanded: Boolean by mutableStateOf(false) } -@Preview +@ColorSchemePreview @Composable fun ShoppingListItemEditorPreview() { AppTheme { @@ -453,7 +461,7 @@ fun ShoppingListItemEditorPreview() { } } -@Preview +@ColorSchemePreview @Composable fun ShoppingListItemEditorNonFoodPreview() { AppTheme { @@ -567,7 +575,7 @@ fun ShoppingListItem( @OptIn(ExperimentalMaterial3Api::class) @Composable -@Preview +@ColorSchemePreview fun PreviewShoppingListItemChecked() { AppTheme { ShoppingListItem( @@ -579,7 +587,7 @@ fun PreviewShoppingListItemChecked() { @OptIn(ExperimentalMaterial3Api::class) @Composable -@Preview +@ColorSchemePreview fun PreviewShoppingListItemUnchecked() { AppTheme { ShoppingListItem( @@ -591,7 +599,7 @@ fun PreviewShoppingListItemUnchecked() { @OptIn(ExperimentalMaterial3Api::class) @Composable -@Preview +@ColorSchemePreview fun PreviewShoppingListItemDismissed() { AppTheme { ShoppingListItem( @@ -606,7 +614,7 @@ fun PreviewShoppingListItemDismissed() { @OptIn(ExperimentalMaterial3Api::class) @Composable -@Preview +@ColorSchemePreview fun PreviewShoppingListItemEditing() { AppTheme { ShoppingListItem( diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListScreenState.kt b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/details/ShoppingListScreenState.kt similarity index 96% rename from features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListScreenState.kt rename to features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/details/ShoppingListScreenState.kt index 05b7646..5f55fe3 100644 --- a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListScreenState.kt +++ b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/details/ShoppingListScreenState.kt @@ -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.GetShoppingListItemResponse diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListViewModel.kt b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/details/ShoppingListViewModel.kt similarity index 93% rename from features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListViewModel.kt rename to features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/details/ShoppingListViewModel.kt index 662eac9..4360c82 100644 --- a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListViewModel.kt +++ b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/details/ShoppingListViewModel.kt @@ -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.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.ShoppingListsRepo import gq.kirmanak.mealient.shopping_lists.ui.destinations.ShoppingListScreenDestination -import gq.kirmanak.mealient.shopping_lists.util.LoadingHelperFactory -import gq.kirmanak.mealient.shopping_lists.util.LoadingState -import gq.kirmanak.mealient.shopping_lists.util.LoadingStateNoData -import gq.kirmanak.mealient.shopping_lists.util.data -import gq.kirmanak.mealient.shopping_lists.util.map +import gq.kirmanak.mealient.ui.util.LoadingHelperFactory +import gq.kirmanak.mealient.ui.util.LoadingState +import gq.kirmanak.mealient.ui.util.LoadingStateNoData +import gq.kirmanak.mealient.ui.util.data +import gq.kirmanak.mealient.ui.util.map import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope 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 = coroutineScope { val foodsDeferred = async { runCatchingExceptCancel { shoppingListsRepo.getFoods() @@ -93,14 +93,18 @@ internal class ShoppingListViewModel @Inject constructor( } val shoppingListDeferred = async { - shoppingListsRepo.getShoppingList(args.shoppingListId) + runCatchingExceptCancel { + shoppingListsRepo.getShoppingList(args.shoppingListId) + } } - ShoppingListData( - foods = foodsDeferred.await(), - units = unitsDeferred.await(), - shoppingList = shoppingListDeferred.await(), - ) + shoppingListDeferred.await().map { + ShoppingListData( + foods = foodsDeferred.await(), + units = unitsDeferred.await(), + shoppingList = it + ) + } } private suspend fun doRefresh() { diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/MealientApp.kt b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/list/MealientApp.kt similarity index 81% rename from features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/MealientApp.kt rename to features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/list/MealientApp.kt index e97514c..b0d8b9e 100644 --- a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/MealientApp.kt +++ b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/list/MealientApp.kt @@ -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 com.ramcosta.composedestinations.DestinationsNavHost import com.ramcosta.composedestinations.rememberNavHostEngine +import gq.kirmanak.mealient.shopping_lists.ui.NavGraphs @Composable fun MealientApp() { diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListsFragment.kt b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/list/ShoppingListsFragment.kt similarity index 96% rename from features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListsFragment.kt rename to features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/list/ShoppingListsFragment.kt index 189c0a4..646cf95 100644 --- a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListsFragment.kt +++ b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/list/ShoppingListsFragment.kt @@ -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.view.LayoutInflater diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListsScreen.kt b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/list/ShoppingListsScreen.kt similarity index 69% rename from features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListsScreen.kt rename to features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/list/ShoppingListsScreen.kt index a77a4a5..3002b4b 100644 --- a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListsScreen.kt +++ b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/list/ShoppingListsScreen.kt @@ -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.layout.Row @@ -11,21 +11,24 @@ import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.navigation.compose.hiltViewModel import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootNavGraph import com.ramcosta.composedestinations.navigation.DestinationsNavigator import gq.kirmanak.mealient.datasource.models.GetShoppingListsSummaryResponse 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.ui.AppTheme 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) @Destination(start = true) @@ -34,31 +37,32 @@ fun ShoppingListsScreen( navigator: DestinationsNavigator, shoppingListsViewModel: ShoppingListsViewModel = hiltViewModel(), ) { - val loadingState = shoppingListsViewModel.loadingState.collectAsState() + val loadingState by shoppingListsViewModel.loadingState.collectAsState() val errorToShowInSnackbar = shoppingListsViewModel.errorToShowInSnackBar LazyColumnWithLoadingState( - loadingState = loadingState.value, - errorToShowInSnackbar = errorToShowInSnackbar, + loadingState = loadingState, + 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, - onRefresh = shoppingListsViewModel::refresh, - defaultEmptyListError = stringResource(R.string.shopping_lists_screen_empty), - lazyColumnContent = { items -> - items(items) { shoppingList -> - ShoppingListCard( - shoppingList = shoppingList, - onItemClick = { clickedEntity -> - val shoppingListId = clickedEntity.id - navigator.navigate(ShoppingListScreenDestination(shoppingListId)) - } - ) - } + onRefresh = shoppingListsViewModel::refresh + ) { items -> + items(items) { shoppingList -> + ShoppingListCard( + shoppingList = shoppingList, + onItemClick = { clickedEntity -> + val shoppingListId = clickedEntity.id + navigator.navigate(ShoppingListScreenDestination(shoppingListId)) + } + ) } - ) + } } @Composable -@Preview +@ColorSchemePreview private fun PreviewShoppingListCard() { AppTheme { ShoppingListCard( diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListsViewModel.kt b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/list/ShoppingListsViewModel.kt similarity index 82% rename from features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListsViewModel.kt rename to features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/list/ShoppingListsViewModel.kt index f88f484..767933e 100644 --- a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/ShoppingListsViewModel.kt +++ b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/list/ShoppingListsViewModel.kt @@ -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.mutableStateOf @@ -8,12 +8,13 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import gq.kirmanak.mealient.architecture.valueUpdatesOnly import gq.kirmanak.mealient.datasource.models.GetShoppingListsSummaryResponse +import gq.kirmanak.mealient.datasource.runCatchingExceptCancel import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsAuthRepo import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsRepo -import gq.kirmanak.mealient.shopping_lists.util.LoadingHelper -import gq.kirmanak.mealient.shopping_lists.util.LoadingHelperFactory -import gq.kirmanak.mealient.shopping_lists.util.LoadingState +import gq.kirmanak.mealient.ui.util.LoadingHelper +import gq.kirmanak.mealient.ui.util.LoadingHelperFactory +import gq.kirmanak.mealient.ui.util.LoadingState import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import javax.inject.Inject @@ -27,7 +28,9 @@ class ShoppingListsViewModel @Inject constructor( ) : ViewModel() { private val loadingHelper: LoadingHelper> = - loadingHelperFactory.create(viewModelScope) { shoppingListsRepo.getShoppingLists() } + loadingHelperFactory.create(viewModelScope) { + runCatchingExceptCancel { shoppingListsRepo.getShoppingLists() } + } val loadingState: StateFlow>> = loadingHelper.loadingState diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/util/LoadingHelperFactory.kt b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/util/LoadingHelperFactory.kt deleted file mode 100644 index 963e595..0000000 --- a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/util/LoadingHelperFactory.kt +++ /dev/null @@ -1,8 +0,0 @@ -package gq.kirmanak.mealient.shopping_lists.util - -import kotlinx.coroutines.CoroutineScope - -interface LoadingHelperFactory { - - fun create(coroutineScope: CoroutineScope, fetch: suspend () -> T): LoadingHelper -} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8eede0b..c8b059e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -87,8 +87,8 @@ accompanistVersion = "0.32.0" materialCompose = "1.5.4" # https://github.com/raamcosta/compose-destinations composeDestinations = "1.9.54" -# https://mvnrepository.com/artifact/androidx.hilt/hilt-navigation-compose -hiltNavigationCompose = "1.0.0" +# https://developer.android.com/jetpack/androidx/releases/hilt +androidxHilt = "1.1.0" # https://github.com/ktorio/ktor/releases ktor = "2.3.5" # 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-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-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-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-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" } 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-ksp = { group = "io.github.raamcosta.compose-destinations", name = "ksp", version.ref = "composeDestinations" } diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index a6e3952..77f9092 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -11,12 +11,17 @@ android { } dependencies { + implementation(project(":logging")) + implementation(libs.google.dagger.hiltAndroid) kapt(libs.google.dagger.hiltCompiler) kaptTest(libs.google.dagger.hiltAndroidCompiler) testImplementation(libs.google.dagger.hiltAndroidTesting) implementation(libs.android.material.material) + implementation(libs.androidx.compose.material) + + implementation(libs.androidx.paging.compose) testImplementation(libs.androidx.test.junit) diff --git a/ui/src/main/kotlin/gq/kirmanak/mealient/ui/ActivityUiStateControllerImpl.kt b/ui/src/main/kotlin/gq/kirmanak/mealient/ui/ActivityUiStateControllerImpl.kt index 1d6ea11..43a4590 100644 --- a/ui/src/main/kotlin/gq/kirmanak/mealient/ui/ActivityUiStateControllerImpl.kt +++ b/ui/src/main/kotlin/gq/kirmanak/mealient/ui/ActivityUiStateControllerImpl.kt @@ -8,7 +8,7 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class ActivityUiStateControllerImpl @Inject constructor() : ActivityUiStateController { +internal class ActivityUiStateControllerImpl @Inject constructor() : ActivityUiStateController { private val uiState = MutableStateFlow(ActivityUiState()) override fun updateUiState(update: (ActivityUiState) -> ActivityUiState) { diff --git a/ui/src/main/kotlin/gq/kirmanak/mealient/ui/BaseComposeFragment.kt b/ui/src/main/kotlin/gq/kirmanak/mealient/ui/BaseComposeFragment.kt new file mode 100644 index 0000000..1de4603 --- /dev/null +++ b/ui/src/main/kotlin/gq/kirmanak/mealient/ui/BaseComposeFragment.kt @@ -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() +} \ No newline at end of file diff --git a/ui/src/main/kotlin/gq/kirmanak/mealient/ui/UiModule.kt b/ui/src/main/kotlin/gq/kirmanak/mealient/ui/UiModule.kt index 4440254..e5c4c64 100644 --- a/ui/src/main/kotlin/gq/kirmanak/mealient/ui/UiModule.kt +++ b/ui/src/main/kotlin/gq/kirmanak/mealient/ui/UiModule.kt @@ -4,11 +4,16 @@ import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import gq.kirmanak.mealient.ui.util.LoadingHelperFactory +import gq.kirmanak.mealient.ui.util.LoadingHelperFactoryImpl @Module @InstallIn(SingletonComponent::class) -interface UiModule { +internal interface UiModule { @Binds fun bindActivityUiStateController(impl: ActivityUiStateControllerImpl): ActivityUiStateController + + @Binds + fun bindLoadingHelperFactory(impl: LoadingHelperFactoryImpl): LoadingHelperFactory } \ No newline at end of file diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/CenteredProgressIndicator.kt b/ui/src/main/kotlin/gq/kirmanak/mealient/ui/components/CenteredProgressIndicator.kt similarity index 84% rename from features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/CenteredProgressIndicator.kt rename to ui/src/main/kotlin/gq/kirmanak/mealient/ui/components/CenteredProgressIndicator.kt index 2bca6e3..1b41018 100644 --- a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/CenteredProgressIndicator.kt +++ b/ui/src/main/kotlin/gq/kirmanak/mealient/ui/components/CenteredProgressIndicator.kt @@ -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.fillMaxSize @@ -6,8 +6,8 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview import gq.kirmanak.mealient.ui.AppTheme +import gq.kirmanak.mealient.ui.preview.ColorSchemePreview @Composable fun CenteredProgressIndicator( @@ -21,7 +21,7 @@ fun CenteredProgressIndicator( } } -@Preview +@ColorSchemePreview @Composable fun PreviewCenteredProgressIndicator() { AppTheme { diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/EmptyListError.kt b/ui/src/main/kotlin/gq/kirmanak/mealient/ui/components/EmptyListError.kt similarity index 51% rename from features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/EmptyListError.kt rename to ui/src/main/kotlin/gq/kirmanak/mealient/ui/components/EmptyListError.kt index 54d7f2e..664f976 100644 --- a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/EmptyListError.kt +++ b/ui/src/main/kotlin/gq/kirmanak/mealient/ui/components/EmptyListError.kt @@ -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.Column @@ -8,20 +8,19 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import gq.kirmanak.mealient.shopping_list.R +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag import gq.kirmanak.mealient.ui.AppTheme import gq.kirmanak.mealient.ui.Dimens +import gq.kirmanak.mealient.ui.preview.ColorSchemePreview @Composable fun EmptyListError( - loadError: Throwable?, - onRetry: () -> Unit, - defaultError: String, + text: String, modifier: Modifier = Modifier, + onRetry: () -> Unit = {}, + retryButtonText: String? = null, ) { - val text = loadError?.let { getErrorMessage(it) } ?: defaultError Box( modifier = modifier, ) { @@ -30,27 +29,31 @@ fun EmptyListError( horizontalAlignment = Alignment.CenterHorizontally, ) { Text( - modifier = Modifier.padding(top = Dimens.Medium), + modifier = Modifier + .padding(top = Dimens.Medium) + .semantics { testTag = "empty-list-error-text" }, text = text, ) - Button( - modifier = Modifier.padding(top = Dimens.Medium), - onClick = onRetry, - ) { - Text(text = stringResource(id = R.string.shopping_lists_screen_empty_button_refresh)) + if (!retryButtonText.isNullOrBlank()) { + Button( + modifier = Modifier.padding(top = Dimens.Medium), + onClick = onRetry, + ) { + Text(text = retryButtonText) + } } } } } @Composable -@Preview +@ColorSchemePreview fun PreviewEmptyListError() { AppTheme { EmptyListError( - loadError = null, - onRetry = {}, - defaultError = "No items in the list" + text = "No items in the list", + retryButtonText = "Try again", + onRetry = {} ) } } \ No newline at end of file diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/ErrorSnackbar.kt b/ui/src/main/kotlin/gq/kirmanak/mealient/ui/components/ErrorSnackbar.kt similarity index 80% rename from features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/ErrorSnackbar.kt rename to ui/src/main/kotlin/gq/kirmanak/mealient/ui/components/ErrorSnackbar.kt index 347f84e..2997512 100644 --- a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/ErrorSnackbar.kt +++ b/ui/src/main/kotlin/gq/kirmanak/mealient/ui/components/ErrorSnackbar.kt @@ -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.runtime.Composable @@ -8,16 +8,15 @@ import kotlinx.coroutines.launch @Composable fun ErrorSnackbar( - error: Throwable?, + text: String?, snackbarHostState: SnackbarHostState, onSnackbarShown: () -> Unit, ) { - if (error == null) { + if (text.isNullOrBlank()) { snackbarHostState.currentSnackbarData?.dismiss() return } - val text = getErrorMessage(error = error) val scope = rememberCoroutineScope() LaunchedEffect(snackbarHostState) { diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/LazyColumnPullRefresh.kt b/ui/src/main/kotlin/gq/kirmanak/mealient/ui/components/LazyColumnPullRefresh.kt similarity index 95% rename from features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/LazyColumnPullRefresh.kt rename to ui/src/main/kotlin/gq/kirmanak/mealient/ui/components/LazyColumnPullRefresh.kt index d835231..8118e93 100644 --- a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/LazyColumnPullRefresh.kt +++ b/ui/src/main/kotlin/gq/kirmanak/mealient/ui/components/LazyColumnPullRefresh.kt @@ -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.Box diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/LazyColumnWithLoadingState.kt b/ui/src/main/kotlin/gq/kirmanak/mealient/ui/components/LazyColumnWithLoadingState.kt similarity index 81% rename from features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/LazyColumnWithLoadingState.kt rename to ui/src/main/kotlin/gq/kirmanak/mealient/ui/components/LazyColumnWithLoadingState.kt index 4545727..6f72117 100644 --- a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/LazyColumnWithLoadingState.kt +++ b/ui/src/main/kotlin/gq/kirmanak/mealient/ui/components/LazyColumnWithLoadingState.kt @@ -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.PaddingValues @@ -15,22 +15,22 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import gq.kirmanak.mealient.shopping_lists.util.LoadingState -import gq.kirmanak.mealient.shopping_lists.util.LoadingStateNoData -import gq.kirmanak.mealient.shopping_lists.util.data -import gq.kirmanak.mealient.shopping_lists.util.error -import gq.kirmanak.mealient.shopping_lists.util.isLoading -import gq.kirmanak.mealient.shopping_lists.util.isRefreshing +import gq.kirmanak.mealient.ui.util.LoadingState +import gq.kirmanak.mealient.ui.util.LoadingStateNoData +import gq.kirmanak.mealient.ui.util.data +import gq.kirmanak.mealient.ui.util.isLoading +import gq.kirmanak.mealient.ui.util.isRefreshing @OptIn(ExperimentalMaterialApi::class) @Composable fun LazyColumnWithLoadingState( loadingState: LoadingState>, - defaultEmptyListError: String, + emptyListError: String, + retryButtonText: String, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(0.dp), verticalArrangement: Arrangement.Vertical = Arrangement.Top, - errorToShowInSnackbar: Throwable? = null, + snackbarText: String?, onSnackbarShown: () -> Unit = {}, onRefresh: () -> Unit = {}, floatingActionButton: @Composable () -> Unit = {}, @@ -62,9 +62,9 @@ fun LazyColumnWithLoadingState( !loadingState.isLoading && list.isEmpty() -> { EmptyListError( - loadError = loadingState.error, + text = emptyListError, + retryButtonText = retryButtonText, onRetry = onRefresh, - defaultError = defaultEmptyListError, modifier = innerModifier, ) } @@ -80,7 +80,7 @@ fun LazyColumnWithLoadingState( ) ErrorSnackbar( - error = errorToShowInSnackbar, + text = snackbarText, snackbarHostState = snackbarHostState, onSnackbarShown = onSnackbarShown, ) diff --git a/ui/src/main/kotlin/gq/kirmanak/mealient/ui/components/LazyPagingColumnPullRefresh.kt b/ui/src/main/kotlin/gq/kirmanak/mealient/ui/components/LazyPagingColumnPullRefresh.kt new file mode 100644 index 0000000..336bc67 --- /dev/null +++ b/ui/src/main/kotlin/gq/kirmanak/mealient/ui/components/LazyPagingColumnPullRefresh.kt @@ -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 LazyPagingColumnPullRefresh( + lazyPagingItems: LazyPagingItems, + 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, + ) +} \ No newline at end of file diff --git a/ui/src/main/kotlin/gq/kirmanak/mealient/ui/preview/ColorSchemePreview.kt b/ui/src/main/kotlin/gq/kirmanak/mealient/ui/preview/ColorSchemePreview.kt new file mode 100644 index 0000000..a3cf4a5 --- /dev/null +++ b/ui/src/main/kotlin/gq/kirmanak/mealient/ui/preview/ColorSchemePreview.kt @@ -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 diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/util/LoadingHelper.kt b/ui/src/main/kotlin/gq/kirmanak/mealient/ui/util/LoadingHelper.kt similarity index 76% rename from features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/util/LoadingHelper.kt rename to ui/src/main/kotlin/gq/kirmanak/mealient/ui/util/LoadingHelper.kt index cc2fe63..2d8152e 100644 --- a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/util/LoadingHelper.kt +++ b/ui/src/main/kotlin/gq/kirmanak/mealient/ui/util/LoadingHelper.kt @@ -1,4 +1,4 @@ -package gq.kirmanak.mealient.shopping_lists.util +package gq.kirmanak.mealient.ui.util import kotlinx.coroutines.flow.StateFlow diff --git a/ui/src/main/kotlin/gq/kirmanak/mealient/ui/util/LoadingHelperFactory.kt b/ui/src/main/kotlin/gq/kirmanak/mealient/ui/util/LoadingHelperFactory.kt new file mode 100644 index 0000000..22bff11 --- /dev/null +++ b/ui/src/main/kotlin/gq/kirmanak/mealient/ui/util/LoadingHelperFactory.kt @@ -0,0 +1,11 @@ +package gq.kirmanak.mealient.ui.util + +import kotlinx.coroutines.CoroutineScope + +interface LoadingHelperFactory { + + fun create( + coroutineScope: CoroutineScope, + fetch: suspend () -> Result, + ): LoadingHelper +} \ No newline at end of file diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/util/LoadingHelperFactoryImpl.kt b/ui/src/main/kotlin/gq/kirmanak/mealient/ui/util/LoadingHelperFactoryImpl.kt similarity index 76% rename from features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/util/LoadingHelperFactoryImpl.kt rename to ui/src/main/kotlin/gq/kirmanak/mealient/ui/util/LoadingHelperFactoryImpl.kt index 8abaaa6..f4b6835 100644 --- a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/util/LoadingHelperFactoryImpl.kt +++ b/ui/src/main/kotlin/gq/kirmanak/mealient/ui/util/LoadingHelperFactoryImpl.kt @@ -1,4 +1,4 @@ -package gq.kirmanak.mealient.shopping_lists.util +package gq.kirmanak.mealient.ui.util import gq.kirmanak.mealient.logging.Logger import kotlinx.coroutines.CoroutineScope @@ -6,12 +6,12 @@ import javax.inject.Inject // @AssistedFactory does not currently support type parameters in the creator method. // See https://github.com/google/dagger/issues/2279 -class LoadingHelperFactoryImpl @Inject constructor( +internal class LoadingHelperFactoryImpl @Inject constructor( private val logger: Logger ) : LoadingHelperFactory { override fun create( coroutineScope: CoroutineScope, - fetch: suspend () -> T + fetch: suspend () -> Result, ): LoadingHelper = LoadingHelperImpl(logger, fetch) } \ No newline at end of file diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/util/LoadingHelperImpl.kt b/ui/src/main/kotlin/gq/kirmanak/mealient/ui/util/LoadingHelperImpl.kt similarity index 84% rename from features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/util/LoadingHelperImpl.kt rename to ui/src/main/kotlin/gq/kirmanak/mealient/ui/util/LoadingHelperImpl.kt index 5be7c98..4a396ae 100644 --- a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/util/LoadingHelperImpl.kt +++ b/ui/src/main/kotlin/gq/kirmanak/mealient/ui/util/LoadingHelperImpl.kt @@ -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 kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update -class LoadingHelperImpl( +internal class LoadingHelperImpl( private val logger: Logger, - private val fetch: suspend () -> T, + private val fetch: suspend () -> Result, ) : LoadingHelper { private val _loadingState = MutableStateFlow>(LoadingStateNoData.InitialLoad) @@ -22,7 +21,7 @@ class LoadingHelperImpl( is LoadingStateNoData -> LoadingStateNoData.InitialLoad } } - val result = runCatchingExceptCancel { fetch() } + val result = fetch() _loadingState.update { currentState -> result.fold( onSuccess = { data -> diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/util/LoadingState.kt b/ui/src/main/kotlin/gq/kirmanak/mealient/ui/util/LoadingState.kt similarity index 97% rename from features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/util/LoadingState.kt rename to ui/src/main/kotlin/gq/kirmanak/mealient/ui/util/LoadingState.kt index acca373..eee0204 100644 --- a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/util/LoadingState.kt +++ b/ui/src/main/kotlin/gq/kirmanak/mealient/ui/util/LoadingState.kt @@ -1,4 +1,4 @@ -package gq.kirmanak.mealient.shopping_lists.util +package gq.kirmanak.mealient.ui.util sealed class LoadingState