Merge pull request #108 from kirmanak/share-recipe-url

Parse recipe from shared URL
This commit is contained in:
Kirill Kamakin
2022-11-29 20:57:37 +01:00
committed by GitHub
30 changed files with 588 additions and 30 deletions

View File

@@ -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)

View File

@@ -19,14 +19,27 @@
tools:ignore="UnusedAttribute">
<activity
android:name=".ui.activity.MainActivity"
android:windowSoftInputMode="adjustPan"
android:exported="true">
android:exported="true"
android:windowSoftInputMode="adjustPan">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ui.share.ShareRecipeActivity"
android:exported="true"
android:theme="@style/AppTheme">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<meta-data
android:name="android.service.chooser.chooser_target_service"
android:value="androidx.sharetarget.ChooserTargetServiceCompat" />
</activity>
</application>
</manifest>

View File

@@ -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 <T> makeCall(block: (String?, String, ServerVersion) -> T): T {
val authHeader = authRepo.getAuthHeader()
val url = serverInfoRepo.requireUrl()

View File

@@ -0,0 +1,6 @@
package gq.kirmanak.mealient.data.share
interface ParseRecipeDataSource {
suspend fun parseRecipeFromURL(parseRecipeURLInfo: ParseRecipeURLInfo): String
}

View File

@@ -0,0 +1,6 @@
package gq.kirmanak.mealient.data.share
data class ParseRecipeURLInfo(
val url: String,
val includeTags: Boolean
)

View File

@@ -0,0 +1,6 @@
package gq.kirmanak.mealient.data.share
interface ShareRecipeRepo {
suspend fun saveRecipeByURL(url: CharSequence): String
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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(),
)
)
fun ParseRecipeURLInfo.toV1Request() = ParseRecipeURLRequestV1(
url = url,
includeTags = includeTags,
)
fun ParseRecipeURLInfo.toV0Request() = ParseRecipeURLRequestV0(
url = url,
)

View File

@@ -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<T : ViewBinding>(
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
}
}
}

View File

@@ -15,6 +15,9 @@ sealed class OperationUiState<T> {
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<T> {
progressBar.isVisible = isProgress
}
class Initial<T> : OperationUiState<T>()
class Initial<T> : OperationUiState<T>() {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
return true
}
class Progress<T> : OperationUiState<T>()
override fun hashCode(): Int {
return javaClass.hashCode()
}
}
class Progress<T> : OperationUiState<T>() {
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<T>(val exception: Throwable) : OperationUiState<T>()

View File

@@ -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<MainActivityBinding>(
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<MainActivityViewModel>()
private val navController: NavController
get() = binding.navHost.getFragment<NavHostFragment>().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" }

View File

@@ -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<ActivityShareRecipeBinding>(
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<String>) {
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
}

View File

@@ -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<String>>(OperationUiState.Initial())
val saveResult: LiveData<OperationUiState<String>> 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))
}
}
}

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/content_description_activity_share_recipe_progress"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_progress_bar" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -51,4 +51,7 @@
<string name="search_recipes_hint">Найти рецепты</string>
<string name="view_toolbar_navigation_icon_content_description">Открыть меню навигации</string>
<string name="fragment_recipes_list_no_recipes">Нет рецептов</string>
<string name="activity_share_recipe_success_toast">Рецепт успешно сохранен.</string>
<string name="activity_share_recipe_failure_toast">Что-то пошло не так.</string>
<string name="content_description_activity_share_recipe_progress">Индикатор прогресса</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<drawable name="ic_splash_screen">@drawable/ic_progress_bar</drawable>
</resources>

View File

@@ -54,4 +54,7 @@
<string name="menu_navigation_drawer_header" translatable="false">@string/app_name</string>
<string name="view_toolbar_navigation_icon_content_description">Open navigation drawer</string>
<string name="fragment_recipes_list_no_recipes">No recipes</string>
<string name="activity_share_recipe_success_toast">Recipe saved successfully.</string>
<string name="activity_share_recipe_failure_toast">Something went wrong.</string>
<string name="content_description_activity_share_recipe_progress">Progress indicator</string>
</resources>

View File

@@ -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)) }
}
}

View File

@@ -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<String>(),
OperationUiState.Progress<String>(),
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/")) }
}
}

View File

@@ -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
}

View File

@@ -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" }
)
}

View File

@@ -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
}

View File

@@ -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,
)

View File

@@ -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
}

View File

@@ -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" }
)
}

View File

@@ -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
}

View File

@@ -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
)

View File

@@ -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" }