Implement ability to view badges and modify whether they appear.

Note: this is available in staging only.
This commit is contained in:
Alex Hart
2021-09-20 17:05:31 -03:00
parent 556ca5a573
commit 77cf029fdc
48 changed files with 1880 additions and 100 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
package org.thoughtcrime.securesms.badges.self.overview
enum class BadgesOverviewEvent {
FAILED_TO_UPDATE_PROFILE
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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