Replace "Mealie" with "Mealient" everywhere

This commit is contained in:
Kirill Kamakin
2021-11-20 13:41:47 +03:00
parent d789bfcf97
commit 5866584d14
81 changed files with 283 additions and 284 deletions

View File

@@ -0,0 +1,8 @@
package gq.kirmanak.mealient.ui
import android.widget.ImageView
import androidx.annotation.DrawableRes
interface ImageLoader {
fun loadImage(url: String?, @DrawableRes placeholderId: Int, imageView: ImageView)
}

View File

@@ -0,0 +1,42 @@
package gq.kirmanak.mealient.ui
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.onFailure
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.collectLatest
import timber.log.Timber
@ExperimentalCoroutinesApi
object SwipeRefreshLayoutHelper {
private fun SwipeRefreshLayout.refreshesFlow(): Flow<Unit> {
Timber.v("refreshesFlow() called")
return callbackFlow {
val listener = SwipeRefreshLayout.OnRefreshListener {
Timber.v("Refresh requested")
trySendBlocking(Unit).onFailure { Timber.e(it, "Can't send refresh callback") }
}
Timber.v("Adding refresh request listener")
setOnRefreshListener(listener)
awaitClose {
Timber.v("Removing refresh request listener")
setOnRefreshListener(null)
}
}
}
suspend fun <T : Any, VH : RecyclerView.ViewHolder> PagingDataAdapter<T, VH>.listenToRefreshRequests(
refreshLayout: SwipeRefreshLayout
) {
Timber.v("listenToRefreshRequests() called")
refreshLayout.refreshesFlow().collectLatest {
Timber.d("Received refresh request")
refresh()
}
}
}

View File

@@ -0,0 +1,14 @@
package gq.kirmanak.mealient.ui
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.ui.glide.ImageLoaderGlide
@Module
@InstallIn(SingletonComponent::class)
interface UiModule {
@Binds
fun bindImageLoader(imageLoaderGlide: ImageLoaderGlide): ImageLoader
}

View File

@@ -0,0 +1,107 @@
package gq.kirmanak.mealient.ui.auth
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import com.google.android.material.textfield.TextInputLayout
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.databinding.FragmentAuthenticationBinding
import kotlinx.coroutines.flow.collectLatest
import timber.log.Timber
@AndroidEntryPoint
class AuthenticationFragment : Fragment() {
private var _binding: FragmentAuthenticationBinding? = null
private val binding: FragmentAuthenticationBinding
get() = checkNotNull(_binding) { "Binding requested when fragment is off screen" }
private val viewModel by viewModels<AuthenticationViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Timber.v("onCreate() called with: savedInstanceState = $savedInstanceState")
listenToAuthenticationStatuses()
}
private fun listenToAuthenticationStatuses() {
Timber.d("listenToAuthenticationStatuses() called")
lifecycleScope.launchWhenCreated {
viewModel.authenticationStatuses().collectLatest {
Timber.d("listenToAuthenticationStatuses: new status = $it")
if (it) navigateToRecipes()
}
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
Timber.v("onCreateView() called with: inflater = $inflater, container = $container, savedInstanceState = $savedInstanceState")
_binding = FragmentAuthenticationBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
binding.button.setOnClickListener { onLoginClicked() }
}
private fun navigateToRecipes() {
Timber.v("navigateToRecipes() called")
findNavController().navigate(AuthenticationFragmentDirections.actionAuthenticationFragmentToRecipesFragment())
}
private fun onLoginClicked() {
Timber.v("onLoginClicked() called")
val email: String
val pass: String
val url: String
with(binding) {
email = checkIfInputIsEmpty(emailInput, emailInputLayout) {
"Email is empty"
} ?: return
pass = checkIfInputIsEmpty(passwordInput, passwordInputLayout) {
"Pass is empty"
} ?: return
url = checkIfInputIsEmpty(urlInput, urlInputLayout) {
"URL is empty"
} ?: return
}
viewLifecycleOwner.lifecycleScope.launchWhenResumed {
runCatching {
viewModel.authenticate(email, pass, url)
}.onFailure {
Timber.e(it, "Can't authenticate")
}
}
}
private fun checkIfInputIsEmpty(
input: EditText,
inputLayout: TextInputLayout,
errorText: () -> String
): String? {
Timber.v("checkIfInputIsEmpty() called with: input = $input, inputLayout = $inputLayout, errorText = $errorText")
val text = input.text?.toString()
Timber.d("Input text is \"$text\"")
if (text.isNullOrBlank()) {
inputLayout.error = errorText()
return null
}
return text
}
override fun onDestroyView() {
super.onDestroyView()
Timber.v("onDestroyView() called")
_binding = null
}
}

