diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f3207a6..1b3b0dc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -48,6 +48,7 @@ dependencies { implementation(libs.material) implementation(libs.androidx.activity) implementation(libs.androidx.constraintlayout) + implementation(libs.github.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 @@ + + + // Handle recipe click, e.g., navigate to RecipeFragment + val action = CookbookFragmentDirections.actionToRecipeFragment(recipeId) + findNavController().navigate(action) + } + + recyclerView.layoutManager = LinearLayoutManager(requireContext()) + recyclerView.adapter = adapter } - private fun onRecipeListUpdated(recipeList: List) { + private fun onRecipeListUpdated(recipeList: List) = binding.withBinding { // Handle recipe list + (recyclerView.adapter as RecipeAdapter).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..f3a37b5 --- /dev/null +++ b/app/src/main/kotlin/ru/otus/cookbook/ui/DeleteRecipeDialogFragment.kt @@ -0,0 +1,45 @@ +package ru.otus.cookbook.ui + +import android.app.Dialog +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import ru.otus.cookbook.R + +class DeleteRecipeDialogFragment : DialogFragment() { + + companion object { + const val CONFIRMATION_RESULT = "confirmation_result" + } + + private val args: DeleteRecipeDialogFragmentArgs by navArgs() + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val recipeTitle = args.recipeTitle + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(getString(R.string.delete)) + .setMessage(getString(R.string.delete_recipe_message, recipeTitle)) + .setPositiveButton(R.string.ok) { _, _ -> + dismiss() + setResult(true) + } + .setNegativeButton(R.string.cancel + ) { _, _ -> + dismiss() + setResult(false) + } + .create() + } + + private fun setResult(result: Boolean) { + val navController = findNavController() + navController.previousBackStackEntry?.savedStateHandle?.set( + CONFIRMATION_RESULT, + result + ) + navController.popBackStack() + } + +} \ 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..1b4ee18 --- /dev/null +++ b/app/src/main/kotlin/ru/otus/cookbook/ui/RecipeAdapter.kt @@ -0,0 +1,81 @@ +package ru.otus.cookbook.ui + +import android.content.Context +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 ru.otus.cookbook.R +import ru.otus.cookbook.data.RecipeListItem +import ru.otus.cookbook.databinding.VhRecipeCategoryBinding +import ru.otus.cookbook.databinding.VhRecipeItemBinding + +class RecipeAdapter(private val context: Context, private val onRecipeClick: (Int) -> Unit): + ListAdapter(RecipeDiffCallback()) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when (viewType) { + R.layout.vh_recipe_item-> { + val binding = + VhRecipeItemBinding.inflate(LayoutInflater.from(context), parent, false) + RecipeViewHolder(binding) + } + + R.layout.vh_recipe_category-> { + val binding = + VhRecipeCategoryBinding.inflate(LayoutInflater.from(context), parent, false) + CategoryViewHolder(binding) + } + + else -> throw IllegalArgumentException(context.getString(R.string.unknown_view_type)) + } + } + + override fun getItemViewType(position: Int): Int { + return when (getItem(position)) { + is RecipeListItem.RecipeItem -> R.layout.vh_recipe_item + is RecipeListItem.CategoryItem -> R.layout.vh_recipe_category + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (val item = getItem(position)) { + is RecipeListItem.RecipeItem -> (holder as RecipeViewHolder).bind(item) + is RecipeListItem.CategoryItem -> (holder as CategoryViewHolder).bind(item) + } + } + + inner class RecipeViewHolder(private val binding: VhRecipeItemBinding) : RecyclerView.ViewHolder(binding.root) { + fun bind(item: RecipeListItem.RecipeItem) { + binding.firstLetter.text = item.title.firstOrNull()?.toString() ?: "" + binding.title.text = item.title + binding.description.text = item.description + Glide.with(binding.root.context) + .load(item.imageUrl) + .centerCrop() + .into(binding.image) + binding.root.setOnClickListener { onRecipeClick(item.id) } + } + } + + inner class CategoryViewHolder(private val binding: VhRecipeCategoryBinding) : RecyclerView.ViewHolder(binding.root) { + fun bind(item: RecipeListItem.CategoryItem) { + binding.categoryName.text = item.name + } + } +} + +class RecipeDiffCallback : 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/RecipeFragment.kt b/app/src/main/kotlin/ru/otus/cookbook/ui/RecipeFragment.kt index e4460c1..e36aaf5 100644 --- a/app/src/main/kotlin/ru/otus/cookbook/ui/RecipeFragment.kt +++ b/app/src/main/kotlin/ru/otus/cookbook/ui/RecipeFragment.kt @@ -9,14 +9,18 @@ import androidx.fragment.app.viewModels import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewmodel.MutableCreationExtras +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +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 args: RecipeFragmentArgs by navArgs() + private val recipeId: Int get() = args.recipeId private val binding = FragmentBindingDelegate(this) private val model: RecipeFragmentViewModel by viewModels( extrasProducer = { @@ -38,6 +42,19 @@ class RecipeFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + + setupAppBar() + + val navController = findNavController() + navController.currentBackStackEntry?.savedStateHandle?.getLiveData( + DeleteRecipeDialogFragment.CONFIRMATION_RESULT + )?.observe(viewLifecycleOwner) { result-> + if (result) { + model.delete() + navController.navigateUp() + } + } + viewLifecycleOwner.lifecycleScope.launch { model.recipe .flowWithLifecycle(viewLifecycleOwner.lifecycle) @@ -52,11 +69,32 @@ class RecipeFragment : Fragment() { return model.recipe.value.title } - private fun displayRecipe(recipe: Recipe) { - // Display the recipe + private fun setupAppBar() { + binding.withBinding { + recipeToolbar.setNavigationOnClickListener { + findNavController().navigate(R.id.action_to_cookbook) + } + recipeToolbar.setOnMenuItemClickListener { menuItem-> + when (menuItem.itemId) { + R.id.delete -> { + val action = RecipeFragmentDirections.actionToDeleteRecipeDialog(getTitle()) + findNavController().navigate(action) + true + } + + else -> false + } + } + } } - private fun deleteRecipe() { - model.delete() + private fun displayRecipe(recipe: Recipe) = binding.withBinding { + // Display the recipe + recipeTitle.text = recipe.title + recipeShortDescription.text = recipe.category.name + recipeFullDescription.text = recipe.description + Glide.with(this@RecipeFragment) + .load(recipe.imageUrl) + .into(recipeImage) } } \ No newline at end of file diff --git a/app/src/main/res/drawable/arrow_back_24px.xml b/app/src/main/res/drawable/arrow_back_24px.xml new file mode 100644 index 0000000..0e2e863 --- /dev/null +++ b/app/src/main/res/drawable/arrow_back_24px.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/circle.xml b/app/src/main/res/drawable/circle.xml new file mode 100644 index 0000000..e8ab92e --- /dev/null +++ b/app/src/main/res/drawable/circle.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/close_24px.xml b/app/src/main/res/drawable/close_24px.xml new file mode 100644 index 0000000..7a0ff35 --- /dev/null +++ b/app/src/main/res/drawable/close_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/delete_24px.xml b/app/src/main/res/drawable/delete_24px.xml new file mode 100644 index 0000000..d724c2e --- /dev/null +++ b/app/src/main/res/drawable/delete_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 86a5d97..bbdeab1 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -7,13 +7,11 @@ 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..0c6495d 100644 --- a/app/src/main/res/layout/fragment_cookbook.xml +++ b/app/src/main/res/layout/fragment_cookbook.xml @@ -1,6 +1,44 @@ + android:layout_height="match_parent" + xmlns:app="http://schemas.android.com/apk/res-auto"> + + + + + + + + + \ 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..724cb9e 100644 --- a/app/src/main/res/layout/fragment_recipe.xml +++ b/app/src/main/res/layout/fragment_recipe.xml @@ -1,6 +1,73 @@ + android:layout_height="match_parent" + xmlns:app="http://schemas.android.com/apk/res-auto"> - \ 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..a64ac49 100644 --- a/app/src/main/res/layout/vh_recipe_category.xml +++ b/app/src/main/res/layout/vh_recipe_category.xml @@ -1,6 +1,19 @@ + xmlns:app="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginHorizontal="16dp" + > + + \ 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..b0f99b4 100644 --- a/app/src/main/res/layout/vh_recipe_item.xml +++ b/app/src/main/res/layout/vh_recipe_item.xml @@ -1,6 +1,62 @@ - + - \ No newline at end of file + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/recipe_app_bar.xml b/app/src/main/res/menu/recipe_app_bar.xml new file mode 100644 index 0000000..91be031 --- /dev/null +++ b/app/src/main/res/menu/recipe_app_bar.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/cookbook_graph.xml b/app/src/main/res/navigation/cookbook_graph.xml new file mode 100644 index 0000000..ac291ed --- /dev/null +++ b/app/src/main/res/navigation/cookbook_graph.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + \ 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..60cc69a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,17 @@ Cookbook + Close + Search + Delete + Unknown view type + Recipe image description + Title + Short description + Full description + A + First letter + Recipe image + Are you sure you want to delete %1$s? + Cancel + OK \ 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..adc4036 --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 515d8d1..d12c941 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.7.3" +agp = "8.10.0" kotlin = "2.1.0" coreKtx = "1.15.0" fragmentKtx = "1.8.5" @@ -15,6 +15,7 @@ junitVersion = "1.2.1" espressoCore = "3.6.1" serialization = "1.7.3" datastore = "1.1.1" +glideVersion = "4.16.0" [libraries] @@ -37,6 +38,7 @@ 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" } +github-glide = { module = "com.github.bumptech.glide:glide", version.ref = "glideVersion" } [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..b88a568 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.11.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists