Implement opening of recipe info card

This commit is contained in:
Kirill Kamakin
2021-11-17 22:59:01 +03:00
parent 7ebe89adfc
commit a67a3a5de0
17 changed files with 222 additions and 22 deletions

View File

@@ -10,7 +10,7 @@ import javax.inject.Singleton
@Database(
version = 1,
entities = [CategoryEntity::class, CategoryRecipeEntity::class, TagEntity::class, TagRecipeEntity::class, RecipeSummaryEntity::class],
entities = [CategoryEntity::class, CategoryRecipeEntity::class, TagEntity::class, TagRecipeEntity::class, RecipeSummaryEntity::class, RecipeEntity::class, RecipeIngredientEntity::class, RecipeInstructionEntity::class],
exportSchema = false
)
@TypeConverters(RoomTypeConverters::class)

View File

@@ -10,7 +10,12 @@ import javax.inject.Inject
@ExperimentalSerializationApi
class RetrofitBuilder @Inject constructor(private val okHttpBuilder: OkHttpBuilder) {
private val json by lazy { Json { coerceInputValues = true } }
private val json by lazy {
Json {
coerceInputValues = true
ignoreUnknownKeys = true
}
}
fun buildRetrofit(baseUrl: String, token: String? = null): Retrofit {
Timber.v("buildRetrofit() called with: baseUrl = $baseUrl, token = $token")

View File

@@ -2,9 +2,12 @@ package gq.kirmanak.mealie.data.recipes
import androidx.paging.Pager
import gq.kirmanak.mealie.data.recipes.db.entity.RecipeSummaryEntity
import gq.kirmanak.mealie.data.recipes.impl.FullRecipeInfo
interface RecipeRepo {
fun createPager(): Pager<Int, RecipeSummaryEntity>
suspend fun clearLocalData()
suspend fun loadRecipeInfo(recipeId: Long, recipeSlug: String): FullRecipeInfo
}

View File

@@ -1,11 +1,9 @@
package gq.kirmanak.mealie.data.recipes.db
import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.*
import gq.kirmanak.mealie.data.recipes.db.entity.*
import gq.kirmanak.mealie.data.recipes.impl.FullRecipeInfo
@Dao
interface RecipeDao {
@@ -57,12 +55,16 @@ interface RecipeDao {
@Query("SELECT * FROM tag_recipe")
suspend fun queryAllTagRecipes(): List<TagRecipeEntity>
@Insert
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertRecipe(recipe: RecipeEntity)
@Insert
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertRecipeInstructions(instructions: List<RecipeInstructionEntity>)
@Insert
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertRecipeIngredients(ingredients: List<RecipeIngredientEntity>)
@Transaction
@Query("SELECT * FROM recipe JOIN recipe_summaries ON recipe.remote_id = recipe_summaries.remote_id JOIN recipe_ingredient ON recipe_ingredient.recipe_id = recipe.remote_id JOIN recipe_instruction ON recipe_instruction.recipe_id = recipe.remote_id WHERE recipe.remote_id = :recipeId")
suspend fun queryFullRecipeInfo(recipeId: Long): FullRecipeInfo?
}

View File

@@ -2,6 +2,7 @@ package gq.kirmanak.mealie.data.recipes.db
import androidx.paging.PagingSource
import gq.kirmanak.mealie.data.recipes.db.entity.RecipeSummaryEntity
import gq.kirmanak.mealie.data.recipes.impl.FullRecipeInfo
import gq.kirmanak.mealie.data.recipes.network.response.GetRecipeResponse
import gq.kirmanak.mealie.data.recipes.network.response.GetRecipeSummaryResponse
@@ -15,4 +16,6 @@ interface RecipeStorage {
suspend fun clearAllLocalData()
suspend fun saveRecipeInfo(recipe: GetRecipeResponse)
suspend fun queryRecipeInfo(recipeId: Long): FullRecipeInfo
}

View File

@@ -4,6 +4,7 @@ import androidx.paging.PagingSource
import androidx.room.withTransaction
import gq.kirmanak.mealie.data.MealieDb
import gq.kirmanak.mealie.data.recipes.db.entity.*
import gq.kirmanak.mealie.data.recipes.impl.FullRecipeInfo
import gq.kirmanak.mealie.data.recipes.network.response.GetRecipeIngredientResponse
import gq.kirmanak.mealie.data.recipes.network.response.GetRecipeInstructionResponse
import gq.kirmanak.mealie.data.recipes.network.response.GetRecipeResponse
@@ -154,4 +155,13 @@ class RecipeStorageImpl @Inject constructor(
title = title,
text = text
)
override suspend fun queryRecipeInfo(recipeId: Long): FullRecipeInfo {
Timber.v("queryRecipeInfo() called with: recipeId = $recipeId")
val fullRecipeInfo = checkNotNull(recipeDao.queryFullRecipeInfo(recipeId)) {
"Can't find recipe by id $recipeId in DB"
}
Timber.v("queryRecipeInfo() returned: $fullRecipeInfo")
return fullRecipeInfo
}
}

View File

@@ -6,7 +6,8 @@ import androidx.room.PrimaryKey
@Entity(tableName = "recipe_ingredient")
data class RecipeIngredientEntity(
@PrimaryKey @ColumnInfo(name = "recipe_id") val recipeId: Long,
@PrimaryKey(autoGenerate = true) @ColumnInfo(name = "local_id") val localId: Long = 0,
@ColumnInfo(name = "recipe_id") val recipeId: Long,
@ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "note") val note: String,
@ColumnInfo(name = "unit") val unit: String,

View File

@@ -6,7 +6,8 @@ import androidx.room.PrimaryKey
@Entity(tableName = "recipe_instruction")
data class RecipeInstructionEntity(
@PrimaryKey @ColumnInfo(name = "recipe_id") val recipeId: Long,
@PrimaryKey(autoGenerate = true) @ColumnInfo(name = "local_id") val localId: Long = 0,
@ColumnInfo(name = "recipe_id") val recipeId: Long,
@ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "text") val text: String,
)

View File

@@ -0,0 +1,27 @@
package gq.kirmanak.mealie.data.recipes.impl
import androidx.room.Embedded
import androidx.room.Relation
import gq.kirmanak.mealie.data.recipes.db.entity.RecipeEntity
import gq.kirmanak.mealie.data.recipes.db.entity.RecipeIngredientEntity
import gq.kirmanak.mealie.data.recipes.db.entity.RecipeInstructionEntity
import gq.kirmanak.mealie.data.recipes.db.entity.RecipeSummaryEntity
data class FullRecipeInfo(
@Embedded val recipeEntity: RecipeEntity,
@Relation(
parentColumn = "remote_id",
entityColumn = "remote_id"
)
val recipeSummaryEntity: RecipeSummaryEntity,
@Relation(
parentColumn = "remote_id",
entityColumn = "recipe_id"
)
val recipeIngredients: List<RecipeIngredientEntity>,
@Relation(
parentColumn = "remote_id",
entityColumn = "recipe_id"
)
val recipeInstructions: List<RecipeInstructionEntity>,
)

View File

@@ -6,6 +6,8 @@ import androidx.paging.PagingConfig
import gq.kirmanak.mealie.data.recipes.RecipeRepo
import gq.kirmanak.mealie.data.recipes.db.RecipeStorage
import gq.kirmanak.mealie.data.recipes.db.entity.RecipeSummaryEntity
import gq.kirmanak.mealie.data.recipes.network.RecipeDataSource
import kotlinx.coroutines.CancellationException
import timber.log.Timber
import javax.inject.Inject
@@ -13,7 +15,8 @@ import javax.inject.Inject
class RecipeRepoImpl @Inject constructor(
private val mediator: RecipesRemoteMediator,
private val storage: RecipeStorage,
private val pagingSourceFactory: RecipePagingSourceFactory
private val pagingSourceFactory: RecipePagingSourceFactory,
private val dataSource: RecipeDataSource,
) : RecipeRepo {
override fun createPager(): Pager<Int, RecipeSummaryEntity> {
Timber.v("createPager() called")
@@ -29,4 +32,18 @@ class RecipeRepoImpl @Inject constructor(
Timber.v("clearLocalData() called")
storage.clearAllLocalData()
}
override suspend fun loadRecipeInfo(recipeId: Long, recipeSlug: String): FullRecipeInfo {
Timber.v("loadRecipeInfo() called with: recipeId = $recipeId, recipeSlug = $recipeSlug")
try {
storage.saveRecipeInfo(dataSource.requestRecipeInfo(recipeSlug))
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
Timber.e(e, "loadRecipeInfo: can't update full recipe info")
}
return storage.queryRecipeInfo(recipeId)
}
}

View File

@@ -13,7 +13,7 @@ interface RecipeService {
@Query("limit") limit: Int
): List<GetRecipeSummaryResponse>
@GET("/api/recipes/:recipe_slug")
@GET("/api/recipes/{recipe_slug}")
suspend fun getRecipe(
@Path("recipe_slug") recipeSlug: String
): GetRecipeResponse

View File

@@ -7,7 +7,8 @@ import gq.kirmanak.mealie.databinding.ViewHolderRecipeBinding
class RecipeViewHolder(
private val binding: ViewHolderRecipeBinding,
private val recipeViewModel: RecipeViewModel
private val recipeViewModel: RecipeViewModel,
private val clickListener: (RecipeSummaryEntity) -> Unit
) : RecyclerView.ViewHolder(binding.root) {
private val loadingPlaceholder by lazy {
binding.root.resources.getString(R.string.view_holder_recipe_text_placeholder)
@@ -16,5 +17,6 @@ class RecipeViewHolder(
fun bind(item: RecipeSummaryEntity?) {
binding.name.text = item?.name ?: loadingPlaceholder
recipeViewModel.loadRecipeImage(binding.image, item)
item?.let { entity -> binding.root.setOnClickListener { clickListener(entity) } }
}
}

View File

@@ -10,6 +10,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealie.data.recipes.db.entity.RecipeSummaryEntity
import gq.kirmanak.mealie.databinding.FragmentRecipesBinding
import gq.kirmanak.mealie.ui.SwipeRefreshLayoutHelper.listenToRefreshRequests
import gq.kirmanak.mealie.ui.auth.AuthenticationViewModel
@@ -44,6 +45,15 @@ class RecipesFragment : Fragment() {
listenToAuthStatuses()
}
private fun navigateToRecipeInfo(recipeSummaryEntity: RecipeSummaryEntity) {
findNavController().navigate(
RecipesFragmentDirections.actionRecipesFragmentToRecipeInfoFragment(
recipeSlug = recipeSummaryEntity.slug,
recipeId = recipeSummaryEntity.remoteId
)
)
}
private fun listenToAuthStatuses() {
Timber.v("listenToAuthStatuses() called")
lifecycleScope.launchWhenCreated {
@@ -62,7 +72,7 @@ class RecipesFragment : Fragment() {
private fun setupRecipeAdapter() {
Timber.v("setupRecipeAdapter() called")
binding.recipes.layoutManager = LinearLayoutManager(requireContext())
val adapter = RecipesPagingAdapter(viewModel)
val adapter = RecipesPagingAdapter(viewModel) { navigateToRecipeInfo(it) }
binding.recipes.adapter = adapter
viewLifecycleOwner.lifecycleScope.launchWhenResumed {
viewModel.recipeFlow.collect {
@@ -79,11 +89,6 @@ class RecipesFragment : Fragment() {
binding.refresher.isRefreshing = false
}
}
viewLifecycleOwner.lifecycleScope.launchWhenResumed {
adapter.loadStateFlow.collect {
Timber.d("New load state: $it")
}
}
}
override fun onDestroyView() {

View File

@@ -9,7 +9,8 @@ import gq.kirmanak.mealie.databinding.ViewHolderRecipeBinding
import timber.log.Timber
class RecipesPagingAdapter(
private val viewModel: RecipeViewModel
private val viewModel: RecipeViewModel,
private val clickListener: (RecipeSummaryEntity) -> Unit
) : PagingDataAdapter<RecipeSummaryEntity, RecipeViewHolder>(RecipeDiffCallback) {
override fun onBindViewHolder(holder: RecipeViewHolder, position: Int) {
val item = getItem(position)
@@ -20,7 +21,7 @@ class RecipesPagingAdapter(
Timber.v("onCreateViewHolder() called with: parent = $parent, viewType = $viewType")
val inflater = LayoutInflater.from(parent.context)
val binding = ViewHolderRecipeBinding.inflate(inflater, parent, false)
return RecipeViewHolder(binding, viewModel)
return RecipeViewHolder(binding, viewModel, clickListener)
}
private object RecipeDiffCallback : DiffUtil.ItemCallback<RecipeSummaryEntity>() {

View File

@@ -1,8 +1,69 @@
package gq.kirmanak.mealie.ui.recipes.info
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealie.databinding.FragmentRecipeInfoBinding
import gq.kirmanak.mealie.ui.auth.AuthenticationViewModel
import kotlinx.coroutines.flow.collectLatest
import timber.log.Timber
@AndroidEntryPoint
class RecipeInfoFragment : Fragment() {
private var _binding: FragmentRecipeInfoBinding? = null
private val binding: FragmentRecipeInfoBinding
get() = checkNotNull(_binding) { "Binding requested when fragment is off screen" }
private val authViewModel by viewModels<AuthenticationViewModel>()
private val arguments by navArgs<RecipeInfoFragmentArgs>()
private val viewModel by viewModels<RecipeInfoViewModel>()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
Timber.v("onCreateView() called with: inflater = $inflater, container = $container, savedInstanceState = $savedInstanceState")
_binding = FragmentRecipeInfoBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
listenToAuthStatuses()
viewModel.loadRecipeImage(binding.image, arguments.recipeSlug)
viewModel.loadRecipeInfo(arguments.recipeId, arguments.recipeSlug)
viewModel.recipeInfo.observe(viewLifecycleOwner) {
binding.title.text = it.recipeSummaryEntity.name
binding.description.text = it.recipeSummaryEntity.description
}
}
private fun listenToAuthStatuses() {
Timber.v("listenToAuthStatuses() called")
lifecycleScope.launchWhenCreated {
authViewModel.authenticationStatuses().collectLatest {
Timber.v("listenToAuthStatuses: new auth status = $it")
if (!it) navigateToAuthFragment()
}
}
}
private fun navigateToAuthFragment() {
Timber.v("navigateToAuthFragment() called")
findNavController().navigate(RecipeInfoFragmentDirections.actionRecipeInfoFragmentToAuthenticationFragment())
}
override fun onDestroyView() {
super.onDestroyView()
Timber.v("onDestroyView() called")
_binding = null
}
}

View File

@@ -0,0 +1,43 @@
package gq.kirmanak.mealie.ui.recipes.info
import android.widget.ImageView
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealie.data.recipes.RecipeImageLoader
import gq.kirmanak.mealie.data.recipes.RecipeRepo
import gq.kirmanak.mealie.data.recipes.impl.FullRecipeInfo
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class RecipeInfoViewModel @Inject constructor(
private val recipeRepo: RecipeRepo,
private val recipeImageLoader: RecipeImageLoader
) : ViewModel() {
private val _recipeInfo = MutableLiveData<FullRecipeInfo>()
val recipeInfo: LiveData<FullRecipeInfo> = _recipeInfo
fun loadRecipeImage(view: ImageView, recipeSlug: String) {
viewModelScope.launch {
recipeImageLoader.loadRecipeImage(view, recipeSlug)
}
}
fun loadRecipeInfo(recipeId: Long, recipeSlug: String) {
Timber.v("loadRecipeInfo() called with: recipeId = $recipeId, recipeSlug = $recipeSlug")
viewModelScope.launch {
runCatching {
recipeRepo.loadRecipeInfo(recipeId, recipeSlug)
}.onSuccess {
Timber.d("loadRecipeInfo: received recipe info = $it")
_recipeInfo.value = it
}.onFailure {
Timber.e(it, "loadRecipeInfo: can't load recipe info")
}
}
}
}

View File

@@ -25,5 +25,24 @@
app:destination="@id/authenticationFragment"
app:popUpTo="@id/nav_graph"
app:popUpToInclusive="true" />
<action
android:id="@+id/action_recipesFragment_to_recipeInfoFragment"
app:destination="@id/recipeInfoFragment" />
</fragment>
<fragment
android:id="@+id/recipeInfoFragment"
android:name="gq.kirmanak.mealie.ui.recipes.info.RecipeInfoFragment"
android:label="RecipeInfoFragment">
<action
android:id="@+id/action_recipeInfoFragment_to_authenticationFragment"
app:destination="@id/authenticationFragment"
app:popUpTo="@id/nav_graph"
app:popUpToInclusive="true" />
<argument
android:name="recipe_slug"
app:argType="string" />
<argument
android:name="recipe_id"
app:argType="long" />
</fragment>
</navigation>