mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-21 17:29:32 +01:00
Adjust new avatar picker logic.
* Better emoji rendering support * Deleting an avatar will deselect it * Added padding to the bottom of recyclers * Disabled save if no edit / selection has been made. * Clearing and saving will remove a user's avatar.
This commit is contained in:
committed by
Greyson Parrelli
parent
a75f634c0a
commit
a27d60f830
@@ -3,13 +3,11 @@ package org.thoughtcrime.securesms.avatar
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import com.airbnb.lottie.SimpleColorFilter
|
||||
import com.amulyakhare.textdrawable.TextDrawable
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.mediasend.Media
|
||||
import org.thoughtcrime.securesms.mms.PartAuthority
|
||||
@@ -28,7 +26,7 @@ import javax.annotation.meta.Exhaustive
|
||||
*/
|
||||
object AvatarRenderer {
|
||||
|
||||
private val DIMENSIONS = AvatarHelper.AVATAR_DIMENSIONS
|
||||
val DIMENSIONS = AvatarHelper.AVATAR_DIMENSIONS
|
||||
|
||||
fun getTypeface(context: Context): Typeface {
|
||||
return Typeface.createFromAsset(context.assets, "fonts/Inter-Medium.otf")
|
||||
@@ -50,30 +48,8 @@ object AvatarRenderer {
|
||||
avatar: Avatar.Text,
|
||||
inverted: Boolean = false,
|
||||
size: Int = DIMENSIONS,
|
||||
isRect: Boolean = true
|
||||
): Drawable {
|
||||
val typeface = getTypeface(context)
|
||||
val color: Int = if (inverted) {
|
||||
avatar.color.backgroundColor
|
||||
} else {
|
||||
avatar.color.foregroundColor
|
||||
}
|
||||
|
||||
val builder = TextDrawable
|
||||
.builder()
|
||||
.beginConfig()
|
||||
.fontSize(Avatars.getTextSizeForLength(context, avatar.text, size * 0.8f, size * 0.45f).toInt())
|
||||
.textColor(color)
|
||||
.useFont(typeface)
|
||||
.width(size)
|
||||
.height(size)
|
||||
.endConfig()
|
||||
|
||||
return if (isRect) {
|
||||
builder.buildRect(avatar.text, Color.TRANSPARENT)
|
||||
} else {
|
||||
builder.buildRound(avatar.text, Color.TRANSPARENT)
|
||||
}
|
||||
return TextAvatarDrawable(context, avatar, inverted, size)
|
||||
}
|
||||
|
||||
private fun renderVector(context: Context, avatar: Avatar.Vector, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit) {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package org.thoughtcrime.securesms.avatar
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.ColorFilter
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.TypedValue
|
||||
import android.view.Gravity
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
|
||||
|
||||
/**
|
||||
* Uses EmojiTextView to properly render a Text Avatar with emoji in it.
|
||||
*/
|
||||
class TextAvatarDrawable(
|
||||
context: Context,
|
||||
avatar: Avatar.Text,
|
||||
inverted: Boolean = false,
|
||||
private val size: Int = AvatarRenderer.DIMENSIONS,
|
||||
) : Drawable() {
|
||||
|
||||
private val layout: FrameLayout = FrameLayout(context)
|
||||
private val textView: EmojiTextView = EmojiTextView(context)
|
||||
|
||||
init {
|
||||
textView.typeface = AvatarRenderer.getTypeface(context)
|
||||
textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, Avatars.getTextSizeForLength(context, avatar.text, size * 0.8f, size * 0.45f))
|
||||
textView.text = avatar.text
|
||||
textView.gravity = Gravity.CENTER
|
||||
textView.setTextColor(if (inverted) avatar.color.backgroundColor else avatar.color.foregroundColor)
|
||||
|
||||
layout.addView(textView)
|
||||
|
||||
textView.updateLayoutParams {
|
||||
width = size
|
||||
height = size
|
||||
}
|
||||
|
||||
layout.measure(size, size)
|
||||
layout.layout(0, 0, size, size)
|
||||
}
|
||||
|
||||
override fun getIntrinsicHeight(): Int = size
|
||||
|
||||
override fun getIntrinsicWidth(): Int = size
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
layout.draw(canvas)
|
||||
}
|
||||
|
||||
override fun setAlpha(alpha: Int) = Unit
|
||||
|
||||
override fun setColorFilter(colorFilter: ColorFilter?) = Unit
|
||||
|
||||
override fun getOpacity(): Int = PixelFormat.OPAQUE
|
||||
}
|
||||
@@ -40,6 +40,7 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
|
||||
companion object {
|
||||
const val REQUEST_KEY_SELECT_AVATAR = "org.thoughtcrime.securesms.avatar.picker.SELECT_AVATAR"
|
||||
const val SELECT_AVATAR_MEDIA = "org.thoughtcrime.securesms.avatar.picker.SELECT_AVATAR_MEDIA"
|
||||
const val SELECT_AVATAR_CLEAR = "org.thoughtcrime.securesms.avatar.picker.SELECT_AVATAR_CLEAR"
|
||||
|
||||
private const val REQUEST_CODE_SELECT_IMAGE = 1
|
||||
}
|
||||
@@ -94,15 +95,26 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
|
||||
photoButton.setOnIconClickedListener { openGallery() }
|
||||
textButton.setOnIconClickedListener { openTextEditor(null) }
|
||||
saveButton.setOnClickListener { v ->
|
||||
viewModel.save {
|
||||
setFragmentResult(
|
||||
REQUEST_KEY_SELECT_AVATAR,
|
||||
Bundle().apply {
|
||||
putParcelable(SELECT_AVATAR_MEDIA, it)
|
||||
}
|
||||
)
|
||||
Navigation.findNavController(v).popBackStack()
|
||||
}
|
||||
viewModel.save(
|
||||
{
|
||||
setFragmentResult(
|
||||
REQUEST_KEY_SELECT_AVATAR,
|
||||
Bundle().apply {
|
||||
putParcelable(SELECT_AVATAR_MEDIA, it)
|
||||
}
|
||||
)
|
||||
Navigation.findNavController(v).popBackStack()
|
||||
},
|
||||
{
|
||||
setFragmentResult(
|
||||
REQUEST_KEY_SELECT_AVATAR,
|
||||
Bundle().apply {
|
||||
putBoolean(SELECT_AVATAR_CLEAR, true)
|
||||
}
|
||||
)
|
||||
Navigation.findNavController(v).popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
clearButton.setOnClickListener { viewModel.clear() }
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.core.view.setPadding
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import com.airbnb.lottie.SimpleColorFilter
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.avatar.Avatar
|
||||
@@ -58,17 +57,14 @@ object AvatarPickerItem {
|
||||
init {
|
||||
textView.typeface = AvatarRenderer.getTypeface(context)
|
||||
textView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
|
||||
updateTextSize()
|
||||
updateAndApplyText(textView.text.toString())
|
||||
}
|
||||
textView.addTextChangedListener(
|
||||
afterTextChanged = {
|
||||
updateTextSize()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateTextSize() {
|
||||
textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, Avatars.getTextSizeForLength(context, textView.text.toString(), textView.measuredWidth * 0.8f, textView.measuredHeight * 0.45f))
|
||||
private fun updateAndApplyText(text: String) {
|
||||
val textSize = Avatars.getTextSizeForLength(context, text, textView.measuredWidth * 0.8f, textView.measuredHeight * 0.45f)
|
||||
textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
|
||||
textView.text = text
|
||||
}
|
||||
|
||||
override fun bind(model: Model) {
|
||||
@@ -112,9 +108,7 @@ object AvatarPickerItem {
|
||||
is Avatar.Text -> {
|
||||
textView.visible = true
|
||||
|
||||
if (textView.text.toString() != model.avatar.text) {
|
||||
textView.text = model.avatar.text
|
||||
}
|
||||
updateAndApplyText(model.avatar.text)
|
||||
|
||||
imageView.setImageDrawable(null)
|
||||
imageView.background.colorFilter = SimpleColorFilter(model.avatar.color.backgroundColor)
|
||||
|
||||
@@ -13,6 +13,7 @@ import org.thoughtcrime.securesms.avatar.Avatar
|
||||
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage
|
||||
import org.thoughtcrime.securesms.avatar.AvatarRenderer
|
||||
import org.thoughtcrime.securesms.avatar.Avatars
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.mediasend.Media
|
||||
@@ -61,10 +62,10 @@ class AvatarPickerRepository(context: Context) {
|
||||
)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Failed to read group avatar!")
|
||||
getDefaultAvatarForGroup()
|
||||
getDefaultAvatarForGroup(recipient.avatarColor)
|
||||
}
|
||||
} else {
|
||||
getDefaultAvatarForGroup()
|
||||
getDefaultAvatarForGroup(recipient.avatarColor)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,12 +156,25 @@ class AvatarPickerRepository(context: Context) {
|
||||
return if (initials.isNullOrBlank()) {
|
||||
Avatar.getDefaultForSelf()
|
||||
} else {
|
||||
Avatar.Text(initials, Avatars.colors.random(), Avatar.DatabaseId.NotSet)
|
||||
Avatar.Text(initials, requireNotNull(Avatars.colorMap[Recipient.self().avatarColor.serialize()]), Avatar.DatabaseId.DoNotPersist)
|
||||
}
|
||||
}
|
||||
|
||||
fun getDefaultAvatarForGroup(): Avatar {
|
||||
return Avatar.getDefaultForGroup()
|
||||
fun getDefaultAvatarForGroup(groupId: GroupId): Avatar {
|
||||
val recipient = Recipient.externalGroupExact(applicationContext, groupId)
|
||||
|
||||
return getDefaultAvatarForGroup(recipient.avatarColor)
|
||||
}
|
||||
|
||||
fun getDefaultAvatarForGroup(color: AvatarColor?): Avatar {
|
||||
val colorPair = Avatars.colorMap[color?.serialize()]
|
||||
val defaultColor = Avatar.getDefaultForGroup()
|
||||
|
||||
return if (colorPair != null) {
|
||||
defaultColor.copy(color = colorPair)
|
||||
} else {
|
||||
defaultColor
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(avatar: Avatar, onDelete: () -> Unit) {
|
||||
|
||||
@@ -6,5 +6,6 @@ data class AvatarPickerState(
|
||||
val currentAvatar: Avatar? = null,
|
||||
val selectableAvatars: List<Avatar> = listOf(),
|
||||
val canSave: Boolean = false,
|
||||
val canClear: Boolean = false
|
||||
val canClear: Boolean = false,
|
||||
val isCleared: Boolean = false
|
||||
)
|
||||
|
||||
@@ -27,6 +27,7 @@ sealed class AvatarPickerViewModel(private val repository: AvatarPickerRepositor
|
||||
|
||||
fun delete(avatar: Avatar) {
|
||||
repository.delete(avatar) {
|
||||
refreshAvatar()
|
||||
refreshSelectableAvatars()
|
||||
}
|
||||
}
|
||||
@@ -34,22 +35,26 @@ sealed class AvatarPickerViewModel(private val repository: AvatarPickerRepositor
|
||||
fun clear() {
|
||||
store.update {
|
||||
val avatar = getDefaultAvatarFromRepository()
|
||||
it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = false)
|
||||
it.copy(currentAvatar = avatar, canSave = true, canClear = false, isCleared = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun save(onSaved: (Media) -> Unit) {
|
||||
val avatar = store.state.currentAvatar ?: throw AssertionError()
|
||||
persistAndCreateMedia(avatar, onSaved)
|
||||
fun save(onSaved: (Media) -> Unit, onCleared: () -> Unit) {
|
||||
if (store.state.isCleared) {
|
||||
onCleared()
|
||||
} else {
|
||||
val avatar = store.state.currentAvatar ?: throw AssertionError()
|
||||
persistAndCreateMedia(avatar, onSaved)
|
||||
}
|
||||
}
|
||||
|
||||
fun onAvatarSelectedFromGrid(avatar: Avatar) {
|
||||
store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = true) }
|
||||
store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = true, isCleared = false) }
|
||||
}
|
||||
|
||||
fun onAvatarEditCompleted(avatar: Avatar) {
|
||||
persistAvatar(avatar) { saved ->
|
||||
store.update { it.copy(currentAvatar = saved, canSave = isSaveable(saved), canClear = true) }
|
||||
store.update { it.copy(currentAvatar = saved, canSave = isSaveable(saved), canClear = true, isCleared = false) }
|
||||
refreshSelectableAvatars()
|
||||
}
|
||||
}
|
||||
@@ -57,7 +62,7 @@ sealed class AvatarPickerViewModel(private val repository: AvatarPickerRepositor
|
||||
fun onAvatarPhotoSelectionCompleted(media: Media) {
|
||||
repository.writeMediaToMultiSessionStorage(media) { multiSessionUri ->
|
||||
persistAvatar(Avatar.Photo(multiSessionUri, media.size, Avatar.DatabaseId.NotSet)) { avatar ->
|
||||
store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = true) }
|
||||
store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = true, isCleared = false) }
|
||||
refreshSelectableAvatars()
|
||||
}
|
||||
}
|
||||
@@ -66,7 +71,7 @@ sealed class AvatarPickerViewModel(private val repository: AvatarPickerRepositor
|
||||
protected fun refreshAvatar() {
|
||||
disposables.add(
|
||||
getAvatar().subscribeOn(Schedulers.io()).subscribe { avatar ->
|
||||
store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = !isSaveable(avatar)) }
|
||||
store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = avatar is Avatar.Photo && !isSaveable(avatar), isCleared = false) }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -84,7 +89,7 @@ sealed class AvatarPickerViewModel(private val repository: AvatarPickerRepositor
|
||||
)
|
||||
}
|
||||
|
||||
private fun isSaveable(avatar: Avatar) = !(avatar is Avatar.Photo && avatar.databaseId == Avatar.DatabaseId.DoNotPersist)
|
||||
private fun isSaveable(avatar: Avatar) = avatar.databaseId != Avatar.DatabaseId.DoNotPersist
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.dispose()
|
||||
@@ -132,7 +137,7 @@ sealed class AvatarPickerViewModel(private val repository: AvatarPickerRepositor
|
||||
}
|
||||
}
|
||||
|
||||
override fun getDefaultAvatarFromRepository(): Avatar = repository.getDefaultAvatarForGroup()
|
||||
override fun getDefaultAvatarFromRepository(): Avatar = repository.getDefaultAvatarForGroup(groupId)
|
||||
override fun getPersistedAvatars(): Single<List<Avatar>> = repository.getPersistedAvatarsForGroup(groupId)
|
||||
override fun getDefaultAvatars(): Single<List<Avatar>> = repository.getDefaultAvatarsForGroup()
|
||||
|
||||
@@ -161,11 +166,11 @@ sealed class AvatarPickerViewModel(private val repository: AvatarPickerRepositor
|
||||
return if (initialAvatar != null) {
|
||||
Single.just(initialAvatar)
|
||||
} else {
|
||||
Single.just(getDefaultAvatarFromRepository())
|
||||
Single.fromCallable { getDefaultAvatarFromRepository() }
|
||||
}
|
||||
}
|
||||
|
||||
override fun getDefaultAvatarFromRepository(): Avatar = repository.getDefaultAvatarForGroup()
|
||||
override fun getDefaultAvatarFromRepository(): Avatar = repository.getDefaultAvatarForGroup(null)
|
||||
override fun getPersistedAvatars(): Single<List<Avatar>> = Single.just(listOf())
|
||||
override fun getDefaultAvatars(): Single<List<Avatar>> = repository.getDefaultAvatarsForGroup()
|
||||
override fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit) = onPersisted(avatar)
|
||||
|
||||
@@ -41,6 +41,8 @@ class TextAvatarCreationFragment : Fragment(R.layout.text_avatar_creation_fragme
|
||||
private val withRecyclerSet = ConstraintSet()
|
||||
private val withoutRecyclerSet = ConstraintSet()
|
||||
|
||||
private var hasBoundFromViewModel: Boolean = false
|
||||
|
||||
private fun createFactory(): TextAvatarCreationViewModel.Factory {
|
||||
val args = TextAvatarCreationFragmentArgs.fromBundle(requireArguments())
|
||||
val textBundle = args.textAvatar
|
||||
@@ -83,17 +85,25 @@ class TextAvatarCreationFragment : Fragment(R.layout.text_avatar_creation_fragme
|
||||
EditTextUtil.setCursorColor(textInput, state.currentAvatar.color.foregroundColor)
|
||||
|
||||
val hadText = textInput.length() > 0
|
||||
val selectionStart = textInput.selectionStart
|
||||
val selectionEnd = textInput.selectionEnd
|
||||
|
||||
viewHolder.bind(AvatarPickerItem.Model(state.currentAvatar, false))
|
||||
if (!hadText) {
|
||||
textInput.setSelection(textInput.length())
|
||||
textInput.post {
|
||||
if (!hadText) {
|
||||
textInput.setSelection(textInput.length())
|
||||
} else {
|
||||
textInput.setSelection(selectionStart, selectionEnd)
|
||||
}
|
||||
}
|
||||
|
||||
adapter.submitList(state.colors().map { AvatarColorItem.Model(it) })
|
||||
hasBoundFromViewModel = true
|
||||
}
|
||||
|
||||
EditTextUtil.addGraphemeClusterLimitFilter(textInput, 3)
|
||||
textInput.doAfterTextChanged {
|
||||
if (it != null) {
|
||||
if (it != null && hasBoundFromViewModel) {
|
||||
viewModel.setText(it.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.thoughtcrime.securesms.avatar.text
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.Transformations
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import org.thoughtcrime.securesms.avatar.Avatar
|
||||
@@ -11,14 +12,20 @@ class TextAvatarCreationViewModel(initialText: Avatar.Text) : ViewModel() {
|
||||
|
||||
private val store = Store(TextAvatarCreationState(initialText))
|
||||
|
||||
val state: LiveData<TextAvatarCreationState> = store.stateLiveData
|
||||
val state: LiveData<TextAvatarCreationState> = Transformations.distinctUntilChanged(store.stateLiveData)
|
||||
|
||||
fun setColor(colorPair: Avatars.ColorPair) {
|
||||
store.update { it.copy(currentAvatar = it.currentAvatar.copy(color = colorPair)) }
|
||||
}
|
||||
|
||||
fun setText(text: String) {
|
||||
store.update { it.copy(currentAvatar = it.currentAvatar.copy(text = text)) }
|
||||
store.update {
|
||||
if (it.currentAvatar.text == text) {
|
||||
it
|
||||
} else {
|
||||
it.copy(currentAvatar = it.currentAvatar.copy(text = text))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getCurrentAvatar(): Avatar.Text {
|
||||
|
||||
Reference in New Issue
Block a user