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/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..7d711ae
--- /dev/null
+++ b/app/src/main/java/gq/kirmanak/mealient/data/share/ShareRecipeRepoImpl.kt
@@ -0,0 +1,22 @@
+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
+
+@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 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)
+ }
+}
\ 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/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..5decff9 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
@@ -24,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/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
new file mode 100644
index 0000000..6b30c8d
--- /dev/null
+++ b/app/src/main/java/gq/kirmanak/mealient/ui/share/ShareRecipeActivity.kt
@@ -0,0 +1,93 @@
+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.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.ui.BaseActivity
+import gq.kirmanak.mealient.ui.OperationUiState
+
+@AndroidEntryPoint
+class ShareRecipeActivity : BaseActivity(
+ binder = ActivityShareRecipeBinding::bind,
+ containerId = R.id.root,
+ layoutRes = R.layout.activity_share_recipe,
+) {
+
+ private val viewModel: ShareRecipeViewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(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
+ }
+
+ 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 (state.isSuccess) R.string.activity_share_recipe_success_toast
+ else R.string.activity_share_recipe_failure_toast
+ )
+ finish()
+ }
+ }
+
+ 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
new file mode 100644
index 0000000..8172e88
--- /dev/null
+++ b/app/src/main/java/gq/kirmanak/mealient/ui/share/ShareRecipeViewModel.kt
@@ -0,0 +1,34 @@
+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 gq.kirmanak.mealient.ui.OperationUiState
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class ShareRecipeViewModel @Inject constructor(
+ private val shareRecipeRepo: ShareRecipeRepo,
+ private val logger: Logger,
+) : ViewModel() {
+
+ 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 {
+ 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 eb4ec6a..3d341a7 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -51,4 +51,7 @@
Найти рецепты
Открыть меню навигации
Нет рецептов
+ Рецепт успешно сохранен.
+ Что-то пошло не так.
+ Индикатор прогресса
\ 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 f14a3ab..c0e1fcf 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -54,4 +54,7 @@
@string/app_name
Open navigation drawer
No recipes
+ Recipe saved successfully.
+ Something went wrong.
+ Progress indicator
\ 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..1f03463
--- /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
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
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
+)
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" }