diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f3207a6..f8dc461 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.coil) 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..6e79293 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,7 +1,7 @@ - + () { + private var list = listOf() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when (viewType) { + ViewTypes.CATEGORY.id -> { + val view = LayoutInflater.from(parent.context).inflate(R.layout.vh_recipe_category, parent, false) + CategoryViewHolder(view) + } + ViewTypes.RECIPE.id -> { + val view = LayoutInflater.from(parent.context).inflate(R.layout.vh_recipe_item, parent, false) + RecipeViewHolder(view, listener) + } + else -> throw IllegalArgumentException("Not found view type for chat adapter") + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val item = list.getOrNull(position) ?: return + when (item) { + is RecipeListItem.CategoryItem -> { + (holder as? CategoryViewHolder)?.bind(item) + } + is RecipeListItem.RecipeItem -> { + (holder as? RecipeViewHolder)?.bind(item) + } + } + } + + override fun getItemCount(): Int = list.size + + override fun getItemViewType(position: Int): Int { + return when (list[position]) { + is RecipeListItem.CategoryItem -> ViewTypes.CATEGORY.id + is RecipeListItem.RecipeItem -> ViewTypes.RECIPE.id + } + } + + fun setItems(items: List) { + list = items + notifyDataSetChanged() + } + + enum class ViewTypes(val id: Int) { + CATEGORY(R.layout.vh_recipe_category), + RECIPE(R.layout.vh_recipe_item) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/ru/otus/cookbook/Listener.kt b/app/src/main/kotlin/ru/otus/cookbook/Listener.kt new file mode 100644 index 0000000..74c1cf9 --- /dev/null +++ b/app/src/main/kotlin/ru/otus/cookbook/Listener.kt @@ -0,0 +1,5 @@ +package ru.otus.cookbook + +interface Listener { + fun onItemClicked(id: Int) +} \ No newline at end of file diff --git a/app/src/main/kotlin/ru/otus/cookbook/MainActivity.kt b/app/src/main/kotlin/ru/otus/cookbook/MainActivity.kt index 6e524b6..9d064a6 100644 --- a/app/src/main/kotlin/ru/otus/cookbook/MainActivity.kt +++ b/app/src/main/kotlin/ru/otus/cookbook/MainActivity.kt @@ -1,16 +1,55 @@ package ru.otus.cookbook import android.os.Bundle +import android.view.Menu +import android.widget.ImageButton +import androidx.activity.addCallback import androidx.appcompat.app.AppCompatActivity +import androidx.navigation.findNavController +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.ui.AppBarConfiguration +import androidx.navigation.ui.setupActionBarWithNavController +import com.google.android.material.appbar.MaterialToolbar import ru.otus.cookbook.databinding.ActivityMainBinding class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding + val fragmentsWithButton = setOf( + R.id.fragment_recipe, + ) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) + + val toolbar = findViewById(R.id.topAppBar) + setSupportActionBar(toolbar) + + val navHostFragment = supportFragmentManager + .findFragmentById(R.id.fragment_container_view) as NavHostFragment + val navController = navHostFragment.navController + + val appBarConfiguration = AppBarConfiguration(navController.graph) + setupActionBarWithNavController(navController, appBarConfiguration) + + onBackPressedDispatcher.addCallback(this) { + if (!findNavController(R.id.fragment_container_view).popBackStack()) { + finish() + } + } + + navController.addOnDestinationChangedListener { _, destination, _ -> + val shouldShow = fragmentsWithButton.contains(destination.id) + toolbar.findViewById(R.id.deleteButton)?.visibility = + if (shouldShow) ImageButton.VISIBLE else ImageButton.GONE + } + } + + override fun onSupportNavigateUp(): Boolean { + val navController = findNavController(R.id.fragment_container_view) + return navController.navigateUp() || super.onSupportNavigateUp() } } \ No newline at end of file diff --git a/app/src/main/kotlin/ru/otus/cookbook/RecipeViewHolder.kt b/app/src/main/kotlin/ru/otus/cookbook/RecipeViewHolder.kt new file mode 100644 index 0000000..d6bd619 --- /dev/null +++ b/app/src/main/kotlin/ru/otus/cookbook/RecipeViewHolder.kt @@ -0,0 +1,23 @@ +package ru.otus.cookbook + +import android.view.View +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import ru.otus.cookbook.data.RecipeListItem + +class RecipeViewHolder( + private val view: View, + private val listener: Listener): RecyclerView.ViewHolder(view) { + + private val twName: TextView by lazy { view.findViewById(R.id.tw_name) } + private val twDescription: TextView by lazy { view.findViewById(R.id.tw_description) } + + fun bind(item: RecipeListItem.RecipeItem) { + twName.text = item.title + twDescription.text = item.description + + view.setOnClickListener { + listener.onItemClicked(item.id) + } + } +} \ 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..3b9f2c7 100644 --- a/app/src/main/kotlin/ru/otus/cookbook/ui/CookbookFragment.kt +++ b/app/src/main/kotlin/ru/otus/cookbook/ui/CookbookFragment.kt @@ -8,14 +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 androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.appbar.MaterialToolbar import kotlinx.coroutines.launch +import ru.otus.cookbook.CookbookAdapter +import ru.otus.cookbook.Listener +import ru.otus.cookbook.R import ru.otus.cookbook.data.RecipeListItem import ru.otus.cookbook.databinding.FragmentCookbookBinding -class CookbookFragment : Fragment() { +class CookbookFragment : Fragment(), Listener { private val binding = FragmentBindingDelegate(this) private val model: CookbookFragmentViewModel by viewModels { CookbookFragmentViewModel.Factory } + private val cookbookAdapter = CookbookAdapter(this@CookbookFragment) + override fun onCreateView( inflater: LayoutInflater, @@ -37,10 +46,18 @@ class CookbookFragment : Fragment() { } private fun setupRecyclerView() = binding.withBinding { - // Setup RecyclerView + val dividerItemDecoration = DividerItemDecoration(context, LinearLayoutManager.VERTICAL) + recyclerView.addItemDecoration(dividerItemDecoration) + recyclerView.adapter = cookbookAdapter } private fun onRecipeListUpdated(recipeList: List) { - // Handle recipe list + cookbookAdapter.setItems(recipeList) + } + + override fun onItemClicked(id: Int) { + findNavController().navigate(CookbookFragmentDirections.actionCookbookToRecipe( + recipeID = id + )) } } \ No newline at end of file diff --git a/app/src/main/kotlin/ru/otus/cookbook/ui/RecipeDeleteDialogFragment.kt b/app/src/main/kotlin/ru/otus/cookbook/ui/RecipeDeleteDialogFragment.kt new file mode 100644 index 0000000..b0e7e85 --- /dev/null +++ b/app/src/main/kotlin/ru/otus/cookbook/ui/RecipeDeleteDialogFragment.kt @@ -0,0 +1,30 @@ +package ru.otus.cookbook.ui + +import android.app.Dialog +import android.os.Bundle +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import androidx.navigation.fragment.findNavController +import ru.otus.cookbook.R + +class RecipeDeleteDialogFragment : DialogFragment() { + private val title get() = RecipeDeleteDialogFragmentArgs.fromBundle(requireArguments()).title + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = + requireContext().let { + AlertDialog.Builder(it, R.style.RoundedDialog) + .setTitle(R.string.rdf_title) + .setMessage(getString(R.string.rdf_message, title)) + .setPositiveButton(R.string.rdf_ok) { _, _ -> + findNavController().previousBackStackEntry?.savedStateHandle?.set(RESULT, 1) + } + .setNeutralButton(R.string.rdf_cancel) { dialog, _ -> + dialog.dismiss() + } + .create() + } + + companion object { + const val RESULT = "result" + } +} \ 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..61a05ee 100644 --- a/app/src/main/kotlin/ru/otus/cookbook/ui/RecipeFragment.kt +++ b/app/src/main/kotlin/ru/otus/cookbook/ui/RecipeFragment.kt @@ -2,20 +2,27 @@ package ru.otus.cookbook.ui import android.os.Bundle import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import android.view.View import android.view.ViewGroup +import android.widget.ImageButton import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewmodel.MutableCreationExtras +import androidx.navigation.fragment.findNavController import kotlinx.coroutines.launch import ru.otus.cookbook.data.Recipe import ru.otus.cookbook.databinding.FragmentRecipeBinding +import coil.load +import com.google.android.material.appbar.MaterialToolbar +import ru.otus.cookbook.R 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( @@ -26,6 +33,14 @@ class RecipeFragment : Fragment() { }, factoryProducer = { RecipeFragmentViewModel.Factory } ) + private val navigationController by lazy { findNavController() } + private val toolbar by lazy { activity?.findViewById(R.id.topAppBar) } + private val deleteButton by lazy { toolbar?.findViewById(R.id.deleteButton) } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setHasOptionsMenu(true) + } override fun onCreateView( inflater: LayoutInflater, @@ -43,20 +58,41 @@ class RecipeFragment : Fragment() { .flowWithLifecycle(viewLifecycleOwner.lifecycle) .collect(::displayRecipe) } + + displayRecipe(model.recipe.value) + + navigationController.currentBackStackEntry?.savedStateHandle?.getLiveData( + RecipeDeleteDialogFragment.RESULT) + ?.observe(viewLifecycleOwner) { + if (it == 1) { + deleteRecipe() + } + } + + + toolbar?.title = getTitle() + deleteButton?.setOnClickListener { + navigationController.navigate(RecipeFragmentDirections.actionRecipeToDialog( + getTitle())) + } } - /** - * Use to get recipe title and pass to confirmation dialog - */ private fun getTitle(): String { return model.recipe.value.title } private fun displayRecipe(recipe: Recipe) { - // Display the recipe + binding.withBinding { + twName.text = recipe.title + twDescription.text = recipe.description + imageView.load(recipe.imageUrl) { + crossfade(true) + } + } } private fun deleteRecipe() { model.delete() + findNavController().popBackStack(R.id.fragment_cookbook, false) } } \ 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..2640a8d --- /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..e2022e6 --- /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..a5c9daa --- /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..5f0e616 --- /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/dialog_background.xml b/app/src/main/res/drawable/dialog_background.xml new file mode 100644 index 0000000..412c55b --- /dev/null +++ b/app/src/main/res/drawable/dialog_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/divider.xml b/app/src/main/res/drawable/divider.xml new file mode 100644 index 0000000..44e6f26 --- /dev/null +++ b/app/src/main/res/drawable/divider.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rounded_corners.xml b/app/src/main/res/drawable/rounded_corners.xml new file mode 100644 index 0000000..c90dc07 --- /dev/null +++ b/app/src/main/res/drawable/rounded_corners.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/trailing_icon.xml b/app/src/main/res/drawable/trailing_icon.xml new file mode 100644 index 0000000..42848c8 --- /dev/null +++ b/app/src/main/res/drawable/trailing_icon.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 86a5d97..5e12186 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,19 +1,38 @@ - + xmlns:app="http://schemas.android.com/apk/res-auto" + android:orientation="vertical"> + + + + + + - + + - \ 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..d0170af 100644 --- a/app/src/main/res/layout/fragment_cookbook.xml +++ b/app/src/main/res/layout/fragment_cookbook.xml @@ -1,6 +1,15 @@ + 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..3d65d58 100644 --- a/app/src/main/res/layout/fragment_recipe.xml +++ b/app/src/main/res/layout/fragment_recipe.xml @@ -1,6 +1,50 @@ + + + + + \ 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..0bdedc4 100644 --- a/app/src/main/res/layout/vh_recipe_category.xml +++ b/app/src/main/res/layout/vh_recipe_category.xml @@ -1,6 +1,24 @@ + xmlns:app="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="@color/white"> + \ 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..36bbef8 100644 --- a/app/src/main/res/layout/vh_recipe_item.xml +++ b/app/src/main/res/layout/vh_recipe_item.xml @@ -1,6 +1,37 @@ + + + + \ 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..9933c13 --- /dev/null +++ b/app/src/main/res/navigation/nav_graph.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + \ 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..b571605 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,10 @@ Cookbook + Delete + Hello blank fragment + Delete + Are you sure you want to delete %1$s? + Yes + Cancel + Image \ 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..5f0ee17 --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + \ 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..94ea169 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,9 +1,16 @@ + + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 515d8d1..76db4b0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,6 @@ [versions] agp = "8.7.3" +coil = "2.5.0" kotlin = "2.1.0" coreKtx = "1.15.0" fragmentKtx = "1.8.5" @@ -18,6 +19,7 @@ datastore = "1.1.1" [libraries] +coil = { module = "io.coil-kt:coil", version.ref = "coil" } 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" } @@ -35,8 +37,6 @@ 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-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } -androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }