Implement opening of recipe info card
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) } }
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user