Implement ability to select featured badge to display on profile.

This commit is contained in:
Alex Hart 2021-09-21 13:58:58 -03:00
parent fb86fdfcd9
commit e6b03b1a4a
12 changed files with 106 additions and 22 deletions

View file

@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.badges
import android.content.Context import android.content.Context
import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.database.DatabaseFactory import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.RecipientDatabase import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.Recipient
@ -19,4 +20,13 @@ class BadgeRepository(context: Context) {
val recipientDatabase: RecipientDatabase = DatabaseFactory.getRecipientDatabase(context) val recipientDatabase: RecipientDatabase = DatabaseFactory.getRecipientDatabase(context)
recipientDatabase.setBadges(Recipient.self().id, badges) recipientDatabase.setBadges(Recipient.self().id, badges)
}.subscribeOn(Schedulers.io()) }.subscribeOn(Schedulers.io())
fun setFeaturedBadge(featuredBadge: Badge): Completable = Completable.fromAction {
val badges = Recipient.self().badges
val reOrderedBadges = listOf(featuredBadge) + (badges - featuredBadge)
ProfileUtil.uploadProfileWithBadges(context, reOrderedBadges)
val recipientDatabase: RecipientDatabase = DatabaseFactory.getRecipientDatabase(context)
recipientDatabase.setBadges(Recipient.self().id, reOrderedBadges)
}.subscribeOn(Schedulers.io())
} }

View file

@ -23,6 +23,16 @@ data class LargeBadge(
} }
} }
class EmptyModel : MappingModel<EmptyModel> {
override fun areItemsTheSame(newItem: EmptyModel): Boolean = true
override fun areContentsTheSame(newItem: EmptyModel): Boolean = true
}
class EmptyViewHolder(itemView: View) : MappingViewHolder<EmptyModel>(itemView) {
override fun bind(model: EmptyModel) {
}
}
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) { class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val badge: ImageView = itemView.findViewById(R.id.badge) private val badge: ImageView = itemView.findViewById(R.id.badge)
@ -42,6 +52,7 @@ data class LargeBadge(
companion object { companion object {
fun register(mappingAdapter: MappingAdapter) { fun register(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.view_badge_bottom_sheet_dialog_fragment_page)) mappingAdapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.view_badge_bottom_sheet_dialog_fragment_page))
mappingAdapter.registerFactory(EmptyModel::class.java, MappingAdapter.LayoutFactory({ EmptyViewHolder(it) }, R.layout.view_badge_bottom_sheet_dialog_fragment_page))
} }
} }
} }

View file

@ -0,0 +1,7 @@
package org.thoughtcrime.securesms.badges.self.featured
enum class SelectFeaturedBadgeEvent {
NO_BADGE_SELECTED,
FAILED_TO_UPDATE_PROFILE,
SAVE_SUCCESSFUL
}

View file

