mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-22 01:40:07 +01:00
Implement ability to view badges and modify whether they appear.
Note: this is available in staging only.
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
package org.thoughtcrime.securesms.badges
|
||||
|
||||
import android.content.Context
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.ProfileUtil
|
||||
|
||||
class BadgeRepository(context: Context) {
|
||||
|
||||
private val context = context.applicationContext
|
||||
|
||||
fun setVisibilityForAllBadges(displayBadgesOnProfile: Boolean): Completable = Completable.fromAction {
|
||||
val badges = Recipient.self().badges.map { it.copy(visible = displayBadgesOnProfile) }
|
||||
ProfileUtil.uploadProfileWithBadges(context, badges)
|
||||
|
||||
val recipientDatabase: RecipientDatabase = DatabaseFactory.getRecipientDatabase(context)
|
||||
recipientDatabase.setBadges(Recipient.self().id, badges)
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
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.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.insetWithOutline(
|
||||
@Px outlineWidth: Float,
|
||||
@ColorInt outlineColor: Int
|
||||
): Drawable {
|
||||
val clone = mutate().constantState?.newDrawable()?.mutate()
|
||||
clone?.colorFilter = SimpleColorFilter(outlineColor)
|
||||
|
||||
return customizeOnDraw { wrapped, canvas ->
|
||||
clone?.bounds = wrapped.bounds
|
||||
clone?.draw(canvas)
|
||||
|
||||
val scale = 1 - ((outlineWidth * 2) / canvas.width)
|
||||
|
||||
canvas.withScale(x = scale, y = scale, canvas.width / 2f, canvas.height / 2f) {
|
||||
wrapped.draw(canvas)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Drawable.selectable(
|
||||
@Px outlineWidth: Float,
|
||||
@ColorInt outlineColor: Int,
|
||||
@ColorInt gapColor: Int,
|
||||
animator: BadgeAnimator
|
||||
): Drawable {
|
||||
val outline = mutate().constantState?.newDrawable()?.mutate()
|
||||
outline?.colorFilter = SimpleColorFilter(outlineColor)
|
||||
|
||||
val gap = mutate().constantState?.newDrawable()?.mutate()
|
||||
gap?.colorFilter = SimpleColorFilter(gapColor)
|
||||
|
||||
return customizeOnDraw { wrapped, canvas ->
|
||||
outline?.bounds = wrapped.bounds
|
||||
gap?.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) {
|
||||
gap?.draw(canvas)
|
||||
|
||||
canvas.withScale(x = interpolatedScale, y = interpolatedScale, wrapped.bounds.width() / 2f, wrapped.bounds.height() / 2f) {
|
||||
wrapped.draw(canvas)
|
||||
}
|
||||
}
|
||||
|
||||
if (animator.shouldInvalidate()) {
|
||||
invalidateSelf()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun DSLConfiguration.displayBadges(badges: List<Badge>, selectedBadge: Badge? = null) {
|
||||
badges
|
||||
.map { Badge.Model(it, it == selectedBadge) }
|
||||
.forEach { customPref(it) }
|
||||
|
||||
val empties = (4 - (badges.size % 4)) % 4
|
||||
repeat(empties) {
|
||||
customPref(Badge.EmptyModel())
|
||||
}
|
||||
}
|
||||
|
||||
fun createLayoutManagerForGridWithBadges(context: Context): RecyclerView.LayoutManager {
|
||||
val layoutManager = FlexboxLayoutManager(context)
|
||||
|
||||
layoutManager.flexDirection = FlexDirection.ROW
|
||||
layoutManager.alignItems = AlignItems.CENTER
|
||||
layoutManager.justifyContent = JustifyContent.CENTER
|
||||
|
||||
return layoutManager
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package org.thoughtcrime.securesms.badges.models
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.os.Parcel
|
||||
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.request.target.CustomViewTarget
|
||||
import com.bumptech.glide.request.transition.Transition
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.Badges.selectable
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.mms.GlideApp
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
import java.security.MessageDigest
|
||||
|
||||
typealias OnBadgeClicked = (Badge, Boolean) -> Unit
|
||||
|
||||
/**
|
||||
* A Badge that can be collected and displayed by a user.
|
||||
*/
|
||||
data class Badge(
|
||||
val id: String,
|
||||
val category: Category,
|
||||
val imageUrl: Uri,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val expirationTimestamp: Long,
|
||||
val visible: Boolean
|
||||
) : Parcelable, Key {
|
||||
|
||||
constructor(parcel: Parcel) : this(
|
||||
requireNotNull(parcel.readString()),
|
||||
Category.fromCode(requireNotNull(parcel.readString())),
|
||||
requireNotNull(parcel.readParcelable(Uri::class.java.classLoader)),
|
||||
requireNotNull(parcel.readString()),
|
||||
requireNotNull(parcel.readString()),
|
||||
parcel.readLong(),
|
||||
parcel.readByte() == 1.toByte()
|
||||
)
|
||||
|
||||
override fun describeContents(): Int {
|
||||
return 0
|
||||
}
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||
parcel.writeString(id)
|
||||
parcel.writeString(category.code)
|
||||
parcel.writeParcelable(imageUrl, flags)
|
||||
parcel.writeString(name)
|
||||
parcel.writeString(description)
|
||||
parcel.writeLong(expirationTimestamp)
|
||||
parcel.writeByte(if (visible) 1 else 0)
|
||||
}
|
||||
|
||||
override fun updateDiskCacheKey(messageDigest: MessageDigest) {
|
||||
messageDigest.update(id.toByteArray(Key.CHARSET))
|
||||
}
|
||||
|
||||
fun resolveDescription(shortName: String): String {
|
||||
return description.replace("{short_name}", shortName)
|
||||
}
|
||||
|
||||
class EmptyModel : PreferenceModel<EmptyModel>() {
|
||||
override fun areItemsTheSame(newItem: EmptyModel): Boolean = true
|
||||
}
|
||||
|
||||
class EmptyViewHolder(itemView: View) : MappingViewHolder<EmptyModel>(itemView) {
|
||||
|
||||
private val name: TextView = itemView.findViewById(R.id.name)
|
||||
|
||||
init {
|
||||
itemView.isEnabled = false
|
||||
itemView.isFocusable = false
|
||||
itemView.isClickable = false
|
||||
itemView.visibility = View.INVISIBLE
|
||||
|
||||
name.text = " "
|
||||
}
|
||||
|
||||
override fun bind(model: EmptyModel) = Unit
|
||||
}
|
||||
|
||||
class Model(
|
||||
val badge: Badge,
|
||||
val isSelected: Boolean = false
|
||||
) : PreferenceModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return newItem.badge.id == badge.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return super.areContentsTheSame(newItem) && badge == newItem.badge && isSelected == newItem.isSelected
|
||||
}
|
||||
|
||||
override fun getChangePayload(newItem: Model): Any? {
|
||||
return if (badge == newItem.badge && isSelected != newItem.isSelected) {
|
||||
SELECTION_CHANGED
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View, private val onBadgeClicked: OnBadgeClicked) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val badge: ImageView = itemView.findViewById(R.id.badge)
|
||||
private val name: TextView = itemView.findViewById(R.id.name)
|
||||
private val target = Target(badge)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
itemView.setOnClickListener {
|
||||
onBadgeClicked(model.badge, model.isSelected)
|
||||
}
|
||||
|
||||
if (payload.isNotEmpty()) {
|
||||
if (model.isSelected) {
|
||||
target.animateToStart()
|
||||
} else {
|
||||
target.animateToEnd()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
GlideApp.with(badge)
|
||||
.load(model.badge)
|
||||
.into(target)
|
||||
|
||||
if (model.isSelected) {
|
||||
target.setAnimationToStart()
|
||||
} else {
|
||||
target.setAnimationToEnd()
|
||||
}
|
||||
|
||||
name.text = model.badge.name
|
||||
}
|
||||
}
|
||||
|
||||
enum class Category(val code: String) {
|
||||
Donor("donor"),
|
||||
Other("other"),
|
||||
Testing("testing"); // Will be removed before final release
|
||||
|
||||
companion object {
|
||||
fun fromCode(code: String): Category {
|
||||
return when (code) {
|
||||
"donor" -> Donor
|
||||
"testing" -> Testing
|
||||
else -> Other
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
ContextCompat.getColor(view.context, R.color.signal_background_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 CREATOR : Parcelable.Creator<Badge> {
|
||||
private val SELECTION_CHANGED = Any()
|
||||
|
||||
override fun createFromParcel(parcel: Parcel): Badge {
|
||||
return Badge(parcel)
|
||||
}
|
||||
|
||||
override fun newArray(size: Int): Array<Badge?> {
|
||||
return arrayOfNulls(size)
|
||||
}
|
||||
|
||||
fun register(mappingAdapter: MappingAdapter, onBadgeClicked: OnBadgeClicked) {
|
||||
mappingAdapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it, onBadgeClicked) }, R.layout.badge_preference_view))
|
||||
mappingAdapter.registerFactory(EmptyModel::class.java, MappingAdapter.LayoutFactory({ EmptyViewHolder(it) }, R.layout.badge_preference_view))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package org.thoughtcrime.securesms.badges.models
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.bumptech.glide.request.target.CustomViewTarget
|
||||
import com.bumptech.glide.request.transition.Transition
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.Badges.insetWithOutline
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.mms.GlideApp
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
object FeaturedBadgePreview {
|
||||
|
||||
fun register(mappingAdapter: MappingAdapter) {
|
||||
mappingAdapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.featured_badge_preview_preference))
|
||||
}
|
||||
|
||||
data 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) && badge == newItem.badge
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val avatar: AvatarImageView = itemView.findViewById(R.id.avatar)
|
||||
private val badge: ImageView = itemView.findViewById(R.id.badge)
|
||||
private val target: Target = Target(badge)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
avatar.setRecipient(Recipient.self())
|
||||
avatar.disableQuickContact()
|
||||
|
||||
if (model.badge != null) {
|
||||
GlideApp.with(badge)
|
||||
.load(model.badge)
|
||||
.into(target)
|
||||
} else {
|
||||
GlideApp.with(badge).clear(badge)
|
||||
badge.setImageDrawable(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class Target(view: ImageView) : CustomViewTarget<ImageView, Drawable>(view) {
|
||||
override fun onLoadFailed(errorDrawable: Drawable?) {
|
||||
view.setImageDrawable(errorDrawable)
|
||||
}
|
||||
|
||||
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
|
||||
view.setImageDrawable(
|
||||
resource.insetWithOutline(
|
||||
DimensionUnit.DP.toPixels(2.5f),
|
||||
ContextCompat.getColor(view.context, R.color.signal_background_primary)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onResourceCleared(placeholder: Drawable?) {
|
||||
view.setImageDrawable(placeholder)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package org.thoughtcrime.securesms.badges.models
|
||||
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.mms.GlideApp
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingModel
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
data class LargeBadge(
|
||||
val badge: Badge
|
||||
) {
|
||||
|
||||
class Model(val largeBadge: LargeBadge, val shortName: String) : MappingModel<Model> {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return newItem.largeBadge.badge.id == largeBadge.badge.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return newItem.largeBadge == largeBadge && newItem.shortName == shortName
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val badge: ImageView = itemView.findViewById(R.id.badge)
|
||||
private val name: TextView = itemView.findViewById(R.id.name)
|
||||
private val description: TextView = itemView.findViewById(R.id.description)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
GlideApp.with(badge)
|
||||
.load(model.largeBadge.badge)
|
||||
.into(badge)
|
||||
|
||||
name.text = model.largeBadge.badge.name
|
||||
description.text = model.largeBadge.badge.resolveDescription(model.shortName)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun register(mappingAdapter: MappingAdapter) {
|
||||
mappingAdapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.view_badge_bottom_sheet_dialog_fragment_page))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package org.thoughtcrime.securesms.badges.self.featured
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.thoughtcrime.securesms.R
|
||||
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.components.recyclerview.OnScrollAnimationHelper
|
||||
import org.thoughtcrime.securesms.components.recyclerview.ToolbarShadowAnimationHelper
|
||||
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.configure
|
||||
|
||||
/**
|
||||
* Fragment which allows user to select one of their badges to be their "Featured" badge.
|
||||
*/
|
||||
class SelectFeaturedBadgeFragment : DSLSettingsFragment(
|
||||
titleId = R.string.BadgesOverviewFragment__featured_badge,
|
||||
layoutId = R.layout.select_featured_badge_fragment,
|
||||
layoutManagerProducer = Badges::createLayoutManagerForGridWithBadges
|
||||
) {
|
||||
|
||||
private val viewModel: SelectFeaturedBadgeViewModel by viewModels(factoryProducer = { SelectFeaturedBadgeViewModel.Factory(BadgeRepository(requireContext())) })
|
||||
|
||||
private lateinit var scrollShadow: View
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
scrollShadow = view.findViewById(R.id.scroll_shadow)
|
||||
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val save: View = view.findViewById(R.id.save)
|
||||
save.setOnClickListener {
|
||||
viewModel.save()
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getOnScrollAnimationHelper(toolbarShadow: View): OnScrollAnimationHelper {
|
||||
return ToolbarShadowAnimationHelper(scrollShadow)
|
||||
}
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
Badge.register(adapter) { badge, isSelected ->
|
||||
if (!isSelected) {
|
||||
viewModel.setSelectedBadge(badge)
|
||||
}
|
||||
}
|
||||
|
||||
val previewView: View = requireView().findViewById(R.id.preview)
|
||||
val previewViewHolder = FeaturedBadgePreview.ViewHolder(previewView)
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
previewViewHolder.bind(FeaturedBadgePreview.Model(state.selectedBadge))
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
}
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: SelectFeaturedBadgeState): DSLConfiguration {
|
||||
return configure {
|
||||
sectionHeaderPref(R.string.SelectFeaturedBadgeFragment__select_a_badge)
|
||||
displayBadges(state.allUnlockedBadges, state.selectedBadge)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.thoughtcrime.securesms.badges.self.featured
|
||||
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
|
||||
data class SelectFeaturedBadgeState(
|
||||
val selectedBadge: Badge? = null,
|
||||
val allUnlockedBadges: List<Badge> = listOf()
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
package org.thoughtcrime.securesms.badges.self.featured
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import org.thoughtcrime.securesms.badges.BadgeRepository
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
|
||||
class SelectFeaturedBadgeViewModel(repository: BadgeRepository) : ViewModel() {
|
||||
|
||||
private val store = Store(SelectFeaturedBadgeState())
|
||||
|
||||
val state: LiveData<SelectFeaturedBadgeState> = store.stateLiveData
|
||||
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
init {
|
||||
store.update(Recipient.live(Recipient.self().id).liveDataResolved) { recipient, state ->
|
||||
state.copy(selectedBadge = recipient.badges.firstOrNull(), allUnlockedBadges = recipient.badges)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSelectedBadge(badge: Badge) {
|
||||
store.update { it.copy(selectedBadge = badge) }
|
||||
}
|
||||
|
||||
fun save() {
|
||||
// TODO "Persist selection to database"
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
class Factory(private val badgeRepository: BadgeRepository) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return requireNotNull(modelClass.cast(SelectFeaturedBadgeViewModel(badgeRepository)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.thoughtcrime.securesms.badges.self.overview
|
||||
|
||||
enum class BadgesOverviewEvent {
|
||||
FAILED_TO_UPDATE_PROFILE
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package org.thoughtcrime.securesms.badges.self.overview
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.thoughtcrime.securesms.R
|
||||
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.view.ViewBadgeBottomSheetDialogFragment
|
||||
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.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
|
||||
/**
|
||||
* Fragment to allow user to manage options related to the badges they've unlocked.
|
||||
*/
|
||||
class BadgesOverviewFragment : DSLSettingsFragment(
|
||||
titleId = R.string.ManageProfileFragment_badges,
|
||||
layoutManagerProducer = Badges::createLayoutManagerForGridWithBadges
|
||||
) {
|
||||
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
private val viewModel: BadgesOverviewViewModel by viewModels(factoryProducer = { BadgesOverviewViewModel.Factory(BadgeRepository(requireContext())) })
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
Badge.register(adapter) { badge, _ ->
|
||||
ViewBadgeBottomSheetDialogFragment.show(parentFragmentManager, Recipient.self().id, badge)
|
||||
}
|
||||
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
}
|
||||
|
||||
lifecycleDisposable.add(
|
||||
viewModel.events.subscribe { event: BadgesOverviewEvent ->
|
||||
when (event) {
|
||||
BadgesOverviewEvent.FAILED_TO_UPDATE_PROFILE -> Toast.makeText(requireContext(), R.string.BadgesOverviewFragment__failed_to_update_profile, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: BadgesOverviewState): DSLConfiguration {
|
||||
return configure {
|
||||
sectionHeaderPref(R.string.BadgesOverviewFragment__my_badges)
|
||||
|
||||
displayBadges(state.allUnlockedBadges)
|
||||
|
||||
switchPref(
|
||||
title = DSLSettingsText.from(R.string.BadgesOverviewFragment__display_badges_on_profile),
|
||||
isChecked = state.displayBadgesOnProfile,
|
||||
onClick = {
|
||||
viewModel.setDisplayBadgesOnProfile(!state.displayBadgesOnProfile)
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.BadgesOverviewFragment__featured_badge),
|
||||
summary = state.featuredBadge?.name?.let { DSLSettingsText.from(it) },
|
||||
isEnabled = state.stage == BadgesOverviewState.Stage.READY,
|
||||
onClick = {
|
||||
findNavController().navigate(BadgesOverviewFragmentDirections.actionBadgeManageFragmentToFeaturedBadgeFragment())
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.thoughtcrime.securesms.badges.self.overview
|
||||
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
|
||||
data class BadgesOverviewState(
|
||||
val stage: Stage = Stage.INIT,
|
||||
val allUnlockedBadges: List<Badge> = listOf(),
|
||||
val featuredBadge: Badge? = null,
|
||||
val displayBadgesOnProfile: Boolean = false
|
||||
) {
|
||||
enum class Stage {
|
||||
INIT,
|
||||
READY,
|
||||
UPDATING
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package org.thoughtcrime.securesms.badges.self.overview
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.badges.BadgeRepository
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
|
||||
private val TAG = Log.tag(BadgesOverviewViewModel::class.java)
|
||||
|
||||
class BadgesOverviewViewModel(private val badgeRepository: BadgeRepository) : ViewModel() {
|
||||
private val store = Store(BadgesOverviewState())
|
||||
private val eventSubject = PublishSubject.create<BadgesOverviewEvent>()
|
||||
|
||||
val state: LiveData<BadgesOverviewState> = store.stateLiveData
|
||||
val events: Observable<BadgesOverviewEvent> = eventSubject
|
||||
|
||||
val disposables = CompositeDisposable()
|
||||
|
||||
init {
|
||||
store.update(Recipient.live(Recipient.self().id).liveDataResolved) { recipient, state ->
|
||||
state.copy(
|
||||
stage = if (state.stage == BadgesOverviewState.Stage.INIT) BadgesOverviewState.Stage.READY else state.stage,
|
||||
allUnlockedBadges = recipient.badges,
|
||||
displayBadgesOnProfile = recipient.badges.firstOrNull()?.visible == true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setDisplayBadgesOnProfile(displayBadgesOnProfile: Boolean) {
|
||||
disposables += badgeRepository.setVisibilityForAllBadges(displayBadgesOnProfile)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{
|
||||
store.update { it.copy(stage = BadgesOverviewState.Stage.READY) }
|
||||
},
|
||||
{ error ->
|
||||
Log.e(TAG, "Failed to update visibility.", error)
|
||||
store.update { it.copy(stage = BadgesOverviewState.Stage.READY) }
|
||||
eventSubject.onNext(BadgesOverviewEvent.FAILED_TO_UPDATE_PROFILE)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
class Factory(private val badgeRepository: BadgeRepository) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return requireNotNull(modelClass.cast(BadgesOverviewViewModel(badgeRepository)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package org.thoughtcrime.securesms.badges.view
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeRepository
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.badges.models.LargeBadge
|
||||
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
class ViewBadgeBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFragment() {
|
||||
|
||||
private val viewModel: ViewBadgeViewModel by viewModels(factoryProducer = { ViewBadgeViewModel.Factory(getStartBadge(), getRecipientId(), BadgeRepository(requireContext())) })
|
||||
|
||||
override val peekHeightPercentage: Float = 1f
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(R.layout.view_badge_bottom_sheet_dialog_fragment, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val pager: ViewPager2 = view.findViewById(R.id.pager)
|
||||
val tabs: TabLayout = view.findViewById(R.id.tab_layout)
|
||||
val action: MaterialButton = view.findViewById(R.id.action)
|
||||
|
||||
if (getRecipientId() == Recipient.self().id) {
|
||||
action.visible = false
|
||||
}
|
||||
|
||||
val adapter = MappingAdapter()
|
||||
|
||||
LargeBadge.register(adapter)
|
||||
pager.adapter = adapter
|
||||
|
||||
TabLayoutMediator(tabs, pager) { _, _ ->
|
||||
}.attach()
|
||||
|
||||
pager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
|
||||
override fun onPageSelected(position: Int) {
|
||||
viewModel.onPageSelected(position)
|
||||
}
|
||||
})
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
if (state.recipient == null || state.badgeLoadState == ViewBadgeState.LoadState.INIT) {
|
||||
return@observe
|
||||
}
|
||||
|
||||
if (state.allBadgesVisibleOnProfile.isEmpty()) {
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
adapter.submitList(
|
||||
state.allBadgesVisibleOnProfile.map {
|
||||
LargeBadge.Model(LargeBadge(it), state.recipient.getShortDisplayName(requireContext()))
|
||||
}
|
||||
) {
|
||||
val stateSelectedIndex = state.allBadgesVisibleOnProfile.indexOf(state.selectedBadge)
|
||||
if (state.selectedBadge != null && pager.currentItem != stateSelectedIndex) {
|
||||
pager.currentItem = stateSelectedIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getStartBadge(): Badge? = requireArguments().getParcelable(ARG_START_BADGE)
|
||||
|
||||
private fun getRecipientId(): RecipientId = requireNotNull(requireArguments().getParcelable(ARG_RECIPIENT_ID))
|
||||
|
||||
companion object {
|
||||
|
||||
private const val ARG_START_BADGE = "start_badge"
|
||||
private const val ARG_RECIPIENT_ID = "recipient_id"
|
||||
|
||||
@JvmStatic
|
||||
fun show(
|
||||
fragmentManager: FragmentManager,
|
||||
recipientId: RecipientId,
|
||||
startBadge: Badge? = null
|
||||
) {
|
||||
ViewBadgeBottomSheetDialogFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
putParcelable(ARG_START_BADGE, startBadge)
|
||||
putParcelable(ARG_RECIPIENT_ID, recipientId)
|
||||
}
|
||||
|
||||
show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.thoughtcrime.securesms.badges.view
|
||||
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
data class ViewBadgeState(
|
||||
val allBadgesVisibleOnProfile: List<Badge> = listOf(),
|
||||
val badgeLoadState: LoadState = LoadState.INIT,
|
||||
val selectedBadge: Badge? = null,
|
||||
val recipient: Recipient? = null
|
||||
) {
|
||||
enum class LoadState {
|
||||
INIT,
|
||||
LOADED
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package org.thoughtcrime.securesms.badges.view
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import org.thoughtcrime.securesms.badges.BadgeRepository
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
|
||||
class ViewBadgeViewModel(
|
||||
private val startBadge: Badge?,
|
||||
private val recipientId: RecipientId,
|
||||
private val repository: BadgeRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
private val store = Store(ViewBadgeState())
|
||||
|
||||
val state: LiveData<ViewBadgeState> = store.stateLiveData
|
||||
|
||||
init {
|
||||
store.update(Recipient.live(recipientId).liveData) { recipient, state ->
|
||||
state.copy(
|
||||
recipient = recipient,
|
||||
allBadgesVisibleOnProfile = recipient.badges,
|
||||
selectedBadge = startBadge ?: recipient.badges.firstOrNull(),
|
||||
badgeLoadState = ViewBadgeState.LoadState.LOADED
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
fun onPageSelected(position: Int) {
|
||||
if (position > store.state.allBadgesVisibleOnProfile.size - 1 || position < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
store.update {
|
||||
it.copy(selectedBadge = it.allBadgesVisibleOnProfile[position])
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val startBadge: Badge?,
|
||||
private val recipientId: RecipientId,
|
||||
private val repository: BadgeRepository
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return requireNotNull(modelClass.cast(ViewBadgeViewModel(startBadge, recipientId, repository)))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user