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
This commit is contained in:
Kirill Kamakin
2023-11-07 20:47:01 +01:00
committed by GitHub
parent 5ed1acb678
commit 941d45328e
46 changed files with 797 additions and 730 deletions

View File

@@ -11,6 +11,7 @@ plugins {
id("dagger.hilt.android.plugin") id("dagger.hilt.android.plugin")
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
alias(libs.plugins.appsweep) alias(libs.plugins.appsweep)
id("gq.kirmanak.mealient.compose.app")
} }
android { android {
@@ -135,6 +136,9 @@ dependencies {
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation(libs.coil)
implementation(libs.coil.compose)
testImplementation(libs.junit) testImplementation(libs.junit)
implementation(libs.jetbrains.kotlinx.coroutinesAndroid) implementation(libs.jetbrains.kotlinx.coroutinesAndroid)

View File

@@ -1,6 +1,8 @@
package gq.kirmanak.mealient package gq.kirmanak.mealient
import android.app.Application import android.app.Application
import coil.Coil
import coil.ImageLoader
import com.google.android.material.color.DynamicColors import com.google.android.material.color.DynamicColors
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import gq.kirmanak.mealient.architecture.configuration.BuildConfiguration import gq.kirmanak.mealient.architecture.configuration.BuildConfiguration
@@ -24,6 +26,9 @@ class App : Application() {
@Inject @Inject
lateinit var migrationDetector: MigrationDetector lateinit var migrationDetector: MigrationDetector
@Inject
lateinit var imageLoader: ImageLoader
private val appCoroutineScope = CoroutineScope(Dispatchers.Main + Job()) private val appCoroutineScope = CoroutineScope(Dispatchers.Main + Job())
override fun onCreate() { override fun onCreate() {
@@ -31,5 +36,6 @@ class App : Application() {
logger.v { "onCreate() called" } logger.v { "onCreate() called" }
DynamicColors.applyToActivitiesIfAvailable(this) DynamicColors.applyToActivitiesIfAvailable(this)
appCoroutineScope.launch { migrationDetector.executeMigrations() } appCoroutineScope.launch { migrationDetector.executeMigrations() }
Coil.setImageLoader(imageLoader)
} }
} }

View File

@@ -2,5 +2,5 @@ package gq.kirmanak.mealient.data.recipes.impl
interface RecipeImageUrlProvider { interface RecipeImageUrlProvider {
suspend fun generateImageUrl(slug: String?): String? suspend fun generateImageUrl(imageId: String?): String?
} }

View File

@@ -10,10 +10,10 @@ class RecipeImageUrlProviderImpl @Inject constructor(
private val logger: Logger, private val logger: Logger,
) : RecipeImageUrlProvider { ) : RecipeImageUrlProvider {
override suspend fun generateImageUrl(slug: String?): String? { override suspend fun generateImageUrl(imageId: String?): String? {
logger.v { "generateImageUrl() called with: slug = $slug" } logger.v { "generateImageUrl() called with: slug = $imageId" }
slug?.takeUnless { it.isBlank() } ?: return null imageId?.takeUnless { it.isBlank() } ?: return null
val imagePath = IMAGE_PATH_FORMAT.format(slug) val imagePath = IMAGE_PATH_FORMAT.format(imageId)
val baseUrl = serverInfoRepo.getUrl()?.takeUnless { it.isEmpty() } val baseUrl = serverInfoRepo.getUrl()?.takeUnless { it.isEmpty() }
val result = baseUrl val result = baseUrl
?.takeUnless { it.isBlank() } ?.takeUnless { it.isBlank() }

View File

@@ -6,6 +6,7 @@ import androidx.paging.PagingConfig
import gq.kirmanak.mealient.data.recipes.RecipeRepo import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
import gq.kirmanak.mealient.database.recipe.RecipeStorage 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.RecipeSummaryEntity
import gq.kirmanak.mealient.database.recipe.entity.RecipeWithSummaryAndIngredientsAndInstructions import gq.kirmanak.mealient.database.recipe.entity.RecipeWithSummaryAndIngredientsAndInstructions
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
@@ -45,15 +46,24 @@ class RecipeRepoImpl @Inject constructor(
override suspend fun refreshRecipeInfo(recipeSlug: String): Result<Unit> { override suspend fun refreshRecipeInfo(recipeSlug: String): Result<Unit> {
logger.v { "refreshRecipeInfo() called with: recipeSlug = $recipeSlug" } logger.v { "refreshRecipeInfo() called with: recipeSlug = $recipeSlug" }
return runCatchingExceptCancel { return runCatchingExceptCancel {
val info = dataSource.requestRecipe(recipeSlug) val recipe = dataSource.requestRecipe(recipeSlug)
val entity = modelMapper.toRecipeEntity(info) val entity = modelMapper.toRecipeEntity(recipe)
val ingredients = info.recipeIngredients.map { val ingredients = recipe.ingredients.map {
modelMapper.toRecipeIngredientEntity(it, entity.remoteId) modelMapper.toRecipeIngredientEntity(it, entity.remoteId)
} }
val instructions = info.recipeInstructions.map { val instructions = recipe.instructions.map {
modelMapper.toRecipeInstructionEntity(it, entity.remoteId) 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 { }.onFailure {
logger.e(it) { "loadRecipeInfo: can't update full recipe info" } logger.e(it) { "loadRecipeInfo: can't update full recipe info" }
} }

View File

@@ -5,6 +5,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile import androidx.datastore.preferences.preferencesDataStoreFile
import coil.ImageLoader
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
@@ -13,6 +14,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.data.storage.PreferencesStorage import gq.kirmanak.mealient.data.storage.PreferencesStorage
import gq.kirmanak.mealient.data.storage.PreferencesStorageImpl import gq.kirmanak.mealient.data.storage.PreferencesStorageImpl
import okhttp3.OkHttpClient
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@@ -24,6 +26,17 @@ interface AppModule {
@Singleton @Singleton
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> = fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
PreferenceDataStoreFactory.create { context.preferencesDataStoreFile("settings") } PreferenceDataStoreFactory.create { context.preferencesDataStoreFile("settings") }
@Provides
@Singleton
fun provideCoilImageLoader(
@ApplicationContext context: Context,
okHttpClient: OkHttpClient,
): ImageLoader {
return ImageLoader.Builder(context)
.okHttpClient(okHttpClient)
.build()
}
} }
@Binds @Binds

View File

@@ -82,6 +82,7 @@ class RecipesListFragment : Fragment(R.layout.fragment_recipes_list) {
private fun navigateToRecipeInfo(id: String) { private fun navigateToRecipeInfo(id: String) {
logger.v { "navigateToRecipeInfo() called with: id = $id" } logger.v { "navigateToRecipeInfo() called with: id = $id" }
val directions = actionRecipesFragmentToRecipeInfoFragment(id) val directions = actionRecipesFragmentToRecipeInfoFragment(id)
binding.root.hideKeyboard()
findNavController().navigate(directions) findNavController().navigate(directions)
} }

View File

@@ -4,79 +4,52 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup 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 androidx.fragment.app.viewModels
import by.kirich1409.viewbindingdelegate.viewBinding
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.databinding.FragmentRecipeInfoBinding
import gq.kirmanak.mealient.logging.Logger 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 import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class RecipeInfoFragment : BottomSheetDialogFragment() { class RecipeInfoFragment : Fragment() {
private val binding by viewBinding(FragmentRecipeInfoBinding::bind)
private val viewModel by viewModels<RecipeInfoViewModel>() private val viewModel by viewModels<RecipeInfoViewModel>()
private lateinit var ingredientsAdapter: RecipeIngredientsAdapter private val activityViewModel by activityViewModels<MainActivityViewModel>()
@Inject
lateinit var instructionsAdapter: RecipeInstructionsAdapter
@Inject
lateinit var recipeIngredientsAdapterFactory: RecipeIngredientsAdapter.Factory
@Inject @Inject
lateinit var logger: Logger lateinit var logger: Logger
@Inject
lateinit var recipeImageLoader: RecipeImageLoader
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View { ): View {
logger.v { "onCreateView() called" } 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?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
logger.v { "onViewCreated() called" } activityViewModel.updateUiState {
it.copy(
with(binding) { navigationVisible = false,
instructionsList.adapter = instructionsAdapter searchVisible = false,
} checkedMenuItem = CheckableMenuItem.RecipesList,
)
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
} }
} }
} }

View File

@@ -9,8 +9,9 @@ data class RecipeInfoUiState(
val showInstructions: Boolean = false, val showInstructions: Boolean = false,
val summaryEntity: RecipeSummaryEntity? = null, val summaryEntity: RecipeSummaryEntity? = null,
val recipeIngredients: List<RecipeIngredientEntity> = emptyList(), val recipeIngredients: List<RecipeIngredientEntity> = emptyList(),
val recipeInstructions: List<RecipeInstructionEntity> = emptyList(), val recipeInstructions: Map<RecipeInstructionEntity, List<RecipeIngredientEntity>> = emptyMap(),
val title: String? = null, val title: String? = null,
val description: String? = null, val description: String? = null,
val disableAmounts: Boolean = true, val disableAmounts: Boolean = true,
val imageUrl: String? = null,
) )

View File

@@ -1,38 +1,66 @@
package gq.kirmanak.mealient.ui.recipes.info package gq.kirmanak.mealient.ui.recipes.info
import androidx.lifecycle.LiveData
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.liveData import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.recipes.RecipeRepo import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.data.recipes.impl.RecipeImageUrlProvider
import gq.kirmanak.mealient.database.recipe.entity.RecipeIngredientEntity
import gq.kirmanak.mealient.database.recipe.entity.RecipeInstructionEntity
import gq.kirmanak.mealient.database.recipe.entity.RecipeWithSummaryAndIngredientsAndInstructions
import gq.kirmanak.mealient.logging.Logger 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 import javax.inject.Inject
@HiltViewModel @HiltViewModel
class RecipeInfoViewModel @Inject constructor( class RecipeInfoViewModel @Inject constructor(
private val recipeRepo: RecipeRepo, private val recipeRepo: RecipeRepo,
private val logger: Logger, private val logger: Logger,
private val recipeImageUrlProvider: RecipeImageUrlProvider,
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
) : ViewModel() { ) : ViewModel() {
private val args = RecipeInfoFragmentArgs.fromSavedStateHandle(savedStateHandle) private val args = RecipeInfoFragmentArgs.fromSavedStateHandle(savedStateHandle)
private val _uiState = flow {
val uiState: LiveData<RecipeInfoUiState> = liveData {
logger.v { "Initializing UI state with args = $args" } 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( RecipeInfoUiState(
showIngredients = entity.recipeIngredients.isNotEmpty(), showIngredients = entity.recipeIngredients.isNotEmpty(),
showInstructions = entity.recipeInstructions.isNotEmpty(), showInstructions = entity.recipeInstructions.isNotEmpty(),
summaryEntity = entity.recipeSummaryEntity, summaryEntity = entity.recipeSummaryEntity,
recipeIngredients = entity.recipeIngredients, recipeIngredients = entity.recipeIngredients,
recipeInstructions = entity.recipeInstructions, recipeInstructions = associateInstructionsToIngredients(entity),
title = entity.recipeSummaryEntity.name, title = entity.recipeSummaryEntity.name,
description = entity.recipeSummaryEntity.description, description = entity.recipeSummaryEntity.description,
disableAmounts = entity.recipeEntity.disableAmounts, imageUrl = imageUrl,
) )
} ?: RecipeInfoUiState() } ?: RecipeInfoUiState()
emit(state) emit(state)
} }.stateIn(viewModelScope, SharingStarted.Eagerly, RecipeInfoUiState())
val uiState: StateFlow<RecipeInfoUiState> = _uiState
} }
private fun associateInstructionsToIngredients(
recipe: RecipeWithSummaryAndIngredientsAndInstructions,
): Map<RecipeInstructionEntity, List<RecipeIngredientEntity>> {
return recipe.recipeInstructions.associateWith { instruction ->
recipe.recipeIngredientToInstructionEntity
.filter { it.instructionId == instruction.id }
.flatMap { mapping ->
recipe.recipeIngredients.filter { ingredient ->
ingredient.id == mapping.ingredientId
}
}
}
}

View File

@@ -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<RecipeIngredientEntity, RecipeIngredientViewHolder>(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<RecipeIngredientEntity>() {
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<Int, Int, Int> {
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)
}

View File

@@ -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<RecipeInstructionEntity, RecipeInstructionViewHolder>(RecipeInstructionDiffCallback) {
private object RecipeInstructionDiffCallback :
DiffUtil.ItemCallback<RecipeInstructionEntity>() {
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)
}
}

View File

@@ -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<RecipeInstructionEntity, List<RecipeIngredientEntity>>,
) {
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<RecipeIngredientEntity>,
) {
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<RecipeIngredientEntity>,
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
}

View File

@@ -1,144 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:keepScreenOn="true"
tools:context=".ui.recipes.info.RecipeInfoFragment">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.constraintlayout.widget.Guideline
android:id="@+id/end_guide"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintGuide_end="4dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/start_guide"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintGuide_begin="4dp"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/image"
android:layout_width="0dp"
android:layout_height="0dp"
android:contentDescription="@string/content_description_fragment_recipe_info_image"
android:scaleType="centerCrop"
app:layout_constraintBottom_toTopOf="@id/title"
app:layout_constraintDimensionRatio="2:1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearance="?shapeAppearanceCornerMedium"
tools:srcCompat="@drawable/placeholder_recipe" />
<TextView
android:id="@+id/title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_small"
android:layout_marginTop="7dp"
android:textAppearance="?textAppearanceHeadline6"
app:layout_constraintBottom_toTopOf="@id/description"
app:layout_constraintEnd_toStartOf="@id/end_guide"
app:layout_constraintStart_toEndOf="@id/start_guide"
app:layout_constraintTop_toBottomOf="@id/image"
tools:text="Best-Ever Beef Stew" />
<TextView
android:id="@+id/description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_small"
android:layout_marginTop="6dp"
android:textAppearance="?textAppearanceBody2"
app:layout_constraintBottom_toTopOf="@id/ingredients_header"
app:layout_constraintEnd_toStartOf="@id/end_guide"
app:layout_constraintStart_toEndOf="@id/start_guide"
app:layout_constraintTop_toBottomOf="@id/title"
tools:text="Stay warm all winter with this classic Beef Stew made with red wine and beef stock from Delish.com." />
<TextView
android:id="@+id/ingredients_header"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="35dp"
android:layout_marginTop="@dimen/margin_medium"
android:layout_marginEnd="@dimen/margin_small"
android:text="@string/fragment_recipe_info_ingredients_header"
android:textAppearance="?textAppearanceHeadline6"
app:layout_constraintBottom_toTopOf="@id/ingredients_holder"
app:layout_constraintEnd_toStartOf="@id/end_guide"
app:layout_constraintStart_toEndOf="@id/start_guide"
app:layout_constraintTop_toBottomOf="@id/description" />
<com.google.android.material.card.MaterialCardView
android:id="@+id/ingredients_holder"
style="?materialCardViewFilledStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_small"
android:layout_marginTop="11dp"
android:layout_marginBottom="20dp"
app:layout_constraintBottom_toTopOf="@id/instructions_header"
app:layout_constraintEnd_toStartOf="@id/end_guide"
app:layout_constraintStart_toEndOf="@id/start_guide"
app:layout_constraintTop_toBottomOf="@id/ingredients_header">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/ingredients_list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_small"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:itemCount="3"
tools:listitem="@layout/view_holder_ingredient" />
</com.google.android.material.card.MaterialCardView>
<androidx.constraintlayout.widget.Group
android:id="@+id/instructions_group"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:constraint_referenced_ids="instructions_header,instructions_list" />
<TextView
android:id="@+id/instructions_header"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="35dp"
android:layout_marginEnd="@dimen/margin_small"
android:text="@string/fragment_recipe_info_instructions_header"
android:textAppearance="?textAppearanceHeadline6"
app:layout_constraintBottom_toTopOf="@id/instructions_list"
app:layout_constraintEnd_toStartOf="@id/end_guide"
app:layout_constraintStart_toEndOf="@id/start_guide"
app:layout_constraintTop_toBottomOf="@id/ingredients_holder" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/instructions_list"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/end_guide"
app:layout_constraintStart_toEndOf="@id/start_guide"
app:layout_constraintTop_toBottomOf="@id/instructions_header"
tools:itemCount="2"
tools:listitem="@layout/view_holder_instruction" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>

View File

@@ -1,45 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.constraintlayout.widget.Group
android:id="@+id/section_group"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:constraint_referenced_ids="title, title_divider"
tools:visibility="visible" />
<TextView
android:id="@+id/title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_small"
android:textAppearance="?textAppearanceHeadline6"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Meat a very long and weird title that someone might have for some reason" />
<View
android:id="@+id/title_divider"
android:layout_width="0dp"
android:layout_height="1dp"
android:alpha="0.3"
android:background="?colorOnSurface"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/title" />
<CheckBox
android:id="@+id/checkBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/title_divider"
tools:text="900 g braising steak/stew meat, cubed into 2.5cm pieces" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -1,38 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
style="?materialCardViewFilledStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="@dimen/margin_small">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="18dp"
android:paddingVertical="12dp">
<TextView
android:id="@+id/step"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textAppearance="?textAppearanceHeadline6"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Step: 1" />
<TextView
android:id="@+id/instruction"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:textAppearance="?textAppearanceBody2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/step"
tools:text="In a large dutch oven or heavy-bottomed pot over medium heat, heat oil. Add beef and cook until seared on all sides, 10 minutes, working in batches if necessary. Transfer beef to a plate." />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>

View File

@@ -18,18 +18,21 @@
tools:layout="@layout/fragment_recipes_list"> tools:layout="@layout/fragment_recipes_list">
<action <action
android:id="@+id/action_recipesFragment_to_recipeInfoFragment" android:id="@+id/action_recipesFragment_to_recipeInfoFragment"
app:destination="@id/recipeInfoFragment" /> app:destination="@id/recipeInfoFragment"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
</fragment> </fragment>
<dialog <fragment
android:id="@+id/recipeInfoFragment" android:id="@+id/recipeInfoFragment"
android:name="gq.kirmanak.mealient.ui.recipes.info.RecipeInfoFragment" android:name="gq.kirmanak.mealient.ui.recipes.info.RecipeInfoFragment"
android:label="RecipeInfoFragment" android:label="RecipeInfoFragment">
tools:layout="@layout/fragment_recipe_info">
<argument <argument
android:name="recipe_id" android:name="recipe_id"
app:argType="string" /> app:argType="string" />
</dialog> </fragment>
<fragment <fragment
android:id="@+id/disclaimerFragment" android:id="@+id/disclaimerFragment"

View File

@@ -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_RECIPE_SUMMARY_ENTITY
import gq.kirmanak.mealient.database.CAKE_SUGAR_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.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_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.database.recipe.RecipeStorage
import gq.kirmanak.mealient.datasource.NetworkError.Unauthorized import gq.kirmanak.mealient.datasource.NetworkError.Unauthorized
import gq.kirmanak.mealient.datasource_test.CAKE_RECIPE_RESPONSE import gq.kirmanak.mealient.datasource_test.CAKE_RECIPE_RESPONSE
@@ -78,7 +80,13 @@ class RecipeRepoTest : BaseUnitTest() {
CAKE_BREAD_RECIPE_INGREDIENT_ENTITY 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
)
),
) )
} }
} }

View File

@@ -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<Double, Int, Boolean>,
private val output: Triple<Int, Int, Int>,
) {
@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<Array<Any>> {
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)),
)
}
}
}

View File

@@ -1,9 +1,13 @@
package gq.kirmanak.mealient.ui.recipes.info package gq.kirmanak.mealient.ui.recipes.info
import androidx.lifecycle.asFlow
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.recipes.RecipeRepo import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.data.recipes.impl.RecipeImageUrlProvider
import gq.kirmanak.mealient.database.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.FULL_CAKE_INFO_ENTITY
import gq.kirmanak.mealient.database.MIX_CAKE_RECIPE_INSTRUCTION_ENTITY
import gq.kirmanak.mealient.test.BaseUnitTest import gq.kirmanak.mealient.test.BaseUnitTest
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.MockK
@@ -16,10 +20,13 @@ class RecipeInfoViewModelTest : BaseUnitTest() {
@MockK @MockK
lateinit var recipeRepo: RecipeRepo lateinit var recipeRepo: RecipeRepo
@MockK
lateinit var recipeImageUrlProvider: RecipeImageUrlProvider
@Test @Test
fun `when recipe isn't found then UI state is empty`() = runTest { fun `when recipe isn't found then UI state is empty`() = runTest {
coEvery { recipeRepo.loadRecipeInfo(eq(RECIPE_ID)) } returns null coEvery { recipeRepo.loadRecipeInfo(eq(RECIPE_ID)) } returns null
val uiState = createSubject().uiState.asFlow().first() val uiState = createSubject().uiState.first()
assertThat(uiState).isEqualTo(RecipeInfoUiState()) assertThat(uiState).isEqualTo(RecipeInfoUiState())
} }
@@ -29,22 +36,30 @@ class RecipeInfoViewModelTest : BaseUnitTest() {
recipeIngredients = FULL_CAKE_INFO_ENTITY.recipeIngredients recipeIngredients = FULL_CAKE_INFO_ENTITY.recipeIngredients
) )
coEvery { recipeRepo.loadRecipeInfo(eq(RECIPE_ID)) } returns returnedEntity coEvery { recipeRepo.loadRecipeInfo(eq(RECIPE_ID)) } returns returnedEntity
coEvery { recipeImageUrlProvider.generateImageUrl(eq("1")) } returns "imageUrl"
val expected = RecipeInfoUiState( val expected = RecipeInfoUiState(
showIngredients = true, showIngredients = true,
showInstructions = true, showInstructions = true,
summaryEntity = FULL_CAKE_INFO_ENTITY.recipeSummaryEntity, summaryEntity = FULL_CAKE_INFO_ENTITY.recipeSummaryEntity,
recipeIngredients = FULL_CAKE_INFO_ENTITY.recipeIngredients, 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, title = FULL_CAKE_INFO_ENTITY.recipeSummaryEntity.name,
description = FULL_CAKE_INFO_ENTITY.recipeSummaryEntity.description, description = FULL_CAKE_INFO_ENTITY.recipeSummaryEntity.description,
imageUrl = "imageUrl",
) )
val actual = createSubject().uiState.asFlow().first() val actual = createSubject().uiState.first()
assertThat(actual).isEqualTo(expected) assertThat(actual).isEqualTo(expected)
} }
private fun createSubject(): RecipeInfoViewModel { private fun createSubject(): RecipeInfoViewModel {
val argument = RecipeInfoFragmentArgs(RECIPE_ID).toSavedStateHandle() val argument = RecipeInfoFragmentArgs(RECIPE_ID).toSavedStateHandle()
return RecipeInfoViewModel(recipeRepo, logger, argument) return RecipeInfoViewModel(recipeRepo, logger, recipeImageUrlProvider, argument)
} }
companion object { companion object {

View File

@@ -23,5 +23,9 @@ gradlePlugin {
id = "gq.kirmanak.mealient.compose" id = "gq.kirmanak.mealient.compose"
implementationClass = "AndroidLibraryComposeConventionPlugin" implementationClass = "AndroidLibraryComposeConventionPlugin"
} }
register("appCompose") {
id = "gq.kirmanak.mealient.compose.app"
implementationClass = "AndroidApplicationComposeConventionPlugin"
}
} }
} }

View File

@@ -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<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("com.android.application")
extensions.configure<BaseAppModuleExtension> {
configureAndroidCompose(this)
}
}
}
}

View File

@@ -5,12 +5,13 @@ import gq.kirmanak.mealient.database.recipe.RecipeDao
import gq.kirmanak.mealient.database.recipe.entity.* import gq.kirmanak.mealient.database.recipe.entity.*
@Database( @Database(
version = 10, version = 11,
entities = [ entities = [
RecipeSummaryEntity::class, RecipeSummaryEntity::class,
RecipeEntity::class, RecipeEntity::class,
RecipeIngredientEntity::class, RecipeIngredientEntity::class,
RecipeInstructionEntity::class, RecipeInstructionEntity::class,
RecipeIngredientToInstructionEntity::class,
] ]
) )
@TypeConverters(RoomTypeConverters::class) @TypeConverters(RoomTypeConverters::class)

View File

@@ -35,13 +35,17 @@ internal interface RecipeDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertRecipeIngredients(ingredients: List<RecipeIngredientEntity>) suspend fun insertRecipeIngredients(ingredients: List<RecipeIngredientEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertIngredientToInstructionEntities(entities: List<RecipeIngredientToInstructionEntity>)
@Transaction @Transaction
@SuppressWarnings(RoomWarnings.CURSOR_MISMATCH) // The lint is wrong, the columns are actually used @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH) // The lint is wrong, the columns are actually used
@Query( @Query(
"SELECT * FROM recipe " + "SELECT * FROM recipe " +
"JOIN recipe_summaries USING(recipe_id) " + "JOIN recipe_summaries USING(recipe_id) " +
"JOIN recipe_ingredient USING(recipe_id) " + "LEFT JOIN recipe_ingredient USING(recipe_id) " +
"JOIN recipe_instruction USING(recipe_id) " + "LEFT JOIN recipe_instruction USING(recipe_id) " +
"LEFT JOIN recipe_ingredient_to_instruction USING(recipe_id) " +
"WHERE recipe.recipe_id = :recipeId" "WHERE recipe.recipe_id = :recipeId"
) )
suspend fun queryFullRecipeInfo(recipeId: String): RecipeWithSummaryAndIngredientsAndInstructions? suspend fun queryFullRecipeInfo(recipeId: String): RecipeWithSummaryAndIngredientsAndInstructions?
@@ -52,6 +56,9 @@ internal interface RecipeDao {
@Query("DELETE FROM recipe_instruction WHERE recipe_id IN (:recipeIds)") @Query("DELETE FROM recipe_instruction WHERE recipe_id IN (:recipeIds)")
suspend fun deleteRecipeInstructions(vararg recipeIds: String) 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)") @Query("UPDATE recipe_summaries SET recipe_summaries_is_favorite = 1 WHERE recipe_summaries_slug IN (:favorites)")
suspend fun setFavorite(favorites: List<String>) suspend fun setFavorite(favorites: List<String>)

View File

@@ -3,6 +3,7 @@ package gq.kirmanak.mealient.database.recipe
import androidx.paging.PagingSource import androidx.paging.PagingSource
import gq.kirmanak.mealient.database.recipe.entity.RecipeEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeEntity
import gq.kirmanak.mealient.database.recipe.entity.RecipeIngredientEntity 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.RecipeInstructionEntity
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.database.recipe.entity.RecipeWithSummaryAndIngredientsAndInstructions import gq.kirmanak.mealient.database.recipe.entity.RecipeWithSummaryAndIngredientsAndInstructions
@@ -19,7 +20,8 @@ interface RecipeStorage {
suspend fun saveRecipeInfo( suspend fun saveRecipeInfo(
recipe: RecipeEntity, recipe: RecipeEntity,
ingredients: List<RecipeIngredientEntity>, ingredients: List<RecipeIngredientEntity>,
instructions: List<RecipeInstructionEntity> instructions: List<RecipeInstructionEntity>,
ingredientToInstruction: List<RecipeIngredientToInstructionEntity>,
) )
suspend fun queryRecipeInfo(recipeId: String): RecipeWithSummaryAndIngredientsAndInstructions? suspend fun queryRecipeInfo(recipeId: String): RecipeWithSummaryAndIngredientsAndInstructions?

View File

@@ -5,6 +5,7 @@ import androidx.room.withTransaction
import gq.kirmanak.mealient.database.AppDb import gq.kirmanak.mealient.database.AppDb
import gq.kirmanak.mealient.database.recipe.entity.RecipeEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeEntity
import gq.kirmanak.mealient.database.recipe.entity.RecipeIngredientEntity 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.RecipeInstructionEntity
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.database.recipe.entity.RecipeWithSummaryAndIngredientsAndInstructions import gq.kirmanak.mealient.database.recipe.entity.RecipeWithSummaryAndIngredientsAndInstructions
@@ -44,9 +45,10 @@ internal class RecipeStorageImpl @Inject constructor(
override suspend fun saveRecipeInfo( override suspend fun saveRecipeInfo(
recipe: RecipeEntity, recipe: RecipeEntity,
ingredients: List<RecipeIngredientEntity>, ingredients: List<RecipeIngredientEntity>,
instructions: List<RecipeInstructionEntity> instructions: List<RecipeInstructionEntity>,
ingredientToInstruction: List<RecipeIngredientToInstructionEntity>,
) { ) {
logger.v { "saveRecipeInfo() called with: recipe = $recipe" } logger.v { "saveRecipeInfo() called with: recipe = $recipe, ingredients = $ingredients, instructions = $instructions, ingredientToInstructions = $ingredientToInstruction" }
db.withTransaction { db.withTransaction {
recipeDao.insertRecipe(recipe) recipeDao.insertRecipe(recipe)
@@ -55,6 +57,9 @@ internal class RecipeStorageImpl @Inject constructor(
recipeDao.deleteRecipeInstructions(recipe.remoteId) recipeDao.deleteRecipeInstructions(recipe.remoteId)
recipeDao.insertRecipeInstructions(instructions) recipeDao.insertRecipeInstructions(instructions)
recipeDao.deleteRecipeIngredientToInstructions(recipe.remoteId)
recipeDao.insertIngredientToInstructionEntities(ingredientToInstruction)
} }
} }

View File

@@ -17,37 +17,12 @@ import androidx.room.PrimaryKey
] ]
) )
data class RecipeIngredientEntity( 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_id", index = true) val recipeId: String,
@ColumnInfo(name = "recipe_ingredient_note") val note: String, @ColumnInfo(name = "recipe_ingredient_note") val note: String,
@ColumnInfo(name = "recipe_ingredient_food") val food: String?, @ColumnInfo(name = "recipe_ingredient_food") val food: String?,
@ColumnInfo(name = "recipe_ingredient_unit") val unit: String?, @ColumnInfo(name = "recipe_ingredient_unit") val unit: String?,
@ColumnInfo(name = "recipe_ingredient_quantity") val quantity: Double?, @ColumnInfo(name = "recipe_ingredient_quantity") val quantity: Double?,
@ColumnInfo(name = "recipe_ingredient_display") val display: String,
@ColumnInfo(name = "recipe_ingredient_title") val title: 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
}
}

View File

@@ -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,
)

View File

@@ -17,25 +17,8 @@ import androidx.room.PrimaryKey
] ]
) )
data class RecipeInstructionEntity( 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_id", index = true) val recipeId: String,
@ColumnInfo(name = "recipe_instruction_text") val text: String, @ColumnInfo(name = "recipe_instruction_text") val text: String,
) { @ColumnInfo(name = "recipe_instruction_title") val title: 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
}
}

View File

@@ -15,8 +15,5 @@ data class RecipeSummaryEntity(
@ColumnInfo(name = "recipe_summaries_date_added") val dateAdded: LocalDate, @ColumnInfo(name = "recipe_summaries_date_added") val dateAdded: LocalDate,
@ColumnInfo(name = "recipe_summaries_date_updated") val dateUpdated: LocalDateTime, @ColumnInfo(name = "recipe_summaries_date_updated") val dateUpdated: LocalDateTime,
@ColumnInfo(name = "recipe_summaries_image_id") val imageId: String?, @ColumnInfo(name = "recipe_summaries_image_id") val imageId: String?,
@ColumnInfo( @ColumnInfo(name = "recipe_summaries_is_favorite") val isFavorite: Boolean,
name = "recipe_summaries_is_favorite",
defaultValue = "false"
) val isFavorite: Boolean,
) )

View File

@@ -20,4 +20,9 @@ data class RecipeWithSummaryAndIngredientsAndInstructions(
entityColumn = "recipe_id" entityColumn = "recipe_id"
) )
val recipeInstructions: List<RecipeInstructionEntity>, val recipeInstructions: List<RecipeInstructionEntity>,
@Relation(
parentColumn = "recipe_id",
entityColumn = "recipe_id"
)
val recipeIngredientToInstructionEntity: List<RecipeIngredientToInstructionEntity>,
) )

View File

@@ -50,7 +50,11 @@ internal class RecipeStorageImplTest : HiltRobolectricTest() {
subject.saveRecipeInfo( subject.saveRecipeInfo(
CAKE_RECIPE_ENTITY, CAKE_RECIPE_ENTITY,
listOf(CAKE_SUGAR_RECIPE_INGREDIENT_ENTITY, CAKE_BREAD_RECIPE_INGREDIENT_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") val actual = recipeDao.queryFullRecipeInfo("1")
assertThat(actual).isEqualTo(FULL_CAKE_INFO_ENTITY) assertThat(actual).isEqualTo(FULL_CAKE_INFO_ENTITY)
@@ -63,11 +67,16 @@ internal class RecipeStorageImplTest : HiltRobolectricTest() {
CAKE_RECIPE_ENTITY, CAKE_RECIPE_ENTITY,
listOf(CAKE_SUGAR_RECIPE_INGREDIENT_ENTITY, CAKE_BREAD_RECIPE_INGREDIENT_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
),
) )
subject.saveRecipeInfo( subject.saveRecipeInfo(
PORRIDGE_RECIPE_ENTITY_FULL, PORRIDGE_RECIPE_ENTITY_FULL,
listOf(PORRIDGE_SUGAR_RECIPE_INGREDIENT_ENTITY, PORRIDGE_MILK_RECIPE_INGREDIENT_ENTITY), listOf(PORRIDGE_SUGAR_RECIPE_INGREDIENT_ENTITY, PORRIDGE_MILK_RECIPE_INGREDIENT_ENTITY),
listOf(PORRIDGE_MIX_RECIPE_INSTRUCTION_ENTITY, PORRIDGE_BOIL_RECIPE_INSTRUCTION_ENTITY), listOf(PORRIDGE_MIX_RECIPE_INSTRUCTION_ENTITY, PORRIDGE_BOIL_RECIPE_INSTRUCTION_ENTITY),
emptyList(),
) )
val actual = recipeDao.queryFullRecipeInfo("2") val actual = recipeDao.queryFullRecipeInfo("2")
assertThat(actual).isEqualTo(FULL_PORRIDGE_INFO_ENTITY) assertThat(actual).isEqualTo(FULL_PORRIDGE_INFO_ENTITY)
@@ -80,14 +89,19 @@ internal class RecipeStorageImplTest : HiltRobolectricTest() {
CAKE_RECIPE_ENTITY, CAKE_RECIPE_ENTITY,
listOf(CAKE_SUGAR_RECIPE_INGREDIENT_ENTITY, CAKE_BREAD_RECIPE_INGREDIENT_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
),
) )
subject.saveRecipeInfo( subject.saveRecipeInfo(
CAKE_RECIPE_ENTITY, CAKE_RECIPE_ENTITY,
listOf(CAKE_BREAD_RECIPE_INGREDIENT_ENTITY), listOf(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),
emptyList(),
) )
val actual = recipeDao.queryFullRecipeInfo("1")?.recipeIngredients 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) assertThat(actual).isEqualTo(expected)
} }
@@ -98,14 +112,19 @@ internal class RecipeStorageImplTest : HiltRobolectricTest() {
CAKE_RECIPE_ENTITY, CAKE_RECIPE_ENTITY,
listOf(CAKE_SUGAR_RECIPE_INGREDIENT_ENTITY, CAKE_BREAD_RECIPE_INGREDIENT_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
),
) )
subject.saveRecipeInfo( subject.saveRecipeInfo(
CAKE_RECIPE_ENTITY, CAKE_RECIPE_ENTITY,
listOf(CAKE_SUGAR_RECIPE_INGREDIENT_ENTITY, CAKE_BREAD_RECIPE_INGREDIENT_ENTITY), listOf(CAKE_SUGAR_RECIPE_INGREDIENT_ENTITY, CAKE_BREAD_RECIPE_INGREDIENT_ENTITY),
listOf(MIX_CAKE_RECIPE_INSTRUCTION_ENTITY), listOf(MIX_CAKE_RECIPE_INSTRUCTION_ENTITY),
emptyList(),
) )
val actual = recipeDao.queryFullRecipeInfo("1")?.recipeInstructions 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) assertThat(actual).isEqualTo(expected)
} }
} }

View File

@@ -2,6 +2,7 @@ package gq.kirmanak.mealient.database
import gq.kirmanak.mealient.database.recipe.entity.RecipeEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeEntity
import gq.kirmanak.mealient.database.recipe.entity.RecipeIngredientEntity 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.RecipeInstructionEntity
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.database.recipe.entity.RecipeWithSummaryAndIngredientsAndInstructions 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) listOf(CAKE_RECIPE_SUMMARY_ENTITY, PORRIDGE_RECIPE_SUMMARY_ENTITY)
val MIX_CAKE_RECIPE_INSTRUCTION_ENTITY = RecipeInstructionEntity( val MIX_CAKE_RECIPE_INSTRUCTION_ENTITY = RecipeInstructionEntity(
id = "1",
recipeId = "1", recipeId = "1",
text = "Mix the ingredients", text = "Mix the ingredients",
title = "",
) )
val BAKE_CAKE_RECIPE_INSTRUCTION_ENTITY = RecipeInstructionEntity( val BAKE_CAKE_RECIPE_INSTRUCTION_ENTITY = RecipeInstructionEntity(
id = "2",
recipeId = "1", recipeId = "1",
text = "Bake the ingredients", 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( val CAKE_RECIPE_ENTITY = RecipeEntity(
@@ -50,20 +67,24 @@ val CAKE_RECIPE_ENTITY = RecipeEntity(
) )
val CAKE_SUGAR_RECIPE_INGREDIENT_ENTITY = RecipeIngredientEntity( val CAKE_SUGAR_RECIPE_INGREDIENT_ENTITY = RecipeIngredientEntity(
id = "1",
recipeId = "1", recipeId = "1",
note = "2 oz of white sugar", note = "2 oz of white sugar",
quantity = 1.0, quantity = 1.0,
unit = null, unit = null,
food = null, food = null,
title = null, display = "2 oz of white sugar",
title = "Sugar",
) )
val CAKE_BREAD_RECIPE_INGREDIENT_ENTITY = RecipeIngredientEntity( val CAKE_BREAD_RECIPE_INGREDIENT_ENTITY = RecipeIngredientEntity(
id = "3",
recipeId = "1", recipeId = "1",
note = "2 oz of white bread", note = "2 oz of white bread",
quantity = 1.0, quantity = 1.0,
unit = null, unit = null,
food = null, food = null,
display = "2 oz of white bread",
title = null, title = null,
) )
@@ -78,6 +99,10 @@ val FULL_CAKE_INFO_ENTITY = RecipeWithSummaryAndIngredientsAndInstructions(
MIX_CAKE_RECIPE_INSTRUCTION_ENTITY, MIX_CAKE_RECIPE_INSTRUCTION_ENTITY,
BAKE_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( val PORRIDGE_RECIPE_ENTITY_FULL = RecipeEntity(
@@ -87,31 +112,39 @@ val PORRIDGE_RECIPE_ENTITY_FULL = RecipeEntity(
) )
val PORRIDGE_MILK_RECIPE_INGREDIENT_ENTITY = RecipeIngredientEntity( val PORRIDGE_MILK_RECIPE_INGREDIENT_ENTITY = RecipeIngredientEntity(
id = "1",
recipeId = "2", recipeId = "2",
note = "2 oz of white milk", note = "2 oz of white milk",
quantity = 1.0, quantity = 1.0,
unit = null, unit = null,
food = null, food = null,
display = "2 oz of white milk",
title = null, title = null,
) )
val PORRIDGE_SUGAR_RECIPE_INGREDIENT_ENTITY = RecipeIngredientEntity( val PORRIDGE_SUGAR_RECIPE_INGREDIENT_ENTITY = RecipeIngredientEntity(
id = "2",
recipeId = "2", recipeId = "2",
note = "2 oz of white sugar", note = "2 oz of white sugar",
quantity = 1.0, quantity = 1.0,
unit = null, unit = null,
food = null, food = null,
title = null, display = "2 oz of white sugar",
title = "Sugar",
) )
val PORRIDGE_MIX_RECIPE_INSTRUCTION_ENTITY = RecipeInstructionEntity( val PORRIDGE_MIX_RECIPE_INSTRUCTION_ENTITY = RecipeInstructionEntity(
id = "1",
recipeId = "2", recipeId = "2",
text = "Mix the ingredients" text = "Mix the ingredients",
title = "",
) )
val PORRIDGE_BOIL_RECIPE_INSTRUCTION_ENTITY = RecipeInstructionEntity( val PORRIDGE_BOIL_RECIPE_INSTRUCTION_ENTITY = RecipeInstructionEntity(
id = "2",
recipeId = "2", recipeId = "2",
text = "Boil the ingredients" text = "Boil the ingredients",
title = "",
) )
val FULL_PORRIDGE_INFO_ENTITY = RecipeWithSummaryAndIngredientsAndInstructions( val FULL_PORRIDGE_INFO_ENTITY = RecipeWithSummaryAndIngredientsAndInstructions(
@@ -124,5 +157,6 @@ val FULL_PORRIDGE_INFO_ENTITY = RecipeWithSummaryAndIngredientsAndInstructions(
recipeInstructions = listOf( recipeInstructions = listOf(
PORRIDGE_MIX_RECIPE_INSTRUCTION_ENTITY, PORRIDGE_MIX_RECIPE_INSTRUCTION_ENTITY,
PORRIDGE_BOIL_RECIPE_INSTRUCTION_ENTITY, PORRIDGE_BOIL_RECIPE_INSTRUCTION_ENTITY,
) ),
recipeIngredientToInstructionEntity = emptyList(),
) )

View File

@@ -8,8 +8,8 @@ data class GetRecipeResponse(
@SerialName("id") val remoteId: String, @SerialName("id") val remoteId: String,
@SerialName("name") val name: String, @SerialName("name") val name: String,
@SerialName("recipeYield") val recipeYield: String = "", @SerialName("recipeYield") val recipeYield: String = "",
@SerialName("recipeIngredient") val recipeIngredients: List<GetRecipeIngredientResponse> = emptyList(), @SerialName("recipeIngredient") val ingredients: List<GetRecipeIngredientResponse> = emptyList(),
@SerialName("recipeInstructions") val recipeInstructions: List<GetRecipeInstructionResponse> = emptyList(), @SerialName("recipeInstructions") val instructions: List<GetRecipeInstructionResponse> = emptyList(),
@SerialName("settings") val settings: GetRecipeSettingsResponse? = null, @SerialName("settings") val settings: GetRecipeSettingsResponse? = null,
) )
@@ -24,10 +24,20 @@ data class GetRecipeIngredientResponse(
@SerialName("unit") val unit: GetUnitResponse?, @SerialName("unit") val unit: GetUnitResponse?,
@SerialName("food") val food: GetFoodResponse?, @SerialName("food") val food: GetFoodResponse?,
@SerialName("quantity") val quantity: Double?, @SerialName("quantity") val quantity: Double?,
@SerialName("display") val display: String,
@SerialName("referenceId") val referenceId: String,
@SerialName("title") val title: String?, @SerialName("title") val title: String?,
) )
@Serializable @Serializable
data class GetRecipeInstructionResponse( data class GetRecipeInstructionResponse(
@SerialName("id") val id: String,
@SerialName("title") val title: String = "",
@SerialName("text") val text: String, @SerialName("text") val text: String,
@SerialName("ingredientReferences") val ingredientReferences: List<GetRecipeInstructionIngredientReference> = emptyList(),
)
@Serializable
data class GetRecipeInstructionIngredientReference(
@SerialName("referenceId") val referenceId: String,
) )

View File

@@ -9,6 +9,7 @@ import gq.kirmanak.mealient.datasource.models.AddRecipeSettings
import gq.kirmanak.mealient.datasource.models.AddRecipeSettingsInfo import gq.kirmanak.mealient.datasource.models.AddRecipeSettingsInfo
import gq.kirmanak.mealient.datasource.models.CreateRecipeRequest import gq.kirmanak.mealient.datasource.models.CreateRecipeRequest
import gq.kirmanak.mealient.datasource.models.GetRecipeIngredientResponse 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.GetRecipeInstructionResponse
import gq.kirmanak.mealient.datasource.models.GetRecipeResponse import gq.kirmanak.mealient.datasource.models.GetRecipeResponse
import gq.kirmanak.mealient.datasource.models.GetRecipeSettingsResponse import gq.kirmanak.mealient.datasource.models.GetRecipeSettingsResponse
@@ -76,6 +77,8 @@ val MILK_RECIPE_INGREDIENT_RESPONSE = GetRecipeIngredientResponse(
quantity = 1.0, quantity = 1.0,
unit = null, unit = null,
food = null, food = null,
display = "2 oz of white milk",
referenceId = "1",
title = null, title = null,
) )
@@ -84,7 +87,9 @@ val SUGAR_RECIPE_INGREDIENT_RESPONSE = GetRecipeIngredientResponse(
quantity = 1.0, quantity = 1.0,
unit = null, unit = null,
food = null, food = null,
title = null, display = "2 oz of white sugar",
referenceId = "1",
title = "Sugar",
) )
val BREAD_RECIPE_INGREDIENT_RESPONSE = GetRecipeIngredientResponse( val BREAD_RECIPE_INGREDIENT_RESPONSE = GetRecipeIngredientResponse(
@@ -92,14 +97,34 @@ val BREAD_RECIPE_INGREDIENT_RESPONSE = GetRecipeIngredientResponse(
quantity = 1.0, quantity = 1.0,
unit = null, unit = null,
food = null, food = null,
display = "2 oz of white bread",
referenceId = "3",
title = null, 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) val NO_AMOUNT_RECIPE_SETTINGS_RESPONSE = GetRecipeSettingsResponse(disableAmount = true)
@@ -107,8 +132,8 @@ val CAKE_RECIPE_RESPONSE = GetRecipeResponse(
remoteId = "1", remoteId = "1",
name = "Cake", name = "Cake",
recipeYield = "4 servings", recipeYield = "4 servings",
recipeIngredients = listOf(SUGAR_RECIPE_INGREDIENT_RESPONSE, BREAD_RECIPE_INGREDIENT_RESPONSE), ingredients = listOf(SUGAR_RECIPE_INGREDIENT_RESPONSE, BREAD_RECIPE_INGREDIENT_RESPONSE),
recipeInstructions = listOf(MIX_RECIPE_INSTRUCTION_RESPONSE, BAKE_RECIPE_INSTRUCTION_RESPONSE), instructions = listOf(MIX_RECIPE_INSTRUCTION_RESPONSE, BAKE_RECIPE_INSTRUCTION_RESPONSE),
settings = NO_AMOUNT_RECIPE_SETTINGS_RESPONSE, settings = NO_AMOUNT_RECIPE_SETTINGS_RESPONSE,
) )
@@ -116,11 +141,11 @@ val PORRIDGE_RECIPE_RESPONSE = GetRecipeResponse(
remoteId = "2", remoteId = "2",
recipeYield = "3 servings", recipeYield = "3 servings",
name = "Porridge", name = "Porridge",
recipeIngredients = listOf( ingredients = listOf(
SUGAR_RECIPE_INGREDIENT_RESPONSE, SUGAR_RECIPE_INGREDIENT_RESPONSE,
MILK_RECIPE_INGREDIENT_RESPONSE, MILK_RECIPE_INGREDIENT_RESPONSE,
), ),
recipeInstructions = listOf( instructions = listOf(
MIX_RECIPE_INSTRUCTION_RESPONSE, MIX_RECIPE_INSTRUCTION_RESPONSE,
BOIL_RECIPE_INSTRUCTION_RESPONSE BOIL_RECIPE_INSTRUCTION_RESPONSE
), ),

View File

@@ -52,8 +52,6 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.Destination
import gq.kirmanak.mealient.AppTheme
import gq.kirmanak.mealient.Dimens
import gq.kirmanak.mealient.datasource.models.GetFoodResponse import gq.kirmanak.mealient.datasource.models.GetFoodResponse
import gq.kirmanak.mealient.datasource.models.GetShoppingListItemRecipeReferenceResponse import gq.kirmanak.mealient.datasource.models.GetShoppingListItemRecipeReferenceResponse
import gq.kirmanak.mealient.datasource.models.GetShoppingListItemResponse 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.ui.composables.LazyColumnWithLoadingState
import gq.kirmanak.mealient.shopping_lists.util.data import gq.kirmanak.mealient.shopping_lists.util.data
import gq.kirmanak.mealient.shopping_lists.util.map 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 kotlinx.coroutines.android.awaitFrame
import java.text.DecimalFormat import java.text.DecimalFormat

View File

@@ -8,8 +8,8 @@ import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.AppTheme
import gq.kirmanak.mealient.ui.ActivityUiStateController import gq.kirmanak.mealient.ui.ActivityUiStateController
import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.CheckableMenuItem import gq.kirmanak.mealient.ui.CheckableMenuItem
import javax.inject.Inject import javax.inject.Inject

View File

@@ -20,12 +20,12 @@ import androidx.hilt.navigation.compose.hiltViewModel
import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootNavGraph import com.ramcosta.composedestinations.annotation.RootNavGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import gq.kirmanak.mealient.AppTheme
import gq.kirmanak.mealient.Dimens
import gq.kirmanak.mealient.datasource.models.GetShoppingListsSummaryResponse import gq.kirmanak.mealient.datasource.models.GetShoppingListsSummaryResponse
import gq.kirmanak.mealient.shopping_list.R import gq.kirmanak.mealient.shopping_list.R
import gq.kirmanak.mealient.shopping_lists.ui.composables.LazyColumnWithLoadingState import gq.kirmanak.mealient.shopping_lists.ui.composables.LazyColumnWithLoadingState
import gq.kirmanak.mealient.shopping_lists.ui.destinations.ShoppingListScreenDestination import gq.kirmanak.mealient.shopping_lists.ui.destinations.ShoppingListScreenDestination
import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.Dimens
@RootNavGraph(start = true) @RootNavGraph(start = true)
@Destination(start = true) @Destination(start = true)

View File

@@ -7,7 +7,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import gq.kirmanak.mealient.AppTheme import gq.kirmanak.mealient.ui.AppTheme
@Composable @Composable
fun CenteredProgressIndicator( fun CenteredProgressIndicator(

View File

@@ -7,7 +7,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import gq.kirmanak.mealient.AppTheme import gq.kirmanak.mealient.ui.AppTheme
@Composable @Composable
fun CenteredText( fun CenteredText(

View File

@@ -10,9 +10,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import 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.shopping_list.R
import gq.kirmanak.mealient.ui.AppTheme
import gq.kirmanak.mealient.ui.Dimens
@Composable @Composable
fun EmptyListError( fun EmptyListError(

View File

@@ -91,6 +91,8 @@ composeDestinations = "1.9.54"
hiltNavigationCompose = "1.0.0" hiltNavigationCompose = "1.0.0"
# https://github.com/ktorio/ktor/releases # https://github.com/ktorio/ktor/releases
ktor = "2.3.5" ktor = "2.3.5"
# https://github.com/coil-kt/coil/releases
coil = "2.5.0"
[libraries] [libraries]
android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } 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-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" } 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] [plugins]
sonarqube = { id = "org.sonarqube", version.ref = "sonarqube" } sonarqube = { id = "org.sonarqube", version.ref = "sonarqube" }
appsweep = { id = "com.guardsquare.appsweep", version.ref = "appsweep" } appsweep = { id = "com.guardsquare.appsweep", version.ref = "appsweep" }

View File

@@ -24,11 +24,11 @@ interface ModelMapper {
fun toRecipeEntity(getRecipeResponse: GetRecipeResponse): RecipeEntity fun toRecipeEntity(getRecipeResponse: GetRecipeResponse): RecipeEntity
fun toRecipeIngredientEntity( fun toRecipeIngredientEntity(
ingredientResponse: GetRecipeIngredientResponse, remoteId: String ingredientResponse: GetRecipeIngredientResponse, recipeId: String
): RecipeIngredientEntity ): RecipeIngredientEntity
fun toRecipeInstructionEntity( fun toRecipeInstructionEntity(
instructionResponse: GetRecipeInstructionResponse, remoteId: String instructionResponse: GetRecipeInstructionResponse, recipeId: String
): RecipeInstructionEntity ): RecipeInstructionEntity
fun toRecipeSummaryEntity( fun toRecipeSummaryEntity(

View File

@@ -32,22 +32,26 @@ class ModelMapperImpl @Inject constructor() : ModelMapper {
override fun toRecipeIngredientEntity( override fun toRecipeIngredientEntity(
ingredientResponse: GetRecipeIngredientResponse, ingredientResponse: GetRecipeIngredientResponse,
remoteId: String recipeId: String
) = RecipeIngredientEntity( ) = RecipeIngredientEntity(
recipeId = remoteId, id = ingredientResponse.referenceId,
recipeId = recipeId,
note = ingredientResponse.note, note = ingredientResponse.note,
unit = ingredientResponse.unit?.name, unit = ingredientResponse.unit?.name,
food = ingredientResponse.food?.name, food = ingredientResponse.food?.name,
quantity = ingredientResponse.quantity, quantity = ingredientResponse.quantity,
display = ingredientResponse.display,
title = ingredientResponse.title, title = ingredientResponse.title,
) )
override fun toRecipeInstructionEntity( override fun toRecipeInstructionEntity(
instructionResponse: GetRecipeInstructionResponse, instructionResponse: GetRecipeInstructionResponse,
remoteId: String recipeId: String
) = RecipeInstructionEntity( ) = RecipeInstructionEntity(
recipeId = remoteId, id = instructionResponse.id,
recipeId = recipeId,
text = instructionResponse.text, text = instructionResponse.text,
title = instructionResponse.title,
) )
override fun toRecipeSummaryEntity( override fun toRecipeSummaryEntity(

View File

@@ -1,5 +1,7 @@
plugins { plugins {
id("gq.kirmanak.mealient.library") id("gq.kirmanak.mealient.library")
alias(libs.plugins.ksp)
id("gq.kirmanak.mealient.compose")
id("kotlin-kapt") id("kotlin-kapt")
id("dagger.hilt.android.plugin") id("dagger.hilt.android.plugin")
} }
@@ -14,6 +16,8 @@ dependencies {
kaptTest(libs.google.dagger.hiltAndroidCompiler) kaptTest(libs.google.dagger.hiltAndroidCompiler)
testImplementation(libs.google.dagger.hiltAndroidTesting) testImplementation(libs.google.dagger.hiltAndroidTesting)
implementation(libs.android.material.material)
testImplementation(libs.androidx.test.junit) testImplementation(libs.androidx.test.junit)
testImplementation(libs.google.truth) testImplementation(libs.google.truth)

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient package gq.kirmanak.mealient.ui
import android.os.Build import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@@ -40,6 +40,8 @@ object Dimens {
val Small = 8.dp val Small = 8.dp
val Intermediate = 12.dp
val Medium = 16.dp val Medium = 16.dp
val Large = 24.dp val Large = 24.dp