From e18f726da573597dc48c40c4e199b1931e4dea23 Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Thu, 26 May 2022 13:29:10 +0200 Subject: [PATCH] Implement adding recipes through app --- app/build.gradle | 24 ++- .../mealient/data/add/AddRecipeDataSource.kt | 8 + .../mealient/data/add/AddRecipeRepo.kt | 15 ++ .../mealient/data/add/AddRecipeStorage.kt | 13 ++ .../data/add/impl/AddRecipeDataSourceImpl.kt | 25 +++ .../data/add/impl/AddRecipeRepoImpl.kt | 36 ++++ .../data/add/impl/AddRecipeService.kt | 12 ++ .../data/add/impl/AddRecipeStorageImpl.kt | 30 +++ .../add/models/AddRecipeInputSerializer.kt | 19 ++ .../data/add/models/AddRecipeRequest.kt | 76 ++++++++ .../data/auth/impl/AuthDataSourceImpl.kt | 13 +- .../baseurl/impl/VersionDataSourceImpl.kt | 13 +- .../kirmanak/mealient/di/AddRecipeModule.kt | 67 +++++++ .../gq/kirmanak/mealient/di/NetworkModule.kt | 1 + .../mealient/extensions/NetworkExtensions.kt | 6 + .../mealient/extensions/ViewExtensions.kt | 4 - .../mealient/ui/activity/MainActivity.kt | 36 +++- .../ui/activity/MainActivityUiState.kt | 1 + .../mealient/ui/add/AddRecipeFragment.kt | 175 ++++++++++++++++++ .../mealient/ui/add/AddRecipeViewModel.kt | 63 +++++++ .../ui/auth/AuthenticationFragment.kt | 4 +- .../mealient/ui/baseurl/BaseURLFragment.kt | 4 +- .../ui/disclaimer/DisclaimerFragment.kt | 4 +- .../mealient/ui/recipes/RecipesFragment.kt | 4 +- app/src/main/proto/AddRecipeInput.proto | 14 ++ .../main/res/layout/fragment_add_recipe.xml | 142 ++++++++++++++ app/src/main/res/layout/fragment_recipes.xml | 12 +- app/src/main/res/layout/main_activity.xml | 68 ++++--- app/src/main/res/layout/view_single_input.xml | 13 ++ app/src/main/res/menu/navigation_menu.xml | 10 + app/src/main/res/navigation/nav_graph.xml | 12 ++ app/src/main/res/values-ru/strings.xml | 16 ++ app/src/main/res/values/strings.xml | 32 +++- .../add/impl/AddRecipeDataSourceImplTest.kt | 46 +++++ .../data/add/models/AddRecipeRequestTest.kt | 73 ++++++++ .../mealient/ui/add/AddRecipeViewModelTest.kt | 87 +++++++++ build.gradle | 5 + 37 files changed, 1105 insertions(+), 78 deletions(-) create mode 100644 app/src/main/java/gq/kirmanak/mealient/data/add/AddRecipeDataSource.kt create mode 100644 app/src/main/java/gq/kirmanak/mealient/data/add/AddRecipeRepo.kt create mode 100644 app/src/main/java/gq/kirmanak/mealient/data/add/AddRecipeStorage.kt create mode 100644 app/src/main/java/gq/kirmanak/mealient/data/add/impl/AddRecipeDataSourceImpl.kt create mode 100644 app/src/main/java/gq/kirmanak/mealient/data/add/impl/AddRecipeRepoImpl.kt create mode 100644 app/src/main/java/gq/kirmanak/mealient/data/add/impl/AddRecipeService.kt create mode 100644 app/src/main/java/gq/kirmanak/mealient/data/add/impl/AddRecipeStorageImpl.kt create mode 100644 app/src/main/java/gq/kirmanak/mealient/data/add/models/AddRecipeInputSerializer.kt create mode 100644 app/src/main/java/gq/kirmanak/mealient/data/add/models/AddRecipeRequest.kt create mode 100644 app/src/main/java/gq/kirmanak/mealient/di/AddRecipeModule.kt create mode 100644 app/src/main/java/gq/kirmanak/mealient/ui/add/AddRecipeFragment.kt create mode 100644 app/src/main/java/gq/kirmanak/mealient/ui/add/AddRecipeViewModel.kt create mode 100644 app/src/main/proto/AddRecipeInput.proto create mode 100644 app/src/main/res/layout/fragment_add_recipe.xml create mode 100644 app/src/main/res/layout/view_single_input.xml create mode 100644 app/src/main/res/menu/navigation_menu.xml create mode 100644 app/src/test/java/gq/kirmanak/mealient/data/add/impl/AddRecipeDataSourceImplTest.kt create mode 100644 app/src/test/java/gq/kirmanak/mealient/data/add/models/AddRecipeRequestTest.kt create mode 100644 app/src/test/java/gq/kirmanak/mealient/ui/add/AddRecipeViewModelTest.kt diff --git a/app/build.gradle b/app/build.gradle index 8cb48f5..cbb7466 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -9,6 +9,8 @@ plugins { id 'com.google.firebase.crashlytics' // https://plugins.gradle.org/plugin/com.guardsquare.appsweep id "com.guardsquare.appsweep" version "1.0.0" + // https://github.com/google/protobuf-gradle-plugin/releases + id "com.google.protobuf" version "0.8.18" } android { @@ -175,7 +177,11 @@ dependencies { implementation "com.github.kirich1409:viewbindingpropertydelegate-noreflection:1.5.6" // https://developer.android.com/jetpack/androidx/releases/datastore - implementation "androidx.datastore:datastore-preferences:1.0.0" + def datastore_version = "1.0.0" + implementation "androidx.datastore:datastore-preferences:$datastore_version" + implementation "androidx.datastore:datastore:$datastore_version" + + implementation "com.google.protobuf:protobuf-javalite:$protobuf_version" // https://developer.android.com/jetpack/androidx/releases/security implementation "androidx.security:security-crypto:1.0.0" @@ -219,4 +225,20 @@ dependencies { // https://github.com/ChuckerTeam/chucker/releases debugImplementation "com.github.chuckerteam.chucker:library:3.5.2" +} + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:$protobuf_version" + } + + generateProtoTasks { + all().each { task -> + task.builtins { + java { + option 'lite' + } + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/add/AddRecipeDataSource.kt b/app/src/main/java/gq/kirmanak/mealient/data/add/AddRecipeDataSource.kt new file mode 100644 index 0000000..e8390df --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/data/add/AddRecipeDataSource.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/add/AddRecipeRepo.kt b/app/src/main/java/gq/kirmanak/mealient/data/add/AddRecipeRepo.kt new file mode 100644 index 0000000..50c756d --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/data/add/AddRecipeRepo.kt @@ -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 + + suspend fun preserve(recipe: AddRecipeRequest) + + suspend fun clear() + + suspend fun saveRecipe(): String +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/add/AddRecipeStorage.kt b/app/src/main/java/gq/kirmanak/mealient/data/add/AddRecipeStorage.kt new file mode 100644 index 0000000..283416a --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/data/add/AddRecipeStorage.kt @@ -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 + + suspend fun save(addRecipeRequest: AddRecipeRequest) + + suspend fun clear() +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/add/impl/AddRecipeDataSourceImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/add/impl/AddRecipeDataSourceImpl.kt new file mode 100644 index 0000000..53ca6b7 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/data/add/impl/AddRecipeDataSourceImpl.kt @@ -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, +) : 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/add/impl/AddRecipeRepoImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/add/impl/AddRecipeRepoImpl.kt new file mode 100644 index 0000000..372a8b6 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/data/add/impl/AddRecipeRepoImpl.kt @@ -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 + 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()) + } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/add/impl/AddRecipeService.kt b/app/src/main/java/gq/kirmanak/mealient/data/add/impl/AddRecipeService.kt new file mode 100644 index 0000000..d59c0d1 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/data/add/impl/AddRecipeService.kt @@ -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 + +} diff --git a/app/src/main/java/gq/kirmanak/mealient/data/add/impl/AddRecipeStorageImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/add/impl/AddRecipeStorageImpl.kt new file mode 100644 index 0000000..921d414 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/data/add/impl/AddRecipeStorageImpl.kt @@ -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, +) : AddRecipeStorage { + + override val updates: Flow + 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() } + } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/add/models/AddRecipeInputSerializer.kt b/app/src/main/java/gq/kirmanak/mealient/data/add/models/AddRecipeInputSerializer.kt new file mode 100644 index 0000000..d7f0199 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/data/add/models/AddRecipeInputSerializer.kt @@ -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 { + 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) +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/add/models/AddRecipeRequest.kt b/app/src/main/java/gq/kirmanak/mealient/data/add/models/AddRecipeRequest.kt new file mode 100644 index 0000000..deb538e --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/data/add/models/AddRecipeRequest.kt @@ -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 = emptyList(), + @SerialName("recipeInstructions") val recipeInstructions: List = emptyList(), + @SerialName("slug") val slug: String = "", + @SerialName("filePath") val filePath: String = "", + @SerialName("tags") val tags: List = emptyList(), + @SerialName("categories") val categories: List = emptyList(), + @SerialName("notes") val notes: List = emptyList(), + @SerialName("extras") val extras: Map = emptyMap(), + @SerialName("assets") val assets: List = 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, +) diff --git a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImpl.kt index 0fe2951..b634a7d 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImpl.kt @@ -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 = runCatchingExceptCancel { - authService.getToken(username, password) - }.getOrElse { - Timber.e(it, "sendRequest: can't request token") - throw it.mapToNetworkError() - } + ): Response = logAndMapErrors( + block = { authService.getToken(username = username, password = password) }, + logProvider = { "sendRequest: can't get token" }, + ) private fun parseToken( response: Response diff --git a/app/src/main/java/gq/kirmanak/mealient/data/baseurl/impl/VersionDataSourceImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/impl/VersionDataSourceImpl.kt index 4a472f9..27e65d7 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/baseurl/impl/VersionDataSourceImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/impl/VersionDataSourceImpl.kt @@ -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() } diff --git a/app/src/main/java/gq/kirmanak/mealient/di/AddRecipeModule.kt b/app/src/main/java/gq/kirmanak/mealient/di/AddRecipeModule.kt new file mode 100644 index 0000000..c0b0402 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/di/AddRecipeModule.kt @@ -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 = DataStoreFactory.create(AddRecipeInputSerializer) { + context.dataStoreFile("add_recipe_input") + } + + @Provides + @Singleton + fun provideAddRecipeServiceFactory( + @Named(AUTH_OK_HTTP) okHttpClient: OkHttpClient, + json: Json, + baseURLStorage: BaseURLStorage, + ): ServiceFactory { + 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 +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/di/NetworkModule.kt b/app/src/main/java/gq/kirmanak/mealient/di/NetworkModule.kt index 0a49c38..31bad2f 100644 --- a/app/src/main/java/gq/kirmanak/mealient/di/NetworkModule.kt +++ b/app/src/main/java/gq/kirmanak/mealient/di/NetworkModule.kt @@ -45,5 +45,6 @@ object NetworkModule { fun createJson(): Json = Json { coerceInputValues = true ignoreUnknownKeys = true + encodeDefaults = true } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/extensions/NetworkExtensions.kt b/app/src/main/java/gq/kirmanak/mealient/extensions/NetworkExtensions.kt index 92bb769..aa27e85 100644 --- a/app/src/main/java/gq/kirmanak/mealient/extensions/NetworkExtensions.kt +++ b/app/src/main/java/gq/kirmanak/mealient/extensions/NetworkExtensions.kt @@ -23,3 +23,9 @@ fun Throwable.mapToNetworkError(): NetworkError = when (this) { is HttpException, is SerializationException -> NetworkError.NotMealie(this) else -> NetworkError.NoServerConnection(this) } + +inline fun logAndMapErrors(block: () -> T, logProvider: () -> String): T = + runCatchingExceptCancel(block).getOrElse { + Timber.e(it, logProvider()) + throw it.mapToNetworkError() + } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/extensions/ViewExtensions.kt b/app/src/main/java/gq/kirmanak/mealient/extensions/ViewExtensions.kt index 56d2b72..8a1fc8c 100644 --- a/app/src/main/java/gq/kirmanak/mealient/extensions/ViewExtensions.kt +++ b/app/src/main/java/gq/kirmanak/mealient/extensions/ViewExtensions.kt @@ -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 = 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 = callbackFlow { Timber.v("textChangesFlow() called") val textWatcher = doAfterTextChanged { @@ -109,7 +106,6 @@ suspend fun EditText.waitUntilNotEmpty() { Timber.v("waitUntilNotEmpty() returned") } -@OptIn(ExperimentalCoroutinesApi::class) fun SharedPreferences.prefsChangeFlow( valueReader: SharedPreferences.() -> T, ): Flow = callbackFlow { diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/activity/MainActivity.kt b/app/src/main/java/gq/kirmanak/mealient/ui/activity/MainActivity.kt index 08aac96..4c7dccb 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/activity/MainActivity.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/activity/MainActivity.kt @@ -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" } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/activity/MainActivityUiState.kt b/app/src/main/java/gq/kirmanak/mealient/ui/activity/MainActivityUiState.kt index c9b5e82..edf0d09 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/activity/MainActivityUiState.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/activity/MainActivityUiState.kt @@ -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 diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/add/AddRecipeFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/add/AddRecipeFragment.kt new file mode 100644 index 0000000..06c20b8 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/add/AddRecipeFragment.kt @@ -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() + private val activityViewModel by activityViewModels() + + 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 = + 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.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(id) ?: continue + removeView(view) + binding.holder.removeView(view) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/add/AddRecipeViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/add/AddRecipeViewModel.kt new file mode 100644 index 0000000..6087ce4 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/add/AddRecipeViewModel.kt @@ -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(Channel.UNLIMITED) + val addRecipeResult: Flow get() = _addRecipeResultChannel.receiveAsFlow() + + private val _preservedAddRecipeRequestChannel = Channel(Channel.UNLIMITED) + val preservedAddRecipeRequest: Flow + 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) + } + } +} diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationFragment.kt index f698e90..3be5eaf 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationFragment.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationFragment.kt @@ -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) } diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLFragment.kt index afee238..079b8f9 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLFragment.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLFragment.kt @@ -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) { diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/disclaimer/DisclaimerFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/disclaimer/DisclaimerFragment.kt index f306505..deefaa9 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/disclaimer/DisclaimerFragment.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/disclaimer/DisclaimerFragment.kt @@ -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) + } } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt index f4a6d44..990c6fb 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt @@ -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() } diff --git a/app/src/main/proto/AddRecipeInput.proto b/app/src/main/proto/AddRecipeInput.proto new file mode 100644 index 0000000..13df823 --- /dev/null +++ b/app/src/main/proto/AddRecipeInput.proto @@ -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; +} \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_add_recipe.xml b/app/src/main/res/layout/fragment_add_recipe.xml new file mode 100644 index 0000000..b2eb65b --- /dev/null +++ b/app/src/main/res/layout/fragment_add_recipe.xml @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + +