mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-21 02:08:40 +00:00
Implement the majority of the Donor UI.
This commit is contained in:
@@ -44,7 +44,6 @@ class BadgeImageView @JvmOverloads constructor(
|
||||
|
||||
val lifecycle = ViewUtil.getActivityLifecycle(this)
|
||||
if (lifecycle?.currentState == Lifecycle.State.DESTROYED) {
|
||||
Log.w(TAG, "Ignoring setBadge call for destroyed activity.")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,49 +1,16 @@
|
||||
package org.thoughtcrime.securesms.badges
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.Px
|
||||
import androidx.core.graphics.withScale
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.airbnb.lottie.SimpleColorFilter
|
||||
import com.google.android.flexbox.AlignItems
|
||||
import com.google.android.flexbox.FlexDirection
|
||||
import com.google.android.flexbox.FlexboxLayoutManager
|
||||
import com.google.android.flexbox.JustifyContent
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.badges.models.BadgeAnimator
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.util.customizeOnDraw
|
||||
|
||||
object Badges {
|
||||
fun Drawable.selectable(
|
||||
@Px outlineWidth: Float,
|
||||
@ColorInt outlineColor: Int,
|
||||
animator: BadgeAnimator
|
||||
): Drawable {
|
||||
val outline = mutate().constantState?.newDrawable()?.mutate()
|
||||
outline?.colorFilter = SimpleColorFilter(outlineColor)
|
||||
|
||||
return customizeOnDraw { wrapped, canvas ->
|
||||
outline?.bounds = wrapped.bounds
|
||||
|
||||
outline?.draw(canvas)
|
||||
|
||||
val scale = 1 - ((outlineWidth * 2) / wrapped.bounds.width())
|
||||
val interpolatedScale = scale + (1f - scale) * animator.getFraction()
|
||||
|
||||
canvas.withScale(x = interpolatedScale, y = interpolatedScale, wrapped.bounds.width() / 2f, wrapped.bounds.height() / 2f) {
|
||||
wrapped.draw(canvas)
|
||||
}
|
||||
|
||||
if (animator.shouldInvalidate()) {
|
||||
invalidateSelf()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun DSLConfiguration.displayBadges(context: Context, badges: List<Badge>, selectedBadge: Badge? = null) {
|
||||
badges
|
||||
.map { Badge.Model(it, it == selectedBadge) }
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
package org.thoughtcrime.securesms.badges.models
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.animation.ObjectAnimator
|
||||
import android.net.Uri
|
||||
import android.os.Parcelable
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.bumptech.glide.load.Key
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||
import com.bumptech.glide.request.target.CustomViewTarget
|
||||
import com.bumptech.glide.request.transition.Transition
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.Badges.selectable
|
||||
import org.thoughtcrime.securesms.badges.glide.BadgeSpriteTransformation
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.mms.GlideApp
|
||||
@@ -41,6 +36,8 @@ data class Badge(
|
||||
val visible: Boolean,
|
||||
) : Parcelable, Key {
|
||||
|
||||
fun isExpired(): Boolean = expirationTimestamp < System.currentTimeMillis()
|
||||
|
||||
override fun updateDiskCacheKey(messageDigest: MessageDigest) {
|
||||
messageDigest.update(id.toByteArray(Key.CHARSET))
|
||||
messageDigest.update(imageUrl.toString().toByteArray(Key.CHARSET))
|
||||
@@ -94,35 +91,47 @@ data class Badge(
|
||||
|
||||
class ViewHolder(itemView: View, private val onBadgeClicked: OnBadgeClicked) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val check: ImageView = itemView.findViewById(R.id.checkmark)
|
||||
private val badge: ImageView = itemView.findViewById(R.id.badge)
|
||||
private val name: TextView = itemView.findViewById(R.id.name)
|
||||
private val target = Target(badge)
|
||||
|
||||
private var checkAnimator: ObjectAnimator? = null
|
||||
|
||||
init {
|
||||
check.isSelected = true
|
||||
}
|
||||
|
||||
override fun bind(model: Model) {
|
||||
itemView.setOnClickListener {
|
||||
onBadgeClicked(model.badge, model.isSelected)
|
||||
}
|
||||
|
||||
checkAnimator?.cancel()
|
||||
if (payload.isNotEmpty()) {
|
||||
if (model.isSelected) {
|
||||
target.animateToStart()
|
||||
checkAnimator = if (model.isSelected) {
|
||||
ObjectAnimator.ofFloat(check, "alpha", 1f)
|
||||
} else {
|
||||
target.animateToEnd()
|
||||
ObjectAnimator.ofFloat(check, "alpha", 0f)
|
||||
}
|
||||
checkAnimator?.start()
|
||||
return
|
||||
}
|
||||
|
||||
badge.alpha = if (model.badge.isExpired()) 0.5f else 1f
|
||||
|
||||
GlideApp.with(badge)
|
||||
.load(model.badge)
|
||||
.downsample(DownsampleStrategy.NONE)
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.transform(BadgeSpriteTransformation(BadgeSpriteTransformation.Size.XLARGE, model.badge.imageDensity, ThemeUtil.isDarkTheme(context)))
|
||||
.into(target)
|
||||
.transform(
|
||||
BadgeSpriteTransformation(BadgeSpriteTransformation.Size.XLARGE, model.badge.imageDensity, ThemeUtil.isDarkTheme(context)),
|
||||
)
|
||||
.into(badge)
|
||||
|
||||
if (model.isSelected) {
|
||||
target.setAnimationToStart()
|
||||
check.alpha = 1f
|
||||
} else {
|
||||
target.setAnimationToEnd()
|
||||
check.alpha = 0f
|
||||
}
|
||||
|
||||
name.text = model.badge.name
|
||||
@@ -145,49 +154,6 @@ data class Badge(
|
||||
}
|
||||
}
|
||||
|
||||
private class Target(view: ImageView) : CustomViewTarget<ImageView, Drawable>(view) {
|
||||
|
||||
private val animator: BadgeAnimator = BadgeAnimator()
|
||||
|
||||
override fun onLoadFailed(errorDrawable: Drawable?) {
|
||||
view.setImageDrawable(errorDrawable)
|
||||
}
|
||||
|
||||
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
|
||||
val drawable = resource.selectable(
|
||||
DimensionUnit.DP.toPixels(2.5f),
|
||||
ContextCompat.getColor(view.context, R.color.signal_inverse_primary),
|
||||
animator
|
||||
)
|
||||
|
||||
view.setImageDrawable(drawable)
|
||||
}
|
||||
|
||||
override fun onResourceCleared(placeholder: Drawable?) {
|
||||
view.setImageDrawable(placeholder)
|
||||
}
|
||||
|
||||
fun setAnimationToStart() {
|
||||
animator.setState(BadgeAnimator.State.START)
|
||||
view.drawable?.invalidateSelf()
|
||||
}
|
||||
|
||||
fun setAnimationToEnd() {
|
||||
animator.setState(BadgeAnimator.State.END)
|
||||
view.drawable?.invalidateSelf()
|
||||
}
|
||||
|
||||
fun animateToStart() {
|
||||
animator.setState(BadgeAnimator.State.REVERSE)
|
||||
view.drawable?.invalidateSelf()
|
||||
}
|
||||
|
||||
fun animateToEnd() {
|
||||
animator.setState(BadgeAnimator.State.FORWARD)
|
||||
view.drawable?.invalidateSelf()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val SELECTION_CHANGED = Any()
|
||||
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
package org.thoughtcrime.securesms.badges.models
|
||||
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
|
||||
class BadgeAnimator {
|
||||
|
||||
val duration = 250L
|
||||
|
||||
var state: State = State.START
|
||||
private set
|
||||
|
||||
private var startTime: Long = 0L
|
||||
|
||||
fun getFraction(): Float {
|
||||
return when (state) {
|
||||
State.START -> 0f
|
||||
State.END -> 1f
|
||||
State.FORWARD -> Util.clamp((System.currentTimeMillis() - startTime) / duration.toFloat(), 0f, 1f)
|
||||
State.REVERSE -> 1f - Util.clamp((System.currentTimeMillis() - startTime) / duration.toFloat(), 0f, 1f)
|
||||
}
|
||||
}
|
||||
|
||||
fun setState(newState: State) {
|
||||
shouldInvalidate()
|
||||
|
||||
if (state == newState) {
|
||||
return
|
||||
}
|
||||
|
||||
if (newState == State.END || newState == State.START) {
|
||||
state = newState
|
||||
startTime = 0L
|
||||
return
|
||||
}
|
||||
|
||||
if (state == State.START && newState == State.REVERSE) {
|
||||
return
|
||||
}
|
||||
|
||||
if (state == State.END && newState == State.FORWARD) {
|
||||
return
|
||||
}
|
||||
|
||||
if (state == State.START && newState == State.FORWARD) {
|
||||
state = State.FORWARD
|
||||
startTime = System.currentTimeMillis()
|
||||
return
|
||||
}
|
||||
|
||||
if (state == State.END && newState == State.REVERSE) {
|
||||
state = State.REVERSE
|
||||
startTime = System.currentTimeMillis()
|
||||
return
|
||||
}
|
||||
|
||||
if (state == State.FORWARD && newState == State.REVERSE) {
|
||||
val elapsed = System.currentTimeMillis() - startTime
|
||||
val delta = duration - elapsed
|
||||
startTime -= delta
|
||||
state = State.REVERSE
|
||||
return
|
||||
}
|
||||
|
||||
if (state == State.REVERSE && newState == State.FORWARD) {
|
||||
val elapsed = System.currentTimeMillis() - startTime
|
||||
val delta = duration - elapsed
|
||||
startTime -= delta
|
||||
state = State.FORWARD
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
fun shouldInvalidate(): Boolean {
|
||||
if (state == State.START || state == State.END) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (state == State.FORWARD && getFraction() == 1f) {
|
||||
state = State.END
|
||||
return false
|
||||
}
|
||||
|
||||
if (state == State.REVERSE && getFraction() == 0f) {
|
||||
state = State.START
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
enum class State {
|
||||
START,
|
||||
FORWARD,
|
||||
REVERSE,
|
||||
END
|
||||
}
|
||||
}
|
||||
@@ -9,13 +9,18 @@ import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
object FeaturedBadgePreview {
|
||||
object BadgePreview {
|
||||
|
||||
fun register(mappingAdapter: MappingAdapter) {
|
||||
mappingAdapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.featured_badge_preview_preference))
|
||||
mappingAdapter.registerFactory(SubscriptionModel::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.subscription_flow_badge_preview_preference))
|
||||
}
|
||||
|
||||
data class Model(val badge: Badge?) : PreferenceModel<Model>() {
|
||||
abstract class BadgeModel<T : BadgeModel<T>> : PreferenceModel<T>() {
|
||||
abstract val badge: Badge?
|
||||
}
|
||||
|
||||
data class Model(override val badge: Badge?) : BadgeModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return newItem.badge?.id == badge?.id
|
||||
}
|
||||
@@ -25,12 +30,22 @@ object FeaturedBadgePreview {
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
data class SubscriptionModel(override val badge: Badge?) : BadgeModel<SubscriptionModel>() {
|
||||
override fun areItemsTheSame(newItem: SubscriptionModel): Boolean {
|
||||
return newItem.badge?.id == badge?.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: SubscriptionModel): Boolean {
|
||||
return super.areContentsTheSame(newItem) && badge == newItem.badge
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder<T : BadgeModel<T>>(itemView: View) : MappingViewHolder<T>(itemView) {
|
||||
|
||||
private val avatar: AvatarImageView = itemView.findViewById(R.id.avatar)
|
||||
private val badge: BadgeImageView = itemView.findViewById(R.id.badge)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
override fun bind(model: T) {
|
||||
avatar.setRecipient(Recipient.self())
|
||||
avatar.disableQuickContact()
|
||||
badge.setBadge(model.badge)
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.thoughtcrime.securesms.badges.models
|
||||
|
||||
import android.view.View
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
object ExpiredBadge {
|
||||
|
||||
class Model(val badge: Badge) : PreferenceModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return newItem.badge.id == badge.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return super.areContentsTheSame(newItem) && newItem.badge == badge
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val badge: BadgeImageView = itemView.findViewById(R.id.expired_badge)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
badge.setBadge(model.badge)
|
||||
}
|
||||
}
|
||||
|
||||
fun register(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.expired_badge_preference))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package org.thoughtcrime.securesms.badges.self.expired
|
||||
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.models.ExpiredBadge
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
|
||||
/**
|
||||
* Bottom sheet displaying a fading badge with a notice and action for becoming a subscriber again.
|
||||
*/
|
||||
class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
|
||||
peekHeightPercentage = 1f
|
||||
) {
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
ExpiredBadge.register(adapter)
|
||||
|
||||
adapter.submitList(getConfiguration().toMappingModelList())
|
||||
}
|
||||
|
||||
private fun getConfiguration(): DSLConfiguration {
|
||||
val badge = ExpiredBadgeBottomSheetDialogFragmentArgs.fromBundle(requireArguments()).badge
|
||||
|
||||
return configure {
|
||||
customPref(ExpiredBadge.Model(badge))
|
||||
|
||||
sectionHeaderPref(R.string.ExpiredBadgeBottomSheetDialogFragment__your_badge_has_expired)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(4f).toInt())
|
||||
|
||||
noPadTextPref(
|
||||
DSLSettingsText.from(
|
||||
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_s_badge_has_expired, badge.name),
|
||||
DSLSettingsText.CenterModifier
|
||||
)
|
||||
)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(16f).toInt())
|
||||
|
||||
noPadTextPref(
|
||||
DSLSettingsText.from(
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__to_continue_supporting,
|
||||
DSLSettingsText.CenterModifier
|
||||
)
|
||||
)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(92f).toInt())
|
||||
|
||||
primaryButton(
|
||||
text = DSLSettingsText.from(R.string.ExpiredBadgeBottomSheetDialogFragment__become_a_subscriber),
|
||||
onClick = {
|
||||
dismiss()
|
||||
findNavController().navigate(R.id.action_directly_to_subscribe)
|
||||
}
|
||||
)
|
||||
|
||||
secondaryButtonNoOutline(
|
||||
text = DSLSettingsText.from(R.string.ExpiredBadgeBottomSheetDialogFragment__not_now),
|
||||
onClick = {
|
||||
dismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import org.thoughtcrime.securesms.badges.BadgeRepository
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.badges.Badges.displayBadges
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.badges.models.FeaturedBadgePreview
|
||||
import org.thoughtcrime.securesms.badges.models.BadgePreview
|
||||
import org.thoughtcrime.securesms.components.recyclerview.OnScrollAnimationHelper
|
||||
import org.thoughtcrime.securesms.components.recyclerview.ToolbarShadowAnimationHelper
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
@@ -58,7 +58,7 @@ class SelectFeaturedBadgeFragment : DSLSettingsFragment(
|
||||
}
|
||||
|
||||
val previewView: View = requireView().findViewById(R.id.preview)
|
||||
val previewViewHolder = FeaturedBadgePreview.ViewHolder(previewView)
|
||||
val previewViewHolder = BadgePreview.ViewHolder<BadgePreview.Model>(previewView)
|
||||
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
|
||||
lifecycleDisposable += viewModel.events.subscribe { event: SelectFeaturedBadgeEvent ->
|
||||
@@ -71,7 +71,7 @@ class SelectFeaturedBadgeFragment : DSLSettingsFragment(
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
save.isEnabled = state.stage == SelectFeaturedBadgeState.Stage.READY
|
||||
previewViewHolder.bind(FeaturedBadgePreview.Model(state.selectedBadge))
|
||||
previewViewHolder.bind(BadgePreview.Model(state.selectedBadge))
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,10 +29,11 @@ class SelectFeaturedBadgeViewModel(private val repository: BadgeRepository) : Vi
|
||||
|
||||
init {
|
||||
store.update(Recipient.live(Recipient.self().id).liveDataResolved) { recipient, state ->
|
||||
val unexpiredBadges = recipient.badges.filterNot { it.isExpired() }
|
||||
state.copy(
|
||||
stage = if (state.stage == SelectFeaturedBadgeState.Stage.INIT) SelectFeaturedBadgeState.Stage.READY else state.stage,
|
||||
selectedBadge = recipient.badges.firstOrNull(),
|
||||
allUnlockedBadges = recipient.badges
|
||||
selectedBadge = unexpiredBadges.firstOrNull(),
|
||||
allUnlockedBadges = unexpiredBadges
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,11 @@ class BadgesOverviewFragment : DSLSettingsFragment(
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
Badge.register(adapter) { badge, _ ->
|
||||
ViewBadgeBottomSheetDialogFragment.show(parentFragmentManager, Recipient.self().id, badge)
|
||||
if (badge.isExpired()) {
|
||||
findNavController().navigate(BadgesOverviewFragmentDirections.actionBadgeManageFragmentToExpiredBadgeDialog(badge))
|
||||
} else {
|
||||
ViewBadgeBottomSheetDialogFragment.show(parentFragmentManager, Recipient.self().id, badge)
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
|
||||
@@ -57,6 +61,7 @@ class BadgesOverviewFragment : DSLSettingsFragment(
|
||||
switchPref(
|
||||
title = DSLSettingsText.from(R.string.BadgesOverviewFragment__display_badges_on_profile),
|
||||
isChecked = state.displayBadgesOnProfile,
|
||||
isEnabled = state.stage == BadgesOverviewState.Stage.READY && state.hasUnexpiredBadges,
|
||||
onClick = {
|
||||
viewModel.setDisplayBadgesOnProfile(!state.displayBadgesOnProfile)
|
||||
}
|
||||
@@ -65,7 +70,7 @@ class BadgesOverviewFragment : DSLSettingsFragment(
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.BadgesOverviewFragment__featured_badge),
|
||||
summary = state.featuredBadge?.name?.let { DSLSettingsText.from(it) },
|
||||
isEnabled = state.stage == BadgesOverviewState.Stage.READY,
|
||||
isEnabled = state.stage == BadgesOverviewState.Stage.READY && state.hasUnexpiredBadges,
|
||||
onClick = {
|
||||
findNavController().navigate(BadgesOverviewFragmentDirections.actionBadgeManageFragmentToFeaturedBadgeFragment())
|
||||
}
|
||||
|
||||
@@ -6,8 +6,11 @@ data class BadgesOverviewState(
|
||||
val stage: Stage = Stage.INIT,
|
||||
val allUnlockedBadges: List<Badge> = listOf(),
|
||||
val featuredBadge: Badge? = null,
|
||||
val displayBadgesOnProfile: Boolean = false
|
||||
val displayBadgesOnProfile: Boolean = false,
|
||||
) {
|
||||
|
||||
val hasUnexpiredBadges = allUnlockedBadges.any { it.expirationTimestamp > System.currentTimeMillis() }
|
||||
|
||||
enum class Stage {
|
||||
INIT,
|
||||
READY,
|
||||
|
||||
@@ -29,7 +29,8 @@ class BadgesOverviewViewModel(private val badgeRepository: BadgeRepository) : Vi
|
||||
state.copy(
|
||||
stage = if (state.stage == BadgesOverviewState.Stage.INIT) BadgesOverviewState.Stage.READY else state.stage,
|
||||
allUnlockedBadges = recipient.badges,
|
||||
displayBadgesOnProfile = recipient.badges.firstOrNull()?.visible == true
|
||||
displayBadgesOnProfile = recipient.badges.firstOrNull()?.visible == true,
|
||||
featuredBadge = recipient.featuredBadge
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,8 @@ class ViewBadgeBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFr
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
tabs.visible = state.allBadgesVisibleOnProfile.size > 1
|
||||
|
||||
adapter.submitList(
|
||||
state.allBadgesVisibleOnProfile.map {
|
||||
LargeBadge.Model(LargeBadge(it), state.recipient.getShortDisplayName(requireContext()))
|
||||
|
||||
@@ -13,6 +13,9 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.models.Button
|
||||
import org.thoughtcrime.securesms.components.settings.models.Space
|
||||
import org.thoughtcrime.securesms.components.settings.models.Text
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
@@ -31,6 +34,9 @@ class DSLSettingsAdapter : MappingAdapter() {
|
||||
registerFactory(SectionHeaderPreference::class.java, LayoutFactory(::SectionHeaderPreferenceViewHolder, R.layout.dsl_section_header))
|
||||
registerFactory(SwitchPreference::class.java, LayoutFactory(::SwitchPreferenceViewHolder, R.layout.dsl_switch_preference_item))
|
||||
registerFactory(RadioPreference::class.java, LayoutFactory(::RadioPreferenceViewHolder, R.layout.dsl_radio_preference_item))
|
||||
Text.register(this)
|
||||
Space.register(this)
|
||||
Button.register(this)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.thoughtcrime.securesms.components.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.EdgeEffect
|
||||
import androidx.annotation.LayoutRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
|
||||
|
||||
abstract class DSLSettingsBottomSheetFragment(
|
||||
@LayoutRes private val layoutId: Int = R.layout.dsl_settings_bottom_sheet,
|
||||
val layoutManagerProducer: (Context) -> RecyclerView.LayoutManager = { context -> LinearLayoutManager(context) },
|
||||
override val peekHeightPercentage: Float = 1f
|
||||
) : FixedRoundedCornerBottomSheetDialogFragment() {
|
||||
|
||||
private lateinit var recyclerView: RecyclerView
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(layoutId, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
recyclerView = view.findViewById(R.id.recycler)
|
||||
recyclerView.edgeEffectFactory = EdgeEffectFactory()
|
||||
val adapter = DSLSettingsAdapter()
|
||||
|
||||
recyclerView.layoutManager = layoutManagerProducer(requireContext())
|
||||
recyclerView.adapter = adapter
|
||||
|
||||
bindAdapter(adapter)
|
||||
}
|
||||
|
||||
abstract fun bindAdapter(adapter: DSLSettingsAdapter)
|
||||
|
||||
private class EdgeEffectFactory : RecyclerView.EdgeEffectFactory() {
|
||||
override fun createEdgeEffect(view: RecyclerView, direction: Int): EdgeEffect {
|
||||
return super.createEdgeEffect(view, direction).apply {
|
||||
if (Build.VERSION.SDK_INT > 21) {
|
||||
color =
|
||||
requireNotNull(ContextCompat.getColor(view.context, R.color.settings_ripple_color))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,35 +3,71 @@ package org.thoughtcrime.securesms.components.settings
|
||||
import android.content.Context
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.annotation.StyleRes
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
|
||||
sealed class DSLSettingsText {
|
||||
|
||||
protected abstract val modifiers: List<Modifier>
|
||||
|
||||
private data class FromResource(
|
||||
@StringRes private val stringId: Int,
|
||||
@ColorInt private val textColor: Int?
|
||||
override val modifiers: List<Modifier>
|
||||
) : DSLSettingsText() {
|
||||
override fun resolve(context: Context): CharSequence {
|
||||
val text = context.getString(stringId)
|
||||
|
||||
return if (textColor == null) {
|
||||
text
|
||||
} else {
|
||||
SpanUtil.color(textColor, text)
|
||||
}
|
||||
override fun getCharSequence(context: Context): CharSequence {
|
||||
return context.getString(stringId)
|
||||
}
|
||||
}
|
||||
|
||||
private data class FromCharSequence(private val charSequence: CharSequence) : DSLSettingsText() {
|
||||
override fun resolve(context: Context): CharSequence = charSequence
|
||||
private data class FromCharSequence(
|
||||
private val charSequence: CharSequence,
|
||||
override val modifiers: List<Modifier>
|
||||
) : DSLSettingsText() {
|
||||
override fun getCharSequence(context: Context): CharSequence = charSequence
|
||||
}
|
||||
|
||||
abstract fun resolve(context: Context): CharSequence
|
||||
protected abstract fun getCharSequence(context: Context): CharSequence
|
||||
|
||||
fun resolve(context: Context): CharSequence {
|
||||
val text: CharSequence = getCharSequence(context)
|
||||
return modifiers.fold(text) { t, m -> m.modify(context, t) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(@StringRes stringId: Int, @ColorInt textColor: Int? = null): DSLSettingsText =
|
||||
FromResource(stringId, textColor)
|
||||
fun from(@StringRes stringId: Int, @ColorInt textColor: Int): DSLSettingsText =
|
||||
FromResource(stringId, listOf(ColorModifier(textColor)))
|
||||
|
||||
fun from(charSequence: CharSequence): DSLSettingsText = FromCharSequence(charSequence)
|
||||
fun from(@StringRes stringId: Int, vararg modifiers: Modifier): DSLSettingsText =
|
||||
FromResource(stringId, modifiers.toList())
|
||||
|
||||
fun from(charSequence: CharSequence, vararg modifiers: Modifier): DSLSettingsText =
|
||||
FromCharSequence(charSequence, modifiers.toList())
|
||||
}
|
||||
|
||||
interface Modifier {
|
||||
fun modify(context: Context, charSequence: CharSequence): CharSequence
|
||||
}
|
||||
|
||||
class ColorModifier(@ColorInt private val textColor: Int) : Modifier {
|
||||
override fun modify(context: Context, charSequence: CharSequence): CharSequence {
|
||||
return SpanUtil.color(textColor, charSequence)
|
||||
}
|
||||
}
|
||||
|
||||
object CenterModifier : Modifier {
|
||||
override fun modify(context: Context, charSequence: CharSequence): CharSequence {
|
||||
return SpanUtil.center(charSequence)
|
||||
}
|
||||
}
|
||||
|
||||
object Title2BoldModifier : TextAppearanceModifier(R.style.TextAppearance_Signal_Title2_Bold)
|
||||
object Body1Modifier : TextAppearanceModifier(R.style.Signal_Text_Body)
|
||||
object Body1BoldModifier : TextAppearanceModifier(R.style.TextAppearance_Signal_Body1_Bold)
|
||||
|
||||
open class TextAppearanceModifier(@StyleRes private val textAppearance: Int) : Modifier {
|
||||
override fun modify(context: Context, charSequence: CharSequence): CharSequence {
|
||||
return SpanUtil.textAppearance(context, textAppearance, charSequence)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,16 +3,23 @@ package org.thoughtcrime.securesms.components.settings.app
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.viewModels
|
||||
import androidx.navigation.NavDirections
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.BoostRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.BoostViewModel
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.subscribe.SubscribeViewModel
|
||||
import org.thoughtcrime.securesms.help.HelpFragment
|
||||
import org.thoughtcrime.securesms.keyvalue.SettingsValues
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService
|
||||
import org.thoughtcrime.securesms.util.CachedInflater
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
|
||||
private const val START_LOCATION = "app.settings.start.location"
|
||||
private const val NOTIFICATION_CATEGORY = "android.intent.category.NOTIFICATION_PREFERENCES"
|
||||
@@ -22,7 +29,23 @@ class AppSettingsActivity : DSLSettingsActivity() {
|
||||
|
||||
private var wasConfigurationUpdated = false
|
||||
|
||||
private val donationRepository: DonationPaymentRepository by lazy { DonationPaymentRepository(this) }
|
||||
private val subscribeViewModel: SubscribeViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
SubscribeViewModel.Factory(SubscriptionsRepository(), donationRepository, FETCH_SUBSCRIPTION_TOKEN_REQUEST_CODE)
|
||||
}
|
||||
)
|
||||
|
||||
private val boostViewModel: BoostViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
BoostViewModel.Factory(BoostRepository(), donationRepository, FETCH_BOOST_TOKEN_REQUEST_CODE)
|
||||
}
|
||||
)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
|
||||
warmDonationViewModels()
|
||||
|
||||
if (intent?.hasExtra(ARG_NAV_GRAPH) != true) {
|
||||
intent?.putExtra(ARG_NAV_GRAPH, R.navigation.app_settings)
|
||||
}
|
||||
@@ -79,8 +102,17 @@ class AppSettingsActivity : DSLSettingsActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
subscribeViewModel.onActivityResult(requestCode, resultCode, data)
|
||||
boostViewModel.onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val FETCH_SUBSCRIPTION_TOKEN_REQUEST_CODE = 1000
|
||||
private const val FETCH_BOOST_TOKEN_REQUEST_CODE = 2000
|
||||
|
||||
@JvmStatic
|
||||
fun home(context: Context): Intent = getIntentForStartLocation(context, StartLocation.HOME)
|
||||
|
||||
@@ -109,6 +141,13 @@ class AppSettingsActivity : DSLSettingsActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun warmDonationViewModels() {
|
||||
if (FeatureFlags.donorBadges()) {
|
||||
subscribeViewModel
|
||||
boostViewModel
|
||||
}
|
||||
}
|
||||
|
||||
private enum class StartLocation(val code: Int) {
|
||||
HOME(0),
|
||||
BACKUPS(1),
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.Navigation
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
@@ -130,11 +131,33 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
|
||||
}
|
||||
)
|
||||
|
||||
externalLinkPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__donate_to_signal),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
|
||||
linkId = R.string.donate_url
|
||||
)
|
||||
if (FeatureFlags.donorBadges()) {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__subscription),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
|
||||
onClick = {
|
||||
findNavController()
|
||||
.navigate(
|
||||
AppSettingsFragmentDirections.actionAppSettingsFragmentToSubscriptions()
|
||||
.setSkipToSubscribe(true /* TODO [alex] -- Check state to see if user has active subscription or not. */)
|
||||
)
|
||||
}
|
||||
)
|
||||
// TODO [alex] -- clap
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__signal_boost),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
|
||||
onClick = {
|
||||
findNavController().navigate(R.id.action_appSettingsFragment_to_boostsFragment)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
externalLinkPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__donate_to_signal),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
|
||||
linkId = R.string.donate_url
|
||||
)
|
||||
}
|
||||
|
||||
if (FeatureFlags.internalUser()) {
|
||||
dividerPref()
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription
|
||||
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
|
||||
/**
|
||||
* Events that can arise from use of the donations apis.
|
||||
*/
|
||||
sealed class DonationEvent {
|
||||
class GooglePayUnavailableError(val throwable: Throwable) : DonationEvent()
|
||||
object RequestTokenSuccess : DonationEvent()
|
||||
object RequestTokenError : DonationEvent()
|
||||
class PaymentConfirmationError(val throwable: Throwable) : DonationEvent()
|
||||
class PaymentConfirmationSuccess(val badge: Badge) : DonationEvent()
|
||||
object SubscriptionCancelled : DonationEvent()
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import com.google.android.gms.wallet.PaymentData
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.donations.GooglePayApi
|
||||
import org.signal.donations.GooglePayPaymentSource
|
||||
import org.signal.donations.StripeApi
|
||||
import org.thoughtcrime.securesms.BuildConfig
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
|
||||
class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFetcher {
|
||||
|
||||
private val configuration = StripeApi.Configuration(publishableKey = BuildConfig.STRIPE_PUBLISHABLE_KEY)
|
||||
private val googlePayApi = GooglePayApi(activity, StripeApi.Gateway(configuration))
|
||||
private val stripeApi = StripeApi(configuration, this, ApplicationDependencies.getOkHttpClient())
|
||||
|
||||
fun isGooglePayAvailable(): Completable = googlePayApi.queryIsReadyToPay()
|
||||
|
||||
fun requestTokenFromGooglePay(price: FiatMoney, label: String, requestCode: Int) {
|
||||
googlePayApi.requestPayment(price, label, requestCode)
|
||||
}
|
||||
|
||||
fun onActivityResult(
|
||||
requestCode: Int,
|
||||
resultCode: Int,
|
||||
data: Intent?,
|
||||
expectedRequestCode: Int,
|
||||
paymentsRequestCallback: GooglePayApi.PaymentRequestCallback
|
||||
) {
|
||||
googlePayApi.onActivityResult(requestCode, resultCode, data, expectedRequestCode, paymentsRequestCallback)
|
||||
}
|
||||
|
||||
fun continuePayment(price: FiatMoney, paymentData: PaymentData): Completable {
|
||||
return stripeApi.createPaymentIntent(price)
|
||||
.flatMapCompletable { result ->
|
||||
when (result) {
|
||||
is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> Completable.error(Exception("Amount is too small"))
|
||||
is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> Completable.error(Exception("Amount is too large"))
|
||||
is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> Completable.error(Exception("Currency is not supported"))
|
||||
is StripeApi.CreatePaymentIntentResult.Success -> stripeApi.confirmPaymentIntent(GooglePayPaymentSource(paymentData), result.paymentIntent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun fetchPaymentIntent(price: FiatMoney, description: String?): Single<StripeApi.PaymentIntent> {
|
||||
return ApplicationDependencies
|
||||
.getDonationsService()
|
||||
.createDonationIntentWithAmount(price.minimumUnitPrecisionString, price.currency.currencyCode)
|
||||
.map { StripeApi.PaymentIntent(it.result.get().id, it.result.get().clientSecret) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription
|
||||
|
||||
import io.reactivex.rxjava3.core.Maybe
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
import java.util.Currency
|
||||
|
||||
/**
|
||||
* Repository which can query for the user's active subscription as well as a list of available subscriptions,
|
||||
* in the currency indicated.
|
||||
*/
|
||||
class SubscriptionsRepository {
|
||||
|
||||
fun getActiveSubscription(currency: Currency): Maybe<Subscription> = Maybe.empty()
|
||||
|
||||
fun getSubscriptions(currency: Currency): Single<List<Subscription>> = Single.fromCallable {
|
||||
listOf()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
|
||||
|
||||
import android.text.Editable
|
||||
import android.text.Spanned
|
||||
import android.text.TextWatcher
|
||||
import android.text.method.DigitsKeyListener
|
||||
import android.view.View
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.appcompat.widget.AppCompatEditText
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import java.lang.Integer.min
|
||||
import java.util.Currency
|
||||
import java.util.Locale
|
||||
import java.util.regex.Pattern
|
||||
|
||||
/**
|
||||
* A Signal Boost is a one-time ephemeral show of support. Each boost level
|
||||
* can unlock a corresponding badge for a time determined by the server.
|
||||
*/
|
||||
data class Boost(
|
||||
val badge: Badge,
|
||||
val price: FiatMoney
|
||||
) {
|
||||
|
||||
/**
|
||||
* A heading containing a 96dp rendering of the boost's badge.
|
||||
*/
|
||||
class HeadingModel(
|
||||
val boostBadge: Badge
|
||||
) : PreferenceModel<HeadingModel>() {
|
||||
override fun areItemsTheSame(newItem: HeadingModel): Boolean = true
|
||||
|
||||
override fun areContentsTheSame(newItem: HeadingModel): Boolean {
|
||||
return super.areContentsTheSame(newItem) && newItem.boostBadge == boostBadge
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A widget that allows a user to select from six different amounts, or enter a custom amount.
|
||||
*/
|
||||
class SelectionModel(
|
||||
val boosts: List<Boost>,
|
||||
val selectedBoost: Boost?,
|
||||
val currency: Currency,
|
||||
override val isEnabled: Boolean,
|
||||
val onBoostClick: (Boost) -> Unit,
|
||||
val isCustomAmountFocused: Boolean,
|
||||
val onCustomAmountChanged: (String) -> Unit,
|
||||
val onCustomAmountFocusChanged: (Boolean) -> Unit,
|
||||
) : PreferenceModel<SelectionModel>(isEnabled = isEnabled) {
|
||||
override fun areItemsTheSame(newItem: SelectionModel): Boolean = true
|
||||
|
||||
override fun areContentsTheSame(newItem: SelectionModel): Boolean {
|
||||
return super.areContentsTheSame(newItem) &&
|
||||
newItem.boosts == boosts &&
|
||||
newItem.selectedBoost == selectedBoost &&
|
||||
newItem.currency == currency &&
|
||||
newItem.isCustomAmountFocused == isCustomAmountFocused
|
||||
}
|
||||
}
|
||||
|
||||
private class SelectionViewHolder(itemView: View) : MappingViewHolder<SelectionModel>(itemView) {
|
||||
|
||||
private val boost1: MaterialButton = itemView.findViewById(R.id.boost_1)
|
||||
private val boost2: MaterialButton = itemView.findViewById(R.id.boost_2)
|
||||
private val boost3: MaterialButton = itemView.findViewById(R.id.boost_3)
|
||||
private val boost4: MaterialButton = itemView.findViewById(R.id.boost_4)
|
||||
private val boost5: MaterialButton = itemView.findViewById(R.id.boost_5)
|
||||
private val boost6: MaterialButton = itemView.findViewById(R.id.boost_6)
|
||||
private val custom: AppCompatEditText = itemView.findViewById(R.id.boost_custom)
|
||||
|
||||
private var filter: MoneyFilter? = null
|
||||
|
||||
init {
|
||||
custom.filters = emptyArray()
|
||||
}
|
||||
|
||||
override fun bind(model: SelectionModel) {
|
||||
itemView.isEnabled = model.isEnabled
|
||||
|
||||
model.boosts.zip(listOf(boost1, boost2, boost3, boost4, boost5, boost6)).forEach { (boost, button) ->
|
||||
button.isSelected = boost == model.selectedBoost && !model.isCustomAmountFocused
|
||||
button.text = FiatMoneyUtil.format(
|
||||
context.resources,
|
||||
boost.price,
|
||||
FiatMoneyUtil.formatOptions()
|
||||
)
|
||||
button.setOnClickListener {
|
||||
model.onBoostClick(boost)
|
||||
custom.clearFocus()
|
||||
}
|
||||
}
|
||||
|
||||
if (filter == null || filter?.currency != model.currency) {
|
||||
custom.removeTextChangedListener(filter)
|
||||
|
||||
filter = MoneyFilter(model.currency) {
|
||||
model.onCustomAmountChanged(it)
|
||||
}
|
||||
|
||||
custom.keyListener = filter
|
||||
custom.addTextChangedListener(filter)
|
||||
|
||||
custom.setText("")
|
||||
}
|
||||
|
||||
custom.setOnFocusChangeListener { _, hasFocus ->
|
||||
model.onCustomAmountFocusChanged(hasFocus)
|
||||
}
|
||||
|
||||
if (model.isCustomAmountFocused && !custom.hasFocus()) {
|
||||
ViewUtil.focusAndShowKeyboard(custom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class HeadingViewHolder(itemView: View) : MappingViewHolder<HeadingModel>(itemView) {
|
||||
|
||||
private val badgeImageView: BadgeImageView = itemView as BadgeImageView
|
||||
|
||||
override fun bind(model: HeadingModel) {
|
||||
badgeImageView.setBadge(model.boostBadge)
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
class MoneyFilter(val currency: Currency, private val onCustomAmountChanged: (String) -> Unit = {}) : DigitsKeyListener(), TextWatcher {
|
||||
|
||||
val separatorCount = min(1, currency.defaultFractionDigits)
|
||||
val prefix: String = "${currency.getSymbol(Locale.getDefault())} "
|
||||
val pattern: Pattern = "[0-9]*([.,]){0,$separatorCount}[0-9]{0,${currency.defaultFractionDigits}}".toPattern()
|
||||
|
||||
override fun filter(
|
||||
source: CharSequence,
|
||||
start: Int,
|
||||
end: Int,
|
||||
dest: Spanned,
|
||||
dstart: Int,
|
||||
dend: Int
|
||||
): CharSequence? {
|
||||
|
||||
val result = dest.subSequence(0, dstart).toString() + source.toString() + dest.subSequence(dend, dest.length)
|
||||
val resultWithoutCurrencyPrefix = result.removePrefix(prefix)
|
||||
val matcher = pattern.matcher(resultWithoutCurrencyPrefix)
|
||||
|
||||
if (!matcher.matches()) {
|
||||
return dest.subSequence(dstart, dend)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
|
||||
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
|
||||
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
if (s.isNullOrEmpty()) return
|
||||
|
||||
val hasPrefix = s.startsWith(prefix)
|
||||
if (hasPrefix && s.length == prefix.length) {
|
||||
s.clear()
|
||||
} else if (!hasPrefix) {
|
||||
s.insert(0, prefix)
|
||||
}
|
||||
|
||||
onCustomAmountChanged(s.removePrefix(prefix).toString())
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
|
||||
|
||||
import android.text.SpannableStringBuilder
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.badges.models.BadgePreview
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
|
||||
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.configure
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
|
||||
/**
|
||||
* UX to allow users to donate ephemerally.
|
||||
*/
|
||||
class BoostFragment : DSLSettingsBottomSheetFragment() {
|
||||
|
||||
private val viewModel: BoostViewModel by viewModels(ownerProducer = { requireActivity() })
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
private val sayThanks: CharSequence by lazy {
|
||||
SpannableStringBuilder(requireContext().getString(R.string.BoostFragment__say_thanks_and_earn, 30))
|
||||
.append(" ")
|
||||
.append(
|
||||
SpanUtil.learnMore(requireContext(), ContextCompat.getColor(requireContext(), R.color.signal_accent_primary)) {
|
||||
// TODO [alex] -- Where's this go?
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
CurrencySelection.register(adapter)
|
||||
BadgePreview.register(adapter)
|
||||
Boost.register(adapter)
|
||||
GooglePayButton.register(adapter)
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
}
|
||||
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
|
||||
lifecycleDisposable += viewModel.events.subscribe { event: DonationEvent ->
|
||||
when (event) {
|
||||
is DonationEvent.GooglePayUnavailableError -> Log.w(TAG, "Google Pay error", event.throwable)
|
||||
is DonationEvent.PaymentConfirmationError -> Log.w(TAG, "Payment confirmation error", event.throwable)
|
||||
is DonationEvent.PaymentConfirmationSuccess -> onPaymentConfirmed(event.badge)
|
||||
DonationEvent.RequestTokenError -> Log.w(TAG, "Request token could not be fetched")
|
||||
DonationEvent.RequestTokenSuccess -> Log.w(TAG, "Successfully got request token from Google Pay")
|
||||
DonationEvent.SubscriptionCancelled -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: BoostState): DSLConfiguration {
|
||||
return configure {
|
||||
customPref(BadgePreview.SubscriptionModel(state.boostBadge))
|
||||
|
||||
sectionHeaderPref(
|
||||
title = DSLSettingsText.from(
|
||||
R.string.BoostFragment__give_signal_a_boost,
|
||||
DSLSettingsText.CenterModifier, DSLSettingsText.Title2BoldModifier
|
||||
)
|
||||
)
|
||||
|
||||
noPadTextPref(
|
||||
title = DSLSettingsText.from(
|
||||
sayThanks,
|
||||
DSLSettingsText.CenterModifier
|
||||
)
|
||||
)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(28f).toInt())
|
||||
|
||||
customPref(
|
||||
CurrencySelection.Model(
|
||||
currencySelection = state.currencySelection,
|
||||
isEnabled = state.stage == BoostState.Stage.READY,
|
||||
onClick = {
|
||||
findNavController().navigate(BoostFragmentDirections.actionBoostFragmentToSetDonationCurrencyFragment())
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
customPref(
|
||||
Boost.SelectionModel(
|
||||
boosts = state.boosts,
|
||||
selectedBoost = state.selectedBoost,
|
||||
currency = state.customAmount.currency,
|
||||
isCustomAmountFocused = state.isCustomAmountFocused,
|
||||
isEnabled = state.stage == BoostState.Stage.READY,
|
||||
onBoostClick = {
|
||||
viewModel.setSelectedBoost(it)
|
||||
},
|
||||
onCustomAmountChanged = {
|
||||
viewModel.setCustomAmount(it)
|
||||
},
|
||||
onCustomAmountFocusChanged = {
|
||||
viewModel.setCustomAmountFocused(it)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
if (state.isGooglePayAvailable) {
|
||||
space(DimensionUnit.DP.toPixels(16f).toInt())
|
||||
|
||||
customPref(
|
||||
GooglePayButton.Model(
|
||||
onClick = this@BoostFragment::onGooglePayButtonClicked,
|
||||
isEnabled = state.stage == BoostState.Stage.READY
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
secondaryButtonNoOutline(
|
||||
text = DSLSettingsText.from(R.string.SubscribeFragment__more_payment_options),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_open_20, R.color.signal_accent_primary),
|
||||
onClick = {
|
||||
// TODO
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onGooglePayButtonClicked() {
|
||||
viewModel.requestTokenFromGooglePay(getString(R.string.preferences__signal_boost))
|
||||
}
|
||||
|
||||
private fun onPaymentConfirmed(boostBadge: Badge) {
|
||||
findNavController().navigate(BoostFragmentDirections.actionBoostFragmentToBoostThanksForYourSupportBottomSheetDialog(boostBadge).setIsBoost(true))
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(BoostFragment::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
|
||||
|
||||
import android.net.Uri
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
|
||||
class BoostRepository {
|
||||
|
||||
fun getBoosts(currency: Currency): Single<Pair<List<Boost>, Boost?>> {
|
||||
val boosts = testBoosts(currency)
|
||||
|
||||
return Single.just(
|
||||
Pair(
|
||||
boosts,
|
||||
boosts[2]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun getBoostBadge(): Single<Badge> = Single.fromCallable {
|
||||
// Get boost badge from server
|
||||
// throw NotImplementedError()
|
||||
testBadge
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val testBadge = Badge(
|
||||
id = "TEST",
|
||||
category = Badge.Category.Testing,
|
||||
name = "Test Badge",
|
||||
description = "Test Badge",
|
||||
imageUrl = Uri.EMPTY,
|
||||
imageDensity = "xxxhdpi",
|
||||
expirationTimestamp = 0L,
|
||||
visible = false,
|
||||
)
|
||||
|
||||
private fun testBoosts(currency: Currency) = listOf(
|
||||
3L, 5L, 10L, 20L, 50L, 100L
|
||||
).map {
|
||||
Boost(testBadge, FiatMoney(BigDecimal.valueOf(it), currency))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
|
||||
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
|
||||
data class BoostState(
|
||||
val boostBadge: Badge? = null,
|
||||
val currencySelection: CurrencySelection = CurrencySelection("USD"),
|
||||
val isGooglePayAvailable: Boolean = false,
|
||||
val boosts: List<Boost> = listOf(),
|
||||
val selectedBoost: Boost? = null,
|
||||
val customAmount: FiatMoney = FiatMoney(BigDecimal.ZERO, Currency.getInstance(currencySelection.selectedCurrencyCode)),
|
||||
val isCustomAmountFocused: Boolean = false,
|
||||
val stage: Stage = Stage.INIT
|
||||
) {
|
||||
enum class Stage {
|
||||
INIT,
|
||||
READY,
|
||||
PAYMENT_PIPELINE,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.google.android.gms.wallet.PaymentData
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.donations.GooglePayApi
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
import java.math.BigDecimal
|
||||
|
||||
class BoostViewModel(
|
||||
private val boostRepository: BoostRepository,
|
||||
private val donationPaymentRepository: DonationPaymentRepository,
|
||||
private val fetchTokenRequestCode: Int
|
||||
) : ViewModel() {
|
||||
|
||||
private val store = Store(BoostState())
|
||||
private val eventPublisher: PublishSubject<DonationEvent> = PublishSubject.create()
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
val state: LiveData<BoostState> = store.stateLiveData
|
||||
val events: Observable<DonationEvent> = eventPublisher
|
||||
|
||||
private var boostToPurchase: Boost? = null
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
init {
|
||||
val currencyObservable = SignalStore.donationsValues().observableCurrency
|
||||
val boosts = currencyObservable.flatMapSingle { boostRepository.getBoosts(it) }
|
||||
val boostBadge = boostRepository.getBoostBadge()
|
||||
|
||||
disposables += Observable.combineLatest(boosts, boostBadge.toObservable()) { (boosts, defaultBoost), badge -> BoostInfo(boosts, defaultBoost, badge) }.subscribe { info ->
|
||||
store.update {
|
||||
it.copy(
|
||||
boosts = info.boosts,
|
||||
selectedBoost = if (it.selectedBoost in info.boosts) it.selectedBoost else info.defaultBoost,
|
||||
boostBadge = it.boostBadge ?: info.boostBadge,
|
||||
stage = if (it.stage == BoostState.Stage.INIT) BoostState.Stage.READY else it.stage
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
disposables += donationPaymentRepository.isGooglePayAvailable().subscribeBy(
|
||||
onComplete = { store.update { it.copy(isGooglePayAvailable = true) } },
|
||||
onError = { eventPublisher.onNext(DonationEvent.GooglePayUnavailableError(it)) }
|
||||
)
|
||||
|
||||
disposables += currencyObservable.subscribeBy { currency ->
|
||||
store.update {
|
||||
it.copy(
|
||||
currencySelection = CurrencySelection(currency.currencyCode),
|
||||
isCustomAmountFocused = false,
|
||||
customAmount = FiatMoney(
|
||||
BigDecimal.ZERO, currency
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onActivityResult(
|
||||
requestCode: Int,
|
||||
resultCode: Int,
|
||||
data: Intent?
|
||||
) {
|
||||
donationPaymentRepository.onActivityResult(
|
||||
requestCode,
|
||||
resultCode,
|
||||
data,
|
||||
this.fetchTokenRequestCode,
|
||||
object : GooglePayApi.PaymentRequestCallback {
|
||||
override fun onSuccess(paymentData: PaymentData) {
|
||||
val boost = boostToPurchase
|
||||
boostToPurchase = null
|
||||
|
||||
if (boost != null) {
|
||||
eventPublisher.onNext(DonationEvent.RequestTokenSuccess)
|
||||
donationPaymentRepository.continuePayment(boost.price, paymentData).subscribeBy(
|
||||
onError = { eventPublisher.onNext(DonationEvent.PaymentConfirmationError(it)) },
|
||||
onComplete = {
|
||||
// Now we need to do the whole query for a token, submit token rigamarole
|
||||
eventPublisher.onNext(DonationEvent.PaymentConfirmationSuccess(store.state.boostBadge!!))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError() {
|
||||
store.update { it.copy(stage = BoostState.Stage.PAYMENT_PIPELINE) }
|
||||
eventPublisher.onNext(DonationEvent.RequestTokenError)
|
||||
}
|
||||
|
||||
override fun onCancelled() {
|
||||
store.update { it.copy(stage = BoostState.Stage.PAYMENT_PIPELINE) }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun requestTokenFromGooglePay(label: String) {
|
||||
val snapshot = store.state
|
||||
if (snapshot.selectedBoost == null) {
|
||||
return
|
||||
}
|
||||
|
||||
store.update { it.copy(stage = BoostState.Stage.PAYMENT_PIPELINE) }
|
||||
|
||||
// TODO [alex] -- Do we want prevalidation? Stripe will catch us anyway.
|
||||
// TODO [alex] -- Custom boost badge details... how do we determine this?
|
||||
boostToPurchase = if (snapshot.isCustomAmountFocused) {
|
||||
Boost(snapshot.selectedBoost.badge, snapshot.customAmount)
|
||||
} else {
|
||||
snapshot.selectedBoost
|
||||
}
|
||||
|
||||
donationPaymentRepository.requestTokenFromGooglePay(snapshot.selectedBoost.price, label, fetchTokenRequestCode)
|
||||
}
|
||||
|
||||
fun setSelectedBoost(boost: Boost) {
|
||||
store.update {
|
||||
it.copy(
|
||||
isCustomAmountFocused = false,
|
||||
selectedBoost = boost
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setCustomAmount(amount: String) {
|
||||
val bigDecimalAmount = if (amount.isEmpty()) {
|
||||
BigDecimal.ZERO
|
||||
} else {
|
||||
BigDecimal(amount)
|
||||
}
|
||||
|
||||
store.update { it.copy(customAmount = FiatMoney(bigDecimalAmount, it.customAmount.currency)) }
|
||||
}
|
||||
|
||||
fun setCustomAmountFocused(isFocused: Boolean) {
|
||||
store.update { it.copy(isCustomAmountFocused = isFocused) }
|
||||
}
|
||||
|
||||
private data class BoostInfo(val boosts: List<Boost>, val defaultBoost: Boost?, val boostBadge: Badge)
|
||||
|
||||
class Factory(
|
||||
private val boostRepository: BoostRepository,
|
||||
private val donationPaymentRepository: DonationPaymentRepository,
|
||||
private val fetchTokenRequestCode: Int
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(BoostViewModel(boostRepository, donationPaymentRepository, fetchTokenRequestCode))!!
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.currency
|
||||
|
||||
import androidx.fragment.app.viewModels
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Simple fragment for selecting a currency for Donations
|
||||
*/
|
||||
class SetCurrencyFragment : DSLSettingsBottomSheetFragment() {
|
||||
|
||||
private val viewModel: SetCurrencyViewModel by viewModels()
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
}
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: SetCurrencyState): DSLConfiguration {
|
||||
return configure {
|
||||
state.currencies.forEach { currency ->
|
||||
radioPref(
|
||||
title = DSLSettingsText.from(currency.getDisplayName(Locale.getDefault())),
|
||||
summary = DSLSettingsText.from(currency.currencyCode),
|
||||
isChecked = currency.currencyCode == state.selectedCurrencyCode,
|
||||
onClick = {
|
||||
viewModel.setSelectedCurrency(currency.currencyCode)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.currency
|
||||
|
||||
import java.util.Currency
|
||||
|
||||
data class SetCurrencyState(
|
||||
val selectedCurrencyCode: String = "",
|
||||
val currencies: List<Currency> = listOf()
|
||||
)
|
||||
@@ -0,0 +1,68 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.currency
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import org.signal.donations.StripeApi
|
||||
import org.thoughtcrime.securesms.BuildConfig
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
import java.util.Currency
|
||||
import java.util.Locale
|
||||
|
||||
class SetCurrencyViewModel : ViewModel() {
|
||||
|
||||
private val store = Store(SetCurrencyState())
|
||||
|
||||
val state: LiveData<SetCurrencyState> = store.stateLiveData
|
||||
|
||||
init {
|
||||
val defaultCurrency = SignalStore.donationsValues().getCurrency()
|
||||
|
||||
store.update { state ->
|
||||
val platformCurrencies = Currency.getAvailableCurrencies()
|
||||
val stripeCurrencies = platformCurrencies
|
||||
.filter { StripeApi.Validation.supportedCurrencyCodes.contains(it.currencyCode) }
|
||||
.sortedWith(CurrencyComparator(BuildConfig.DEFAULT_CURRENCIES.split(",")))
|
||||
|
||||
state.copy(
|
||||
selectedCurrencyCode = defaultCurrency.currencyCode,
|
||||
currencies = stripeCurrencies
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSelectedCurrency(selectedCurrencyCode: String) {
|
||||
store.update { it.copy(selectedCurrencyCode = selectedCurrencyCode) }
|
||||
SignalStore.donationsValues().setCurrency(Currency.getInstance(selectedCurrencyCode))
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
class CurrencyComparator(private val defaults: List<String>) : Comparator<Currency> {
|
||||
|
||||
companion object {
|
||||
private const val USD = "USD"
|
||||
}
|
||||
|
||||
override fun compare(o1: Currency, o2: Currency): Int {
|
||||
val isO1Default = o1.currencyCode in defaults
|
||||
val isO2Default = o2.currencyCode in defaults
|
||||
|
||||
return if (o1.currencyCode == o2.currencyCode) {
|
||||
0
|
||||
} else if (o1.currencyCode == USD) {
|
||||
-1
|
||||
} else if (o2.currencyCode == USD) {
|
||||
1
|
||||
} else if (isO1Default && isO2Default) {
|
||||
o1.getDisplayName(Locale.getDefault()).compareTo(o2.getDisplayName(Locale.getDefault()))
|
||||
} else if (isO1Default) {
|
||||
-1
|
||||
} else if (isO2Default) {
|
||||
1
|
||||
} else {
|
||||
o1.getDisplayName(Locale.getDefault()).compareTo(o2.getDisplayName(Locale.getDefault()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
|
||||
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* DSL renderable item that displays active subscription information on the user's
|
||||
* manage donations page.
|
||||
*/
|
||||
object ActiveSubscriptionPreference {
|
||||
|
||||
class Model(
|
||||
val subscription: Subscription,
|
||||
val onAddBoostClick: () -> Unit
|
||||
) : PreferenceModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return subscription.id == newItem.subscription.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return super.areContentsTheSame(newItem) && subscription == newItem.subscription
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
val badge: BadgeImageView = itemView.findViewById(R.id.my_support_badge)
|
||||
val title: TextView = itemView.findViewById(R.id.my_support_title)
|
||||
val price: TextView = itemView.findViewById(R.id.my_support_price)
|
||||
val expiry: TextView = itemView.findViewById(R.id.my_support_expiry)
|
||||
val boost: MaterialButton = itemView.findViewById(R.id.my_support_boost)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
badge.setBadge(model.subscription.badge)
|
||||
title.text = model.subscription.title
|
||||
|
||||
price.text = context.getString(
|
||||
R.string.MySupportPreference__s_per_month,
|
||||
FiatMoneyUtil.format(
|
||||
context.resources,
|
||||
model.subscription.price,
|
||||
FiatMoneyUtil.formatOptions()
|
||||
)
|
||||
)
|
||||
|
||||
expiry.text = context.getString(
|
||||
R.string.MySupportPreference__renews_s,
|
||||
DateUtils.formatDate(
|
||||
Locale.getDefault(),
|
||||
model.subscription.renewalTimestamp
|
||||
)
|
||||
)
|
||||
|
||||
boost.setOnClickListener {
|
||||
model.onAddBoostClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun register(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.my_support_preference))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
|
||||
|
||||
enum class ManageDonationsEvent {
|
||||
NOT_SUBSCRIBED,
|
||||
ERROR_GETTING_SUBSCRIPTION
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
|
||||
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
|
||||
/**
|
||||
* Fragment displayed when a user enters "Subscriptions" via app settings but is already
|
||||
* a subscriber. Used to manage their current subscription, view badges, and boost.
|
||||
*/
|
||||
class ManageDonationsFragment : DSLSettingsFragment() {
|
||||
|
||||
private val viewModel: ManageDonationsViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
ManageDonationsViewModel.Factory(SubscriptionsRepository())
|
||||
}
|
||||
)
|
||||
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val args = ManageDonationsFragmentArgs.fromBundle(requireArguments())
|
||||
if (args.skipToSubscribe) {
|
||||
findNavController().navigate(
|
||||
ManageDonationsFragmentDirections.actionManageDonationsFragmentToSubscribeFragment(),
|
||||
NavOptions.Builder().setPopUpTo(R.id.manageDonationsFragment, true).build()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewModel.refresh()
|
||||
}
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
val args = ManageDonationsFragmentArgs.fromBundle(requireArguments())
|
||||
if (args.skipToSubscribe) {
|
||||
return
|
||||
}
|
||||
|
||||
ActiveSubscriptionPreference.register(adapter)
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
}
|
||||
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
|
||||
lifecycleDisposable += viewModel.events.subscribe { event: ManageDonationsEvent ->
|
||||
when (event) {
|
||||
ManageDonationsEvent.NOT_SUBSCRIBED -> handleUserIsNotSubscribed()
|
||||
ManageDonationsEvent.ERROR_GETTING_SUBSCRIPTION -> handleErrorGettingSubscription()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: ManageDonationsState): DSLConfiguration {
|
||||
return configure {
|
||||
sectionHeaderPref(
|
||||
title = DSLSettingsText.from(
|
||||
R.string.SubscribeFragment__signal_is_powered_by_people_like_you,
|
||||
DSLSettingsText.CenterModifier, DSLSettingsText.Title2BoldModifier
|
||||
)
|
||||
)
|
||||
|
||||
noPadTextPref(
|
||||
title = DSLSettingsText.from(
|
||||
R.string.ManageDonationsFragment__my_support,
|
||||
DSLSettingsText.Title2BoldModifier
|
||||
)
|
||||
)
|
||||
|
||||
if (state.activeSubscription != null) {
|
||||
customPref(
|
||||
ActiveSubscriptionPreference.Model(
|
||||
subscription = state.activeSubscription,
|
||||
onAddBoostClick = {
|
||||
findNavController().navigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToBoosts())
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
}
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ManageDonationsFragment__manage_subscription),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_person_white_24dp),
|
||||
onClick = {
|
||||
findNavController().navigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToSubscribeFragment())
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ManageDonationsFragment__badges),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_badge_24),
|
||||
onClick = {
|
||||
findNavController().navigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToSubscriptionBadgeManageFragment())
|
||||
}
|
||||
)
|
||||
|
||||
externalLinkPref(
|
||||
title = DSLSettingsText.from(R.string.ManageDonationsFragment__subscription_faq),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_help_24),
|
||||
linkId = R.string.donate_url
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleUserIsNotSubscribed() {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
|
||||
private fun handleErrorGettingSubscription() {
|
||||
Toast.makeText(requireContext(), R.string.ManageDonationsFragment__error_getting_subscription, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
|
||||
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
|
||||
data class ManageDonationsState(
|
||||
val featuredBadge: Badge? = null,
|
||||
val activeSubscription: Subscription? = null
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
|
||||
class ManageDonationsViewModel(
|
||||
private val subscriptionsRepository: SubscriptionsRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val store = Store(ManageDonationsState())
|
||||
private val eventPublisher = PublishSubject.create<ManageDonationsEvent>()
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
val state: LiveData<ManageDonationsState> = store.stateLiveData
|
||||
val events: Observable<ManageDonationsEvent> = eventPublisher
|
||||
|
||||
init {
|
||||
store.update(Recipient.self().live().liveDataResolved) { self, state ->
|
||||
state.copy(featuredBadge = self.featuredBadge)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
disposables.clear()
|
||||
disposables += subscriptionsRepository.getActiveSubscription(SignalStore.donationsValues().getCurrency()).subscribeBy(
|
||||
onSuccess = { subscription -> store.update { it.copy(activeSubscription = subscription) } },
|
||||
onComplete = {
|
||||
store.update { it.copy(activeSubscription = null) }
|
||||
eventPublisher.onNext(ManageDonationsEvent.NOT_SUBSCRIBED)
|
||||
},
|
||||
onError = {
|
||||
eventPublisher.onNext(ManageDonationsEvent.ERROR_GETTING_SUBSCRIPTION)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val subscriptionsRepository: SubscriptionsRepository
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(ManageDonationsViewModel(subscriptionsRepository))!!
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.models
|
||||
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
data class CurrencySelection(
|
||||
val selectedCurrencyCode: String,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
fun register(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.subscription_currency_selection))
|
||||
}
|
||||
}
|
||||
|
||||
class Model(
|
||||
val currencySelection: CurrencySelection,
|
||||
override val isEnabled: Boolean,
|
||||
val onClick: () -> Unit
|
||||
) : PreferenceModel<Model>(isEnabled = isEnabled) {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return super.areContentsTheSame(newItem) &&
|
||||
newItem.currencySelection.selectedCurrencyCode == currencySelection.selectedCurrencyCode
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val spinner: TextView = itemView.findViewById(R.id.subscription_currency_selection_spinner)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
spinner.text = model.currencySelection.selectedCurrencyCode
|
||||
itemView.setOnClickListener { model.onClick() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.models
|
||||
|
||||
import android.view.View
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
object GooglePayButton {
|
||||
|
||||
class Model(val onClick: () -> Unit, override val isEnabled: Boolean) : PreferenceModel<Model>(isEnabled = isEnabled) {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean = true
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val googlePayButton: View = findViewById(R.id.googlepay_button)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
googlePayButton.isEnabled = model.isEnabled
|
||||
googlePayButton.setOnClickListener {
|
||||
googlePayButton.isEnabled = false
|
||||
model.onClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun register(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.google_pay_button_pref))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.subscribe
|
||||
|
||||
import android.text.SpannableStringBuilder
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.badges.models.BadgePreview
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
|
||||
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.configure
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
|
||||
/**
|
||||
* UX for creating and changing a subscription
|
||||
*/
|
||||
class SubscribeFragment : DSLSettingsFragment() {
|
||||
|
||||
private val viewModel: SubscribeViewModel by viewModels(ownerProducer = { requireActivity() })
|
||||
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
private val supportTechSummary: CharSequence by lazy {
|
||||
SpannableStringBuilder(requireContext().getString(R.string.SubscribeFragment__support_technology_that_is_built_for_you))
|
||||
.append(" ")
|
||||
.append(
|
||||
SpanUtil.learnMore(requireContext(), ContextCompat.getColor(requireContext(), R.color.signal_accent_primary)) {
|
||||
findNavController().navigate(SubscribeFragmentDirections.actionSubscribeFragmentToSubscribeLearnMoreBottomSheetDialog())
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewModel.refresh()
|
||||
}
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
BadgePreview.register(adapter)
|
||||
CurrencySelection.register(adapter)
|
||||
Subscription.register(adapter)
|
||||
GooglePayButton.register(adapter)
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
}
|
||||
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
|
||||
lifecycleDisposable += viewModel.events.subscribe {
|
||||
when (it) {
|
||||
is DonationEvent.GooglePayUnavailableError -> Log.w(TAG, "Google Pay error", it.throwable)
|
||||
is DonationEvent.PaymentConfirmationError -> Log.w(TAG, "Payment confirmation error", it.throwable)
|
||||
is DonationEvent.PaymentConfirmationSuccess -> onPaymentConfirmed(it.badge)
|
||||
DonationEvent.RequestTokenError -> Log.w(TAG, "Request token could not be fetched")
|
||||
DonationEvent.RequestTokenSuccess -> Log.w(TAG, "Successfully got request token from Google Pay")
|
||||
DonationEvent.SubscriptionCancelled -> onSubscriptionCancelled()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: SubscribeState): DSLConfiguration {
|
||||
return configure {
|
||||
customPref(BadgePreview.SubscriptionModel(state.previewBadge))
|
||||
|
||||
sectionHeaderPref(
|
||||
title = DSLSettingsText.from(
|
||||
R.string.SubscribeFragment__signal_is_powered_by_people_like_you,
|
||||
DSLSettingsText.CenterModifier, DSLSettingsText.Title2BoldModifier
|
||||
)
|
||||
)
|
||||
|
||||
noPadTextPref(
|
||||
title = DSLSettingsText.from(supportTechSummary, DSLSettingsText.CenterModifier)
|
||||
)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(16f).toInt())
|
||||
|
||||
customPref(
|
||||
CurrencySelection.Model(
|
||||
currencySelection = state.currencySelection,
|
||||
isEnabled = state.stage == SubscribeState.Stage.READY,
|
||||
onClick = {
|
||||
findNavController().navigate(SubscribeFragmentDirections.actionSubscribeFragmentToSetDonationCurrencyFragment())
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
state.subscriptions.forEach {
|
||||
customPref(
|
||||
Subscription.Model(
|
||||
subscription = it,
|
||||
isSelected = state.selectedSubscription == it,
|
||||
isEnabled = state.stage == SubscribeState.Stage.READY,
|
||||
isActive = state.activeSubscription == it,
|
||||
onClick = { viewModel.setSelectedSubscription(it) }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (state.activeSubscription != null) {
|
||||
primaryButton(
|
||||
text = DSLSettingsText.from(R.string.SubscribeFragment__update_subscription),
|
||||
onClick = {
|
||||
// TODO [alex] -- Dunno what the update process requires.
|
||||
}
|
||||
)
|
||||
|
||||
secondaryButtonNoOutline(
|
||||
text = DSLSettingsText.from(R.string.SubscribeFragment__cancel_subscription),
|
||||
onClick = {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.SubscribeFragment__confirm_cancellation)
|
||||
.setMessage(R.string.SubscribeFragment__you_wont_be_charged_again)
|
||||
.setPositiveButton(R.string.SubscribeFragment__confirm) { d, _ ->
|
||||
d.dismiss()
|
||||
viewModel.cancel()
|
||||
}
|
||||
.setNegativeButton(R.string.SubscribeFragment__not_now) { d, _ ->
|
||||
d.dismiss()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
if (state.isGooglePayAvailable) {
|
||||
space(DimensionUnit.DP.toPixels(16f).toInt())
|
||||
|
||||
customPref(
|
||||
GooglePayButton.Model(
|
||||
onClick = this@SubscribeFragment::onGooglePayButtonClicked,
|
||||
isEnabled = state.stage == SubscribeState.Stage.READY
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
secondaryButtonNoOutline(
|
||||
text = DSLSettingsText.from(R.string.SubscribeFragment__more_payment_options),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_open_20, R.color.signal_accent_primary),
|
||||
onClick = {
|
||||
// TODO
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onGooglePayButtonClicked() {
|
||||
viewModel.requestTokenFromGooglePay()
|
||||
}
|
||||
|
||||
private fun onPaymentConfirmed(badge: Badge) {
|
||||
findNavController().navigate(SubscribeFragmentDirections.actionSubscribeFragmentToSubscribeThanksForYourSupportBottomSheetDialog(badge).setIsBoost(false))
|
||||
}
|
||||
|
||||
private fun onSubscriptionCancelled() {
|
||||
Snackbar.make(requireView(), R.string.SubscribeFragment__your_subscription_has_been_cancelled, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(SubscribeFragment::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.subscribe
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
|
||||
|
||||
class SubscribeLearnMoreBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFragment() {
|
||||
|
||||
override val peekHeightPercentage: Float = 1f
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(R.layout.subscribe_learn_more_bottom_sheet_dialog_fragment, container, false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.subscribe
|
||||
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
|
||||
data class SubscribeState(
|
||||
val previewBadge: Badge? = null,
|
||||
val currencySelection: CurrencySelection = CurrencySelection("USD"),
|
||||
val subscriptions: List<Subscription> = listOf(),
|
||||
val selectedSubscription: Subscription? = null,
|
||||
val activeSubscription: Subscription? = null,
|
||||
val isGooglePayAvailable: Boolean = false,
|
||||
val stage: Stage = Stage.INIT
|
||||
) {
|
||||
enum class Stage {
|
||||
INIT,
|
||||
READY,
|
||||
PAYMENT_PIPELINE,
|
||||
CANCELLING
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.subscribe
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.google.android.gms.wallet.PaymentData
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import org.signal.donations.GooglePayApi
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
import org.whispersystems.libsignal.util.guava.Optional
|
||||
|
||||
class SubscribeViewModel(
|
||||
private val subscriptionsRepository: SubscriptionsRepository,
|
||||
private val donationPaymentRepository: DonationPaymentRepository,
|
||||
private val fetchTokenRequestCode: Int
|
||||
) : ViewModel() {
|
||||
|
||||
private val store = Store(SubscribeState())
|
||||
private val eventPublisher: PublishSubject<DonationEvent> = PublishSubject.create()
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
val state: LiveData<SubscribeState> = store.stateLiveData
|
||||
val events: Observable<DonationEvent> = eventPublisher
|
||||
|
||||
private var subscriptionToPurchase: Subscription? = null
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
disposables.clear()
|
||||
|
||||
val currency = SignalStore.donationsValues().getCurrency()
|
||||
|
||||
val allSubscriptions = subscriptionsRepository.getSubscriptions(currency)
|
||||
val activeSubscription = subscriptionsRepository.getActiveSubscription(currency)
|
||||
.map { Optional.of(it) }
|
||||
.defaultIfEmpty(Optional.absent())
|
||||
|
||||
disposables += allSubscriptions.zipWith(activeSubscription, ::Pair).subscribe { (subs, active) ->
|
||||
store.update {
|
||||
it.copy(
|
||||
subscriptions = subs,
|
||||
selectedSubscription = it.selectedSubscription ?: active.orNull() ?: subs.firstOrNull(),
|
||||
activeSubscription = active.orNull(),
|
||||
stage = SubscribeState.Stage.READY
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
disposables += donationPaymentRepository.isGooglePayAvailable().subscribeBy(
|
||||
onComplete = { store.update { it.copy(isGooglePayAvailable = true) } },
|
||||
onError = { eventPublisher.onNext(DonationEvent.GooglePayUnavailableError(it)) }
|
||||
)
|
||||
|
||||
store.update { it.copy(currencySelection = CurrencySelection(SignalStore.donationsValues().getCurrency().currencyCode)) }
|
||||
}
|
||||
fun cancel() {
|
||||
store.update { it.copy(stage = SubscribeState.Stage.CANCELLING) }
|
||||
// TODO [alex] -- cancel api call
|
||||
}
|
||||
|
||||
fun onActivityResult(
|
||||
requestCode: Int,
|
||||
resultCode: Int,
|
||||
data: Intent?
|
||||
) {
|
||||
donationPaymentRepository.onActivityResult(
|
||||
requestCode, resultCode, data, this.fetchTokenRequestCode,
|
||||
object : GooglePayApi.PaymentRequestCallback {
|
||||
override fun onSuccess(paymentData: PaymentData) {
|
||||
val subscription = subscriptionToPurchase
|
||||
subscriptionToPurchase = null
|
||||
|
||||
if (subscription != null) {
|
||||
eventPublisher.onNext(DonationEvent.RequestTokenSuccess)
|
||||
donationPaymentRepository.continuePayment(subscription.price, paymentData).subscribeBy(
|
||||
onError = { eventPublisher.onNext(DonationEvent.PaymentConfirmationError(it)) },
|
||||
onComplete = {
|
||||
// Now we need to do the whole query for a token, submit token rigamarole
|
||||
eventPublisher.onNext(DonationEvent.PaymentConfirmationSuccess(subscription.badge))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError() {
|
||||
store.update { it.copy(stage = SubscribeState.Stage.PAYMENT_PIPELINE) }
|
||||
eventPublisher.onNext(DonationEvent.RequestTokenError)
|
||||
}
|
||||
|
||||
override fun onCancelled() {
|
||||
store.update { it.copy(stage = SubscribeState.Stage.PAYMENT_PIPELINE) }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun requestTokenFromGooglePay() {
|
||||
val snapshot = store.state
|
||||
if (snapshot.selectedSubscription == null) {
|
||||
return
|
||||
}
|
||||
|
||||
store.update { it.copy(stage = SubscribeState.Stage.PAYMENT_PIPELINE) }
|
||||
|
||||
subscriptionToPurchase = snapshot.selectedSubscription
|
||||
donationPaymentRepository.requestTokenFromGooglePay(snapshot.selectedSubscription.price, snapshot.selectedSubscription.title, fetchTokenRequestCode)
|
||||
}
|
||||
|
||||
fun setSelectedSubscription(subscription: Subscription) {
|
||||
store.update { it.copy(selectedSubscription = subscription) }
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val subscriptionsRepository: SubscriptionsRepository,
|
||||
private val donationPaymentRepository: DonationPaymentRepository,
|
||||
private val fetchTokenRequestCode: Int
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(SubscribeViewModel(subscriptionsRepository, donationPaymentRepository, fetchTokenRequestCode))!!
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.thanks
|
||||
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
|
||||
|
||||
class ThanksForYourSupportBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFragment() {
|
||||
|
||||
override val peekHeightPercentage: Float = 1f
|
||||
|
||||
private lateinit var displayOnProfileSwitch: SwitchMaterial
|
||||
private lateinit var heading: TextView
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(R.layout.thanks_for_your_support_bottom_sheet_dialog_fragment, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val badgeView: BadgeImageView = view.findViewById(R.id.thanks_bottom_sheet_badge)
|
||||
val badgeName: TextView = view.findViewById(R.id.thanks_bottom_sheet_badge_name)
|
||||
val done: MaterialButton = view.findViewById(R.id.thanks_bottom_sheet_done)
|
||||
|
||||
heading = view.findViewById(R.id.thanks_bottom_sheet_heading)
|
||||
displayOnProfileSwitch = view.findViewById(R.id.thanks_bottom_sheet_display_on_profile)
|
||||
|
||||
val args = ThanksForYourSupportBottomSheetDialogFragmentArgs.fromBundle(requireArguments())
|
||||
|
||||
badgeView.setBadge(args.badge)
|
||||
badgeName.text = args.badge.name
|
||||
displayOnProfileSwitch.isChecked = true
|
||||
|
||||
if (args.isBoost) {
|
||||
presentBoostCopy()
|
||||
} else {
|
||||
presentSubscriptionCopy()
|
||||
}
|
||||
|
||||
done.setOnClickListener { dismissAllowingStateLoss() }
|
||||
}
|
||||
|
||||
override fun onDismiss(dialog: DialogInterface) {
|
||||
val isDisplayOnProfile = displayOnProfileSwitch.isChecked
|
||||
// TODO [alex] -- Not sure what state we're in with regards to submitting the token.
|
||||
}
|
||||
|
||||
private fun presentBoostCopy() {
|
||||
heading.setText(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__thanks_for_the_boost)
|
||||
}
|
||||
|
||||
private fun presentSubscriptionCopy() {
|
||||
heading.setText(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__thanks_for_your_support)
|
||||
}
|
||||
}
|
||||
@@ -660,7 +660,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
val blockUnblockIcon = if (isBlocked) unblockIcon else blockIcon
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(title, titleTint),
|
||||
title = if (titleTint != null) DSLSettingsText.from(title, titleTint) else DSLSettingsText.from(title),
|
||||
icon = DSLSettingsIcon.from(blockUnblockIcon),
|
||||
onClick = {
|
||||
if (state.recipient.isBlocked) {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package org.thoughtcrime.securesms.components.settings
|
||||
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.annotation.Px
|
||||
import androidx.annotation.StringRes
|
||||
import org.thoughtcrime.securesms.components.settings.models.Button
|
||||
import org.thoughtcrime.securesms.components.settings.models.Space
|
||||
import org.thoughtcrime.securesms.components.settings.models.Text
|
||||
import org.thoughtcrime.securesms.util.MappingModel
|
||||
import org.thoughtcrime.securesms.util.MappingModelList
|
||||
|
||||
@@ -121,6 +125,35 @@ class DSLConfiguration {
|
||||
children.add(preference)
|
||||
}
|
||||
|
||||
fun noPadTextPref(title: DSLSettingsText) {
|
||||
val preference = Text(title)
|
||||
children.add(Text.Model(preference))
|
||||
}
|
||||
|
||||
fun space(@Px pixels: Int) {
|
||||
val preference = Space(pixels)
|
||||
children.add(Space.Model(preference))
|
||||
}
|
||||
|
||||
fun primaryButton(
|
||||
text: DSLSettingsText,
|
||||
isEnabled: Boolean = true,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val preference = Button.Model.Primary(text, null, isEnabled, onClick)
|
||||
children.add(preference)
|
||||
}
|
||||
|
||||
fun secondaryButtonNoOutline(
|
||||
text: DSLSettingsText,
|
||||
icon: DSLSettingsIcon? = null,
|
||||
isEnabled: Boolean = true,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val preference = Button.Model.SecondaryNoOutline(text, icon, isEnabled, onClick)
|
||||
children.add(preference)
|
||||
}
|
||||
|
||||
fun textPref(
|
||||
title: DSLSettingsText? = null,
|
||||
summary: DSLSettingsText? = null
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package org.thoughtcrime.securesms.components.settings.models
|
||||
|
||||
import android.view.View
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
object Button {
|
||||
|
||||
fun register(mappingAdapter: MappingAdapter) {
|
||||
mappingAdapter.registerFactory(Model.Primary::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) as MappingViewHolder<Model.Primary> }, R.layout.dsl_button_primary))
|
||||
mappingAdapter.registerFactory(Model.SecondaryNoOutline::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) as MappingViewHolder<Model.SecondaryNoOutline> }, R.layout.dsl_button_secondary))
|
||||
}
|
||||
|
||||
sealed class Model<T : Model<T>>(
|
||||
title: DSLSettingsText?,
|
||||
icon: DSLSettingsIcon?,
|
||||
isEnabled: Boolean,
|
||||
val onClick: () -> Unit
|
||||
) : PreferenceModel<T>(
|
||||
title = title,
|
||||
icon = icon,
|
||||
isEnabled = isEnabled
|
||||
) {
|
||||
class Primary(
|
||||
title: DSLSettingsText?,
|
||||
icon: DSLSettingsIcon?,
|
||||
isEnabled: Boolean,
|
||||
onClick: () -> Unit
|
||||
) : Model<Primary>(title, icon, isEnabled, onClick)
|
||||
|
||||
class SecondaryNoOutline(
|
||||
title: DSLSettingsText?,
|
||||
icon: DSLSettingsIcon?,
|
||||
isEnabled: Boolean,
|
||||
onClick: () -> Unit
|
||||
) : Model<SecondaryNoOutline>(title, icon, isEnabled, onClick)
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model<*>>(itemView) {
|
||||
|
||||
private val button: MaterialButton = itemView as MaterialButton
|
||||
|
||||
override fun bind(model: Model<*>) {
|
||||
button.text = model.title?.resolve(context)
|
||||
button.setOnClickListener {
|
||||
model.onClick()
|
||||
}
|
||||
button.icon = model.icon?.resolve(context)
|
||||
button.isEnabled = model.isEnabled
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.thoughtcrime.securesms.components.settings.models
|
||||
|
||||
import android.view.View
|
||||
import androidx.annotation.Px
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
/**
|
||||
* Adds extra space between elements in a DSL fragment
|
||||
*/
|
||||
data class Space(
|
||||
@Px val pixels: Int
|
||||
) {
|
||||
|
||||
companion object {
|
||||
fun register(mappingAdapter: MappingAdapter) {
|
||||
mappingAdapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.dsl_space_preference))
|
||||
}
|
||||
}
|
||||
|
||||
class Model(val space: Space) : PreferenceModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return super.areContentsTheSame(newItem) && newItem.space == space
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
override fun bind(model: Model) {
|
||||
itemView.updateLayoutParams {
|
||||
height = model.space.pixels
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.thoughtcrime.securesms.components.settings.models
|
||||
|
||||
import android.text.Spanned
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.text.style.ClickableSpan
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
/**
|
||||
* A Text without any padding, allowing for exact padding to be handed in at runtime.
|
||||
*/
|
||||
data class Text(
|
||||
val text: DSLSettingsText,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
fun register(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.dsl_text_preference))
|
||||
}
|
||||
}
|
||||
|
||||
class Model(val paddableText: Text) : PreferenceModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return super.areContentsTheSame(newItem) && newItem.paddableText == paddableText
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val text: TextView = itemView.findViewById(R.id.title)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
text.text = model.paddableText.text.resolve(context)
|
||||
|
||||
val clickableSpans = (text.text as? Spanned)?.getSpans(0, text.text.length, ClickableSpan::class.java)
|
||||
if (clickableSpans?.isEmpty() == false) {
|
||||
text.movementMethod = LinkMovementMethod.getInstance()
|
||||
} else {
|
||||
text.movementMethod = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package org.thoughtcrime.securesms.keyvalue
|
||||
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.subjects.BehaviorSubject
|
||||
import io.reactivex.rxjava3.subjects.Subject
|
||||
import org.signal.donations.StripeApi
|
||||
import java.util.Currency
|
||||
import java.util.Locale
|
||||
|
||||
internal class DonationsValues internal constructor(store: KeyValueStore) : SignalStoreValues(store) {
|
||||
|
||||
companion object {
|
||||
private const val KEY_CURRENCY_CODE = "donation.currency.code"
|
||||
}
|
||||
|
||||
override fun onFirstEverAppLaunch() = Unit
|
||||
|
||||
override fun getKeysToIncludeInBackup(): MutableList<String> = mutableListOf(KEY_CURRENCY_CODE)
|
||||
|
||||
private val currencyPublisher: Subject<Currency> = BehaviorSubject.createDefault(getCurrency())
|
||||
val observableCurrency: Observable<Currency> = currencyPublisher
|
||||
|
||||
fun getCurrency(): Currency {
|
||||
val currencyCode = getString(KEY_CURRENCY_CODE, null)
|
||||
val currency = if (currencyCode == null) {
|
||||
Currency.getInstance(Locale.getDefault())
|
||||
} else {
|
||||
Currency.getInstance(currencyCode)
|
||||
}
|
||||
|
||||
return if (StripeApi.Validation.supportedCurrencyCodes.contains(currency.currencyCode)) {
|
||||
currency
|
||||
} else {
|
||||
Currency.getInstance("USD")
|
||||
}
|
||||
}
|
||||
|
||||
fun setCurrency(currency: Currency) {
|
||||
putString(KEY_CURRENCY_CODE, currency.currencyCode)
|
||||
currencyPublisher.onNext(currency)
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ public final class SignalStore {
|
||||
private final OnboardingValues onboardingValues;
|
||||
private final WallpaperValues wallpaperValues;
|
||||
private final PaymentsValues paymentsValues;
|
||||
private final DonationsValues donationsValues;
|
||||
private final ProxyValues proxyValues;
|
||||
private final RateLimitValues rateLimitValues;
|
||||
private final ChatColorsValues chatColorsValues;
|
||||
@@ -57,6 +58,7 @@ public final class SignalStore {
|
||||
this.onboardingValues = new OnboardingValues(store);
|
||||
this.wallpaperValues = new WallpaperValues(store);
|
||||
this.paymentsValues = new PaymentsValues(store);
|
||||
this.donationsValues = new DonationsValues(store);
|
||||
this.proxyValues = new ProxyValues(store);
|
||||
this.rateLimitValues = new RateLimitValues(store);
|
||||
this.chatColorsValues = new ChatColorsValues(store);
|
||||
@@ -80,6 +82,7 @@ public final class SignalStore {
|
||||
onboarding().onFirstEverAppLaunch();
|
||||
wallpaper().onFirstEverAppLaunch();
|
||||
paymentsValues().onFirstEverAppLaunch();
|
||||
donationsValues().onFirstEverAppLaunch();
|
||||
proxy().onFirstEverAppLaunch();
|
||||
rateLimit().onFirstEverAppLaunch();
|
||||
chatColorsValues().onFirstEverAppLaunch();
|
||||
@@ -104,6 +107,7 @@ public final class SignalStore {
|
||||
keys.addAll(onboarding().getKeysToIncludeInBackup());
|
||||
keys.addAll(wallpaper().getKeysToIncludeInBackup());
|
||||
keys.addAll(paymentsValues().getKeysToIncludeInBackup());
|
||||
keys.addAll(donationsValues().getKeysToIncludeInBackup());
|
||||
keys.addAll(proxy().getKeysToIncludeInBackup());
|
||||
keys.addAll(rateLimit().getKeysToIncludeInBackup());
|
||||
keys.addAll(chatColorsValues().getKeysToIncludeInBackup());
|
||||
@@ -184,6 +188,10 @@ public final class SignalStore {
|
||||
return INSTANCE.paymentsValues;
|
||||
}
|
||||
|
||||
public static @NonNull DonationsValues donationsValues() {
|
||||
return INSTANCE.donationsValues;
|
||||
}
|
||||
|
||||
public static @NonNull ProxyValues proxy() {
|
||||
return INSTANCE.proxyValues;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package org.thoughtcrime.securesms.subscription
|
||||
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Represents a Subscription that a user can start.
|
||||
*/
|
||||
data class Subscription(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val badge: Badge,
|
||||
val price: FiatMoney,
|
||||
) {
|
||||
|
||||
val renewalTimestamp = badge.expirationTimestamp
|
||||
|
||||
companion object {
|
||||
fun register(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.subscription_preference))
|
||||
}
|
||||
}
|
||||
|
||||
class Model(
|
||||
val subscription: Subscription,
|
||||
val isSelected: Boolean,
|
||||
val isActive: Boolean,
|
||||
override val isEnabled: Boolean,
|
||||
val onClick: () -> Unit
|
||||
) : PreferenceModel<Model>(isEnabled = isEnabled) {
|
||||
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return subscription.id == newItem.subscription.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return super.areContentsTheSame(newItem) &&
|
||||
newItem.subscription == subscription &&
|
||||
newItem.isSelected == isSelected &&
|
||||
newItem.isActive == isActive
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val badge: BadgeImageView = itemView.findViewById(R.id.badge)
|
||||
private val title: TextView = itemView.findViewById(R.id.title)
|
||||
private val tagline: TextView = itemView.findViewById(R.id.tagline)
|
||||
private val price: TextView = itemView.findViewById(R.id.price)
|
||||
private val check: ImageView = itemView.findViewById(R.id.check)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
itemView.isEnabled = model.isEnabled
|
||||
itemView.setOnClickListener { model.onClick() }
|
||||
itemView.isSelected = model.isSelected
|
||||
badge.setBadge(model.subscription.badge)
|
||||
title.text = model.subscription.title
|
||||
tagline.text = model.subscription.id
|
||||
|
||||
val formattedPrice = FiatMoneyUtil.format(
|
||||
context.resources,
|
||||
model.subscription.price,
|
||||
FiatMoneyUtil.formatOptions()
|
||||
)
|
||||
|
||||
if (model.isActive) {
|
||||
price.text = context.getString(
|
||||
R.string.Subscription__s_per_month_dot_renews_s,
|
||||
formattedPrice,
|
||||
DateUtils.formatDate(Locale.getDefault(), model.subscription.renewalTimestamp)
|
||||
)
|
||||
} else {
|
||||
price.text = context.getString(
|
||||
R.string.Subscription__s_per_month,
|
||||
formattedPrice
|
||||
)
|
||||
}
|
||||
|
||||
check.visible = model.isActive
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import androidx.lifecycle.LifecycleOwner;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
public abstract class MappingViewHolder<Model extends MappingModel<Model>> extends LifecycleViewHolder implements LifecycleOwner {
|
||||
public abstract class MappingViewHolder<Model> extends LifecycleViewHolder implements LifecycleOwner {
|
||||
|
||||
protected final Context context;
|
||||
protected final List<Object> payload;
|
||||
@@ -36,7 +36,7 @@ public abstract class MappingViewHolder<Model extends MappingModel<Model>> exten
|
||||
this.payload.addAll(payload);
|
||||
}
|
||||
|
||||
public static final class SimpleViewHolder<Model extends MappingModel<Model>> extends MappingViewHolder<Model> {
|
||||
public static final class SimpleViewHolder<Model> extends MappingViewHolder<Model> {
|
||||
public SimpleViewHolder(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.res.Resources;
|
||||
import android.graphics.Typeface;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.os.Build;
|
||||
import android.text.Layout;
|
||||
import android.text.Spannable;
|
||||
import android.text.SpannableString;
|
||||
import android.text.SpannableStringBuilder;
|
||||
@@ -12,6 +13,7 @@ import android.text.Spanned;
|
||||
import android.text.TextPaint;
|
||||
import android.text.TextUtils;
|
||||
import android.text.style.AbsoluteSizeSpan;
|
||||
import android.text.style.AlignmentSpan;
|
||||
import android.text.style.BulletSpan;
|
||||
import android.text.style.CharacterStyle;
|
||||
import android.text.style.ClickableSpan;
|
||||
@@ -20,12 +22,14 @@ import android.text.style.ForegroundColorSpan;
|
||||
import android.text.style.ImageSpan;
|
||||
import android.text.style.RelativeSizeSpan;
|
||||
import android.text.style.StyleSpan;
|
||||
import android.text.style.TextAppearanceSpan;
|
||||
import android.text.style.TypefaceSpan;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.annotation.StyleRes;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
@@ -40,6 +44,18 @@ public final class SpanUtil {
|
||||
private final static Typeface BOLD_TYPEFACE = Typeface.create("sans-serif-medium", Typeface.NORMAL);
|
||||
private final static Typeface LIGHT_TYPEFACE = Typeface.create("sans-serif", Typeface.NORMAL);
|
||||
|
||||
public static CharSequence center(@NonNull CharSequence sequence) {
|
||||
SpannableString spannable = new SpannableString(sequence);
|
||||
spannable.setSpan(new AlignmentSpan.Standard(Layout.Alignment.ALIGN_CENTER), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
return spannable;
|
||||
}
|
||||
|
||||
public static CharSequence textAppearance(@NonNull Context context, @StyleRes int textAppearance, @NonNull CharSequence sequence) {
|
||||
SpannableString spannable = new SpannableString(sequence);
|
||||
spannable.setSpan(new TextAppearanceSpan(context, textAppearance), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
return spannable;
|
||||
}
|
||||
|
||||
public static CharSequence italic(CharSequence sequence) {
|
||||
return italic(sequence, sequence.length());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user