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

@@ -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>