diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f3207a6..1c235ef 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -46,8 +46,14 @@ dependencies { implementation(libs.androidx.navigation.fragment) implementation(libs.androidx.navigation.ui) implementation(libs.material) + implementation(libs.glide) + implementation(libs.androidx.recyclerview) implementation(libs.androidx.activity) implementation(libs.androidx.constraintlayout) testImplementation(libs.junit) testImplementation(libs.kotlin.coroutines.test) -} \ No newline at end of file + annotationProcessor(libs.compiler) +} + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 063f4d1..6f1d8ae 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,9 @@ + + + (R.id.vh_category_name) + + 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..170dcec 100644 --- a/app/src/main/kotlin/ru/otus/cookbook/ui/CookbookFragment.kt +++ b/app/src/main/kotlin/ru/otus/cookbook/ui/CookbookFragment.kt @@ -9,13 +9,17 @@ import androidx.fragment.app.viewModels import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.launch +import ru.otus.cookbook.R import ru.otus.cookbook.data.RecipeListItem import ru.otus.cookbook.databinding.FragmentCookbookBinding +import androidx.navigation.fragment.findNavController +import com.google.android.material.dialog.MaterialAlertDialogBuilder -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, @@ -34,13 +38,29 @@ class CookbookFragment : Fragment() { .flowWithLifecycle(viewLifecycleOwner.lifecycle) .collect(::onRecipeListUpdated) } + binding.withBinding { + cookbookToolbar.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 + cookbookList.adapter = adapter } private fun onRecipeListUpdated(recipeList: List) { - // Handle recipe list + adapter.submitList(recipeList) + } + + override fun itemClicked(id: Int) { + val action = CookbookFragmentDirections.actionCookbookToRecipe(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..78bbeb7 --- /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" + } + +} 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..a5644ef --- /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) +} \ 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 e4460c1..c7f2b78 100644 --- a/app/src/main/kotlin/ru/otus/cookbook/ui/RecipeFragment.kt +++ b/app/src/main/kotlin/ru/otus/cookbook/ui/RecipeFragment.kt @@ -10,12 +10,18 @@ import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewmodel.MutableCreationExtras import kotlinx.coroutines.launch +import ru.otus.cookbook.R import ru.otus.cookbook.data.Recipe import ru.otus.cookbook.databinding.FragmentRecipeBinding +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.navigation.fragment.findNavController +import com.bumptech.glide.Glide + 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( @@ -43,6 +49,38 @@ class RecipeFragment : Fragment() { .flowWithLifecycle(viewLifecycleOwner.lifecycle) .collect(::displayRecipe) } + binding.withBinding { + recipeToolbar.setNavigationOnClickListener { + findNavController().popBackStack() + } + recipeToolbar.setOnMenuItemClickListener { + val action = RecipeFragmentDirections.actionRecipeToDeleteDialog(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 +91,16 @@ class RecipeFragment : Fragment() { } private fun displayRecipe(recipe: Recipe) { - // Display the recipe + binding.withBinding { + recipeToolbar.title = recipe.title + recipeTitle.text = recipe.title + recipeDescription.text = recipe.description + recipeSteps.text = recipe.steps.joinToString(".") + Glide.with(root) + .load(recipe.imageUrl) + .centerCrop() + .into(recipeImage) + } } 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..dd4ba1c --- /dev/null +++ b/app/src/main/kotlin/ru/otus/cookbook/ui/RecipeViewHolder.kt @@ -0,0 +1,32 @@ +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 ru.otus.cookbook.R +import ru.otus.cookbook.data.RecipeListItem +import com.bumptech.glide.Glide + +class RecipeViewHolder(view: View, private val itemClickListener: ItemClickListener) : RecyclerView.ViewHolder(view) { + private val root = view.findViewById(R.id.vh_recipe_item) + private val letter = view.findViewById(R.id.vh_recipe_letter) + private val title = view.findViewById(R.id.vh_recipe_title) + private val description = view.findViewById(R.id.vh_recipe_description) + private val image = view.findViewById(R.id.vh_recipe_image) + + 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..34a59cf --- /dev/null +++ b/app/src/main/res/drawable/bg_list_item_rounded_corners.xml @@ -0,0 +1,17 @@ + + + + + + + + \ 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..8c1618e --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow.xml @@ -0,0 +1,14 @@ + + + + + + + \ No newline at end of file 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..0abee38 --- /dev/null +++ b/app/src/main/res/drawable/ic_close.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file 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..99cedbe --- /dev/null +++ b/app/src/main/res/drawable/ic_trash.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 86a5d97..1c94f77 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,5 +1,6 @@ - - + + \ 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..5b01abd 100644 --- a/app/src/main/res/layout/fragment_cookbook.xml +++ b/app/src/main/res/layout/fragment_cookbook.xml @@ -1,6 +1,39 @@ - + + + + + + + \ 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..76fce39 100644 --- a/app/src/main/res/layout/fragment_recipe.xml +++ b/app/src/main/res/layout/fragment_recipe.xml @@ -1,6 +1,80 @@ - - \ 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..c7af0b5 100644 --- a/app/src/main/res/layout/vh_recipe_category.xml +++ b/app/src/main/res/layout/vh_recipe_category.xml @@ -1,6 +1,21 @@ - - \ 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..dadaf73 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..88eb315 --- /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..cf8a4d7 --- /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/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..84768fc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,11 @@ 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..81c7369 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,9 +1,15 @@ + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 515d8d1..9d9302b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.7.3" +agp = "8.12.3" kotlin = "2.1.0" coreKtx = "1.15.0" fragmentKtx = "1.8.5" @@ -15,6 +15,10 @@ junitVersion = "1.2.1" espressoCore = "3.6.1" serialization = "1.7.3" datastore = "1.1.1" +navigationFragmentKtx = "2.9.6" +navigationUiKtx = "2.9.6" +glide = "4.16.0" +recyclerview = "1.3.2" [libraries] @@ -37,6 +41,11 @@ androidx-activity = { group = "androidx.activity", name = "activity", version.re androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-navigation-fragment-ktx = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "navigationFragmentKtx" } +androidx-navigation-ui-ktx = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "navigationUiKtx" } +compiler = { module = "com.github.bumptech.glide:compiler", version.ref = "glide" } +glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" } +androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c4701d6..21f9fff 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Sat Nov 30 19:52:55 CET 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists