From 8ee48d5d4c910a89b0fa365be82d9b0bc19113d7 Mon Sep 17 00:00:00 2001 From: Nikolay Kochetkov Date: Mon, 2 Dec 2024 11:37:37 +0100 Subject: [PATCH 1/3] Cookbook fragment --- .../ru/otus/cookbook/data/RecipeListItem.kt | 14 ++++ .../ru/otus/cookbook/ui/CookbookFragment.kt | 14 +++- .../ru/otus/cookbook/ui/RecipeAdapter.kt | 68 +++++++++++++++++++ app/src/main/res/drawable/ic_add.xml | 5 ++ app/src/main/res/drawable/ic_back.xml | 5 ++ app/src/main/res/drawable/ic_search.xml | 5 ++ app/src/main/res/layout/activity_main.xml | 25 +++---- app/src/main/res/layout/fragment_cookbook.xml | 47 ++++++++++++- .../main/res/layout/vh_recipe_category.xml | 18 ++++- app/src/main/res/layout/vh_recipe_item.xml | 39 ++++++++++- app/src/main/res/navigation/navigation.xml | 11 +++ app/src/main/res/values/dimens.xml | 5 ++ app/src/main/res/values/strings.xml | 2 + app/src/main/res/values/styles.xml | 10 +++ app/src/main/res/values/themes.xml | 7 +- 15 files changed, 252 insertions(+), 23 deletions(-) create mode 100644 app/src/main/kotlin/ru/otus/cookbook/ui/RecipeAdapter.kt create mode 100644 app/src/main/res/drawable/ic_add.xml create mode 100644 app/src/main/res/drawable/ic_back.xml create mode 100644 app/src/main/res/drawable/ic_search.xml create mode 100644 app/src/main/res/navigation/navigation.xml create mode 100644 app/src/main/res/values/dimens.xml create mode 100644 app/src/main/res/values/styles.xml diff --git a/app/src/main/kotlin/ru/otus/cookbook/data/RecipeListItem.kt b/app/src/main/kotlin/ru/otus/cookbook/data/RecipeListItem.kt index ba9bbe1..d62008a 100644 --- a/app/src/main/kotlin/ru/otus/cookbook/data/RecipeListItem.kt +++ b/app/src/main/kotlin/ru/otus/cookbook/data/RecipeListItem.kt @@ -7,6 +7,11 @@ import ru.otus.cookbook.R * Recipe list items. */ sealed class RecipeListItem : WithLayoutId { + + // See RecipeAdapter.kt + abstract fun isSame(other: RecipeListItem): Boolean + abstract fun isContentSame(other: RecipeListItem): Boolean + /** * Recipe item. */ @@ -20,6 +25,12 @@ sealed class RecipeListItem : WithLayoutId { val title: String get() = recipe.title val description: String get() = recipe.description val imageUrl: String get() = recipe.imageUrl + + override fun isSame(other: RecipeListItem): Boolean = id == (other as? RecipeItem)?.id + override fun isContentSame(other: RecipeListItem): Boolean { + if (other !is RecipeItem) return false + return title == other.title && description == other.description && imageUrl == other.imageUrl + } } /** @@ -32,5 +43,8 @@ sealed class RecipeListItem : WithLayoutId { } val name: String get() = category.name + + override fun isSame(other: RecipeListItem): Boolean = name == (other as? CategoryItem)?.name + override fun isContentSame(other: RecipeListItem): Boolean = name == (other as? CategoryItem)?.name } } \ No newline at end of file diff --git a/app/src/main/kotlin/ru/otus/cookbook/ui/CookbookFragment.kt b/app/src/main/kotlin/ru/otus/cookbook/ui/CookbookFragment.kt index efe6939..a315a3c 100644 --- a/app/src/main/kotlin/ru/otus/cookbook/ui/CookbookFragment.kt +++ b/app/src/main/kotlin/ru/otus/cookbook/ui/CookbookFragment.kt @@ -1,6 +1,7 @@ package ru.otus.cookbook.ui import android.os.Bundle +import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -17,6 +18,11 @@ class CookbookFragment : Fragment() { private val binding = FragmentBindingDelegate(this) private val model: CookbookFragmentViewModel by viewModels { CookbookFragmentViewModel.Factory } + private val adapter = RecipeAdapter { id -> + Log.d(TAG, "Recipe clicked: $id") + // TODO Navigate to recipe details + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -37,10 +43,14 @@ class CookbookFragment : Fragment() { } private fun setupRecyclerView() = binding.withBinding { - // Setup RecyclerView + recipes.adapter = adapter } private fun onRecipeListUpdated(recipeList: List) { - // Handle recipe list + adapter.submitList(recipeList) + } + + companion object { + const val TAG = "CookbookFragment" } } \ No newline at end of file diff --git a/app/src/main/kotlin/ru/otus/cookbook/ui/RecipeAdapter.kt b/app/src/main/kotlin/ru/otus/cookbook/ui/RecipeAdapter.kt new file mode 100644 index 0000000..82d5e83 --- /dev/null +++ b/app/src/main/kotlin/ru/otus/cookbook/ui/RecipeAdapter.kt @@ -0,0 +1,68 @@ +package ru.otus.cookbook.ui + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import ru.otus.cookbook.data.RecipeListItem +import ru.otus.cookbook.databinding.VhRecipeCategoryBinding +import ru.otus.cookbook.databinding.VhRecipeItemBinding +import java.util.Locale + +/** + * Recipe adapter + */ +class RecipeAdapter(private val onClick: (Int) -> Unit) : ListAdapter(RecipeDiff) { + + override fun getItemViewType(position: Int): Int { + return getItem(position).layoutId + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when(viewType) { + RecipeListItem.RecipeItem.layoutId -> RecipeViewHolder.RecipeHolder( + VhRecipeItemBinding.inflate(LayoutInflater.from(parent.context), parent, false), + onClick + ) + RecipeListItem.CategoryItem.layoutId -> RecipeViewHolder.CategoryHolder( + VhRecipeCategoryBinding.inflate(LayoutInflater.from(parent.context), parent, false) + ) + else -> throw IllegalArgumentException("Unknown view type: $viewType") + } + + override fun onBindViewHolder(holder: RecipeViewHolder, position: Int) { + when(holder) { + is RecipeViewHolder.CategoryHolder -> holder.bind(getItem(position) as RecipeListItem.CategoryItem) + is RecipeViewHolder.RecipeHolder -> holder.bind(getItem(position) as RecipeListItem.RecipeItem) + } + } + + sealed class RecipeViewHolder(view: View): ViewHolder(view) { + + class RecipeHolder(private val binding: VhRecipeItemBinding, private val onClick: (Int) -> Unit) : RecipeViewHolder(binding.root) { + private var id: Int = -1 + + init { + binding.root.setOnClickListener { onClick(id) } + } + + fun bind(recipe: RecipeListItem.RecipeItem) = with(binding) { + this@RecipeHolder.id = recipe.id + id.text = String.format(Locale.getDefault(), "%02d", recipe.id) + title.text = recipe.title + } + } + + class CategoryHolder(private val binding: VhRecipeCategoryBinding) : RecipeViewHolder(binding.root) { + fun bind(category: RecipeListItem.CategoryItem) = with(binding) { + name.text = category.name + } + } + } +} + +private object RecipeDiff : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: RecipeListItem, newItem: RecipeListItem): Boolean = oldItem.isSame(newItem) + override fun areContentsTheSame(oldItem: RecipeListItem, newItem: RecipeListItem): Boolean = oldItem.isContentSame(newItem) +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml new file mode 100644 index 0000000..9f83b8f --- /dev/null +++ b/app/src/main/res/drawable/ic_add.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_back.xml b/app/src/main/res/drawable/ic_back.xml new file mode 100644 index 0000000..075e95d --- /dev/null +++ b/app/src/main/res/drawable/ic_back.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_search.xml b/app/src/main/res/drawable/ic_search.xml new file mode 100644 index 0000000..d29c6ea --- /dev/null +++ b/app/src/main/res/drawable/ic_search.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 86a5d97..8b540e6 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,19 +1,14 @@ - - + android:layout_height="match_parent"> - + - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_cookbook.xml b/app/src/main/res/layout/fragment_cookbook.xml index 77d9ef6..e55ef97 100644 --- a/app/src/main/res/layout/fragment_cookbook.xml +++ b/app/src/main/res/layout/fragment_cookbook.xml @@ -1,6 +1,49 @@ - - \ No newline at end of file + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/vh_recipe_category.xml b/app/src/main/res/layout/vh_recipe_category.xml index 006fd49..f8fc841 100644 --- a/app/src/main/res/layout/vh_recipe_category.xml +++ b/app/src/main/res/layout/vh_recipe_category.xml @@ -1,6 +1,22 @@ + \ No newline at end of file diff --git a/app/src/main/res/layout/vh_recipe_item.xml b/app/src/main/res/layout/vh_recipe_item.xml index 006fd49..b411898 100644 --- a/app/src/main/res/layout/vh_recipe_item.xml +++ b/app/src/main/res/layout/vh_recipe_item.xml @@ -1,6 +1,43 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/navigation.xml b/app/src/main/res/navigation/navigation.xml new file mode 100644 index 0000000..d339710 --- /dev/null +++ b/app/src/main/res/navigation/navigation.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml new file mode 100644 index 0000000..30ae36e --- /dev/null +++ b/app/src/main/res/values/dimens.xml @@ -0,0 +1,5 @@ + + + 16dp + 8dp + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8fce1d1..54fb80c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,5 @@ Cookbook + Delete + Add recipe \ No newline at end of file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..42e65e1 --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 47d6575..7bee1e1 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,9 +1,12 @@ - + - \ No newline at end of file From 6ab47c24d3c8913c81b32c74e92947685f75969d Mon Sep 17 00:00:00 2001 From: Nikolay Kochetkov Date: Mon, 2 Dec 2024 11:48:08 +0100 Subject: [PATCH 2/3] Recipe fragment --- .../ru/otus/cookbook/ui/CookbookFragment.kt | 5 +- .../ru/otus/cookbook/ui/RecipeFragment.kt | 33 ++++++++-- app/src/main/res/drawable/ic_delete.xml | 5 ++ app/src/main/res/layout/fragment_recipe.xml | 61 ++++++++++++++++++- app/src/main/res/menu/menu_recipe.xml | 8 +++ app/src/main/res/navigation/navigation.xml | 16 ++++- 6 files changed, 120 insertions(+), 8 deletions(-) create mode 100644 app/src/main/res/drawable/ic_delete.xml create mode 100644 app/src/main/res/menu/menu_recipe.xml diff --git a/app/src/main/kotlin/ru/otus/cookbook/ui/CookbookFragment.kt b/app/src/main/kotlin/ru/otus/cookbook/ui/CookbookFragment.kt index a315a3c..d49e404 100644 --- a/app/src/main/kotlin/ru/otus/cookbook/ui/CookbookFragment.kt +++ b/app/src/main/kotlin/ru/otus/cookbook/ui/CookbookFragment.kt @@ -9,6 +9,7 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController import kotlinx.coroutines.launch import ru.otus.cookbook.data.RecipeListItem import ru.otus.cookbook.databinding.FragmentCookbookBinding @@ -20,7 +21,9 @@ class CookbookFragment : Fragment() { private val adapter = RecipeAdapter { id -> Log.d(TAG, "Recipe clicked: $id") - // TODO Navigate to recipe details + val navController = findNavController() + val action = CookbookFragmentDirections.actionCookbookFragmentToRecipeFragment(id) + navController.navigate(action) } override fun onCreateView( diff --git a/app/src/main/kotlin/ru/otus/cookbook/ui/RecipeFragment.kt b/app/src/main/kotlin/ru/otus/cookbook/ui/RecipeFragment.kt index e4460c1..3a4e66e 100644 --- a/app/src/main/kotlin/ru/otus/cookbook/ui/RecipeFragment.kt +++ b/app/src/main/kotlin/ru/otus/cookbook/ui/RecipeFragment.kt @@ -1,6 +1,7 @@ package ru.otus.cookbook.ui import android.os.Bundle +import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -9,13 +10,15 @@ import androidx.fragment.app.viewModels import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewmodel.MutableCreationExtras +import androidx.navigation.fragment.findNavController import kotlinx.coroutines.launch +import ru.otus.cookbook.R import ru.otus.cookbook.data.Recipe import ru.otus.cookbook.databinding.FragmentRecipeBinding class RecipeFragment : Fragment() { - private val recipeId: Int get() = TODO("Use Safe Args to get the recipe ID: https://developer.android.com/guide/navigation/use-graph/pass-data#Safe-args") + private val recipeId: Int get() = RecipeFragmentArgs.fromBundle(requireArguments()).recipeId private val binding = FragmentBindingDelegate(this) private val model: RecipeFragmentViewModel by viewModels( @@ -38,6 +41,7 @@ class RecipeFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + setupAppBar() viewLifecycleOwner.lifecycleScope.launch { model.recipe .flowWithLifecycle(viewLifecycleOwner.lifecycle) @@ -52,11 +56,32 @@ class RecipeFragment : Fragment() { return model.recipe.value.title } - private fun displayRecipe(recipe: Recipe) { - // Display the recipe + private fun setupAppBar() = binding.withBinding { + topAppBar.setNavigationOnClickListener { + findNavController().popBackStack() + } + topAppBar.setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.action_delete -> { + deleteRecipe() + true + } + else -> false + } + } + } + + private fun displayRecipe(recipe: Recipe) = binding.withBinding { + title.text = recipe.title + steps.text = recipe.steps.joinToString("\n") } private fun deleteRecipe() { - model.delete() + Log.d(TAG, "Deleting recipe $recipeId") + //model.delete() + } + + companion object { + const val TAG = "RecipeFragment" } } \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml new file mode 100644 index 0000000..883bcaa --- /dev/null +++ b/app/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/layout/fragment_recipe.xml b/app/src/main/res/layout/fragment_recipe.xml index 77d9ef6..d559178 100644 --- a/app/src/main/res/layout/fragment_recipe.xml +++ b/app/src/main/res/layout/fragment_recipe.xml @@ -1,6 +1,63 @@ - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_recipe.xml b/app/src/main/res/menu/menu_recipe.xml new file mode 100644 index 0000000..2cb4a26 --- /dev/null +++ b/app/src/main/res/menu/menu_recipe.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/navigation.xml b/app/src/main/res/navigation/navigation.xml index d339710..078d843 100644 --- a/app/src/main/res/navigation/navigation.xml +++ b/app/src/main/res/navigation/navigation.xml @@ -7,5 +7,19 @@ + android:label="CookbookFragment" > + + + + + \ No newline at end of file From 25b2435ceb5c8924f2e35c946de6d32cff9dfa0f Mon Sep 17 00:00:00 2001 From: Nikolay Kochetkov Date: Mon, 2 Dec 2024 12:56:12 +0100 Subject: [PATCH 3/3] Delete confirmation --- .../cookbook/ui/DeleteConfirmationFragment.kt | 48 ++++++++++++++++ .../ru/otus/cookbook/ui/RecipeFragment.kt | 44 ++++++++++++++- .../main/res/layout/fragment_confirmation.xml | 56 +++++++++++++++++++ app/src/main/res/navigation/navigation.xml | 12 ++++ app/src/main/res/values/strings.xml | 4 ++ 5 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 app/src/main/kotlin/ru/otus/cookbook/ui/DeleteConfirmationFragment.kt create mode 100644 app/src/main/res/layout/fragment_confirmation.xml diff --git a/app/src/main/kotlin/ru/otus/cookbook/ui/DeleteConfirmationFragment.kt b/app/src/main/kotlin/ru/otus/cookbook/ui/DeleteConfirmationFragment.kt new file mode 100644 index 0000000..8382371 --- /dev/null +++ b/app/src/main/kotlin/ru/otus/cookbook/ui/DeleteConfirmationFragment.kt @@ -0,0 +1,48 @@ +package ru.otus.cookbook.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import androidx.navigation.fragment.findNavController +import ru.otus.cookbook.R +import ru.otus.cookbook.databinding.FragmentConfirmationBinding + +class DeleteConfirmationFragment : DialogFragment() { + private val binding = FragmentBindingDelegate(this) + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = binding.bind( + container, + FragmentConfirmationBinding::inflate + ) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.withBinding { + text.text = getString(R.string.text_delete, DeleteConfirmationFragmentArgs.fromBundle(requireArguments()).title) + ok.setOnClickListener { + setResult(true) + } + cancel.setOnClickListener { + setResult(false) + } + } + } + + private fun setResult(result: Boolean) { + findNavController().previousBackStackEntry?.savedStateHandle?.set( + CONFIRMATION_RESULT, + result + ) + findNavController().popBackStack() + } + + companion object { + const val CONFIRMATION_RESULT = "confirmation_result" + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/ru/otus/cookbook/ui/RecipeFragment.kt b/app/src/main/kotlin/ru/otus/cookbook/ui/RecipeFragment.kt index 3a4e66e..0380cf0 100644 --- a/app/src/main/kotlin/ru/otus/cookbook/ui/RecipeFragment.kt +++ b/app/src/main/kotlin/ru/otus/cookbook/ui/RecipeFragment.kt @@ -7,6 +7,8 @@ import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewmodel.MutableCreationExtras @@ -42,6 +44,7 @@ class RecipeFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setupAppBar() + setupAlertResult() viewLifecycleOwner.lifecycleScope.launch { model.recipe .flowWithLifecycle(viewLifecycleOwner.lifecycle) @@ -58,7 +61,7 @@ class RecipeFragment : Fragment() { private fun setupAppBar() = binding.withBinding { topAppBar.setNavigationOnClickListener { - findNavController().popBackStack() + close() } topAppBar.setOnMenuItemClickListener { item -> when (item.itemId) { @@ -71,6 +74,39 @@ class RecipeFragment : Fragment() { } } + private fun close() { + findNavController().popBackStack() + } + + /** + * Sets up alert dialog for delete result. + * https://developer.android.com/guide/navigation/use-graph/programmatic#returning_a_result + */ + private fun setupAlertResult() { + val navBackStackEntry = findNavController().getBackStackEntry(R.id.recipeFragment) + + val observer = object : DefaultLifecycleObserver { + override fun onResume(owner: LifecycleOwner) { + if (navBackStackEntry.savedStateHandle.contains(DeleteConfirmationFragment.CONFIRMATION_RESULT)) { + if (true == navBackStackEntry.savedStateHandle.get(DeleteConfirmationFragment.CONFIRMATION_RESULT)) { + Log.d(TAG, "Deleting recipe $recipeId") + model.delete() + close() + } + navBackStackEntry.savedStateHandle.remove(DeleteConfirmationFragment.CONFIRMATION_RESULT) + } + } + } + + navBackStackEntry.lifecycle.addObserver(observer) + + viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + navBackStackEntry.lifecycle.removeObserver(observer) + } + }) + } + private fun displayRecipe(recipe: Recipe) = binding.withBinding { title.text = recipe.title steps.text = recipe.steps.joinToString("\n") @@ -78,10 +114,12 @@ class RecipeFragment : Fragment() { private fun deleteRecipe() { Log.d(TAG, "Deleting recipe $recipeId") - //model.delete() + findNavController().navigate( + RecipeFragmentDirections.actionRecipeFragmentToDeleteConfirmation(getTitle()) + ) } companion object { - const val TAG = "RecipeFragment" + private const val TAG = "RecipeFragment" } } \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_confirmation.xml b/app/src/main/res/layout/fragment_confirmation.xml new file mode 100644 index 0000000..382626f --- /dev/null +++ b/app/src/main/res/layout/fragment_confirmation.xml @@ -0,0 +1,56 @@ + + + + + + + +