View File

@@ -0,0 +1,37 @@
package gq.kirmanak.mealient.ui.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.recipes.RecipeRepo
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class AuthenticationViewModel @Inject constructor(
private val authRepo: AuthRepo,
private val recipeRepo: RecipeRepo
) : ViewModel() {
init {
Timber.v("constructor called")
}
suspend fun authenticate(username: String, password: String, baseUrl: String) {
Timber.v("authenticate() called with: username = $username, password = $password, baseUrl = $baseUrl")
authRepo.authenticate(username, password, baseUrl)
}
fun authenticationStatuses(): Flow<Boolean> {
Timber.v("authenticationStatuses() called")
return authRepo.authenticationStatuses()
}
fun logout() {
Timber.v("logout() called")
authRepo.logout()
viewModelScope.launch { recipeRepo.clearLocalData() }
}
}

View File

@@ -0,0 +1,16 @@
package gq.kirmanak.mealient.ui.glide
import android.widget.ImageView
import androidx.annotation.DrawableRes
import gq.kirmanak.mealient.ui.ImageLoader
import javax.inject.Inject
class ImageLoaderGlide @Inject constructor() : ImageLoader {
override fun loadImage(url: String?, @DrawableRes placeholderId: Int, imageView: ImageView) {
GlideApp.with(imageView)
.load(url)
.centerCrop()
.placeholder(placeholderId)
.into(imageView)
}
}

View File

@@ -0,0 +1,7 @@
package gq.kirmanak.mealient.ui.glide
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.module.AppGlideModule
@GlideModule
class MainGlideModule : AppGlideModule()

View File

@@ -0,0 +1,22 @@
package gq.kirmanak.mealient.ui.recipes
import androidx.recyclerview.widget.RecyclerView
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.databinding.ViewHolderRecipeBinding
class RecipeViewHolder(
private val binding: ViewHolderRecipeBinding,
private val recipeViewModel: RecipeViewModel,
private val clickListener: (RecipeSummaryEntity) -> Unit
) : RecyclerView.ViewHolder(binding.root) {
private val loadingPlaceholder by lazy {
binding.root.resources.getString(R.string.view_holder_recipe_text_placeholder)
}
fun bind(item: RecipeSummaryEntity?) {
binding.name.text = item?.name ?: loadingPlaceholder
recipeViewModel.loadRecipeImage(binding.image, item)
item?.let { entity -> binding.root.setOnClickListener { clickListener(entity) } }
}
}

View File

@@ -0,0 +1,25 @@
package gq.kirmanak.mealient.ui.recipes
import android.widget.ImageView
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.recipes.RecipeImageLoader
import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class RecipeViewModel @Inject constructor(
recipeRepo: RecipeRepo,
private val recipeImageLoader: RecipeImageLoader
) : ViewModel() {
val recipeFlow = recipeRepo.createPager().flow
fun loadRecipeImage(view: ImageView, recipeSummary: RecipeSummaryEntity?) {
viewModelScope.launch {
recipeImageLoader.loadRecipeImage(view, recipeSummary?.slug)
}
}
}

View File

@@ -0,0 +1,99 @@
package gq.kirmanak.mealient.ui.recipes
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.databinding.FragmentRecipesBinding
import gq.kirmanak.mealient.ui.SwipeRefreshLayoutHelper.listenToRefreshRequests
import gq.kirmanak.mealient.ui.auth.AuthenticationViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.collectLatest
import timber.log.Timber
@ExperimentalCoroutinesApi
@AndroidEntryPoint
class RecipesFragment : Fragment() {
private var _binding: FragmentRecipesBinding? = null
private val binding: FragmentRecipesBinding
get() = checkNotNull(_binding) { "Binding requested when fragment is off screen" }
private val viewModel by viewModels<RecipeViewModel>()
private val authViewModel by viewModels<AuthenticationViewModel>()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
Timber.v("onCreateView() called with: inflater = $inflater, container = $container, savedInstanceState = $savedInstanceState")
_binding = FragmentRecipesBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
setupRecipeAdapter()
listenToAuthStatuses()
}
private fun navigateToRecipeInfo(recipeSummaryEntity: RecipeSummaryEntity) {
findNavController().navigate(
RecipesFragmentDirections.actionRecipesFragmentToRecipeInfoFragment(
recipeSlug = recipeSummaryEntity.slug,
recipeId = recipeSummaryEntity.remoteId
)
)
}
private fun listenToAuthStatuses() {
Timber.v("listenToAuthStatuses() called")
lifecycleScope.launchWhenCreated {
authViewModel.authenticationStatuses().collectLatest {
Timber.v("listenToAuthStatuses: new auth status = $it")
if (!it) navigateToAuthFragment()
}
}
}
private fun navigateToAuthFragment() {
Timber.v("navigateToAuthFragment() called")
findNavController().navigate(RecipesFragmentDirections.actionRecipesFragmentToAuthenticationFragment())
}
private fun setupRecipeAdapter() {
Timber.v("setupRecipeAdapter() called")
binding.recipes.layoutManager = LinearLayoutManager(requireContext())
val adapter = RecipesPagingAdapter(viewModel) { navigateToRecipeInfo(it) }
binding.recipes.adapter = adapter
viewLifecycleOwner.lifecycleScope.launchWhenResumed {
viewModel.recipeFlow.collect {
Timber.d("Received update")
adapter.submitData(it)
}
}
viewLifecycleOwner.lifecycleScope.launchWhenResumed {
adapter.listenToRefreshRequests(binding.refresher)
}
viewLifecycleOwner.lifecycleScope.launchWhenResumed {
adapter.onPagesUpdatedFlow.collect {
Timber.d("Pages have been updated")
binding.refresher.isRefreshing = false
}
}
}
override fun onDestroyView() {
super.onDestroyView()
Timber.v("onDestroyView() called")
_binding = null
}
}

View File

@@ -0,0 +1,42 @@
package gq.kirmanak.mealient.ui.recipes
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.databinding.ViewHolderRecipeBinding
import timber.log.Timber
class RecipesPagingAdapter(
private val viewModel: RecipeViewModel,
private val clickListener: (RecipeSummaryEntity) -> Unit
) : PagingDataAdapter<RecipeSummaryEntity, RecipeViewHolder>(RecipeDiffCallback) {
override fun onBindViewHolder(holder: RecipeViewHolder, position: Int) {
val item = getItem(position)
holder.bind(item)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecipeViewHolder {
Timber.v("onCreateViewHolder() called with: parent = $parent, viewType = $viewType")
val inflater = LayoutInflater.from(parent.context)
val binding = ViewHolderRecipeBinding.inflate(inflater, parent, false)
return RecipeViewHolder(binding, viewModel, clickListener)
}
private object RecipeDiffCallback : DiffUtil.ItemCallback<RecipeSummaryEntity>() {
override fun areItemsTheSame(
oldItem: RecipeSummaryEntity,
newItem: RecipeSummaryEntity
): Boolean {
return oldItem.remoteId == newItem.remoteId
}
override fun areContentsTheSame(
oldItem: RecipeSummaryEntity,
newItem: RecipeSummaryEntity
): Boolean {
return oldItem.name == newItem.name && oldItem.slug == newItem.slug
}
}
}

View File

@@ -0,0 +1,80 @@
package gq.kirmanak.mealient.ui.recipes.info
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.LinearLayoutManager
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.databinding.FragmentRecipeInfoBinding
import gq.kirmanak.mealient.ui.auth.AuthenticationViewModel
import kotlinx.coroutines.flow.collectLatest
import timber.log.Timber
@AndroidEntryPoint
class RecipeInfoFragment : Fragment() {
private var _binding: FragmentRecipeInfoBinding? = null
private val binding: FragmentRecipeInfoBinding
get() = checkNotNull(_binding) { "Binding requested when fragment is off screen" }
private val authViewModel by viewModels<AuthenticationViewModel>()
private val arguments by navArgs<RecipeInfoFragmentArgs>()
private val viewModel by viewModels<RecipeInfoViewModel>()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
Timber.v("onCreateView() called with: inflater = $inflater, container = $container, savedInstanceState = $savedInstanceState")
_binding = FragmentRecipeInfoBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
listenToAuthStatuses()
viewModel.loadRecipeImage(binding.image, arguments.recipeSlug)
viewModel.loadRecipeInfo(arguments.recipeId, arguments.recipeSlug)
viewModel.recipeInfo.observe(viewLifecycleOwner) {
binding.title.text = it.recipeSummaryEntity.name
binding.description.text = it.recipeSummaryEntity.description
val recipeIngredientsAdapter = RecipeIngredientsAdapter()
binding.ingredientsList.adapter = recipeIngredientsAdapter
binding.ingredientsList.layoutManager = LinearLayoutManager(requireContext())
recipeIngredientsAdapter.submitList(it.recipeIngredients)
val recipeInstructionsAdapter = RecipeInstructionsAdapter()
binding.instructionsList.adapter = recipeInstructionsAdapter
binding.instructionsList.layoutManager = LinearLayoutManager(requireContext())
recipeInstructionsAdapter.submitList(it.recipeInstructions)
}
}
private fun listenToAuthStatuses() {
Timber.v("listenToAuthStatuses() called")
lifecycleScope.launchWhenCreated {
authViewModel.authenticationStatuses().collectLatest {
Timber.v("listenToAuthStatuses: new auth status = $it")
if (!it) navigateToAuthFragment()
}
}
}
private fun navigateToAuthFragment() {
Timber.v("navigateToAuthFragment() called")
findNavController().navigate(RecipeInfoFragmentDirections.actionRecipeInfoFragmentToAuthenticationFragment())
}
override fun onDestroyView() {
super.onDestroyView()
Timber.v("onDestroyView() called")
_binding = null
}
}

View File

@@ -0,0 +1,43 @@
package gq.kirmanak.mealient.ui.recipes.info
import android.widget.ImageView
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.recipes.RecipeImageLoader
import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.data.recipes.impl.FullRecipeInfo
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class RecipeInfoViewModel @Inject constructor(
private val recipeRepo: RecipeRepo,
private val recipeImageLoader: RecipeImageLoader
) : ViewModel() {
private val _recipeInfo = MutableLiveData<FullRecipeInfo>()
val recipeInfo: LiveData<FullRecipeInfo> = _recipeInfo
fun loadRecipeImage(view: ImageView, recipeSlug: String) {
viewModelScope.launch {
recipeImageLoader.loadRecipeImage(view, recipeSlug)
}
}
fun loadRecipeInfo(recipeId: Long, recipeSlug: String) {
Timber.v("loadRecipeInfo() called with: recipeId = $recipeId, recipeSlug = $recipeSlug")
viewModelScope.launch {
runCatching {
recipeRepo.loadRecipeInfo(recipeId, recipeSlug)
}.onSuccess {
Timber.d("loadRecipeInfo: received recipe info = $it")
_recipeInfo.value = it
}.onFailure {
Timber.e(it, "loadRecipeInfo: can't load recipe info")
}
}
}
}

View File

@@ -0,0 +1,45 @@
package gq.kirmanak.mealient.ui.recipes.info
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeIngredientEntity
import gq.kirmanak.mealient.databinding.ViewHolderIngredientBinding
import gq.kirmanak.mealient.ui.recipes.info.RecipeIngredientsAdapter.RecipeIngredientViewHolder
class RecipeIngredientsAdapter() :
ListAdapter<RecipeIngredientEntity, RecipeIngredientViewHolder>(RecipeIngredientDiffCallback) {
class RecipeIngredientViewHolder(
private val binding: ViewHolderIngredientBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: RecipeIngredientEntity) {
binding.checkBox.text = item.note
}
}
private object RecipeIngredientDiffCallback : DiffUtil.ItemCallback<RecipeIngredientEntity>() {
override fun areItemsTheSame(
oldItem: RecipeIngredientEntity,
newItem: RecipeIngredientEntity
): Boolean = oldItem.localId == newItem.localId
override fun areContentsTheSame(
oldItem: RecipeIngredientEntity,
newItem: RecipeIngredientEntity
): Boolean = oldItem == newItem
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecipeIngredientViewHolder {
val inflater = LayoutInflater.from(parent.context)
return RecipeIngredientViewHolder(
ViewHolderIngredientBinding.inflate(inflater, parent, false)
)
}
override fun onBindViewHolder(holder: RecipeIngredientViewHolder, position: Int) {
holder.bind(getItem(position))
}
}

View File

@@ -0,0 +1,47 @@
package gq.kirmanak.mealient.ui.recipes.info
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeInstructionEntity
import gq.kirmanak.mealient.databinding.ViewHolderInstructionBinding
class RecipeInstructionsAdapter :
ListAdapter<RecipeInstructionEntity, RecipeInstructionsAdapter.RecipeInstructionViewHolder>(
RecipeInstructionDiffCallback
) {
private object RecipeInstructionDiffCallback :
DiffUtil.ItemCallback<RecipeInstructionEntity>() {
override fun areItemsTheSame(
oldItem: RecipeInstructionEntity,
newItem: RecipeInstructionEntity
): Boolean = oldItem.localId == newItem.localId
override fun areContentsTheSame(
oldItem: RecipeInstructionEntity,
newItem: RecipeInstructionEntity
): Boolean = oldItem == newItem
}
class RecipeInstructionViewHolder(private val binding: ViewHolderInstructionBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(item: RecipeInstructionEntity, position: Int) {
binding.step.text = "Step: ${position + 1}"
binding.instruction.text = item.text
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecipeInstructionViewHolder {
val inflater = LayoutInflater.from(parent.context)
return RecipeInstructionViewHolder(
ViewHolderInstructionBinding.inflate(inflater, parent, false)
)
}
override fun onBindViewHolder(holder: RecipeInstructionViewHolder, position: Int) {
holder.bind(getItem(position), position)
}
}