Implement adding recipes through app
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
67
app/src/main/java/gq/kirmanak/mealient/di/AddRecipeModule.kt
Normal file
67
app/src/main/java/gq/kirmanak/mealient/di/AddRecipeModule.kt
Normal 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
|
||||
}
|
||||
@@ -45,5 +45,6 @@ object NetworkModule {
|
||||
fun createJson(): Json = Json {
|
||||
coerceInputValues = true
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user