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

@@ -9,6 +9,8 @@ plugins {
id 'com.google.firebase.crashlytics'
// https://plugins.gradle.org/plugin/com.guardsquare.appsweep
id "com.guardsquare.appsweep" version "1.0.0"
// https://github.com/google/protobuf-gradle-plugin/releases
id "com.google.protobuf" version "0.8.18"
}
android {
@@ -175,7 +177,11 @@ dependencies {
implementation "com.github.kirich1409:viewbindingpropertydelegate-noreflection:1.5.6"
// https://developer.android.com/jetpack/androidx/releases/datastore
implementation "androidx.datastore:datastore-preferences:1.0.0"
def datastore_version = "1.0.0"
implementation "androidx.datastore:datastore-preferences:$datastore_version"
implementation "androidx.datastore:datastore:$datastore_version"
implementation "com.google.protobuf:protobuf-javalite:$protobuf_version"
// https://developer.android.com/jetpack/androidx/releases/security
implementation "androidx.security:security-crypto:1.0.0"
@@ -219,4 +225,20 @@ dependencies {
// https://github.com/ChuckerTeam/chucker/releases
debugImplementation "com.github.chuckerteam.chucker:library:3.5.2"
}
protobuf {
protoc {
artifact = "com.google.protobuf:protoc:$protobuf_version"
}
generateProtoTasks {
all().each { task ->
task.builtins {
java {
option 'lite'
}
}
}
}
}

View File

@@ -0,0 +1,8 @@
package gq.kirmanak.mealient.data.add
import gq.kirmanak.mealient.data.add.models.AddRecipeRequest
interface AddRecipeDataSource {
suspend fun addRecipe(recipe: AddRecipeRequest): String
}

View File

@@ -0,0 +1,15 @@
package gq.kirmanak.mealient.data.add
import gq.kirmanak.mealient.data.add.models.AddRecipeRequest
import kotlinx.coroutines.flow.Flow
interface AddRecipeRepo {
val addRecipeRequestFlow: Flow<AddRecipeRequest>
suspend fun preserve(recipe: AddRecipeRequest)
suspend fun clear()
suspend fun saveRecipe(): String
}

View File

@@ -0,0 +1,13 @@
package gq.kirmanak.mealient.data.add
import gq.kirmanak.mealient.data.add.models.AddRecipeRequest
import kotlinx.coroutines.flow.Flow
interface AddRecipeStorage {
val updates: Flow<AddRecipeRequest>
suspend fun save(addRecipeRequest: AddRecipeRequest)
suspend fun clear()
}

View File

@@ -0,0 +1,25 @@
package gq.kirmanak.mealient.data.add.impl
import gq.kirmanak.mealient.data.add.AddRecipeDataSource
import gq.kirmanak.mealient.data.add.models.AddRecipeRequest
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.extensions.logAndMapErrors
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AddRecipeDataSourceImpl @Inject constructor(
private val addRecipeServiceFactory: ServiceFactory<AddRecipeService>,
) : AddRecipeDataSource {
override suspend fun addRecipe(recipe: AddRecipeRequest): String {
Timber.v("addRecipe() called with: recipe = $recipe")
val service = addRecipeServiceFactory.provideService()
val response = logAndMapErrors(
block = { service.addRecipe(recipe) }, logProvider = { "addRecipe: can't add recipe" }
)
Timber.v("addRecipe() response = $response")
return response
}
}

View File

@@ -0,0 +1,36 @@
package gq.kirmanak.mealient.data.add.impl
import gq.kirmanak.mealient.data.add.AddRecipeDataSource
import gq.kirmanak.mealient.data.add.AddRecipeRepo
import gq.kirmanak.mealient.data.add.AddRecipeStorage
import gq.kirmanak.mealient.data.add.models.AddRecipeRequest
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AddRecipeRepoImpl @Inject constructor(
private val addRecipeDataSource: AddRecipeDataSource,
private val addRecipeStorage: AddRecipeStorage,
) : AddRecipeRepo {
override val addRecipeRequestFlow: Flow<AddRecipeRequest>
get() = addRecipeStorage.updates
override suspend fun preserve(recipe: AddRecipeRequest) {
Timber.v("preserveRecipe() called with: recipe = $recipe")
addRecipeStorage.save(recipe)
}
override suspend fun clear() {
Timber.v("clear() called")
addRecipeStorage.clear()
}
override suspend fun saveRecipe(): String {
Timber.v("saveRecipe() called")
return addRecipeDataSource.addRecipe(addRecipeRequestFlow.first())
}
}

View File

@@ -0,0 +1,12 @@
package gq.kirmanak.mealient.data.add.impl
import gq.kirmanak.mealient.data.add.models.AddRecipeRequest
import retrofit2.http.Body
import retrofit2.http.POST
interface AddRecipeService {
@POST("/api/recipes/create")
suspend fun addRecipe(@Body addRecipeRequest: AddRecipeRequest): String
}

View File

@@ -0,0 +1,30 @@
package gq.kirmanak.mealient.data.add.impl
import androidx.datastore.core.DataStore
import gq.kirmanak.mealient.data.add.AddRecipeStorage
import gq.kirmanak.mealient.data.add.models.AddRecipeInput
import gq.kirmanak.mealient.data.add.models.AddRecipeRequest
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AddRecipeStorageImpl @Inject constructor(
private val dataStore: DataStore<AddRecipeInput>,
) : AddRecipeStorage {
override val updates: Flow<AddRecipeRequest>
get() = dataStore.data.map { AddRecipeRequest(it) }
override suspend fun save(addRecipeRequest: AddRecipeRequest) {
Timber.v("saveRecipeInput() called with: addRecipeRequest = $addRecipeRequest")
dataStore.updateData { addRecipeRequest.toInput() }
}
override suspend fun clear() {
Timber.v("clearRecipeInput() called")
dataStore.updateData { AddRecipeInput.getDefaultInstance() }
}
}

View File

@@ -0,0 +1,19 @@
package gq.kirmanak.mealient.data.add.models
import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
import com.google.protobuf.InvalidProtocolBufferException
import java.io.InputStream
import java.io.OutputStream
object AddRecipeInputSerializer : Serializer<AddRecipeInput> {
override val defaultValue: AddRecipeInput = AddRecipeInput.getDefaultInstance()
override suspend fun readFrom(input: InputStream): AddRecipeInput = try {
AddRecipeInput.parseFrom(input)
} catch (e: InvalidProtocolBufferException) {
throw CorruptionException("Can't read proto file", e)
}
override suspend fun writeTo(t: AddRecipeInput, output: OutputStream) = t.writeTo(output)
}

View File

@@ -0,0 +1,76 @@
package gq.kirmanak.mealient.data.add.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class AddRecipeRequest(
@SerialName("name") val name: String = "",
@SerialName("description") val description: String = "",
@SerialName("image") val image: String = "",
@SerialName("recipeYield") val recipeYield: String = "",
@SerialName("recipeIngredient") val recipeIngredient: List<AddRecipeIngredient> = emptyList(),
@SerialName("recipeInstructions") val recipeInstructions: List<AddRecipeInstruction> = emptyList(),
@SerialName("slug") val slug: String = "",
@SerialName("filePath") val filePath: String = "",
@SerialName("tags") val tags: List<String> = emptyList(),
@SerialName("categories") val categories: List<String> = emptyList(),
@SerialName("notes") val notes: List<AddRecipeNote> = emptyList(),
@SerialName("extras") val extras: Map<String, String> = emptyMap(),
@SerialName("assets") val assets: List<String> = emptyList(),
@SerialName("settings") val settings: AddRecipeSettings = AddRecipeSettings(),
) {
constructor(input: AddRecipeInput) : this(
name = input.recipeName,
description = input.recipeDescription,
recipeYield = input.recipeYield,
recipeIngredient = input.recipeIngredientsList.map { AddRecipeIngredient(note = it) },
recipeInstructions = input.recipeInstructionsList.map { AddRecipeInstruction(text = it) },
settings = AddRecipeSettings(
public = input.isRecipePublic,
disableComments = input.areCommentsDisabled,
)
)
fun toInput(): AddRecipeInput = AddRecipeInput.newBuilder()
.setRecipeName(name)
.setRecipeDescription(description)
.setRecipeYield(recipeYield)
.setIsRecipePublic(settings.public)
.setAreCommentsDisabled(settings.disableComments)
.addAllRecipeIngredients(recipeIngredient.map { it.note })
.addAllRecipeInstructions(recipeInstructions.map { it.text })
.build()
}
@Serializable
data class AddRecipeSettings(
@SerialName("disableAmount") val disableAmount: Boolean = true,
@SerialName("disableComments") val disableComments: Boolean = false,
@SerialName("landscapeView") val landscapeView: Boolean = true,
@SerialName("public") val public: Boolean = true,
@SerialName("showAssets") val showAssets: Boolean = true,
@SerialName("showNutrition") val showNutrition: Boolean = true,
)
@Serializable
data class AddRecipeNote(
@SerialName("title") val title: String = "",
@SerialName("text") val text: String = "",
)
@Serializable
data class AddRecipeInstruction(
@SerialName("title") val title: String = "",
@SerialName("text") val text: String = "",
)
@Serializable
data class AddRecipeIngredient(
@SerialName("disableAmount") val disableAmount: Boolean = true,
@SerialName("food") val food: String? = null,
@SerialName("note") val note: String = "",
@SerialName("quantity") val quantity: Int = 1,
@SerialName("title") val title: String? = null,
@SerialName("unit") val unit: String? = null,
)

View File

@@ -6,8 +6,7 @@ import gq.kirmanak.mealient.data.network.NetworkError.NotMealie
import gq.kirmanak.mealient.data.network.NetworkError.Unauthorized
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.extensions.decodeErrorBodyOrNull
import gq.kirmanak.mealient.extensions.mapToNetworkError
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
import gq.kirmanak.mealient.extensions.logAndMapErrors
import kotlinx.serialization.json.Json
import retrofit2.HttpException
import retrofit2.Response
@@ -34,12 +33,10 @@ class AuthDataSourceImpl @Inject constructor(
authService: AuthService,
username: String,
password: String
): Response<GetTokenResponse> = runCatchingExceptCancel {
authService.getToken(username, password)
}.getOrElse {
Timber.e(it, "sendRequest: can't request token")
throw it.mapToNetworkError()
}
): Response<GetTokenResponse> = logAndMapErrors(
block = { authService.getToken(username = username, password = password) },
logProvider = { "sendRequest: can't get token" },
)
private fun parseToken(
response: Response<GetTokenResponse>

View File

@@ -3,8 +3,7 @@ package gq.kirmanak.mealient.data.baseurl.impl
import gq.kirmanak.mealient.data.baseurl.VersionDataSource
import gq.kirmanak.mealient.data.baseurl.VersionInfo
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.extensions.mapToNetworkError
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
import gq.kirmanak.mealient.extensions.logAndMapErrors
import gq.kirmanak.mealient.extensions.versionInfo
import timber.log.Timber
import javax.inject.Inject
@@ -19,12 +18,10 @@ class VersionDataSourceImpl @Inject constructor(
Timber.v("getVersionInfo() called with: baseUrl = $baseUrl")
val service = serviceFactory.provideService(baseUrl)
val response = runCatchingExceptCancel {
service.getVersion()
}.getOrElse {
Timber.e(it, "getVersionInfo: can't request version")
throw it.mapToNetworkError()
}
val response = logAndMapErrors(
block = { service.getVersion() },
logProvider = { "getVersionInfo: can't request version" }
)
return response.versionInfo()
}

View File

@@ -0,0 +1,67 @@
package gq.kirmanak.mealient.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.data.add.AddRecipeDataSource
import gq.kirmanak.mealient.data.add.AddRecipeRepo
import gq.kirmanak.mealient.data.add.AddRecipeStorage
import gq.kirmanak.mealient.data.add.impl.AddRecipeDataSourceImpl
import gq.kirmanak.mealient.data.add.impl.AddRecipeRepoImpl
import gq.kirmanak.mealient.data.add.impl.AddRecipeService
import gq.kirmanak.mealient.data.add.impl.AddRecipeStorageImpl
import gq.kirmanak.mealient.data.add.models.AddRecipeInput
import gq.kirmanak.mealient.data.add.models.AddRecipeInputSerializer
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.network.RetrofitBuilder
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.data.network.createServiceFactory
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import javax.inject.Named
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
interface AddRecipeModule {
companion object {
@Provides
@Singleton
fun provideAddRecipeInputStore(
@ApplicationContext context: Context
): DataStore<AddRecipeInput> = DataStoreFactory.create(AddRecipeInputSerializer) {
context.dataStoreFile("add_recipe_input")
}
@Provides
@Singleton
fun provideAddRecipeServiceFactory(
@Named(AUTH_OK_HTTP) okHttpClient: OkHttpClient,
json: Json,
baseURLStorage: BaseURLStorage,
): ServiceFactory<AddRecipeService> {
return RetrofitBuilder(okHttpClient, json).createServiceFactory(baseURLStorage)
}
}
@Binds
@Singleton
fun provideAddRecipeRepo(repo: AddRecipeRepoImpl): AddRecipeRepo
@Binds
@Singleton
fun bindAddRecipeDataSource(addRecipeDataSourceImpl: AddRecipeDataSourceImpl): AddRecipeDataSource
@Binds
@Singleton
fun bindAddRecipeStorage(addRecipeStorageImpl: AddRecipeStorageImpl): AddRecipeStorage
}

View File

@@ -45,5 +45,6 @@ object NetworkModule {
fun createJson(): Json = Json {
coerceInputValues = true
ignoreUnknownKeys = true
encodeDefaults = true
}
}

View File

@@ -23,3 +23,9 @@ fun Throwable.mapToNetworkError(): NetworkError = when (this) {
is HttpException, is SerializationException -> NetworkError.NotMealie(this)
else -> NetworkError.NoServerConnection(this)
}
inline fun <T> logAndMapErrors(block: () -> T, logProvider: () -> String): T =
runCatchingExceptCancel(block).getOrElse {
Timber.e(it, logProvider())
throw it.mapToNetworkError()
}

View File

@@ -15,7 +15,6 @@ import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.textfield.TextInputLayout
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.ChannelResult
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.onClosed
@@ -27,7 +26,6 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import timber.log.Timber
@OptIn(ExperimentalCoroutinesApi::class)
fun SwipeRefreshLayout.refreshRequestFlow(): Flow<Unit> = callbackFlow {
Timber.v("refreshRequestFlow() called")
val listener = SwipeRefreshLayout.OnRefreshListener {
@@ -67,7 +65,6 @@ fun AppCompatActivity.setActionBarVisibility(isVisible: Boolean) {
?: Timber.w("setActionBarVisibility: action bar is null")
}
@OptIn(ExperimentalCoroutinesApi::class)
fun TextView.textChangesFlow(): Flow<CharSequence?> = callbackFlow {
Timber.v("textChangesFlow() called")
val textWatcher = doAfterTextChanged {
@@ -109,7 +106,6 @@ suspend fun EditText.waitUntilNotEmpty() {
Timber.v("waitUntilNotEmpty() returned")
}
@OptIn(ExperimentalCoroutinesApi::class)
fun <T> SharedPreferences.prefsChangeFlow(
valueReader: SharedPreferences.() -> T,
): Flow<T> = callbackFlow {

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()
}

View File

@@ -0,0 +1,14 @@
syntax = "proto3";
option java_package = "gq.kirmanak.mealient.data.add.models";
option java_multiple_files = true;
message AddRecipeInput {
string recipeName = 1;
string recipeDescription = 2;
string recipeYield = 3;
repeated string recipeInstructions = 4;
repeated string recipeIngredients = 5;
bool isRecipePublic = 6;
bool areCommentsDisabled = 7;
}

View File

@@ -0,0 +1,142 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.add.AddRecipeFragment">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/holder"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="200dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/recipe_name_input_layout"
style="@style/SmallMarginTextInputLayoutStyle"
android:hint="@string/fragment_add_recipe_recipe_name"
app:layout_constraintBottom_toTopOf="@+id/recipe_description_input_layout"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/recipe_name_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/recipe_description_input_layout"
style="@style/SmallMarginTextInputLayoutStyle"
android:hint="@string/fragment_add_recipe_recipe_description"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/recipe_name_input_layout">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/recipe_description_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textMultiLine" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/recipe_yield_input_layout"
style="@style/SmallMarginTextInputLayoutStyle"
android:hint="@string/fragment_add_recipe_recipe_yield"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/recipe_description_input_layout">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/recipe_yield_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<androidx.constraintlayout.helper.widget.Flow
android:id="@+id/ingredients_flow"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="@dimen/margin_small"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/recipe_yield_input_layout" />
<Button
android:id="@+id/new_ingredient_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="@dimen/margin_small"
android:text="@string/fragment_add_recipe_new_ingredient"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/ingredients_flow" />
<androidx.constraintlayout.helper.widget.Flow
android:id="@+id/instructions_flow"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="@dimen/margin_small"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/new_ingredient_button" />
<Button
android:id="@+id/new_instruction_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="@dimen/margin_small"
android:text="@string/fragment_add_recipe_new_instruction"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/instructions_flow" />
<androidx.constraintlayout.helper.widget.Flow
android:id="@+id/switches_flow"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="@dimen/margin_small"
android:orientation="vertical"
app:constraint_referenced_ids="public_recipe,disable_comments"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/new_instruction_button" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/public_recipe"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/fragment_add_recipe_public_recipe" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/disable_comments"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/fragment_add_recipe_disable_comments" />
<Button
android:id="@+id/save_recipe_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="@dimen/margin_small"
android:text="@string/fragment_add_recipe_save_button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/clear_button"
app:layout_constraintTop_toBottomOf="@+id/switches_flow" />
<Button
android:id="@+id/clear_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="@dimen/margin_small"
android:text="@string/fragment_add_recipe_clear_button"
app:layout_constraintEnd_toStartOf="@+id/save_recipe_button"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/switches_flow" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
@@ -8,12 +8,8 @@
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/refresher"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recipes"
@@ -23,4 +19,4 @@
tools:listitem="@layout/view_holder_recipe" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -1,38 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/drawer"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.activity.MainActivity">
tools:context=".ui.activity.MainActivity"
tools:openDrawer="start">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/toolbar_holder"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:liftOnScroll="true">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
style="@style/Widget.MaterialComponents.Toolbar.Primary"
android:layout_width="match_parent"
android:layout_height="?actionBarSize"
app:layout_scrollFlags="scroll|snap" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host"
android:name="androidx.navigation.fragment.NavHostFragment"
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
app:defaultNavHost="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbar_holder"
app:navGraph="@navigation/nav_graph" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
android:fitsSystemWindows="true">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/toolbar_holder"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:liftOnScroll="true">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
style="@style/Widget.MaterialComponents.Toolbar.Primary"
android:layout_width="match_parent"
android:layout_height="?actionBarSize"
app:layout_scrollFlags="scroll|snap|enterAlways" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="true"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
app:navGraph="@navigation/nav_graph" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
<com.google.android.material.navigation.NavigationView
android:id="@+id/navigation_view"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
app:menu="@menu/navigation_menu" />
</androidx.drawerlayout.widget.DrawerLayout>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.textfield.TextInputLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:endIconMode="clear_text">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textMultiLine" />
</com.google.android.material.textfield.TextInputLayout>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/recipes_list"
android:title="@string/menu_bottom_navigation_recipes_list" />
<item
android:id="@+id/add_recipe"
android:title="@string/menu_bottom_navigation_add_recipe" />
</menu>

View File

@@ -22,6 +22,9 @@
<action
android:id="@+id/action_recipesFragment_to_recipeInfoFragment"
app:destination="@id/recipeInfoFragment" />
<deepLink
android:id="@+id/deepLink"
app:uri="mealient://recipe/list" />
</fragment>
<dialog
android:id="@+id/recipeInfoFragment"
@@ -78,4 +81,13 @@
app:popUpTo="@id/nav_graph"
app:popUpToInclusive="true" />
</fragment>
<fragment
android:id="@+id/addRecipeFragment"
android:name="gq.kirmanak.mealient.ui.add.AddRecipeFragment"
android:label="fragment_add_recipe"
tools:layout="@layout/fragment_add_recipe">
<deepLink
android:id="@+id/deepLink"
app:uri="mealient://recipe/add" />
</fragment>
</navigation>

View File

@@ -22,4 +22,20 @@
<string name="fragment_base_url_malformed_url">Проверьте формат URL: %s</string>
<string name="fragment_base_url_save">Продолжить</string>
<string name="menu_main_toolbar_login">Войти</string>
<string name="fragment_add_recipe_recipe_name">Название рецепта</string>
<string name="fragment_add_recipe_recipe_description">Описание</string>
<string name="menu_bottom_navigation_recipes_list">Рецепты</string>
<string name="menu_bottom_navigation_add_recipe">Добавить рецепт</string>
<string name="fragment_add_recipe_recipe_yield">Количество порций</string>
<string name="fragment_add_recipe_save_button">Сохранить рецепт</string>
<string name="fragment_add_recipe_new_instruction">Добавить шаг</string>
<string name="fragment_add_recipe_new_ingredient">Добавить ингредиент</string>
<string name="fragment_add_recipe_public_recipe">Публичный рецепт</string>
<string name="fragment_add_recipe_disable_comments">Отключить комментарии</string>
<string name="fragment_add_recipe_ingredient_hint">Ингредиент</string>
<string name="fragment_add_recipe_instruction_hint">Описание шага</string>
<string name="fragment_add_recipe_name_error">Имя рецепта не может быть пустым</string>
<string name="fragment_add_recipe_save_error">Что-то пошло не так</string>
<string name="fragment_add_recipe_save_success">Рецепт сохранен успешно</string>
<string name="fragment_add_recipe_clear_button">Очистить</string>
</resources>

View File

@@ -20,12 +20,28 @@
<string name="fragment_base_url_unknown_error" translatable="false">@string/fragment_authentication_unknown_error</string>
<string name="menu_main_toolbar_content_description_login" translatable="false">@string/menu_main_toolbar_login</string>
<string name="menu_main_toolbar_login">Login</string>
<string name="fragment_disclaimer_button_okay">Okay</string>
<string name="view_holder_recipe_instructions_step">Step: %d</string>
<string name="fragment_authentication_email_input_empty">E-mail can\'t be empty</string>
<string name="fragment_authentication_password_input_empty">Password can\'t be empty</string>
<string name="fragment_authentication_credentials_incorrect">E-mail or password is incorrect.</string>
<string name="fragment_authentication_unknown_error">Something went wrong, please try again.</string>
<string name="account_type" translatable="false">Mealient</string>
<string name="auth_token_type" translatable="false">mealientAuthToken</string>
<string name="fragment_disclaimer_button_okay">Okay</string>
<string name="view_holder_recipe_instructions_step">Step: %d</string>
<string name="fragment_authentication_email_input_empty">E-mail can\'t be empty</string>
<string name="fragment_authentication_password_input_empty">Password can\'t be empty</string>
<string name="fragment_authentication_credentials_incorrect">E-mail or password is incorrect.</string>
<string name="fragment_authentication_unknown_error">Something went wrong, please try again.</string>
<string name="account_type" translatable="false">Mealient</string>
<string name="auth_token_type" translatable="false">mealientAuthToken</string>
<string name="fragment_add_recipe_recipe_name">Recipe name</string>
<string name="fragment_add_recipe_recipe_description">Description</string>
<string name="menu_bottom_navigation_add_recipe">Add recipe</string>
<string name="menu_bottom_navigation_recipes_list">Recipes</string>
<string name="fragment_add_recipe_recipe_yield">Recipe yield</string>
<string name="fragment_add_recipe_save_button">Save recipe</string>
<string name="fragment_add_recipe_new_instruction">New step</string>
<string name="fragment_add_recipe_new_ingredient">New ingredient</string>
<string name="fragment_add_recipe_public_recipe">Public recipe</string>
<string name="fragment_add_recipe_disable_comments">Disable comments</string>
<string name="fragment_add_recipe_ingredient_hint">Ingredient</string>
<string name="fragment_add_recipe_instruction_hint">Step description</string>
<string name="fragment_add_recipe_name_error">Recipe name can\'t be empty</string>
<string name="fragment_add_recipe_save_error">Something went wrong</string>
<string name="fragment_add_recipe_save_success">Saved recipe successfully</string>
<string name="fragment_add_recipe_clear_button">Clear</string>
</resources>

View File

@@ -0,0 +1,46 @@
package gq.kirmanak.mealient.data.add.impl
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.add.models.AddRecipeRequest
import gq.kirmanak.mealient.data.network.NetworkError
import gq.kirmanak.mealient.data.network.ServiceFactory
import io.mockk.MockKAnnotations
import io.mockk.coEvery
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.SerializationException
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class AddRecipeDataSourceImplTest {
@MockK
lateinit var serviceProvider: ServiceFactory<AddRecipeService>
@MockK
lateinit var service: AddRecipeService
lateinit var subject: AddRecipeDataSourceImpl
@Before
fun setUp() {
MockKAnnotations.init(this)
coEvery { serviceProvider.provideService(any()) } returns service
subject = AddRecipeDataSourceImpl(serviceProvider)
}
@Test(expected = NetworkError.NotMealie::class)
fun `when addRecipe fails then maps error`() = runTest {
coEvery { service.addRecipe(any()) } throws SerializationException()
subject.addRecipe(AddRecipeRequest())
}
@Test
fun `when addRecipe succeeds then returns response`() = runTest {
coEvery { service.addRecipe(any()) } returns "response"
assertThat(subject.addRecipe(AddRecipeRequest())).isEqualTo("response")
}
}

View File

@@ -0,0 +1,73 @@
package gq.kirmanak.mealient.data.add.models
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class AddRecipeRequestTest {
@Test
fun `when construct from input then fills fields correctly`() {
val input = AddRecipeInput.newBuilder()
.setRecipeName("Recipe name")
.setRecipeDescription("Recipe description")
.setRecipeYield("Recipe yield")
.addAllRecipeIngredients(listOf("Recipe ingredient 1", "Recipe ingredient 2"))
.addAllRecipeInstructions(listOf("Recipe instruction 1", "Recipe instruction 2"))
.setIsRecipePublic(false)
.setAreCommentsDisabled(true)
.build()
val expected = AddRecipeRequest(
name = "Recipe name",
description = "Recipe description",
recipeYield = "Recipe yield",
recipeIngredient = listOf(
AddRecipeIngredient(note = "Recipe ingredient 1"),
AddRecipeIngredient(note = "Recipe ingredient 2")
),
recipeInstructions = listOf(
AddRecipeInstruction(text = "Recipe instruction 1"),
AddRecipeInstruction(text = "Recipe instruction 2")
),
settings = AddRecipeSettings(
public = false,
disableComments = true,
)
)
assertThat(AddRecipeRequest(input)).isEqualTo(expected)
}
@Test
fun `when toInput then fills fields correctly`() {
val request = AddRecipeRequest(
name = "Recipe name",
description = "Recipe description",
recipeYield = "Recipe yield",
recipeIngredient = listOf(
AddRecipeIngredient(note = "Recipe ingredient 1"),
AddRecipeIngredient(note = "Recipe ingredient 2")
),
recipeInstructions = listOf(
AddRecipeInstruction(text = "Recipe instruction 1"),
AddRecipeInstruction(text = "Recipe instruction 2")
),
settings = AddRecipeSettings(
public = false,
disableComments = true,
)
)
val expected = AddRecipeInput.newBuilder()
.setRecipeName("Recipe name")
.setRecipeDescription("Recipe description")
.setRecipeYield("Recipe yield")
.addAllRecipeIngredients(listOf("Recipe ingredient 1", "Recipe ingredient 2"))
.addAllRecipeInstructions(listOf("Recipe instruction 1", "Recipe instruction 2"))
.setIsRecipePublic(false)
.setAreCommentsDisabled(true)
.build()
assertThat(request.toInput()).isEqualTo(expected)
}
}

View File

@@ -0,0 +1,87 @@
package gq.kirmanak.mealient.ui.add
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.add.AddRecipeRepo
import gq.kirmanak.mealient.data.add.models.AddRecipeRequest
import io.mockk.MockKAnnotations
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.withTimeoutOrNull
import org.junit.After
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class AddRecipeViewModelTest {
@MockK(relaxUnitFun = true)
lateinit var addRecipeRepo: AddRecipeRepo
lateinit var subject: AddRecipeViewModel
@Before
fun setUp() {
MockKAnnotations.init(this)
Dispatchers.setMain(UnconfinedTestDispatcher())
subject = AddRecipeViewModel(addRecipeRepo)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `when saveRecipe fails then addRecipeResult is false`() = runTest {
coEvery { addRecipeRepo.saveRecipe() } throws IllegalStateException()
subject.saveRecipe()
assertThat(subject.addRecipeResult.first()).isFalse()
}
@Test
fun `when saveRecipe succeeds then addRecipeResult is true`() = runTest {
coEvery { addRecipeRepo.saveRecipe() } returns "recipe-slug"
subject.saveRecipe()
assertThat(subject.addRecipeResult.first()).isTrue()
}
@Test
fun `when preserve then doesn't update UI`() {
coEvery { addRecipeRepo.addRecipeRequestFlow } returns flowOf(AddRecipeRequest())
subject.preserve(AddRecipeRequest())
coVerify(inverse = true) { addRecipeRepo.addRecipeRequestFlow }
}
@Test
fun `when preservedAddRecipeRequest without loadPreservedRequest then empty`() = runTest {
coEvery { addRecipeRepo.addRecipeRequestFlow } returns flowOf(AddRecipeRequest())
val actual = withTimeoutOrNull(10) { subject.preservedAddRecipeRequest.firstOrNull() }
assertThat(actual).isNull()
}
@Test
fun `when loadPreservedRequest then updates preservedAddRecipeRequest`() = runTest {
val expected = AddRecipeRequest()
coEvery { addRecipeRepo.addRecipeRequestFlow } returns flowOf(expected)
subject.loadPreservedRequest()
assertThat(subject.preservedAddRecipeRequest.first()).isSameInstanceAs(expected)
}
@Test
fun `when clear then updates preservedAddRecipeRequest`() = runTest {
val expected = AddRecipeRequest()
coEvery { addRecipeRepo.addRecipeRequestFlow } returns flowOf(expected)
subject.clear()
assertThat(subject.preservedAddRecipeRequest.first()).isSameInstanceAs(expected)
}
}

View File

@@ -54,4 +54,9 @@ sonarqube {
rootCoverage {
generateXml true
}
ext {
// https://github.com/protocolbuffers/protobuf/releases
protobuf_version = "3.21.1"
}