Merge pull request #108 from kirmanak/share-recipe-url
Parse recipe from shared URL
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package gq.kirmanak.mealient.data.share
|
||||
|
||||
interface ParseRecipeDataSource {
|
||||
|
||||
suspend fun parseRecipeFromURL(parseRecipeURLInfo: ParseRecipeURLInfo): String
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package gq.kirmanak.mealient.data.share
|
||||
|
||||
data class ParseRecipeURLInfo(
|
||||
val url: String,
|
||||
val includeTags: Boolean
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package gq.kirmanak.mealient.data.share
|
||||
|
||||
interface ShareRecipeRepo {
|
||||
|
||||
suspend fun saveRecipeByURL(url: CharSequence): String
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
36
app/src/main/java/gq/kirmanak/mealient/ui/BaseActivity.kt
Normal file
36
app/src/main/java/gq/kirmanak/mealient/ui/BaseActivity.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>()
|
||||
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
19
app/src/main/res/layout/activity_share_recipe.xml
Normal file
19
app/src/main/res/layout/activity_share_recipe.xml
Normal 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>
|
||||
@@ -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>
|
||||
4
app/src/main/res/values-v31/drawable.xml
Normal file
4
app/src/main/res/values-v31/drawable.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<drawable name="ic_splash_screen">@drawable/ic_progress_bar</drawable>
|
||||
</resources>
|
||||
@@ -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>
|
||||
@@ -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)) }
|
||||
}
|
||||
}
|
||||
@@ -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/")) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user