diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f3207a6..c4a934d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -48,6 +48,8 @@ dependencies { implementation(libs.material) implementation(libs.androidx.activity) implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.recyclerview) + implementation(libs.glide) testImplementation(libs.junit) testImplementation(libs.kotlin.coroutines.test) } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 063f4d1..682aa63 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + (this) private val model: CookbookFragmentViewModel by viewModels { CookbookFragmentViewModel.Factory } + // Адаптер для списка рецептов + private val adapter = RecipeListAdapter { recipeId -> + // Навигация к экрану детальной информации о рецепте с использованием Safe Args + val action = CookbookFragmentDirections.actionCookbookToRecipe(recipeId) + findNavController().navigate(action) + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -36,11 +44,17 @@ class CookbookFragment : Fragment() { } } + /** + * Настройка RecyclerView: подключение адаптера и LayoutManager + */ private fun setupRecyclerView() = binding.withBinding { - // Setup RecyclerView + recipes.adapter = adapter } + /** + * Обновление списка рецептов в адаптере при изменении данных + */ private fun onRecipeListUpdated(recipeList: List) { - // Handle recipe list + adapter.submitList(recipeList) } } \ No newline at end of file diff --git a/app/src/main/kotlin/ru/otus/cookbook/ui/DeleteRecipeDialogFragment.kt b/app/src/main/kotlin/ru/otus/cookbook/ui/DeleteRecipeDialogFragment.kt new file mode 100644 index 0000000..3df0311 --- /dev/null +++ b/app/src/main/kotlin/ru/otus/cookbook/ui/DeleteRecipeDialogFragment.kt @@ -0,0 +1,41 @@ +package ru.otus.cookbook.ui + +import android.app.AlertDialog +import android.app.Dialog +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import androidx.navigation.fragment.findNavController +import ru.otus.cookbook.R + +/** + * Диалог подтверждения удаления рецепта + */ +class DeleteRecipeDialogFragment : DialogFragment() { + + companion object { + const val CONFIRMATION_RESULT = "confirmation_result" + } + + // Получение названия рецепта через Safe Args + private val recipeTitle: String get() = DeleteRecipeDialogFragmentArgs.fromBundle(requireArguments()).recipeTitle + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = + AlertDialog.Builder(requireContext()) + .setTitle(R.string.delete) + .setMessage(getString(R.string.delete_recipe_confirmation, recipeTitle)) + .setPositiveButton(android.R.string.ok) { _, _ -> + dismiss() + setResult(true) + } + .setNegativeButton(android.R.string.cancel) { _, _ -> + dismiss() + setResult(false) + } + .create() + + private fun setResult(result: Boolean) { + findNavController().previousBackStackEntry?.savedStateHandle?.set(CONFIRMATION_RESULT, result) + findNavController().popBackStack() + } +} + 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..d2851c6 100644 --- a/app/src/main/kotlin/ru/otus/cookbook/ui/RecipeFragment.kt +++ b/app/src/main/kotlin/ru/otus/cookbook/ui/RecipeFragment.kt @@ -1,28 +1,39 @@ package ru.otus.cookbook.ui +import android.annotation.SuppressLint 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.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.viewmodel.MutableCreationExtras +import ru.otus.cookbook.R +import androidx.navigation.fragment.findNavController +import androidx.navigation.ui.setupWithNavController +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide import kotlinx.coroutines.launch import ru.otus.cookbook.data.Recipe import ru.otus.cookbook.databinding.FragmentRecipeBinding +import androidx.activity.OnBackPressedCallback 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") + // Получение ID рецепта через SafeArgs + private val recipeId: Int get() = RecipeFragmentArgs.fromBundle(requireArguments()).recipeId private val binding = FragmentBindingDelegate(this) private val model: RecipeFragmentViewModel by viewModels( extrasProducer = { - MutableCreationExtras(defaultViewModelCreationExtras).apply { - set(RecipeFragmentViewModel.RECIPE_ID_KEY, recipeId) - } + androidx.lifecycle.viewmodel.MutableCreationExtras(defaultViewModelCreationExtras) + .apply { + set(RecipeFragmentViewModel.RECIPE_ID_KEY, recipeId) + } }, factoryProducer = { RecipeFragmentViewModel.Factory } ) @@ -38,6 +49,10 @@ class RecipeFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + setupToolbar() + setupDeleteButton() + setupDeleteResult() + setupBackPressHandler() viewLifecycleOwner.lifecycleScope.launch { model.recipe .flowWithLifecycle(viewLifecycleOwner.lifecycle) @@ -45,18 +60,135 @@ class RecipeFragment : Fragment() { } } - /** - * Use to get recipe title and pass to confirmation dialog - */ + private fun setupToolbar() = binding.withBinding { + toolbar.setupWithNavController(findNavController()) + } + + private fun setupDeleteButton() = binding.withBinding { + deleteButton.setOnClickListener { + // Навигация в диалог подтверждения удаления с передачей названия рецепта + val action = RecipeFragmentDirections.actionRecipeToDeleteDialog(getTitle()) + findNavController().navigate(action) + } + } + + private fun setupDeleteResult() { + val navBackStackEntry = findNavController().getBackStackEntry(R.id.recipeFragment) + + val observer = object : DefaultLifecycleObserver { + override fun onResume(owner: LifecycleOwner) { + // Проверка результата подтверждения удаления + if (navBackStackEntry.savedStateHandle.contains(DeleteRecipeDialogFragment.CONFIRMATION_RESULT)) { + val confirmed = + navBackStackEntry.savedStateHandle.get(DeleteRecipeDialogFragment.CONFIRMATION_RESULT) == true + + if (confirmed) { + deleteRecipe() + // Возврат к списку рецептов после удаления + findNavController().popBackStack() + } + + // Удаление результата из SavedStateHandle + navBackStackEntry.savedStateHandle.remove( + DeleteRecipeDialogFragment.CONFIRMATION_RESULT + ) + } + } + } + + navBackStackEntry.lifecycle.addObserver(observer) + + viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + navBackStackEntry.lifecycle.removeObserver(observer) + } + }) + } + + private fun setupBackPressHandler() { + requireActivity().onBackPressedDispatcher.addCallback( + viewLifecycleOwner, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + // Возвращаемся к списку рецептов + findNavController().popBackStack() + } + }) + } + private fun getTitle(): String { return model.recipe.value.title } - private fun displayRecipe(recipe: Recipe) { - // Display the recipe + private fun displayRecipe(recipe: Recipe) = binding.withBinding { + // Заголовок рецепта + recipeTitle.text = recipe.title + + // Категория рецепта + recipeCategory.text = recipe.category.name + + // Загрузка изображения через Glide + Glide.with(this@RecipeFragment) + .load(recipe.imageUrl) + .into(recipeImage) + + recipeImage.setImageResource(android.R.drawable.ic_menu_gallery) + + // Описание рецепта + recipeDescription.text = recipe.description + + // Настройка списка шагов приготовления + stepsLabel.text = getString(R.string.title_recipe_steps) + stepsRecycler.layoutManager = LinearLayoutManager(requireContext()) + stepsRecycler.adapter = StepsAdapter(recipe.steps) } private fun deleteRecipe() { model.delete() } +} + +private class StepsAdapter(private val steps: List) : + RecyclerView.Adapter() { + + @SuppressLint("ResourceType") + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StepViewHolder { + val view = com.google.android.material.textview.MaterialTextView(parent.context).apply { + layoutParams = ViewGroup.MarginLayoutParams( + ViewGroup.MarginLayoutParams.MATCH_PARENT, + ViewGroup.MarginLayoutParams.WRAP_CONTENT + ).apply { + val margin = parent.context.resources.getDimensionPixelSize( + ru.otus.cookbook.R.dimen.margin_small + ) + setMargins(margin, margin, margin, margin) + } + + setTextAppearance( + parent.context, + com.google.android.material.R.attr.textAppearanceBody1 + ) + setPadding( + parent.context.resources.getDimensionPixelSize(ru.otus.cookbook.R.dimen.margin), + 0, + parent.context.resources.getDimensionPixelSize(ru.otus.cookbook.R.dimen.margin), + 0 + ) + } + return StepViewHolder(view) + } + + override fun onBindViewHolder(holder: StepViewHolder, position: Int) { + holder.bind(steps[position], position + 1) + } + + override fun getItemCount(): Int = steps.size + + class StepViewHolder(private val textView: com.google.android.material.textview.MaterialTextView) : + RecyclerView.ViewHolder(textView) { + @SuppressLint("SetTextI18n") + fun bind(step: String, number: Int) { + textView.text = "$number. $step" + } + } } \ No newline at end of file diff --git a/app/src/main/kotlin/ru/otus/cookbook/ui/RecipeListAdapter.kt b/app/src/main/kotlin/ru/otus/cookbook/ui/RecipeListAdapter.kt new file mode 100644 index 0000000..93477fd --- /dev/null +++ b/app/src/main/kotlin/ru/otus/cookbook/ui/RecipeListAdapter.kt @@ -0,0 +1,124 @@ +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 com.bumptech.glide.Glide +import com.bumptech.glide.R +import ru.otus.cookbook.data.RecipeListItem +import ru.otus.cookbook.databinding.VhRecipeCategoryBinding +import ru.otus.cookbook.databinding.VhRecipeItemBinding + +class RecipeListAdapter( + private val onRecipeClick: (Int) -> Unit +) : ListAdapter(RecipeListItemDiff) { + + companion object { + // Типы элементов списка + private const val TYPE_CATEGORY = 0 + private const val TYPE_RECIPE = 1 + } + + override fun getItemViewType(position: Int): Int { + return when (getItem(position)) { + is RecipeListItem.CategoryItem -> TYPE_CATEGORY + is RecipeListItem.RecipeItem -> TYPE_RECIPE + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(parent.context) + return when (viewType) { + TYPE_CATEGORY -> { + CategoryViewHolder( + VhRecipeCategoryBinding.inflate(inflater, parent, false) + ) + } + TYPE_RECIPE -> { + RecipeViewHolder( + VhRecipeItemBinding.inflate(inflater, parent, false), + onRecipeClick + ) + } + else -> throw IllegalArgumentException("Unknown view type: $viewType") + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (val item = getItem(position)) { + is RecipeListItem.CategoryItem -> { + (holder as CategoryViewHolder).bind(item) + } + is RecipeListItem.RecipeItem -> { + (holder as RecipeViewHolder).bind(item) + } + } + } + + /** + * ViewHolder для категории рецептов + */ + class CategoryViewHolder( + private val binding: VhRecipeCategoryBinding + ) : RecyclerView.ViewHolder(binding.root) { + + fun bind(item: RecipeListItem.CategoryItem) { + binding.name.text = item.name + } + } + + /** + * ViewHolder для элемента рецепта + */ + class RecipeViewHolder( + private val binding: VhRecipeItemBinding, + private val onRecipeClick: (Int) -> Unit + ) : RecyclerView.ViewHolder(binding.root) { + + private var recipeId: Int = -1 + + init { + // Обработка клика на элемент рецепта + binding.root.setOnClickListener { + if (recipeId != -1) { + onRecipeClick(recipeId) + } + } + } + + fun bind(item: RecipeListItem.RecipeItem) { + recipeId = item.id + // Отображение первой буквы названия рецепта + binding.letterText.text = item.title.firstOrNull()?.uppercase().orEmpty() + // Отображение заголовка рецепта + binding.title.text = item.title + + // Загрузка изображения через Glide + Glide.with(binding.root.context) + .load(item.imageUrl) + .into(binding.previewImage) + } + } +} + +private object RecipeListItemDiff : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: RecipeListItem, newItem: RecipeListItem): Boolean { + return when { + oldItem is RecipeListItem.CategoryItem && newItem is RecipeListItem.CategoryItem -> { + oldItem.category == newItem.category + } + oldItem is RecipeListItem.RecipeItem && newItem is RecipeListItem.RecipeItem -> { + oldItem.id == newItem.id + } + else -> false + } + } + + override fun areContentsTheSame(oldItem: RecipeListItem, newItem: RecipeListItem): Boolean { + return oldItem == newItem + } +} + + diff --git a/app/src/main/res/anim/fade_in.xml b/app/src/main/res/anim/fade_in.xml new file mode 100644 index 0000000..231f72e --- /dev/null +++ b/app/src/main/res/anim/fade_in.xml @@ -0,0 +1,7 @@ + + + diff --git a/app/src/main/res/anim/fade_out.xml b/app/src/main/res/anim/fade_out.xml new file mode 100644 index 0000000..18b63d0 --- /dev/null +++ b/app/src/main/res/anim/fade_out.xml @@ -0,0 +1,7 @@ + + + diff --git a/app/src/main/res/anim/slide_in.xml b/app/src/main/res/anim/slide_in.xml new file mode 100644 index 0000000..bcc3268 --- /dev/null +++ b/app/src/main/res/anim/slide_in.xml @@ -0,0 +1,7 @@ + + + diff --git a/app/src/main/res/anim/slide_out.xml b/app/src/main/res/anim/slide_out.xml new file mode 100644 index 0000000..93bb159 --- /dev/null +++ b/app/src/main/res/anim/slide_out.xml @@ -0,0 +1,7 @@ + + + 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..01f2667 --- /dev/null +++ b/app/src/main/res/drawable/ic_back.xml @@ -0,0 +1,15 @@ + + + + + + + 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..e9f04a6 --- /dev/null +++ b/app/src/main/res/drawable/ic_search.xml @@ -0,0 +1,13 @@ + + + + + + \ 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..125047f 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -7,13 +7,15 @@ android:layout_height="match_parent" tools:context=".MainActivity"> - \ 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..5e00a4b 100644 --- a/app/src/main/res/layout/fragment_cookbook.xml +++ b/app/src/main/res/layout/fragment_cookbook.xml @@ -1,6 +1,42 @@ - - \ No newline at end of file + + + + + + + + + + + \ 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..7005f45 100644 --- a/app/src/main/res/layout/fragment_recipe.xml +++ b/app/src/main/res/layout/fragment_recipe.xml @@ -1,6 +1,124 @@ - - \ 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..2caed67 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..6f8d123 100644 --- a/app/src/main/res/layout/vh_recipe_item.xml +++ b/app/src/main/res/layout/vh_recipe_item.xml @@ -1,6 +1,64 @@ - + - \ 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..3af5e51 --- /dev/null +++ b/app/src/main/res/navigation/nav_graph.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml new file mode 100644 index 0000000..ec207d2 --- /dev/null +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -0,0 +1,8 @@ + + + Книга рецептов + Изображение рецепта + Удалить + Вы уверены, что хотите удалить рецепт \"%1$s\"? + Шаги приготовления + \ 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..6eddd71 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,7 @@ Cookbook + Recipe image + Delete + Are you sure you want to delete the recipe \"%1$s\"? + Steps \ 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..c9c1d4c 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,9 +1,11 @@ - + - \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 515d8d1..076094d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,11 +10,13 @@ appcompat = "1.7.0" material = "1.12.0" activity = "1.9.3" constraintlayout = "2.2.0" +recyclerview = "1.3.2" coroutines = "1.9.0" junitVersion = "1.2.1" espressoCore = "3.6.1" serialization = "1.7.3" datastore = "1.1.1" +glide = "4.16.0" [libraries] @@ -35,6 +37,8 @@ androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version material = { group = "com.google.android.material", name = "material", version.ref = "material" } androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } +androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" } +glide = { group = "com.github.bumptech.glide", name = "glide", version.ref = "glide" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }