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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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() }
|
||||
|
||||
@@ -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<Unit> {
|
||||
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" }
|
||||
}
|
||||
|
||||
@@ -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<Preferences> =
|
||||
PreferenceDataStoreFactory.create { context.preferencesDataStoreFile("settings") }
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCoilImageLoader(
|
||||
@ApplicationContext context: Context,
|
||||
okHttpClient: OkHttpClient,
|
||||
): ImageLoader {
|
||||
return ImageLoader.Builder(context)
|
||||
.okHttpClient(okHttpClient)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@Binds
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<RecipeInfoViewModel>()
|
||||
private lateinit var ingredientsAdapter: RecipeIngredientsAdapter
|
||||
|
||||
@Inject
|
||||
lateinit var instructionsAdapter: RecipeInstructionsAdapter
|
||||
|
||||
@Inject
|
||||
lateinit var recipeIngredientsAdapterFactory: RecipeIngredientsAdapter.Factory
|
||||
private val activityViewModel by activityViewModels<MainActivityViewModel>()
|
||||
|
||||
@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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,9 @@ data class RecipeInfoUiState(
|
||||
val showInstructions: Boolean = false,
|
||||
val summaryEntity: RecipeSummaryEntity? = null,
|
||||
val recipeIngredients: List<RecipeIngredientEntity> = emptyList(),
|
||||
val recipeInstructions: List<RecipeInstructionEntity> = emptyList(),
|
||||
val recipeInstructions: Map<RecipeInstructionEntity, List<RecipeIngredientEntity>> = emptyMap(),
|
||||
val title: String? = null,
|
||||
val description: String? = null,
|
||||
val disableAmounts: Boolean = true,
|
||||
val imageUrl: String? = null,
|
||||
)
|
||||
|
||||
@@ -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<RecipeInfoUiState> = 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<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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -18,18 +18,21 @@
|
||||
tools:layout="@layout/fragment_recipes_list">
|
||||
<action
|
||||
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>
|
||||
|
||||
<dialog
|
||||
<fragment
|
||||
android:id="@+id/recipeInfoFragment"
|
||||
android:name="gq.kirmanak.mealient.ui.recipes.info.RecipeInfoFragment"
|
||||
android:label="RecipeInfoFragment"
|
||||
tools:layout="@layout/fragment_recipe_info">
|
||||
android:label="RecipeInfoFragment">
|
||||
<argument
|
||||
android:name="recipe_id"
|
||||
app:argType="string" />
|
||||
</dialog>
|
||||
</fragment>
|
||||
|
||||
<fragment
|
||||
android:id="@+id/disclaimerFragment"
|
||||
|
||||
@@ -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
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -23,5 +23,9 @@ gradlePlugin {
|
||||
id = "gq.kirmanak.mealient.compose"
|
||||
implementationClass = "AndroidLibraryComposeConventionPlugin"
|
||||
}
|
||||
register("appCompose") {
|
||||
id = "gq.kirmanak.mealient.compose.app"
|
||||
implementationClass = "AndroidApplicationComposeConventionPlugin"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -35,13 +35,17 @@ internal interface RecipeDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertRecipeIngredients(ingredients: List<RecipeIngredientEntity>)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertIngredientToInstructionEntities(entities: List<RecipeIngredientToInstructionEntity>)
|
||||
|
||||
@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<String>)
|
||||
|
||||
|
||||
@@ -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<RecipeIngredientEntity>,
|
||||
instructions: List<RecipeInstructionEntity>
|
||||
instructions: List<RecipeInstructionEntity>,
|
||||
ingredientToInstruction: List<RecipeIngredientToInstructionEntity>,
|
||||
)
|
||||
|
||||
suspend fun queryRecipeInfo(recipeId: String): RecipeWithSummaryAndIngredientsAndInstructions?
|
||||
|
||||
@@ -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<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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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?,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -20,4 +20,9 @@ data class RecipeWithSummaryAndIngredientsAndInstructions(
|
||||
entityColumn = "recipe_id"
|
||||
)
|
||||
val recipeInstructions: List<RecipeInstructionEntity>,
|
||||
@Relation(
|
||||
parentColumn = "recipe_id",
|
||||
entityColumn = "recipe_id"
|
||||
)
|
||||
val recipeIngredientToInstructionEntity: List<RecipeIngredientToInstructionEntity>,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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<GetRecipeIngredientResponse> = emptyList(),
|
||||
@SerialName("recipeInstructions") val recipeInstructions: List<GetRecipeInstructionResponse> = emptyList(),
|
||||
@SerialName("recipeIngredient") val ingredients: List<GetRecipeIngredientResponse> = emptyList(),
|
||||
@SerialName("recipeInstructions") val instructions: List<GetRecipeInstructionResponse> = 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<GetRecipeInstructionIngredientReference> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GetRecipeInstructionIngredientReference(
|
||||
@SerialName("referenceId") val referenceId: String,
|
||||
)
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user