Add indicator and story ring for stories in chat selection.

This commit is contained in:
Clark 2023-03-16 16:46:25 -04:00 committed by Greyson Parrelli
parent 7c8de901f1
commit 17fc0dc0a1
9 changed files with 221 additions and 26 deletions

View file

@ -344,7 +344,8 @@ public final class ContactSelectionListFragment extends LoggingFragment {
isMulti,
ContactSearchAdapter.DisplaySmsTag.DEFAULT,
ContactSearchAdapter.DisplaySecondaryInformation.ALWAYS,
newCallCallback != null
newCallCallback != null,
false
),
this::mapStateToConfiguration,
new ContactSearchMediator.SimpleCallbacks() {

View file

@ -4,6 +4,7 @@ import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.widget.FrameLayout
import androidx.core.content.res.use
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.database.model.StoryViewState
@ -20,10 +21,12 @@ class AvatarView @JvmOverloads constructor(
attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {
private var storyRingScale = 0.8f
init {
inflate(context, R.layout.avatar_view, this)
isClickable = false
storyRingScale = context.theme.obtainStyledAttributes(attrs, R.styleable.AvatarView, 0, 0).use { it.getFloat(R.styleable.AvatarView_storyRingScale, storyRingScale) }
}
private val avatar: AvatarImageView = findViewById<AvatarImageView>(R.id.avatar_image_view).apply {
@ -40,8 +43,8 @@ class AvatarView @JvmOverloads constructor(
storyRing.visible = true
storyRing.isActivated = hasUnreadStory
avatar.scaleX = 0.8f
avatar.scaleY = 0.8f
avatar.scaleX = storyRingScale
avatar.scaleY = storyRingScale
}
private fun hideStoryRing() {

View file

@ -5,10 +5,15 @@ import android.view.View
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.TextView
import androidx.appcompat.widget.AppCompatImageView
import com.google.android.material.button.MaterialButton
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.Disposable
import org.signal.core.util.BreakIteratorCompat
import org.signal.core.util.dp
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.view.AvatarView
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.FromTextView
@ -19,6 +24,7 @@ import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration
import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto
import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
import org.thoughtcrime.securesms.database.model.StoryViewState
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
import org.thoughtcrime.securesms.recipients.Recipient
@ -45,7 +51,7 @@ open class ContactSearchAdapter(
) : PagingMappingAdapter<ContactSearchKey>(), FastScrollAdapter {
init {
registerStoryItems(this, displayOptions.displayCheckBox, onClickCallbacks::onStoryClicked, storyContextMenuCallbacks)
registerStoryItems(this, displayOptions.displayCheckBox, onClickCallbacks::onStoryClicked, storyContextMenuCallbacks, displayOptions.displayStoryRing)
registerKnownRecipientItems(this, fixedContacts, displayOptions, onClickCallbacks::onKnownRecipientClicked, longClickCallbacks::onKnownRecipientLongClick, callButtonClickCallbacks)
registerHeaders(this)
registerExpands(this, onClickCallbacks::onExpandClicked)
@ -70,11 +76,12 @@ open class ContactSearchAdapter(
mappingAdapter: MappingAdapter,
displayCheckBox: Boolean = false,
storyListener: OnClickedCallback<ContactSearchData.Story>,
storyContextMenuCallbacks: StoryContextMenuCallbacks? = null
storyContextMenuCallbacks: StoryContextMenuCallbacks? = null,
showStoryRing: Boolean = false
) {
mappingAdapter.registerFactory(
StoryModel::class.java,
LayoutFactory({ StoryViewHolder(it, displayCheckBox, storyListener, storyContextMenuCallbacks) }, R.layout.contact_search_item)
LayoutFactory({ StoryViewHolder(it, displayCheckBox, storyListener, storyContextMenuCallbacks, showStoryRing) }, R.layout.contact_search_story_item)
)
}
@ -158,15 +165,47 @@ open class ContactSearchAdapter(
private class StoryViewHolder(
itemView: View,
displayCheckBox: Boolean,
onClick: OnClickedCallback<ContactSearchData.Story>,
private val storyContextMenuCallbacks: StoryContextMenuCallbacks?
) : BaseRecipientViewHolder<StoryModel, ContactSearchData.Story>(itemView, DisplayOptions(displayCheckBox = displayCheckBox), onClick, EmptyCallButtonClickCallbacks) {
override fun isSelected(model: StoryModel): Boolean = model.isSelected
override fun getData(model: StoryModel): ContactSearchData.Story = model.story
override fun getRecipient(model: StoryModel): Recipient = model.story.recipient
val displayCheckBox: Boolean,
val onClick: OnClickedCallback<ContactSearchData.Story>,
private val storyContextMenuCallbacks: StoryContextMenuCallbacks?,
private val showStoryRing: Boolean = false
) : MappingViewHolder<StoryModel>(itemView) {
override fun bindNumberField(model: StoryModel) {
val avatar: AvatarView = itemView.findViewById(R.id.contact_photo_image)
val badge: BadgeImageView = itemView.findViewById(R.id.contact_badge)
val checkbox: CheckBox = itemView.findViewById(R.id.check_box)
val name: FromTextView = itemView.findViewById(R.id.name)
val number: TextView = itemView.findViewById(R.id.number)
val groupStoryIndicator: AppCompatImageView = itemView.findViewById(R.id.group_story_indicator)
var storyViewState: Observable<StoryViewState>? = null
var storyDisposable: Disposable? = null
override fun bind(model: StoryModel) {
itemView.setOnClickListener { onClick.onClicked(avatar, getData(model), isSelected(model)) }
bindLongPress(model)
bindCheckbox(model)
if (payload.isNotEmpty()) {
return
}
storyViewState = if (showStoryRing) StoryViewState.getForRecipientId(getRecipient(model).id) else null
avatar.setStoryRingFromState(StoryViewState.NONE)
groupStoryIndicator.isActivated = false
name.setText(getRecipient(model))
badge.setBadgeFromRecipient(getRecipient(model))
bindAvatar(model)
bindNumberField(model)
}
fun isSelected(model: StoryModel): Boolean = model.isSelected
fun getData(model: StoryModel): ContactSearchData.Story = model.story
fun getRecipient(model: StoryModel): Recipient = model.story.recipient
fun bindNumberField(model: StoryModel) {
number.visible = true
val count = if (model.story.recipient.isGroup) {
@ -193,17 +232,23 @@ open class ContactSearchAdapter(
}
}
override fun bindAvatar(model: StoryModel) {
if (model.story.recipient.isMyStory) {
avatar.setFallbackPhotoProvider(MyStoryFallbackPhotoProvider(Recipient.self().getDisplayName(context), 40.dp))
avatar.setAvatarUsingProfile(Recipient.self())
} else {
avatar.setFallbackPhotoProvider(Recipient.DEFAULT_FALLBACK_PHOTO_PROVIDER)
super.bindAvatar(model)
}
fun bindCheckbox(model: StoryModel) {
checkbox.visible = displayCheckBox
checkbox.isChecked = isSelected(model)
}
override fun bindLongPress(model: StoryModel) {
fun bindAvatar(model: StoryModel) {
if (model.story.recipient.isMyStory) {
avatar.setFallbackPhotoProvider(MyStoryFallbackPhotoProvider(Recipient.self().getDisplayName(context), 40.dp))
avatar.displayProfileAvatar(Recipient.self())
} else {
avatar.setFallbackPhotoProvider(Recipient.DEFAULT_FALLBACK_PHOTO_PROVIDER)
avatar.displayChatAvatar(getRecipient(model))
}
groupStoryIndicator.visible = showStoryRing && model.story.recipient.isGroup
}
fun bindLongPress(model: StoryModel) {
if (storyContextMenuCallbacks == null) {
return
}
@ -264,6 +309,20 @@ open class ContactSearchAdapter(
return GeneratedContactPhoto(name, R.drawable.symbol_person_40, targetSize)
}
}
override fun onAttachedToWindow() {
storyDisposable = storyViewState?.observeOn(AndroidSchedulers.mainThread())?.subscribe {
avatar.setStoryRingFromState(it)
when (it) {
StoryViewState.UNVIEWED -> groupStoryIndicator.isActivated = true
else -> groupStoryIndicator.isActivated = false
}
}
}
override fun onDetachedFromWindow() {
storyDisposable?.dispose()
}
}
/**
@ -661,7 +720,8 @@ open class ContactSearchAdapter(
val displayCheckBox: Boolean = false,
val displaySmsTag: DisplaySmsTag = DisplaySmsTag.NEVER,
val displaySecondaryInformation: DisplaySecondaryInformation = DisplaySecondaryInformation.NEVER,
val displayCallButtons: Boolean = false
val displayCallButtons: Boolean = false,
val displayStoryRing: Boolean = false
)
fun interface OnClickedCallback<D : ContactSearchData> {

View file

@ -127,7 +127,8 @@ class MultiselectForwardFragment :
ContactSearchAdapter.DisplayOptions(
displayCheckBox = !args.selectSingleRecipient,
displaySmsTag = ContactSearchAdapter.DisplaySmsTag.DEFAULT,
displaySecondaryInformation = ContactSearchAdapter.DisplaySecondaryInformation.NEVER
displaySecondaryInformation = ContactSearchAdapter.DisplaySecondaryInformation.NEVER,
displayStoryRing = true
),
this::getConfiguration,
object : ContactSearchMediator.SimpleCallbacks() {
@ -136,7 +137,6 @@ class MultiselectForwardFragment :
}
}
)
contactSearchRecycler.adapter = contactSearchMediator.adapter
callback = findListener()!!

View file

@ -304,6 +304,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
false,
ContactSearchAdapter.DisplaySmsTag.DEFAULT,
ContactSearchAdapter.DisplaySecondaryInformation.NEVER,
false,
false
),
this::mapSearchStateToConfiguration,

View file

@ -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_colorBackground" android:state_activated="true" />
<item android:color="@color/signal_light_colorBackground" />
</selector>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_activated="true">
<shape android:shape="oval">
<solid android:color="@color/signal_colorPrimary" />
</shape>
</item>
<item>
<shape android:shape="oval">
<solid android:color="@color/signal_colorOutline" />
</shape>
</item>
</selector>

View file

@ -0,0 +1,108 @@
<?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"
tools:viewBindingIgnore="true"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/conversation_item_background"
android:focusable="true"
android:minHeight="@dimen/contact_selection_item_height"
android:paddingStart="@dimen/dsl_settings_gutter"
android:paddingEnd="@dimen/dsl_settings_gutter">
<org.thoughtcrime.securesms.avatar.view.AvatarView
android:id="@+id/contact_photo_image"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/SingleContactSelectionActivity_contact_photo"
android:cropToPadding="true"
android:foreground="@drawable/contact_photo_background"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:storyRingScale="0.75"
tools:ignore="UnusedAttribute"
tools:src="@color/blue_600" />
<org.thoughtcrime.securesms.badges.BadgeImageView
android:id="@+id/contact_badge"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="20dp"
android:layout_marginTop="22dp"
android:contentDescription="@string/ImageView__badge"
app:badge_size="medium"
app:layout_constraintStart_toStartOf="@id/contact_photo_image"
app:layout_constraintTop_toTopOf="@id/contact_photo_image"
tools:visibility="visible" />
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/group_story_indicator"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginStart="23dp"
android:layout_marginTop="21dp"
android:background="@drawable/group_story_indicator_background"
android:scaleType="centerInside"
android:visibility="gone"
app:layout_constraintStart_toStartOf="@id/contact_photo_image"
app:layout_constraintTop_toTopOf="@id/contact_photo_image"
app:tint="@color/group_story_indicator_tint_selector"
app:srcCompat="@drawable/symbol_stories_fill_compact_12" />
<androidx.appcompat.widget.AppCompatCheckBox
android:id="@+id/check_box"
android:layout_width="24dp"
android:layout_height="24dp"
android:background="?contactCheckboxBackground"
android:button="@null"
android:clickable="false"
android:focusable="false"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<org.thoughtcrime.securesms.components.FromTextView
android:id="@+id/name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:checkMark="?android:attr/listChoiceIndicatorMultiple"
android:drawablePadding="4dp"
android:ellipsize="marquee"
android:singleLine="true"
android:textAppearance="@style/TextAppearance.Signal.Body1"
android:textColor="@color/signal_text_primary"
app:layout_constraintBottom_toTopOf="@id/number"
app:layout_constraintEnd_toStartOf="@id/check_box"
app:layout_constraintStart_toEndOf="@id/contact_photo_image"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
tools:drawableStart="@drawable/ic_bell_24"
tools:drawableTint="@color/signal_icon_tint_secondary"
tools:text="@sample/contacts.json/data/name" />
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/number"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:ellipsize="marquee"
android:singleLine="true"
android:textAppearance="@style/TextAppearance.Signal.Body2"
android:textColor="@color/signal_text_secondary"
android:textDirection="ltr"
app:emoji_forceCustom="true"
app:layout_constrainedWidth="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@id/name"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toEndOf="@id/contact_photo_image"
app:layout_constraintTop_toBottomOf="@id/name"
tools:text="@tools:sample/lorem/random" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -57,6 +57,10 @@
</attr>
</declare-styleable>
<declare-styleable name="AvatarView">
<attr name="storyRingScale" format="float" />
</declare-styleable>
<attr name="minWidth" format="dimension" />
<attr name="maxWidth" format="dimension" />
<attr name="minHeight" format="dimension" />