From 941d45328e81bebb2e6807e4778999e0f6dffd5a Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Tue, 7 Nov 2023 20:47:01 +0100 Subject: [PATCH] Add linked ingredients to recipe step (#177) * Add Compose to app module * Move Theme to ui module * Add Coil image loader * Use Compose for recipe screen * Save instruction to ingredient relation to DB * Display ingredients as server formats them * Display linked ingredients under each step * Fix ingredients padding * Show recipe full screen * Fix recipe screen UI issues * Hide keyboard on recipe navigation * Fix loading recipes from DB with no instructions or ingredients * Add instructions section title * Add ingredients section title * Remove unused view holders --- app/build.gradle.kts | 4 + app/src/main/java/gq/kirmanak/mealient/App.kt | 6 + .../recipes/impl/RecipeImageUrlProvider.kt | 2 +- .../impl/RecipeImageUrlProviderImpl.kt | 8 +- .../data/recipes/impl/RecipeRepoImpl.kt | 20 +- .../java/gq/kirmanak/mealient/di/AppModule.kt | 13 + .../ui/recipes/RecipesListFragment.kt | 1 + .../ui/recipes/info/RecipeInfoFragment.kt | 75 ++-- .../ui/recipes/info/RecipeInfoUiState.kt | 3 +- .../ui/recipes/info/RecipeInfoViewModel.kt | 44 ++- .../recipes/info/RecipeIngredientsAdapter.kt | 150 -------- .../recipes/info/RecipeInstructionsAdapter.kt | 70 ---- .../mealient/ui/recipes/info/RecipeScreen.kt | 354 ++++++++++++++++++ .../main/res/layout/fragment_recipe_info.xml | 144 ------- .../res/layout/view_holder_ingredient.xml | 45 --- .../res/layout/view_holder_instruction.xml | 38 -- app/src/main/res/navigation/nav_graph.xml | 159 ++++---- .../data/recipes/impl/RecipeRepoTest.kt | 10 +- .../ui/recipes/info/MediantMethodTest.kt | 37 -- .../recipes/info/RecipeInfoViewModelTest.kt | 25 +- build-logic/convention/build.gradle.kts | 4 + ...droidApplicationComposeConventionPlugin.kt | 18 + .../gq/kirmanak/mealient/database/AppDb.kt | 3 +- .../mealient/database/recipe/RecipeDao.kt | 11 +- .../mealient/database/recipe/RecipeStorage.kt | 4 +- .../database/recipe/RecipeStorageImpl.kt | 9 +- .../recipe/entity/RecipeIngredientEntity.kt | 31 +- .../RecipeIngredientToInstructionEntity.kt | 35 ++ .../recipe/entity/RecipeInstructionEntity.kt | 23 +- .../recipe/entity/RecipeSummaryEntity.kt | 5 +- ...ithSummaryAndIngredientsAndInstructions.kt | 5 + .../database/RecipeStorageImplTest.kt | 25 +- .../gq/kirmanak/mealient/database/TestData.kt | 44 ++- .../datasource/models/GetRecipeResponse.kt | 14 +- .../mealient/datasource_test/TestData.kt | 41 +- .../shopping_lists/ui/ShoppingListScreen.kt | 4 +- .../ui/ShoppingListsFragment.kt | 2 +- .../shopping_lists/ui/ShoppingListsScreen.kt | 4 +- .../composables/CenteredProgressIndicator.kt | 2 +- .../ui/composables/CenteredText.kt | 2 +- .../ui/composables/EmptyListError.kt | 4 +- gradle/libs.versions.toml | 5 + .../mealient/model_mapper/ModelMapper.kt | 4 +- .../mealient/model_mapper/ModelMapperImpl.kt | 12 +- ui/build.gradle.kts | 4 + .../kotlin/gq/kirmanak/mealient/ui}/Theme.kt | 4 +- 46 files changed, 797 insertions(+), 730 deletions(-) delete mode 100644 app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeIngredientsAdapter.kt delete mode 100644 app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInstructionsAdapter.kt create mode 100644 app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeScreen.kt delete mode 100644 app/src/main/res/layout/fragment_recipe_info.xml delete mode 100644 app/src/main/res/layout/view_holder_ingredient.xml delete mode 100644 app/src/main/res/layout/view_holder_instruction.xml delete mode 100644 app/src/test/java/gq/kirmanak/mealient/ui/recipes/info/MediantMethodTest.kt create mode 100644 build-logic/convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt create mode 100644 database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/entity/RecipeIngredientToInstructionEntity.kt rename {features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient => ui/src/main/kotlin/gq/kirmanak/mealient/ui}/Theme.kt (95%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d3b182d..eac7796 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,6 +11,7 @@ plugins { id("dagger.hilt.android.plugin") alias(libs.plugins.ksp) alias(libs.plugins.appsweep) + id("gq.kirmanak.mealient.compose.app") } android { @@ -135,6 +136,9 @@ dependencies { implementation(libs.androidx.datastore.preferences) + implementation(libs.coil) + implementation(libs.coil.compose) + testImplementation(libs.junit) implementation(libs.jetbrains.kotlinx.coroutinesAndroid) diff --git a/app/src/main/java/gq/kirmanak/mealient/App.kt b/app/src/main/java/gq/kirmanak/mealient/App.kt index df1cc2d..1368d87 100644 --- a/app/src/main/java/gq/kirmanak/mealient/App.kt +++ b/app/src/main/java/gq/kirmanak/mealient/App.kt @@ -1,6 +1,8 @@ package gq.kirmanak.mealient import android.app.Application +import coil.Coil +import coil.ImageLoader import com.google.android.material.color.DynamicColors import dagger.hilt.android.HiltAndroidApp import gq.kirmanak.mealient.architecture.configuration.BuildConfiguration @@ -24,6 +26,9 @@ class App : Application() { @Inject lateinit var migrationDetector: MigrationDetector + @Inject + lateinit var imageLoader: ImageLoader + private val appCoroutineScope = CoroutineScope(Dispatchers.Main + Job()) override fun onCreate() { @@ -31,5 +36,6 @@ class App : Application() { logger.v { "onCreate() called" } DynamicColors.applyToActivitiesIfAvailable(this) appCoroutineScope.launch { migrationDetector.executeMigrations() } + Coil.setImageLoader(imageLoader) } } diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeImageUrlProvider.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeImageUrlProvider.kt index 2d05869..deaad20 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeImageUrlProvider.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeImageUrlProvider.kt @@ -2,5 +2,5 @@ package gq.kirmanak.mealient.data.recipes.impl interface RecipeImageUrlProvider { - suspend fun generateImageUrl(slug: String?): String? + suspend fun generateImageUrl(imageId: String?): String? } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeImageUrlProviderImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeImageUrlProviderImpl.kt index 7b6a2b9..c27c086 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeImageUrlProviderImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeImageUrlProviderImpl.kt @@ -10,10 +10,10 @@ class RecipeImageUrlProviderImpl @Inject constructor( private val logger: Logger, ) : RecipeImageUrlProvider { - override suspend fun generateImageUrl(slug: String?): String? { - logger.v { "generateImageUrl() called with: slug = $slug" } - slug?.takeUnless { it.isBlank() } ?: return null - val imagePath = IMAGE_PATH_FORMAT.format(slug) + override suspend fun generateImageUrl(imageId: String?): String? { + logger.v { "generateImageUrl() called with: slug = $imageId" } + imageId?.takeUnless { it.isBlank() } ?: return null + val imagePath = IMAGE_PATH_FORMAT.format(imageId) val baseUrl = serverInfoRepo.getUrl()?.takeUnless { it.isEmpty() } val result = baseUrl ?.takeUnless { it.isBlank() } 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 78ff918..4a23750 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 @@ -6,6 +6,7 @@ import androidx.paging.PagingConfig import gq.kirmanak.mealient.data.recipes.RecipeRepo import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource import gq.kirmanak.mealient.database.recipe.RecipeStorage +import gq.kirmanak.mealient.database.recipe.entity.RecipeIngredientToInstructionEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeWithSummaryAndIngredientsAndInstructions import gq.kirmanak.mealient.datasource.runCatchingExceptCancel @@ -45,15 +46,24 @@ class RecipeRepoImpl @Inject constructor( override suspend fun refreshRecipeInfo(recipeSlug: String): Result { logger.v { "refreshRecipeInfo() called with: recipeSlug = $recipeSlug" } return runCatchingExceptCancel { - val info = dataSource.requestRecipe(recipeSlug) - val entity = modelMapper.toRecipeEntity(info) - val ingredients = info.recipeIngredients.map { + val recipe = dataSource.requestRecipe(recipeSlug) + val entity = modelMapper.toRecipeEntity(recipe) + val ingredients = recipe.ingredients.map { modelMapper.toRecipeIngredientEntity(it, entity.remoteId) } - val instructions = info.recipeInstructions.map { + val instructions = recipe.instructions.map { modelMapper.toRecipeInstructionEntity(it, entity.remoteId) } - storage.saveRecipeInfo(entity, ingredients, instructions) + val ingredientToInstruction = recipe.instructions.flatMap { instruction -> + instruction.ingredientReferences.map { ingredientReference -> + RecipeIngredientToInstructionEntity( + recipeId = entity.remoteId, + ingredientId = ingredientReference.referenceId, + instructionId = instruction.id, + ) + } + } + storage.saveRecipeInfo(entity, ingredients, instructions, ingredientToInstruction) }.onFailure { logger.e(it) { "loadRecipeInfo: can't update full recipe info" } } diff --git a/app/src/main/java/gq/kirmanak/mealient/di/AppModule.kt b/app/src/main/java/gq/kirmanak/mealient/di/AppModule.kt index a717fb5..f2d0b9a 100644 --- a/app/src/main/java/gq/kirmanak/mealient/di/AppModule.kt +++ b/app/src/main/java/gq/kirmanak/mealient/di/AppModule.kt @@ -5,6 +5,7 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.preferencesDataStoreFile +import coil.ImageLoader import dagger.Binds import dagger.Module import dagger.Provides @@ -13,6 +14,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import gq.kirmanak.mealient.data.storage.PreferencesStorage import gq.kirmanak.mealient.data.storage.PreferencesStorageImpl +import okhttp3.OkHttpClient import javax.inject.Singleton @Module @@ -24,6 +26,17 @@ interface AppModule { @Singleton fun provideDataStore(@ApplicationContext context: Context): DataStore = PreferenceDataStoreFactory.create { context.preferencesDataStoreFile("settings") } + + @Provides + @Singleton + fun provideCoilImageLoader( + @ApplicationContext context: Context, + okHttpClient: OkHttpClient, + ): ImageLoader { + return ImageLoader.Builder(context) + .okHttpClient(okHttpClient) + .build() + } } @Binds 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 index 9db8c14..e17bdaa 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListFragment.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListFragment.kt @@ -82,6 +82,7 @@ class RecipesListFragment : Fragment(R.layout.fragment_recipes_list) { private fun navigateToRecipeInfo(id: String) { logger.v { "navigateToRecipeInfo() called with: id = $id" } val directions = actionRecipesFragmentToRecipeInfoFragment(id) + binding.root.hideKeyboard() findNavController().navigate(directions) } 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 739984c..1dcd07c 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 @@ -4,79 +4,52 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.core.view.isVisible +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 by.kirich1409.viewbindingdelegate.viewBinding -import com.google.android.material.bottomsheet.BottomSheetDialogFragment import dagger.hilt.android.AndroidEntryPoint -import gq.kirmanak.mealient.databinding.FragmentRecipeInfoBinding import gq.kirmanak.mealient.logging.Logger -import gq.kirmanak.mealient.ui.recipes.images.RecipeImageLoader +import gq.kirmanak.mealient.ui.AppTheme +import gq.kirmanak.mealient.ui.CheckableMenuItem +import gq.kirmanak.mealient.ui.activity.MainActivityViewModel import javax.inject.Inject @AndroidEntryPoint -class RecipeInfoFragment : BottomSheetDialogFragment() { +class RecipeInfoFragment : Fragment() { - private val binding by viewBinding(FragmentRecipeInfoBinding::bind) private val viewModel by viewModels() - private lateinit var ingredientsAdapter: RecipeIngredientsAdapter - - @Inject - lateinit var instructionsAdapter: RecipeInstructionsAdapter - - @Inject - lateinit var recipeIngredientsAdapterFactory: RecipeIngredientsAdapter.Factory + private val activityViewModel by activityViewModels() @Inject lateinit var logger: Logger - @Inject - lateinit var recipeImageLoader: RecipeImageLoader - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { logger.v { "onCreateView() called" } - return FragmentRecipeInfoBinding.inflate(inflater, container, false).root + return ComposeView(requireContext()).apply { + setContent { + val uiState by viewModel.uiState.collectAsState() + AppTheme { + RecipeScreen(uiState = uiState) + } + } + } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - logger.v { "onViewCreated() called" } - - with(binding) { - instructionsList.adapter = instructionsAdapter - } - - with(viewModel) { - uiState.observe(viewLifecycleOwner, ::onUiStateChange) - } - } - - private fun onUiStateChange(uiState: RecipeInfoUiState) = with(binding) { - logger.v { "onUiStateChange() called" } - if (::ingredientsAdapter.isInitialized.not()) { - ingredientsAdapter = recipeIngredientsAdapterFactory.build(uiState.disableAmounts) - ingredientsList.adapter = ingredientsAdapter - } - ingredientsHolder.isVisible = uiState.showIngredients - instructionsGroup.isVisible = uiState.showInstructions - recipeImageLoader.loadRecipeImage(image, uiState.summaryEntity) - title.text = uiState.title - description.text = uiState.description - ingredientsAdapter.submitList(uiState.recipeIngredients) - instructionsAdapter.submitList(uiState.recipeInstructions) - } - - override fun onDestroyView() { - super.onDestroyView() - logger.v { "onDestroyView() called" } - // Prevent RV leaking through mObservers list in adapter - with(binding) { - ingredientsList.adapter = null - instructionsList.adapter = null + activityViewModel.updateUiState { + it.copy( + navigationVisible = false, + searchVisible = false, + checkedMenuItem = CheckableMenuItem.RecipesList, + ) } } } diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoUiState.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoUiState.kt index 00de3cd..3fe0f9b 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoUiState.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoUiState.kt @@ -9,8 +9,9 @@ data class RecipeInfoUiState( val showInstructions: Boolean = false, val summaryEntity: RecipeSummaryEntity? = null, val recipeIngredients: List = emptyList(), - val recipeInstructions: List = emptyList(), + val recipeInstructions: Map> = emptyMap(), val title: String? = null, val description: String? = null, val disableAmounts: Boolean = true, + val imageUrl: String? = null, ) diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoViewModel.kt index b3fb8b6..6c0514d 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoViewModel.kt @@ -1,38 +1,66 @@ package gq.kirmanak.mealient.ui.recipes.info -import androidx.lifecycle.LiveData import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel -import androidx.lifecycle.liveData +import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import gq.kirmanak.mealient.data.recipes.RecipeRepo +import gq.kirmanak.mealient.data.recipes.impl.RecipeImageUrlProvider +import gq.kirmanak.mealient.database.recipe.entity.RecipeIngredientEntity +import gq.kirmanak.mealient.database.recipe.entity.RecipeInstructionEntity +import gq.kirmanak.mealient.database.recipe.entity.RecipeWithSummaryAndIngredientsAndInstructions import gq.kirmanak.mealient.logging.Logger +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @HiltViewModel class RecipeInfoViewModel @Inject constructor( private val recipeRepo: RecipeRepo, private val logger: Logger, + private val recipeImageUrlProvider: RecipeImageUrlProvider, savedStateHandle: SavedStateHandle, ) : ViewModel() { private val args = RecipeInfoFragmentArgs.fromSavedStateHandle(savedStateHandle) - - val uiState: LiveData = liveData { + private val _uiState = flow { logger.v { "Initializing UI state with args = $args" } - val state = recipeRepo.loadRecipeInfo(args.recipeId)?.let { entity -> + val recipeInfo = recipeRepo.loadRecipeInfo(args.recipeId) + logger.v { "Loaded recipe info = $recipeInfo" } + val slug = recipeInfo?.recipeSummaryEntity?.imageId + val imageUrl = slug?.let { recipeImageUrlProvider.generateImageUrl(slug) } + val state = recipeInfo?.let { entity -> RecipeInfoUiState( showIngredients = entity.recipeIngredients.isNotEmpty(), showInstructions = entity.recipeInstructions.isNotEmpty(), summaryEntity = entity.recipeSummaryEntity, recipeIngredients = entity.recipeIngredients, - recipeInstructions = entity.recipeInstructions, + recipeInstructions = associateInstructionsToIngredients(entity), title = entity.recipeSummaryEntity.name, description = entity.recipeSummaryEntity.description, - disableAmounts = entity.recipeEntity.disableAmounts, + imageUrl = imageUrl, ) } ?: RecipeInfoUiState() emit(state) - } + }.stateIn(viewModelScope, SharingStarted.Eagerly, RecipeInfoUiState()) + + + val uiState: StateFlow = _uiState } + +private fun associateInstructionsToIngredients( + recipe: RecipeWithSummaryAndIngredientsAndInstructions, +): Map> { + return recipe.recipeInstructions.associateWith { instruction -> + recipe.recipeIngredientToInstructionEntity + .filter { it.instructionId == instruction.id } + .flatMap { mapping -> + recipe.recipeIngredients.filter { ingredient -> + ingredient.id == mapping.ingredientId + } + } + } +} diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeIngredientsAdapter.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeIngredientsAdapter.kt deleted file mode 100644 index b7172b1..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeIngredientsAdapter.kt +++ /dev/null @@ -1,150 +0,0 @@ -package gq.kirmanak.mealient.ui.recipes.info - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.annotation.VisibleForTesting -import androidx.core.view.isGone -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import gq.kirmanak.mealient.database.recipe.entity.RecipeIngredientEntity -import gq.kirmanak.mealient.databinding.ViewHolderIngredientBinding -import gq.kirmanak.mealient.logging.Logger -import gq.kirmanak.mealient.ui.recipes.info.RecipeIngredientsAdapter.RecipeIngredientViewHolder - -class RecipeIngredientsAdapter @AssistedInject constructor( - private val recipeIngredientViewHolderFactory: RecipeIngredientViewHolder.Factory, - private val logger: Logger, - @Assisted private val disableAmounts: Boolean, -) : ListAdapter(RecipeIngredientDiffCallback) { - - @AssistedFactory - interface Factory { - fun build(disableAmounts: Boolean): RecipeIngredientsAdapter - } - - class RecipeIngredientViewHolder @AssistedInject constructor( - @Assisted private val binding: ViewHolderIngredientBinding, - @Assisted private val disableAmounts: Boolean, - private val logger: Logger, - ) : RecyclerView.ViewHolder(binding.root) { - - @AssistedFactory - interface Factory { - - fun build( - binding: ViewHolderIngredientBinding, - disableAmounts: Boolean, - ): RecipeIngredientViewHolder - } - - fun bind(item: RecipeIngredientEntity) { - logger.v { "bind() called with: item = $item" } - binding.sectionGroup.isGone = item.title.isNullOrBlank() - binding.title.text = item.title.orEmpty() - binding.checkBox.text = if (disableAmounts) { - item.note - } else { - val builder = StringBuilder() - item.quantity?.let { builder.append("${it.formatUsingMediantMethod()} ") } - item.unit?.let { builder.append("$it ") } - item.food?.let { builder.append("$it ") } - builder.append(item.note) - builder.toString().trim() - } - } - } - - private object RecipeIngredientDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: RecipeIngredientEntity, newItem: RecipeIngredientEntity - ): Boolean = oldItem.localId == newItem.localId - - override fun areContentsTheSame( - oldItem: RecipeIngredientEntity, newItem: RecipeIngredientEntity - ): Boolean = oldItem == newItem - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecipeIngredientViewHolder { - logger.v { "onCreateViewHolder() called with: parent = $parent, viewType = $viewType" } - val inflater = LayoutInflater.from(parent.context) - return recipeIngredientViewHolderFactory.build( - ViewHolderIngredientBinding.inflate(inflater, parent, false), - disableAmounts, - ) - } - - override fun onBindViewHolder(holder: RecipeIngredientViewHolder, position: Int) { - logger.v { "onBindViewHolder() called with: holder = $holder, position = $position" } - val item = getItem(position) - logger.d { "onBindViewHolder: item is $item" } - holder.bind(item) - } -} - -private fun Double.formatUsingMediantMethod(d: Int = 10, mixed: Boolean = true): String { - val triple = mediantMethod(d, mixed) - return when { - triple.second == 0 -> "${triple.first}" - triple.first == 0 -> "${triple.second}/${triple.third}" - else -> "${triple.first} ${triple.second}/${triple.third}" - } -} - -/** - * Rational approximation to a floating point number with bounded denominator using Mediant Method. - * For example, 333/1000 will become [0, 1, 3] (1/3), 1414/1000 will be [1, 2, 5] (1 2/5). - * Uses algorithm from this npm package - https://www.npmjs.com/package/frac - * Can be seen here https://github.com/SheetJS/frac/blob/d07f3c99c7dc059fb47d391bcb3da80f4956608e/frac.js - * @receiver - number that needs to be approximated - * @param d - maximum denominator (i.e. if 10 then 17/20 will be 4/5, if 20 then 17/20). - * @param mixed - if true returns a mixed fraction otherwise improper (i.e. "7/5" or "1 2/5") - */ -@VisibleForTesting -fun Double.mediantMethod(d: Int = 10, mixed: Boolean = true): Triple { - val x = this - var n1 = x.toInt() - var d1 = 1 - var n2 = n1 + 1 - var d2 = 1 - if (x != n1.toDouble()) { - while (d1 <= d && d2 <= d) { - val m = (n1 + n2).toDouble() / (d1 + d2) - when { - x == m -> { - when { - d1 + d2 <= d -> { - d1 += d2 - n1 += n2 - d2 = d + 1 - } - - d1 > d2 -> d2 = d + 1 - else -> d1 = d + 1 - } - break - } - - x < m -> { - n2 += n1 - d2 += d1 - } - - else -> { - n1 += n2 - d1 += d2 - } - } - } - } - if (d1 > d) { - d1 = d2 - n1 = n2 - } - if (!mixed) return Triple(0, n1, d1) - val q = (n1.toDouble() / d1).toInt() - return Triple(q, n1 - q * d1, d1) -} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInstructionsAdapter.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInstructionsAdapter.kt deleted file mode 100644 index 7f0b507..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInstructionsAdapter.kt +++ /dev/null @@ -1,70 +0,0 @@ -package gq.kirmanak.mealient.ui.recipes.info - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import gq.kirmanak.mealient.R -import gq.kirmanak.mealient.database.recipe.entity.RecipeInstructionEntity -import gq.kirmanak.mealient.databinding.ViewHolderInstructionBinding -import gq.kirmanak.mealient.extensions.resources -import gq.kirmanak.mealient.logging.Logger -import gq.kirmanak.mealient.ui.recipes.info.RecipeInstructionsAdapter.RecipeInstructionViewHolder -import javax.inject.Inject - -class RecipeInstructionsAdapter @Inject constructor( - private val logger: Logger, - private val recipeInstructionViewHolderFactory: RecipeInstructionViewHolder.Factory, -) : ListAdapter(RecipeInstructionDiffCallback) { - - private object RecipeInstructionDiffCallback : - DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: RecipeInstructionEntity, - newItem: RecipeInstructionEntity - ): Boolean = oldItem.localId == newItem.localId - - override fun areContentsTheSame( - oldItem: RecipeInstructionEntity, - newItem: RecipeInstructionEntity - ): Boolean = oldItem == newItem - } - - class RecipeInstructionViewHolder @AssistedInject constructor( - @Assisted private val binding: ViewHolderInstructionBinding, - private val logger: Logger, - ) : RecyclerView.ViewHolder(binding.root) { - - @AssistedFactory - interface Factory { - fun build(binding: ViewHolderInstructionBinding): RecipeInstructionViewHolder - } - - fun bind(item: RecipeInstructionEntity, position: Int) { - logger.v { "bind() called with: item = $item, position = $position" } - binding.step.text = binding.resources.getString( - R.string.view_holder_recipe_instructions_step, position + 1 - ) - binding.instruction.text = item.text - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecipeInstructionViewHolder { - logger.v { "onCreateViewHolder() called with: parent = $parent, viewType = $viewType" } - val inflater = LayoutInflater.from(parent.context) - return recipeInstructionViewHolderFactory.build( - ViewHolderInstructionBinding.inflate(inflater, parent, false), - ) - } - - override fun onBindViewHolder(holder: RecipeInstructionViewHolder, position: Int) { - logger.v { "onBindViewHolder() called with: holder = $holder, position = $position" } - val item = getItem(position) - logger.d { "onBindViewHolder: item is $item" } - holder.bind(item, position) - } -} \ No newline at end of file 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 new file mode 100644 index 0000000..24ca985 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeScreen.kt @@ -0,0 +1,354 @@ +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.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Divider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +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.database.recipe.entity.RecipeSummaryEntity +import gq.kirmanak.mealient.ui.AppTheme +import gq.kirmanak.mealient.ui.Dimens +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime + +@Composable +fun RecipeScreen( + uiState: RecipeInfoUiState, +) { + 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, + ) + } + + if (uiState.showInstructions) { + InstructionsSection( + instructions = uiState.recipeInstructions, + ) + } + } +} + +@Composable +private fun HeaderSection( + imageUrl: String?, + title: String?, + description: String?, +) { + val imageFallback = painterResource(id = R.drawable.placeholder_recipe) + + AsyncImage( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(2f) // 2:1 + .clip( + RoundedCornerShape( + topEnd = 0.dp, + topStart = 0.dp, + bottomEnd = Dimens.Intermediate, + bottomStart = Dimens.Intermediate, + ) + ), + model = imageUrl, + contentDescription = stringResource(id = R.string.content_description_fragment_recipe_info_image), + placeholder = imageFallback, + error = imageFallback, + fallback = imageFallback, + contentScale = ContentScale.Crop, + ) + + if (!title.isNullOrEmpty()) { + Text( + modifier = Modifier + .padding(horizontal = Dimens.Small), + text = title, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + if (!description.isNullOrEmpty()) { + Text( + modifier = Modifier + .padding(horizontal = Dimens.Small), + text = description, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + +@Composable +private fun InstructionsSection( + instructions: Map>, +) { + Text( + modifier = Modifier + .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 + instructions.forEach { (instruction, ingredients) -> + InstructionListItem( + modifier = Modifier + .padding(horizontal = Dimens.Small), + item = instruction, + ingredients = ingredients, + index = stepCount++, + ) + } +} + +@Composable +private fun IngredientsSection( + ingredients: List, +) { + Text( + modifier = Modifier + .padding(horizontal = Dimens.Large), + text = stringResource(id = R.string.fragment_recipe_info_ingredients_header), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimens.Small), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(Dimens.Small), + verticalArrangement = Arrangement.spacedBy(Dimens.Small, Alignment.Top), + ) { + ingredients.forEach { item -> + IngredientListItem( + item = item, + ) + } + } + } +} + +@Composable +private fun InstructionListItem( + item: RecipeInstructionEntity, + index: Int, + ingredients: List, + modifier: Modifier = Modifier, +) { + val title = item.title + + if (!title.isNullOrBlank()) { + Text( + modifier = modifier + .padding(horizontal = Dimens.Medium), + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + } + + Card( + modifier = modifier + .fillMaxWidth(), + ) { + Column( + modifier = Modifier + .padding(Dimens.Medium), + verticalArrangement = Arrangement.spacedBy(Dimens.Small, Alignment.Top), + ) { + Text( + text = stringResource( + R.string.view_holder_recipe_instructions_step, + 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, + ) + ingredients.forEach { ingredient -> + Text( + text = ingredient.display, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + } +} + +@Composable +private fun IngredientListItem( + item: RecipeIngredientEntity, + modifier: Modifier = Modifier, +) { + var isChecked by rememberSaveable { mutableStateOf(false) } + + val title = item.title + if (!title.isNullOrBlank()) { + Text( + modifier = modifier + .padding(horizontal = Dimens.Medium), + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + Divider( + color = MaterialTheme.colorScheme.outline, + ) + } + + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(Dimens.Small, Alignment.Start), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = isChecked, + onCheckedChange = { isChecked = it }, + ) + + Text( + text = item.display, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun RecipeScreenPreview() { + AppTheme { + RecipeScreen( + uiState = previewUiState() + ) + } +} + +@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_MASK and UI_MODE_NIGHT_YES) +@Composable +private fun RecipeScreenNightPreview() { + AppTheme { + RecipeScreen( + uiState = previewUiState() + ) + } +} + +private fun previewUiState(): RecipeInfoUiState { + val ingredient = RecipeIngredientEntity( + id = "2", + recipeId = "1", + note = "Recipe ingredient note", + food = "Recipe ingredient food", + unit = "Recipe ingredient unit", + display = "Recipe ingredient display that is very long and should be wrapped", + quantity = 1.0, + title = null, + ) + val uiState = RecipeInfoUiState( + showIngredients = true, + showInstructions = true, + summaryEntity = RecipeSummaryEntity( + remoteId = "1", + name = "Recipe name", + slug = "recipe-name", + description = "Recipe description", + dateAdded = LocalDate(2021, 1, 1), + dateUpdated = LocalDateTime(2021, 1, 1, 1, 1, 1), + imageId = null, + isFavorite = false, + ), + recipeIngredients = listOf( + RecipeIngredientEntity( + id = "1", + recipeId = "1", + note = "Recipe ingredient note", + food = "Recipe ingredient food", + unit = "Recipe ingredient unit", + display = "Recipe ingredient display that is very long and should be wrapped", + quantity = 1.0, + title = "Recipe ingredient section title", + ), + ingredient, + ), + recipeInstructions = mapOf( + RecipeInstructionEntity( + id = "1", + recipeId = "1", + text = "Recipe instruction", + title = "Section title", + ) to emptyList(), + RecipeInstructionEntity( + id = "2", + recipeId = "1", + text = "Recipe instruction", + title = "", + ) to listOf(ingredient), + ), + title = "Recipe title", + description = "Recipe description", + ) + return uiState +} + diff --git a/app/src/main/res/layout/fragment_recipe_info.xml b/app/src/main/res/layout/fragment_recipe_info.xml deleted file mode 100644 index 83a1ce3..0000000 --- a/app/src/main/res/layout/fragment_recipe_info.xml +++ /dev/null @@ -1,144 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/view_holder_ingredient.xml b/app/src/main/res/layout/view_holder_ingredient.xml deleted file mode 100644 index 56008cd..0000000 --- a/app/src/main/res/layout/view_holder_ingredient.xml +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/view_holder_instruction.xml b/app/src/main/res/layout/view_holder_instruction.xml deleted file mode 100644 index e633e30..0000000 --- a/app/src/main/res/layout/view_holder_instruction.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml index e317d98..7e6b484 100644 --- a/app/src/main/res/navigation/nav_graph.xml +++ b/app/src/main/res/navigation/nav_graph.xml @@ -1,94 +1,97 @@ + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:id="@+id/nav_graph" + tools:ignore="InvalidNavigation"> - + - - - + + + - - - + + + - - - + + + - - - - + + + + - + - + - + - + - + - + - + \ No newline at end of file diff --git a/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoTest.kt index 24c90da..bb49eb0 100644 --- a/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoTest.kt @@ -10,7 +10,9 @@ import gq.kirmanak.mealient.database.CAKE_RECIPE_ENTITY import gq.kirmanak.mealient.database.CAKE_RECIPE_SUMMARY_ENTITY import gq.kirmanak.mealient.database.CAKE_SUGAR_RECIPE_INGREDIENT_ENTITY import gq.kirmanak.mealient.database.FULL_CAKE_INFO_ENTITY +import gq.kirmanak.mealient.database.MIX_BREAD_RECIPE_INGREDIENT_INSTRUCTION_ENTITY import gq.kirmanak.mealient.database.MIX_CAKE_RECIPE_INSTRUCTION_ENTITY +import gq.kirmanak.mealient.database.MIX_SUGAR_RECIPE_INGREDIENT_INSTRUCTION_ENTITY import gq.kirmanak.mealient.database.recipe.RecipeStorage import gq.kirmanak.mealient.datasource.NetworkError.Unauthorized import gq.kirmanak.mealient.datasource_test.CAKE_RECIPE_RESPONSE @@ -78,7 +80,13 @@ class RecipeRepoTest : BaseUnitTest() { CAKE_BREAD_RECIPE_INGREDIENT_ENTITY ) ), - eq(listOf(MIX_CAKE_RECIPE_INSTRUCTION_ENTITY, BAKE_CAKE_RECIPE_INSTRUCTION_ENTITY)) + eq(listOf(MIX_CAKE_RECIPE_INSTRUCTION_ENTITY, BAKE_CAKE_RECIPE_INSTRUCTION_ENTITY)), + eq( + listOf( + MIX_SUGAR_RECIPE_INGREDIENT_INSTRUCTION_ENTITY, + MIX_BREAD_RECIPE_INGREDIENT_INSTRUCTION_ENTITY + ) + ), ) } } diff --git a/app/src/test/java/gq/kirmanak/mealient/ui/recipes/info/MediantMethodTest.kt b/app/src/test/java/gq/kirmanak/mealient/ui/recipes/info/MediantMethodTest.kt deleted file mode 100644 index 7b7ab44..0000000 --- a/app/src/test/java/gq/kirmanak/mealient/ui/recipes/info/MediantMethodTest.kt +++ /dev/null @@ -1,37 +0,0 @@ -package gq.kirmanak.mealient.ui.recipes.info - -import com.google.common.truth.Truth.assertThat -import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.Parameterized -import org.junit.runners.Parameterized.Parameters - -@RunWith(Parameterized::class) -class MediantMethodTest( - private val input: Triple, - private val output: Triple, -) { - - @Test - fun `when mediantMethod is called expect correct result`() { - assertThat(input.first.mediantMethod(input.second, input.third)).isEqualTo(output) - } - - companion object { - @Parameters - @JvmStatic - fun parameters(): List> { - return listOf( - arrayOf(Triple(0.333, 10, true), Triple(0, 1, 3)), - arrayOf(Triple(0.333, 10, false), Triple(0, 1, 3)), - arrayOf(Triple(0.333, 100, false), Triple(0, 1, 3)), - arrayOf(Triple(0.333, 100, true), Triple(0, 1, 3)), - arrayOf(Triple(1.5, 10, true), Triple(1, 1, 2)), - arrayOf(Triple(1.5, 10, false), Triple(0, 3, 2)), - arrayOf(Triple(0.4, 10, false), Triple(0, 2, 5)), - arrayOf(Triple(0.41412412412412, 100, true), Triple(0, 41, 99)), - arrayOf(Triple(8.98, 10, true), Triple(9, 0, 1)), - ) - } - } -} \ No newline at end of file diff --git a/app/src/test/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoViewModelTest.kt b/app/src/test/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoViewModelTest.kt index f7bb354..3bc5497 100644 --- a/app/src/test/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoViewModelTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoViewModelTest.kt @@ -1,9 +1,13 @@ package gq.kirmanak.mealient.ui.recipes.info -import androidx.lifecycle.asFlow import com.google.common.truth.Truth.assertThat import gq.kirmanak.mealient.data.recipes.RecipeRepo +import gq.kirmanak.mealient.data.recipes.impl.RecipeImageUrlProvider +import gq.kirmanak.mealient.database.BAKE_CAKE_RECIPE_INSTRUCTION_ENTITY +import gq.kirmanak.mealient.database.CAKE_BREAD_RECIPE_INGREDIENT_ENTITY +import gq.kirmanak.mealient.database.CAKE_SUGAR_RECIPE_INGREDIENT_ENTITY import gq.kirmanak.mealient.database.FULL_CAKE_INFO_ENTITY +import gq.kirmanak.mealient.database.MIX_CAKE_RECIPE_INSTRUCTION_ENTITY import gq.kirmanak.mealient.test.BaseUnitTest import io.mockk.coEvery import io.mockk.impl.annotations.MockK @@ -16,10 +20,13 @@ class RecipeInfoViewModelTest : BaseUnitTest() { @MockK lateinit var recipeRepo: RecipeRepo + @MockK + lateinit var recipeImageUrlProvider: RecipeImageUrlProvider + @Test fun `when recipe isn't found then UI state is empty`() = runTest { coEvery { recipeRepo.loadRecipeInfo(eq(RECIPE_ID)) } returns null - val uiState = createSubject().uiState.asFlow().first() + val uiState = createSubject().uiState.first() assertThat(uiState).isEqualTo(RecipeInfoUiState()) } @@ -29,22 +36,30 @@ class RecipeInfoViewModelTest : BaseUnitTest() { recipeIngredients = FULL_CAKE_INFO_ENTITY.recipeIngredients ) coEvery { recipeRepo.loadRecipeInfo(eq(RECIPE_ID)) } returns returnedEntity + coEvery { recipeImageUrlProvider.generateImageUrl(eq("1")) } returns "imageUrl" val expected = RecipeInfoUiState( showIngredients = true, showInstructions = true, summaryEntity = FULL_CAKE_INFO_ENTITY.recipeSummaryEntity, recipeIngredients = FULL_CAKE_INFO_ENTITY.recipeIngredients, - recipeInstructions = FULL_CAKE_INFO_ENTITY.recipeInstructions, + recipeInstructions = mapOf( + MIX_CAKE_RECIPE_INSTRUCTION_ENTITY to listOf( + CAKE_SUGAR_RECIPE_INGREDIENT_ENTITY, + CAKE_BREAD_RECIPE_INGREDIENT_ENTITY, + ), + BAKE_CAKE_RECIPE_INSTRUCTION_ENTITY to emptyList(), + ), title = FULL_CAKE_INFO_ENTITY.recipeSummaryEntity.name, description = FULL_CAKE_INFO_ENTITY.recipeSummaryEntity.description, + imageUrl = "imageUrl", ) - val actual = createSubject().uiState.asFlow().first() + val actual = createSubject().uiState.first() assertThat(actual).isEqualTo(expected) } private fun createSubject(): RecipeInfoViewModel { val argument = RecipeInfoFragmentArgs(RECIPE_ID).toSavedStateHandle() - return RecipeInfoViewModel(recipeRepo, logger, argument) + return RecipeInfoViewModel(recipeRepo, logger, recipeImageUrlProvider, argument) } companion object { diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index ac58325..65dd8bc 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -23,5 +23,9 @@ gradlePlugin { id = "gq.kirmanak.mealient.compose" implementationClass = "AndroidLibraryComposeConventionPlugin" } + register("appCompose") { + id = "gq.kirmanak.mealient.compose.app" + implementationClass = "AndroidApplicationComposeConventionPlugin" + } } } diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt new file mode 100644 index 0000000..9d60133 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt @@ -0,0 +1,18 @@ +import com.android.build.gradle.internal.dsl.BaseAppModuleExtension +import gq.kirmanak.mealient.configureAndroidCompose +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure + +class AndroidApplicationComposeConventionPlugin : Plugin { + + override fun apply(target: Project) { + with(target) { + pluginManager.apply("com.android.application") + + extensions.configure { + configureAndroidCompose(this) + } + } + } +} \ No newline at end of file diff --git a/database/src/main/kotlin/gq/kirmanak/mealient/database/AppDb.kt b/database/src/main/kotlin/gq/kirmanak/mealient/database/AppDb.kt index 6f32655..da16faa 100644 --- a/database/src/main/kotlin/gq/kirmanak/mealient/database/AppDb.kt +++ b/database/src/main/kotlin/gq/kirmanak/mealient/database/AppDb.kt @@ -5,12 +5,13 @@ import gq.kirmanak.mealient.database.recipe.RecipeDao import gq.kirmanak.mealient.database.recipe.entity.* @Database( - version = 10, + version = 11, entities = [ RecipeSummaryEntity::class, RecipeEntity::class, RecipeIngredientEntity::class, RecipeInstructionEntity::class, + RecipeIngredientToInstructionEntity::class, ] ) @TypeConverters(RoomTypeConverters::class) diff --git a/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/RecipeDao.kt b/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/RecipeDao.kt index 4f758c7..4123025 100644 --- a/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/RecipeDao.kt +++ b/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/RecipeDao.kt @@ -35,13 +35,17 @@ internal interface RecipeDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertRecipeIngredients(ingredients: List) + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertIngredientToInstructionEntities(entities: List) + @Transaction @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH) // The lint is wrong, the columns are actually used @Query( "SELECT * FROM recipe " + "JOIN recipe_summaries USING(recipe_id) " + - "JOIN recipe_ingredient USING(recipe_id) " + - "JOIN recipe_instruction USING(recipe_id) " + + "LEFT JOIN recipe_ingredient USING(recipe_id) " + + "LEFT JOIN recipe_instruction USING(recipe_id) " + + "LEFT JOIN recipe_ingredient_to_instruction USING(recipe_id) " + "WHERE recipe.recipe_id = :recipeId" ) suspend fun queryFullRecipeInfo(recipeId: String): RecipeWithSummaryAndIngredientsAndInstructions? @@ -52,6 +56,9 @@ internal interface RecipeDao { @Query("DELETE FROM recipe_instruction WHERE recipe_id IN (:recipeIds)") suspend fun deleteRecipeInstructions(vararg recipeIds: String) + @Query("DELETE FROM recipe_ingredient_to_instruction WHERE recipe_id IN (:recipeIds)") + suspend fun deleteRecipeIngredientToInstructions(vararg recipeIds: String) + @Query("UPDATE recipe_summaries SET recipe_summaries_is_favorite = 1 WHERE recipe_summaries_slug IN (:favorites)") suspend fun setFavorite(favorites: List) diff --git a/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/RecipeStorage.kt b/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/RecipeStorage.kt index 40a7225..e24ce99 100644 --- a/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/RecipeStorage.kt +++ b/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/RecipeStorage.kt @@ -3,6 +3,7 @@ package gq.kirmanak.mealient.database.recipe import androidx.paging.PagingSource import gq.kirmanak.mealient.database.recipe.entity.RecipeEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeIngredientEntity +import gq.kirmanak.mealient.database.recipe.entity.RecipeIngredientToInstructionEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeInstructionEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeWithSummaryAndIngredientsAndInstructions @@ -19,7 +20,8 @@ interface RecipeStorage { suspend fun saveRecipeInfo( recipe: RecipeEntity, ingredients: List, - instructions: List + instructions: List, + ingredientToInstruction: List, ) suspend fun queryRecipeInfo(recipeId: String): RecipeWithSummaryAndIngredientsAndInstructions? diff --git a/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/RecipeStorageImpl.kt b/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/RecipeStorageImpl.kt index 2548f43..56a635d 100644 --- a/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/RecipeStorageImpl.kt +++ b/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/RecipeStorageImpl.kt @@ -5,6 +5,7 @@ import androidx.room.withTransaction import gq.kirmanak.mealient.database.AppDb import gq.kirmanak.mealient.database.recipe.entity.RecipeEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeIngredientEntity +import gq.kirmanak.mealient.database.recipe.entity.RecipeIngredientToInstructionEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeInstructionEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeWithSummaryAndIngredientsAndInstructions @@ -44,9 +45,10 @@ internal class RecipeStorageImpl @Inject constructor( override suspend fun saveRecipeInfo( recipe: RecipeEntity, ingredients: List, - instructions: List + instructions: List, + ingredientToInstruction: List, ) { - logger.v { "saveRecipeInfo() called with: recipe = $recipe" } + logger.v { "saveRecipeInfo() called with: recipe = $recipe, ingredients = $ingredients, instructions = $instructions, ingredientToInstructions = $ingredientToInstruction" } db.withTransaction { recipeDao.insertRecipe(recipe) @@ -55,6 +57,9 @@ internal class RecipeStorageImpl @Inject constructor( recipeDao.deleteRecipeInstructions(recipe.remoteId) recipeDao.insertRecipeInstructions(instructions) + + recipeDao.deleteRecipeIngredientToInstructions(recipe.remoteId) + recipeDao.insertIngredientToInstructionEntities(ingredientToInstruction) } } diff --git a/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/entity/RecipeIngredientEntity.kt b/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/entity/RecipeIngredientEntity.kt index 10d9416..22903e4 100644 --- a/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/entity/RecipeIngredientEntity.kt +++ b/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/entity/RecipeIngredientEntity.kt @@ -17,37 +17,12 @@ import androidx.room.PrimaryKey ] ) data class RecipeIngredientEntity( - @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "recipe_ingredient_local_id") val localId: Long = 0, + @PrimaryKey @ColumnInfo(name = "recipe_ingredient_id") val id: String, @ColumnInfo(name = "recipe_id", index = true) val recipeId: String, @ColumnInfo(name = "recipe_ingredient_note") val note: String, @ColumnInfo(name = "recipe_ingredient_food") val food: String?, @ColumnInfo(name = "recipe_ingredient_unit") val unit: String?, @ColumnInfo(name = "recipe_ingredient_quantity") val quantity: Double?, + @ColumnInfo(name = "recipe_ingredient_display") val display: String, @ColumnInfo(name = "recipe_ingredient_title") val title: String?, -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as RecipeIngredientEntity - - if (recipeId != other.recipeId) return false - if (note != other.note) return false - if (food != other.food) return false - if (unit != other.unit) return false - if (quantity != other.quantity) return false - if (title != other.title) return false - - return true - } - - override fun hashCode(): Int { - var result = recipeId.hashCode() - result = 31 * result + note.hashCode() - result = 31 * result + (food?.hashCode() ?: 0) - result = 31 * result + (unit?.hashCode() ?: 0) - result = 31 * result + (quantity?.hashCode() ?: 0) - result = 31 * result + (title?.hashCode() ?: 0) - return result - } -} \ No newline at end of file +) \ No newline at end of file diff --git a/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/entity/RecipeIngredientToInstructionEntity.kt b/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/entity/RecipeIngredientToInstructionEntity.kt new file mode 100644 index 0000000..728cba3 --- /dev/null +++ b/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/entity/RecipeIngredientToInstructionEntity.kt @@ -0,0 +1,35 @@ +package gq.kirmanak.mealient.database.recipe.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey + +@Entity( + tableName = "recipe_ingredient_to_instruction", + foreignKeys = [ + ForeignKey( + entity = RecipeEntity::class, + parentColumns = ["recipe_id"], + childColumns = ["recipe_id"], + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + entity = RecipeIngredientEntity::class, + parentColumns = ["recipe_ingredient_id"], + childColumns = ["ingredient_id"], + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + entity = RecipeInstructionEntity::class, + parentColumns = ["recipe_instruction_id"], + childColumns = ["instruction_id"], + onDelete = ForeignKey.CASCADE + ), + ], + primaryKeys = ["recipe_id", "ingredient_id", "instruction_id"] +) +data class RecipeIngredientToInstructionEntity( + @ColumnInfo(name = "recipe_id", index = true) val recipeId: String, + @ColumnInfo(name = "ingredient_id", index = true) val ingredientId: String, + @ColumnInfo(name = "instruction_id", index = true) val instructionId: String, +) diff --git a/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/entity/RecipeInstructionEntity.kt b/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/entity/RecipeInstructionEntity.kt index 2e37976..1698d7d 100644 --- a/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/entity/RecipeInstructionEntity.kt +++ b/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/entity/RecipeInstructionEntity.kt @@ -17,25 +17,8 @@ import androidx.room.PrimaryKey ] ) data class RecipeInstructionEntity( - @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "recipe_instruction_local_id") val localId: Long = 0, + @PrimaryKey @ColumnInfo(name = "recipe_instruction_id") val id: String, @ColumnInfo(name = "recipe_id", index = true) val recipeId: String, @ColumnInfo(name = "recipe_instruction_text") val text: String, -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as RecipeInstructionEntity - - if (recipeId != other.recipeId) return false - if (text != other.text) return false - - return true - } - - override fun hashCode(): Int { - var result = recipeId.hashCode() - result = 31 * result + text.hashCode() - return result - } -} + @ColumnInfo(name = "recipe_instruction_title") val title: String?, +) diff --git a/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/entity/RecipeSummaryEntity.kt b/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/entity/RecipeSummaryEntity.kt index 65ec822..d9ff339 100644 --- a/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/entity/RecipeSummaryEntity.kt +++ b/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/entity/RecipeSummaryEntity.kt @@ -15,8 +15,5 @@ data class RecipeSummaryEntity( @ColumnInfo(name = "recipe_summaries_date_added") val dateAdded: LocalDate, @ColumnInfo(name = "recipe_summaries_date_updated") val dateUpdated: LocalDateTime, @ColumnInfo(name = "recipe_summaries_image_id") val imageId: String?, - @ColumnInfo( - name = "recipe_summaries_is_favorite", - defaultValue = "false" - ) val isFavorite: Boolean, + @ColumnInfo(name = "recipe_summaries_is_favorite") val isFavorite: Boolean, ) \ No newline at end of file diff --git a/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/entity/RecipeWithSummaryAndIngredientsAndInstructions.kt b/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/entity/RecipeWithSummaryAndIngredientsAndInstructions.kt index 51746b0..a3d2982 100644 --- a/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/entity/RecipeWithSummaryAndIngredientsAndInstructions.kt +++ b/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/entity/RecipeWithSummaryAndIngredientsAndInstructions.kt @@ -20,4 +20,9 @@ data class RecipeWithSummaryAndIngredientsAndInstructions( entityColumn = "recipe_id" ) val recipeInstructions: List, + @Relation( + parentColumn = "recipe_id", + entityColumn = "recipe_id" + ) + val recipeIngredientToInstructionEntity: List, ) diff --git a/database/src/test/kotlin/gq/kirmanak/mealient/database/RecipeStorageImplTest.kt b/database/src/test/kotlin/gq/kirmanak/mealient/database/RecipeStorageImplTest.kt index 3a1450f..82dc8f3 100644 --- a/database/src/test/kotlin/gq/kirmanak/mealient/database/RecipeStorageImplTest.kt +++ b/database/src/test/kotlin/gq/kirmanak/mealient/database/RecipeStorageImplTest.kt @@ -50,7 +50,11 @@ internal class RecipeStorageImplTest : HiltRobolectricTest() { subject.saveRecipeInfo( CAKE_RECIPE_ENTITY, listOf(CAKE_SUGAR_RECIPE_INGREDIENT_ENTITY, CAKE_BREAD_RECIPE_INGREDIENT_ENTITY), - listOf(MIX_CAKE_RECIPE_INSTRUCTION_ENTITY, BAKE_CAKE_RECIPE_INSTRUCTION_ENTITY) + listOf(MIX_CAKE_RECIPE_INSTRUCTION_ENTITY, BAKE_CAKE_RECIPE_INSTRUCTION_ENTITY), + listOf( + MIX_SUGAR_RECIPE_INGREDIENT_INSTRUCTION_ENTITY, + MIX_BREAD_RECIPE_INGREDIENT_INSTRUCTION_ENTITY + ), ) val actual = recipeDao.queryFullRecipeInfo("1") assertThat(actual).isEqualTo(FULL_CAKE_INFO_ENTITY) @@ -63,11 +67,16 @@ internal class RecipeStorageImplTest : HiltRobolectricTest() { CAKE_RECIPE_ENTITY, listOf(CAKE_SUGAR_RECIPE_INGREDIENT_ENTITY, CAKE_BREAD_RECIPE_INGREDIENT_ENTITY), listOf(MIX_CAKE_RECIPE_INSTRUCTION_ENTITY, BAKE_CAKE_RECIPE_INSTRUCTION_ENTITY), + listOf( + MIX_SUGAR_RECIPE_INGREDIENT_INSTRUCTION_ENTITY, + MIX_BREAD_RECIPE_INGREDIENT_INSTRUCTION_ENTITY + ), ) subject.saveRecipeInfo( PORRIDGE_RECIPE_ENTITY_FULL, listOf(PORRIDGE_SUGAR_RECIPE_INGREDIENT_ENTITY, PORRIDGE_MILK_RECIPE_INGREDIENT_ENTITY), listOf(PORRIDGE_MIX_RECIPE_INSTRUCTION_ENTITY, PORRIDGE_BOIL_RECIPE_INSTRUCTION_ENTITY), + emptyList(), ) val actual = recipeDao.queryFullRecipeInfo("2") assertThat(actual).isEqualTo(FULL_PORRIDGE_INFO_ENTITY) @@ -80,14 +89,19 @@ internal class RecipeStorageImplTest : HiltRobolectricTest() { CAKE_RECIPE_ENTITY, listOf(CAKE_SUGAR_RECIPE_INGREDIENT_ENTITY, CAKE_BREAD_RECIPE_INGREDIENT_ENTITY), listOf(MIX_CAKE_RECIPE_INSTRUCTION_ENTITY, BAKE_CAKE_RECIPE_INSTRUCTION_ENTITY), + listOf( + MIX_SUGAR_RECIPE_INGREDIENT_INSTRUCTION_ENTITY, + MIX_BREAD_RECIPE_INGREDIENT_INSTRUCTION_ENTITY + ), ) subject.saveRecipeInfo( CAKE_RECIPE_ENTITY, listOf(CAKE_BREAD_RECIPE_INGREDIENT_ENTITY), listOf(MIX_CAKE_RECIPE_INSTRUCTION_ENTITY, BAKE_CAKE_RECIPE_INSTRUCTION_ENTITY), + emptyList(), ) val actual = recipeDao.queryFullRecipeInfo("1")?.recipeIngredients - val expected = listOf(CAKE_BREAD_RECIPE_INGREDIENT_ENTITY.copy(localId = 3)) + val expected = listOf(CAKE_BREAD_RECIPE_INGREDIENT_ENTITY) assertThat(actual).isEqualTo(expected) } @@ -98,14 +112,19 @@ internal class RecipeStorageImplTest : HiltRobolectricTest() { CAKE_RECIPE_ENTITY, listOf(CAKE_SUGAR_RECIPE_INGREDIENT_ENTITY, CAKE_BREAD_RECIPE_INGREDIENT_ENTITY), listOf(MIX_CAKE_RECIPE_INSTRUCTION_ENTITY, BAKE_CAKE_RECIPE_INSTRUCTION_ENTITY), + listOf( + MIX_SUGAR_RECIPE_INGREDIENT_INSTRUCTION_ENTITY, + MIX_BREAD_RECIPE_INGREDIENT_INSTRUCTION_ENTITY + ), ) subject.saveRecipeInfo( CAKE_RECIPE_ENTITY, listOf(CAKE_SUGAR_RECIPE_INGREDIENT_ENTITY, CAKE_BREAD_RECIPE_INGREDIENT_ENTITY), listOf(MIX_CAKE_RECIPE_INSTRUCTION_ENTITY), + emptyList(), ) val actual = recipeDao.queryFullRecipeInfo("1")?.recipeInstructions - val expected = listOf(MIX_CAKE_RECIPE_INSTRUCTION_ENTITY.copy(localId = 3)) + val expected = listOf(MIX_CAKE_RECIPE_INSTRUCTION_ENTITY) assertThat(actual).isEqualTo(expected) } } \ No newline at end of file diff --git a/database_test/src/main/kotlin/gq/kirmanak/mealient/database/TestData.kt b/database_test/src/main/kotlin/gq/kirmanak/mealient/database/TestData.kt index 0fdf0ba..77b3411 100644 --- a/database_test/src/main/kotlin/gq/kirmanak/mealient/database/TestData.kt +++ b/database_test/src/main/kotlin/gq/kirmanak/mealient/database/TestData.kt @@ -2,6 +2,7 @@ package gq.kirmanak.mealient.database import gq.kirmanak.mealient.database.recipe.entity.RecipeEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeIngredientEntity +import gq.kirmanak.mealient.database.recipe.entity.RecipeIngredientToInstructionEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeInstructionEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeWithSummaryAndIngredientsAndInstructions @@ -34,13 +35,29 @@ val TEST_RECIPE_SUMMARY_ENTITIES = listOf(CAKE_RECIPE_SUMMARY_ENTITY, PORRIDGE_RECIPE_SUMMARY_ENTITY) val MIX_CAKE_RECIPE_INSTRUCTION_ENTITY = RecipeInstructionEntity( + id = "1", recipeId = "1", text = "Mix the ingredients", + title = "", ) val BAKE_CAKE_RECIPE_INSTRUCTION_ENTITY = RecipeInstructionEntity( + id = "2", recipeId = "1", text = "Bake the ingredients", + title = "", +) + +val MIX_SUGAR_RECIPE_INGREDIENT_INSTRUCTION_ENTITY = RecipeIngredientToInstructionEntity( + recipeId = "1", + ingredientId = "1", + instructionId = "1", +) + +val MIX_BREAD_RECIPE_INGREDIENT_INSTRUCTION_ENTITY = RecipeIngredientToInstructionEntity( + recipeId = "1", + ingredientId = "3", + instructionId = "1", ) val CAKE_RECIPE_ENTITY = RecipeEntity( @@ -50,20 +67,24 @@ val CAKE_RECIPE_ENTITY = RecipeEntity( ) val CAKE_SUGAR_RECIPE_INGREDIENT_ENTITY = RecipeIngredientEntity( + id = "1", recipeId = "1", note = "2 oz of white sugar", quantity = 1.0, unit = null, food = null, - title = null, + display = "2 oz of white sugar", + title = "Sugar", ) val CAKE_BREAD_RECIPE_INGREDIENT_ENTITY = RecipeIngredientEntity( + id = "3", recipeId = "1", note = "2 oz of white bread", quantity = 1.0, unit = null, food = null, + display = "2 oz of white bread", title = null, ) @@ -78,6 +99,10 @@ val FULL_CAKE_INFO_ENTITY = RecipeWithSummaryAndIngredientsAndInstructions( MIX_CAKE_RECIPE_INSTRUCTION_ENTITY, BAKE_CAKE_RECIPE_INSTRUCTION_ENTITY, ), + recipeIngredientToInstructionEntity = listOf( + MIX_SUGAR_RECIPE_INGREDIENT_INSTRUCTION_ENTITY, + MIX_BREAD_RECIPE_INGREDIENT_INSTRUCTION_ENTITY + ), ) val PORRIDGE_RECIPE_ENTITY_FULL = RecipeEntity( @@ -87,31 +112,39 @@ val PORRIDGE_RECIPE_ENTITY_FULL = RecipeEntity( ) val PORRIDGE_MILK_RECIPE_INGREDIENT_ENTITY = RecipeIngredientEntity( + id = "1", recipeId = "2", note = "2 oz of white milk", quantity = 1.0, unit = null, food = null, + display = "2 oz of white milk", title = null, ) val PORRIDGE_SUGAR_RECIPE_INGREDIENT_ENTITY = RecipeIngredientEntity( + id = "2", recipeId = "2", note = "2 oz of white sugar", quantity = 1.0, unit = null, food = null, - title = null, + display = "2 oz of white sugar", + title = "Sugar", ) val PORRIDGE_MIX_RECIPE_INSTRUCTION_ENTITY = RecipeInstructionEntity( + id = "1", recipeId = "2", - text = "Mix the ingredients" + text = "Mix the ingredients", + title = "", ) val PORRIDGE_BOIL_RECIPE_INSTRUCTION_ENTITY = RecipeInstructionEntity( + id = "2", recipeId = "2", - text = "Boil the ingredients" + text = "Boil the ingredients", + title = "", ) val FULL_PORRIDGE_INFO_ENTITY = RecipeWithSummaryAndIngredientsAndInstructions( @@ -124,5 +157,6 @@ val FULL_PORRIDGE_INFO_ENTITY = RecipeWithSummaryAndIngredientsAndInstructions( recipeInstructions = listOf( PORRIDGE_MIX_RECIPE_INSTRUCTION_ENTITY, PORRIDGE_BOIL_RECIPE_INSTRUCTION_ENTITY, - ) + ), + recipeIngredientToInstructionEntity = emptyList(), ) diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/GetRecipeResponse.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/GetRecipeResponse.kt index 5e80975..1d6f0b7 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/GetRecipeResponse.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/models/GetRecipeResponse.kt @@ -8,8 +8,8 @@ data class GetRecipeResponse( @SerialName("id") val remoteId: String, @SerialName("name") val name: String, @SerialName("recipeYield") val recipeYield: String = "", - @SerialName("recipeIngredient") val recipeIngredients: List = emptyList(), - @SerialName("recipeInstructions") val recipeInstructions: List = emptyList(), + @SerialName("recipeIngredient") val ingredients: List = emptyList(), + @SerialName("recipeInstructions") val instructions: List = emptyList(), @SerialName("settings") val settings: GetRecipeSettingsResponse? = null, ) @@ -24,10 +24,20 @@ data class GetRecipeIngredientResponse( @SerialName("unit") val unit: GetUnitResponse?, @SerialName("food") val food: GetFoodResponse?, @SerialName("quantity") val quantity: Double?, + @SerialName("display") val display: String, + @SerialName("referenceId") val referenceId: String, @SerialName("title") val title: String?, ) @Serializable data class GetRecipeInstructionResponse( + @SerialName("id") val id: String, + @SerialName("title") val title: String = "", @SerialName("text") val text: String, + @SerialName("ingredientReferences") val ingredientReferences: List = emptyList(), +) + +@Serializable +data class GetRecipeInstructionIngredientReference( + @SerialName("referenceId") val referenceId: String, ) \ No newline at end of file diff --git a/datasource_test/src/main/kotlin/gq/kirmanak/mealient/datasource_test/TestData.kt b/datasource_test/src/main/kotlin/gq/kirmanak/mealient/datasource_test/TestData.kt index 3aa9564..de80df8 100644 --- a/datasource_test/src/main/kotlin/gq/kirmanak/mealient/datasource_test/TestData.kt +++ b/datasource_test/src/main/kotlin/gq/kirmanak/mealient/datasource_test/TestData.kt @@ -9,6 +9,7 @@ import gq.kirmanak.mealient.datasource.models.AddRecipeSettings import gq.kirmanak.mealient.datasource.models.AddRecipeSettingsInfo import gq.kirmanak.mealient.datasource.models.CreateRecipeRequest import gq.kirmanak.mealient.datasource.models.GetRecipeIngredientResponse +import gq.kirmanak.mealient.datasource.models.GetRecipeInstructionIngredientReference import gq.kirmanak.mealient.datasource.models.GetRecipeInstructionResponse import gq.kirmanak.mealient.datasource.models.GetRecipeResponse import gq.kirmanak.mealient.datasource.models.GetRecipeSettingsResponse @@ -76,6 +77,8 @@ val MILK_RECIPE_INGREDIENT_RESPONSE = GetRecipeIngredientResponse( quantity = 1.0, unit = null, food = null, + display = "2 oz of white milk", + referenceId = "1", title = null, ) @@ -84,7 +87,9 @@ val SUGAR_RECIPE_INGREDIENT_RESPONSE = GetRecipeIngredientResponse( quantity = 1.0, unit = null, food = null, - title = null, + display = "2 oz of white sugar", + referenceId = "1", + title = "Sugar", ) val BREAD_RECIPE_INGREDIENT_RESPONSE = GetRecipeIngredientResponse( @@ -92,14 +97,34 @@ val BREAD_RECIPE_INGREDIENT_RESPONSE = GetRecipeIngredientResponse( quantity = 1.0, unit = null, food = null, + display = "2 oz of white bread", + referenceId = "3", title = null, ) -val MIX_RECIPE_INSTRUCTION_RESPONSE = GetRecipeInstructionResponse("Mix the ingredients") +val MIX_RECIPE_INSTRUCTION_RESPONSE = GetRecipeInstructionResponse( + id = "1", + title = "", + text = "Mix the ingredients", + ingredientReferences = listOf( + GetRecipeInstructionIngredientReference(referenceId = "1"), + GetRecipeInstructionIngredientReference(referenceId = "3"), + ), +) -val BAKE_RECIPE_INSTRUCTION_RESPONSE = GetRecipeInstructionResponse("Bake the ingredients") +val BAKE_RECIPE_INSTRUCTION_RESPONSE = GetRecipeInstructionResponse( + id = "2", + title = "", + text = "Bake the ingredients", + ingredientReferences = emptyList() +) -val BOIL_RECIPE_INSTRUCTION_RESPONSE = GetRecipeInstructionResponse("Boil the ingredients") +val BOIL_RECIPE_INSTRUCTION_RESPONSE = GetRecipeInstructionResponse( + id = "3", + title = "", + text = "Boil the ingredients", + ingredientReferences = emptyList() +) val NO_AMOUNT_RECIPE_SETTINGS_RESPONSE = GetRecipeSettingsResponse(disableAmount = true) @@ -107,8 +132,8 @@ val CAKE_RECIPE_RESPONSE = GetRecipeResponse( remoteId = "1", name = "Cake", recipeYield = "4 servings", - recipeIngredients = listOf(SUGAR_RECIPE_INGREDIENT_RESPONSE, BREAD_RECIPE_INGREDIENT_RESPONSE), - recipeInstructions = listOf(MIX_RECIPE_INSTRUCTION_RESPONSE, BAKE_RECIPE_INSTRUCTION_RESPONSE), + ingredients = listOf(SUGAR_RECIPE_INGREDIENT_RESPONSE, BREAD_RECIPE_INGREDIENT_RESPONSE), + instructions = listOf(MIX_RECIPE_INSTRUCTION_RESPONSE, BAKE_RECIPE_INSTRUCTION_RESPONSE), settings = NO_AMOUNT_RECIPE_SETTINGS_RESPONSE, ) @@ -116,11 +141,11 @@ val PORRIDGE_RECIPE_RESPONSE = GetRecipeResponse( remoteId = "2", recipeYield = "3 servings", name = "Porridge", - recipeIngredients = listOf( + ingredients = listOf( SUGAR_RECIPE_INGREDIENT_RESPONSE, MILK_RECIPE_INGREDIENT_RESPONSE, ), - recipeInstructions = listOf( + instructions = listOf( MIX_RECIPE_INSTRUCTION_RESPONSE, BOIL_RECIPE_INSTRUCTION_RESPONSE ), 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/ShoppingListScreen.kt index 3523d38..41dff48 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/ShoppingListScreen.kt @@ -52,8 +52,6 @@ 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.AppTheme -import gq.kirmanak.mealient.Dimens import gq.kirmanak.mealient.datasource.models.GetFoodResponse import gq.kirmanak.mealient.datasource.models.GetShoppingListItemRecipeReferenceResponse import gq.kirmanak.mealient.datasource.models.GetShoppingListItemResponse @@ -62,6 +60,8 @@ 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.ui.AppTheme +import gq.kirmanak.mealient.ui.Dimens import kotlinx.coroutines.android.awaitFrame import java.text.DecimalFormat 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/ShoppingListsFragment.kt index e71b3b3..189c0a4 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/ShoppingListsFragment.kt @@ -8,8 +8,8 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment import dagger.hilt.android.AndroidEntryPoint -import gq.kirmanak.mealient.AppTheme import gq.kirmanak.mealient.ui.ActivityUiStateController +import gq.kirmanak.mealient.ui.AppTheme import gq.kirmanak.mealient.ui.CheckableMenuItem import javax.inject.Inject 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/ShoppingListsScreen.kt index 88ec478..a77a4a5 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/ShoppingListsScreen.kt @@ -20,12 +20,12 @@ 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.AppTheme -import gq.kirmanak.mealient.Dimens 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.destinations.ShoppingListScreenDestination +import gq.kirmanak.mealient.ui.AppTheme +import gq.kirmanak.mealient.ui.Dimens @RootNavGraph(start = true) @Destination(start = true) diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/CenteredProgressIndicator.kt b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/CenteredProgressIndicator.kt index 65db4dc..2bca6e3 100644 --- a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/CenteredProgressIndicator.kt +++ b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/CenteredProgressIndicator.kt @@ -7,7 +7,7 @@ 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.AppTheme +import gq.kirmanak.mealient.ui.AppTheme @Composable fun CenteredProgressIndicator( 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 index 092829a..8e4edf2 100644 --- 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 @@ -7,7 +7,7 @@ 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.AppTheme +import gq.kirmanak.mealient.ui.AppTheme @Composable fun CenteredText( diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/EmptyListError.kt b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/EmptyListError.kt index fff7c0b..54d7f2e 100644 --- a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/EmptyListError.kt +++ b/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/shopping_lists/ui/composables/EmptyListError.kt @@ -10,9 +10,9 @@ 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.AppTheme -import gq.kirmanak.mealient.Dimens import gq.kirmanak.mealient.shopping_list.R +import gq.kirmanak.mealient.ui.AppTheme +import gq.kirmanak.mealient.ui.Dimens @Composable fun EmptyListError( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a25a180..8eede0b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -91,6 +91,8 @@ composeDestinations = "1.9.54" hiltNavigationCompose = "1.0.0" # https://github.com/ktorio/ktor/releases ktor = "2.3.5" +# https://github.com/coil-kt/coil/releases +coil = "2.5.0" [libraries] android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } @@ -202,6 +204,9 @@ ktor-encoding = { group = "io.ktor", name = "ktor-client-encoding", version.ref ktor-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" } ktor-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } +coil = { group = "io.coil-kt", name = "coil", version.ref = "coil" } +coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } + [plugins] sonarqube = { id = "org.sonarqube", version.ref = "sonarqube" } appsweep = { id = "com.guardsquare.appsweep", version.ref = "appsweep" } diff --git a/model_mapper/src/main/kotlin/gq/kirmanak/mealient/model_mapper/ModelMapper.kt b/model_mapper/src/main/kotlin/gq/kirmanak/mealient/model_mapper/ModelMapper.kt index d85c1e6..2e575b2 100644 --- a/model_mapper/src/main/kotlin/gq/kirmanak/mealient/model_mapper/ModelMapper.kt +++ b/model_mapper/src/main/kotlin/gq/kirmanak/mealient/model_mapper/ModelMapper.kt @@ -24,11 +24,11 @@ interface ModelMapper { fun toRecipeEntity(getRecipeResponse: GetRecipeResponse): RecipeEntity fun toRecipeIngredientEntity( - ingredientResponse: GetRecipeIngredientResponse, remoteId: String + ingredientResponse: GetRecipeIngredientResponse, recipeId: String ): RecipeIngredientEntity fun toRecipeInstructionEntity( - instructionResponse: GetRecipeInstructionResponse, remoteId: String + instructionResponse: GetRecipeInstructionResponse, recipeId: String ): RecipeInstructionEntity fun toRecipeSummaryEntity( diff --git a/model_mapper/src/main/kotlin/gq/kirmanak/mealient/model_mapper/ModelMapperImpl.kt b/model_mapper/src/main/kotlin/gq/kirmanak/mealient/model_mapper/ModelMapperImpl.kt index 79c84ce..4071510 100644 --- a/model_mapper/src/main/kotlin/gq/kirmanak/mealient/model_mapper/ModelMapperImpl.kt +++ b/model_mapper/src/main/kotlin/gq/kirmanak/mealient/model_mapper/ModelMapperImpl.kt @@ -32,22 +32,26 @@ class ModelMapperImpl @Inject constructor() : ModelMapper { override fun toRecipeIngredientEntity( ingredientResponse: GetRecipeIngredientResponse, - remoteId: String + recipeId: String ) = RecipeIngredientEntity( - recipeId = remoteId, + id = ingredientResponse.referenceId, + recipeId = recipeId, note = ingredientResponse.note, unit = ingredientResponse.unit?.name, food = ingredientResponse.food?.name, quantity = ingredientResponse.quantity, + display = ingredientResponse.display, title = ingredientResponse.title, ) override fun toRecipeInstructionEntity( instructionResponse: GetRecipeInstructionResponse, - remoteId: String + recipeId: String ) = RecipeInstructionEntity( - recipeId = remoteId, + id = instructionResponse.id, + recipeId = recipeId, text = instructionResponse.text, + title = instructionResponse.title, ) override fun toRecipeSummaryEntity( diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index 176c335..a6e3952 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -1,5 +1,7 @@ plugins { id("gq.kirmanak.mealient.library") + alias(libs.plugins.ksp) + id("gq.kirmanak.mealient.compose") id("kotlin-kapt") id("dagger.hilt.android.plugin") } @@ -14,6 +16,8 @@ dependencies { kaptTest(libs.google.dagger.hiltAndroidCompiler) testImplementation(libs.google.dagger.hiltAndroidTesting) + implementation(libs.android.material.material) + testImplementation(libs.androidx.test.junit) testImplementation(libs.google.truth) diff --git a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/Theme.kt b/ui/src/main/kotlin/gq/kirmanak/mealient/ui/Theme.kt similarity index 95% rename from features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/Theme.kt rename to ui/src/main/kotlin/gq/kirmanak/mealient/ui/Theme.kt index 6c77051..2b1a1b9 100644 --- a/features/shopping_lists/src/main/kotlin/gq/kirmanak/mealient/Theme.kt +++ b/ui/src/main/kotlin/gq/kirmanak/mealient/ui/Theme.kt @@ -1,4 +1,4 @@ -package gq.kirmanak.mealient +package gq.kirmanak.mealient.ui import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme @@ -40,6 +40,8 @@ object Dimens { val Small = 8.dp + val Intermediate = 12.dp + val Medium = 16.dp val Large = 24.dp