Implement the majority of the Donor UI.
This commit is contained in:
parent
6cbc2f684d
commit
43e4cba3d7
96 changed files with 3601 additions and 266 deletions
|
@ -179,6 +179,7 @@ android {
|
|||
buildConfigField "String", "BUILD_ENVIRONMENT_TYPE", "\"unset\""
|
||||
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"unset\""
|
||||
buildConfigField "String", "BADGE_STATIC_ROOT", "\"https://updates2.signal.org/static/badges/\""
|
||||
buildConfigField "String", "STRIPE_PUBLISHABLE_KEY", "\"pk_test_sngOd8FnXNkpce9nPXawKrJD00kIDngZkD\""
|
||||
|
||||
ndk {
|
||||
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
|
||||
|
@ -456,6 +457,7 @@ dependencies {
|
|||
implementation project(':video')
|
||||
implementation project(':device-transfer')
|
||||
implementation project(':image-editor')
|
||||
implementation project(':donations')
|
||||
|
||||
implementation libs.signal.zkgroup.android
|
||||
implementation libs.signal.client.android
|
||||
|
@ -546,6 +548,8 @@ dependencies {
|
|||
androidTestImplementation testLibs.androidx.test.ext.junit
|
||||
androidTestImplementation testLibs.espresso.core
|
||||
|
||||
testImplementation testLibs.espresso.core
|
||||
|
||||
implementation libs.kotlin.stdlib.jdk8
|
||||
implementation libs.kotlin.reflect
|
||||
implementation libs.jackson.module.kotlin
|
||||
|
|
|
@ -101,6 +101,10 @@
|
|||
android:theme="@style/TextSecure.LightTheme"
|
||||
android:largeHeap="true">
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.wallet.api.enabled"
|
||||
android:value="true" />
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.android.geo.API_KEY"
|
||||
android:value="AIzaSyCSx9xea86GwDKGznCAULE9Y5a8b-TfN9U"/>
|
||||
|
|
|
@ -44,7 +44,6 @@ class BadgeImageView @JvmOverloads constructor(
|
|||
|
||||
val lifecycle = ViewUtil.getActivityLifecycle(this)
|
||||
if (lifecycle?.currentState == Lifecycle.State.DESTROYED) {
|
||||
Log.w(TAG, "Ignoring setBadge call for destroyed activity.")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -1,49 +1,16 @@
|
|||
package org.thoughtcrime.securesms.badges
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.Px
|
||||
import androidx.core.graphics.withScale
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.airbnb.lottie.SimpleColorFilter
|
||||
import com.google.android.flexbox.AlignItems
|
||||
import com.google.android.flexbox.FlexDirection
|
||||
import com.google.android.flexbox.FlexboxLayoutManager
|
||||
import com.google.android.flexbox.JustifyContent
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.badges.models.BadgeAnimator
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.util.customizeOnDraw
|
||||
|
||||
object Badges {
|
||||
fun Drawable.selectable(
|
||||
@Px outlineWidth: Float,
|
||||
@ColorInt outlineColor: Int,
|
||||
animator: BadgeAnimator
|
||||
): Drawable {
|
||||
val outline = mutate().constantState?.newDrawable()?.mutate()
|
||||
outline?.colorFilter = SimpleColorFilter(outlineColor)
|
||||
|
||||
return customizeOnDraw { wrapped, canvas ->
|
||||
outline?.bounds = wrapped.bounds
|
||||
|
||||
outline?.draw(canvas)
|
||||
|
||||
val scale = 1 - ((outlineWidth * 2) / wrapped.bounds.width())
|
||||
val interpolatedScale = scale + (1f - scale) * animator.getFraction()
|
||||
|
||||
canvas.withScale(x = interpolatedScale, y = interpolatedScale, wrapped.bounds.width() / 2f, wrapped.bounds.height() / 2f) {
|
||||
wrapped.draw(canvas)
|
||||
}
|
||||
|
||||
if (animator.shouldInvalidate()) {
|
||||
invalidateSelf()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun DSLConfiguration.displayBadges(context: Context, badges: List<Badge>, selectedBadge: Badge? = null) {
|
||||
badges
|
||||
.map { Badge.Model(it, it == selectedBadge) }
|
||||
|
|
|
@ -1,21 +1,16 @@
|
|||
package org.thoughtcrime.securesms.badges.models
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.animation.ObjectAnimator
|
||||
import android.net.Uri
|
||||
import android.os.Parcelable
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.bumptech.glide.load.Key
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||
import com.bumptech.glide.request.target.CustomViewTarget
|
||||
import com.bumptech.glide.request.transition.Transition
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.Badges.selectable
|
||||
import org.thoughtcrime.securesms.badges.glide.BadgeSpriteTransformation
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.mms.GlideApp
|
||||
|
@ -41,6 +36,8 @@ data class Badge(
|
|||
val visible: Boolean,
|
||||
) : Parcelable, Key {
|
||||
|
||||
fun isExpired(): Boolean = expirationTimestamp < System.currentTimeMillis()
|
||||
|
||||
override fun updateDiskCacheKey(messageDigest: MessageDigest) {
|
||||
messageDigest.update(id.toByteArray(Key.CHARSET))
|
||||
messageDigest.update(imageUrl.toString().toByteArray(Key.CHARSET))
|
||||
|
@ -94,35 +91,47 @@ data class Badge(
|
|||
|
||||
class ViewHolder(itemView: View, private val onBadgeClicked: OnBadgeClicked) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val check: ImageView = itemView.findViewById(R.id.checkmark)
|
||||
private val badge: ImageView = itemView.findViewById(R.id.badge)
|
||||
private val name: TextView = itemView.findViewById(R.id.name)
|
||||
private val target = Target(badge)
|
||||
|
||||
private var checkAnimator: ObjectAnimator? = null
|
||||
|
||||
init {
|
||||
check.isSelected = true
|
||||
}
|
||||
|
||||
override fun bind(model: Model) {
|
||||
itemView.setOnClickListener {
|
||||
onBadgeClicked(model.badge, model.isSelected)
|
||||
}
|
||||
|
||||
checkAnimator?.cancel()
|
||||
if (payload.isNotEmpty()) {
|
||||
if (model.isSelected) {
|
||||
target.animateToStart()
|
||||
checkAnimator = if (model.isSelected) {
|
||||
ObjectAnimator.ofFloat(check, "alpha", 1f)
|
||||
} else {
|
||||
target.animateToEnd()
|
||||
ObjectAnimator.ofFloat(check, "alpha", 0f)
|
||||
}
|
||||
checkAnimator?.start()
|
||||
return
|
||||
}
|
||||
|
||||
badge.alpha = if (model.badge.isExpired()) 0.5f else 1f
|
||||
|
||||
GlideApp.with(badge)
|
||||
.load(model.badge)
|
||||
.downsample(DownsampleStrategy.NONE)
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.transform(BadgeSpriteTransformation(BadgeSpriteTransformation.Size.XLARGE, model.badge.imageDensity, ThemeUtil.isDarkTheme(context)))
|
||||
.into(target)
|
||||
.transform(
|
||||
BadgeSpriteTransformation(BadgeSpriteTransformation.Size.XLARGE, model.badge.imageDensity, ThemeUtil.isDarkTheme(context)),
|
||||
)
|
||||
.into(badge)
|
||||
|
||||
if (model.isSelected) {
|
||||
target.setAnimationToStart()
|
||||
check.alpha = 1f
|
||||
} else {
|
||||
target.setAnimationToEnd()
|
||||
check.alpha = 0f
|
||||
}
|
||||
|
||||
name.text = model.badge.name
|
||||
|
@ -145,49 +154,6 @@ data class Badge(
|
|||
}
|
||||
}
|
||||
|
||||
private class Target(view: ImageView) : CustomViewTarget<ImageView, Drawable>(view) {
|
||||
|
||||
private val animator: BadgeAnimator = BadgeAnimator()
|
||||
|
||||
override fun onLoadFailed(errorDrawable: Drawable?) {
|
||||
view.setImageDrawable(errorDrawable)
|
||||
}
|
||||
|
||||
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
|
||||
val drawable = resource.selectable(
|
||||
DimensionUnit.DP.toPixels(2.5f),
|
||||
ContextCompat.getColor(view.context, R.color.signal_inverse_primary),
|
||||
animator
|
||||
)
|
||||
|
||||
view.setImageDrawable(drawable)
|
||||
}
|
||||
|
||||
override fun onResourceCleared(placeholder: Drawable?) {
|
||||
view.setImageDrawable(placeholder)
|
||||
}
|
||||
|
||||
fun setAnimationToStart() {
|
||||
animator.setState(BadgeAnimator.State.START)
|
||||
view.drawable?.invalidateSelf()
|
||||
}
|
||||
|
||||
fun setAnimationToEnd() {
|
||||
animator.setState(BadgeAnimator.State.END)
|
||||
view.drawable?.invalidateSelf()
|
||||
}
|
||||
|
||||
fun animateToStart() {
|
||||
animator.setState(BadgeAnimator.State.REVERSE)
|
||||
view.drawable?.invalidateSelf()
|
||||
}
|
||||
|
||||
fun animateToEnd() {
|
||||
animator.setState(BadgeAnimator.State.FORWARD)
|
||||
view.drawable?.invalidateSelf()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val SELECTION_CHANGED = Any()
|
||||
|
||||
|
|
|
@ -1,97 +0,0 @@
|
|||
package org.thoughtcrime.securesms.badges.models
|
||||
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
|
||||
class BadgeAnimator {
|
||||
|
||||
val duration = 250L
|
||||
|
||||
var state: State = State.START
|
||||
private set
|
||||
|
||||
private var startTime: Long = 0L
|
||||
|
||||
fun getFraction(): Float {
|
||||
return when (state) {
|
||||
State.START -> 0f
|
||||
State.END -> 1f
|
||||
State.FORWARD -> Util.clamp((System.currentTimeMillis() - startTime) / duration.toFloat(), 0f, 1f)
|
||||
State.REVERSE -> 1f - Util.clamp((System.currentTimeMillis() - startTime) / duration.toFloat(), 0f, 1f)
|
||||
}
|
||||
}
|
||||
|
||||
fun setState(newState: State) {
|
||||
shouldInvalidate()
|
||||
|
||||
if (state == newState) {
|
||||
return
|
||||
}
|
||||
|
||||
if (newState == State.END || newState == State.START) {
|
||||
state = newState
|
||||
startTime = 0L
|
||||
return
|
||||
}
|
||||
|
||||
if (state == State.START && newState == State.REVERSE) {
|
||||
return
|
||||
}
|
||||
|
||||
if (state == State.END && newState == State.FORWARD) {
|
||||
return
|
||||
}
|
||||
|
||||
if (state == State.START && newState == State.FORWARD) {
|
||||
state = State.FORWARD
|
||||
startTime = System.currentTimeMillis()
|
||||
return
|
||||
}
|
||||
|
||||
if (state == State.END && newState == State.REVERSE) {
|
||||
state = State.REVERSE
|
||||
startTime = System.currentTimeMillis()
|
||||
return
|
||||
}
|
||||
|
||||
if (state == State.FORWARD && newState == State.REVERSE) {
|
||||
val elapsed = System.currentTimeMillis() - startTime
|
||||
val delta = duration - elapsed
|
||||
startTime -= delta
|
||||
state = State.REVERSE
|
||||
return
|
||||
}
|
||||
|
||||
if (state == State.REVERSE && newState == State.FORWARD) {
|
||||
val elapsed = System.currentTimeMillis() - startTime
|
||||
val delta = duration - elapsed
|
||||
startTime -= delta
|
||||
state = State.FORWARD
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
fun shouldInvalidate(): Boolean {
|
||||
if (state == State.START || state == State.END) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (state == State.FORWARD && getFraction() == 1f) {
|
||||
state = State.END
|
||||
return false
|
||||
}
|
||||
|
||||
if (state == State.REVERSE && getFraction() == 0f) {
|
||||
state = State.START
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
enum class State {
|
||||
START,
|
||||
FORWARD,
|
||||
REVERSE,
|
||||
END
|
||||
}
|
||||
}
|
|
@ -9,13 +9,18 @@ import org.thoughtcrime.securesms.recipients.Recipient
|
|||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
object FeaturedBadgePreview {
|
||||
object BadgePreview {
|
||||
|
||||
fun register(mappingAdapter: MappingAdapter) {
|
||||
mappingAdapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.featured_badge_preview_preference))
|
||||
mappingAdapter.registerFactory(SubscriptionModel::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.subscription_flow_badge_preview_preference))
|
||||
}
|
||||
|
||||
data class Model(val badge: Badge?) : PreferenceModel<Model>() {
|
||||
abstract class BadgeModel<T : BadgeModel<T>> : PreferenceModel<T>() {
|
||||
abstract val badge: Badge?
|
||||
}
|
||||
|
||||
data class Model(override val badge: Badge?) : BadgeModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return newItem.badge?.id == badge?.id
|
||||
}
|
||||
|
@ -25,12 +30,22 @@ object FeaturedBadgePreview {
|
|||
}
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
data class SubscriptionModel(override val badge: Badge?) : BadgeModel<SubscriptionModel>() {
|
||||
override fun areItemsTheSame(newItem: SubscriptionModel): Boolean {
|
||||
return newItem.badge?.id == badge?.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: SubscriptionModel): Boolean {
|
||||
return super.areContentsTheSame(newItem) && badge == newItem.badge
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder<T : BadgeModel<T>>(itemView: View) : MappingViewHolder<T>(itemView) {
|
||||
|
||||
private val avatar: AvatarImageView = itemView.findViewById(R.id.avatar)
|
||||
private val badge: BadgeImageView = itemView.findViewById(R.id.badge)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
override fun bind(model: T) {
|
||||
avatar.setRecipient(Recipient.self())
|
||||
avatar.disableQuickContact()
|
||||
badge.setBadge(model.badge)
|
|
@ -0,0 +1,34 @@
|
|||
package org.thoughtcrime.securesms.badges.models
|
||||
|
||||
import android.view.View
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
object ExpiredBadge {
|
||||
|
||||
class Model(val badge: Badge) : PreferenceModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return newItem.badge.id == badge.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return super.areContentsTheSame(newItem) && newItem.badge == badge
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val badge: BadgeImageView = itemView.findViewById(R.id.expired_badge)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
badge.setBadge(model.badge)
|
||||
}
|
||||
}
|
||||
|
||||
fun register(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.expired_badge_preference))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
package org.thoughtcrime.securesms.badges.self.expired
|
||||
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.models.ExpiredBadge
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
|
||||
/**
|
||||
* Bottom sheet displaying a fading badge with a notice and action for becoming a subscriber again.
|
||||
*/
|
||||
class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
|
||||
peekHeightPercentage = 1f
|
||||
) {
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
ExpiredBadge.register(adapter)
|
||||
|
||||
adapter.submitList(getConfiguration().toMappingModelList())
|
||||
}
|
||||
|
||||
private fun getConfiguration(): DSLConfiguration {
|
||||
val badge = ExpiredBadgeBottomSheetDialogFragmentArgs.fromBundle(requireArguments()).badge
|
||||
|
||||
return configure {
|
||||
customPref(ExpiredBadge.Model(badge))
|
||||
|
||||
sectionHeaderPref(R.string.ExpiredBadgeBottomSheetDialogFragment__your_badge_has_expired)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(4f).toInt())
|
||||
|
||||
noPadTextPref(
|
||||
DSLSettingsText.from(
|
||||
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_s_badge_has_expired, badge.name),
|
||||
DSLSettingsText.CenterModifier
|
||||
)
|
||||
)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(16f).toInt())
|
||||
|
||||
noPadTextPref(
|
||||
DSLSettingsText.from(
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__to_continue_supporting,
|
||||
DSLSettingsText.CenterModifier
|
||||
)
|
||||
)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(92f).toInt())
|
||||
|
||||
primaryButton(
|
||||
text = DSLSettingsText.from(R.string.ExpiredBadgeBottomSheetDialogFragment__become_a_subscriber),
|
||||
onClick = {
|
||||
dismiss()
|
||||
findNavController().navigate(R.id.action_directly_to_subscribe)
|
||||
}
|
||||
)
|
||||
|
||||
secondaryButtonNoOutline(
|
||||
text = DSLSettingsText.from(R.string.ExpiredBadgeBottomSheetDialogFragment__not_now),
|
||||
onClick = {
|
||||
dismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -10,7 +10,7 @@ import org.thoughtcrime.securesms.badges.BadgeRepository
|
|||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.badges.Badges.displayBadges
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.badges.models.FeaturedBadgePreview
|
||||
import org.thoughtcrime.securesms.badges.models.BadgePreview
|
||||
import org.thoughtcrime.securesms.components.recyclerview.OnScrollAnimationHelper
|
||||
import org.thoughtcrime.securesms.components.recyclerview.ToolbarShadowAnimationHelper
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
|
@ -58,7 +58,7 @@ class SelectFeaturedBadgeFragment : DSLSettingsFragment(
|
|||
}
|
||||
|
||||
val previewView: View = requireView().findViewById(R.id.preview)
|
||||
val previewViewHolder = FeaturedBadgePreview.ViewHolder(previewView)
|
||||
val previewViewHolder = BadgePreview.ViewHolder<BadgePreview.Model>(previewView)
|
||||
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
|
||||
lifecycleDisposable += viewModel.events.subscribe { event: SelectFeaturedBadgeEvent ->
|
||||
|
@ -71,7 +71,7 @@ class SelectFeaturedBadgeFragment : DSLSettingsFragment(
|
|||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
save.isEnabled = state.stage == SelectFeaturedBadgeState.Stage.READY
|
||||
previewViewHolder.bind(FeaturedBadgePreview.Model(state.selectedBadge))
|
||||
previewViewHolder.bind(BadgePreview.Model(state.selectedBadge))
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,10 +29,11 @@ class SelectFeaturedBadgeViewModel(private val repository: BadgeRepository) : Vi
|
|||
|
||||
init {
|
||||
store.update(Recipient.live(Recipient.self().id).liveDataResolved) { recipient, state ->
|
||||
val unexpiredBadges = recipient.badges.filterNot { it.isExpired() }
|
||||
state.copy(
|
||||
stage = if (state.stage == SelectFeaturedBadgeState.Stage.INIT) SelectFeaturedBadgeState.Stage.READY else state.stage,
|
||||
selectedBadge = recipient.badges.firstOrNull(),
|
||||
allUnlockedBadges = recipient.badges
|
||||
selectedBadge = unexpiredBadges.firstOrNull(),
|
||||
allUnlockedBadges = unexpiredBadges
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -30,7 +30,11 @@ class BadgesOverviewFragment : DSLSettingsFragment(
|
|||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
Badge.register(adapter) { badge, _ ->
|
||||
ViewBadgeBottomSheetDialogFragment.show(parentFragmentManager, Recipient.self().id, badge)
|
||||
if (badge.isExpired()) {
|
||||
findNavController().navigate(BadgesOverviewFragmentDirections.actionBadgeManageFragmentToExpiredBadgeDialog(badge))
|
||||
} else {
|
||||
ViewBadgeBottomSheetDialogFragment.show(parentFragmentManager, Recipient.self().id, badge)
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
|
||||
|
@ -57,6 +61,7 @@ class BadgesOverviewFragment : DSLSettingsFragment(
|
|||
switchPref(
|
||||
title = DSLSettingsText.from(R.string.BadgesOverviewFragment__display_badges_on_profile),
|
||||
isChecked = state.displayBadgesOnProfile,
|
||||
isEnabled = state.stage == BadgesOverviewState.Stage.READY && state.hasUnexpiredBadges,
|
||||
onClick = {
|
||||
viewModel.setDisplayBadgesOnProfile(!state.displayBadgesOnProfile)
|
||||
}
|
||||
|
@ -65,7 +70,7 @@ class BadgesOverviewFragment : DSLSettingsFragment(
|
|||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.BadgesOverviewFragment__featured_badge),
|
||||
summary = state.featuredBadge?.name?.let { DSLSettingsText.from(it) },
|
||||
isEnabled = state.stage == BadgesOverviewState.Stage.READY,
|
||||
isEnabled = state.stage == BadgesOverviewState.Stage.READY && state.hasUnexpiredBadges,
|
||||
onClick = {
|
||||
findNavController().navigate(BadgesOverviewFragmentDirections.actionBadgeManageFragmentToFeaturedBadgeFragment())
|
||||
}
|
||||
|
|
|
@ -6,8 +6,11 @@ data class BadgesOverviewState(
|
|||
val stage: Stage = Stage.INIT,
|
||||
val allUnlockedBadges: List<Badge> = listOf(),
|
||||
val featuredBadge: Badge? = null,
|
||||
val displayBadgesOnProfile: Boolean = false
|
||||
val displayBadgesOnProfile: Boolean = false,
|
||||
) {
|
||||
|
||||
val hasUnexpiredBadges = allUnlockedBadges.any { it.expirationTimestamp > System.currentTimeMillis() }
|
||||
|
||||
enum class Stage {
|
||||
INIT,
|
||||
READY,
|
||||
|
|
|
@ -29,7 +29,8 @@ class BadgesOverviewViewModel(private val badgeRepository: BadgeRepository) : Vi
|
|||
state.copy(
|
||||
stage = if (state.stage == BadgesOverviewState.Stage.INIT) BadgesOverviewState.Stage.READY else state.stage,
|
||||
allUnlockedBadges = recipient.badges,
|
||||
displayBadgesOnProfile = recipient.badges.firstOrNull()?.visible == true
|
||||
displayBadgesOnProfile = recipient.badges.firstOrNull()?.visible == true,
|
||||
featuredBadge = recipient.featuredBadge
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -68,6 +68,8 @@ class ViewBadgeBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFr
|
|||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
tabs.visible = state.allBadgesVisibleOnProfile.size > 1
|
||||
|
||||
adapter.submitList(
|
||||
state.allBadgesVisibleOnProfile.map {
|
||||
LargeBadge.Model(LargeBadge(it), state.recipient.getShortDisplayName(requireContext()))
|
||||
|
|
|
@ -13,6 +13,9 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|||
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.models.Button
|
||||
import org.thoughtcrime.securesms.components.settings.models.Space
|
||||
import org.thoughtcrime.securesms.components.settings.models.Text
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
@ -31,6 +34,9 @@ class DSLSettingsAdapter : MappingAdapter() {
|
|||
registerFactory(SectionHeaderPreference::class.java, LayoutFactory(::SectionHeaderPreferenceViewHolder, R.layout.dsl_section_header))
|
||||
registerFactory(SwitchPreference::class.java, LayoutFactory(::SwitchPreferenceViewHolder, R.layout.dsl_switch_preference_item))
|
||||
registerFactory(RadioPreference::class.java, LayoutFactory(::RadioPreferenceViewHolder, R.layout.dsl_radio_preference_item))
|
||||
Text.register(this)
|
||||
Space.register(this)
|
||||
Button.register(this)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
package org.thoughtcrime.securesms.components.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.EdgeEffect
|
||||
import androidx.annotation.LayoutRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
|
||||
|
||||
abstract class DSLSettingsBottomSheetFragment(
|
||||
@LayoutRes private val layoutId: Int = R.layout.dsl_settings_bottom_sheet,
|
||||
val layoutManagerProducer: (Context) -> RecyclerView.LayoutManager = { context -> LinearLayoutManager(context) },
|
||||
override val peekHeightPercentage: Float = 1f
|
||||
) : FixedRoundedCornerBottomSheetDialogFragment() {
|
||||
|
||||
private lateinit var recyclerView: RecyclerView
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(layoutId, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
recyclerView = view.findViewById(R.id.recycler)
|
||||
recyclerView.edgeEffectFactory = EdgeEffectFactory()
|
||||
val adapter = DSLSettingsAdapter()
|
||||
|
||||
recyclerView.layoutManager = layoutManagerProducer(requireContext())
|
||||
recyclerView.adapter = adapter
|
||||
|
||||
bindAdapter(adapter)
|
||||
}
|
||||
|
||||
abstract fun bindAdapter(adapter: DSLSettingsAdapter)
|
||||
|
||||
private class EdgeEffectFactory : RecyclerView.EdgeEffectFactory() {
|
||||
override fun createEdgeEffect(view: RecyclerView, direction: Int): EdgeEffect {
|
||||
return super.createEdgeEffect(view, direction).apply {
|
||||
if (Build.VERSION.SDK_INT > 21) {
|
||||
color =
|
||||
requireNotNull(ContextCompat.getColor(view.context, R.color.settings_ripple_color))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -3,35 +3,71 @@ package org.thoughtcrime.securesms.components.settings
|
|||
import android.content.Context
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.annotation.StyleRes
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
|
||||
sealed class DSLSettingsText {
|
||||
|
||||
protected abstract val modifiers: List<Modifier>
|
||||
|
||||
private data class FromResource(
|
||||
@StringRes private val stringId: Int,
|
||||
@ColorInt private val textColor: Int?
|
||||
override val modifiers: List<Modifier>
|
||||
) : DSLSettingsText() {
|
||||
override fun resolve(context: Context): CharSequence {
|
||||
val text = context.getString(stringId)
|
||||
|
||||
return if (textColor == null) {
|
||||
text
|
||||
} else {
|
||||
SpanUtil.color(textColor, text)
|
||||
}
|
||||
override fun getCharSequence(context: Context): CharSequence {
|
||||
return context.getString(stringId)
|
||||
}
|
||||
}
|
||||
|
||||
private data class FromCharSequence(private val charSequence: CharSequence) : DSLSettingsText() {
|
||||
override fun resolve(context: Context): CharSequence = charSequence
|
||||
private data class FromCharSequence(
|
||||
private val charSequence: CharSequence,
|
||||
override val modifiers: List<Modifier>
|
||||
) : DSLSettingsText() {
|
||||
override fun getCharSequence(context: Context): CharSequence = charSequence
|
||||
}
|
||||
|
||||
abstract fun resolve(context: Context): CharSequence
|
||||
protected abstract fun getCharSequence(context: Context): CharSequence
|
||||
|
||||
fun resolve(context: Context): CharSequence {
|
||||
val text: CharSequence = getCharSequence(context)
|
||||
return modifiers.fold(text) { t, m -> m.modify(context, t) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(@StringRes stringId: Int, @ColorInt textColor: Int? = null): DSLSettingsText =
|
||||
FromResource(stringId, textColor)
|
||||
fun from(@StringRes stringId: Int, @ColorInt textColor: Int): DSLSettingsText =
|
||||
FromResource(stringId, listOf(ColorModifier(textColor)))
|
||||
|
||||
fun from(charSequence: CharSequence): DSLSettingsText = FromCharSequence(charSequence)
|
||||
fun from(@StringRes stringId: Int, vararg modifiers: Modifier): DSLSettingsText =
|
||||
FromResource(stringId, modifiers.toList())
|
||||
|
||||
fun from(charSequence: CharSequence, vararg modifiers: Modifier): DSLSettingsText =
|
||||
FromCharSequence(charSequence, modifiers.toList())
|
||||
}
|
||||
|
||||
interface Modifier {
|
||||
fun modify(context: Context, charSequence: CharSequence): CharSequence
|
||||
}
|
||||
|
||||
class ColorModifier(@ColorInt private val textColor: Int) : Modifier {
|
||||
override fun modify(context: Context, charSequence: CharSequence): CharSequence {
|
||||
return SpanUtil.color(textColor, charSequence)
|
||||
}
|
||||
}
|
||||
|
||||
object CenterModifier : Modifier {
|
||||
override fun modify(context: Context, charSequence: CharSequence): CharSequence {
|
||||
return SpanUtil.center(charSequence)
|
||||
}
|
||||
}
|
||||
|
||||
object Title2BoldModifier : TextAppearanceModifier(R.style.TextAppearance_Signal_Title2_Bold)
|
||||
object Body1Modifier : TextAppearanceModifier(R.style.Signal_Text_Body)
|
||||
object Body1BoldModifier : TextAppearanceModifier(R.style.TextAppearance_Signal_Body1_Bold)
|
||||
|
||||
open class TextAppearanceModifier(@StyleRes private val textAppearance: Int) : Modifier {
|
||||
override fun modify(context: Context, charSequence: CharSequence): CharSequence {
|
||||
return SpanUtil.textAppearance(context, textAppearance, charSequence)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,16 +3,23 @@ package org.thoughtcrime.securesms.components.settings.app
|
|||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.viewModels
|
||||
import androidx.navigation.NavDirections
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.BoostRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.BoostViewModel
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.subscribe.SubscribeViewModel
|
||||
import org.thoughtcrime.securesms.help.HelpFragment
|
||||
import org.thoughtcrime.securesms.keyvalue.SettingsValues
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService
|
||||
import org.thoughtcrime.securesms.util.CachedInflater
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
|
||||
private const val START_LOCATION = "app.settings.start.location"
|
||||
private const val NOTIFICATION_CATEGORY = "android.intent.category.NOTIFICATION_PREFERENCES"
|
||||
|
@ -22,7 +29,23 @@ class AppSettingsActivity : DSLSettingsActivity() {
|
|||
|
||||
private var wasConfigurationUpdated = false
|
||||
|
||||
private val donationRepository: DonationPaymentRepository by lazy { DonationPaymentRepository(this) }
|
||||
private val subscribeViewModel: SubscribeViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
SubscribeViewModel.Factory(SubscriptionsRepository(), donationRepository, FETCH_SUBSCRIPTION_TOKEN_REQUEST_CODE)
|
||||
}
|
||||
)
|
||||
|
||||
private val boostViewModel: BoostViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
BoostViewModel.Factory(BoostRepository(), donationRepository, FETCH_BOOST_TOKEN_REQUEST_CODE)
|
||||
}
|
||||
)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
|
||||
warmDonationViewModels()
|
||||
|
||||
if (intent?.hasExtra(ARG_NAV_GRAPH) != true) {
|
||||
intent?.putExtra(ARG_NAV_GRAPH, R.navigation.app_settings)
|
||||
}
|
||||
|
@ -79,8 +102,17 @@ class AppSettingsActivity : DSLSettingsActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
subscribeViewModel.onActivityResult(requestCode, resultCode, data)
|
||||
boostViewModel.onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val FETCH_SUBSCRIPTION_TOKEN_REQUEST_CODE = 1000
|
||||
private const val FETCH_BOOST_TOKEN_REQUEST_CODE = 2000
|
||||
|
||||
@JvmStatic
|
||||
fun home(context: Context): Intent = getIntentForStartLocation(context, StartLocation.HOME)
|
||||
|
||||
|
@ -109,6 +141,13 @@ class AppSettingsActivity : DSLSettingsActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun warmDonationViewModels() {
|
||||
if (FeatureFlags.donorBadges()) {
|
||||
subscribeViewModel
|
||||
boostViewModel
|
||||
}
|
||||
}
|
||||
|
||||
private enum class StartLocation(val code: Int) {
|
||||
HOME(0),
|
||||
BACKUPS(1),
|
||||
|
|
|
@ -4,6 +4,7 @@ import android.view.View
|
|||
import android.widget.TextView
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.Navigation
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
|
@ -130,11 +131,33 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
|
|||
}
|
||||
)
|
||||
|
||||
externalLinkPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__donate_to_signal),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
|
||||
linkId = R.string.donate_url
|
||||
)
|
||||
if (FeatureFlags.donorBadges()) {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__subscription),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
|
||||
onClick = {
|
||||
findNavController()
|
||||
.navigate(
|
||||
AppSettingsFragmentDirections.actionAppSettingsFragmentToSubscriptions()
|
||||
.setSkipToSubscribe(true /* TODO [alex] -- Check state to see if user has active subscription or not. */)
|
||||
)
|
||||
}
|
||||
)
|
||||
// TODO [alex] -- clap
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__signal_boost),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
|
||||
onClick = {
|
||||
findNavController().navigate(R.id.action_appSettingsFragment_to_boostsFragment)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
externalLinkPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__donate_to_signal),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
|
||||
linkId = R.string.donate_url
|
||||
)
|
||||
}
|
||||
|
||||
if (FeatureFlags.internalUser()) {
|
||||
dividerPref()
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription
|
||||
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
|
||||
/**
|
||||
* Events that can arise from use of the donations apis.
|
||||
*/
|
||||
sealed class DonationEvent {
|
||||
class GooglePayUnavailableError(val throwable: Throwable) : DonationEvent()
|
||||
object RequestTokenSuccess : DonationEvent()
|
||||
object RequestTokenError : DonationEvent()
|
||||
class PaymentConfirmationError(val throwable: Throwable) : DonationEvent()
|
||||
class PaymentConfirmationSuccess(val badge: Badge) : DonationEvent()
|
||||
object SubscriptionCancelled : DonationEvent()
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import com.google.android.gms.wallet.PaymentData
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.donations.GooglePayApi
|
||||
import org.signal.donations.GooglePayPaymentSource
|
||||
import org.signal.donations.StripeApi
|
||||
import org.thoughtcrime.securesms.BuildConfig
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
|
||||
class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFetcher {
|
||||
|
||||
private val configuration = StripeApi.Configuration(publishableKey = BuildConfig.STRIPE_PUBLISHABLE_KEY)
|
||||
private val googlePayApi = GooglePayApi(activity, StripeApi.Gateway(configuration))
|
||||
private val stripeApi = StripeApi(configuration, this, ApplicationDependencies.getOkHttpClient())
|
||||
|
||||
fun isGooglePayAvailable(): Completable = googlePayApi.queryIsReadyToPay()
|
||||
|
||||
fun requestTokenFromGooglePay(price: FiatMoney, label: String, requestCode: Int) {
|
||||
googlePayApi.requestPayment(price, label, requestCode)
|
||||
}
|
||||
|
||||
fun onActivityResult(
|
||||
requestCode: Int,
|
||||
resultCode: Int,
|
||||
data: Intent?,
|
||||
expectedRequestCode: Int,
|
||||
paymentsRequestCallback: GooglePayApi.PaymentRequestCallback
|
||||
) {
|
||||
googlePayApi.onActivityResult(requestCode, resultCode, data, expectedRequestCode, paymentsRequestCallback)
|
||||
}
|
||||
|
||||
fun continuePayment(price: FiatMoney, paymentData: PaymentData): Completable {
|
||||
return stripeApi.createPaymentIntent(price)
|
||||
.flatMapCompletable { result ->
|
||||
when (result) {
|
||||
is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> Completable.error(Exception("Amount is too small"))
|
||||
is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> Completable.error(Exception("Amount is too large"))
|
||||
is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> Completable.error(Exception("Currency is not supported"))
|
||||
is StripeApi.CreatePaymentIntentResult.Success -> stripeApi.confirmPaymentIntent(GooglePayPaymentSource(paymentData), result.paymentIntent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun fetchPaymentIntent(price: FiatMoney, description: String?): Single<StripeApi.PaymentIntent> {
|
||||
return ApplicationDependencies
|
||||
.getDonationsService()
|
||||
.createDonationIntentWithAmount(price.minimumUnitPrecisionString, price.currency.currencyCode)
|
||||
.map { StripeApi.PaymentIntent(it.result.get().id, it.result.get().clientSecret) }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription
|
||||
|
||||
import io.reactivex.rxjava3.core.Maybe
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
import java.util.Currency
|
||||
|
||||
/**
|
||||
* Repository which can query for the user's active subscription as well as a list of available subscriptions,
|
||||
* in the currency indicated.
|
||||
*/
|
||||
class SubscriptionsRepository {
|
||||
|
||||
fun getActiveSubscription(currency: Currency): Maybe<Subscription> = Maybe.empty()
|
||||
|
||||
fun getSubscriptions(currency: Currency): Single<List<Subscription>> = Single.fromCallable {
|
||||
listOf()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,187 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
|
||||
|
||||
import android.text.Editable
|
||||
import android.text.Spanned
|
||||
import android.text.TextWatcher
|
||||
import android.text.method.DigitsKeyListener
|
||||
import android.view.View
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.appcompat.widget.AppCompatEditText
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import java.lang.Integer.min
|
||||
import java.util.Currency
|
||||
import java.util.Locale
|
||||
import java.util.regex.Pattern
|
||||
|
||||
/**
|
||||
* A Signal Boost is a one-time ephemeral show of support. Each boost level
|
||||
* can unlock a corresponding badge for a time determined by the server.
|
||||
*/
|
||||
data class Boost(
|
||||
val badge: Badge,
|
||||
val price: FiatMoney
|
||||
) {
|
||||
|
||||
/**
|
||||
* A heading containing a 96dp rendering of the boost's badge.
|
||||
*/
|
||||
class HeadingModel(
|
||||
val boostBadge: Badge
|
||||
) : PreferenceModel<HeadingModel>() {
|
||||
override fun areItemsTheSame(newItem: HeadingModel): Boolean = true
|
||||
|
||||
override fun areContentsTheSame(newItem: HeadingModel): Boolean {
|
||||
return super.areContentsTheSame(newItem) && newItem.boostBadge == boostBadge
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A widget that allows a user to select from six different amounts, or enter a custom amount.
|
||||
*/
|
||||
class SelectionModel(
|
||||
val boosts: List<Boost>,
|
||||
val selectedBoost: Boost?,
|
||||
val currency: Currency,
|
||||
override val isEnabled: Boolean,
|
||||
val onBoostClick: (Boost) -> Unit,
|
||||
val isCustomAmountFocused: Boolean,
|
||||
val onCustomAmountChanged: (String) -> Unit,
|
||||
val onCustomAmountFocusChanged: (Boolean) -> Unit,
|
||||
) : PreferenceModel<SelectionModel>(isEnabled = isEnabled) {
|
||||
override fun areItemsTheSame(newItem: SelectionModel): Boolean = true
|
||||
|
||||
override fun areContentsTheSame(newItem: SelectionModel): Boolean {
|
||||
return super.areContentsTheSame(newItem) &&
|
||||
newItem.boosts == boosts &&
|
||||
newItem.selectedBoost == selectedBoost &&
|
||||
newItem.currency == currency &&
|
||||
newItem.isCustomAmountFocused == isCustomAmountFocused
|
||||
}
|
||||
}
|
||||
|
||||
private class SelectionViewHolder(itemView: View) : MappingViewHolder<SelectionModel>(itemView) {
|
||||
|
||||
private val boost1: MaterialButton = itemView.findViewById(R.id.boost_1)
|
||||
private val boost2: MaterialButton = itemView.findViewById(R.id.boost_2)
|
||||
private val boost3: MaterialButton = itemView.findViewById(R.id.boost_3)
|
||||
private val boost4: MaterialButton = itemView.findViewById(R.id.boost_4)
|
||||
private val boost5: MaterialButton = itemView.findViewById(R.id.boost_5)
|
||||
private val boost6: MaterialButton = itemView.findViewById(R.id.boost_6)
|
||||
private val custom: AppCompatEditText = itemView.findViewById(R.id.boost_custom)
|
||||
|
||||
private var filter: MoneyFilter? = null
|
||||
|
||||
init {
|
||||
custom.filters = emptyArray()
|
||||
}
|
||||
|
||||
override fun bind(model: SelectionModel) {
|
||||
itemView.isEnabled = model.isEnabled
|
||||
|
||||
model.boosts.zip(listOf(boost1, boost2, boost3, boost4, boost5, boost6)).forEach { (boost, button) ->
|
||||
button.isSelected = boost == model.selectedBoost && !model.isCustomAmountFocused
|
||||
button.text = FiatMoneyUtil.format(
|
||||
context.resources,
|
||||
boost.price,
|
||||
FiatMoneyUtil.formatOptions()
|
||||
)
|
||||
button.setOnClickListener {
|
||||
model.onBoostClick(boost)
|
||||
custom.clearFocus()
|
||||
}
|
||||
}
|
||||
|
||||
if (filter == null || filter?.currency != model.currency) {
|
||||
custom.removeTextChangedListener(filter)
|
||||
|
||||
filter = MoneyFilter(model.currency) {
|
||||
model.onCustomAmountChanged(it)
|
||||
}
|
||||
|
||||
custom.keyListener = filter
|
||||
custom.addTextChangedListener(filter)
|
||||
|
||||
custom.setText("")
|
||||
}
|
||||
|
||||
custom.setOnFocusChangeListener { _, hasFocus ->
|
||||
model.onCustomAmountFocusChanged(hasFocus)
|
||||
}
|
||||
|
||||
if (model.isCustomAmountFocused && !custom.hasFocus()) {
|
||||
ViewUtil.focusAndShowKeyboard(custom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class HeadingViewHolder(itemView: View) : MappingViewHolder<HeadingModel>(itemView) {
|
||||
|
||||
private val badgeImageView: BadgeImageView = itemView as BadgeImageView
|
||||
|
||||
override fun bind(model: HeadingModel) {
|
||||
badgeImageView.setBadge(model.boostBadge)
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
class MoneyFilter(val currency: Currency, private val onCustomAmountChanged: (String) -> Unit = {}) : DigitsKeyListener(), TextWatcher {
|
||||
|
||||
val separatorCount = min(1, currency.defaultFractionDigits)
|
||||
val prefix: String = "${currency.getSymbol(Locale.getDefault())} "
|
||||
val pattern: Pattern = "[0-9]*([.,]){0,$separatorCount}[0-9]{0,${currency.defaultFractionDigits}}".toPattern()
|
||||
|
||||
override fun filter(
|
||||
source: CharSequence,
|
||||
start: Int,
|
||||
end: Int,
|
||||
dest: Spanned,
|
||||
dstart: Int,
|
||||
dend: Int
|
||||
): CharSequence? {
|
||||
|
||||
val result = dest.subSequence(0, dstart).toString() + source.toString() + dest.subSequence(dend, dest.length)
|
||||
val resultWithoutCurrencyPrefix = result.removePrefix(prefix)
|
||||
val matcher = pattern.matcher(resultWithoutCurrencyPrefix)
|
||||
|
||||
if (!matcher.matches()) {
|
||||
return dest.subSequence(dstart, dend)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
|
||||
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
|
||||
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
if (s.isNullOrEmpty()) return
|
||||
|
||||
val hasPrefix = s.startsWith(prefix)
|
||||
if (hasPrefix && s.length == prefix.length) {
|
||||
s.clear()
|
||||
} else if (!hasPrefix) {
|
||||
s.insert(0, prefix)
|
||||
}
|
||||
|
||||
onCustomAmountChanged(s.removePrefix(prefix).toString())
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun register(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(SelectionModel::class.java, MappingAdapter.LayoutFactory({ SelectionViewHolder(it) }, R.layout.boost_preference))
|
||||
adapter.registerFactory(HeadingModel::class.java, MappingAdapter.LayoutFactory({ HeadingViewHolder(it) }, R.layout.boost_preview_preference))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,146 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
|
||||
|
||||
import android.text.SpannableStringBuilder
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.badges.models.BadgePreview
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
|
||||
/**
|
||||
* UX to allow users to donate ephemerally.
|
||||
*/
|
||||
class BoostFragment : DSLSettingsBottomSheetFragment() {
|
||||
|
||||
private val viewModel: BoostViewModel by viewModels(ownerProducer = { requireActivity() })
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
private val sayThanks: CharSequence by lazy {
|
||||
SpannableStringBuilder(requireContext().getString(R.string.BoostFragment__say_thanks_and_earn, 30))
|
||||
.append(" ")
|
||||
.append(
|
||||
SpanUtil.learnMore(requireContext(), ContextCompat.getColor(requireContext(), R.color.signal_accent_primary)) {
|
||||
// TODO [alex] -- Where's this go?
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
CurrencySelection.register(adapter)
|
||||
BadgePreview.register(adapter)
|
||||
Boost.register(adapter)
|
||||
GooglePayButton.register(adapter)
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
}
|
||||
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
|
||||
lifecycleDisposable += viewModel.events.subscribe { event: DonationEvent ->
|
||||
when (event) {
|
||||
is DonationEvent.GooglePayUnavailableError -> Log.w(TAG, "Google Pay error", event.throwable)
|
||||
is DonationEvent.PaymentConfirmationError -> Log.w(TAG, "Payment confirmation error", event.throwable)
|
||||
is DonationEvent.PaymentConfirmationSuccess -> onPaymentConfirmed(event.badge)
|
||||
DonationEvent.RequestTokenError -> Log.w(TAG, "Request token could not be fetched")
|
||||
DonationEvent.RequestTokenSuccess -> Log.w(TAG, "Successfully got request token from Google Pay")
|
||||
DonationEvent.SubscriptionCancelled -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: BoostState): DSLConfiguration {
|
||||
return configure {
|
||||
customPref(BadgePreview.SubscriptionModel(state.boostBadge))
|
||||
|
||||
sectionHeaderPref(
|
||||
title = DSLSettingsText.from(
|
||||
R.string.BoostFragment__give_signal_a_boost,
|
||||
DSLSettingsText.CenterModifier, DSLSettingsText.Title2BoldModifier
|
||||
)
|
||||
)
|
||||
|
||||
noPadTextPref(
|
||||
title = DSLSettingsText.from(
|
||||
sayThanks,
|
||||
DSLSettingsText.CenterModifier
|
||||
)
|
||||
)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(28f).toInt())
|
||||
|
||||
customPref(
|
||||
CurrencySelection.Model(
|
||||
currencySelection = state.currencySelection,
|
||||
isEnabled = state.stage == BoostState.Stage.READY,
|
||||
onClick = {
|
||||
findNavController().navigate(BoostFragmentDirections.actionBoostFragmentToSetDonationCurrencyFragment())
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
customPref(
|
||||
Boost.SelectionModel(
|
||||
boosts = state.boosts,
|
||||
selectedBoost = state.selectedBoost,
|
||||
currency = state.customAmount.currency,
|
||||
isCustomAmountFocused = state.isCustomAmountFocused,
|
||||
isEnabled = state.stage == BoostState.Stage.READY,
|
||||
onBoostClick = {
|
||||
viewModel.setSelectedBoost(it)
|
||||
},
|
||||
onCustomAmountChanged = {
|
||||
viewModel.setCustomAmount(it)
|
||||
},
|
||||
onCustomAmountFocusChanged = {
|
||||
viewModel.setCustomAmountFocused(it)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
if (state.isGooglePayAvailable) {
|
||||
space(DimensionUnit.DP.toPixels(16f).toInt())
|
||||
|
||||
customPref(
|
||||
GooglePayButton.Model(
|
||||
onClick = this@BoostFragment::onGooglePayButtonClicked,
|
||||
isEnabled = state.stage == BoostState.Stage.READY
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
secondaryButtonNoOutline(
|
||||
text = DSLSettingsText.from(R.string.SubscribeFragment__more_payment_options),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_open_20, R.color.signal_accent_primary),
|
||||
onClick = {
|
||||
// TODO
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onGooglePayButtonClicked() {
|
||||
viewModel.requestTokenFromGooglePay(getString(R.string.preferences__signal_boost))
|
||||
}
|
||||
|
||||
private fun onPaymentConfirmed(boostBadge: Badge) {
|
||||
findNavController().navigate(BoostFragmentDirections.actionBoostFragmentToBoostThanksForYourSupportBottomSheetDialog(boostBadge).setIsBoost(true))
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(BoostFragment::class.java)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
|
||||
|
||||
import android.net.Uri
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
|
||||
class BoostRepository {
|
||||
|
||||
fun getBoosts(currency: Currency): Single<Pair<List<Boost>, Boost?>> {
|
||||
val boosts = testBoosts(currency)
|
||||
|
||||
return Single.just(
|
||||
Pair(
|
||||
boosts,
|
||||
boosts[2]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun getBoostBadge(): Single<Badge> = Single.fromCallable {
|
||||
// Get boost badge from server
|
||||
// throw NotImplementedError()
|
||||
testBadge
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val testBadge = Badge(
|
||||
id = "TEST",
|
||||
category = Badge.Category.Testing,
|
||||
name = "Test Badge",
|
||||
description = "Test Badge",
|
||||
imageUrl = Uri.EMPTY,
|
||||
imageDensity = "xxxhdpi",
|
||||
expirationTimestamp = 0L,
|
||||
visible = false,
|
||||
)
|
||||
|
||||
private fun testBoosts(currency: Currency) = listOf(
|
||||
3L, 5L, 10L, 20L, 50L, 100L
|
||||
).map {
|
||||
Boost(testBadge, FiatMoney(BigDecimal.valueOf(it), currency))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
|
||||
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
|
||||
data class BoostState(
|
||||
val boostBadge: Badge? = null,
|
||||
val currencySelection: CurrencySelection = CurrencySelection("USD"),
|
||||
val isGooglePayAvailable: Boolean = false,
|
||||
val boosts: List<Boost> = listOf(),
|
||||
val selectedBoost: Boost? = null,
|
||||
val customAmount: FiatMoney = FiatMoney(BigDecimal.ZERO, Currency.getInstance(currencySelection.selectedCurrencyCode)),
|
||||
val isCustomAmountFocused: Boolean = false,
|
||||
val stage: Stage = Stage.INIT
|
||||
) {
|
||||
enum class Stage {
|
||||
INIT,
|
||||
READY,
|
||||
PAYMENT_PIPELINE,
|
||||
}
|
||||
}
|
|
@ -0,0 +1,168 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.google.android.gms.wallet.PaymentData
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.donations.GooglePayApi
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
import java.math.BigDecimal
|
||||
|
||||
class BoostViewModel(
|
||||
private val boostRepository: BoostRepository,
|
||||
private val donationPaymentRepository: DonationPaymentRepository,
|
||||
private val fetchTokenRequestCode: Int
|
||||
) : ViewModel() {
|
||||
|
||||
private val store = Store(BoostState())
|
||||
private val eventPublisher: PublishSubject<DonationEvent> = PublishSubject.create()
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
val state: LiveData<BoostState> = store.stateLiveData
|
||||
val events: Observable<DonationEvent> = eventPublisher
|
||||
|
||||
private var boostToPurchase: Boost? = null
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
init {
|
||||
val currencyObservable = SignalStore.donationsValues().observableCurrency
|
||||
val boosts = currencyObservable.flatMapSingle { boostRepository.getBoosts(it) }
|
||||
val boostBadge = boostRepository.getBoostBadge()
|
||||
|
||||
disposables += Observable.combineLatest(boosts, boostBadge.toObservable()) { (boosts, defaultBoost), badge -> BoostInfo(boosts, defaultBoost, badge) }.subscribe { info ->
|
||||
store.update {
|
||||
it.copy(
|
||||
boosts = info.boosts,
|
||||
selectedBoost = if (it.selectedBoost in info.boosts) it.selectedBoost else info.defaultBoost,
|
||||
boostBadge = it.boostBadge ?: info.boostBadge,
|
||||
stage = if (it.stage == BoostState.Stage.INIT) BoostState.Stage.READY else it.stage
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
disposables += donationPaymentRepository.isGooglePayAvailable().subscribeBy(
|
||||
onComplete = { store.update { it.copy(isGooglePayAvailable = true) } },
|
||||
onError = { eventPublisher.onNext(DonationEvent.GooglePayUnavailableError(it)) }
|
||||
)
|
||||
|
||||
disposables += currencyObservable.subscribeBy { currency ->
|
||||
store.update {
|
||||
it.copy(
|
||||
currencySelection = CurrencySelection(currency.currencyCode),
|
||||
isCustomAmountFocused = false,
|
||||
customAmount = FiatMoney(
|
||||
BigDecimal.ZERO, currency
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onActivityResult(
|
||||
requestCode: Int,
|
||||
resultCode: Int,
|
||||
data: Intent?
|
||||
) {
|
||||
donationPaymentRepository.onActivityResult(
|
||||
requestCode,
|
||||
resultCode,
|
||||
data,
|
||||
this.fetchTokenRequestCode,
|
||||
object : GooglePayApi.PaymentRequestCallback {
|
||||
override fun onSuccess(paymentData: PaymentData) {
|
||||
val boost = boostToPurchase
|
||||
boostToPurchase = null
|
||||
|
||||
if (boost != null) {
|
||||
eventPublisher.onNext(DonationEvent.RequestTokenSuccess)
|
||||
donationPaymentRepository.continuePayment(boost.price, paymentData).subscribeBy(
|
||||
onError = { eventPublisher.onNext(DonationEvent.PaymentConfirmationError(it)) },
|
||||
onComplete = {
|
||||
// Now we need to do the whole query for a token, submit token rigamarole
|
||||
eventPublisher.onNext(DonationEvent.PaymentConfirmationSuccess(store.state.boostBadge!!))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError() {
|
||||
store.update { it.copy(stage = BoostState.Stage.PAYMENT_PIPELINE) }
|
||||
eventPublisher.onNext(DonationEvent.RequestTokenError)
|
||||
}
|
||||
|
||||
override fun onCancelled() {
|
||||
store.update { it.copy(stage = BoostState.Stage.PAYMENT_PIPELINE) }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun requestTokenFromGooglePay(label: String) {
|
||||
val snapshot = store.state
|
||||
if (snapshot.selectedBoost == null) {
|
||||
return
|
||||
}
|
||||
|
||||
store.update { it.copy(stage = BoostState.Stage.PAYMENT_PIPELINE) }
|
||||
|
||||
// TODO [alex] -- Do we want prevalidation? Stripe will catch us anyway.
|
||||
// TODO [alex] -- Custom boost badge details... how do we determine this?
|
||||
boostToPurchase = if (snapshot.isCustomAmountFocused) {
|
||||
Boost(snapshot.selectedBoost.badge, snapshot.customAmount)
|
||||
} else {
|
||||
snapshot.selectedBoost
|
||||
}
|
||||
|
||||
donationPaymentRepository.requestTokenFromGooglePay(snapshot.selectedBoost.price, label, fetchTokenRequestCode)
|
||||
}
|
||||
|
||||
fun setSelectedBoost(boost: Boost) {
|
||||
store.update {
|
||||
it.copy(
|
||||
isCustomAmountFocused = false,
|
||||
selectedBoost = boost
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setCustomAmount(amount: String) {
|
||||
val bigDecimalAmount = if (amount.isEmpty()) {
|
||||
BigDecimal.ZERO
|
||||
} else {
|
||||
BigDecimal(amount)
|
||||
}
|
||||
|
||||
store.update { it.copy(customAmount = FiatMoney(bigDecimalAmount, it.customAmount.currency)) }
|
||||
}
|
||||
|
||||
fun setCustomAmountFocused(isFocused: Boolean) {
|
||||
store.update { it.copy(isCustomAmountFocused = isFocused) }
|
||||
}
|
||||
|
||||
private data class BoostInfo(val boosts: List<Boost>, val defaultBoost: Boost?, val boostBadge: Badge)
|
||||
|
||||
class Factory(
|
||||
private val boostRepository: BoostRepository,
|
||||
private val donationPaymentRepository: DonationPaymentRepository,
|
||||
private val fetchTokenRequestCode: Int
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(BoostViewModel(boostRepository, donationPaymentRepository, fetchTokenRequestCode))!!
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription.currency
|
||||
|
||||
import androidx.fragment.app.viewModels
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Simple fragment for selecting a currency for Donations
|
||||
*/
|
||||
class SetCurrencyFragment : DSLSettingsBottomSheetFragment() {
|
||||
|
||||
private val viewModel: SetCurrencyViewModel by viewModels()
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
}
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: SetCurrencyState): DSLConfiguration {
|
||||
return configure {
|
||||
state.currencies.forEach { currency ->
|
||||
radioPref(
|
||||
title = DSLSettingsText.from(currency.getDisplayName(Locale.getDefault())),
|
||||
summary = DSLSettingsText.from(currency.currencyCode),
|
||||
isChecked = currency.currencyCode == state.selectedCurrencyCode,
|
||||
onClick = {
|
||||
viewModel.setSelectedCurrency(currency.currencyCode)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription.currency
|
||||
|
||||
import java.util.Currency
|
||||
|
||||
data class SetCurrencyState(
|
||||
val selectedCurrencyCode: String = "",
|
||||
val currencies: List<Currency> = listOf()
|
||||
)
|
|
@ -0,0 +1,68 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription.currency
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import org.signal.donations.StripeApi
|
||||
import org.thoughtcrime.securesms.BuildConfig
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
import java.util.Currency
|
||||
import java.util.Locale
|
||||
|
||||
class SetCurrencyViewModel : ViewModel() {
|
||||
|
||||
private val store = Store(SetCurrencyState())
|
||||
|
||||
val state: LiveData<SetCurrencyState> = store.stateLiveData
|
||||
|
||||
init {
|
||||
val defaultCurrency = SignalStore.donationsValues().getCurrency()
|
||||
|
||||
store.update { state ->
|
||||
val platformCurrencies = Currency.getAvailableCurrencies()
|
||||
val stripeCurrencies = platformCurrencies
|
||||
.filter { StripeApi.Validation.supportedCurrencyCodes.contains(it.currencyCode) }
|
||||
.sortedWith(CurrencyComparator(BuildConfig.DEFAULT_CURRENCIES.split(",")))
|
||||
|
||||
state.copy(
|
||||
selectedCurrencyCode = defaultCurrency.currencyCode,
|
||||
currencies = stripeCurrencies
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSelectedCurrency(selectedCurrencyCode: String) {
|
||||
store.update { it.copy(selectedCurrencyCode = selectedCurrencyCode) }
|
||||
SignalStore.donationsValues().setCurrency(Currency.getInstance(selectedCurrencyCode))
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
class CurrencyComparator(private val defaults: List<String>) : Comparator<Currency> {
|
||||
|
||||
companion object {
|
||||
private const val USD = "USD"
|
||||
}
|
||||
|
||||
override fun compare(o1: Currency, o2: Currency): Int {
|
||||
val isO1Default = o1.currencyCode in defaults
|
||||
val isO2Default = o2.currencyCode in defaults
|
||||
|
||||
return if (o1.currencyCode == o2.currencyCode) {
|
||||
0
|
||||
} else if (o1.currencyCode == USD) {
|
||||
-1
|
||||
} else if (o2.currencyCode == USD) {
|
||||
1
|
||||
} else if (isO1Default && isO2Default) {
|
||||
o1.getDisplayName(Locale.getDefault()).compareTo(o2.getDisplayName(Locale.getDefault()))
|
||||
} else if (isO1Default) {
|
||||
-1
|
||||
} else if (isO2Default) {
|
||||
1
|
||||
} else {
|
||||
o1.getDisplayName(Locale.getDefault()).compareTo(o2.getDisplayName(Locale.getDefault()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
|
||||
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* DSL renderable item that displays active subscription information on the user's
|
||||
* manage donations page.
|
||||
*/
|
||||
object ActiveSubscriptionPreference {
|
||||
|
||||
class Model(
|
||||
val subscription: Subscription,
|
||||
val onAddBoostClick: () -> Unit
|
||||
) : PreferenceModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return subscription.id == newItem.subscription.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return super.areContentsTheSame(newItem) && subscription == newItem.subscription
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
val badge: BadgeImageView = itemView.findViewById(R.id.my_support_badge)
|
||||
val title: TextView = itemView.findViewById(R.id.my_support_title)
|
||||
val price: TextView = itemView.findViewById(R.id.my_support_price)
|
||||
val expiry: TextView = itemView.findViewById(R.id.my_support_expiry)
|
||||
val boost: MaterialButton = itemView.findViewById(R.id.my_support_boost)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
badge.setBadge(model.subscription.badge)
|
||||
title.text = model.subscription.title
|
||||
|
||||
price.text = context.getString(
|
||||
R.string.MySupportPreference__s_per_month,
|
||||
FiatMoneyUtil.format(
|
||||
context.resources,
|
||||
model.subscription.price,
|
||||
FiatMoneyUtil.formatOptions()
|
||||
)
|
||||
)
|
||||
|
||||
expiry.text = context.getString(
|
||||
R.string.MySupportPreference__renews_s,
|
||||
DateUtils.formatDate(
|
||||
Locale.getDefault(),
|
||||
model.subscription.renewalTimestamp
|
||||
)
|
||||
)
|
||||
|
||||
boost.setOnClickListener {
|
||||
model.onAddBoostClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun register(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.my_support_preference))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
|
||||
|
||||
enum class ManageDonationsEvent {
|
||||
NOT_SUBSCRIBED,
|
||||
ERROR_GETTING_SUBSCRIPTION
|
||||
}
|
|
@ -0,0 +1,130 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
|
||||
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
|
||||
/**
|
||||
* Fragment displayed when a user enters "Subscriptions" via app settings but is already
|
||||
* a subscriber. Used to manage their current subscription, view badges, and boost.
|
||||
*/
|
||||
class ManageDonationsFragment : DSLSettingsFragment() {
|
||||
|
||||
private val viewModel: ManageDonationsViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
ManageDonationsViewModel.Factory(SubscriptionsRepository())
|
||||
}
|
||||
)
|
||||
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val args = ManageDonationsFragmentArgs.fromBundle(requireArguments())
|
||||
if (args.skipToSubscribe) {
|
||||
findNavController().navigate(
|
||||
ManageDonationsFragmentDirections.actionManageDonationsFragmentToSubscribeFragment(),
|
||||
NavOptions.Builder().setPopUpTo(R.id.manageDonationsFragment, true).build()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewModel.refresh()
|
||||
}
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
val args = ManageDonationsFragmentArgs.fromBundle(requireArguments())
|
||||
if (args.skipToSubscribe) {
|
||||
return
|
||||
}
|
||||
|
||||
ActiveSubscriptionPreference.register(adapter)
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
}
|
||||
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
|
||||
lifecycleDisposable += viewModel.events.subscribe { event: ManageDonationsEvent ->
|
||||
when (event) {
|
||||
ManageDonationsEvent.NOT_SUBSCRIBED -> handleUserIsNotSubscribed()
|
||||
ManageDonationsEvent.ERROR_GETTING_SUBSCRIPTION -> handleErrorGettingSubscription()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: ManageDonationsState): DSLConfiguration {
|
||||
return configure {
|
||||
sectionHeaderPref(
|
||||
title = DSLSettingsText.from(
|
||||
R.string.SubscribeFragment__signal_is_powered_by_people_like_you,
|
||||
DSLSettingsText.CenterModifier, DSLSettingsText.Title2BoldModifier
|
||||
)
|
||||
)
|
||||
|
||||
noPadTextPref(
|
||||
title = DSLSettingsText.from(
|
||||
R.string.ManageDonationsFragment__my_support,
|
||||
DSLSettingsText.Title2BoldModifier
|
||||
)
|
||||
)
|
||||
|
||||
if (state.activeSubscription != null) {
|
||||
customPref(
|
||||
ActiveSubscriptionPreference.Model(
|
||||
subscription = state.activeSubscription,
|
||||
onAddBoostClick = {
|
||||
findNavController().navigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToBoosts())
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
}
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ManageDonationsFragment__manage_subscription),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_person_white_24dp),
|
||||
onClick = {
|
||||
findNavController().navigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToSubscribeFragment())
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ManageDonationsFragment__badges),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_badge_24),
|
||||
onClick = {
|
||||
findNavController().navigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToSubscriptionBadgeManageFragment())
|
||||
}
|
||||
)
|
||||
|
||||
externalLinkPref(
|
||||
title = DSLSettingsText.from(R.string.ManageDonationsFragment__subscription_faq),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_help_24),
|
||||
linkId = R.string.donate_url
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleUserIsNotSubscribed() {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
|
||||
private fun handleErrorGettingSubscription() {
|
||||
Toast.makeText(requireContext(), R.string.ManageDonationsFragment__error_getting_subscription, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
|
||||
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
|
||||
data class ManageDonationsState(
|
||||
val featuredBadge: Badge? = null,
|
||||
val activeSubscription: Subscription? = null
|
||||
)
|
|
@ -0,0 +1,58 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
|
||||
class ManageDonationsViewModel(
|
||||
private val subscriptionsRepository: SubscriptionsRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val store = Store(ManageDonationsState())
|
||||
private val eventPublisher = PublishSubject.create<ManageDonationsEvent>()
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
val state: LiveData<ManageDonationsState> = store.stateLiveData
|
||||
val events: Observable<ManageDonationsEvent> = eventPublisher
|
||||
|
||||
init {
|
||||
store.update(Recipient.self().live().liveDataResolved) { self, state ->
|
||||
state.copy(featuredBadge = self.featuredBadge)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
disposables.clear()
|
||||
disposables += subscriptionsRepository.getActiveSubscription(SignalStore.donationsValues().getCurrency()).subscribeBy(
|
||||
onSuccess = { subscription -> store.update { it.copy(activeSubscription = subscription) } },
|
||||
onComplete = {
|
||||
store.update { it.copy(activeSubscription = null) }
|
||||
eventPublisher.onNext(ManageDonationsEvent.NOT_SUBSCRIBED)
|
||||
},
|
||||
onError = {
|
||||
eventPublisher.onNext(ManageDonationsEvent.ERROR_GETTING_SUBSCRIPTION)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val subscriptionsRepository: SubscriptionsRepository
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(ManageDonationsViewModel(subscriptionsRepository))!!
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription.models
|
||||
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
data class CurrencySelection(
|
||||
val selectedCurrencyCode: String,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
fun register(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.subscription_currency_selection))
|
||||
}
|
||||
}
|
||||
|
||||
class Model(
|
||||
val currencySelection: CurrencySelection,
|
||||
override val isEnabled: Boolean,
|
||||
val onClick: () -> Unit
|
||||
) : PreferenceModel<Model>(isEnabled = isEnabled) {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return super.areContentsTheSame(newItem) &&
|
||||
newItem.currencySelection.selectedCurrencyCode == currencySelection.selectedCurrencyCode
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val spinner: TextView = itemView.findViewById(R.id.subscription_currency_selection_spinner)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
spinner.text = model.currencySelection.selectedCurrencyCode
|
||||
itemView.setOnClickListener { model.onClick() }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription.models
|
||||
|
||||
import android.view.View
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
object GooglePayButton {
|
||||
|
||||
class Model(val onClick: () -> Unit, override val isEnabled: Boolean) : PreferenceModel<Model>(isEnabled = isEnabled) {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean = true
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val googlePayButton: View = findViewById(R.id.googlepay_button)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
googlePayButton.isEnabled = model.isEnabled
|
||||
googlePayButton.setOnClickListener {
|
||||
googlePayButton.isEnabled = false
|
||||
model.onClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun register(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.google_pay_button_pref))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,175 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription.subscribe
|
||||
|
||||
import android.text.SpannableStringBuilder
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.badges.models.BadgePreview
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
|
||||
/**
|
||||
* UX for creating and changing a subscription
|
||||
*/
|
||||
class SubscribeFragment : DSLSettingsFragment() {
|
||||
|
||||
private val viewModel: SubscribeViewModel by viewModels(ownerProducer = { requireActivity() })
|
||||
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
private val supportTechSummary: CharSequence by lazy {
|
||||
SpannableStringBuilder(requireContext().getString(R.string.SubscribeFragment__support_technology_that_is_built_for_you))
|
||||
.append(" ")
|
||||
.append(
|
||||
SpanUtil.learnMore(requireContext(), ContextCompat.getColor(requireContext(), R.color.signal_accent_primary)) {
|
||||
findNavController().navigate(SubscribeFragmentDirections.actionSubscribeFragmentToSubscribeLearnMoreBottomSheetDialog())
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewModel.refresh()
|
||||
}
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
BadgePreview.register(adapter)
|
||||
CurrencySelection.register(adapter)
|
||||
Subscription.register(adapter)
|
||||
GooglePayButton.register(adapter)
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
}
|
||||
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
|
||||
lifecycleDisposable += viewModel.events.subscribe {
|
||||
when (it) {
|
||||
is DonationEvent.GooglePayUnavailableError -> Log.w(TAG, "Google Pay error", it.throwable)
|
||||
is DonationEvent.PaymentConfirmationError -> Log.w(TAG, "Payment confirmation error", it.throwable)
|
||||
is DonationEvent.PaymentConfirmationSuccess -> onPaymentConfirmed(it.badge)
|
||||
DonationEvent.RequestTokenError -> Log.w(TAG, "Request token could not be fetched")
|
||||
DonationEvent.RequestTokenSuccess -> Log.w(TAG, "Successfully got request token from Google Pay")
|
||||
DonationEvent.SubscriptionCancelled -> onSubscriptionCancelled()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: SubscribeState): DSLConfiguration {
|
||||
return configure {
|
||||
customPref(BadgePreview.SubscriptionModel(state.previewBadge))
|
||||
|
||||
sectionHeaderPref(
|
||||
title = DSLSettingsText.from(
|
||||
R.string.SubscribeFragment__signal_is_powered_by_people_like_you,
|
||||
DSLSettingsText.CenterModifier, DSLSettingsText.Title2BoldModifier
|
||||
)
|
||||
)
|
||||
|
||||
noPadTextPref(
|
||||
title = DSLSettingsText.from(supportTechSummary, DSLSettingsText.CenterModifier)
|
||||
)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(16f).toInt())
|
||||
|
||||
customPref(
|
||||
CurrencySelection.Model(
|
||||
currencySelection = state.currencySelection,
|
||||
isEnabled = state.stage == SubscribeState.Stage.READY,
|
||||
onClick = {
|
||||
findNavController().navigate(SubscribeFragmentDirections.actionSubscribeFragmentToSetDonationCurrencyFragment())
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
state.subscriptions.forEach {
|
||||
customPref(
|
||||
Subscription.Model(
|
||||
subscription = it,
|
||||
isSelected = state.selectedSubscription == it,
|
||||
isEnabled = state.stage == SubscribeState.Stage.READY,
|
||||
isActive = state.activeSubscription == it,
|
||||
onClick = { viewModel.setSelectedSubscription(it) }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (state.activeSubscription != null) {
|
||||
primaryButton(
|
||||
text = DSLSettingsText.from(R.string.SubscribeFragment__update_subscription),
|
||||
onClick = {
|
||||
// TODO [alex] -- Dunno what the update process requires.
|
||||
}
|
||||
)
|
||||
|
||||
secondaryButtonNoOutline(
|
||||
text = DSLSettingsText.from(R.string.SubscribeFragment__cancel_subscription),
|
||||
onClick = {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.SubscribeFragment__confirm_cancellation)
|
||||
.setMessage(R.string.SubscribeFragment__you_wont_be_charged_again)
|
||||
.setPositiveButton(R.string.SubscribeFragment__confirm) { d, _ ->
|
||||
d.dismiss()
|
||||
viewModel.cancel()
|
||||
}
|
||||
.setNegativeButton(R.string.SubscribeFragment__not_now) { d, _ ->
|
||||
d.dismiss()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
if (state.isGooglePayAvailable) {
|
||||
space(DimensionUnit.DP.toPixels(16f).toInt())
|
||||
|
||||
customPref(
|
||||
GooglePayButton.Model(
|
||||
onClick = this@SubscribeFragment::onGooglePayButtonClicked,
|
||||
isEnabled = state.stage == SubscribeState.Stage.READY
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
secondaryButtonNoOutline(
|
||||
text = DSLSettingsText.from(R.string.SubscribeFragment__more_payment_options),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_open_20, R.color.signal_accent_primary),
|
||||
onClick = {
|
||||
// TODO
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onGooglePayButtonClicked() {
|
||||
viewModel.requestTokenFromGooglePay()
|
||||
}
|
||||
|
||||
private fun onPaymentConfirmed(badge: Badge) {
|
||||
findNavController().navigate(SubscribeFragmentDirections.actionSubscribeFragmentToSubscribeThanksForYourSupportBottomSheetDialog(badge).setIsBoost(false))
|
||||
}
|
||||
|
||||
private fun onSubscriptionCancelled() {
|
||||
Snackbar.make(requireView(), R.string.SubscribeFragment__your_subscription_has_been_cancelled, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(SubscribeFragment::class.java)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription.subscribe
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
|
||||
|
||||
class SubscribeLearnMoreBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFragment() {
|
||||
|
||||
override val peekHeightPercentage: Float = 1f
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(R.layout.subscribe_learn_more_bottom_sheet_dialog_fragment, container, false)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription.subscribe
|
||||
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
|
||||
data class SubscribeState(
|
||||
val previewBadge: Badge? = null,
|
||||
val currencySelection: CurrencySelection = CurrencySelection("USD"),
|
||||
val subscriptions: List<Subscription> = listOf(),
|
||||
val selectedSubscription: Subscription? = null,
|
||||
val activeSubscription: Subscription? = null,
|
||||
val isGooglePayAvailable: Boolean = false,
|
||||
val stage: Stage = Stage.INIT
|
||||
) {
|
||||
enum class Stage {
|
||||
INIT,
|
||||
READY,
|
||||
PAYMENT_PIPELINE,
|
||||
CANCELLING
|
||||
}
|
||||
}
|
|
@ -0,0 +1,136 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription.subscribe
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.google.android.gms.wallet.PaymentData
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import org.signal.donations.GooglePayApi
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
import org.whispersystems.libsignal.util.guava.Optional
|
||||
|
||||
class SubscribeViewModel(
|
||||
private val subscriptionsRepository: SubscriptionsRepository,
|
||||
private val donationPaymentRepository: DonationPaymentRepository,
|
||||
private val fetchTokenRequestCode: Int
|
||||
) : ViewModel() {
|
||||
|
||||
private val store = Store(SubscribeState())
|
||||
private val eventPublisher: PublishSubject<DonationEvent> = PublishSubject.create()
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
val state: LiveData<SubscribeState> = store.stateLiveData
|
||||
val events: Observable<DonationEvent> = eventPublisher
|
||||
|
||||
private var subscriptionToPurchase: Subscription? = null
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
disposables.clear()
|
||||
|
||||
val currency = SignalStore.donationsValues().getCurrency()
|
||||
|
||||
val allSubscriptions = subscriptionsRepository.getSubscriptions(currency)
|
||||
val activeSubscription = subscriptionsRepository.getActiveSubscription(currency)
|
||||
.map { Optional.of(it) }
|
||||
.defaultIfEmpty(Optional.absent())
|
||||
|
||||
disposables += allSubscriptions.zipWith(activeSubscription, ::Pair).subscribe { (subs, active) ->
|
||||
store.update {
|
||||
it.copy(
|
||||
subscriptions = subs,
|
||||
selectedSubscription = it.selectedSubscription ?: active.orNull() ?: subs.firstOrNull(),
|
||||
activeSubscription = active.orNull(),
|
||||
stage = SubscribeState.Stage.READY
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
disposables += donationPaymentRepository.isGooglePayAvailable().subscribeBy(
|
||||
onComplete = { store.update { it.copy(isGooglePayAvailable = true) } },
|
||||
onError = { eventPublisher.onNext(DonationEvent.GooglePayUnavailableError(it)) }
|
||||
)
|
||||
|
||||
store.update { it.copy(currencySelection = CurrencySelection(SignalStore.donationsValues().getCurrency().currencyCode)) }
|
||||
}
|
||||
fun cancel() {
|
||||
store.update { it.copy(stage = SubscribeState.Stage.CANCELLING) }
|
||||
// TODO [alex] -- cancel api call
|
||||
}
|
||||
|
||||
fun onActivityResult(
|
||||
requestCode: Int,
|
||||
resultCode: Int,
|
||||
data: Intent?
|
||||
) {
|
||||
donationPaymentRepository.onActivityResult(
|
||||
requestCode, resultCode, data, this.fetchTokenRequestCode,
|
||||
object : GooglePayApi.PaymentRequestCallback {
|
||||
override fun onSuccess(paymentData: PaymentData) {
|
||||
val subscription = subscriptionToPurchase
|
||||
subscriptionToPurchase = null
|
||||
|
||||
if (subscription != null) {
|
||||
eventPublisher.onNext(DonationEvent.RequestTokenSuccess)
|
||||
donationPaymentRepository.continuePayment(subscription.price, paymentData).subscribeBy(
|
||||
onError = { eventPublisher.onNext(DonationEvent.PaymentConfirmationError(it)) },
|
||||
onComplete = {
|
||||
// Now we need to do the whole query for a token, submit token rigamarole
|
||||
eventPublisher.onNext(DonationEvent.PaymentConfirmationSuccess(subscription.badge))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError() {
|
||||
store.update { it.copy(stage = SubscribeState.Stage.PAYMENT_PIPELINE) }
|
||||
eventPublisher.onNext(DonationEvent.RequestTokenError)
|
||||
}
|
||||
|
||||
override fun onCancelled() {
|
||||
store.update { it.copy(stage = SubscribeState.Stage.PAYMENT_PIPELINE) }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun requestTokenFromGooglePay() {
|
||||
val snapshot = store.state
|
||||
if (snapshot.selectedSubscription == null) {
|
||||
return
|
||||
}
|
||||
|
||||
store.update { it.copy(stage = SubscribeState.Stage.PAYMENT_PIPELINE) }
|
||||
|
||||
subscriptionToPurchase = snapshot.selectedSubscription
|
||||
donationPaymentRepository.requestTokenFromGooglePay(snapshot.selectedSubscription.price, snapshot.selectedSubscription.title, fetchTokenRequestCode)
|
||||
}
|
||||
|
||||
fun setSelectedSubscription(subscription: Subscription) {
|
||||
store.update { it.copy(selectedSubscription = subscription) }
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val subscriptionsRepository: SubscriptionsRepository,
|
||||
private val donationPaymentRepository: DonationPaymentRepository,
|
||||
private val fetchTokenRequestCode: Int
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(SubscribeViewModel(subscriptionsRepository, donationPaymentRepository, fetchTokenRequestCode))!!
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription.thanks
|
||||
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
|
||||
|
||||
class ThanksForYourSupportBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFragment() {
|
||||
|
||||
override val peekHeightPercentage: Float = 1f
|
||||
|
||||
private lateinit var displayOnProfileSwitch: SwitchMaterial
|
||||
private lateinit var heading: TextView
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(R.layout.thanks_for_your_support_bottom_sheet_dialog_fragment, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val badgeView: BadgeImageView = view.findViewById(R.id.thanks_bottom_sheet_badge)
|
||||
val badgeName: TextView = view.findViewById(R.id.thanks_bottom_sheet_badge_name)
|
||||
val done: MaterialButton = view.findViewById(R.id.thanks_bottom_sheet_done)
|
||||
|
||||
heading = view.findViewById(R.id.thanks_bottom_sheet_heading)
|
||||
displayOnProfileSwitch = view.findViewById(R.id.thanks_bottom_sheet_display_on_profile)
|
||||
|
||||
val args = ThanksForYourSupportBottomSheetDialogFragmentArgs.fromBundle(requireArguments())
|
||||
|
||||
badgeView.setBadge(args.badge)
|
||||
badgeName.text = args.badge.name
|
||||
displayOnProfileSwitch.isChecked = true
|
||||
|
||||
if (args.isBoost) {
|
||||
presentBoostCopy()
|
||||
} else {
|
||||
presentSubscriptionCopy()
|
||||
}
|
||||
|
||||
done.setOnClickListener { dismissAllowingStateLoss() }
|
||||
}
|
||||
|
||||
override fun onDismiss(dialog: DialogInterface) {
|
||||
val isDisplayOnProfile = displayOnProfileSwitch.isChecked
|
||||
// TODO [alex] -- Not sure what state we're in with regards to submitting the token.
|
||||
}
|
||||
|
||||
private fun presentBoostCopy() {
|
||||
heading.setText(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__thanks_for_the_boost)
|
||||
}
|
||||
|
||||
private fun presentSubscriptionCopy() {
|
||||
heading.setText(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__thanks_for_your_support)
|
||||
}
|
||||
}
|
|
@ -660,7 +660,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
|||
val blockUnblockIcon = if (isBlocked) unblockIcon else blockIcon
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(title, titleTint),
|
||||
title = if (titleTint != null) DSLSettingsText.from(title, titleTint) else DSLSettingsText.from(title),
|
||||
icon = DSLSettingsIcon.from(blockUnblockIcon),
|
||||
onClick = {
|
||||
if (state.recipient.isBlocked) {
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
package org.thoughtcrime.securesms.components.settings
|
||||
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.annotation.Px
|
||||
import androidx.annotation.StringRes
|
||||
import org.thoughtcrime.securesms.components.settings.models.Button
|
||||
import org.thoughtcrime.securesms.components.settings.models.Space
|
||||
import org.thoughtcrime.securesms.components.settings.models.Text
|
||||
import org.thoughtcrime.securesms.util.MappingModel
|
||||
import org.thoughtcrime.securesms.util.MappingModelList
|
||||
|
||||
|
@ -121,6 +125,35 @@ class DSLConfiguration {
|
|||
children.add(preference)
|
||||
}
|
||||
|
||||
fun noPadTextPref(title: DSLSettingsText) {
|
||||
val preference = Text(title)
|
||||
children.add(Text.Model(preference))
|
||||
}
|
||||
|
||||
fun space(@Px pixels: Int) {
|
||||
val preference = Space(pixels)
|
||||
children.add(Space.Model(preference))
|
||||
}
|
||||
|
||||
fun primaryButton(
|
||||
text: DSLSettingsText,
|
||||
isEnabled: Boolean = true,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val preference = Button.Model.Primary(text, null, isEnabled, onClick)
|
||||
children.add(preference)
|
||||
}
|
||||
|
||||
fun secondaryButtonNoOutline(
|
||||
text: DSLSettingsText,
|
||||
icon: DSLSettingsIcon? = null,
|
||||
isEnabled: Boolean = true,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val preference = Button.Model.SecondaryNoOutline(text, icon, isEnabled, onClick)
|
||||
children.add(preference)
|
||||
}
|
||||
|
||||
fun textPref(
|
||||
title: DSLSettingsText? = null,
|
||||
summary: DSLSettingsText? = null
|
||||
|
|
|
@ -0,0 +1,57 @@
|
|||
package org.thoughtcrime.securesms.components.settings.models
|
||||
|
||||
import android.view.View
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
object Button {
|
||||
|
||||
fun register(mappingAdapter: MappingAdapter) {
|
||||
mappingAdapter.registerFactory(Model.Primary::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) as MappingViewHolder<Model.Primary> }, R.layout.dsl_button_primary))
|
||||
mappingAdapter.registerFactory(Model.SecondaryNoOutline::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) as MappingViewHolder<Model.SecondaryNoOutline> }, R.layout.dsl_button_secondary))
|
||||
}
|
||||
|
||||
sealed class Model<T : Model<T>>(
|
||||
title: DSLSettingsText?,
|
||||
icon: DSLSettingsIcon?,
|
||||
isEnabled: Boolean,
|
||||
val onClick: () -> Unit
|
||||
) : PreferenceModel<T>(
|
||||
title = title,
|
||||
icon = icon,
|
||||
isEnabled = isEnabled
|
||||
) {
|
||||
class Primary(
|
||||
title: DSLSettingsText?,
|
||||
icon: DSLSettingsIcon?,
|
||||
isEnabled: Boolean,
|
||||
onClick: () -> Unit
|
||||
) : Model<Primary>(title, icon, isEnabled, onClick)
|
||||
|
||||
class SecondaryNoOutline(
|
||||
title: DSLSettingsText?,
|
||||
icon: DSLSettingsIcon?,
|
||||
isEnabled: Boolean,
|
||||
onClick: () -> Unit
|
||||
) : Model<SecondaryNoOutline>(title, icon, isEnabled, onClick)
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model<*>>(itemView) {
|
||||
|
||||
private val button: MaterialButton = itemView as MaterialButton
|
||||
|
||||
override fun bind(model: Model<*>) {
|
||||
button.text = model.title?.resolve(context)
|
||||
button.setOnClickListener {
|
||||
model.onClick()
|
||||
}
|
||||
button.icon = model.icon?.resolve(context)
|
||||
button.isEnabled = model.isEnabled
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
package org.thoughtcrime.securesms.components.settings.models
|
||||
|
||||
import android.view.View
|
||||
import androidx.annotation.Px
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
/**
|
||||
* Adds extra space between elements in a DSL fragment
|
||||
*/
|
||||
data class Space(
|
||||
@Px val pixels: Int
|
||||
) {
|
||||
|
||||
companion object {
|
||||
fun register(mappingAdapter: MappingAdapter) {
|
||||
mappingAdapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.dsl_space_preference))
|
||||
}
|
||||
}
|
||||
|
||||
class Model(val space: Space) : PreferenceModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return super.areContentsTheSame(newItem) && newItem.space == space
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
override fun bind(model: Model) {
|
||||
itemView.updateLayoutParams {
|
||||
height = model.space.pixels
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
package org.thoughtcrime.securesms.components.settings.models
|
||||
|
||||
import android.text.Spanned
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.text.style.ClickableSpan
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
/**
|
||||
* A Text without any padding, allowing for exact padding to be handed in at runtime.
|
||||
*/
|
||||
data class Text(
|
||||
val text: DSLSettingsText,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
fun register(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.dsl_text_preference))
|
||||
}
|
||||
}
|
||||
|
||||
class Model(val paddableText: Text) : PreferenceModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return super.areContentsTheSame(newItem) && newItem.paddableText == paddableText
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val text: TextView = itemView.findViewById(R.id.title)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
text.text = model.paddableText.text.resolve(context)
|
||||
|
||||
val clickableSpans = (text.text as? Spanned)?.getSpans(0, text.text.length, ClickableSpan::class.java)
|
||||
if (clickableSpans?.isEmpty() == false) {
|
||||
text.movementMethod = LinkMovementMethod.getInstance()
|
||||
} else {
|
||||
text.movementMethod = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
package org.thoughtcrime.securesms.keyvalue
|
||||
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.subjects.BehaviorSubject
|
||||
import io.reactivex.rxjava3.subjects.Subject
|
||||
import org.signal.donations.StripeApi
|
||||
import java.util.Currency
|
||||
import java.util.Locale
|
||||
|
||||
internal class DonationsValues internal constructor(store: KeyValueStore) : SignalStoreValues(store) {
|
||||
|
||||
companion object {
|
||||
private const val KEY_CURRENCY_CODE = "donation.currency.code"
|
||||
}
|
||||
|
||||
override fun onFirstEverAppLaunch() = Unit
|
||||
|
||||
override fun getKeysToIncludeInBackup(): MutableList<String> = mutableListOf(KEY_CURRENCY_CODE)
|
||||
|
||||
private val currencyPublisher: Subject<Currency> = BehaviorSubject.createDefault(getCurrency())
|
||||
val observableCurrency: Observable<Currency> = currencyPublisher
|
||||
|
||||
fun getCurrency(): Currency {
|
||||
val currencyCode = getString(KEY_CURRENCY_CODE, null)
|
||||
val currency = if (currencyCode == null) {
|
||||
Currency.getInstance(Locale.getDefault())
|
||||
} else {
|
||||
Currency.getInstance(currencyCode)
|
||||
}
|
||||
|
||||
return if (StripeApi.Validation.supportedCurrencyCodes.contains(currency.currencyCode)) {
|
||||
currency
|
||||
} else {
|
||||
Currency.getInstance("USD")
|
||||
}
|
||||
}
|
||||
|
||||
fun setCurrency(currency: Currency) {
|
||||
putString(KEY_CURRENCY_CODE, currency.currencyCode)
|
||||
currencyPublisher.onNext(currency)
|
||||
}
|
||||
}
|
|
@ -34,6 +34,7 @@ public final class SignalStore {
|
|||
private final OnboardingValues onboardingValues;
|
||||
private final WallpaperValues wallpaperValues;
|
||||
private final PaymentsValues paymentsValues;
|
||||
private final DonationsValues donationsValues;
|
||||
private final ProxyValues proxyValues;
|
||||
private final RateLimitValues rateLimitValues;
|
||||
private final ChatColorsValues chatColorsValues;
|
||||
|
@ -57,6 +58,7 @@ public final class SignalStore {
|
|||
this.onboardingValues = new OnboardingValues(store);
|
||||
this.wallpaperValues = new WallpaperValues(store);
|
||||
this.paymentsValues = new PaymentsValues(store);
|
||||
this.donationsValues = new DonationsValues(store);
|
||||
this.proxyValues = new ProxyValues(store);
|
||||
this.rateLimitValues = new RateLimitValues(store);
|
||||
this.chatColorsValues = new ChatColorsValues(store);
|
||||
|
@ -80,6 +82,7 @@ public final class SignalStore {
|
|||
onboarding().onFirstEverAppLaunch();
|
||||
wallpaper().onFirstEverAppLaunch();
|
||||
paymentsValues().onFirstEverAppLaunch();
|
||||
donationsValues().onFirstEverAppLaunch();
|
||||
proxy().onFirstEverAppLaunch();
|
||||
rateLimit().onFirstEverAppLaunch();
|
||||
chatColorsValues().onFirstEverAppLaunch();
|
||||
|
@ -104,6 +107,7 @@ public final class SignalStore {
|
|||
keys.addAll(onboarding().getKeysToIncludeInBackup());
|
||||
keys.addAll(wallpaper().getKeysToIncludeInBackup());
|
||||
keys.addAll(paymentsValues().getKeysToIncludeInBackup());
|
||||
keys.addAll(donationsValues().getKeysToIncludeInBackup());
|
||||
keys.addAll(proxy().getKeysToIncludeInBackup());
|
||||
keys.addAll(rateLimit().getKeysToIncludeInBackup());
|
||||
keys.addAll(chatColorsValues().getKeysToIncludeInBackup());
|
||||
|
@ -184,6 +188,10 @@ public final class SignalStore {
|
|||
return INSTANCE.paymentsValues;
|
||||
}
|
||||
|
||||
public static @NonNull DonationsValues donationsValues() {
|
||||
return INSTANCE.donationsValues;
|
||||
}
|
||||
|
||||
public static @NonNull ProxyValues proxy() {
|
||||
return INSTANCE.proxyValues;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,94 @@
|
|||
package org.thoughtcrime.securesms.subscription
|
||||
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Represents a Subscription that a user can start.
|
||||
*/
|
||||
data class Subscription(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val badge: Badge,
|
||||
val price: FiatMoney,
|
||||
) {
|
||||
|
||||
val renewalTimestamp = badge.expirationTimestamp
|
||||
|
||||
companion object {
|
||||
fun register(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.subscription_preference))
|
||||
}
|
||||
}
|
||||
|
||||
class Model(
|
||||
val subscription: Subscription,
|
||||
val isSelected: Boolean,
|
||||
val isActive: Boolean,
|
||||
override val isEnabled: Boolean,
|
||||
val onClick: () -> Unit
|
||||
) : PreferenceModel<Model>(isEnabled = isEnabled) {
|
||||
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return subscription.id == newItem.subscription.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return super.areContentsTheSame(newItem) &&
|
||||
newItem.subscription == subscription &&
|
||||
newItem.isSelected == isSelected &&
|
||||
newItem.isActive == isActive
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val badge: BadgeImageView = itemView.findViewById(R.id.badge)
|
||||
private val title: TextView = itemView.findViewById(R.id.title)
|
||||
private val tagline: TextView = itemView.findViewById(R.id.tagline)
|
||||
private val price: TextView = itemView.findViewById(R.id.price)
|
||||
private val check: ImageView = itemView.findViewById(R.id.check)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
itemView.isEnabled = model.isEnabled
|
||||
itemView.setOnClickListener { model.onClick() }
|
||||
itemView.isSelected = model.isSelected
|
||||
badge.setBadge(model.subscription.badge)
|
||||
title.text = model.subscription.title
|
||||
tagline.text = model.subscription.id
|
||||
|
||||
val formattedPrice = FiatMoneyUtil.format(
|
||||
context.resources,
|
||||
model.subscription.price,
|
||||
FiatMoneyUtil.formatOptions()
|
||||
)
|
||||
|
||||
if (model.isActive) {
|
||||
price.text = context.getString(
|
||||
R.string.Subscription__s_per_month_dot_renews_s,
|
||||
formattedPrice,
|
||||
DateUtils.formatDate(Locale.getDefault(), model.subscription.renewalTimestamp)
|
||||
)
|
||||
} else {
|
||||
price.text = context.getString(
|
||||
R.string.Subscription__s_per_month,
|
||||
formattedPrice
|
||||
)
|
||||
}
|
||||
|
||||
check.visible = model.isActive
|
||||
}
|
||||
}
|
||||
}
|
|
@ -10,7 +10,7 @@ import androidx.lifecycle.LifecycleOwner;
|
|||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
public abstract class MappingViewHolder<Model extends MappingModel<Model>> extends LifecycleViewHolder implements LifecycleOwner {
|
||||
public abstract class MappingViewHolder<Model> extends LifecycleViewHolder implements LifecycleOwner {
|
||||
|
||||
protected final Context context;
|
||||
protected final List<Object> payload;
|
||||
|
@ -36,7 +36,7 @@ public abstract class MappingViewHolder<Model extends MappingModel<Model>> exten
|
|||
this.payload.addAll(payload);
|
||||
}
|
||||
|
||||
public static final class SimpleViewHolder<Model extends MappingModel<Model>> extends MappingViewHolder<Model> {
|
||||
public static final class SimpleViewHolder<Model> extends MappingViewHolder<Model> {
|
||||
public SimpleViewHolder(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import android.content.res.Resources;
|
|||
import android.graphics.Typeface;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.os.Build;
|
||||
import android.text.Layout;
|
||||
import android.text.Spannable;
|
||||
import android.text.SpannableString;
|
||||
import android.text.SpannableStringBuilder;
|
||||
|
@ -12,6 +13,7 @@ import android.text.Spanned;
|
|||
import android.text.TextPaint;
|
||||
import android.text.TextUtils;
|
||||
import android.text.style.AbsoluteSizeSpan;
|
||||
import android.text.style.AlignmentSpan;
|
||||
import android.text.style.BulletSpan;
|
||||
import android.text.style.CharacterStyle;
|
||||
import android.text.style.ClickableSpan;
|
||||
|
@ -20,12 +22,14 @@ import android.text.style.ForegroundColorSpan;
|
|||
import android.text.style.ImageSpan;
|
||||
import android.text.style.RelativeSizeSpan;
|
||||
import android.text.style.StyleSpan;
|
||||
import android.text.style.TextAppearanceSpan;
|
||||
import android.text.style.TypefaceSpan;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.annotation.StyleRes;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
@ -40,6 +44,18 @@ public final class SpanUtil {
|
|||
private final static Typeface BOLD_TYPEFACE = Typeface.create("sans-serif-medium", Typeface.NORMAL);
|
||||
private final static Typeface LIGHT_TYPEFACE = Typeface.create("sans-serif", Typeface.NORMAL);
|
||||
|
||||
public static CharSequence center(@NonNull CharSequence sequence) {
|
||||
SpannableString spannable = new SpannableString(sequence);
|
||||
spannable.setSpan(new AlignmentSpan.Standard(Layout.Alignment.ALIGN_CENTER), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
return spannable;
|
||||
}
|
||||
|
||||
public static CharSequence textAppearance(@NonNull Context context, @StyleRes int textAppearance, @NonNull CharSequence sequence) {
|
||||
SpannableString spannable = new SpannableString(sequence);
|
||||
spannable.setSpan(new TextAppearanceSpan(context, textAppearance), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
return spannable;
|
||||
}
|
||||
|
||||
public static CharSequence italic(CharSequence sequence) {
|
||||
return italic(sequence, sequence.length());
|
||||
}
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:color="@color/signal_accent_primary_transparent_15" android:state_selected="true" />
|
||||
<item android:color="@color/transparent" />
|
||||
</selector>
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:color="@color/signal_accent_primary" android:state_selected="true" />
|
||||
<item android:color="@color/signal_button_secondary_stroke" />
|
||||
</selector>
|
7
app/src/main/res/drawable/expired_badge_fade.xml
Normal file
7
app/src/main/res/drawable/expired_badge_fade.xml
Normal file
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<gradient
|
||||
android:endColor="@color/signal_background_dialog"
|
||||
android:startColor="@color/transparent"
|
||||
android:type="linear" />
|
||||
</shape>
|
8
app/src/main/res/drawable/my_boost_gradient.xml
Normal file
8
app/src/main/res/drawable/my_boost_gradient.xml
Normal file
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<gradient
|
||||
android:angle="139.59"
|
||||
android:endColor="#4C3EAE"
|
||||
android:startColor="#439DF1"
|
||||
android:type="linear" />
|
||||
</shape>
|
5
app/src/main/res/drawable/rounded_outline_38dp.xml
Normal file
5
app/src/main/res/drawable/rounded_outline_38dp.xml
Normal file
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape android:shape="rectangle" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<stroke android:color="@color/signal_divider_minor" android:width="1dp" />
|
||||
<corners android:radius="38dp" />
|
||||
</shape>
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape android:shape="rectangle" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="@color/signal_accent_primary_transparent_15" />
|
||||
<stroke android:color="@color/signal_accent_primary" android:width="1dp" />
|
||||
<corners android:radius="38dp" />
|
||||
</shape>
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@drawable/rounded_outline_accent_38dp" android:state_focused="true" />
|
||||
<item android:drawable="@drawable/rounded_outline_38dp" />
|
||||
</selector>
|
15
app/src/main/res/drawable/selectable_rounded_outline.xml
Normal file
15
app/src/main/res/drawable/selectable_rounded_outline.xml
Normal file
|
@ -0,0 +1,15 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_selected="true">
|
||||
<shape android:shape="rectangle" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<stroke android:color="@color/signal_accent_primary" android:width="1dp" />
|
||||
<corners android:radius="10dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<stroke android:color="@color/signal_divider_minor" android:width="1dp" />
|
||||
<corners android:radius="10dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
|
@ -19,6 +19,20 @@
|
|||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:src="@drawable/circle_ultramarine" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageView
|
||||
android:id="@+id/checkmark"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:alpha="0"
|
||||
android:background="@drawable/circle_tintable"
|
||||
android:elevation="4dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitCenter"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:srcCompat="@drawable/v2_media_check"
|
||||
tools:alpha="1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/name"
|
||||
android:layout_width="wrap_content"
|
||||
|
|
133
app/src/main/res/layout/boost_preference.xml
Normal file
133
app/src/main/res/layout/boost_preference.xml
Normal file
|
@ -0,0 +1,133 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/dsl_settings_gutter"
|
||||
android:layout_marginTop="20dp"
|
||||
android:layout_marginEnd="@dimen/dsl_settings_gutter">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/boost_1"
|
||||
style="@style/Signal.Widget.Button.Large.Secondary"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48sp"
|
||||
android:textAppearance="@style/Signal.Text.Body"
|
||||
android:textColor="@color/signal_text_primary"
|
||||
app:backgroundTint="@color/signal_selectable_button_background_tint"
|
||||
app:cornerRadius="38dp"
|
||||
app:layout_constraintEnd_toStartOf="@id/boost_2"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:strokeColor="@color/signal_selectable_button_stroke"
|
||||
app:strokeWidth="1.5dp"
|
||||
tools:text="$3" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/boost_2"
|
||||
style="@style/Signal.Widget.Button.Large.Secondary"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48sp"
|
||||
android:layout_marginStart="10dp"
|
||||
android:layout_marginEnd="10dp"
|
||||
android:textAppearance="@style/Signal.Text.Body"
|
||||
android:textColor="@color/signal_text_primary"
|
||||
app:backgroundTint="@color/signal_selectable_button_background_tint"
|
||||
app:cornerRadius="38dp"
|
||||
app:layout_constraintEnd_toStartOf="@id/boost_3"
|
||||
app:layout_constraintStart_toEndOf="@id/boost_1"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:strokeColor="@color/signal_selectable_button_stroke"
|
||||
app:strokeWidth="1.5dp"
|
||||
tools:text="$5" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/boost_3"
|
||||
style="@style/Signal.Widget.Button.Large.Secondary"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48sp"
|
||||
android:textAppearance="@style/Signal.Text.Body"
|
||||
android:textColor="@color/signal_text_primary"
|
||||
app:backgroundTint="@color/signal_selectable_button_background_tint"
|
||||
app:cornerRadius="38dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/boost_2"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:strokeColor="@color/signal_selectable_button_stroke"
|
||||
app:strokeWidth="1.5dp"
|
||||
tools:text="$10" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/boost_4"
|
||||
style="@style/Signal.Widget.Button.Large.Secondary"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48sp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:textAppearance="@style/Signal.Text.Body"
|
||||
android:textColor="@color/signal_text_primary"
|
||||
app:backgroundTint="@color/signal_selectable_button_background_tint"
|
||||
app:cornerRadius="38dp"
|
||||
app:layout_constraintEnd_toStartOf="@id/boost_5"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/boost_1"
|
||||
app:strokeColor="@color/signal_selectable_button_stroke"
|
||||
app:strokeWidth="1.5dp"
|
||||
tools:text="$20" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/boost_5"
|
||||
style="@style/Signal.Widget.Button.Large.Secondary"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48sp"
|
||||
android:layout_marginStart="10dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:layout_marginEnd="10dp"
|
||||
android:textAppearance="@style/Signal.Text.Body"
|
||||
android:textColor="@color/signal_text_primary"
|
||||
app:backgroundTint="@color/signal_selectable_button_background_tint"
|
||||
app:cornerRadius="38dp"
|
||||
app:layout_constraintEnd_toStartOf="@id/boost_6"
|
||||
app:layout_constraintStart_toEndOf="@id/boost_4"
|
||||
app:layout_constraintTop_toBottomOf="@id/boost_2"
|
||||
app:strokeColor="@color/signal_selectable_button_stroke"
|
||||
app:strokeWidth="1.5dp"
|
||||
tools:text="$50" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/boost_6"
|
||||
style="@style/Signal.Widget.Button.Large.Secondary"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48sp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:gravity="center"
|
||||
android:textAppearance="@style/Signal.Text.Body"
|
||||
android:textColor="@color/signal_text_primary"
|
||||
app:backgroundTint="@color/signal_selectable_button_background_tint"
|
||||
app:cornerRadius="38dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/boost_5"
|
||||
app:layout_constraintTop_toBottomOf="@id/boost_3"
|
||||
app:strokeColor="@color/signal_selectable_button_stroke"
|
||||
app:strokeWidth="1.5dp"
|
||||
tools:text="$100" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatEditText
|
||||
android:id="@+id/boost_custom"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48sp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:background="@drawable/rounded_outline_focusable_38dp"
|
||||
android:gravity="center"
|
||||
android:hint="@string/Boost__enter_custom_amount"
|
||||
android:imeOptions="actionDone"
|
||||
android:inputType="numberDecimal"
|
||||
android:textAppearance="@style/Signal.Text.Body"
|
||||
android:textColor="@color/signal_text_primary"
|
||||
android:textColorHint="@color/signal_text_primary"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/boost_4" />
|
||||
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
6
app/src/main/res/layout/boost_preview_preference.xml
Normal file
6
app/src/main/res/layout/boost_preview_preference.xml
Normal file
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<org.thoughtcrime.securesms.badges.BadgeImageView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="96dp"
|
||||
android:layout_height="96dp"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginTop="24dp" />
|
10
app/src/main/res/layout/dsl_button_primary.xml
Normal file
10
app/src/main/res/layout/dsl_button_primary.xml
Normal file
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.button.MaterialButton xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/button"
|
||||
style="@style/Signal.Widget.Button.Large.Primary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/dsl_settings_gutter"
|
||||
android:layout_marginEnd="@dimen/dsl_settings_gutter"
|
||||
tools:text="Primary button" />
|
12
app/src/main/res/layout/dsl_button_secondary.xml
Normal file
12
app/src/main/res/layout/dsl_button_secondary.xml
Normal file
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.button.MaterialButton xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/button"
|
||||
style="@style/Signal.Widget.Button.Large.Secondary.NoOutline"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/dsl_settings_gutter"
|
||||
android:layout_marginEnd="@dimen/dsl_settings_gutter"
|
||||
app:iconGravity="textEnd"
|
||||
tools:text="Secondary button" />
|
|
@ -11,7 +11,7 @@
|
|||
|
||||
<TextView
|
||||
android:id="@+id/section_header"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="@style/TextAppearance.Signal.Body1.Bold"
|
||||
android:textStyle="bold"
|
||||
|
|
28
app/src/main/res/layout/dsl_settings_bottom_sheet.xml
Normal file
28
app/src/main/res/layout/dsl_settings_bottom_sheet.xml
Normal file
|
@ -0,0 +1,28 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/handle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:importantForAccessibility="no"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:srcCompat="@drawable/bottom_sheet_handle" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:orientation="vertical"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/handle" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
5
app/src/main/res/layout/dsl_space_preference.xml
Normal file
5
app/src/main/res/layout/dsl_space_preference.xml
Normal file
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<View xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/space"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp" />
|
11
app/src/main/res/layout/dsl_text_preference.xml
Normal file
11
app/src/main/res/layout/dsl_text_preference.xml
Normal file
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/dsl_settings_gutter"
|
||||
android:layout_marginEnd="@dimen/dsl_settings_gutter"
|
||||
android:textAlignment="viewStart"
|
||||
android:textAppearance="@style/Signal.Text.Body"
|
||||
tools:text="Message font size" />
|
33
app/src/main/res/layout/expired_badge_preference.xml
Normal file
33
app/src/main/res/layout/expired_badge_preference.xml
Normal file
|
@ -0,0 +1,33 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="28dp"
|
||||
android:layout_marginBottom="24dp">
|
||||
|
||||
<org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
android:id="@+id/expired_badge"
|
||||
android:layout_width="110dp"
|
||||
android:layout_height="110dp"
|
||||
app:layout_constraintHorizontal_bias="0.48"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:src="@drawable/test_gradient" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/expired_badge_fade"
|
||||
android:importantForAccessibility="no"
|
||||
android:layout_width="110dp"
|
||||
android:layout_height="110dp"
|
||||
app:layout_constraintHorizontal_bias="0.52"
|
||||
android:src="@drawable/expired_badge_fade"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
12
app/src/main/res/layout/google_pay_button_pref.xml
Normal file
12
app/src/main/res/layout/google_pay_button_pref.xml
Normal file
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/dsl_settings_gutter"
|
||||
android:layout_marginEnd="@dimen/dsl_settings_gutter">
|
||||
|
||||
<include
|
||||
android:id="@+id/googlepay_button"
|
||||
layout="@layout/donate_with_googlepay_button" />
|
||||
|
||||
</FrameLayout>
|
87
app/src/main/res/layout/my_support_preference.xml
Normal file
87
app/src/main/res/layout/my_support_preference.xml
Normal file
|
@ -0,0 +1,87 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/dsl_settings_gutter"
|
||||
android:layout_marginEnd="@dimen/dsl_settings_gutter"
|
||||
app:cardCornerRadius="10dp"
|
||||
app:cardElevation="0dp">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/rounded_outline">
|
||||
|
||||
<org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
android:id="@+id/my_support_badge"
|
||||
android:layout_width="64dp"
|
||||
android:layout_height="64dp"
|
||||
android:layout_marginStart="16dp"
|
||||
app:badge_size="xlarge"
|
||||
app:layout_constraintBottom_toBottomOf="@id/my_support_expiry"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@id/my_support_title"
|
||||
tools:src="@drawable/test_gradient" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/my_support_title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:layout_marginTop="15dp"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:textAppearance="@style/TextAppearance.Signal.Body1.Bold"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/my_support_badge"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:text="Subscription Name" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/my_support_price"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:layout_marginTop="4dp"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:textAppearance="@style/TextAppearance.Signal.Body2"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/my_support_badge"
|
||||
app:layout_constraintTop_toBottomOf="@id/my_support_title"
|
||||
tools:text="Earn a badge!" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/my_support_expiry"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:layout_marginTop="4dp"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:textAppearance="@style/TextAppearance.Signal.Body2"
|
||||
android:textColor="@color/signal_text_secondary"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/my_support_badge"
|
||||
app:layout_constraintTop_toBottomOf="@id/my_support_price"
|
||||
tools:text="$400.00" />
|
||||
|
||||
<androidx.constraintlayout.widget.Barrier
|
||||
android:id="@+id/my_support_heading_barrier"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:barrierDirection="bottom"
|
||||
app:barrierMargin="16dp"
|
||||
app:constraint_referenced_ids="my_support_badge,my_support_expiry,my_support_price,my_support_title" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/my_support_boost"
|
||||
android:background="@drawable/my_boost_gradient"
|
||||
style="@style/Signal.Widget.Button.Large.Primary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/MySupportPreference__add_a_signal_boost"
|
||||
app:cornerRadius="0dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/my_support_heading_barrier" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</androidx.cardview.widget.CardView>
|
13
app/src/main/res/layout/subscribe_activity.xml
Normal file
13
app/src/main/res/layout/subscribe_activity.xml
Normal file
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<include
|
||||
android:id="@+id/donate_with_googlepay"
|
||||
layout="@layout/donate_with_googlepay_button" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -0,0 +1,95 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/subscribe_bottom_sheet_handle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:importantForAccessibility="no"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:srcCompat="@drawable/bottom_sheet_handle" />
|
||||
|
||||
<!-- TODO [alex] - need final asset -->
|
||||
<ImageView
|
||||
android:id="@+id/subscribe_bottom_sheet_heart"
|
||||
android:layout_width="76dp"
|
||||
android:layout_height="74dp"
|
||||
android:layout_marginTop="36dp"
|
||||
android:importantForAccessibility="no"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/subscribe_bottom_sheet_handle" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/subscribe_bottom_sheet_headline"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/dsl_settings_gutter"
|
||||
android:layout_marginTop="28dp"
|
||||
android:layout_marginEnd="@dimen/dsl_settings_gutter"
|
||||
android:text="@string/SubscribeFragment__support_technology_that_is_built_for_you"
|
||||
android:textAppearance="@style/TextAppearance.Signal.Title2"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/subscribe_bottom_sheet_heart" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/subscribe_bottom_sheet_subhead"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/dsl_settings_gutter"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginEnd="@dimen/dsl_settings_gutter"
|
||||
android:text="@string/SubscribeLearnMoreBottomSheetDialogFragment__signal_is_a_non_profit_with_no"
|
||||
android:textAppearance="@style/Signal.Text.Body"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/subscribe_bottom_sheet_headline" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/subscribe_bottom_why"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/dsl_settings_gutter"
|
||||
android:layout_marginTop="36dp"
|
||||
android:layout_marginEnd="@dimen/dsl_settings_gutter"
|
||||
android:text="@string/SubscribeLearnMoreBottomSheetDialogFragment__why_contribute"
|
||||
android:textAppearance="@style/TextAppearance.Signal.Title2"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/subscribe_bottom_sheet_subhead" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/subscribe_bottom_sheet_the_team"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/dsl_settings_gutter"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginEnd="@dimen/dsl_settings_gutter"
|
||||
android:text="@string/SubscribeLearnMoreBottomSheetDialogFragment__the_team_at_signal_is_committed"
|
||||
android:textAppearance="@style/Signal.Text.Body"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/subscribe_bottom_why" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/subscribe_bottom_sheet_your_contribution"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/dsl_settings_gutter"
|
||||
android:layout_marginTop="24dp"
|
||||
android:layout_marginEnd="@dimen/dsl_settings_gutter"
|
||||
android:paddingBottom="36dp"
|
||||
android:text="@string/SubscribeLearnMoreBottomSheetDialogFragment__your_contribution_helps_pay"
|
||||
android:textAppearance="@style/Signal.Text.Body"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/subscribe_bottom_sheet_the_team" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
39
app/src/main/res/layout/subscription_currency_selection.xml
Normal file
39
app/src/main/res/layout/subscription_currency_selection.xml
Normal file
|
@ -0,0 +1,39 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="@dimen/dsl_settings_gutter"
|
||||
android:paddingEnd="@dimen/dsl_settings_gutter">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/subscription_currency_selection_donation_amount"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="13dp"
|
||||
android:gravity="center_vertical|end"
|
||||
android:minHeight="48dp"
|
||||
android:text="@string/SubscribeFragment__donation_amount"
|
||||
android:textAppearance="@style/Signal.Text.Body"
|
||||
app:layout_constraintEnd_toStartOf="@id/subscription_currency_selection_spinner"
|
||||
app:layout_constraintHorizontal_chainStyle="packed"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatTextView
|
||||
android:id="@+id/subscription_currency_selection_spinner"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/circled_rectangle_outline"
|
||||
android:drawablePadding="8dp"
|
||||
android:padding="12dp"
|
||||
app:drawableEndCompat="@drawable/ic_chevron_down_20"
|
||||
app:drawableTint="@color/conversation_mention_background_color"
|
||||
app:layout_constraintBottom_toBottomOf="@id/subscription_currency_selection_donation_amount"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/subscription_currency_selection_donation_amount"
|
||||
app:layout_constraintTop_toTopOf="@id/subscription_currency_selection_donation_amount"
|
||||
tools:text="USD" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -0,0 +1,33 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardCornerRadius="0dp"
|
||||
app:cardElevation="0dp">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginTop="24dp"
|
||||
android:layout_marginBottom="24dp">
|
||||
|
||||
<org.thoughtcrime.securesms.components.AvatarImageView
|
||||
android:id="@+id/avatar"
|
||||
android:layout_width="88dp"
|
||||
android:layout_height="88dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
android:id="@+id/badge"
|
||||
android:layout_width="33dp"
|
||||
android:layout_height="33dp"
|
||||
android:contentDescription="@string/BadgesOverviewFragment__featured_badge"
|
||||
app:layout_constraintBottom_toBottomOf="@id/avatar"
|
||||
app:layout_constraintEnd_toEndOf="@id/avatar" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</androidx.cardview.widget.CardView>
|
71
app/src/main/res/layout/subscription_preference.xml
Normal file
71
app/src/main/res/layout/subscription_preference.xml
Normal file
|
@ -0,0 +1,71 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/dsl_settings_gutter"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginEnd="@dimen/dsl_settings_gutter"
|
||||
android:background="@drawable/selectable_rounded_outline"
|
||||
android:padding="16dp">
|
||||
|
||||
<org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
android:id="@+id/badge"
|
||||
android:layout_width="64dp"
|
||||
android:layout_height="64dp"
|
||||
app:badge_size="xlarge"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:textAppearance="@style/TextAppearance.Signal.Body1.Bold"
|
||||
app:layout_constraintEnd_toStartOf="@id/check"
|
||||
app:layout_constraintStart_toEndOf="@id/badge"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:text="Subscription Name" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageView
|
||||
android:id="@+id/check"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:importantForAccessibility="no"
|
||||
app:layout_constraintBottom_toBottomOf="@id/title"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/title"
|
||||
app:layout_constraintTop_toTopOf="@id/title"
|
||||
app:srcCompat="@drawable/ic_check_24"
|
||||
app:tint="@color/signal_inverse_primary" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tagline"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textAppearance="@style/TextAppearance.Signal.Body2"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/badge"
|
||||
app:layout_constraintTop_toBottomOf="@id/title"
|
||||
tools:text="Earn a badge!" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/price"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textAppearance="@style/TextAppearance.Signal.Body2"
|
||||
android:textColor="@color/signal_text_secondary"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/badge"
|
||||
app:layout_constraintTop_toBottomOf="@id/tagline"
|
||||
tools:text="$400.00" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -0,0 +1,124 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/thanks_bottom_sheet_handle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:importantForAccessibility="no"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:srcCompat="@drawable/bottom_sheet_handle" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/thanks_bottom_sheet_heading"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/dsl_settings_gutter"
|
||||
android:layout_marginTop="20dp"
|
||||
android:layout_marginEnd="@dimen/dsl_settings_gutter"
|
||||
android:gravity="center"
|
||||
android:text="@string/SubscribeThanksForYourSupportBottomSheetDialogFragment__thanks_for_your_support"
|
||||
android:textAppearance="@style/TextAppearance.Signal.Title2"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/thanks_bottom_sheet_handle" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/thanks_bottom_sheet_subhead"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/dsl_settings_gutter"
|
||||
android:layout_marginTop="12dp"
|
||||
android:layout_marginEnd="@dimen/dsl_settings_gutter"
|
||||
android:gravity="center"
|
||||
android:textAppearance="@style/Signal.Text.Body"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/thanks_bottom_sheet_heading"
|
||||
tools:text="@string/SubscribeThanksForYourSupportBottomSheetDialogFragment__youve_earned_a_s_badge" />
|
||||
|
||||
<org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
android:id="@+id/thanks_bottom_sheet_badge"
|
||||
android:layout_width="112dp"
|
||||
android:layout_height="112dp"
|
||||
android:layout_marginTop="26dp"
|
||||
app:badge_size="xlarge"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/thanks_bottom_sheet_subhead"
|
||||
tools:src="@drawable/test_gradient" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/thanks_bottom_sheet_badge_name"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/dsl_settings_gutter"
|
||||
android:layout_marginTop="12dp"
|
||||
android:layout_marginEnd="@dimen/dsl_settings_gutter"
|
||||
android:gravity="center"
|
||||
android:textAppearance="@style/TextAppearance.Signal.Title2"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/thanks_bottom_sheet_badge"
|
||||
tools:text="Signal Meteor" />
|
||||
|
||||
<View
|
||||
android:id="@+id/thanks_bottom_sheet_control_outline"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:background="@drawable/rounded_outline"
|
||||
app:layout_constraintBottom_toBottomOf="@id/thanks_bottom_sheet_display_on_profile"
|
||||
app:layout_constraintEnd_toEndOf="@id/thanks_bottom_sheet_switch"
|
||||
app:layout_constraintStart_toStartOf="@id/thanks_bottom_sheet_display_on_profile"
|
||||
app:layout_constraintTop_toTopOf="@id/thanks_bottom_sheet_display_on_profile" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/thanks_bottom_sheet_display_on_profile"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48sp"
|
||||
android:layout_marginStart="@dimen/dsl_settings_gutter"
|
||||
android:layout_marginTop="32dp"
|
||||
android:layout_marginEnd="@dimen/dsl_settings_gutter"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:text="@string/SubscribeThanksForYourSupportBottomSheetDialogFragment__display_on_profile"
|
||||
android:textAppearance="@style/TextAppearance.Signal.Body1"
|
||||
app:layout_constraintEnd_toStartOf="@id/thanks_bottom_sheet_switch"
|
||||
app:layout_constraintHorizontal_chainStyle="spread"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/thanks_bottom_sheet_badge_name" />
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/thanks_bottom_sheet_switch"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="48sp"
|
||||
android:layout_marginEnd="@dimen/dsl_settings_gutter"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp"
|
||||
app:layout_constraintBottom_toBottomOf="@id/thanks_bottom_sheet_display_on_profile"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/thanks_bottom_sheet_display_on_profile"
|
||||
app:layout_constraintTop_toTopOf="@id/thanks_bottom_sheet_display_on_profile" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/thanks_bottom_sheet_done"
|
||||
style="@style/Signal.Widget.Button.Large.Primary"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48sp"
|
||||
android:layout_marginStart="@dimen/dsl_settings_gutter"
|
||||
android:layout_marginTop="36dp"
|
||||
android:layout_marginEnd="@dimen/dsl_settings_gutter"
|
||||
android:text="@string/SubscribeThanksForYourSupportBottomSheetDialogFragment__done"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/thanks_bottom_sheet_display_on_profile" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -25,6 +25,7 @@
|
|||
android:id="@+id/tab_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="gone"
|
||||
app:tabBackground="@drawable/tab_selector"
|
||||
app:tabGravity="center"
|
||||
app:tabIndicatorHeight="0dp" />
|
||||
|
|
|
@ -94,6 +94,27 @@
|
|||
app:exitAnim="@anim/fragment_open_exit"
|
||||
app:popEnterAnim="@anim/fragment_close_enter"
|
||||
app:popExitAnim="@anim/fragment_close_exit" />
|
||||
<action
|
||||
android:id="@+id/action_appSettingsFragment_to_subscriptions"
|
||||
app:destination="@id/subscriptions"
|
||||
app:enterAnim="@anim/fragment_open_enter"
|
||||
app:exitAnim="@anim/fragment_open_exit"
|
||||
app:popEnterAnim="@anim/fragment_close_enter"
|
||||
app:popExitAnim="@anim/fragment_close_exit">
|
||||
|
||||
<argument
|
||||
android:name="skipToSubscribe"
|
||||
app:argType="boolean"
|
||||
android:defaultValue="false" />
|
||||
|
||||
</action>
|
||||
<action
|
||||
android:id="@+id/action_appSettingsFragment_to_boostsFragment"
|
||||
app:destination="@id/boosts"
|
||||
app:enterAnim="@anim/fragment_open_enter"
|
||||
app:exitAnim="@anim/fragment_open_exit"
|
||||
app:popEnterAnim="@anim/fragment_close_enter"
|
||||
app:popExitAnim="@anim/fragment_close_exit" />
|
||||
</fragment>
|
||||
|
||||
<activity
|
||||
|
@ -139,6 +160,10 @@
|
|||
|
||||
<include app:graph="@navigation/app_settings_change_number" />
|
||||
|
||||
<include app:graph="@navigation/subscriptions" />
|
||||
|
||||
<include app:graph="@navigation/boosts" />
|
||||
|
||||
<fragment
|
||||
android:id="@+id/advancedPinSettingsFragment"
|
||||
android:name="org.thoughtcrime.securesms.components.settings.app.wrapped.WrappedAdvancedPinPreferenceFragment"
|
||||
|
|
48
app/src/main/res/navigation/boosts.xml
Normal file
48
app/src/main/res/navigation/boosts.xml
Normal file
|
@ -0,0 +1,48 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/boosts"
|
||||
app:startDestination="@id/boostFragment">
|
||||
|
||||
<dialog
|
||||
android:id="@+id/boostFragment"
|
||||
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.boost.BoostFragment"
|
||||
android:label="boost_fragment"
|
||||
tools:layout="@layout/dsl_settings_fragment">
|
||||
<action
|
||||
android:id="@+id/action_boostFragment_to_setDonationCurrencyFragment"
|
||||
app:destination="@id/setDonationCurrencyFragment"
|
||||
app:enterAnim="@anim/fragment_open_enter"
|
||||
app:exitAnim="@anim/fragment_open_exit"
|
||||
app:popEnterAnim="@anim/fragment_close_enter"
|
||||
app:popExitAnim="@anim/fragment_close_exit" />
|
||||
<action
|
||||
android:id="@+id/action_boostFragment_to_boostThanksForYourSupportBottomSheetDialog"
|
||||
app:destination="@id/boostThanksForYourSupportBottomSheetDialog" />
|
||||
</dialog>
|
||||
|
||||
<dialog
|
||||
android:id="@+id/setDonationCurrencyFragment"
|
||||
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.currency.SetCurrencyFragment"
|
||||
android:label="set_currency_fragment"
|
||||
tools:layout="@layout/dsl_settings_fragment" />
|
||||
|
||||
<dialog
|
||||
android:id="@+id/boostThanksForYourSupportBottomSheetDialog"
|
||||
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragment"
|
||||
android:label="boost_thanks_for_your_support_bottom_sheet_dialog"
|
||||
tools:layout="@layout/thanks_for_your_support_bottom_sheet_dialog_fragment">
|
||||
|
||||
<argument
|
||||
android:name="badge"
|
||||
app:argType="org.thoughtcrime.securesms.badges.models.Badge"
|
||||
app:nullable="false" />
|
||||
|
||||
<argument
|
||||
android:name="isBoost"
|
||||
app:argType="boolean"
|
||||
android:defaultValue="false" />
|
||||
</dialog>
|
||||
|
||||
</navigation>
|
40
app/src/main/res/navigation/manage_badges.xml
Normal file
40
app/src/main/res/navigation/manage_badges.xml
Normal file
|
@ -0,0 +1,40 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/manage_badges"
|
||||
app:startDestination="@id/badgeManageFragment">
|
||||
<fragment
|
||||
android:id="@+id/badgeManageFragment"
|
||||
android:name="org.thoughtcrime.securesms.badges.self.overview.BadgesOverviewFragment"
|
||||
android:label="fragment_manage_badges">
|
||||
|
||||
<action
|
||||
android:id="@+id/action_badgeManageFragment_to_featuredBadgeFragment"
|
||||
app:destination="@id/featuredBadgeFragment"
|
||||
app:enterAnim="@anim/nav_default_enter_anim"
|
||||
app:exitAnim="@anim/nav_default_exit_anim"
|
||||
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
|
||||
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
|
||||
<action
|
||||
android:id="@+id/action_badgeManageFragment_to_expiredBadgeDialog"
|
||||
app:destination="@id/expiredBadgeDialog" />
|
||||
|
||||
</fragment>
|
||||
|
||||
<fragment
|
||||
android:id="@+id/featuredBadgeFragment"
|
||||
android:name="org.thoughtcrime.securesms.badges.self.featured.SelectFeaturedBadgeFragment"
|
||||
android:label="fragment_featured_badge" />
|
||||
|
||||
<dialog
|
||||
android:id="@+id/expiredBadgeDialog"
|
||||
android:name="org.thoughtcrime.securesms.badges.self.expired.ExpiredBadgeBottomSheetDialogFragment"
|
||||
android:label="dialog_expired_badge">
|
||||
|
||||
<argument
|
||||
android:name="badge"
|
||||
app:argType="org.thoughtcrime.securesms.badges.models.Badge"
|
||||
app:nullable="false" />
|
||||
|
||||
</dialog>
|
||||
</navigation>
|
|
@ -57,7 +57,7 @@
|
|||
|
||||
<action
|
||||
android:id="@+id/action_manageProfileFragment_to_badgeManageFragment"
|
||||
app:destination="@id/badgeManageFragment"
|
||||
app:destination="@id/manage_badges"
|
||||
app:enterAnim="@anim/nav_default_enter_anim"
|
||||
app:exitAnim="@anim/nav_default_exit_anim"
|
||||
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
|
||||
|
@ -83,25 +83,7 @@
|
|||
android:label="fragment_manage_about"
|
||||
tools:layout="@layout/edit_about_fragment" />
|
||||
|
||||
<fragment
|
||||
android:id="@+id/badgeManageFragment"
|
||||
android:name="org.thoughtcrime.securesms.badges.self.overview.BadgesOverviewFragment"
|
||||
android:label="fragment_manage_badges" >
|
||||
|
||||
<action
|
||||
android:id="@+id/action_badgeManageFragment_to_featuredBadgeFragment"
|
||||
app:destination="@id/featuredBadgeFragment"
|
||||
app:enterAnim="@anim/nav_default_enter_anim"
|
||||
app:exitAnim="@anim/nav_default_exit_anim"
|
||||
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
|
||||
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
|
||||
|
||||
</fragment>
|
||||
|
||||
<fragment
|
||||
android:id="@+id/featuredBadgeFragment"
|
||||
android:name="org.thoughtcrime.securesms.badges.self.featured.SelectFeaturedBadgeFragment"
|
||||
android:label="fragment_featured_badge" />
|
||||
<include app:graph="@navigation/manage_badges" />
|
||||
|
||||
<include app:graph="@navigation/avatar_picker" />
|
||||
|
||||
|
|
103
app/src/main/res/navigation/subscriptions.xml
Normal file
103
app/src/main/res/navigation/subscriptions.xml
Normal file
|
@ -0,0 +1,103 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/subscriptions"
|
||||
app:startDestination="@id/manageDonationsFragment">
|
||||
|
||||
<fragment
|
||||
android:id="@+id/manageDonationsFragment"
|
||||
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.manage.ManageDonationsFragment"
|
||||
android:label="manage_donations_fragment"
|
||||
tools:layout="@layout/dsl_settings_fragment">
|
||||
|
||||
<action
|
||||
android:id="@+id/action_manageDonationsFragment_to_subscribeFragment"
|
||||
app:destination="@id/subscribeFragment"
|
||||
app:enterAnim="@anim/fragment_open_enter"
|
||||
app:exitAnim="@anim/fragment_open_exit"
|
||||
app:popEnterAnim="@anim/fragment_close_enter"
|
||||
app:popExitAnim="@anim/fragment_close_exit" />
|
||||
|
||||
<action
|
||||
android:id="@+id/action_manageDonationsFragment_to_subscriptionBadgeManageFragment"
|
||||
app:destination="@id/manage_badges"
|
||||
app:enterAnim="@anim/nav_default_enter_anim"
|
||||
app:exitAnim="@anim/nav_default_exit_anim"
|
||||
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
|
||||
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
|
||||
|
||||
<action
|
||||
android:id="@+id/action_manageDonationsFragment_to_manage_badges"
|
||||
app:destination="@id/manage_badges" />
|
||||
|
||||
<action
|
||||
android:id="@+id/action_manageDonationsFragment_to_boosts"
|
||||
app:destination="@id/boosts" />
|
||||
|
||||
<argument
|
||||
android:name="skipToSubscribe"
|
||||
android:defaultValue="false"
|
||||
app:argType="boolean" />
|
||||
</fragment>
|
||||
|
||||
<fragment
|
||||
android:id="@+id/subscribeFragment"
|
||||
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.subscribe.SubscribeFragment"
|
||||
android:label="subscribe_fragment"
|
||||
tools:layout="@layout/dsl_settings_fragment">
|
||||
<action
|
||||
android:id="@+id/action_subscribeFragment_to_setDonationCurrencyFragment"
|
||||
app:destination="@id/setDonationCurrencyFragment"
|
||||
app:enterAnim="@anim/fragment_open_enter"
|
||||
app:exitAnim="@anim/fragment_open_exit"
|
||||
app:popEnterAnim="@anim/fragment_close_enter"
|
||||
app:popExitAnim="@anim/fragment_close_exit" />
|
||||
|
||||
<action
|
||||
android:id="@+id/action_subscribeFragment_to_subscribeLearnMoreBottomSheetDialog"
|
||||
app:destination="@id/subscribeLearnMoreBottomSheetDialog" />
|
||||
|
||||
<action
|
||||
android:id="@+id/action_subscribeFragment_to_subscribeThanksForYourSupportBottomSheetDialog"
|
||||
app:destination="@id/subscribeThanksForYourSupportBottomSheetDialog" />
|
||||
</fragment>
|
||||
|
||||
<dialog
|
||||
android:id="@+id/setDonationCurrencyFragment"
|
||||
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.currency.SetCurrencyFragment"
|
||||
android:label="set_currency_fragment"
|
||||
tools:layout="@layout/dsl_settings_fragment" />
|
||||
|
||||
<dialog
|
||||
android:id="@+id/subscribeLearnMoreBottomSheetDialog"
|
||||
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.subscribe.SubscribeLearnMoreBottomSheetDialogFragment"
|
||||
android:label="subscribe_learn_more_bottom_sheet_dialog"
|
||||
tools:layout="@layout/subscribe_learn_more_bottom_sheet_dialog_fragment" />
|
||||
|
||||
<dialog
|
||||
android:id="@+id/subscribeThanksForYourSupportBottomSheetDialog"
|
||||
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragment"
|
||||
android:label="subscribe_thanks_for_your_support_bottom_sheet_dialog"
|
||||
tools:layout="@layout/thanks_for_your_support_bottom_sheet_dialog_fragment">
|
||||
|
||||
<argument
|
||||
android:name="badge"
|
||||
app:argType="org.thoughtcrime.securesms.badges.models.Badge"
|
||||
app:nullable="false" />
|
||||
|
||||
<argument
|
||||
android:name="isBoost"
|
||||
android:defaultValue="false"
|
||||
app:argType="boolean" />
|
||||
</dialog>
|
||||
|
||||
<include app:graph="@navigation/manage_badges" />
|
||||
|
||||
<include app:graph="@navigation/boosts" />
|
||||
|
||||
<action
|
||||
android:id="@+id/action_directly_to_subscribe"
|
||||
app:destination="@id/subscribeFragment" />
|
||||
|
||||
</navigation>
|
|
@ -164,4 +164,6 @@
|
|||
|
||||
<color name="voice_note_player_view_background">@color/core_grey_80</color>
|
||||
<color name="voice_note_player_speed_background_tint">@color/core_grey_65</color>
|
||||
|
||||
<color name="signal_accent_primary_transparent_15">#266191f3</color>
|
||||
</resources>
|
||||
|
|
|
@ -164,4 +164,6 @@
|
|||
|
||||
<color name="voice_note_player_view_background">@color/core_grey_02</color>
|
||||
<color name="voice_note_player_speed_background_tint">@color/transparent_black_08</color>
|
||||
|
||||
<color name="signal_accent_primary_transparent_15">#262c6bed</color>
|
||||
</resources>
|
||||
|
|
|
@ -2352,6 +2352,8 @@
|
|||
<string name="preferences__help">Help</string>
|
||||
<string name="preferences__advanced">Advanced</string>
|
||||
<string name="preferences__donate_to_signal">Donate to Signal</string>
|
||||
<string name="preferences__subscription">Subscription</string>
|
||||
<string name="preferences__signal_boost">Signal Boost</string>
|
||||
<string name="preferences__privacy">Privacy</string>
|
||||
<string name="preferences__mms_user_agent">MMS User Agent</string>
|
||||
<string name="preferences__advanced_mms_access_point_names">Manual MMS settings</string>
|
||||
|
@ -3866,6 +3868,55 @@
|
|||
|
||||
<string name="ImageView__badge">Badge</string>
|
||||
|
||||
<string name="SubscribeFragment__signal_is_powered_by_people_like_you">Signal is powered by people like you.</string>
|
||||
<string name="SubscribeFragment__support_technology_that_is_built_for_you">Support technology that is built for you—not for your data—by joining the community of people that sustain it.</string>
|
||||
<string name="SubscribeFragment__donation_amount">Donation amount</string>
|
||||
<string name="SubscribeFragment__more_payment_options">More Payment Options</string>
|
||||
<string name="SubscribeFragment__cancel_subscription">Cancel Subscription</string>
|
||||
<string name="SubscribeFragment__confirm_cancellation">Confirm Cancellation?</string>
|
||||
<string name="SubscribeFragment__you_wont_be_charged_again">You won\'t be charged again. Your badge will be removed from your profile at the end of your billing period.</string>
|
||||
<string name="SubscribeFragment__not_now">Not now</string>
|
||||
<string name="SubscribeFragment__confirm">Confirm</string>
|
||||
<string name="SubscribeFragment__update_subscription">Update Subscription</string>
|
||||
<string name="SubscribeFragment__your_subscription_has_been_cancelled">Your subscription has been cancelled.</string>
|
||||
|
||||
<string name="Subscription__s_per_month">%s/month</string>
|
||||
<string name="Subscription__s_per_month_dot_renews_s">%1$s/month · Renews %2$s</string>
|
||||
|
||||
<string name="SubscribeLearnMoreBottomSheetDialogFragment__signal_is_a_non_profit_with_no">Signal is a non-profit with no advertisers or investors, sustained only by the people who use and value it. Make a recurring monthly contribution and receive a profile badge to share your support.</string>
|
||||
<string name="SubscribeLearnMoreBottomSheetDialogFragment__why_contribute">Why Contribute?</string>
|
||||
<string name="SubscribeLearnMoreBottomSheetDialogFragment__the_team_at_signal_is_committed">The team at Signal is committed to the mission of developing open source privacy technology that protects free expression and enables secure global communication. Your contribution fuels this cause. No ads. No trackers. No kidding.</string>
|
||||
<string name="SubscribeLearnMoreBottomSheetDialogFragment__your_contribution_helps_pay">Your contribution helps pay for the development, servers, and bandwidth of an app used by millions around the world for private and instantaneous communication.</string>
|
||||
|
||||
<string name="SubscribeThanksForYourSupportBottomSheetDialogFragment__thanks_for_your_support">Thanks for your Support!</string>
|
||||
<string name="SubscribeThanksForYourSupportBottomSheetDialogFragment__thanks_for_the_boost">Thanks for the Boost!</string>
|
||||
<string name="SubscribeThanksForYourSupportBottomSheetDialogFragment__youve_earned_a_s_badge">You\'ve earned a %s badge! Displaying your badge will show people you chat with that you support Signal.</string>
|
||||
<string name="SubscribeThanksForYourSupportBottomSheetDialogFragment__display_on_profile">Display on Profile</string>
|
||||
<string name="SubscribeThanksForYourSupportBottomSheetDialogFragment__make_featured_badge">Make featured badge</string>
|
||||
<string name="SubscribeThanksForYourSupportBottomSheetDialogFragment__done">Done</string>
|
||||
|
||||
<string name="ManageDonationsFragment__my_support">My support</string>
|
||||
<string name="ManageDonationsFragment__manage_subscription">Manage subscription</string>
|
||||
<string name="ManageDonationsFragment__badges">Badges</string>
|
||||
<string name="ManageDonationsFragment__subscription_faq">Subscription FAQ</string>
|
||||
<string name="ManageDonationsFragment__error_getting_subscription">Error getting subscription.</string>
|
||||
|
||||
<string name="BoostFragment__give_signal_a_boost">Give Signal a Boost</string>
|
||||
<string name="BoostFragment__say_thanks_and_earn">Say "Thanks!" and earn the Boost badge for %1$d days.</string>
|
||||
|
||||
<string name="Boost__enter_custom_amount">Enter Custom Amount</string>
|
||||
<string name="Boost__one_time_contribution">One-time contribution</string>
|
||||
|
||||
<string name="MySupportPreference__add_a_signal_boost">Add a Signal Boost</string>
|
||||
<string name="MySupportPreference__s_per_month">%1$s/month</string>
|
||||
<string name="MySupportPreference__renews_s">Renews %1$s</string>
|
||||
|
||||
<string name="ExpiredBadgeBottomSheetDialogFragment__your_badge_has_expired">Your Badge has Expired</string>
|
||||
<string name="ExpiredBadgeBottomSheetDialogFragment__your_s_badge_has_expired">Your %1$s badge has expired, and is no longer visible to others on your profile.</string>
|
||||
<string name="ExpiredBadgeBottomSheetDialogFragment__to_continue_supporting">To continue supporting technology that is built for you—not for your data—please consider becoming a monthly subscriber.</string>
|
||||
<string name="ExpiredBadgeBottomSheetDialogFragment__become_a_subscriber">Become a subscriber</string>
|
||||
<string name="ExpiredBadgeBottomSheetDialogFragment__not_now">Not now</string>
|
||||
|
||||
<!-- EOF -->
|
||||
|
||||
</resources>
|
||||
|
|
|
@ -0,0 +1,135 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
|
||||
|
||||
import android.app.Application
|
||||
import android.text.SpannableStringBuilder
|
||||
import junit.framework.Assert.assertEquals
|
||||
import junit.framework.Assert.assertNotNull
|
||||
import junit.framework.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import java.util.Currency
|
||||
import java.util.Locale
|
||||
|
||||
@Suppress("ClassName")
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(manifest = Config.NONE, application = Application::class)
|
||||
class BoostTest__MoneyFilter {
|
||||
|
||||
private val usd = Currency.getInstance("USD")
|
||||
private val yen = Currency.getInstance("JPY")
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Locale.setDefault(Locale.US)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given USD, when I enter 5, then I expect $ 5`() {
|
||||
val testSubject = Boost.MoneyFilter(usd)
|
||||
val editable = SpannableStringBuilder("5")
|
||||
|
||||
testSubject.afterTextChanged(editable)
|
||||
|
||||
assertEquals("$ 5", editable.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given USD, when I enter 5dot00, then I expect successful filter`() {
|
||||
val testSubject = Boost.MoneyFilter(usd)
|
||||
val editable = SpannableStringBuilder("5.00")
|
||||
val dest = SpannableStringBuilder()
|
||||
|
||||
testSubject.afterTextChanged(editable)
|
||||
val filterResult = testSubject.filter(editable, 0, editable.length, dest, 0, 0)
|
||||
|
||||
assertNull(filterResult)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given USD, when I enter 5dot00, then I expect 5dot00 from text change`() {
|
||||
var result = ""
|
||||
val testSubject = Boost.MoneyFilter(usd) {
|
||||
result = it
|
||||
}
|
||||
|
||||
val editable = SpannableStringBuilder("5.00")
|
||||
testSubject.afterTextChanged(editable)
|
||||
|
||||
assertEquals("5.00", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given USD, when I enter 5dot000, then I expect unsuccessful filter`() {
|
||||
val testSubject = Boost.MoneyFilter(yen)
|
||||
val editable = SpannableStringBuilder("5.000")
|
||||
val dest = SpannableStringBuilder()
|
||||
|
||||
testSubject.afterTextChanged(editable)
|
||||
val filterResult = testSubject.filter(editable, 0, editable.length, dest, 0, 0)
|
||||
|
||||
assertNotNull(filterResult)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given USD, when I enter 5dot, then I expect successful filter`() {
|
||||
val testSubject = Boost.MoneyFilter(usd)
|
||||
val editable = SpannableStringBuilder("5.")
|
||||
val dest = SpannableStringBuilder()
|
||||
|
||||
testSubject.afterTextChanged(editable)
|
||||
val filterResult = testSubject.filter(editable, 0, editable.length, dest, 0, 0)
|
||||
|
||||
assertNull(filterResult)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given JPY, when I enter 5, then I expect yen 5`() {
|
||||
val testSubject = Boost.MoneyFilter(yen)
|
||||
val editable = SpannableStringBuilder("5")
|
||||
|
||||
testSubject.afterTextChanged(editable)
|
||||
|
||||
assertEquals("¥ 5", editable.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given JPY, when I enter 5, then I expect 5 from text change`() {
|
||||
var result = ""
|
||||
val testSubject = Boost.MoneyFilter(yen) {
|
||||
result = it
|
||||
}
|
||||
|
||||
val editable = SpannableStringBuilder("5")
|
||||
|
||||
testSubject.afterTextChanged(editable)
|
||||
|
||||
assertEquals("5", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given JPY, when I enter 5, then I expect successful filter`() {
|
||||
val testSubject = Boost.MoneyFilter(yen)
|
||||
val editable = SpannableStringBuilder("5")
|
||||
val dest = SpannableStringBuilder()
|
||||
|
||||
testSubject.afterTextChanged(editable)
|
||||
val filterResult = testSubject.filter(editable, 0, editable.length, dest, 0, 0)
|
||||
|
||||
assertNull(filterResult)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given JPY, when I enter 5dot, then I expect unsuccessful filter`() {
|
||||
val testSubject = Boost.MoneyFilter(yen)
|
||||
val editable = SpannableStringBuilder("5.")
|
||||
val dest = SpannableStringBuilder()
|
||||
|
||||
testSubject.afterTextChanged(editable)
|
||||
val filterResult = testSubject.filter(editable, 0, editable.length, dest, 0, 0)
|
||||
|
||||
assertNotNull(filterResult)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription.currency
|
||||
|
||||
import junit.framework.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.JUnit4
|
||||
import java.util.Currency
|
||||
|
||||
@Suppress("ClassName")
|
||||
@RunWith(JUnit4::class)
|
||||
class SetCurrencyViewModel__CurrencyComparatorTest {
|
||||
|
||||
private val currencyComparator = SetCurrencyViewModel.CurrencyComparator(listOf("AUD", "EUR", "CAD"))
|
||||
|
||||
@Test
|
||||
fun givenAListOfCurrencies_whenISort_thenIExpectTheProperOrder() {
|
||||
// GIVEN
|
||||
val currencies = listOf("EUR", "AUD", "JPY", "USD", "CAD", "BWP", "BIF").map { Currency.getInstance(it) }
|
||||
val expected = listOf("USD", "AUD", "CAD", "EUR", "BWP", "BIF", "JPY").map { Currency.getInstance(it) }
|
||||
|
||||
// WHEN
|
||||
val sorted: List<Currency> = currencies.sortedWith(currencyComparator)
|
||||
|
||||
// THEN
|
||||
assertEquals(expected, sorted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenUSDAndADefaultCurrency_whenISort_thenIExpectUSDFirst() {
|
||||
// GIVEN
|
||||
val currencies = listOf("EUR", "USD").map { Currency.getInstance(it) }
|
||||
val expected = listOf("USD", "EUR").map { Currency.getInstance(it) }
|
||||
|
||||
// WHEN
|
||||
val sorted: List<Currency> = currencies.sortedWith(currencyComparator)
|
||||
|
||||
// THEN
|
||||
assertEquals(expected, sorted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenADefaultCurrencyAndANonDefaultCurrency_whenISort_thenIExpectUSDFirst() {
|
||||
// GIVEN
|
||||
val currencies = listOf("JPY", "EUR").map { Currency.getInstance(it) }
|
||||
val expected = listOf("EUR", "JPY").map { Currency.getInstance(it) }
|
||||
|
||||
// WHEN
|
||||
val sorted: List<Currency> = currencies.sortedWith(currencyComparator)
|
||||
|
||||
// THEN
|
||||
assertEquals(expected, sorted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenTwoDefaultCurrencies_whenISort_thenIExpectOrderedByDisplayName() {
|
||||
// GIVEN
|
||||
val currencies = listOf("EUR", "AUD").map { Currency.getInstance(it) }
|
||||
val expected = listOf("AUD", "EUR").map { Currency.getInstance(it) }
|
||||
|
||||
// WHEN
|
||||
val sorted = currencies.sortedWith(currencyComparator)
|
||||
|
||||
// THEN
|
||||
assertEquals(expected, sorted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenTwoNonDefaultCurrencies_whenISort_thenIExpectOrderedByDisplayName() {
|
||||
// GIVEN
|
||||
val currencies = listOf("XPF", "BIF").map { Currency.getInstance(it) }
|
||||
val expected = listOf("BIF", "XPF").map { Currency.getInstance(it) }
|
||||
|
||||
// WHEN
|
||||
val sorted = currencies.sortedWith(currencyComparator)
|
||||
|
||||
// THEN
|
||||
assertEquals(expected, sorted)
|
||||
}
|
||||
}
|
|
@ -333,23 +333,29 @@ dependencyVerification {
|
|||
['com.google.android.gms:play-services-auth:16.0.1',
|
||||
'aec9e1c584d442cb9f59481a50b2c66dc191872607c04d97ecb82dd0eb5149ec'],
|
||||
|
||||
['com.google.android.gms:play-services-base:16.0.1',
|
||||
'aca10c780c3219bc50f3db06734f4ab88badd3113c564c0a3156ff8ff674655b'],
|
||||
['com.google.android.gms:play-services-base:17.5.0',
|
||||
'198c9e2115f5ce5f91140cd9b481dc6d64dd634ac2d6c6525567dc5fe00065cb'],
|
||||
|
||||
['com.google.android.gms:play-services-basement:17.0.0',
|
||||
'd324a1785bbc48bfe3639fc847cfd3cf43d49e967b5caf2794240a854557a39c'],
|
||||
['com.google.android.gms:play-services-basement:17.5.0',
|
||||
'362301c0da1c765cbbdcf8ea866b6cb62bc130c86d2aa7cc9e9c18a6e51ea79d'],
|
||||
|
||||
['com.google.android.gms:play-services-cloud-messaging:16.0.0',
|
||||
'3a5000df3d6b91f9b8b681b29331b4680d30c140f693b1c5d2969755b6fc4cf9'],
|
||||
|
||||
['com.google.android.gms:play-services-maps:16.1.0',
|
||||
'ff50cae9e4059416202375597d99cdc8ddefd9cea3f1dc2ff53779a3a12eb480'],
|
||||
['com.google.android.gms:play-services-identity:17.0.0',
|
||||
'8987c6c303eaaa9c10c403822cf5ae188ee1ce61c3056eb3be2ca4aaecc80b5f'],
|
||||
|
||||
['com.google.android.gms:play-services-maps:17.0.0',
|
||||
'f9e479bc57ff423959c6dd9d08d463c677f440e29d90de795418ea27da6c67fb'],
|
||||
|
||||
['com.google.android.gms:play-services-stats:17.0.0',
|
||||
'e8ae5b40512b71e2258bfacd8cd3da398733aa4cde3b32d056093f832b83a6fe'],
|
||||
|
||||
['com.google.android.gms:play-services-tasks:17.0.0',
|
||||
'2e6d1738b73647f3fe7a038b9780b97717b3746eae258009197e36e7bf3112a5'],
|
||||
['com.google.android.gms:play-services-tasks:17.2.0',
|
||||
'a131d126145dfe87de04fa904f9ce91753b2d3273851b7d084666a71255792a8'],
|
||||
|
||||
['com.google.android.gms:play-services-wallet:18.1.3',
|
||||
'e19d1f4650f51ce2202c092cbe174058860b6558cf26c8be37a732eff3ae1864'],
|
||||
|
||||
['com.google.android.material:material:1.3.0',
|
||||
'cbf1e7d69fc236cdadcbd1ec5f6c0a1a41aca6ad1ef7f8481058956270ab1f0a'],
|
||||
|
|
|
@ -23,21 +23,20 @@ class StripeApi(private val configuration: Configuration, private val paymentInt
|
|||
data class Success(val paymentIntent: PaymentIntent) : CreatePaymentIntentResult()
|
||||
}
|
||||
|
||||
fun createPaymentIntent(price: FiatMoney, description: String? = null): Single<CreatePaymentIntentResult> = Single.fromCallable {
|
||||
if (Validation.isAmountTooSmall(price)) {
|
||||
CreatePaymentIntentResult.AmountIsTooSmall(price)
|
||||
fun createPaymentIntent(price: FiatMoney, description: String? = null): Single<CreatePaymentIntentResult> {
|
||||
@Suppress("CascadeIf")
|
||||
return if (Validation.isAmountTooSmall(price)) {
|
||||
Single.just(CreatePaymentIntentResult.AmountIsTooSmall(price))
|
||||
} else if (Validation.isAmountTooLarge(price)) {
|
||||
CreatePaymentIntentResult.AmountIsTooLarge(price)
|
||||
Single.just(CreatePaymentIntentResult.AmountIsTooLarge(price))
|
||||
} else if (!Validation.supportedCurrencyCodes.contains(price.currency.currencyCode.toUpperCase(Locale.ROOT))) {
|
||||
CreatePaymentIntentResult.CurrencyIsNotSupported(price.currency.currencyCode)
|
||||
Single.just<CreatePaymentIntentResult>(CreatePaymentIntentResult.CurrencyIsNotSupported(price.currency.currencyCode))
|
||||
} else {
|
||||
CreatePaymentIntentResult.Success(
|
||||
paymentIntentFetcher.fetchPaymentIntent(
|
||||
price, description
|
||||
)
|
||||
)
|
||||
}
|
||||
}.subscribeOn(Schedulers.io())
|
||||
paymentIntentFetcher
|
||||
.fetchPaymentIntent(price, description)
|
||||
.map<CreatePaymentIntentResult> { CreatePaymentIntentResult.Success(it) }
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun confirmPaymentIntent(paymentSource: PaymentSource, paymentIntent: PaymentIntent): Completable = Completable.fromAction {
|
||||
val paymentMethodId = createPaymentMethod(paymentSource).use { response ->
|
||||
|
@ -293,7 +292,7 @@ class StripeApi(private val configuration: Configuration, private val paymentInt
|
|||
fun fetchPaymentIntent(
|
||||
price: FiatMoney,
|
||||
description: String? = null
|
||||
): PaymentIntent
|
||||
): Single<PaymentIntent>
|
||||
}
|
||||
|
||||
data class PaymentIntent(
|
||||
|
|
|
@ -6,8 +6,11 @@ import org.whispersystems.signalservice.api.util.CredentialsProvider;
|
|||
import org.whispersystems.signalservice.internal.EmptyResponse;
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration;
|
||||
import org.whispersystems.signalservice.internal.push.DonationIntentResult;
|
||||
import org.whispersystems.signalservice.internal.push.PushServiceSocket;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import io.reactivex.rxjava3.core.Scheduler;
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
@ -45,4 +48,21 @@ public class DonationsService {
|
|||
}
|
||||
}).subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits price information to the server to generate a payment intent via the payment gateway.
|
||||
*
|
||||
* @param amount Price, in the minimum currency unit (e.g. cents or yen)
|
||||
* @param currencyCode The currency code for the amount
|
||||
* @return A ServiceResponse containing a DonationIntentResult with details given to us by the payment gateway.
|
||||
*/
|
||||
public Single<ServiceResponse<DonationIntentResult>> createDonationIntentWithAmount(String amount, String currencyCode) {
|
||||
return Single.fromCallable(() -> {
|
||||
try {
|
||||
return ServiceResponse.forResult(this.pushServiceSocket.createDonationIntentWithAmount(amount, currencyCode), 200, null);
|
||||
} catch (IOException e) {
|
||||
return ServiceResponse.<DonationIntentResult>forUnknownError(e);
|
||||
}
|
||||
}).subscribeOn(Schedulers.io());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
package org.whispersystems.signalservice.internal.push;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
class DonationIntentPayload {
|
||||
@JsonProperty
|
||||
private long amount;
|
||||
|
||||
@JsonProperty
|
||||
private String currency;
|
||||
|
||||
public DonationIntentPayload(long amount, String currency) {
|
||||
this.amount = amount;
|
||||
this.currency = currency;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
package org.whispersystems.signalservice.internal.push;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
public class DonationIntentResult {
|
||||
@JsonProperty("id")
|
||||
private String id;
|
||||
|
||||
@JsonProperty("client_secret")
|
||||
private String clientSecret;
|
||||
|
||||
public DonationIntentResult(@JsonProperty("id") String id, @JsonProperty("client_secret") String clientSecret) {
|
||||
this.id = id;
|
||||
this.clientSecret = clientSecret;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getClientSecret() {
|
||||
return clientSecret;
|
||||
}
|
||||
}
|
|
@ -230,9 +230,11 @@ public class PushServiceSocket {
|
|||
|
||||
private static final String PAYMENTS_CONVERSIONS = "/v1/payments/conversions";
|
||||
|
||||
|
||||
private static final String SUBMIT_RATE_LIMIT_CHALLENGE = "/v1/challenge";
|
||||
private static final String REQUEST_RATE_LIMIT_PUSH_CHALLENGE = "/v1/challenge/push";
|
||||
|
||||
private static final String DONATION_INTENT = "/v1/donation/authorize-apple-pay";
|
||||
private static final String DONATION_REDEEM_RECEIPT = "/v1/donation/redeem-receipt";
|
||||
|
||||
private static final String REPORT_SPAM = "/v1/messages/report/%s/%s";
|
||||
|
@ -869,6 +871,15 @@ public class PushServiceSocket {
|
|||
makeServiceRequest(DONATION_REDEEM_RECEIPT, "PUT", payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return The PaymentIntent id
|
||||
*/
|
||||
public DonationIntentResult createDonationIntentWithAmount(String amount, String currencyCode) throws IOException {
|
||||
String payload = JsonUtil.toJson(new DonationIntentPayload(Long.parseLong(amount), currencyCode.toLowerCase(Locale.ROOT)));
|
||||
String result = makeServiceRequest(DONATION_INTENT, "POST", payload);
|
||||
return JsonUtil.fromJsonResponse(result, DonationIntentResult.class);
|
||||
}
|
||||
|
||||
public List<ContactTokenDetails> retrieveDirectory(Set<String> contactTokens)
|
||||
throws NonSuccessfulResponseCodeException, PushNetworkException, MalformedResponseException
|
||||
{
|
||||
|
|
Loading…
Add table
Reference in a new issue