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..0f330b5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,9 +2,11 @@ + + Unit +) : + ListAdapter(RecipeDiff) { + + override fun getItemViewType(position: Int): Int { + return getItem(position).layoutId + } + + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ) = when (viewType) { + RecipeListItem.CategoryItem.layoutId -> CookbookViewHolder.CategoryHolder( + VhRecipeCategoryBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + + RecipeListItem.RecipeItem.layoutId -> CookbookViewHolder.RecipeHolder( + VhRecipeItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ), onRecipeClick + ) + + else -> throw IllegalArgumentException("View is not supported") + } + + override fun onBindViewHolder(holder: CookbookViewHolder, position: Int) { + when (holder) { + is CookbookViewHolder.CategoryHolder -> holder.bind(getItem(position) as RecipeListItem.CategoryItem) + is CookbookViewHolder.RecipeHolder -> holder.bind(getItem(position) as RecipeListItem.RecipeItem) + } + } + + sealed class CookbookViewHolder(view: View) : ViewHolder(view) { + class RecipeHolder( + private val binding: VhRecipeItemBinding, + private val onClick: (Int) -> Unit + ) : CookbookViewHolder(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 + txtRecipeAvatar.text = recipe.title[0].uppercase() + txtRecipeName.text = recipe.title + txtRecipeDescription.text = recipe.description + Glide.with(root.context).load(recipe.imageUrl).centerCrop().into(imgRecipeImage) + } + + } + + class CategoryHolder( + private val binding: VhRecipeCategoryBinding + ) : + CookbookViewHolder(binding.root) { + + fun bind(category: RecipeListItem.CategoryItem) = with(binding) { + txtCategoryName.text = category.name + } + } + } + +} + +private object RecipeDiff : 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.category == newItem.category + else -> false + } + } + + override fun areContentsTheSame(oldItem: RecipeListItem, newItem: RecipeListItem): Boolean { + return oldItem == newItem + } +} + + 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..bdd8d78 100644 --- a/app/src/main/kotlin/ru/otus/cookbook/ui/CookbookFragment.kt +++ b/app/src/main/kotlin/ru/otus/cookbook/ui/CookbookFragment.kt @@ -4,10 +4,12 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.Toast 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 @@ -17,6 +19,14 @@ class CookbookFragment : Fragment() { private val binding = FragmentBindingDelegate(this) private val model: CookbookFragmentViewModel by viewModels { CookbookFragmentViewModel.Factory } + private val adapter = CookbookAdapter( + { id -> + val navController = findNavController() + val action = CookbookFragmentDirections.actionCookbookToRecipe(id) + navController.navigate(action) + } + ) + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -29,6 +39,7 @@ class CookbookFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setupRecyclerView() + setupAppBar() viewLifecycleOwner.lifecycleScope.launch { model.recipeList .flowWithLifecycle(viewLifecycleOwner.lifecycle) @@ -36,11 +47,20 @@ class CookbookFragment : Fragment() { } } + private fun setupAppBar() { + binding.withBinding { + cookbookToolbar.setNavigationOnClickListener { + Toast.makeText(requireContext(), "Close application?", Toast.LENGTH_LONG).show() + } + } + } + + private fun setupRecyclerView() = binding.withBinding { - // Setup RecyclerView + recViewCategory.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/DialogFragmentDelete.kt b/app/src/main/kotlin/ru/otus/cookbook/ui/DialogFragmentDelete.kt new file mode 100644 index 0000000..cf2c766 --- /dev/null +++ b/app/src/main/kotlin/ru/otus/cookbook/ui/DialogFragmentDelete.kt @@ -0,0 +1,40 @@ +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 DialogFragmentDelete : DialogFragment() { + private val title: String get() = DialogFragmentDeleteArgs.fromBundle(requireArguments()).title + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = + requireContext().let { + val navController = findNavController() + AlertDialog.Builder(it) + + .setTitle(getString(R.string.delete_dialog_title, title)) + .setMessage(getString(R.string.delete_dialog_message, title)) + .setPositiveButton(getString(R.string.delete_dialog_positive_button)) { _, _ -> + navController.previousBackStackEntry?.savedStateHandle?.set( + DIALOG_RESULT_KEY, + true + ) + } + .setNegativeButton(getString(R.string.delete_dialog_negative_button)) { _, _ -> + navController.previousBackStackEntry?.savedStateHandle?.set( + DIALOG_RESULT_KEY, + false + ) + } + .create() + } + + companion object { + const val DIALOG_RESULT_KEY = "deleteResult" + } + +} + 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..969cf74 100644 --- a/app/src/main/kotlin/ru/otus/cookbook/ui/RecipeFragment.kt +++ b/app/src/main/kotlin/ru/otus/cookbook/ui/RecipeFragment.kt @@ -9,15 +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 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( extrasProducer = { MutableCreationExtras(defaultViewModelCreationExtras).apply { @@ -27,6 +30,7 @@ class RecipeFragment : Fragment() { factoryProducer = { RecipeFragmentViewModel.Factory } ) + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -38,6 +42,8 @@ class RecipeFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + setupAppBar() + setupDialogObserver() viewLifecycleOwner.lifecycleScope.launch { model.recipe .flowWithLifecycle(viewLifecycleOwner.lifecycle) @@ -45,6 +51,16 @@ class RecipeFragment : Fragment() { } } + private fun setupDialogObserver() { + findNavController().currentBackStackEntry?.savedStateHandle?.getLiveData( + DialogFragmentDelete.DIALOG_RESULT_KEY + )?.observe(viewLifecycleOwner) { result -> + if (result) { + deleteRecipe() + } + } + } + /** * Use to get recipe title and pass to confirmation dialog */ @@ -52,11 +68,39 @@ class RecipeFragment : Fragment() { return model.recipe.value.title } + private fun setupAppBar() { + binding.withBinding { + recipeToolbar.setNavigationOnClickListener { + findNavController().navigate(R.id.action_recipe_to_cookbook) + } + recipeToolbar.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + R.id.delete_recipe -> { + val navController = findNavController() + val action = RecipeFragmentDirections.actionRecipeToDialogDelete(getTitle()) + navController.navigate(action) + true + } + + else -> false + } + } + } + } + private fun displayRecipe(recipe: Recipe) { - // Display the recipe + binding.withBinding { + txtRecipeName.text = recipe.title + txtRecipeDescription.text = recipe.description + txtRecipeSteps.text = + recipe.steps.mapIndexed { index, step -> "${index + 1}. $step" }.joinToString("\n") + Glide.with(root.context).load(recipe.imageUrl).into(imgRecipeImageBig) + recipeToolbar.title = recipe.title + } } private fun deleteRecipe() { model.delete() + findNavController().navigate(R.id.action_dialog_delete_to_cookbook) } } \ No newline at end of file diff --git a/app/src/main/res/anim/slide_in_left.xml b/app/src/main/res/anim/slide_in_left.xml new file mode 100644 index 0000000..2e2ffcd --- /dev/null +++ b/app/src/main/res/anim/slide_in_left.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_in_right.xml b/app/src/main/res/anim/slide_in_right.xml new file mode 100644 index 0000000..8669814 --- /dev/null +++ b/app/src/main/res/anim/slide_in_right.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_out_left.xml b/app/src/main/res/anim/slide_out_left.xml new file mode 100644 index 0000000..59195b4 --- /dev/null +++ b/app/src/main/res/anim/slide_out_left.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_out_right.xml b/app/src/main/res/anim/slide_out_right.xml new file mode 100644 index 0000000..8669814 --- /dev/null +++ b/app/src/main/res/anim/slide_out_right.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/arrow_back_24dp.xml b/app/src/main/res/drawable/arrow_back_24dp.xml new file mode 100644 index 0000000..1c25a9a --- /dev/null +++ b/app/src/main/res/drawable/arrow_back_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/category_80dp.xml b/app/src/main/res/drawable/category_80dp.xml new file mode 100644 index 0000000..8642d79 --- /dev/null +++ b/app/src/main/res/drawable/category_80dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/category_xxx_width.xml b/app/src/main/res/drawable/category_xxx_width.xml new file mode 100644 index 0000000..0aa162e --- /dev/null +++ b/app/src/main/res/drawable/category_xxx_width.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/circle_background.xml b/app/src/main/res/drawable/circle_background.xml new file mode 100644 index 0000000..d7c8e96 --- /dev/null +++ b/app/src/main/res/drawable/circle_background.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/close_24dp.xml b/app/src/main/res/drawable/close_24dp.xml new file mode 100644 index 0000000..7393823 --- /dev/null +++ b/app/src/main/res/drawable/close_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/delete_24dp.xml b/app/src/main/res/drawable/delete_24dp.xml new file mode 100644 index 0000000..7c0eee8 --- /dev/null +++ b/app/src/main/res/drawable/delete_24dp.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..68a355d 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,19 +1,13 @@ - - - - \ 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..0657f10 100644 --- a/app/src/main/res/layout/fragment_cookbook.xml +++ b/app/src/main/res/layout/fragment_cookbook.xml @@ -1,6 +1,55 @@ + android:layout_height="match_parent" + android:fitsSystemWindows="true"> + + + + + + + + + + + + + + + + + + + + \ 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..65ebc3f 100644 --- a/app/src/main/res/layout/fragment_recipe.xml +++ b/app/src/main/res/layout/fragment_recipe.xml @@ -1,6 +1,99 @@ + android:layout_height="match_parent" + android:fitsSystemWindows="true"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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..becf3f4 100644 --- a/app/src/main/res/layout/vh_recipe_category.xml +++ b/app/src/main/res/layout/vh_recipe_category.xml @@ -1,6 +1,29 @@ + + + + \ 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..53ad9a1 100644 --- a/app/src/main/res/layout/vh_recipe_item.xml +++ b/app/src/main/res/layout/vh_recipe_item.xml @@ -1,6 +1,79 @@ + xmlns:app="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:padding="5dp"> + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/recipe_menu.xml b/app/src/main/res/menu/recipe_menu.xml new file mode 100644 index 0000000..bf0e4cb --- /dev/null +++ b/app/src/main/res/menu/recipe_menu.xml @@ -0,0 +1,9 @@ + + + + \ 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..0905028 --- /dev/null +++ b/app/src/main/res/navigation/nav_graph.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index c8524cd..7c49f40 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -2,4 +2,10 @@ #FF000000 #FFFFFFFF + + #F3EDF7 + #DADCE0 + + #F3EDF7 + #4F378A \ 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..9bb1026 --- /dev/null +++ b/app/src/main/res/values/dimens.xml @@ -0,0 +1,16 @@ + + + 32sp + 10dp + 24sp + 24sp + 36sp + 80dp + 40dp + 16sp + 10dp + 16sp + 10dp + 5dp + 5dp + \ 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..3f93997 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,19 @@ Cookbook + Cookbook + Recipe name placeholder + A recipe image + Recipe Description placeholder + Recipe steps Lorem ipsum Quisque blandit dolor vel ullamcorper fringilla. Etiam ut ultricies nibh. Maecenas sit amet ipsum at felis convallis luctus. Praesent at urna ac massa eleifend iaculis. Donec at venenatis metus. Donec malesuada sapien eget neque finibus, vel aliquam mi lacinia. Mauris a sem elementum, pretium diam nec, luctus neque. Praesent lacinia imperdiet erat, eget feugiat mi feugiat ut. Proin iaculis, massa eu lacinia tempor, dui lacus pellentesque dui, ut dapibus tellus purus at augue. Nam sagittis lobortis justo vel porta. Proin vel vulputate quam. Vivamus at vestibulum est. Phasellus a arcu lacus. Interdum et malesuada fames ac ante ipsum primis in faucibus. Nullam elit mauris, pharetra vitae libero sit amet, efficitur rhoncus mauris. + Category placeholder + + + Delete %s recipe + Are you sure you want to delete the "%s" recipe? + Yes + No + C + delete + monospace + \ 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..3110cc2 --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 515d8d1..594d23c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,23 +1,25 @@ [versions] agp = "8.7.3" +glideVersion = "4.16.0" kotlin = "2.1.0" -coreKtx = "1.15.0" -fragmentKtx = "1.8.5" +coreKtx = "1.16.0" +fragmentKtx = "1.8.6" lifecycleKtx = "2.8.7" -navigation = "2.8.5" +navigation = "2.8.9" junit = "4.13.2" appcompat = "1.7.0" material = "1.12.0" -activity = "1.9.3" -constraintlayout = "2.2.0" +activity = "1.10.1" +constraintlayout = "2.2.1" coroutines = "1.9.0" junitVersion = "1.2.1" espressoCore = "3.6.1" serialization = "1.7.3" -datastore = "1.1.1" +datastore = "1.1.5" [libraries] +github-glide = { module = "com.github.bumptech.glide:glide", version.ref = "glideVersion" } 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" }