From d423245dfcd524ce93066eec7d31ec4e5d81a979 Mon Sep 17 00:00:00 2001 From: atuzhilov Date: Fri, 14 Feb 2025 23:09:05 +0300 Subject: [PATCH] navigation logic, alert dialogs and layout have been added --- app/build.gradle.kts | 1 + .../ru/otus/cookbook/ui/CategoryViewHolder.kt | 15 ++++ .../ru/otus/cookbook/ui/CookBookAdapter.kt | 66 +++++++++++++++ .../ru/otus/cookbook/ui/CookbookFragment.kt | 28 ++++++- .../otus/cookbook/ui/DeleteDialogFragment.kt | 41 ++++++++++ .../ru/otus/cookbook/ui/ItemClickListener.kt | 5 ++ .../ru/otus/cookbook/ui/RecipeFragment.kt | 52 +++++++++++- .../ru/otus/cookbook/ui/RecipeViewHolder.kt | 31 +++++++ .../drawable/bg_list_item_rounded_corners.xml | 16 ++++ app/src/main/res/drawable/circle_shape.xml | 11 +++ app/src/main/res/drawable/ic_arrow.xml | 13 +++ app/src/main/res/drawable/ic_close.xml | 9 ++ app/src/main/res/drawable/ic_trash.xml | 9 ++ app/src/main/res/layout/activity_main.xml | 24 +++--- app/src/main/res/layout/fragment_cookbook.xml | 38 ++++++++- app/src/main/res/layout/fragment_recipe.xml | 82 ++++++++++++++++++- .../main/res/layout/vh_recipe_category.xml | 24 +++++- app/src/main/res/layout/vh_recipe_item.xml | 70 +++++++++++++++- app/src/main/res/menu/menu.xml | 12 +++ app/src/main/res/navigation/nav_graph.xml | 44 ++++++++++ app/src/main/res/values-night/themes.xml | 6 +- app/src/main/res/values/dimensions.xml | 4 + app/src/main/res/values/strings.xml | 9 ++ app/src/main/res/values/themes.xml | 7 ++ gradle/libs.versions.toml | 2 + 25 files changed, 587 insertions(+), 32 deletions(-) create mode 100644 app/src/main/kotlin/ru/otus/cookbook/ui/CategoryViewHolder.kt create mode 100644 app/src/main/kotlin/ru/otus/cookbook/ui/CookBookAdapter.kt create mode 100644 app/src/main/kotlin/ru/otus/cookbook/ui/DeleteDialogFragment.kt create mode 100644 app/src/main/kotlin/ru/otus/cookbook/ui/ItemClickListener.kt create mode 100644 app/src/main/kotlin/ru/otus/cookbook/ui/RecipeViewHolder.kt create mode 100644 app/src/main/res/drawable/bg_list_item_rounded_corners.xml create mode 100644 app/src/main/res/drawable/circle_shape.xml create mode 100644 app/src/main/res/drawable/ic_arrow.xml create mode 100644 app/src/main/res/drawable/ic_close.xml create mode 100644 app/src/main/res/drawable/ic_trash.xml create mode 100644 app/src/main/res/menu/menu.xml create mode 100644 app/src/main/res/navigation/nav_graph.xml create mode 100644 app/src/main/res/values/dimensions.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f3207a6..38a7356 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -45,6 +45,7 @@ dependencies { implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.navigation.fragment) implementation(libs.androidx.navigation.ui) + implementation(libs.glide) implementation(libs.material) implementation(libs.androidx.activity) implementation(libs.androidx.constraintlayout) diff --git a/app/src/main/kotlin/ru/otus/cookbook/ui/CategoryViewHolder.kt b/app/src/main/kotlin/ru/otus/cookbook/ui/CategoryViewHolder.kt new file mode 100644 index 0000000..33080cc --- /dev/null +++ b/app/src/main/kotlin/ru/otus/cookbook/ui/CategoryViewHolder.kt @@ -0,0 +1,15 @@ +package ru.otus.cookbook.ui + +import android.view.View +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import ru.otus.cookbook.R +import ru.otus.cookbook.data.RecipeListItem + +class CategoryViewHolder(view: View) : RecyclerView.ViewHolder(view) { + private val categoryName = view.findViewById(R.id.category_name_tv) + + fun bind(item: RecipeListItem.CategoryItem) { + categoryName.text = item.name + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/ru/otus/cookbook/ui/CookBookAdapter.kt b/app/src/main/kotlin/ru/otus/cookbook/ui/CookBookAdapter.kt new file mode 100644 index 0000000..2d7c64a --- /dev/null +++ b/app/src/main/kotlin/ru/otus/cookbook/ui/CookBookAdapter.kt @@ -0,0 +1,66 @@ +package ru.otus.cookbook.ui + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import ru.otus.cookbook.R +import ru.otus.cookbook.data.RecipeListItem + +class CookBookAdapter(private val itemClickListener: ItemClickListener) : + ListAdapter(DiffCallback()) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when (viewType) { + ViewTypes.RECIPE.ordinal -> { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.vh_recipe_item, parent, false) + RecipeViewHolder(view, itemClickListener) + } + + ViewTypes.CATEGORY.ordinal -> { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.vh_recipe_category, parent, false) + CategoryViewHolder(view) + } + + else -> throw IllegalArgumentException("View type is not supported") + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is RecipeViewHolder -> holder.bind(getItem(position) as RecipeListItem.RecipeItem) + is CategoryViewHolder -> holder.bind(getItem(position) as RecipeListItem.CategoryItem) + } + } + + override fun getItemViewType(position: Int): Int { + return when (currentList[position]) { + is RecipeListItem.CategoryItem -> ViewTypes.CATEGORY.ordinal + is RecipeListItem.RecipeItem -> ViewTypes.RECIPE.ordinal + } + } +} + +enum class ViewTypes { + CATEGORY, + RECIPE +} + +class DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: RecipeListItem, newItem: RecipeListItem): Boolean { + return when { + oldItem is RecipeListItem.RecipeItem && newItem is RecipeListItem.RecipeItem -> oldItem.id == newItem.id + oldItem is RecipeListItem.CategoryItem && newItem is RecipeListItem.CategoryItem -> + oldItem.name == newItem.name + + else -> false + } + } + + override fun areContentsTheSame(oldItem: RecipeListItem, newItem: RecipeListItem): Boolean { + return oldItem == newItem + } + +} \ 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..1889ba2 100644 --- a/app/src/main/kotlin/ru/otus/cookbook/ui/CookbookFragment.kt +++ b/app/src/main/kotlin/ru/otus/cookbook/ui/CookbookFragment.kt @@ -8,19 +8,23 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.launch +import ru.otus.cookbook.R import ru.otus.cookbook.data.RecipeListItem import ru.otus.cookbook.databinding.FragmentCookbookBinding -class CookbookFragment : Fragment() { +class CookbookFragment : Fragment(), ItemClickListener { private val binding = FragmentBindingDelegate(this) private val model: CookbookFragmentViewModel by viewModels { CookbookFragmentViewModel.Factory } + private val adapter: CookBookAdapter by lazy { CookBookAdapter(this) } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? + savedInstanceState: Bundle?, ): View = binding.bind( container, FragmentCookbookBinding::inflate @@ -34,13 +38,29 @@ class CookbookFragment : Fragment() { .flowWithLifecycle(viewLifecycleOwner.lifecycle) .collect(::onRecipeListUpdated) } + binding.withBinding { + recipeListMt.setNavigationOnClickListener { + MaterialAlertDialogBuilder(requireContext()) + .setMessage(getString(R.string.close_app_message)) + .setPositiveButton(R.string.yes_btn) { _, _ -> + requireActivity().finishAndRemoveTask() + } + .setNegativeButton(R.string.no_btn, null) + .show() + } + } } private fun setupRecyclerView() = binding.withBinding { - // Setup RecyclerView + recipeListRv.adapter = adapter } private fun onRecipeListUpdated(recipeList: List) { - // Handle recipe list + adapter.submitList(recipeList) + } + + override fun itemClicked(id: Int) { + val action = CookbookFragmentDirections.actionCookbookFragmentToRecipeFragment(id) + findNavController().navigate(action) } } \ No newline at end of file diff --git a/app/src/main/kotlin/ru/otus/cookbook/ui/DeleteDialogFragment.kt b/app/src/main/kotlin/ru/otus/cookbook/ui/DeleteDialogFragment.kt new file mode 100644 index 0000000..5a82478 --- /dev/null +++ b/app/src/main/kotlin/ru/otus/cookbook/ui/DeleteDialogFragment.kt @@ -0,0 +1,41 @@ +package ru.otus.cookbook.ui + +import android.app.Dialog +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import androidx.navigation.fragment.findNavController +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import ru.otus.cookbook.R + +class DeleteDialogFragment : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(getString(R.string.delete_dialog_title)) + .setMessage( + getString( + R.string.delete_dialog_message, + DeleteDialogFragmentArgs.fromBundle(requireArguments()).recipeTitle + ) + ) + .setPositiveButton(R.string.ok_btn) { _, _ -> + dismiss() + setConfirmationResult(true) + } + .setNegativeButton(R.string.cancel_btn) { _, _ -> + dismiss() + setConfirmationResult(false) + } + .create() + } + + private fun setConfirmationResult(result: Boolean) { + findNavController().previousBackStackEntry?.savedStateHandle?.set( + DELETE_CONFIRMATION_RESULT, result + ) + } + + companion object { + const val DELETE_CONFIRMATION_RESULT = "result" + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/ru/otus/cookbook/ui/ItemClickListener.kt b/app/src/main/kotlin/ru/otus/cookbook/ui/ItemClickListener.kt new file mode 100644 index 0000000..89a7686 --- /dev/null +++ b/app/src/main/kotlin/ru/otus/cookbook/ui/ItemClickListener.kt @@ -0,0 +1,5 @@ +package ru.otus.cookbook.ui + +interface ItemClickListener { + fun itemClicked(id: Int) +} 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..1f5e833 100644 --- a/app/src/main/kotlin/ru/otus/cookbook/ui/RecipeFragment.kt +++ b/app/src/main/kotlin/ru/otus/cookbook/ui/RecipeFragment.kt @@ -6,16 +6,21 @@ import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewmodel.MutableCreationExtras +import androidx.navigation.fragment.findNavController +import com.bumptech.glide.Glide 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( @@ -30,7 +35,7 @@ class RecipeFragment : Fragment() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? + savedInstanceState: Bundle?, ): View = binding.bind( container, FragmentRecipeBinding::inflate @@ -43,6 +48,38 @@ class RecipeFragment : Fragment() { .flowWithLifecycle(viewLifecycleOwner.lifecycle) .collect(::displayRecipe) } + binding.withBinding { + detailRecipeMt.setNavigationOnClickListener { + findNavController().popBackStack() + } + detailRecipeMt.setOnMenuItemClickListener { + val action = RecipeFragmentDirections.actionRecipeFragmentToDeleteDialogFragment(getTitle()) + findNavController().navigate(action) + true + } + } + + val navBackStackEntry = findNavController().getBackStackEntry(R.id.recipeFragment) + + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME && + navBackStackEntry.savedStateHandle.contains(DeleteDialogFragment.DELETE_CONFIRMATION_RESULT) + ) { + if (navBackStackEntry + .savedStateHandle.get(DeleteDialogFragment.DELETE_CONFIRMATION_RESULT) == true + ) { + deleteRecipe() + findNavController().popBackStack() + } + } + } + + navBackStackEntry.lifecycle.addObserver(observer) + viewLifecycleOwner.lifecycle.addObserver(LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_DESTROY) { + navBackStackEntry.lifecycle.removeObserver(observer) + } + }) } /** @@ -53,7 +90,16 @@ class RecipeFragment : Fragment() { } private fun displayRecipe(recipe: Recipe) { - // Display the recipe + binding.withBinding { + detailRecipeMt.title = recipe.title + detailRecipeTitleTv.text = recipe.title + detailRecipeDescriptionTv.text = recipe.description + detailRecipeStepsTv.text = recipe.steps.joinToString(".") + Glide.with(this@RecipeFragment) + .load(recipe.imageUrl) + .centerCrop() + .into(detailRecipeImageIv) + } } private fun deleteRecipe() { diff --git a/app/src/main/kotlin/ru/otus/cookbook/ui/RecipeViewHolder.kt b/app/src/main/kotlin/ru/otus/cookbook/ui/RecipeViewHolder.kt new file mode 100644 index 0000000..ee4e82f --- /dev/null +++ b/app/src/main/kotlin/ru/otus/cookbook/ui/RecipeViewHolder.kt @@ -0,0 +1,31 @@ +package ru.otus.cookbook.ui + +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import ru.otus.cookbook.R +import ru.otus.cookbook.data.RecipeListItem + +class RecipeViewHolder(view: View, private val itemClickListener: ItemClickListener) : RecyclerView.ViewHolder(view) { + private val root = view.findViewById(R.id.recipe_item_cl) + private val letter = view.findViewById(R.id.letter_tv) + private val title = view.findViewById(R.id.title_tv) + private val description = view.findViewById(R.id.description_tv) + private val image = view.findViewById(R.id.item_iv) + + fun bind(item: RecipeListItem.RecipeItem) { + + letter.text = item.title.first().toString() + title.text = item.title + description.text = item.description + Glide.with(root.context) + .load(item.imageUrl) + .centerCrop() + .circleCrop() + .into(image) + root.setOnClickListener { itemClickListener.itemClicked(item.id) } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_list_item_rounded_corners.xml b/app/src/main/res/drawable/bg_list_item_rounded_corners.xml new file mode 100644 index 0000000..07e4a16 --- /dev/null +++ b/app/src/main/res/drawable/bg_list_item_rounded_corners.xml @@ -0,0 +1,16 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circle_shape.xml b/app/src/main/res/drawable/circle_shape.xml new file mode 100644 index 0000000..486e905 --- /dev/null +++ b/app/src/main/res/drawable/circle_shape.xml @@ -0,0 +1,11 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_arrow.xml b/app/src/main/res/drawable/ic_arrow.xml new file mode 100644 index 0000000..2949b00 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_close.xml b/app/src/main/res/drawable/ic_close.xml new file mode 100644 index 0000000..b7cccc1 --- /dev/null +++ b/app/src/main/res/drawable/ic_close.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_trash.xml b/app/src/main/res/drawable/ic_trash.xml new file mode 100644 index 0000000..085e556 --- /dev/null +++ b/app/src/main/res/drawable/ic_trash.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 86a5d97..2381638 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,19 +1,21 @@ - + tools:context=".MainActivity" + > - + - \ 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..a7acc42 100644 --- a/app/src/main/res/layout/fragment_cookbook.xml +++ b/app/src/main/res/layout/fragment_cookbook.xml @@ -1,6 +1,40 @@ - + android:layout_height="match_parent" + > + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_recipe.xml b/app/src/main/res/layout/fragment_recipe.xml index 77d9ef6..096149d 100644 --- a/app/src/main/res/layout/fragment_recipe.xml +++ b/app/src/main/res/layout/fragment_recipe.xml @@ -1,6 +1,82 @@ - + 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/vh_recipe_category.xml b/app/src/main/res/layout/vh_recipe_category.xml index 006fd49..6207272 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 + + + \ 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..d6d616e 100644 --- a/app/src/main/res/layout/vh_recipe_item.xml +++ b/app/src/main/res/layout/vh_recipe_item.xml @@ -1,6 +1,70 @@ - + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu.xml b/app/src/main/res/menu/menu.xml new file mode 100644 index 0000000..c5a0fee --- /dev/null +++ b/app/src/main/res/menu/menu.xml @@ -0,0 +1,12 @@ + + + + + \ 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 new file mode 100644 index 0000000..29b983a --- /dev/null +++ b/app/src/main/res/navigation/nav_graph.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index d5b075a..9ab8011 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -1,7 +1,9 @@ - + - \ No newline at end of file diff --git a/app/src/main/res/values/dimensions.xml b/app/src/main/res/values/dimensions.xml new file mode 100644 index 0000000..7d7ec24 --- /dev/null +++ b/app/src/main/res/values/dimensions.xml @@ -0,0 +1,4 @@ + + + 16dp + \ 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..1ecbd46 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,12 @@ Cookbook + Card item image + Delete recipe + Do you really want to delete %1$s recipe? + OK + Cancel + Are you sure to want to close the application? + Yes + No + \ 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..1178add 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -3,7 +3,14 @@ \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 515d8d1..ae8dff9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,6 @@ [versions] agp = "8.7.3" +glide = "4.16.0" kotlin = "2.1.0" coreKtx = "1.15.0" fragmentKtx = "1.8.5" @@ -18,6 +19,7 @@ datastore = "1.1.1" [libraries] +glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" } kotlin-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlin-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlin-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }