Show progress when parsing recipe

This commit is contained in:
Kirill Kamakin
2022-11-29 19:42:07 +01:00
parent 0c41aac9b7
commit 4a68916433
10 changed files with 135 additions and 41 deletions

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 val isProgress: Boolean
get() = this is Progress get() = this is Progress
val isFailure: Boolean
get() = this is Failure
fun updateButtonState(button: Button) { fun updateButtonState(button: Button) {
button.isEnabled = !isProgress button.isEnabled = !isProgress
button.isClickable = !isProgress button.isClickable = !isProgress

View File

@@ -3,16 +3,13 @@ package gq.kirmanak.mealient.ui.activity
import android.os.Bundle import android.os.Bundle
import android.view.MenuItem import android.view.MenuItem
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.iterator import androidx.core.view.iterator
import androidx.drawerlayout.widget.DrawerLayout import androidx.drawerlayout.widget.DrawerLayout
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavDirections import androidx.navigation.NavDirections
import androidx.navigation.fragment.NavHostFragment import androidx.navigation.fragment.NavHostFragment
import by.kirich1409.viewbindingdelegate.viewBinding
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.NavGraphDirections.Companion.actionGlobalAddRecipeFragment import gq.kirmanak.mealient.NavGraphDirections.Companion.actionGlobalAddRecipeFragment
import gq.kirmanak.mealient.NavGraphDirections.Companion.actionGlobalAuthenticationFragment 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.R
import gq.kirmanak.mealient.databinding.MainActivityBinding import gq.kirmanak.mealient.databinding.MainActivityBinding
import gq.kirmanak.mealient.extensions.collectWhenResumed import gq.kirmanak.mealient.extensions.collectWhenResumed
import gq.kirmanak.mealient.extensions.isDarkThemeOn
import gq.kirmanak.mealient.extensions.observeOnce import gq.kirmanak.mealient.extensions.observeOnce
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.ui.BaseActivity
import javax.inject.Inject
@AndroidEntryPoint @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 viewModel by viewModels<MainActivityViewModel>()
private val navController: NavController private val navController: NavController
get() = binding.navHost.getFragment<NavHostFragment>().navController get() = binding.navHost.getFragment<NavHostFragment>().navController
@Inject
lateinit var logger: Logger
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen() val splashScreen = installSplashScreen()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
logger.v { "onCreate() called with: savedInstanceState = $savedInstanceState" }
splashScreen.setKeepOnScreenCondition { viewModel.startDestination.value == null } splashScreen.setKeepOnScreenCondition { viewModel.startDestination.value == null }
setContentView(binding.root)
setupUi() setupUi()
configureNavGraph() configureNavGraph()
} }
@@ -67,11 +60,6 @@ class MainActivity : AppCompatActivity(R.layout.main_activity) {
viewModel.onSearchQuery(query.trim().takeUnless { it.isEmpty() }) viewModel.onSearchQuery(query.trim().takeUnless { it.isEmpty() })
} }
binding.navigationView.setNavigationItemSelectedListener(::onNavigationItemSelected) binding.navigationView.setNavigationItemSelectedListener(::onNavigationItemSelected)
with(WindowInsetsControllerCompat(window, window.decorView)) {
val isAppearanceLightBars = !isDarkThemeOn()
isAppearanceLightNavigationBars = isAppearanceLightBars
isAppearanceLightStatusBars = isAppearanceLightBars
}
viewModel.uiStateLive.observe(this, ::onUiStateChange) viewModel.uiStateLive.observe(this, ::onUiStateChange)
collectWhenResumed(viewModel.clearSearchViewFocus) { collectWhenResumed(viewModel.clearSearchViewFocus) {
logger.d { "clearSearchViewFocus(): received event" } logger.d { "clearSearchViewFocus(): received event" }

View File

@@ -1,26 +1,31 @@
package gq.kirmanak.mealient.ui.share package gq.kirmanak.mealient.ui.share
import android.content.Intent import android.content.Intent
import android.graphics.drawable.Animatable2
import android.graphics.drawable.AnimatedVectorDrawable
import android.graphics.drawable.Drawable
import android.os.Bundle import android.os.Bundle
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.core.view.isInvisible
import androidx.core.view.postDelayed
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.databinding.ActivityShareRecipeBinding
import gq.kirmanak.mealient.extensions.showLongToast import gq.kirmanak.mealient.extensions.showLongToast
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.ui.BaseActivity
import javax.inject.Inject import gq.kirmanak.mealient.ui.OperationUiState
@AndroidEntryPoint @AndroidEntryPoint
class ShareRecipeActivity : AppCompatActivity() { class ShareRecipeActivity : BaseActivity<ActivityShareRecipeBinding>(
binder = ActivityShareRecipeBinding::bind,
containerId = R.id.root,
layoutRes = R.layout.activity_share_recipe,
) {
private val viewModel: ShareRecipeViewModel by viewModels() private val viewModel: ShareRecipeViewModel by viewModels()
@Inject
lateinit var logger: Logger
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
logger.v { "onCreate() called with: savedInstanceState = $savedInstanceState" }
if (intent.action != Intent.ACTION_SEND || intent.type != "text/plain") { if (intent.action != Intent.ACTION_SEND || intent.type != "text/plain") {
logger.w { "onCreate: intent.action = ${intent.action}, intent.type = ${intent.type}" } logger.w { "onCreate: intent.action = ${intent.action}, intent.type = ${intent.type}" }
@@ -35,14 +40,54 @@ class ShareRecipeActivity : AppCompatActivity() {
return return
} }
viewModel.saveOperationResult.observe(this) { 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( showLongToast(
if (it.isSuccess) R.string.activity_share_recipe_success_toast if (state.isSuccess) R.string.activity_share_recipe_success_toast
else R.string.activity_share_recipe_failure_toast else R.string.activity_share_recipe_failure_toast
) )
finish() finish()
} }
}
viewModel.saveRecipeByURL(url) private fun restartAnimationOnEnd() {
withAnimatedDrawable {
onAnimationEnd {
if (viewModel.saveResult.value?.isProgress == true) {
binding.progress.postDelayed(250) { start() }
}
}
}
}
private inline fun withAnimatedDrawable(block: AnimatedVectorDrawable.() -> Unit) {
binding.progress.drawable.let { drawable ->
if (drawable is AnimatedVectorDrawable) {
drawable.block()
} else {
logger.w { "withAnimatedDrawable: progress's drawable is not AnimatedVectorDrawable" }
}
}
} }
} }
private inline fun AnimatedVectorDrawable.onAnimationEnd(
crossinline block: AnimatedVectorDrawable.() -> Unit,
): Animatable2.AnimationCallback {
val callback = object : Animatable2.AnimationCallback() {
override fun onAnimationEnd(drawable: Drawable?) {
block()
}
}
registerAnimationCallback(callback)
return callback
}

View File

@@ -8,6 +8,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.share.ShareRecipeRepo import gq.kirmanak.mealient.data.share.ShareRecipeRepo
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.OperationUiState
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -17,21 +18,17 @@ class ShareRecipeViewModel @Inject constructor(
private val logger: Logger, private val logger: Logger,
) : ViewModel() { ) : ViewModel() {
private val _saveOperationResult = MutableLiveData<Result<String>>() private val _saveResult = MutableLiveData<OperationUiState<String>>(OperationUiState.Initial())
val saveOperationResult: LiveData<Result<String>> get() = _saveOperationResult val saveResult: LiveData<OperationUiState<String>> get() = _saveResult
fun saveRecipeByURL(url: CharSequence) { fun saveRecipeByURL(url: CharSequence) {
logger.v { "saveRecipeByURL() called with: url = $url" } logger.v { "saveRecipeByURL() called with: url = $url" }
_saveResult.postValue(OperationUiState.Progress())
viewModelScope.launch { viewModelScope.launch {
runCatchingExceptCancel { val result = runCatchingExceptCancel { shareRecipeRepo.saveRecipeByURL(url) }
shareRecipeRepo.saveRecipeByURL(url) .onSuccess { logger.d { "Successfully saved recipe by URL" } }
}.onSuccess { .onFailure { logger.e(it) { "Can't save recipe by URL" } }
logger.d { "Successfully saved recipe by URL" } _saveResult.postValue(OperationUiState.fromResult(result))
_saveOperationResult.postValue(Result.success(it))
}.onFailure {
logger.e(it) { "Can't save recipe by URL" }
_saveOperationResult.postValue(Result.failure(it))
}
} }
} }
} }

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

@@ -53,4 +53,5 @@
<string name="fragment_recipes_list_no_recipes">Нет рецептов</string> <string name="fragment_recipes_list_no_recipes">Нет рецептов</string>
<string name="activity_share_recipe_success_toast">Рецепт успешно сохранен.</string> <string name="activity_share_recipe_success_toast">Рецепт успешно сохранен.</string>
<string name="activity_share_recipe_failure_toast">Что-то пошло не так.</string> <string name="activity_share_recipe_failure_toast">Что-то пошло не так.</string>
<string name="content_description_activity_share_recipe_progress">Индикатор прогресса</string>
</resources> </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

@@ -56,4 +56,5 @@
<string name="fragment_recipes_list_no_recipes">No recipes</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_success_toast">Recipe saved successfully.</string>
<string name="activity_share_recipe_failure_toast">Something went wrong.</string> <string name="activity_share_recipe_failure_toast">Something went wrong.</string>
<string name="content_description_activity_share_recipe_progress">Progress indicator</string>
</resources> </resources>