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:
Alex Hart
2021-07-21 13:35:47 -03:00
committed by Greyson Parrelli
parent a75f634c0a
commit a27d60f830
20 changed files with 293 additions and 119 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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