Add better UX while loading sustainer data and when a load failure happens.

This commit is contained in:
Alex Hart
2021-11-10 11:37:10 -04:00
committed by GitHub
parent 1893896254
commit 320bf45518
14 changed files with 350 additions and 12 deletions

View File

@@ -1,5 +1,8 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
import android.animation.Animator
import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.text.Editable
import android.text.Spanned
import android.text.TextWatcher
@@ -7,7 +10,10 @@ import android.text.method.DigitsKeyListener
import android.view.View
import androidx.annotation.VisibleForTesting
import androidx.appcompat.widget.AppCompatEditText
import androidx.core.animation.doOnEnd
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import com.google.android.material.button.MaterialButton
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.R
@@ -44,6 +50,45 @@ data class Boost(
}
}
class LoadingModel : PreferenceModel<LoadingModel>() {
override fun areItemsTheSame(newItem: LoadingModel): Boolean = true
}
class LoadingViewHolder(itemView: View) : MappingViewHolder<LoadingModel>(itemView), DefaultLifecycleObserver {
private val animator: Animator = AnimatorSet().apply {
val fadeTo25Animator = ObjectAnimator.ofFloat(itemView, "alpha", 0.8f, 0.25f).apply {
duration = 1000L
}
val fadeTo80Animator = ObjectAnimator.ofFloat(itemView, "alpha", 0.25f, 0.8f).apply {
duration = 300L
}
playSequentially(fadeTo25Animator, fadeTo80Animator)
doOnEnd { start() }
}
init {
lifecycle.addObserver(this)
}
override fun bind(model: LoadingModel) {
}
override fun onResume(owner: LifecycleOwner) {
if (animator.isStarted) {
animator.resume()
} else {
animator.start()
}
}
override fun onDestroy(owner: LifecycleOwner) {
animator.pause()
}
}
/**
* A widget that allows a user to select from six different amounts, or enter a custom amount.
*/
@@ -184,6 +229,7 @@ data class Boost(
fun register(adapter: MappingAdapter) {
adapter.registerFactory(SelectionModel::class.java, MappingAdapter.LayoutFactory({ SelectionViewHolder(it) }, R.layout.boost_preference))
adapter.registerFactory(HeadingModel::class.java, MappingAdapter.LayoutFactory({ HeadingViewHolder(it) }, R.layout.boost_preview_preference))
adapter.registerFactory(LoadingModel::class.java, MappingAdapter.LayoutFactory({ LoadingViewHolder(it) }, R.layout.boost_loading_preference))
}
}
}

View File

@@ -27,6 +27,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.DonationE
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.Progress
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
@@ -82,6 +83,7 @@ class BoostFragment : DSLSettingsBottomSheetFragment(
Boost.register(adapter)
GooglePayButton.register(adapter)
Progress.register(adapter)
NetworkFailure.register(adapter)
processingDonationPaymentDialog = MaterialAlertDialogBuilder(requireContext())
.setView(R.layout.processing_payment_dialog)
@@ -163,11 +165,17 @@ class BoostFragment : DSLSettingsBottomSheetFragment(
)
)
@Suppress("CascadeIf")
if (state.stage == BoostState.Stage.INIT) {
customPref(
Progress.Model(
title = DSLSettingsText.from(R.string.load_more_header__loading)
)
Boost.LoadingModel()
)
} else if (state.stage == BoostState.Stage.FAILURE) {
space(DimensionUnit.DP.toPixels(20f).toInt())
customPref(
NetworkFailure.Model {
viewModel.retry()
}
)
} else {
customPref(

View File

@@ -45,9 +45,8 @@ class BoostViewModel(
.internetConnectionObserver()
.distinctUntilChanged()
.subscribe { isConnected ->
if (!disposables.isDisposed && isConnected && store.state.stage == BoostState.Stage.FAILURE) {
store.update { it.copy(stage = BoostState.Stage.INIT) }
refresh()
if (isConnected) {
retry()
}
}
}
@@ -61,6 +60,13 @@ class BoostViewModel(
return store.state.supportedCurrencyCodes
}
fun retry() {
if (!disposables.isDisposed && store.state.stage == BoostState.Stage.FAILURE) {
store.update { it.copy(stage = BoostState.Stage.INIT) }
refresh()
}
}
fun refresh() {
disposables.clear()

View File

@@ -0,0 +1,34 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.models
import android.view.View
import com.google.android.material.button.MaterialButton
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
/**
* NetworkFailure will display a "card" to the user informing them that there
* was a failure and give them a button which allows them to retry fetching data.
*/
object NetworkFailure {
class Model(
val onRetryClick: () -> Unit
) : PreferenceModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean = true
}
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val retryButton = itemView.findViewById<MaterialButton>(R.id.retry_button)
override fun bind(model: Model) {
retryButton.setOnClickListener { model.onRetryClick() }
}
}
fun register(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.network_failure_pref))
}
}

View File

@@ -25,6 +25,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.DonationP
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.Progress
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
@@ -80,6 +81,7 @@ class SubscribeFragment : DSLSettingsFragment(
Subscription.register(adapter)
GooglePayButton.register(adapter)
Progress.register(adapter)
NetworkFailure.register(adapter)
processingDonationPaymentDialog = MaterialAlertDialogBuilder(requireContext())
.setView(R.layout.processing_payment_dialog)
@@ -147,12 +149,19 @@ class SubscribeFragment : DSLSettingsFragment(
space(DimensionUnit.DP.toPixels(4f).toInt())
@Suppress("CascadeIf")
if (state.stage == SubscribeState.Stage.INIT) {
customPref(
Progress.Model(
title = DSLSettingsText.from(R.string.load_more_header__loading)
)
Subscription.LoaderModel()
)
} else if (state.stage == SubscribeState.Stage.FAILURE) {
space(DimensionUnit.DP.toPixels(69f).toInt())
customPref(
NetworkFailure.Model {
viewModel.refresh()
}
)
space(DimensionUnit.DP.toPixels(75f).toInt())
} else {
state.subscriptions.forEach {
val isActive = state.activeSubscription?.activeSubscription?.level == it.level

View File

@@ -52,9 +52,8 @@ class SubscribeViewModel(
.internetConnectionObserver()
.distinctUntilChanged()
.subscribe { isConnected ->
if (!disposables.isDisposed && isConnected && store.state.stage == SubscribeState.Stage.FAILURE) {
store.update { it.copy(stage = SubscribeState.Stage.INIT) }
refresh()
if (isConnected) {
retry()
}
}
}
@@ -72,6 +71,13 @@ class SubscribeViewModel(
return store.state.subscriptions.firstOrNull()?.prices?.map { it.currency.currencyCode }
}
fun retry() {
if (!disposables.isDisposed && store.state.stage == SubscribeState.Stage.FAILURE) {
store.update { it.copy(stage = SubscribeState.Stage.INIT) }
refresh()
}
}
fun refresh() {
disposables.clear()

View File

@@ -1,8 +1,14 @@
package org.thoughtcrime.securesms.subscription
import android.animation.Animator
import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.core.animation.doOnEnd
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
@@ -30,6 +36,46 @@ data class Subscription(
companion object {
fun register(adapter: MappingAdapter) {
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.subscription_preference))
adapter.registerFactory(LoaderModel::class.java, MappingAdapter.LayoutFactory({ LoaderViewHolder(it) }, R.layout.subscription_preference_loader))
}
}
class LoaderModel : PreferenceModel<LoaderModel>() {
override fun areItemsTheSame(newItem: LoaderModel): Boolean = true
}
class LoaderViewHolder(itemView: View) : MappingViewHolder<LoaderModel>(itemView), DefaultLifecycleObserver {
private val animator: Animator = AnimatorSet().apply {
val fadeTo25Animator = ObjectAnimator.ofFloat(itemView, "alpha", 0.8f, 0.25f).apply {
duration = 1000L
}
val fadeTo80Animator = ObjectAnimator.ofFloat(itemView, "alpha", 0.25f, 0.8f).apply {
duration = 300L
}
playSequentially(fadeTo25Animator, fadeTo80Animator)
doOnEnd { start() }
}
init {
lifecycle.addObserver(this)
}
override fun bind(model: LoaderModel) {
}
override fun onResume(owner: LifecycleOwner) {
if (animator.isStarted) {
animator.resume()
} else {
animator.start()
}
}
override fun onDestroy(owner: LifecycleOwner) {
animator.pause()
}
}