Implement the majority of the Donor UI.

This commit is contained in:
Alex Hart
2021-10-12 15:55:54 -03:00
committed by GitHub
parent 6cbc2f684d
commit 43e4cba3d7
96 changed files with 3601 additions and 266 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
enum class ManageDonationsEvent {
NOT_SUBSCRIBED,
ERROR_GETTING_SUBSCRIPTION
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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