Implement adding recipes through app
This commit is contained in:
@@ -9,6 +9,8 @@ plugins {
|
|||||||
id 'com.google.firebase.crashlytics'
|
id 'com.google.firebase.crashlytics'
|
||||||
// https://plugins.gradle.org/plugin/com.guardsquare.appsweep
|
// https://plugins.gradle.org/plugin/com.guardsquare.appsweep
|
||||||
id "com.guardsquare.appsweep" version "1.0.0"
|
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 {
|
android {
|
||||||
@@ -175,7 +177,11 @@ dependencies {
|
|||||||
implementation "com.github.kirich1409:viewbindingpropertydelegate-noreflection:1.5.6"
|
implementation "com.github.kirich1409:viewbindingpropertydelegate-noreflection:1.5.6"
|
||||||
|
|
||||||
// https://developer.android.com/jetpack/androidx/releases/datastore
|
// 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
|
// https://developer.android.com/jetpack/androidx/releases/security
|
||||||
implementation "androidx.security:security-crypto:1.0.0"
|
implementation "androidx.security:security-crypto:1.0.0"
|
||||||
@@ -220,3 +226,19 @@ dependencies {
|
|||||||
// https://github.com/ChuckerTeam/chucker/releases
|
// https://github.com/ChuckerTeam/chucker/releases
|
||||||
debugImplementation "com.github.chuckerteam.chucker:library:3.5.2"
|
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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.NetworkError.Unauthorized
|
||||||
import gq.kirmanak.mealient.data.network.ServiceFactory
|
import gq.kirmanak.mealient.data.network.ServiceFactory
|
||||||
import gq.kirmanak.mealient.extensions.decodeErrorBodyOrNull
|
import gq.kirmanak.mealient.extensions.decodeErrorBodyOrNull
|
||||||
import gq.kirmanak.mealient.extensions.mapToNetworkError
|
import gq.kirmanak.mealient.extensions.logAndMapErrors
|
||||||
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import retrofit2.HttpException
|
import retrofit2.HttpException
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
@@ -34,12 +33,10 @@ class AuthDataSourceImpl @Inject constructor(
|
|||||||
authService: AuthService,
|
authService: AuthService,
|
||||||
username: String,
|
username: String,
|
||||||
password: String
|
password: String
|
||||||
): Response<GetTokenResponse> = runCatchingExceptCancel {
|
): Response<GetTokenResponse> = logAndMapErrors(
|
||||||
authService.getToken(username, password)
|
block = { authService.getToken(username = username, password = password) },
|
||||||
}.getOrElse {
|
logProvider = { "sendRequest: can't get token" },
|
||||||
Timber.e(it, "sendRequest: can't request token")
|
)
|
||||||
throw it.mapToNetworkError()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseToken(
|
private fun parseToken(
|
||||||
response: Response<GetTokenResponse>
|
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.VersionDataSource
|
||||||
import gq.kirmanak.mealient.data.baseurl.VersionInfo
|
import gq.kirmanak.mealient.data.baseurl.VersionInfo
|
||||||
import gq.kirmanak.mealient.data.network.ServiceFactory
|
import gq.kirmanak.mealient.data.network.ServiceFactory
|
||||||
import gq.kirmanak.mealient.extensions.mapToNetworkError
|
import gq.kirmanak.mealient.extensions.logAndMapErrors
|
||||||
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
|
|
||||||
import gq.kirmanak.mealient.extensions.versionInfo
|
import gq.kirmanak.mealient.extensions.versionInfo
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -19,12 +18,10 @@ class VersionDataSourceImpl @Inject constructor(
|
|||||||
Timber.v("getVersionInfo() called with: baseUrl = $baseUrl")
|
Timber.v("getVersionInfo() called with: baseUrl = $baseUrl")
|
||||||
|
|
||||||
val service = serviceFactory.provideService(baseUrl)
|
val service = serviceFactory.provideService(baseUrl)
|
||||||
val response = runCatchingExceptCancel {
|
val response = logAndMapErrors(
|
||||||
service.getVersion()
|
block = { service.getVersion() },
|
||||||
}.getOrElse {
|
logProvider = { "getVersionInfo: can't request version" }
|
||||||
Timber.e(it, "getVersionInfo: can't request version")
|
)
|
||||||
throw it.mapToNetworkError()
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.versionInfo()
|
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 {
|
fun createJson(): Json = Json {
|
||||||
coerceInputValues = true
|
coerceInputValues = true
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
|
encodeDefaults = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -23,3 +23,9 @@ fun Throwable.mapToNetworkError(): NetworkError = when (this) {
|
|||||||
is HttpException, is SerializationException -> NetworkError.NotMealie(this)
|
is HttpException, is SerializationException -> NetworkError.NotMealie(this)
|
||||||
else -> NetworkError.NoServerConnection(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.lifecycle.lifecycleScope
|
||||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
import com.google.android.material.textfield.TextInputLayout
|
import com.google.android.material.textfield.TextInputLayout
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.channels.ChannelResult
|
import kotlinx.coroutines.channels.ChannelResult
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.channels.onClosed
|
import kotlinx.coroutines.channels.onClosed
|
||||||
@@ -27,7 +26,6 @@ import kotlinx.coroutines.flow.first
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
fun SwipeRefreshLayout.refreshRequestFlow(): Flow<Unit> = callbackFlow {
|
fun SwipeRefreshLayout.refreshRequestFlow(): Flow<Unit> = callbackFlow {
|
||||||
Timber.v("refreshRequestFlow() called")
|
Timber.v("refreshRequestFlow() called")
|
||||||
val listener = SwipeRefreshLayout.OnRefreshListener {
|
val listener = SwipeRefreshLayout.OnRefreshListener {
|
||||||
@@ -67,7 +65,6 @@ fun AppCompatActivity.setActionBarVisibility(isVisible: Boolean) {
|
|||||||
?: Timber.w("setActionBarVisibility: action bar is null")
|
?: Timber.w("setActionBarVisibility: action bar is null")
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
fun TextView.textChangesFlow(): Flow<CharSequence?> = callbackFlow {
|
fun TextView.textChangesFlow(): Flow<CharSequence?> = callbackFlow {
|
||||||
Timber.v("textChangesFlow() called")
|
Timber.v("textChangesFlow() called")
|
||||||
val textWatcher = doAfterTextChanged {
|
val textWatcher = doAfterTextChanged {
|
||||||
@@ -109,7 +106,6 @@ suspend fun EditText.waitUntilNotEmpty() {
|
|||||||
Timber.v("waitUntilNotEmpty() returned")
|
Timber.v("waitUntilNotEmpty() returned")
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
fun <T> SharedPreferences.prefsChangeFlow(
|
fun <T> SharedPreferences.prefsChangeFlow(
|
||||||
valueReader: SharedPreferences.() -> T,
|
valueReader: SharedPreferences.() -> T,
|
||||||
): Flow<T> = callbackFlow {
|
): Flow<T> = callbackFlow {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.view.MenuItem
|
|||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import androidx.navigation.findNavController
|
import androidx.navigation.findNavController
|
||||||
import com.google.android.material.shape.CornerFamily
|
import com.google.android.material.shape.CornerFamily
|
||||||
import com.google.android.material.shape.MaterialShapeDrawable
|
import com.google.android.material.shape.MaterialShapeDrawable
|
||||||
@@ -27,14 +28,30 @@ class MainActivity : AppCompatActivity() {
|
|||||||
binding = MainActivityBinding.inflate(layoutInflater)
|
binding = MainActivityBinding.inflate(layoutInflater)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
setSupportActionBar(binding.toolbar)
|
setSupportActionBar(binding.toolbar)
|
||||||
supportActionBar?.setIcon(R.drawable.ic_toolbar)
|
binding.toolbar.setNavigationIcon(R.drawable.ic_toolbar)
|
||||||
|
binding.toolbar.setNavigationOnClickListener { binding.drawer.open() }
|
||||||
setToolbarRoundCorner()
|
setToolbarRoundCorner()
|
||||||
viewModel.uiStateLive.observe(this, ::onUiStateChange)
|
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) {
|
private fun onUiStateChange(uiState: MainActivityUiState) {
|
||||||
Timber.v("onUiStateChange() called with: uiState = $uiState")
|
Timber.v("onUiStateChange() called with: uiState = $uiState")
|
||||||
supportActionBar?.title = if (uiState.titleVisible) title else null
|
supportActionBar?.title = if (uiState.titleVisible) title else null
|
||||||
|
binding.navigationView.isVisible = uiState.navigationVisible
|
||||||
invalidateOptionsMenu()
|
invalidateOptionsMenu()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,8 +66,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
for (drawable in drawables) {
|
for (drawable in drawables) {
|
||||||
drawable?.apply {
|
drawable?.apply {
|
||||||
shapeAppearanceModel = shapeAppearanceModel.toBuilder()
|
shapeAppearanceModel = shapeAppearanceModel.toBuilder()
|
||||||
.setBottomLeftCorner(CornerFamily.ROUNDED, radius)
|
.setBottomLeftCorner(CornerFamily.ROUNDED, radius).build()
|
||||||
.build()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,7 +83,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
Timber.v("onOptionsItemSelected() called with: item = $item")
|
Timber.v("onOptionsItemSelected() called with: item = $item")
|
||||||
val result = when (item.itemId) {
|
val result = when (item.itemId) {
|
||||||
R.id.login -> {
|
R.id.login -> {
|
||||||
navigateToLogin()
|
navigateDeepLink(AUTH_DEEP_LINK)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
R.id.logout -> {
|
R.id.logout -> {
|
||||||
@@ -79,8 +95,14 @@ class MainActivity : AppCompatActivity() {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun navigateToLogin() {
|
private fun navigateDeepLink(deepLink: String) {
|
||||||
Timber.v("navigateToLogin() called")
|
Timber.v("navigateDeepLink() called with: deepLink = $deepLink")
|
||||||
findNavController(binding.navHost.id).navigate("mealient://authenticate".toUri())
|
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 loginButtonVisible: Boolean = false,
|
||||||
val titleVisible: Boolean = true,
|
val titleVisible: Boolean = true,
|
||||||
val isAuthorized: Boolean = false,
|
val isAuthorized: Boolean = false,
|
||||||
|
val navigationVisible: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val canShowLogin: Boolean
|
val canShowLogin: Boolean
|
||||||
get() = !isAuthorized && loginButtonVisible
|
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)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
|
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
|
||||||
binding.button.setOnClickListener { onLoginClicked() }
|
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)
|
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")
|
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
|
||||||
binding.button.setOnClickListener(::onProceedClick)
|
binding.button.setOnClickListener(::onProceedClick)
|
||||||
viewModel.uiState.observe(viewLifecycleOwner, ::onUiStateChange)
|
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) {
|
private fun onProceedClick(view: View) {
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ class DisclaimerFragment : Fragment(R.layout.fragment_disclaimer) {
|
|||||||
binding.okay.isClickable = it == 0
|
binding.okay.isClickable = it == 0
|
||||||
}
|
}
|
||||||
viewModel.startCountDown()
|
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?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $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()
|
setupRecipeAdapter()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
14
app/src/main/proto/AddRecipeInput.proto
Normal file
14
app/src/main/proto/AddRecipeInput.proto
Normal 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;
|
||||||
|
}
|
||||||
142
app/src/main/res/layout/fragment_add_recipe.xml
Normal file
142
app/src/main/res/layout/fragment_add_recipe.xml
Normal 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>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?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:app="http://schemas.android.com/apk/res-auto"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@@ -8,12 +8,8 @@
|
|||||||
|
|
||||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
android:id="@+id/refresher"
|
android:id="@+id/refresher"
|
||||||
android:layout_width="0dp"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="0dp"
|
android:layout_height="match_parent">
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent">
|
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
android:id="@+id/recipes"
|
android:id="@+id/recipes"
|
||||||
@@ -23,4 +19,4 @@
|
|||||||
tools:listitem="@layout/view_holder_recipe" />
|
tools:listitem="@layout/view_holder_recipe" />
|
||||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||||
@@ -1,38 +1,46 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?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:app="http://schemas.android.com/apk/res-auto"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:id="@+id/drawer"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="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
|
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||||
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"
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
app:layout_behavior="@string/appbar_scrolling_view_behavior"
|
android:fitsSystemWindows="true">
|
||||||
app:defaultNavHost="true"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
<com.google.android.material.appbar.AppBarLayout
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
android:id="@+id/toolbar_holder"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
android:layout_width="match_parent"
|
||||||
app:layout_constraintTop_toBottomOf="@+id/toolbar_holder"
|
android:layout_height="wrap_content"
|
||||||
app:navGraph="@navigation/nav_graph" />
|
app:liftOnScroll="true">
|
||||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
|
||||||
|
<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>
|
||||||
|
|||||||
13
app/src/main/res/layout/view_single_input.xml
Normal file
13
app/src/main/res/layout/view_single_input.xml
Normal 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>
|
||||||
10
app/src/main/res/menu/navigation_menu.xml
Normal file
10
app/src/main/res/menu/navigation_menu.xml
Normal 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>
|
||||||
@@ -22,6 +22,9 @@
|
|||||||
<action
|
<action
|
||||||
android:id="@+id/action_recipesFragment_to_recipeInfoFragment"
|
android:id="@+id/action_recipesFragment_to_recipeInfoFragment"
|
||||||
app:destination="@id/recipeInfoFragment" />
|
app:destination="@id/recipeInfoFragment" />
|
||||||
|
<deepLink
|
||||||
|
android:id="@+id/deepLink"
|
||||||
|
app:uri="mealient://recipe/list" />
|
||||||
</fragment>
|
</fragment>
|
||||||
<dialog
|
<dialog
|
||||||
android:id="@+id/recipeInfoFragment"
|
android:id="@+id/recipeInfoFragment"
|
||||||
@@ -78,4 +81,13 @@
|
|||||||
app:popUpTo="@id/nav_graph"
|
app:popUpTo="@id/nav_graph"
|
||||||
app:popUpToInclusive="true" />
|
app:popUpToInclusive="true" />
|
||||||
</fragment>
|
</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>
|
</navigation>
|
||||||
@@ -22,4 +22,20 @@
|
|||||||
<string name="fragment_base_url_malformed_url">Проверьте формат URL: %s</string>
|
<string name="fragment_base_url_malformed_url">Проверьте формат URL: %s</string>
|
||||||
<string name="fragment_base_url_save">Продолжить</string>
|
<string name="fragment_base_url_save">Продолжить</string>
|
||||||
<string name="menu_main_toolbar_login">Войти</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>
|
</resources>
|
||||||
@@ -20,12 +20,28 @@
|
|||||||
<string name="fragment_base_url_unknown_error" translatable="false">@string/fragment_authentication_unknown_error</string>
|
<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_content_description_login" translatable="false">@string/menu_main_toolbar_login</string>
|
||||||
<string name="menu_main_toolbar_login">Login</string>
|
<string name="menu_main_toolbar_login">Login</string>
|
||||||
<string name="fragment_disclaimer_button_okay">Okay</string>
|
<string name="fragment_disclaimer_button_okay">Okay</string>
|
||||||
<string name="view_holder_recipe_instructions_step">Step: %d</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_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_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_credentials_incorrect">E-mail or password is incorrect.</string>
|
||||||
<string name="fragment_authentication_unknown_error">Something went wrong, please try again.</string>
|
<string name="fragment_authentication_unknown_error">Something went wrong, please try again.</string>
|
||||||
<string name="account_type" translatable="false">Mealient</string>
|
<string name="account_type" translatable="false">Mealient</string>
|
||||||
<string name="auth_token_type" translatable="false">mealientAuthToken</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>
|
</resources>
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,3 +55,8 @@ sonarqube {
|
|||||||
rootCoverage {
|
rootCoverage {
|
||||||
generateXml true
|
generateXml true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ext {
|
||||||
|
// https://github.com/protocolbuffers/protobuf/releases
|
||||||
|
protobuf_version = "3.21.1"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user