From f2a4d00cf9296ee85019f530b31639fd68423ec6 Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Mon, 28 Nov 2022 20:09:08 +0100 Subject: [PATCH 1/6] Add sharetarget compatibility library --- app/build.gradle.kts | 2 ++ gradle/libs.versions.toml | 3 +++ 2 files changed, 5 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7ee773a..5d8f8fa 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -87,6 +87,8 @@ dependencies { implementation(libs.androidx.lifecycle.livedataKtx) implementation(libs.androidx.lifecycle.viewmodelKtx) + implementation(libs.androidx.shareTarget) + implementation(libs.google.dagger.hiltAndroid) kapt(libs.google.dagger.hiltCompiler) kaptTest(libs.google.dagger.hiltAndroidCompiler) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3ce76a0..f6f528f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -75,6 +75,8 @@ chucker = "3.5.2" desugar = "1.2.2" # https://github.com/google/ksp/releases kspPlugin = "1.7.20-1.0.7" +# https://developer.android.com/jetpack/androidx/releases/sharetarget +shareTarget = "1.2.0" [libraries] android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } @@ -111,6 +113,7 @@ androidx-constraintLayout = { group = "androidx.constraintlayout", name = "const androidx-swipeRefreshLayout = { group = "androidx.swiperefreshlayout", name = "swiperefreshlayout", version.ref = "swipeRefreshLayout" } androidx-splashScreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashScreen" } androidx-coreTesting = { group = "androidx.arch.core", name = "core-testing", version.ref = "coreTesting" } +androidx-shareTarget = { group = "androidx.sharetarget", name = "sharetarget", version.ref = "shareTarget" } androidx-paging-runtimeKtx = { group = "androidx.paging", name = "paging-runtime-ktx", version.ref = "paging" } androidx-paging-commonKtx = { group = "androidx.paging", name = "paging-common-ktx", version.ref = "paging" } From 4826478a2a28d2ea93f72a323c2a3c50cb732c9a Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Mon, 28 Nov 2022 21:22:24 +0100 Subject: [PATCH 2/6] Implement saving recipes by URLs --- app/src/main/AndroidManifest.xml | 17 ++++++- .../data/network/MealieDataSourceWrapper.kt | 24 +++++++++- .../data/share/ParseRecipeDataSource.kt | 6 +++ .../mealient/data/share/ParseRecipeURLInfo.kt | 6 +++ .../mealient/data/share/ShareRecipeRepo.kt | 6 +++ .../data/share/ShareRecipeRepoImpl.kt | 18 +++++++ .../kirmanak/mealient/di/ShareRecipeModule.kt | 24 ++++++++++ .../mealient/extensions/ModelMappings.kt | 35 ++++++++++++-- .../mealient/ui/share/ShareRecipeActivity.kt | 48 +++++++++++++++++++ .../mealient/ui/share/ShareRecipeViewModel.kt | 37 ++++++++++++++ app/src/main/res/values-ru/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + .../datasource/v0/MealieDataSourceV0.kt | 7 +++ .../datasource/v0/MealieDataSourceV0Impl.kt | 18 ++++++- .../mealient/datasource/v0/MealieServiceV0.kt | 7 +++ .../v0/models/ParseRecipeURLRequestV0.kt | 9 ++++ .../datasource/v1/MealieDataSourceV1.kt | 13 ++++- .../datasource/v1/MealieDataSourceV1Impl.kt | 19 +++++++- .../mealient/datasource/v1/MealieServiceV1.kt | 7 +++ .../v1/models/ParseRecipeURLRequestV1.kt | 10 ++++ 20 files changed, 305 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/gq/kirmanak/mealient/data/share/ParseRecipeDataSource.kt create mode 100644 app/src/main/java/gq/kirmanak/mealient/data/share/ParseRecipeURLInfo.kt create mode 100644 app/src/main/java/gq/kirmanak/mealient/data/share/ShareRecipeRepo.kt create mode 100644 app/src/main/java/gq/kirmanak/mealient/data/share/ShareRecipeRepoImpl.kt create mode 100644 app/src/main/java/gq/kirmanak/mealient/di/ShareRecipeModule.kt create mode 100644 app/src/main/java/gq/kirmanak/mealient/ui/share/ShareRecipeActivity.kt create mode 100644 app/src/main/java/gq/kirmanak/mealient/ui/share/ShareRecipeViewModel.kt create mode 100644 datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/models/ParseRecipeURLRequestV0.kt create mode 100644 datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/models/ParseRecipeURLRequestV1.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b4d3649..d1dfc1e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,14 +19,27 @@ tools:ignore="UnusedAttribute"> + android:exported="true" + android:windowSoftInputMode="adjustPan"> + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/network/MealieDataSourceWrapper.kt b/app/src/main/java/gq/kirmanak/mealient/data/network/MealieDataSourceWrapper.kt index 4f451fc..f830766 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/network/MealieDataSourceWrapper.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/network/MealieDataSourceWrapper.kt @@ -8,11 +8,18 @@ import gq.kirmanak.mealient.data.baseurl.ServerVersion import gq.kirmanak.mealient.data.recipes.network.FullRecipeInfo import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource import gq.kirmanak.mealient.data.recipes.network.RecipeSummaryInfo +import gq.kirmanak.mealient.data.share.ParseRecipeDataSource +import gq.kirmanak.mealient.data.share.ParseRecipeURLInfo import gq.kirmanak.mealient.datasource.NetworkError import gq.kirmanak.mealient.datasource.runCatchingExceptCancel import gq.kirmanak.mealient.datasource.v0.MealieDataSourceV0 import gq.kirmanak.mealient.datasource.v1.MealieDataSourceV1 -import gq.kirmanak.mealient.extensions.* +import gq.kirmanak.mealient.extensions.toFullRecipeInfo +import gq.kirmanak.mealient.extensions.toRecipeSummaryInfo +import gq.kirmanak.mealient.extensions.toV0Request +import gq.kirmanak.mealient.extensions.toV1CreateRequest +import gq.kirmanak.mealient.extensions.toV1Request +import gq.kirmanak.mealient.extensions.toV1UpdateRequest import gq.kirmanak.mealient.logging.Logger import javax.inject.Inject import javax.inject.Singleton @@ -24,7 +31,7 @@ class MealieDataSourceWrapper @Inject constructor( private val v0Source: MealieDataSourceV0, private val v1Source: MealieDataSourceV1, private val logger: Logger, -) : AddRecipeDataSource, RecipeDataSource { +) : AddRecipeDataSource, RecipeDataSource, ParseRecipeDataSource { override suspend fun addRecipe( recipe: AddRecipeInfo, @@ -64,6 +71,19 @@ class MealieDataSourceWrapper @Inject constructor( } } + override suspend fun parseRecipeFromURL( + parseRecipeURLInfo: ParseRecipeURLInfo, + ): String = makeCall { token, url, version -> + when (version) { + ServerVersion.V0 -> { + v0Source.parseRecipeFromURL(url, token, parseRecipeURLInfo.toV0Request()) + } + ServerVersion.V1 -> { + v1Source.parseRecipeFromURL(url, token, parseRecipeURLInfo.toV1Request()) + } + } + } + private suspend inline fun makeCall(block: (String?, String, ServerVersion) -> T): T { val authHeader = authRepo.getAuthHeader() val url = serverInfoRepo.requireUrl() diff --git a/app/src/main/java/gq/kirmanak/mealient/data/share/ParseRecipeDataSource.kt b/app/src/main/java/gq/kirmanak/mealient/data/share/ParseRecipeDataSource.kt new file mode 100644 index 0000000..27c3def --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/data/share/ParseRecipeDataSource.kt @@ -0,0 +1,6 @@ +package gq.kirmanak.mealient.data.share + +interface ParseRecipeDataSource { + + suspend fun parseRecipeFromURL(parseRecipeURLInfo: ParseRecipeURLInfo): String +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/share/ParseRecipeURLInfo.kt b/app/src/main/java/gq/kirmanak/mealient/data/share/ParseRecipeURLInfo.kt new file mode 100644 index 0000000..7866216 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/data/share/ParseRecipeURLInfo.kt @@ -0,0 +1,6 @@ +package gq.kirmanak.mealient.data.share + +data class ParseRecipeURLInfo( + val url: String, + val includeTags: Boolean +) diff --git a/app/src/main/java/gq/kirmanak/mealient/data/share/ShareRecipeRepo.kt b/app/src/main/java/gq/kirmanak/mealient/data/share/ShareRecipeRepo.kt new file mode 100644 index 0000000..4475f28 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/data/share/ShareRecipeRepo.kt @@ -0,0 +1,6 @@ +package gq.kirmanak.mealient.data.share + +interface ShareRecipeRepo { + + suspend fun saveRecipeByURL(url: CharSequence): String +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/share/ShareRecipeRepoImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/share/ShareRecipeRepoImpl.kt new file mode 100644 index 0000000..e040d25 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/data/share/ShareRecipeRepoImpl.kt @@ -0,0 +1,18 @@ +package gq.kirmanak.mealient.data.share + +import gq.kirmanak.mealient.logging.Logger +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ShareRecipeRepoImpl @Inject constructor( + private val logger: Logger, + private val parseRecipeDataSource: ParseRecipeDataSource, +) : ShareRecipeRepo { + + override suspend fun saveRecipeByURL(url: CharSequence): String { + logger.v { "saveRecipeByURL() called with: url = $url" } + val request = ParseRecipeURLInfo(url = url.toString(), includeTags = true) + return parseRecipeDataSource.parseRecipeFromURL(request) + } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/di/ShareRecipeModule.kt b/app/src/main/java/gq/kirmanak/mealient/di/ShareRecipeModule.kt new file mode 100644 index 0000000..7c7ace6 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/di/ShareRecipeModule.kt @@ -0,0 +1,24 @@ +package gq.kirmanak.mealient.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import gq.kirmanak.mealient.data.network.MealieDataSourceWrapper +import gq.kirmanak.mealient.data.share.ParseRecipeDataSource +import gq.kirmanak.mealient.data.share.ShareRecipeRepo +import gq.kirmanak.mealient.data.share.ShareRecipeRepoImpl +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +interface ShareRecipeModule { + + @Binds + @Singleton + fun bindShareRecipeRepo(shareRecipeRepoImpl: ShareRecipeRepoImpl): ShareRecipeRepo + + @Binds + @Singleton + fun bindParseRecipeDataSource(mealieDataSourceWrapper: MealieDataSourceWrapper): ParseRecipeDataSource +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/extensions/ModelMappings.kt b/app/src/main/java/gq/kirmanak/mealient/extensions/ModelMappings.kt index 1ae6168..cf6e27c 100644 --- a/app/src/main/java/gq/kirmanak/mealient/extensions/ModelMappings.kt +++ b/app/src/main/java/gq/kirmanak/mealient/extensions/ModelMappings.kt @@ -9,12 +9,32 @@ import gq.kirmanak.mealient.data.recipes.network.FullRecipeInfo import gq.kirmanak.mealient.data.recipes.network.RecipeIngredientInfo import gq.kirmanak.mealient.data.recipes.network.RecipeInstructionInfo import gq.kirmanak.mealient.data.recipes.network.RecipeSummaryInfo +import gq.kirmanak.mealient.data.share.ParseRecipeURLInfo import gq.kirmanak.mealient.database.recipe.entity.RecipeEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeIngredientEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeInstructionEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity -import gq.kirmanak.mealient.datasource.v0.models.* -import gq.kirmanak.mealient.datasource.v1.models.* +import gq.kirmanak.mealient.datasource.v0.models.AddRecipeIngredientV0 +import gq.kirmanak.mealient.datasource.v0.models.AddRecipeInstructionV0 +import gq.kirmanak.mealient.datasource.v0.models.AddRecipeRequestV0 +import gq.kirmanak.mealient.datasource.v0.models.AddRecipeSettingsV0 +import gq.kirmanak.mealient.datasource.v0.models.GetRecipeIngredientResponseV0 +import gq.kirmanak.mealient.datasource.v0.models.GetRecipeInstructionResponseV0 +import gq.kirmanak.mealient.datasource.v0.models.GetRecipeResponseV0 +import gq.kirmanak.mealient.datasource.v0.models.GetRecipeSummaryResponseV0 +import gq.kirmanak.mealient.datasource.v0.models.ParseRecipeURLRequestV0 +import gq.kirmanak.mealient.datasource.v0.models.VersionResponseV0 +import gq.kirmanak.mealient.datasource.v1.models.AddRecipeIngredientV1 +import gq.kirmanak.mealient.datasource.v1.models.AddRecipeInstructionV1 +import gq.kirmanak.mealient.datasource.v1.models.AddRecipeSettingsV1 +import gq.kirmanak.mealient.datasource.v1.models.CreateRecipeRequestV1 +import gq.kirmanak.mealient.datasource.v1.models.GetRecipeIngredientResponseV1 +import gq.kirmanak.mealient.datasource.v1.models.GetRecipeInstructionResponseV1 +import gq.kirmanak.mealient.datasource.v1.models.GetRecipeResponseV1 +import gq.kirmanak.mealient.datasource.v1.models.GetRecipeSummaryResponseV1 +import gq.kirmanak.mealient.datasource.v1.models.ParseRecipeURLRequestV1 +import gq.kirmanak.mealient.datasource.v1.models.UpdateRecipeRequestV1 +import gq.kirmanak.mealient.datasource.v1.models.VersionResponseV1 import gq.kirmanak.mealient.datastore.recipe.AddRecipeDraft import java.util.* @@ -170,4 +190,13 @@ private fun AddRecipeInstructionInfo.toV1Instruction() = AddRecipeInstructionV1( id = UUID.randomUUID().toString(), text = text, ingredientReferences = emptyList(), -) \ No newline at end of file +) + +fun ParseRecipeURLInfo.toV1Request() = ParseRecipeURLRequestV1( + url = url, + includeTags = includeTags, +) + +fun ParseRecipeURLInfo.toV0Request() = ParseRecipeURLRequestV0( + url = url, +) diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/share/ShareRecipeActivity.kt b/app/src/main/java/gq/kirmanak/mealient/ui/share/ShareRecipeActivity.kt new file mode 100644 index 0000000..437dd30 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/share/ShareRecipeActivity.kt @@ -0,0 +1,48 @@ +package gq.kirmanak.mealient.ui.share + +import android.content.Intent +import android.os.Bundle +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import dagger.hilt.android.AndroidEntryPoint +import gq.kirmanak.mealient.R +import gq.kirmanak.mealient.extensions.showLongToast +import gq.kirmanak.mealient.logging.Logger +import javax.inject.Inject + +@AndroidEntryPoint +class ShareRecipeActivity : AppCompatActivity() { + + private val viewModel: ShareRecipeViewModel by viewModels() + + @Inject + lateinit var logger: Logger + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + logger.v { "onCreate() called with: savedInstanceState = $savedInstanceState" } + + if (intent.action != Intent.ACTION_SEND || intent.type != "text/plain") { + logger.w { "onCreate: intent.action = ${intent.action}, intent.type = ${intent.type}" } + finish() + return + } + + val url: CharSequence? = intent.getCharSequenceExtra(Intent.EXTRA_TEXT) + if (url == null) { + logger.w { "onCreate: Intent's EXTRA_TEXT was null" } + finish() + return + } + + viewModel.saveOperationResult.observe(this) { + showLongToast( + if (it.isSuccess) R.string.activity_share_recipe_success_toast + else R.string.activity_share_recipe_failure_toast + ) + finish() + } + + viewModel.saveRecipeByURL(url) + } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/share/ShareRecipeViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/share/ShareRecipeViewModel.kt new file mode 100644 index 0000000..b82569d --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/share/ShareRecipeViewModel.kt @@ -0,0 +1,37 @@ +package gq.kirmanak.mealient.ui.share + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import gq.kirmanak.mealient.data.share.ShareRecipeRepo +import gq.kirmanak.mealient.datasource.runCatchingExceptCancel +import gq.kirmanak.mealient.logging.Logger +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ShareRecipeViewModel @Inject constructor( + private val shareRecipeRepo: ShareRecipeRepo, + private val logger: Logger, +) : ViewModel() { + + private val _saveOperationResult = MutableLiveData>() + val saveOperationResult: LiveData> get() = _saveOperationResult + + fun saveRecipeByURL(url: CharSequence) { + logger.v { "saveRecipeByURL() called with: url = $url" } + viewModelScope.launch { + runCatchingExceptCancel { + shareRecipeRepo.saveRecipeByURL(url) + }.onSuccess { + logger.d { "Successfully saved recipe by URL" } + _saveOperationResult.postValue(Result.success(it)) + }.onFailure { + logger.e(it) { "Can't save recipe by URL" } + _saveOperationResult.postValue(Result.failure(it)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index eb4ec6a..9ea13b7 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -51,4 +51,6 @@ Найти рецепты Открыть меню навигации Нет рецептов + Рецепт успешно сохранен. + Что-то пошло не так. \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f14a3ab..abdee58 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -54,4 +54,6 @@ @string/app_name Open navigation drawer No recipes + Recipe saved successfully. + Something went wrong. \ No newline at end of file diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieDataSourceV0.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieDataSourceV0.kt index a51d5a7..79e3263 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieDataSourceV0.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieDataSourceV0.kt @@ -3,6 +3,7 @@ package gq.kirmanak.mealient.datasource.v0 import gq.kirmanak.mealient.datasource.v0.models.AddRecipeRequestV0 import gq.kirmanak.mealient.datasource.v0.models.GetRecipeResponseV0 import gq.kirmanak.mealient.datasource.v0.models.GetRecipeSummaryResponseV0 +import gq.kirmanak.mealient.datasource.v0.models.ParseRecipeURLRequestV0 import gq.kirmanak.mealient.datasource.v0.models.VersionResponseV0 interface MealieDataSourceV0 { @@ -38,4 +39,10 @@ interface MealieDataSourceV0 { token: String?, slug: String, ): GetRecipeResponseV0 + + suspend fun parseRecipeFromURL( + baseUrl: String, + token: String?, + request: ParseRecipeURLRequestV0, + ): String } \ No newline at end of file diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieDataSourceV0Impl.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieDataSourceV0Impl.kt index db7b276..d39485a 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieDataSourceV0Impl.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieDataSourceV0Impl.kt @@ -3,7 +3,12 @@ package gq.kirmanak.mealient.datasource.v0 import gq.kirmanak.mealient.datasource.NetworkError import gq.kirmanak.mealient.datasource.NetworkRequestWrapper import gq.kirmanak.mealient.datasource.decode -import gq.kirmanak.mealient.datasource.v0.models.* +import gq.kirmanak.mealient.datasource.v0.models.AddRecipeRequestV0 +import gq.kirmanak.mealient.datasource.v0.models.ErrorDetailV0 +import gq.kirmanak.mealient.datasource.v0.models.GetRecipeResponseV0 +import gq.kirmanak.mealient.datasource.v0.models.GetRecipeSummaryResponseV0 +import gq.kirmanak.mealient.datasource.v0.models.ParseRecipeURLRequestV0 +import gq.kirmanak.mealient.datasource.v0.models.VersionResponseV0 import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json import retrofit2.HttpException @@ -77,4 +82,15 @@ class MealieDataSourceV0Impl @Inject constructor( logMethod = { "requestRecipeInfo" }, logParameters = { "baseUrl = $baseUrl, token = $token, slug = $slug" } ) + + override suspend fun parseRecipeFromURL( + baseUrl: String, + token: String?, + request: ParseRecipeURLRequestV0 + ): String = networkRequestWrapper.makeCallAndHandleUnauthorized( + block = { service.createRecipeFromURL("$baseUrl/api/recipes/create-url", token, request) }, + logMethod = { "parseRecipeFromURL" }, + logParameters = { "baseUrl = $baseUrl, token = $token, request = $request" } + + ) } diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieServiceV0.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieServiceV0.kt index 0d6329b..55771f3 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieServiceV0.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieServiceV0.kt @@ -39,4 +39,11 @@ interface MealieServiceV0 { @Url url: String, @Header(AUTHORIZATION_HEADER_NAME) token: String?, ): GetRecipeResponseV0 + + @POST + suspend fun createRecipeFromURL( + @Url url: String, + @Header(AUTHORIZATION_HEADER_NAME) token: String?, + @Body request: ParseRecipeURLRequestV0, + ): String } \ No newline at end of file diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/models/ParseRecipeURLRequestV0.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/models/ParseRecipeURLRequestV0.kt new file mode 100644 index 0000000..7f46ece --- /dev/null +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/models/ParseRecipeURLRequestV0.kt @@ -0,0 +1,9 @@ +package gq.kirmanak.mealient.datasource.v0.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ParseRecipeURLRequestV0( + @SerialName("url") val url: String, +) diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1.kt index 6ba2d6b..a231834 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1.kt @@ -1,6 +1,11 @@ package gq.kirmanak.mealient.datasource.v1 -import gq.kirmanak.mealient.datasource.v1.models.* +import gq.kirmanak.mealient.datasource.v1.models.CreateRecipeRequestV1 +import gq.kirmanak.mealient.datasource.v1.models.GetRecipeResponseV1 +import gq.kirmanak.mealient.datasource.v1.models.GetRecipeSummaryResponseV1 +import gq.kirmanak.mealient.datasource.v1.models.ParseRecipeURLRequestV1 +import gq.kirmanak.mealient.datasource.v1.models.UpdateRecipeRequestV1 +import gq.kirmanak.mealient.datasource.v1.models.VersionResponseV1 interface MealieDataSourceV1 { @@ -42,4 +47,10 @@ interface MealieDataSourceV1 { token: String?, slug: String, ): GetRecipeResponseV1 + + suspend fun parseRecipeFromURL( + baseUrl: String, + token: String?, + request: ParseRecipeURLRequestV1, + ): String } \ No newline at end of file diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1Impl.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1Impl.kt index e00c0d0..f7551b0 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1Impl.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1Impl.kt @@ -3,7 +3,13 @@ package gq.kirmanak.mealient.datasource.v1 import gq.kirmanak.mealient.datasource.NetworkError import gq.kirmanak.mealient.datasource.NetworkRequestWrapper import gq.kirmanak.mealient.datasource.decode -import gq.kirmanak.mealient.datasource.v1.models.* +import gq.kirmanak.mealient.datasource.v1.models.CreateRecipeRequestV1 +import gq.kirmanak.mealient.datasource.v1.models.ErrorDetailV1 +import gq.kirmanak.mealient.datasource.v1.models.GetRecipeResponseV1 +import gq.kirmanak.mealient.datasource.v1.models.GetRecipeSummaryResponseV1 +import gq.kirmanak.mealient.datasource.v1.models.ParseRecipeURLRequestV1 +import gq.kirmanak.mealient.datasource.v1.models.UpdateRecipeRequestV1 +import gq.kirmanak.mealient.datasource.v1.models.VersionResponseV1 import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json import retrofit2.HttpException @@ -89,5 +95,16 @@ class MealieDataSourceV1Impl @Inject constructor( logParameters = { "baseUrl = $baseUrl, token = $token, slug = $slug" } ) + override suspend fun parseRecipeFromURL( + baseUrl: String, + token: String?, + request: ParseRecipeURLRequestV1 + ): String = networkRequestWrapper.makeCallAndHandleUnauthorized( + block = { service.createRecipeFromURL("$baseUrl/api/recipes/create-url", token, request) }, + logMethod = { "parseRecipeFromURL" }, + logParameters = { "baseUrl = $baseUrl, token = $token, request = $request" } + + ) + } diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieServiceV1.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieServiceV1.kt index c6644fc..c5aa05e 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieServiceV1.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieServiceV1.kt @@ -46,4 +46,11 @@ interface MealieServiceV1 { @Url url: String, @Header(AUTHORIZATION_HEADER_NAME) token: String?, ): GetRecipeResponseV1 + + @POST + suspend fun createRecipeFromURL( + @Url url: String, + @Header(AUTHORIZATION_HEADER_NAME) token: String?, + @Body request: ParseRecipeURLRequestV1, + ): String } \ No newline at end of file diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/models/ParseRecipeURLRequestV1.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/models/ParseRecipeURLRequestV1.kt new file mode 100644 index 0000000..e1f7671 --- /dev/null +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/models/ParseRecipeURLRequestV1.kt @@ -0,0 +1,10 @@ +package gq.kirmanak.mealient.datasource.v1.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ParseRecipeURLRequestV1( + @SerialName("url") val url: String, + @SerialName("includeTags") val includeTags: Boolean +) From a0a3862df7cf6c9e128844e910718c843616e856 Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Mon, 28 Nov 2022 21:45:03 +0100 Subject: [PATCH 3/6] Add ShareRecipeRepoImpl tests --- .../data/share/ShareRecipeRepoImpl.kt | 12 +++- .../data/share/ShareRecipeRepoImplTest.kt | 70 +++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/gq/kirmanak/mealient/data/share/ShareRecipeRepoImplTest.kt diff --git a/app/src/main/java/gq/kirmanak/mealient/data/share/ShareRecipeRepoImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/share/ShareRecipeRepoImpl.kt index e040d25..15c155d 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/share/ShareRecipeRepoImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/share/ShareRecipeRepoImpl.kt @@ -12,7 +12,17 @@ class ShareRecipeRepoImpl @Inject constructor( override suspend fun saveRecipeByURL(url: CharSequence): String { logger.v { "saveRecipeByURL() called with: url = $url" } - val request = ParseRecipeURLInfo(url = url.toString(), includeTags = true) + + val urlStart = url.indexOf("http") + if (urlStart == -1) throw IllegalArgumentException("URL doesn't start with http") + + val startsWithUrl = url.subSequence(urlStart, url.length) + val urlEnd = startsWithUrl.indexOfFirst { it.isWhitespace() } + .takeUnless { it == -1 } + ?: startsWithUrl.length + + val urlString = startsWithUrl.substring(0, urlEnd) + val request = ParseRecipeURLInfo(url = urlString, includeTags = true) return parseRecipeDataSource.parseRecipeFromURL(request) } } \ No newline at end of file diff --git a/app/src/test/java/gq/kirmanak/mealient/data/share/ShareRecipeRepoImplTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/share/ShareRecipeRepoImplTest.kt new file mode 100644 index 0000000..2a83c9b --- /dev/null +++ b/app/src/test/java/gq/kirmanak/mealient/data/share/ShareRecipeRepoImplTest.kt @@ -0,0 +1,70 @@ +package gq.kirmanak.mealient.data.share + +import gq.kirmanak.mealient.test.BaseUnitTest +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ShareRecipeRepoImplTest : BaseUnitTest() { + + + @MockK(relaxUnitFun = true) + lateinit var parseRecipeDataSource: ParseRecipeDataSource + + lateinit var subject: ShareRecipeRepo + + override fun setUp() { + super.setUp() + subject = ShareRecipeRepoImpl(logger, parseRecipeDataSource) + coEvery { parseRecipeDataSource.parseRecipeFromURL(any()) } returns "" + } + + @Test(expected = IllegalArgumentException::class) + fun `when url is empty expect saveRecipeByURL throws Exception`() = runTest { + subject.saveRecipeByURL("") + } + + @Test + fun `when url is correct expect saveRecipeByURL saves it`() = runTest { + subject.saveRecipeByURL("https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie/") + val expected = ParseRecipeURLInfo( + url = "https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie/", + includeTags = true + ) + coVerify { parseRecipeDataSource.parseRecipeFromURL(eq(expected)) } + } + + @Test + fun `when url has prefix expect saveRecipeByURL removes it`() = runTest { + subject.saveRecipeByURL("My favorite recipe: https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie/") + val expected = ParseRecipeURLInfo( + url = "https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie/", + includeTags = true + ) + coVerify { parseRecipeDataSource.parseRecipeFromURL(eq(expected)) } + } + + @Test + fun `when url has suffix expect saveRecipeByURL removes it`() = runTest { + subject.saveRecipeByURL("https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie/ is my favorite recipe") + val expected = ParseRecipeURLInfo( + url = "https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie/", + includeTags = true + ) + coVerify { parseRecipeDataSource.parseRecipeFromURL(eq(expected)) } + } + + @Test + fun `when url has prefix and suffix expect saveRecipeByURL removes them`() = runTest { + subject.saveRecipeByURL("Actually, https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie/ is my favorite recipe") + val expected = ParseRecipeURLInfo( + url = "https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie/", + includeTags = true + ) + coVerify { parseRecipeDataSource.parseRecipeFromURL(eq(expected)) } + } +} \ No newline at end of file From 0c41aac9b7dfd61b7e9830c2b9fdc6271f72fa32 Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Mon, 28 Nov 2022 21:59:08 +0100 Subject: [PATCH 4/6] Use regex to find URLs --- .../mealient/data/share/ShareRecipeRepoImpl.kt | 14 ++++---------- .../mealient/data/share/ShareRecipeRepoImplTest.kt | 4 ++-- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/gq/kirmanak/mealient/data/share/ShareRecipeRepoImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/share/ShareRecipeRepoImpl.kt index 15c155d..7d711ae 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/share/ShareRecipeRepoImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/share/ShareRecipeRepoImpl.kt @@ -1,5 +1,6 @@ package gq.kirmanak.mealient.data.share +import androidx.core.util.PatternsCompat import gq.kirmanak.mealient.logging.Logger import javax.inject.Inject import javax.inject.Singleton @@ -12,16 +13,9 @@ class ShareRecipeRepoImpl @Inject constructor( override suspend fun saveRecipeByURL(url: CharSequence): String { logger.v { "saveRecipeByURL() called with: url = $url" } - - val urlStart = url.indexOf("http") - if (urlStart == -1) throw IllegalArgumentException("URL doesn't start with http") - - val startsWithUrl = url.subSequence(urlStart, url.length) - val urlEnd = startsWithUrl.indexOfFirst { it.isWhitespace() } - .takeUnless { it == -1 } - ?: startsWithUrl.length - - val urlString = startsWithUrl.substring(0, urlEnd) + val matcher = PatternsCompat.WEB_URL.matcher(url) + require(matcher.find()) { "Can't find URL in the text" } + val urlString = matcher.group() val request = ParseRecipeURLInfo(url = urlString, includeTags = true) return parseRecipeDataSource.parseRecipeFromURL(request) } diff --git a/app/src/test/java/gq/kirmanak/mealient/data/share/ShareRecipeRepoImplTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/share/ShareRecipeRepoImplTest.kt index 2a83c9b..1f03463 100644 --- a/app/src/test/java/gq/kirmanak/mealient/data/share/ShareRecipeRepoImplTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/data/share/ShareRecipeRepoImplTest.kt @@ -52,7 +52,7 @@ class ShareRecipeRepoImplTest : BaseUnitTest() { fun `when url has suffix expect saveRecipeByURL removes it`() = runTest { subject.saveRecipeByURL("https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie/ is my favorite recipe") val expected = ParseRecipeURLInfo( - url = "https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie/", + url = "https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie", includeTags = true ) coVerify { parseRecipeDataSource.parseRecipeFromURL(eq(expected)) } @@ -62,7 +62,7 @@ class ShareRecipeRepoImplTest : BaseUnitTest() { fun `when url has prefix and suffix expect saveRecipeByURL removes them`() = runTest { subject.saveRecipeByURL("Actually, https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie/ is my favorite recipe") val expected = ParseRecipeURLInfo( - url = "https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie/", + url = "https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie", includeTags = true ) coVerify { parseRecipeDataSource.parseRecipeFromURL(eq(expected)) } From 4a68916433e97b298d35217966894cfd3acf7aeb Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Tue, 29 Nov 2022 19:42:07 +0100 Subject: [PATCH 5/6] Show progress when parsing recipe --- .../gq/kirmanak/mealient/ui/BaseActivity.kt | 36 ++++++++++ .../kirmanak/mealient/ui/OperationUiState.kt | 3 + .../mealient/ui/activity/MainActivity.kt | 24 ++----- .../mealient/ui/share/ShareRecipeActivity.kt | 69 +++++++++++++++---- .../mealient/ui/share/ShareRecipeViewModel.kt | 19 +++-- .../ic_progress_bar.xml} | 0 .../main/res/layout/activity_share_recipe.xml | 19 +++++ app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values-v31/drawable.xml | 4 ++ app/src/main/res/values/strings.xml | 1 + 10 files changed, 135 insertions(+), 41 deletions(-) create mode 100644 app/src/main/java/gq/kirmanak/mealient/ui/BaseActivity.kt rename app/src/main/res/{drawable-v31/ic_splash_screen.xml => drawable/ic_progress_bar.xml} (100%) create mode 100644 app/src/main/res/layout/activity_share_recipe.xml create mode 100644 app/src/main/res/values-v31/drawable.xml diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/BaseActivity.kt b/app/src/main/java/gq/kirmanak/mealient/ui/BaseActivity.kt new file mode 100644 index 0000000..495c696 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/BaseActivity.kt @@ -0,0 +1,36 @@ +package gq.kirmanak.mealient.ui + +import android.os.Bundle +import android.view.View +import androidx.annotation.IdRes +import androidx.annotation.LayoutRes +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowInsetsControllerCompat +import androidx.viewbinding.ViewBinding +import by.kirich1409.viewbindingdelegate.viewBinding +import gq.kirmanak.mealient.extensions.isDarkThemeOn +import gq.kirmanak.mealient.logging.Logger +import javax.inject.Inject + +abstract class BaseActivity( + binder: (View) -> T, + @IdRes containerId: Int, + @LayoutRes layoutRes: Int, +) : AppCompatActivity(layoutRes) { + + protected val binding by viewBinding(binder, containerId) + + @Inject + lateinit var logger: Logger + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + logger.v { "onCreate() called with: savedInstanceState = $savedInstanceState" } + setContentView(binding.root) + with(WindowInsetsControllerCompat(window, window.decorView)) { + val isAppearanceLightBars = !isDarkThemeOn() + isAppearanceLightNavigationBars = isAppearanceLightBars + isAppearanceLightStatusBars = isAppearanceLightBars + } + } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/OperationUiState.kt b/app/src/main/java/gq/kirmanak/mealient/ui/OperationUiState.kt index faf6f21..042f699 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/OperationUiState.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/OperationUiState.kt @@ -15,6 +15,9 @@ sealed class OperationUiState { val isProgress: Boolean get() = this is Progress + val isFailure: Boolean + get() = this is Failure + fun updateButtonState(button: Button) { button.isEnabled = !isProgress button.isClickable = !isProgress 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 cf4c5ac..af6c010 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 @@ -3,16 +3,13 @@ package gq.kirmanak.mealient.ui.activity import android.os.Bundle import android.view.MenuItem import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.isVisible import androidx.core.view.iterator import androidx.drawerlayout.widget.DrawerLayout import androidx.navigation.NavController import androidx.navigation.NavDirections import androidx.navigation.fragment.NavHostFragment -import by.kirich1409.viewbindingdelegate.viewBinding import dagger.hilt.android.AndroidEntryPoint import gq.kirmanak.mealient.NavGraphDirections.Companion.actionGlobalAddRecipeFragment import gq.kirmanak.mealient.NavGraphDirections.Companion.actionGlobalAuthenticationFragment @@ -21,28 +18,24 @@ import gq.kirmanak.mealient.NavGraphDirections.Companion.actionGlobalRecipesList import gq.kirmanak.mealient.R import gq.kirmanak.mealient.databinding.MainActivityBinding import gq.kirmanak.mealient.extensions.collectWhenResumed -import gq.kirmanak.mealient.extensions.isDarkThemeOn import gq.kirmanak.mealient.extensions.observeOnce -import gq.kirmanak.mealient.logging.Logger -import javax.inject.Inject +import gq.kirmanak.mealient.ui.BaseActivity @AndroidEntryPoint -class MainActivity : AppCompatActivity(R.layout.main_activity) { +class MainActivity : BaseActivity( + binder = MainActivityBinding::bind, + containerId = R.id.drawer, + layoutRes = R.layout.main_activity, +) { - private val binding: MainActivityBinding by viewBinding(MainActivityBinding::bind, R.id.drawer) private val viewModel by viewModels() private val navController: NavController get() = binding.navHost.getFragment().navController - @Inject - lateinit var logger: Logger - override fun onCreate(savedInstanceState: Bundle?) { val splashScreen = installSplashScreen() super.onCreate(savedInstanceState) - logger.v { "onCreate() called with: savedInstanceState = $savedInstanceState" } splashScreen.setKeepOnScreenCondition { viewModel.startDestination.value == null } - setContentView(binding.root) setupUi() configureNavGraph() } @@ -67,11 +60,6 @@ class MainActivity : AppCompatActivity(R.layout.main_activity) { viewModel.onSearchQuery(query.trim().takeUnless { it.isEmpty() }) } binding.navigationView.setNavigationItemSelectedListener(::onNavigationItemSelected) - with(WindowInsetsControllerCompat(window, window.decorView)) { - val isAppearanceLightBars = !isDarkThemeOn() - isAppearanceLightNavigationBars = isAppearanceLightBars - isAppearanceLightStatusBars = isAppearanceLightBars - } viewModel.uiStateLive.observe(this, ::onUiStateChange) collectWhenResumed(viewModel.clearSearchViewFocus) { logger.d { "clearSearchViewFocus(): received event" } diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/share/ShareRecipeActivity.kt b/app/src/main/java/gq/kirmanak/mealient/ui/share/ShareRecipeActivity.kt index 437dd30..6b30c8d 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/share/ShareRecipeActivity.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/share/ShareRecipeActivity.kt @@ -1,26 +1,31 @@ package gq.kirmanak.mealient.ui.share import android.content.Intent +import android.graphics.drawable.Animatable2 +import android.graphics.drawable.AnimatedVectorDrawable +import android.graphics.drawable.Drawable import android.os.Bundle import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.isInvisible +import androidx.core.view.postDelayed import dagger.hilt.android.AndroidEntryPoint import gq.kirmanak.mealient.R +import gq.kirmanak.mealient.databinding.ActivityShareRecipeBinding import gq.kirmanak.mealient.extensions.showLongToast -import gq.kirmanak.mealient.logging.Logger -import javax.inject.Inject +import gq.kirmanak.mealient.ui.BaseActivity +import gq.kirmanak.mealient.ui.OperationUiState @AndroidEntryPoint -class ShareRecipeActivity : AppCompatActivity() { +class ShareRecipeActivity : BaseActivity( + binder = ActivityShareRecipeBinding::bind, + containerId = R.id.root, + layoutRes = R.layout.activity_share_recipe, +) { private val viewModel: ShareRecipeViewModel by viewModels() - @Inject - lateinit var logger: Logger - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - logger.v { "onCreate() called with: savedInstanceState = $savedInstanceState" } if (intent.action != Intent.ACTION_SEND || intent.type != "text/plain") { logger.w { "onCreate: intent.action = ${intent.action}, intent.type = ${intent.type}" } @@ -35,14 +40,54 @@ class ShareRecipeActivity : AppCompatActivity() { return } - viewModel.saveOperationResult.observe(this) { + restartAnimationOnEnd() + viewModel.saveResult.observe(this, ::onStateUpdate) + viewModel.saveRecipeByURL(url) + } + + private fun onStateUpdate(state: OperationUiState) { + binding.progress.isInvisible = !state.isProgress + withAnimatedDrawable { + if (state.isProgress) start() else stop() + } + if (state.isSuccess || state.isFailure) { showLongToast( - if (it.isSuccess) R.string.activity_share_recipe_success_toast + if (state.isSuccess) R.string.activity_share_recipe_success_toast else R.string.activity_share_recipe_failure_toast ) finish() } - - viewModel.saveRecipeByURL(url) } + + private fun restartAnimationOnEnd() { + withAnimatedDrawable { + onAnimationEnd { + if (viewModel.saveResult.value?.isProgress == true) { + binding.progress.postDelayed(250) { start() } + } + } + } + } + + private inline fun withAnimatedDrawable(block: AnimatedVectorDrawable.() -> Unit) { + binding.progress.drawable.let { drawable -> + if (drawable is AnimatedVectorDrawable) { + drawable.block() + } else { + logger.w { "withAnimatedDrawable: progress's drawable is not AnimatedVectorDrawable" } + } + } + } +} + +private inline fun AnimatedVectorDrawable.onAnimationEnd( + crossinline block: AnimatedVectorDrawable.() -> Unit, +): Animatable2.AnimationCallback { + val callback = object : Animatable2.AnimationCallback() { + override fun onAnimationEnd(drawable: Drawable?) { + block() + } + } + registerAnimationCallback(callback) + return callback } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/share/ShareRecipeViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/share/ShareRecipeViewModel.kt index b82569d..8172e88 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/share/ShareRecipeViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/share/ShareRecipeViewModel.kt @@ -8,6 +8,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import gq.kirmanak.mealient.data.share.ShareRecipeRepo import gq.kirmanak.mealient.datasource.runCatchingExceptCancel import gq.kirmanak.mealient.logging.Logger +import gq.kirmanak.mealient.ui.OperationUiState import kotlinx.coroutines.launch import javax.inject.Inject @@ -17,21 +18,17 @@ class ShareRecipeViewModel @Inject constructor( private val logger: Logger, ) : ViewModel() { - private val _saveOperationResult = MutableLiveData>() - val saveOperationResult: LiveData> get() = _saveOperationResult + private val _saveResult = MutableLiveData>(OperationUiState.Initial()) + val saveResult: LiveData> get() = _saveResult fun saveRecipeByURL(url: CharSequence) { logger.v { "saveRecipeByURL() called with: url = $url" } + _saveResult.postValue(OperationUiState.Progress()) viewModelScope.launch { - runCatchingExceptCancel { - shareRecipeRepo.saveRecipeByURL(url) - }.onSuccess { - logger.d { "Successfully saved recipe by URL" } - _saveOperationResult.postValue(Result.success(it)) - }.onFailure { - logger.e(it) { "Can't save recipe by URL" } - _saveOperationResult.postValue(Result.failure(it)) - } + val result = runCatchingExceptCancel { shareRecipeRepo.saveRecipeByURL(url) } + .onSuccess { logger.d { "Successfully saved recipe by URL" } } + .onFailure { logger.e(it) { "Can't save recipe by URL" } } + _saveResult.postValue(OperationUiState.fromResult(result)) } } } \ No newline at end of file diff --git a/app/src/main/res/drawable-v31/ic_splash_screen.xml b/app/src/main/res/drawable/ic_progress_bar.xml similarity index 100% rename from app/src/main/res/drawable-v31/ic_splash_screen.xml rename to app/src/main/res/drawable/ic_progress_bar.xml diff --git a/app/src/main/res/layout/activity_share_recipe.xml b/app/src/main/res/layout/activity_share_recipe.xml new file mode 100644 index 0000000..7919d60 --- /dev/null +++ b/app/src/main/res/layout/activity_share_recipe.xml @@ -0,0 +1,19 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 9ea13b7..3d341a7 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -53,4 +53,5 @@ Нет рецептов Рецепт успешно сохранен. Что-то пошло не так. + Индикатор прогресса \ No newline at end of file diff --git a/app/src/main/res/values-v31/drawable.xml b/app/src/main/res/values-v31/drawable.xml new file mode 100644 index 0000000..3983709 --- /dev/null +++ b/app/src/main/res/values-v31/drawable.xml @@ -0,0 +1,4 @@ + + + @drawable/ic_progress_bar + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index abdee58..c0e1fcf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -56,4 +56,5 @@ No recipes Recipe saved successfully. Something went wrong. + Progress indicator \ No newline at end of file From e1b29f38067d9e4de83b11a12ddd44b6263b3e33 Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Tue, 29 Nov 2022 20:42:56 +0100 Subject: [PATCH 6/6] Add ShareRecipeViewModel tests --- .../kirmanak/mealient/ui/OperationUiState.kt | 24 ++++++- .../ui/share/ShareRecipeViewModelTest.kt | 70 +++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/gq/kirmanak/mealient/ui/share/ShareRecipeViewModelTest.kt diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/OperationUiState.kt b/app/src/main/java/gq/kirmanak/mealient/ui/OperationUiState.kt index 042f699..5decff9 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/OperationUiState.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/OperationUiState.kt @@ -27,9 +27,29 @@ sealed class OperationUiState { progressBar.isVisible = isProgress } - class Initial : OperationUiState() + class Initial : OperationUiState() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + return true + } - class Progress : OperationUiState() + override fun hashCode(): Int { + return javaClass.hashCode() + } + } + + class Progress : OperationUiState() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + return true + } + + override fun hashCode(): Int { + return javaClass.hashCode() + } + } data class Failure(val exception: Throwable) : OperationUiState() diff --git a/app/src/test/java/gq/kirmanak/mealient/ui/share/ShareRecipeViewModelTest.kt b/app/src/test/java/gq/kirmanak/mealient/ui/share/ShareRecipeViewModelTest.kt new file mode 100644 index 0000000..4046062 --- /dev/null +++ b/app/src/test/java/gq/kirmanak/mealient/ui/share/ShareRecipeViewModelTest.kt @@ -0,0 +1,70 @@ +package gq.kirmanak.mealient.ui.share + +import androidx.lifecycle.asFlow +import com.google.common.truth.Truth.assertThat +import gq.kirmanak.mealient.data.share.ShareRecipeRepo +import gq.kirmanak.mealient.test.BaseUnitTest +import gq.kirmanak.mealient.ui.OperationUiState +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.async +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.Timeout + +@OptIn(ExperimentalCoroutinesApi::class) +class ShareRecipeViewModelTest : BaseUnitTest() { + + @MockK(relaxUnitFun = true) + lateinit var shareRecipeRepo: ShareRecipeRepo + + lateinit var subject: ShareRecipeViewModel + + @get:Rule + val timeoutRule: Timeout = Timeout.seconds(5) + + @Before + override fun setUp() { + super.setUp() + subject = ShareRecipeViewModel( + shareRecipeRepo = shareRecipeRepo, + logger = logger, + ) + } + + @Test + fun `when repo throws expect saveRecipeByURL to update saveResult`() { + coEvery { shareRecipeRepo.saveRecipeByURL(any()) } throws RuntimeException() + subject.saveRecipeByURL("") + assertThat(subject.saveResult.value).isInstanceOf(OperationUiState.Failure::class.java) + } + + @Test + fun `when repo returns result expect saveResult to show progress before result`() = runTest { + val deferredActual = async(Dispatchers.Default) { + subject.saveResult.asFlow().take(3).toList(mutableListOf()) + } + coEvery { shareRecipeRepo.saveRecipeByURL(any()) } returns "result" + subject.saveRecipeByURL("") + val actual = deferredActual.await() + assertThat(actual).containsExactly( + OperationUiState.Initial(), + OperationUiState.Progress(), + OperationUiState.Success("result"), + ).inOrder() + } + + @Test + fun `when url is given expect saveRecipeByURL to pass it to repo`() = runTest { + coEvery { shareRecipeRepo.saveRecipeByURL(any()) } returns "result" + subject.saveRecipeByURL("https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie/") + coVerify { shareRecipeRepo.saveRecipeByURL(eq("https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie/")) } + } +} \ No newline at end of file