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")
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)

View File

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

View File

@@ -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?
}

View File

@@ -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() }

View File

@@ -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" }
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
}
}
}
}

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

@@ -1,94 +1,97 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation 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:id="@+id/nav_graph"
tools:ignore="InvalidNavigation">
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/nav_graph"
tools:ignore="InvalidNavigation">
<fragment
android:id="@+id/authenticationFragment"
android:name="gq.kirmanak.mealient.ui.auth.AuthenticationFragment"
android:label="AuthenticationFragment"
tools:layout="@layout/fragment_authentication" />
<fragment
android:id="@+id/authenticationFragment"
android:name="gq.kirmanak.mealient.ui.auth.AuthenticationFragment"
android:label="AuthenticationFragment"
tools:layout="@layout/fragment_authentication" />
<fragment
android:id="@+id/recipesListFragment"
android:name="gq.kirmanak.mealient.ui.recipes.RecipesListFragment"
android:label="fragment_recipes"
tools:layout="@layout/fragment_recipes_list">
<action
android:id="@+id/action_recipesFragment_to_recipeInfoFragment"
app:destination="@id/recipeInfoFragment" />
</fragment>
<fragment
android:id="@+id/recipesListFragment"
android:name="gq.kirmanak.mealient.ui.recipes.RecipesListFragment"
android:label="fragment_recipes"
tools:layout="@layout/fragment_recipes_list">
<action
android:id="@+id/action_recipesFragment_to_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
android:id="@+id/recipeInfoFragment"
android:name="gq.kirmanak.mealient.ui.recipes.info.RecipeInfoFragment"
android:label="RecipeInfoFragment"
tools:layout="@layout/fragment_recipe_info">
<argument
android:name="recipe_id"
app:argType="string" />
</dialog>
<fragment
android:id="@+id/recipeInfoFragment"
android:name="gq.kirmanak.mealient.ui.recipes.info.RecipeInfoFragment"
android:label="RecipeInfoFragment">
<argument
android:name="recipe_id"
app:argType="string" />
</fragment>
<fragment
android:id="@+id/disclaimerFragment"
android:name="gq.kirmanak.mealient.ui.disclaimer.DisclaimerFragment"
android:label="DisclaimerFragment"
tools:layout="@layout/fragment_disclaimer">
<action
android:id="@+id/action_disclaimerFragment_to_baseURLFragment"
app:destination="@id/baseURLFragment"
app:popUpTo="@id/nav_graph"
app:popUpToInclusive="true" />
</fragment>
<fragment
android:id="@+id/disclaimerFragment"
android:name="gq.kirmanak.mealient.ui.disclaimer.DisclaimerFragment"
android:label="DisclaimerFragment"
tools:layout="@layout/fragment_disclaimer">
<action
android:id="@+id/action_disclaimerFragment_to_baseURLFragment"
app:destination="@id/baseURLFragment"
app:popUpTo="@id/nav_graph"
app:popUpToInclusive="true" />
</fragment>
<fragment
android:id="@+id/baseURLFragment"
android:name="gq.kirmanak.mealient.ui.baseurl.BaseURLFragment"
android:label="fragment_base_url"
tools:layout="@layout/fragment_base_url">
<action
android:id="@+id/action_baseURLFragment_to_recipesListFragment"
app:destination="@id/recipesListFragment"
app:popUpTo="@id/nav_graph" />
<argument
android:name="isOnboarding"
app:argType="boolean" />
</fragment>
<fragment
android:id="@+id/baseURLFragment"
android:name="gq.kirmanak.mealient.ui.baseurl.BaseURLFragment"
android:label="fragment_base_url"
tools:layout="@layout/fragment_base_url">
<action
android:id="@+id/action_baseURLFragment_to_recipesListFragment"
app:destination="@id/recipesListFragment"
app:popUpTo="@id/nav_graph" />
<argument
android:name="isOnboarding"
app:argType="boolean" />
</fragment>
<fragment
android:id="@+id/addRecipeFragment"
android:name="gq.kirmanak.mealient.ui.add.AddRecipeFragment"
android:label="fragment_add_recipe"
tools:layout="@layout/fragment_add_recipe" />
<fragment
android:id="@+id/addRecipeFragment"
android:name="gq.kirmanak.mealient.ui.add.AddRecipeFragment"
android:label="fragment_add_recipe"
tools:layout="@layout/fragment_add_recipe" />
<fragment
android:id="@+id/shoppingListsFragment"
android:name="gq.kirmanak.mealient.shopping_lists.ui.ShoppingListsFragment" />
<fragment
android:id="@+id/shoppingListsFragment"
android:name="gq.kirmanak.mealient.shopping_lists.ui.ShoppingListsFragment" />
<action
android:id="@+id/action_global_authenticationFragment"
app:destination="@id/authenticationFragment" />
<action
android:id="@+id/action_global_authenticationFragment"
app:destination="@id/authenticationFragment" />
<action
android:id="@+id/action_global_recipesListFragment"
app:destination="@id/recipesListFragment"
app:popUpTo="@id/nav_graph" />
<action
android:id="@+id/action_global_recipesListFragment"
app:destination="@id/recipesListFragment"
app:popUpTo="@id/nav_graph" />
<action
android:id="@+id/action_global_addRecipeFragment"
app:destination="@id/addRecipeFragment"
app:popUpTo="@id/recipesListFragment" />
<action
android:id="@+id/action_global_addRecipeFragment"
app:destination="@id/addRecipeFragment"
app:popUpTo="@id/recipesListFragment" />
<action
android:id="@+id/action_global_baseURLFragment"
app:destination="@id/baseURLFragment"
app:popUpTo="@id/recipesListFragment" />
<action
android:id="@+id/action_global_baseURLFragment"
app:destination="@id/baseURLFragment"
app:popUpTo="@id/recipesListFragment" />
<action
android:id="@+id/action_global_shoppingListsFragment"
app:destination="@id/shoppingListsFragment"
app:popUpTo="@id/recipesListFragment" />
<action
android:id="@+id/action_global_shoppingListsFragment"
app:destination="@id/shoppingListsFragment"
app:popUpTo="@id/recipesListFragment" />
</navigation>

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_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
)
),
)
}
}

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
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 {