Implement adding recipes through app

This commit is contained in:
Kirill Kamakin
2022-05-26 13:29:10 +02:00
parent 986d8f377f
commit e18f726da5
37 changed files with 1105 additions and 78 deletions

View File

@@ -6,6 +6,7 @@ import android.view.MenuItem
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.net.toUri
import androidx.core.view.isVisible
import androidx.navigation.findNavController
import com.google.android.material.shape.CornerFamily
import com.google.android.material.shape.MaterialShapeDrawable
@@ -27,14 +28,30 @@ class MainActivity : AppCompatActivity() {
binding = MainActivityBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.toolbar)
supportActionBar?.setIcon(R.drawable.ic_toolbar)
binding.toolbar.setNavigationIcon(R.drawable.ic_toolbar)
binding.toolbar.setNavigationOnClickListener { binding.drawer.open() }
setToolbarRoundCorner()
viewModel.uiStateLive.observe(this, ::onUiStateChange)
binding.navigationView.setNavigationItemSelectedListener(::onNavigationItemSelected)
}
private fun onNavigationItemSelected(menuItem: MenuItem): Boolean {
Timber.v("onNavigationItemSelected() called with: menuItem = $menuItem")
menuItem.isChecked = true
val deepLink = when (menuItem.itemId) {
R.id.add_recipe -> ADD_RECIPE_DEEP_LINK
R.id.recipes_list -> RECIPES_LIST_DEEP_LINK
else -> throw IllegalArgumentException("Unknown menu item id: ${menuItem.itemId}")
}
navigateDeepLink(deepLink)
binding.drawer.close()
return true
}
private fun onUiStateChange(uiState: MainActivityUiState) {
Timber.v("onUiStateChange() called with: uiState = $uiState")
supportActionBar?.title = if (uiState.titleVisible) title else null
binding.navigationView.isVisible = uiState.navigationVisible
invalidateOptionsMenu()
}
@@ -49,8 +66,7 @@ class MainActivity : AppCompatActivity() {
for (drawable in drawables) {
drawable?.apply {
shapeAppearanceModel = shapeAppearanceModel.toBuilder()
.setBottomLeftCorner(CornerFamily.ROUNDED, radius)
.build()
.setBottomLeftCorner(CornerFamily.ROUNDED, radius).build()
}
}
}
@@ -67,7 +83,7 @@ class MainActivity : AppCompatActivity() {
Timber.v("onOptionsItemSelected() called with: item = $item")
val result = when (item.itemId) {
R.id.login -> {
navigateToLogin()
navigateDeepLink(AUTH_DEEP_LINK)
true
}
R.id.logout -> {
@@ -79,8 +95,14 @@ class MainActivity : AppCompatActivity() {
return result
}
private fun navigateToLogin() {
Timber.v("navigateToLogin() called")
findNavController(binding.navHost.id).navigate("mealient://authenticate".toUri())
private fun navigateDeepLink(deepLink: String) {
Timber.v("navigateDeepLink() called with: deepLink = $deepLink")
findNavController(binding.navHost.id).navigate(deepLink.toUri())
}
companion object {
private const val AUTH_DEEP_LINK = "mealient://authenticate"
private const val ADD_RECIPE_DEEP_LINK = "mealient://recipe/add"
private const val RECIPES_LIST_DEEP_LINK = "mealient://recipe/list"
}
}

View File

@@ -4,6 +4,7 @@ data class MainActivityUiState(
val loginButtonVisible: Boolean = false,
val titleVisible: Boolean = true,
val isAuthorized: Boolean = false,
val navigationVisible: Boolean = false,
) {
val canShowLogin: Boolean
get() = !isAuthorized && loginButtonVisible

View File

@@ -0,0 +1,175 @@
package gq.kirmanak.mealient.ui.add
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.constraintlayout.helper.widget.Flow
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import by.kirich1409.viewbindingdelegate.viewBinding
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.add.models.AddRecipeIngredient
import gq.kirmanak.mealient.data.add.models.AddRecipeInstruction
import gq.kirmanak.mealient.data.add.models.AddRecipeRequest
import gq.kirmanak.mealient.data.add.models.AddRecipeSettings
import gq.kirmanak.mealient.databinding.FragmentAddRecipeBinding
import gq.kirmanak.mealient.databinding.ViewSingleInputBinding
import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty
import gq.kirmanak.mealient.extensions.collectWhenViewResumed
import gq.kirmanak.mealient.ui.activity.MainActivityViewModel
import timber.log.Timber
@AndroidEntryPoint
class AddRecipeFragment : Fragment(R.layout.fragment_add_recipe) {
private val binding by viewBinding(FragmentAddRecipeBinding::bind)
private val viewModel by viewModels<AddRecipeViewModel>()
private val activityViewModel by activityViewModels<MainActivityViewModel>()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
activityViewModel.updateUiState {
it.copy(loginButtonVisible = true, titleVisible = false, navigationVisible = true)
}
viewModel.loadPreservedRequest()
setupViews()
observeAddRecipeResult()
}
private fun observeAddRecipeResult() {
Timber.v("observeAddRecipeResult() called")
collectWhenViewResumed(viewModel.addRecipeResult, ::onRecipeSaveResult)
}
private fun onRecipeSaveResult(isSuccessful: Boolean) = with(binding) {
Timber.v("onRecipeSaveResult() called with: isSuccessful = $isSuccessful")
listOf(clearButton, saveRecipeButton).forEach { it.isEnabled = true }
val toastText = if (isSuccessful) {
R.string.fragment_add_recipe_save_success
} else {
R.string.fragment_add_recipe_save_error
}
Toast.makeText(requireContext(), getString(toastText), Toast.LENGTH_SHORT).show()
}
private fun setupViews() = with(binding) {
Timber.v("setupViews() called")
saveRecipeButton.setOnClickListener {
recipeNameInput.checkIfInputIsEmpty(
inputLayout = recipeNameInputLayout,
lifecycleOwner = viewLifecycleOwner,
stringId = R.string.fragment_add_recipe_name_error
) ?: return@setOnClickListener
listOf(saveRecipeButton, clearButton).forEach { it.isEnabled = false }
viewModel.saveRecipe()
}
clearButton.setOnClickListener { viewModel.clear() }
newIngredientButton.setOnClickListener {
inflateInputRow(ingredientsFlow, R.string.fragment_add_recipe_ingredient_hint)
}
newInstructionButton.setOnClickListener {
inflateInputRow(instructionsFlow, R.string.fragment_add_recipe_instruction_hint)
}
listOf(
recipeNameInput,
recipeDescriptionInput,
recipeYieldInput
).forEach { it.doAfterTextChanged { saveValues() } }
listOf(
publicRecipe,
disableComments
).forEach { it.setOnCheckedChangeListener { _, _ -> saveValues() } }
collectWhenViewResumed(viewModel.preservedAddRecipeRequest, ::onSavedInputLoaded)
}
private fun inflateInputRow(flow: Flow, @StringRes hintId: Int, text: String? = null) {
Timber.v("inflateInputRow() called with: flow = $flow, hintId = $hintId, text = $text")
val fragmentRoot = binding.holder
val inputBinding = ViewSingleInputBinding.inflate(layoutInflater, fragmentRoot, false)
val root = inputBinding.root
root.setHint(hintId)
val input = inputBinding.input
input.setText(text)
input.doAfterTextChanged { saveValues() }
root.id = View.generateViewId()
fragmentRoot.addView(root)
flow.addView(root)
root.setEndIconOnClickListener {
flow.removeView(root)
fragmentRoot.removeView(root)
}
}
private fun saveValues() = with(binding) {
Timber.v("saveValues() called")
val instructions = parseInputRows(instructionsFlow).map { AddRecipeInstruction(text = it) }
val ingredients = parseInputRows(ingredientsFlow).map { AddRecipeIngredient(note = it) }
val settings = AddRecipeSettings(
public = publicRecipe.isChecked,
disableComments = disableComments.isChecked,
)
viewModel.preserve(
AddRecipeRequest(
name = recipeNameInput.text.toString(),
description = recipeDescriptionInput.text.toString(),
recipeYield = recipeYieldInput.text.toString(),
recipeIngredient = ingredients,
recipeInstructions = instructions,
settings = settings
)
)
}
private fun parseInputRows(flow: Flow): List<String> =
flow.referencedIds.asSequence()
.mapNotNull { binding.holder.findViewById(it) }
.map { ViewSingleInputBinding.bind(it) }
.map { it.input.text.toString() }
.filterNot { it.isBlank() }
.toList()
private fun onSavedInputLoaded(request: AddRecipeRequest) = with(binding) {
Timber.v("onSavedInputLoaded() called with: request = $request")
recipeNameInput.setText(request.name)
recipeDescriptionInput.setText(request.description)
recipeYieldInput.setText(request.recipeYield)
publicRecipe.isChecked = request.settings.public
disableComments.isChecked = request.settings.disableComments
request.recipeIngredient.map { it.note }
.showIn(ingredientsFlow, R.string.fragment_add_recipe_ingredient_hint)
request.recipeInstructions.map { it.text }
.showIn(instructionsFlow, R.string.fragment_add_recipe_instruction_hint)
}
private fun Iterable<String>.showIn(flow: Flow, @StringRes hintId: Int) {
Timber.v("showIn() called with: flow = $flow, hintId = $hintId")
flow.removeAllViews()
forEach { inflateInputRow(flow = flow, hintId = hintId, text = it) }
}
private fun Flow.removeAllViews() {
Timber.v("removeAllViews() called")
for (id in referencedIds.iterator()) {
val view = binding.holder.findViewById<View>(id) ?: continue
removeView(view)
binding.holder.removeView(view)
}
}
}

View File

@@ -0,0 +1,63 @@
package gq.kirmanak.mealient.ui.add
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.add.AddRecipeRepo
import gq.kirmanak.mealient.data.add.models.AddRecipeRequest
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class AddRecipeViewModel @Inject constructor(
private val addRecipeRepo: AddRecipeRepo,
) : ViewModel() {
private val _addRecipeResultChannel = Channel<Boolean>(Channel.UNLIMITED)
val addRecipeResult: Flow<Boolean> get() = _addRecipeResultChannel.receiveAsFlow()
private val _preservedAddRecipeRequestChannel = Channel<AddRecipeRequest>(Channel.UNLIMITED)
val preservedAddRecipeRequest: Flow<AddRecipeRequest>
get() = _preservedAddRecipeRequestChannel.receiveAsFlow()
fun loadPreservedRequest() {
Timber.v("loadPreservedRequest() called")
viewModelScope.launch { doLoadPreservedRequest() }
}
private suspend fun doLoadPreservedRequest() {
Timber.v("doLoadPreservedRequest() called")
val request = addRecipeRepo.addRecipeRequestFlow.first()
Timber.d("doLoadPreservedRequest: request = $request")
_preservedAddRecipeRequestChannel.send(request)
}
fun clear() {
Timber.v("clear() called")
viewModelScope.launch {
addRecipeRepo.clear()
doLoadPreservedRequest()
}
}
fun preserve(request: AddRecipeRequest) {
Timber.v("preserve() called with: request = $request")
viewModelScope.launch { addRecipeRepo.preserve(request) }
}
fun saveRecipe() {
Timber.v("saveRecipe() called")
viewModelScope.launch {
val isSuccessful = runCatchingExceptCancel { addRecipeRepo.saveRecipe() }
.fold(onSuccess = { true }, onFailure = { false })
Timber.d("saveRecipe: isSuccessful = $isSuccessful")
_addRecipeResultChannel.send(isSuccessful)
}
}
}

View File

@@ -26,7 +26,9 @@ class AuthenticationFragment : Fragment(R.layout.fragment_authentication) {
super.onViewCreated(view, savedInstanceState)
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
binding.button.setOnClickListener { onLoginClicked() }
activityViewModel.updateUiState { it.copy(loginButtonVisible = false, titleVisible = true) }
activityViewModel.updateUiState {
it.copy(loginButtonVisible = false, titleVisible = true, navigationVisible = false)
}
viewModel.uiState.observe(viewLifecycleOwner, ::onUiStateChange)
}

View File

@@ -28,7 +28,9 @@ class BaseURLFragment : Fragment(R.layout.fragment_base_url) {
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
binding.button.setOnClickListener(::onProceedClick)
viewModel.uiState.observe(viewLifecycleOwner, ::onUiStateChange)
activityViewModel.updateUiState { it.copy(loginButtonVisible = false, titleVisible = true) }
activityViewModel.updateUiState {
it.copy(loginButtonVisible = false, titleVisible = true, navigationVisible = false)
}
}
private fun onProceedClick(view: View) {

View File

@@ -50,6 +50,8 @@ class DisclaimerFragment : Fragment(R.layout.fragment_disclaimer) {
binding.okay.isClickable = it == 0
}
viewModel.startCountDown()
activityViewModel.updateUiState { it.copy(loginButtonVisible = false, titleVisible = true) }
activityViewModel.updateUiState {
it.copy(loginButtonVisible = false, titleVisible = true, navigationVisible = false)
}
}
}

View File

@@ -34,7 +34,9 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
activityViewModel.updateUiState { it.copy(loginButtonVisible = true, titleVisible = false) }
activityViewModel.updateUiState {
it.copy(loginButtonVisible = true, titleVisible = false, navigationVisible = true)
}
setupRecipeAdapter()
}