@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.badges.self.featured
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import android.widget.Toast
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
@ -16,6 +17,7 @@ import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.configure import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.util.LifecycleDisposable
/** /**
* Fragment which allows user to select one of their badges to be their "Featured" badge. * Fragment which allows user to select one of their badges to be their "Featured" badge.
@ -28,17 +30,19 @@ class SelectFeaturedBadgeFragment : DSLSettingsFragment(
private val viewModel: SelectFeaturedBadgeViewModel by viewModels(factoryProducer = { SelectFeaturedBadgeViewModel.Factory(BadgeRepository(requireContext())) }) private val viewModel: SelectFeaturedBadgeViewModel by viewModels(factoryProducer = { SelectFeaturedBadgeViewModel.Factory(BadgeRepository(requireContext())) })
private val lifecycleDisposable = LifecycleDisposable()
private lateinit var scrollShadow: View private lateinit var scrollShadow: View
private lateinit var save: View
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
scrollShadow = view.findViewById(R.id.scroll_shadow) scrollShadow = view.findViewById(R.id.scroll_shadow)
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
val save: View = view.findViewById(R.id.save) save = view.findViewById(R.id.save)
save.setOnClickListener { save.setOnClickListener {
viewModel.save() viewModel.save()
findNavController().popBackStack()
} }
} }
@ -56,7 +60,17 @@ class SelectFeaturedBadgeFragment : DSLSettingsFragment(
val previewView: View = requireView().findViewById(R.id.preview) val previewView: View = requireView().findViewById(R.id.preview)
val previewViewHolder = FeaturedBadgePreview.ViewHolder(previewView) val previewViewHolder = FeaturedBadgePreview.ViewHolder(previewView)
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
lifecycleDisposable += viewModel.events.subscribe { event: SelectFeaturedBadgeEvent ->
when (event) {
SelectFeaturedBadgeEvent.NO_BADGE_SELECTED -> Toast.makeText(requireContext(), R.string.SelectFeaturedBadgeFragment__you_must_select_a_badge, Toast.LENGTH_LONG).show()
SelectFeaturedBadgeEvent.FAILED_TO_UPDATE_PROFILE -> Toast.makeText(requireContext(), R.string.SelectFeaturedBadgeFragment__failed_to_update_profile, Toast.LENGTH_LONG).show()
SelectFeaturedBadgeEvent.SAVE_SUCCESSFUL -> findNavController().popBackStack()
}
}
viewModel.state.observe(viewLifecycleOwner) { state -> viewModel.state.observe(viewLifecycleOwner) { state ->
save.isEnabled = state.stage == SelectFeaturedBadgeState.Stage.READY
previewViewHolder.bind(FeaturedBadgePreview.Model(state.selectedBadge)) previewViewHolder.bind(FeaturedBadgePreview.Model(state.selectedBadge))
adapter.submitList(getConfiguration(state).toMappingModelList()) adapter.submitList(getConfiguration(state).toMappingModelList())
} }

View file

@ -3,6 +3,13 @@ package org.thoughtcrime.securesms.badges.self.featured
import org.thoughtcrime.securesms.badges.models.Badge import org.thoughtcrime.securesms.badges.models.Badge
data class SelectFeaturedBadgeState( data class SelectFeaturedBadgeState(
val stage: Stage = Stage.INIT,
val selectedBadge: Badge? = null, val selectedBadge: Badge? = null,
val allUnlockedBadges: List<Badge> = listOf() val allUnlockedBadges: List<Badge> = listOf()
) ) {
enum class Stage {
INIT,
READY,
SAVING
}
}

View file

@ -3,24 +3,37 @@ package org.thoughtcrime.securesms.badges.self.featured
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider 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.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.subjects.PublishSubject
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.badges.BadgeRepository import org.thoughtcrime.securesms.badges.BadgeRepository
import org.thoughtcrime.securesms.badges.models.Badge import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.livedata.Store import org.thoughtcrime.securesms.util.livedata.Store
class SelectFeaturedBadgeViewModel(repository: BadgeRepository) : ViewModel() { private val TAG = Log.tag(SelectFeaturedBadgeViewModel::class.java)
class SelectFeaturedBadgeViewModel(private val repository: BadgeRepository) : ViewModel() {
private val store = Store(SelectFeaturedBadgeState()) private val store = Store(SelectFeaturedBadgeState())
private val eventSubject = PublishSubject.create<SelectFeaturedBadgeEvent>()
val state: LiveData<SelectFeaturedBadgeState> = store.stateLiveData val state: LiveData<SelectFeaturedBadgeState> = store.stateLiveData
val events: Observable<SelectFeaturedBadgeEvent> = eventSubject.observeOn(AndroidSchedulers.mainThread())
private val disposables = CompositeDisposable() private val disposables = CompositeDisposable()
init { init {
store.update(Recipient.live(Recipient.self().id).liveDataResolved) { recipient, state -> store.update(Recipient.live(Recipient.self().id).liveDataResolved) { recipient, state ->
state.copy(selectedBadge = recipient.badges.firstOrNull(), allUnlockedBadges = recipient.badges) state.copy(
stage = if (state.stage == SelectFeaturedBadgeState.Stage.INIT) SelectFeaturedBadgeState.Stage.READY else state.stage,
selectedBadge = recipient.badges.firstOrNull(),
allUnlockedBadges = recipient.badges
)
} }
} }
@ -29,7 +42,22 @@ class SelectFeaturedBadgeViewModel(repository: BadgeRepository) : ViewModel() {
} }
fun save() { fun save() {
// TODO "Persist selection to database" val snapshot = store.state
if (snapshot.selectedBadge == null) {
eventSubject.onNext(SelectFeaturedBadgeEvent.NO_BADGE_SELECTED)
return
}
store.update { it.copy(stage = SelectFeaturedBadgeState.Stage.SAVING) }
disposables += repository.setFeaturedBadge(snapshot.selectedBadge).subscribeBy(
onComplete = {
eventSubject.onNext(SelectFeaturedBadgeEvent.SAVE_SUCCESSFUL)
},
onError = { error ->
Log.e(TAG, "Failed to update profile.", error)
eventSubject.onNext(SelectFeaturedBadgeEvent.FAILED_TO_UPDATE_PROFILE)
}
)
} }
override fun onCleared() { override fun onCleared() {

View file

@ -20,7 +20,7 @@ class BadgesOverviewViewModel(private val badgeRepository: BadgeRepository) : Vi
private val eventSubject = PublishSubject.create<BadgesOverviewEvent>() private val eventSubject = PublishSubject.create<BadgesOverviewEvent>()
val state: LiveData<BadgesOverviewState> = store.stateLiveData val state: LiveData<BadgesOverviewState> = store.stateLiveData
val events: Observable<BadgesOverviewEvent> = eventSubject val events: Observable<BadgesOverviewEvent> = eventSubject.observeOn(AndroidSchedulers.mainThread())
val disposables = CompositeDisposable() val disposables = CompositeDisposable()
@ -36,7 +36,6 @@ class BadgesOverviewViewModel(private val badgeRepository: BadgeRepository) : Vi
fun setDisplayBadgesOnProfile(displayBadgesOnProfile: Boolean) { fun setDisplayBadgesOnProfile(displayBadgesOnProfile: Boolean) {
disposables += badgeRepository.setVisibilityForAllBadges(displayBadgesOnProfile) disposables += badgeRepository.setVisibilityForAllBadges(displayBadgesOnProfile)
.observeOn(AndroidSchedulers.mainThread())
.subscribe( .subscribe(
{ {
store.update { it.copy(stage = BadgesOverviewState.Stage.READY) } store.update { it.copy(stage = BadgesOverviewState.Stage.READY) }

View file

@ -32,6 +32,8 @@ class ViewBadgeBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFr
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
postponeEnterTransition()
val pager: ViewPager2 = view.findViewById(R.id.pager) val pager: ViewPager2 = view.findViewById(R.id.pager)
val tabs: TabLayout = view.findViewById(R.id.tab_layout) val tabs: TabLayout = view.findViewById(R.id.tab_layout)
val action: MaterialButton = view.findViewById(R.id.action) val action: MaterialButton = view.findViewById(R.id.action)
@ -44,14 +46,17 @@ class ViewBadgeBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFr
LargeBadge.register(adapter) LargeBadge.register(adapter)
pager.adapter = adapter pager.adapter = adapter
adapter.submitList(listOf(LargeBadge.EmptyModel()))
TabLayoutMediator(tabs, pager) { _, _ -> TabLayoutMediator(tabs, pager) { _, _ ->
}.attach() }.attach()
pager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { pager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) { override fun onPageSelected(position: Int) {
if (adapter.getModel(position).map { it is LargeBadge.Model }.orElse(false)) {
viewModel.onPageSelected(position) viewModel.onPageSelected(position)
} }
}
}) })
viewModel.state.observe(viewLifecycleOwner) { state -> viewModel.state.observe(viewLifecycleOwner) { state ->

View file

@ -26,4 +26,8 @@ class LifecycleDisposable : DefaultLifecycleObserver {
owner.lifecycle.removeObserver(this) owner.lifecycle.removeObserver(this)
disposables.clear() disposables.clear()
} }
operator fun plusAssign(disposable: Disposable) {
add(disposable)
}
} }

View file

@ -24,7 +24,9 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_marginTop="6dp" android:layout_marginTop="6dp"
android:ellipsize="end"
android:lineSpacingExtra="4sp" android:lineSpacingExtra="4sp"
android:lines="2"
android:textAlignment="center" android:textAlignment="center"
android:textAppearance="@style/Signal.Text.Caption" android:textAppearance="@style/Signal.Text.Caption"
app:layout_constraintEnd_toEndOf="@id/badge" app:layout_constraintEnd_toEndOf="@id/badge"

View file

@ -1,29 +1,25 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout 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" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent"
android:orientation="vertical">
<ImageView <ImageView
android:id="@+id/badge" android:id="@+id/badge"
android:layout_width="200dp" android:layout_width="200dp"
android:layout_height="200dp" android:layout_height="200dp"
android:layout_gravity="center_horizontal"
android:contentDescription="@string/BadgesOverviewFragment__featured_badge" android:contentDescription="@string/BadgesOverviewFragment__featured_badge"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/test_gradient" /> tools:src="@drawable/test_gradient" />
<TextView <TextView
android:id="@+id/name" android:id="@+id/name"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="16dp" android:layout_marginTop="16dp"
android:textAppearance="@style/TextAppearance.Signal.Title2" android:textAppearance="@style/TextAppearance.Signal.Title2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/badge"
tools:text="Signal Sustainer" /> tools:text="Signal Sustainer" />
<TextView <TextView
@ -40,9 +36,6 @@
android:paddingEnd="32dp" android:paddingEnd="32dp"
android:textAlignment="center" android:textAlignment="center"
android:textAppearance="@style/Signal.Text.Body" android:textAppearance="@style/Signal.Text.Body"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/name"
tools:text="Paige supports Signal by making a monthly donation. Get your own badge by donating below." /> tools:text="Paige supports Signal by making a monthly donation. Get your own badge by donating below." />
</androidx.constraintlayout.widget.ConstraintLayout> </LinearLayout>

View file

@ -3845,8 +3845,12 @@
<string name="BadgeSelectionFragment__select_badges">Select badges</string> <string name="BadgeSelectionFragment__select_badges">Select badges</string>
<string name="SelectFeaturedBadgeFragment__preview">Preview</string> <string name="SelectFeaturedBadgeFragment__preview">Preview</string>
<string name="SelectFeaturedBadgeFragment__select_a_badge">Select a badge</string> <string name="SelectFeaturedBadgeFragment__select_a_badge">Select a badge</string>
<string name="SelectFeaturedBadgeFragment__you_must_select_a_badge">You must select a badge</string>
<string name="SelectFeaturedBadgeFragment__failed_to_update_profile">Failed to update profile</string>
<string name="ViewBadgeBottomSheetDialogFragment__become_a_sustainer">Become a sustainer</string> <string name="ViewBadgeBottomSheetDialogFragment__become_a_sustainer">Become a sustainer</string>
<!-- EOF --> <!-- EOF -->