diff --git a/app/src/main/java/gq/kirmanak/mealie/data/MealieDb.kt b/app/src/main/java/gq/kirmanak/mealie/data/MealieDb.kt index 37eea52..f19da74 100644 --- a/app/src/main/java/gq/kirmanak/mealie/data/MealieDb.kt +++ b/app/src/main/java/gq/kirmanak/mealie/data/MealieDb.kt @@ -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) diff --git a/app/src/main/java/gq/kirmanak/mealie/data/impl/RetrofitBuilder.kt b/app/src/main/java/gq/kirmanak/mealie/data/impl/RetrofitBuilder.kt index fcdc9ea..98c8878 100644 --- a/app/src/main/java/gq/kirmanak/mealie/data/impl/RetrofitBuilder.kt +++ b/app/src/main/java/gq/kirmanak/mealie/data/impl/RetrofitBuilder.kt @@ -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") diff --git a/app/src/main/java/gq/kirmanak/mealie/data/recipes/RecipeRepo.kt b/app/src/main/java/gq/kirmanak/mealie/data/recipes/RecipeRepo.kt index 215155f..35cfe46 100644 --- a/app/src/main/java/gq/kirmanak/mealie/data/recipes/RecipeRepo.kt +++ b/app/src/main/java/gq/kirmanak/mealie/data/recipes/RecipeRepo.kt @@ -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 suspend fun clearLocalData() + + suspend fun loadRecipeInfo(recipeId: Long, recipeSlug: String): FullRecipeInfo } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/RecipeDao.kt b/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/RecipeDao.kt index e65aef1..0e1d10e 100644 --- a/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/RecipeDao.kt +++ b/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/RecipeDao.kt @@ -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 - @Insert + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertRecipe(recipe: RecipeEntity) - @Insert + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertRecipeInstructions(instructions: List) - @Insert + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertRecipeIngredients(ingredients: List) + + @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? } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/RecipeStorage.kt b/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/RecipeStorage.kt index 6105d9d..b009869 100644 --- a/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/RecipeStorage.kt +++ b/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/RecipeStorage.kt @@ -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 } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/RecipeStorageImpl.kt b/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/RecipeStorageImpl.kt index 0e8413f..e01bbda 100644 --- a/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/RecipeStorageImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/RecipeStorageImpl.kt @@ -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 + } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/entity/RecipeIngredientEntity.kt b/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/entity/RecipeIngredientEntity.kt index 7a71b63..bc8b97b 100644 --- a/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/entity/RecipeIngredientEntity.kt +++ b/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/entity/RecipeIngredientEntity.kt @@ -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, diff --git a/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/entity/RecipeInstructionEntity.kt b/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/entity/RecipeInstructionEntity.kt index f50d56d..f655df9 100644 --- a/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/entity/RecipeInstructionEntity.kt +++ b/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/entity/RecipeInstructionEntity.kt @@ -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, ) diff --git a/app/src/main/java/gq/kirmanak/mealie/data/recipes/impl/FullRecipeInfo.kt b/app/src/main/java/gq/kirmanak/mealie/data/recipes/impl/FullRecipeInfo.kt new file mode 100644 index 0000000..08f8c70 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealie/data/recipes/impl/FullRecipeInfo.kt @@ -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, + @Relation( + parentColumn = "remote_id", + entityColumn = "recipe_id" + ) + val recipeInstructions: List, +) diff --git a/app/src/main/java/gq/kirmanak/mealie/data/recipes/impl/RecipeRepoImpl.kt b/app/src/main/java/gq/kirmanak/mealie/data/recipes/impl/RecipeRepoImpl.kt index 7fda993..3609405 100644 --- a/app/src/main/java/gq/kirmanak/mealie/data/recipes/impl/RecipeRepoImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealie/data/recipes/impl/RecipeRepoImpl.kt @@ -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 { 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) + } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealie/data/recipes/network/RecipeService.kt b/app/src/main/java/gq/kirmanak/mealie/data/recipes/network/RecipeService.kt index ed00fdf..b2881e5 100644 --- a/app/src/main/java/gq/kirmanak/mealie/data/recipes/network/RecipeService.kt +++ b/app/src/main/java/gq/kirmanak/mealie/data/recipes/network/RecipeService.kt @@ -13,7 +13,7 @@ interface RecipeService { @Query("limit") limit: Int ): List - @GET("/api/recipes/:recipe_slug") + @GET("/api/recipes/{recipe_slug}") suspend fun getRecipe( @Path("recipe_slug") recipeSlug: String ): GetRecipeResponse diff --git a/app/src/main/java/gq/kirmanak/mealie/ui/recipes/RecipeViewHolder.kt b/app/src/main/java/gq/kirmanak/mealie/ui/recipes/RecipeViewHolder.kt index e8d0343..f8d332f 100644 --- a/app/src/main/java/gq/kirmanak/mealie/ui/recipes/RecipeViewHolder.kt +++ b/app/src/main/java/gq/kirmanak/mealie/ui/recipes/RecipeViewHolder.kt @@ -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) } } } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealie/ui/recipes/RecipesFragment.kt b/app/src/main/java/gq/kirmanak/mealie/ui/recipes/RecipesFragment.kt index 41de13d..b7e4c4a 100644 --- a/app/src/main/java/gq/kirmanak/mealie/ui/recipes/RecipesFragment.kt +++ b/app/src/main/java/gq/kirmanak/mealie/ui/recipes/RecipesFragment.kt @@ -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() { diff --git a/app/src/main/java/gq/kirmanak/mealie/ui/recipes/RecipesPagingAdapter.kt b/app/src/main/java/gq/kirmanak/mealie/ui/recipes/RecipesPagingAdapter.kt index 87873a7..9fb463a 100644 --- a/app/src/main/java/gq/kirmanak/mealie/ui/recipes/RecipesPagingAdapter.kt +++ b/app/src/main/java/gq/kirmanak/mealie/ui/recipes/RecipesPagingAdapter.kt @@ -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(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() { diff --git a/app/src/main/java/gq/kirmanak/mealie/ui/recipes/info/RecipeInfoFragment.kt b/app/src/main/java/gq/kirmanak/mealie/ui/recipes/info/RecipeInfoFragment.kt index c323e1f..9b40df4 100644 --- a/app/src/main/java/gq/kirmanak/mealie/ui/recipes/info/RecipeInfoFragment.kt +++ b/app/src/main/java/gq/kirmanak/mealie/ui/recipes/info/RecipeInfoFragment.kt @@ -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() + private val arguments by navArgs() + private val viewModel by viewModels() + + 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 + } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealie/ui/recipes/info/RecipeInfoViewModel.kt b/app/src/main/java/gq/kirmanak/mealie/ui/recipes/info/RecipeInfoViewModel.kt new file mode 100644 index 0000000..7cfd002 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealie/ui/recipes/info/RecipeInfoViewModel.kt @@ -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() + val recipeInfo: LiveData = _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") + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml index 8fd46a4..6927100 100644 --- a/app/src/main/res/navigation/nav_graph.xml +++ b/app/src/main/res/navigation/nav_graph.xml @@ -25,5 +25,24 @@ app:destination="@id/authenticationFragment" app:popUpTo="@id/nav_graph" app:popUpToInclusive="true" /> + + + + + + \ No newline at end of file