Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,12 @@ dependencies {
implementation(libs.androidx.appcompat)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.navigation.fragment)
//api(libs.androidx.navigation.fragment.ktx)
implementation(libs.androidx.navigation.ui)
implementation(libs.material)
implementation(libs.androidx.activity)
implementation(libs.androidx.constraintlayout)
implementation(libs.coil)
testImplementation(libs.junit)
testImplementation(libs.kotlin.coroutines.test)
}
5 changes: 3 additions & 2 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.INTERNET"/>

<application
android:allowBackup="true"
android:name=".App"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:icon="@mipmap/ic_launcher_cook"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
Expand All @@ -18,7 +20,6 @@
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
Expand Down
Binary file added app/src/main/ic_launcher_cook-playstore.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
26 changes: 14 additions & 12 deletions app/src/main/kotlin/ru/otus/cookbook/data/RecipeRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,17 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update

/**
* Provides access to the list of recipes.
* Предоставляет доступ к списку рецептов.
*/
class RecipeRepository(recipes: List<Recipe>) {

private val nextId = recipes.maxOfOrNull { it.id }?.plus(1) ?: 1
private val recipes = MutableStateFlow(recipes)

/**
* Returns the list of recipes as a flow.
* @param scope The coroutine scope to use for the flow.
* @param filter The filter to apply to the recipes.
* Возвращает список рецептов в виде потока.
* @param scope Область видимости корутины для потока.
* @param filter Фильтр для применения к рецептам.
*/
suspend fun getRecipes(scope: CoroutineScope, filter: RecipeFilter): StateFlow<List<Recipe>> = recipes
.map { recipes -> recipes.asSequence()
Expand All @@ -29,21 +29,23 @@ class RecipeRepository(recipes: List<Recipe>) {
.stateIn(scope)

/**
* Returns the list of categories as a flow.
* @param scope The coroutine scope to use for the flow.
* Возвращает список категорий в виде потока.
* @param scope Область видимости корутины для потока.
*/
suspend fun getCategories(scope: CoroutineScope): StateFlow<List<RecipeCategory>> = recipes
.map { recipes -> recipes.map { it.category }.distinct().sorted() }
.map { recipes -> recipes.map { it.category }.distinct().sorted() }
.stateIn(scope)

/**
* Returns the recipe with the specified ID.
* Возвращает рецепт по указанному идентификатору.
* @param id Идентификатор рецепта.
* @return Рецепт с указанным идентификатором или null, если рецепт не найден.
*/
fun getRecipe(id: Int): Recipe? = recipes.value.find { it.id == id }

/**
* Returns the list of recipes as a flow.
* @param id The ID of the recipe to delete.
* Удаляет рецепт с указанным идентификатором.
* @param id Идентификатор рецепта для удаления.
*/
fun deleteRecipe(id: Int) {
recipes.update { list ->
Expand All @@ -52,8 +54,8 @@ class RecipeRepository(recipes: List<Recipe>) {
}

/**
* Adds a recipe to the list.
* @param recipe The recipe to add.
* Добавляет новый рецепт в список.
* @param recipe Рецепт для добавления.
*/
fun addRecipe(recipe: Recipe) {
recipes.update { list ->
Expand Down
31 changes: 31 additions & 0 deletions app/src/main/kotlin/ru/otus/cookbook/data/Utils.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package ru.otus.cookbook.data

import android.util.Log
import android.widget.ImageView
import coil.load
import coil.request.CachePolicy
import coil.request.ErrorResult
import coil.request.ImageRequest
import coil.request.SuccessResult
import coil.transform.Transformation
import ru.otus.cookbook.R

fun loadImage(imageView: ImageView, imageUrl: String, vararg transformations: Transformation) {
imageView.load(imageUrl) {
setHeader("User-Agent", "Mozilla/5.0")
placeholder(R.drawable.cart_item_icon)
error(R.drawable.ic_launcher_background)
transformations(*transformations) // Optional: Apply transformations
memoryCachePolicy(CachePolicy.ENABLED) // Optional: Enable memory caching
diskCachePolicy(CachePolicy.ENABLED) // Optional: Enable disk caching
listener(
onSuccess = { request: ImageRequest, result: SuccessResult ->
Log.d("Coil", "Image loaded successfully from ${result.dataSource}")
},
onError = { request: ImageRequest, result: ErrorResult ->
Log.e("Coil", "Image load failed: ${result.throwable.message}")
imageView.setImageResource(R.drawable.ic_launcher_background)
}
)
}
}
68 changes: 61 additions & 7 deletions app/src/main/kotlin/ru/otus/cookbook/ui/CookbookFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,49 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
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 androidx.recyclerview.widget.DividerItemDecoration
import kotlinx.coroutines.launch
import ru.otus.cookbook.data.RecipeListItem
import ru.otus.cookbook.databinding.FragmentCookbookBinding

class CookbookFragment : Fragment() {
/**
* Фрагмент для отображения списка рецептов
*/
class CookbookFragment : Fragment(), ItemListener {

private val binding = FragmentBindingDelegate<FragmentCookbookBinding>(this)
private val model: CookbookFragmentViewModel by viewModels { CookbookFragmentViewModel.Factory }

private val recipeListDiffAdapter: RecipeListDiffAdapter by lazy { RecipeListDiffAdapter(this) }


/**
* Создает и возвращает представление фрагмента
*
* @param inflater Объект LayoutInflater для создания представления
* @param container Родительская ViewGroup
* @param savedInstanceState Сохраненное состояние фрагмента
* @return Представление фрагмента
*/
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View = binding.bind(
container,
FragmentCookbookBinding::inflate
)
): View = binding.bind(container, FragmentCookbookBinding::inflate)

/**
* Вызывается после создания представления фрагмента
*
* @param view Представление фрагмента
* @param savedInstanceState Сохраненное состояние фрагмента
*/
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupRecyclerView()
Expand All @@ -36,11 +57,44 @@ class CookbookFragment : Fragment() {
}
}

/**
* Настраивает RecyclerView для отображения списка рецептов
*/
private fun setupRecyclerView() = binding.withBinding {
// Setup RecyclerView
recycleView.addItemDecoration(
DividerItemDecoration(
this@CookbookFragment.requireActivity(),
LinearLayout.VERTICAL
)
)
recycleView.adapter = recipeListDiffAdapter
}

/**
* Обновляет список рецептов в адаптере
*
* @param recipeList Новый список рецептов для отображения
*/
private fun onRecipeListUpdated(recipeList: List<RecipeListItem>) {
// Handle recipe list
recipeListDiffAdapter.submitList(recipeList)
}

/**
* Обрабатывает нажатие на элемент списка рецептов
*
* @param id Идентификатор выбранного рецепта
*/
override fun onItemClick(id: Int) {
findNavController()
.navigate(CookbookFragmentDirections.actionOpenRecipe(id))
}

/**
* Обрабатывает свайп по элементу списка рецептов
*
* @param id Идентификатор рецепта, по которому был совершен свайп
*/
override fun onSwipe(id: Int) {
Toast.makeText(requireContext(), "Swiped $id", Toast.LENGTH_SHORT).show()
}
}
9 changes: 9 additions & 0 deletions app/src/main/kotlin/ru/otus/cookbook/ui/ItemListener.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package ru.otus.cookbook.ui

import java.util.UUID

interface ItemListener {
fun onItemClick(id: Int)
fun onSwipe(id: Int)

}
74 changes: 68 additions & 6 deletions app/src/main/kotlin/ru/otus/cookbook/ui/RecipeFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,30 @@ package ru.otus.cookbook.ui

import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
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 androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import coil.transform.RoundedCornersTransformation
import kotlinx.coroutines.launch
import ru.otus.cookbook.R
import ru.otus.cookbook.data.Recipe
import ru.otus.cookbook.data.loadImage
import ru.otus.cookbook.databinding.FragmentRecipeBinding
import ru.otus.cookbook.ui.dialog.DeleteConfirmationDialog.Companion.CONFIRMATION_RESULT

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<FragmentRecipeBinding>(this)
private val model: RecipeFragmentViewModel by viewModels(
Expand All @@ -31,20 +41,35 @@ class RecipeFragment : Fragment() {
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View = binding.bind(
container,
FragmentRecipeBinding::inflate
)
): View = binding.bind(container, FragmentRecipeBinding::inflate)


override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupAlertDeleteResult()
binding.withBinding {
topAppBar.setNavigationOnClickListener(::navigateBackToCookBook)
topAppBar.setOnMenuItemClickListener(::navigateToRemoveDialog)
}
viewLifecycleOwner.lifecycleScope.launch {
model.recipe
.flowWithLifecycle(viewLifecycleOwner.lifecycle)
.collect(::displayRecipe)
}
}

private fun navigateToRemoveDialog(menuItem: MenuItem): Boolean =
if (menuItem.itemId == R.id.menu_delete) {
findNavController()
.navigate(RecipeFragmentDirections.actionOpenDeleteConfirmationDialog(getTitle()))
true
} else false


private fun navigateBackToCookBook(v: View?) {
findNavController().navigate(RecipeFragmentDirections.actionBackToCookbook())
}

/**
* Use to get recipe title and pass to confirmation dialog
*/
Expand All @@ -53,7 +78,44 @@ class RecipeFragment : Fragment() {
}

private fun displayRecipe(recipe: Recipe) {
// Display the recipe
binding.withBinding {
if (recipe.imageUrl.isNotEmpty()) {
loadImage(recipeAvatar, recipe.imageUrl, RoundedCornersTransformation())
} else {
recipeAvatar.setImageResource(R.drawable.cart_item_icon)
}

recipeTitle.text = recipe.title.ifEmpty { getString(R.string.no_title) }
recipeDescription.text =
recipe.description.ifEmpty { getString(R.string.no_description) }

recipeStep.text = if (recipe.steps.isNotEmpty()) {
recipe.steps.joinToString("\n • ", "• ")
} else {
getString(R.string.no_steps)
}
}
}

private fun setupAlertDeleteResult() {
val navBackStackEntry = findNavController().getBackStackEntry(R.id.recipeFragment)
val observer = object : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) {
if (navBackStackEntry.savedStateHandle.contains(CONFIRMATION_RESULT)) {
if (true == navBackStackEntry.savedStateHandle.get<Boolean>(CONFIRMATION_RESULT))
deleteRecipe()
findNavController().popBackStack()
}
}
}

navBackStackEntry.lifecycle.addObserver(observer)

viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
navBackStackEntry.lifecycle.removeObserver(observer)
}
})
}

private fun deleteRecipe() {
Expand Down
Loading