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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,35 @@
package gq.kirmanak.mealient.database.recipe.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
@Entity(
tableName = "recipe_ingredient_to_instruction",
foreignKeys = [
ForeignKey(
entity = RecipeEntity::class,
parentColumns = ["recipe_id"],
childColumns = ["recipe_id"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = RecipeIngredientEntity::class,
parentColumns = ["recipe_ingredient_id"],
childColumns = ["ingredient_id"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = RecipeInstructionEntity::class,
parentColumns = ["recipe_instruction_id"],
childColumns = ["instruction_id"],
onDelete = ForeignKey.CASCADE
),
],
primaryKeys = ["recipe_id", "ingredient_id", "instruction_id"]
)
data class RecipeIngredientToInstructionEntity(
@ColumnInfo(name = "recipe_id", index = true) val recipeId: String,
@ColumnInfo(name = "ingredient_id", index = true) val ingredientId: String,
@ColumnInfo(name = "instruction_id", index = true) val instructionId: String,
)

View File

@@ -17,25 +17,8 @@ import androidx.room.PrimaryKey
]
)
data class RecipeInstructionEntity(
@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?,
)

View File

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

View File

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

View File

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