mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-20 00:35:47 +01:00
Convert the avatar picker to compose.
This commit is contained in:
committed by
Cody Henthorne
parent
441ba42c3f
commit
5deaab3cf3
@@ -40,7 +40,7 @@ object AvatarRenderer {
|
||||
when (avatar) {
|
||||
is Avatar.Resource -> renderResource(context, avatar, onAvatarRendered, onRenderFailed)
|
||||
is Avatar.Vector -> renderVector(context, avatar, onAvatarRendered, onRenderFailed)
|
||||
is Avatar.Photo -> renderPhoto(context, avatar, onAvatarRendered)
|
||||
is Avatar.Photo -> renderPhoto(context, avatar, onAvatarRendered, onRenderFailed)
|
||||
is Avatar.Text -> renderText(context, avatar, onAvatarRendered, onRenderFailed)
|
||||
}
|
||||
}
|
||||
@@ -78,11 +78,16 @@ object AvatarRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderPhoto(context: Context, avatar: Avatar.Photo, onAvatarRendered: (Media) -> Unit) {
|
||||
private fun renderPhoto(context: Context, avatar: Avatar.Photo, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val blob = AppDependencies.blobs
|
||||
.forData(AvatarPickerStorage.read(context, PartAuthority.getAvatarPickerFilename(avatar.uri)), avatar.size)
|
||||
.createForSingleSessionOnDisk(context)
|
||||
val blob = try {
|
||||
AppDependencies.blobs
|
||||
.forData(AvatarPickerStorage.read(context, PartAuthority.getAvatarPickerFilename(avatar.uri)), avatar.size)
|
||||
.createForSingleSessionOnDisk(context)
|
||||
} catch (e: Throwable) {
|
||||
onRenderFailed(e)
|
||||
return@execute
|
||||
}
|
||||
|
||||
onAvatarRendered(createMedia(blob, avatar.size))
|
||||
}
|
||||
@@ -104,32 +109,39 @@ object AvatarRenderer {
|
||||
|
||||
private fun renderInBackground(context: Context, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit, drawAvatar: (Canvas) -> Result<Unit>) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val canvasBitmap = Bitmap.createBitmap(DIMENSIONS, DIMENSIONS, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(canvasBitmap)
|
||||
|
||||
val drawResult = drawAvatar(canvas)
|
||||
if (drawResult.isFailure) {
|
||||
canvasBitmap.recycle()
|
||||
onRenderFailed(drawResult.exceptionOrNull())
|
||||
val media = try {
|
||||
renderToMedia(context, drawAvatar)
|
||||
} catch (e: Throwable) {
|
||||
Result.failure(e)
|
||||
}
|
||||
|
||||
val outStream = ByteArrayOutputStream()
|
||||
val compressed = canvasBitmap.compress(Bitmap.CompressFormat.JPEG, 80, outStream)
|
||||
canvasBitmap.recycle()
|
||||
|
||||
if (!compressed) {
|
||||
onRenderFailed(IOException("Failed to compress bitmap"))
|
||||
return@execute
|
||||
}
|
||||
|
||||
val bytes = outStream.toByteArray()
|
||||
val inStream = ByteArrayInputStream(bytes)
|
||||
val uri = AppDependencies.blobs.forData(inStream, bytes.size.toLong()).createForSingleSessionOnDisk(context)
|
||||
|
||||
onAvatarRendered(createMedia(uri, bytes.size.toLong()))
|
||||
media.fold(onSuccess = onAvatarRendered, onFailure = onRenderFailed)
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderToMedia(context: Context, drawAvatar: (Canvas) -> Result<Unit>): Result<Media> {
|
||||
val canvasBitmap = Bitmap.createBitmap(DIMENSIONS, DIMENSIONS, Bitmap.Config.ARGB_8888)
|
||||
val outStream = ByteArrayOutputStream()
|
||||
|
||||
val compressed = try {
|
||||
drawAvatar(Canvas(canvasBitmap)).getOrElse { return Result.failure(it) }
|
||||
|
||||
canvasBitmap.compress(Bitmap.CompressFormat.JPEG, 80, outStream)
|
||||
} finally {
|
||||
canvasBitmap.recycle()
|
||||
}
|
||||
|
||||
if (!compressed) {
|
||||
return Result.failure(IOException("Failed to compress bitmap"))
|
||||
}
|
||||
|
||||
val bytes = outStream.toByteArray()
|
||||
val inStream = ByteArrayInputStream(bytes)
|
||||
val uri = AppDependencies.blobs.forData(inStream, bytes.size.toLong()).createForSingleSessionOnDisk(context)
|
||||
|
||||
return Result.success(createMedia(uri, bytes.size.toLong()))
|
||||
}
|
||||
|
||||
private fun createMedia(uri: Uri, size: Long): Media {
|
||||
return Media(
|
||||
uri = uri,
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.avatar.compose
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.emoji.Emojifier
|
||||
import org.signal.glide.compose.GlideImage
|
||||
import org.signal.glide.compose.GlideImageScaleType
|
||||
import org.signal.glide.decryptableuri.DecryptableUri
|
||||
import org.thoughtcrime.securesms.avatar.Avatar
|
||||
import org.thoughtcrime.securesms.avatar.AvatarRenderer
|
||||
import org.thoughtcrime.securesms.avatar.Avatars
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
|
||||
import kotlin.math.min
|
||||
|
||||
/** Fraction of the avatar width the text is allowed to occupy. */
|
||||
private const val TEXT_WIDTH_PERCENT = 0.8f
|
||||
|
||||
/** Ceiling on the font size, as a fraction of the avatar size. */
|
||||
private const val MAX_TEXT_SIZE_PERCENT = 0.45f
|
||||
|
||||
/** Inset applied to each edge of a resource avatar, as a fraction of the avatar size. */
|
||||
private const val RESOURCE_PADDING_PERCENT = 0.2f
|
||||
|
||||
/**
|
||||
* Displays the given Avatar.
|
||||
*/
|
||||
@Composable
|
||||
fun AvatarImage(
|
||||
avatar: Avatar,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
// The avatar is either a vector, text, or a photo or a resource
|
||||
when (avatar) {
|
||||
is Avatar.Photo -> AvatarPhoto(avatar, contentDescription, modifier)
|
||||
is Avatar.Resource -> AvatarResource(avatar, contentDescription, modifier)
|
||||
is Avatar.Text -> AvatarText(avatar, contentDescription, modifier)
|
||||
is Avatar.Vector -> AvatarVector(avatar, contentDescription, modifier)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AvatarPhoto(
|
||||
avatar: Avatar.Photo,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier
|
||||
) {
|
||||
GlideImage(
|
||||
model = remember(avatar.uri) { DecryptableUri(avatar.uri) },
|
||||
scaleType = GlideImageScaleType.CENTER_CROP,
|
||||
modifier = modifier.semantics {
|
||||
contentDescription?.let { this.contentDescription = it }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AvatarResource(
|
||||
avatar: Avatar.Resource,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier
|
||||
) {
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
.background(color = Color(avatar.color.backgroundColor))
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(avatar.resourceId),
|
||||
contentDescription = contentDescription,
|
||||
tint = Color(avatar.color.foregroundColor),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(maxWidth * RESOURCE_PADDING_PERCENT)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AvatarText(
|
||||
avatar: Avatar.Text,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val density = LocalDensity.current
|
||||
val typeface = remember(context) { FontFamily(AvatarRenderer.getTypeface(context)) }
|
||||
|
||||
BoxWithConstraints(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier
|
||||
// Merged so the description replaces the initials, rather than being announced alongside them.
|
||||
.semantics(mergeDescendants = true) { contentDescription?.let { this.contentDescription = it } }
|
||||
.background(color = Color(avatar.color.backgroundColor))
|
||||
) {
|
||||
val fontSize = remember(avatar.text, constraints, density) {
|
||||
val sizePx = min(constraints.maxWidth, constraints.maxHeight).toFloat()
|
||||
|
||||
with(density) {
|
||||
Avatars.getTextSizeForLength(context, avatar.text, sizePx * TEXT_WIDTH_PERCENT, sizePx * MAX_TEXT_SIZE_PERCENT).toSp()
|
||||
}
|
||||
}
|
||||
|
||||
Emojifier(
|
||||
text = avatar.text
|
||||
) { text, inlineContent ->
|
||||
Text(
|
||||
text = text,
|
||||
textAlign = TextAlign.Center,
|
||||
color = Color(avatar.color.foregroundColor),
|
||||
fontFamily = typeface,
|
||||
fontSize = fontSize,
|
||||
inlineContent = inlineContent
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AvatarVector(
|
||||
avatar: Avatar.Vector,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier
|
||||
) {
|
||||
val drawableId = remember(avatar.key) { Avatars.getDrawableResource(avatar.key) }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(color = Color(avatar.color.backgroundColor))
|
||||
) {
|
||||
if (drawableId != null) {
|
||||
Image(
|
||||
painter = painterResource(drawableId),
|
||||
contentDescription = contentDescription,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun AvatarTextPreview() {
|
||||
Previews.Preview {
|
||||
AvatarImage(
|
||||
Avatar.Text(
|
||||
text = "MM",
|
||||
color = Avatars.ColorPair(foregroundAvatarColor = Avatars.ForegroundColor.A210, backgroundAvatarColor = AvatarColor.A210),
|
||||
databaseId = Avatar.DatabaseId.NotSet
|
||||
),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun AvatarVectorPreview() {
|
||||
Previews.Preview {
|
||||
AvatarImage(
|
||||
Avatar.Vector(
|
||||
key = "avatar_cat",
|
||||
color = Avatars.ColorPair(foregroundAvatarColor = Avatars.ForegroundColor.A210, backgroundAvatarColor = AvatarColor.A210),
|
||||
databaseId = Avatar.DatabaseId.NotSet
|
||||
),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun AvatarResourcePreview() {
|
||||
Previews.Preview {
|
||||
AvatarImage(
|
||||
Avatar.getDefaultForGroup(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.avatar.picker
|
||||
|
||||
import org.signal.core.models.media.Media
|
||||
import org.thoughtcrime.securesms.avatar.Avatar
|
||||
|
||||
/**
|
||||
* One-off actions that require an Activity or the nav graph, and so must be carried out by whoever hosts the picker.
|
||||
*/
|
||||
sealed interface AvatarPickerActions {
|
||||
/** Leave the picker without applying any changes. */
|
||||
data object Close : AvatarPickerActions
|
||||
|
||||
/** Leave the picker, handing [media] back to whoever launched it. */
|
||||
data class FinishWithAvatar(val media: Media) : AvatarPickerActions
|
||||
|
||||
/** Leave the picker, telling whoever launched it that the avatar was cleared. */
|
||||
data object FinishWithClearedAvatar : AvatarPickerActions
|
||||
|
||||
/** Open the camera to take a new avatar photo. */
|
||||
data object LaunchCameraCapture : AvatarPickerActions
|
||||
|
||||
/** Open the gallery to choose an avatar photo. */
|
||||
data object LaunchPhotoSelection : AvatarPickerActions
|
||||
|
||||
/** Open the text avatar creator without a starting avatar. */
|
||||
data object LaunchTextAvatarCreation : AvatarPickerActions
|
||||
|
||||
/** Open the editor that corresponds to [avatar]. */
|
||||
data class LaunchAvatarEditor(val avatar: Avatar) : AvatarPickerActions
|
||||
|
||||
/** Tell the user their avatar could not be saved. */
|
||||
data object ShowSaveFailed : AvatarPickerActions
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.avatar.picker
|
||||
|
||||
import org.signal.core.models.media.Media
|
||||
import org.thoughtcrime.securesms.avatar.Avatar
|
||||
|
||||
sealed interface AvatarPickerEvents {
|
||||
/** User has selected an avatar */
|
||||
data class AvatarSelected(val avatar: Avatar) : AvatarPickerEvents
|
||||
|
||||
/** User has selected a photo to use as an avatar */
|
||||
data class PhotoSelected(val media: Media) : AvatarPickerEvents
|
||||
|
||||
/** User has edited avatar */
|
||||
data class AvatarEdited(val avatar: Avatar) : AvatarPickerEvents
|
||||
|
||||
/** User has cleared their current avatar */
|
||||
data object ClearAvatar : AvatarPickerEvents
|
||||
|
||||
/** User has deleted an avatar */
|
||||
data class DeleteAvatar(val avatar: Avatar) : AvatarPickerEvents
|
||||
|
||||
/** User wants to take a photo */
|
||||
data object CapturePhoto : AvatarPickerEvents
|
||||
|
||||
/** User wants to select a photo */
|
||||
data object SelectPhoto : AvatarPickerEvents
|
||||
|
||||
/** User wants to select text */
|
||||
data object SelectText : AvatarPickerEvents
|
||||
|
||||
/** User wants to edit the avatar */
|
||||
data class EditAvatar(val avatar: Avatar) : AvatarPickerEvents
|
||||
|
||||
/** User has saved */
|
||||
data object Save : AvatarPickerEvents
|
||||
|
||||
/** User has chosen to close the picker */
|
||||
data object Close : AvatarPickerEvents
|
||||
}
|
||||
+61
-152
@@ -3,46 +3,40 @@ package org.thoughtcrime.securesms.avatar.picker
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.widget.PopupMenu
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import androidx.fragment.app.setFragmentResultListener
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.Navigation
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.models.media.Media
|
||||
import org.signal.core.ui.WindowBreakpoint
|
||||
import org.signal.core.ui.getWindowBreakpoint
|
||||
import org.signal.core.ui.compose.CollectActions
|
||||
import org.signal.core.ui.compose.ComposeFragment
|
||||
import org.signal.core.ui.permissions.Permissions
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.signal.core.util.dp
|
||||
import org.signal.core.util.getParcelableExtraCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.avatar.Avatar
|
||||
import org.thoughtcrime.securesms.avatar.AvatarBundler
|
||||
import org.thoughtcrime.securesms.avatar.photo.PhotoEditorActivity
|
||||
import org.thoughtcrime.securesms.avatar.photo.PhotoEditorFragment
|
||||
import org.thoughtcrime.securesms.avatar.text.TextAvatarCreationFragment
|
||||
import org.thoughtcrime.securesms.avatar.vector.VectorAvatarCreationFragment
|
||||
import org.thoughtcrime.securesms.components.ButtonStripItemView
|
||||
import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration
|
||||
import org.thoughtcrime.securesms.mediasend.AvatarSelectionActivity
|
||||
import org.thoughtcrime.securesms.util.SystemWindowInsetsSetter
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import org.thoughtcrime.securesms.util.padding
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
/**
|
||||
* Primary Avatar picker fragment, displays current user avatar and a list of recently used avatars and defaults.
|
||||
* Carries out the [AvatarPickerActions] that need an Activity or the nav graph.
|
||||
*/
|
||||
class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
|
||||
class AvatarPickerFragment : ComposeFragment() {
|
||||
|
||||
companion object {
|
||||
const val REQUEST_KEY_SELECT_AVATAR = "org.thoughtcrime.securesms.avatar.picker.SELECT_AVATAR"
|
||||
@@ -54,122 +48,49 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
|
||||
|
||||
private val viewModel: AvatarPickerViewModel by viewModels(factoryProducer = this::createFactory)
|
||||
|
||||
private lateinit var recycler: RecyclerView
|
||||
private lateinit var photoEditorLauncher: ActivityResultLauncher<Avatar.Photo>
|
||||
private val photoEditorLauncher = registerForActivityResult(PhotoEditorActivity.Contract()) { photo ->
|
||||
if (photo != null) {
|
||||
viewModel.onEvent(AvatarPickerEvents.AvatarEdited(photo))
|
||||
}
|
||||
}
|
||||
|
||||
private fun createFactory(): AvatarPickerViewModel.Factory {
|
||||
val args = AvatarPickerFragmentArgs.fromBundle(requireArguments())
|
||||
|
||||
return AvatarPickerViewModel.Factory(AvatarPickerRepository(requireContext()), args.groupId, args.isNewGroup, args.groupAvatarMedia)
|
||||
return AvatarPickerViewModel.Factory(args.groupId, args.isNewGroup, args.groupAvatarMedia)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
val saveFailedMessage = stringResource(R.string.AvatarPickerRepository__failed_to_save_avatar)
|
||||
|
||||
CollectActions(viewModel.actions) { action ->
|
||||
handleAction(action) {
|
||||
scope.launch { snackbarHostState.showSnackbar(saveFailedMessage) }
|
||||
}
|
||||
}
|
||||
|
||||
AvatarPickerScreen(
|
||||
state = state,
|
||||
onEvent = viewModel::onEvent,
|
||||
snackbarHostState = snackbarHostState
|
||||
)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
SystemWindowInsetsSetter.attach(view, viewLifecycleOwner)
|
||||
|
||||
val toolbar: Toolbar = view.findViewById(R.id.avatar_picker_toolbar)
|
||||
val cameraButton: ButtonStripItemView = view.findViewById(R.id.avatar_picker_camera)
|
||||
val photoButton: ButtonStripItemView = view.findViewById(R.id.avatar_picker_photo)
|
||||
val textButton: ButtonStripItemView = view.findViewById(R.id.avatar_picker_text)
|
||||
val saveButton: View = view.findViewById(R.id.avatar_picker_save)
|
||||
val clearButton: View = view.findViewById(R.id.avatar_picker_clear)
|
||||
|
||||
val spanCount = when (resources.getWindowBreakpoint()) {
|
||||
is WindowBreakpoint.Small -> 4
|
||||
else -> 6
|
||||
}
|
||||
|
||||
val recyclerPadding = when (resources.getWindowBreakpoint()) {
|
||||
is WindowBreakpoint.Small -> 0
|
||||
else -> 112.dp
|
||||
}
|
||||
|
||||
recycler = view.findViewById(R.id.avatar_picker_recycler)
|
||||
recycler.addItemDecoration(GridDividerDecoration(spanCount, ViewUtil.dpToPx(16)))
|
||||
recycler.padding(
|
||||
left = recyclerPadding,
|
||||
right = recyclerPadding
|
||||
)
|
||||
|
||||
val gridLayoutManager: GridLayoutManager = recycler.layoutManager as GridLayoutManager
|
||||
gridLayoutManager.spanCount = spanCount
|
||||
|
||||
val adapter = MappingAdapter()
|
||||
AvatarPickerItem.register(adapter, this::onAvatarClick, this::onAvatarLongClick)
|
||||
|
||||
recycler.adapter = adapter
|
||||
|
||||
val avatarViewHolder = AvatarPickerItem.ViewHolder(view)
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
if (state.currentAvatar != null) {
|
||||
avatarViewHolder.bind(AvatarPickerItem.Model(state.currentAvatar, false))
|
||||
}
|
||||
|
||||
clearButton.visible = state.canClear
|
||||
saveButton.isClickable = state.canSave
|
||||
|
||||
val items = state.selectableAvatars.map { AvatarPickerItem.Model(it, it == state.currentAvatar) }
|
||||
val selectedPosition = items.indexOfFirst { it.isSelected }
|
||||
|
||||
adapter.submitList(items) {
|
||||
if (selectedPosition > -1) {
|
||||
recycler.smoothScrollToPosition(selectedPosition)
|
||||
} else {
|
||||
recycler.smoothScrollToPosition(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toolbar.setNavigationOnClickListener { Navigation.findNavController(it).popBackStack() }
|
||||
cameraButton.setOnIconClickedListener { openCameraCapture() }
|
||||
photoButton.setOnIconClickedListener { openGallery() }
|
||||
textButton.setOnIconClickedListener { openTextEditor(null) }
|
||||
saveButton.setOnClickListener { v ->
|
||||
if (!saveButton.isEnabled) {
|
||||
return@setOnClickListener
|
||||
}
|
||||
|
||||
saveButton.isEnabled = false
|
||||
viewModel.save(
|
||||
{
|
||||
setFragmentResult(
|
||||
REQUEST_KEY_SELECT_AVATAR,
|
||||
Bundle().apply {
|
||||
putParcelable(SELECT_AVATAR_MEDIA, it)
|
||||
}
|
||||
)
|
||||
ThreadUtil.runOnMain { Navigation.findNavController(v).popBackStack() }
|
||||
},
|
||||
{
|
||||
setFragmentResult(
|
||||
REQUEST_KEY_SELECT_AVATAR,
|
||||
Bundle().apply {
|
||||
putBoolean(SELECT_AVATAR_CLEAR, true)
|
||||
}
|
||||
)
|
||||
ThreadUtil.runOnMain { Navigation.findNavController(v).popBackStack() }
|
||||
}
|
||||
)
|
||||
}
|
||||
clearButton.setOnClickListener { viewModel.clearAvatar() }
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
setFragmentResultListener(TextAvatarCreationFragment.REQUEST_KEY_TEXT) { _, bundle ->
|
||||
val text = AvatarBundler.extractText(bundle)
|
||||
viewModel.onAvatarEditCompleted(text)
|
||||
viewModel.onEvent(AvatarPickerEvents.AvatarEdited(text))
|
||||
}
|
||||
|
||||
setFragmentResultListener(VectorAvatarCreationFragment.REQUEST_KEY_VECTOR) { _, bundle ->
|
||||
val vector = AvatarBundler.extractVector(bundle)
|
||||
viewModel.onAvatarEditCompleted(vector)
|
||||
}
|
||||
|
||||
setFragmentResultListener(PhotoEditorFragment.REQUEST_KEY_EDIT) { _, _ ->
|
||||
}
|
||||
|
||||
photoEditorLauncher = registerForActivityResult(PhotoEditorActivity.Contract()) { photo ->
|
||||
if (photo != null) {
|
||||
viewModel.onAvatarEditCompleted(photo)
|
||||
}
|
||||
viewModel.onEvent(AvatarPickerEvents.AvatarEdited(vector))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,46 +99,34 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
|
||||
ViewUtil.hideKeyboard(requireContext(), requireView())
|
||||
}
|
||||
|
||||
private fun handleAction(action: AvatarPickerActions, showSaveFailed: () -> Unit) {
|
||||
when (action) {
|
||||
AvatarPickerActions.Close -> findNavController().popBackStack()
|
||||
AvatarPickerActions.ShowSaveFailed -> showSaveFailed()
|
||||
AvatarPickerActions.LaunchCameraCapture -> openCameraCapture()
|
||||
AvatarPickerActions.LaunchPhotoSelection -> openGallery()
|
||||
AvatarPickerActions.LaunchTextAvatarCreation -> openTextEditor(null)
|
||||
is AvatarPickerActions.LaunchAvatarEditor -> openEditor(action.avatar)
|
||||
is AvatarPickerActions.FinishWithAvatar -> finishWithResult { putParcelable(SELECT_AVATAR_MEDIA, action.media) }
|
||||
AvatarPickerActions.FinishWithClearedAvatar -> finishWithResult { putBoolean(SELECT_AVATAR_CLEAR, true) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun finishWithResult(populateResult: Bundle.() -> Unit) {
|
||||
setFragmentResult(REQUEST_KEY_SELECT_AVATAR, Bundle().apply(populateResult))
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (requestCode == REQUEST_CODE_SELECT_IMAGE && resultCode == Activity.RESULT_OK && data != null) {
|
||||
val media: Media = requireNotNull(data.getParcelableExtraCompat(AvatarSelectionActivity.EXTRA_MEDIA, Media::class.java))
|
||||
viewModel.onAvatarPhotoSelectionCompleted(media)
|
||||
viewModel.onEvent(AvatarPickerEvents.PhotoSelected(media))
|
||||
} else {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onAvatarClick(avatar: Avatar, isSelected: Boolean) {
|
||||
if (isSelected) {
|
||||
openEditor(avatar)
|
||||
} else {
|
||||
viewModel.onAvatarSelectedFromGrid(avatar)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onAvatarLongClick(anchorView: View, avatar: Avatar): Boolean {
|
||||
val menuRes = when (avatar) {
|
||||
is Avatar.Photo -> R.menu.avatar_picker_context
|
||||
is Avatar.Text -> R.menu.avatar_picker_context
|
||||
is Avatar.Vector -> return true
|
||||
is Avatar.Resource -> return true
|
||||
}
|
||||
|
||||
val popup = PopupMenu(context, anchorView, Gravity.TOP)
|
||||
popup.menuInflater.inflate(menuRes, popup.menu)
|
||||
popup.setOnMenuItemClickListener { menuItem ->
|
||||
when (menuItem.itemId) {
|
||||
R.id.action_delete -> viewModel.delete(avatar)
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
popup.show()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun openEditor(avatar: Avatar) {
|
||||
when (avatar) {
|
||||
is Avatar.Photo -> openPhotoEditor(avatar)
|
||||
|
||||
@@ -14,23 +14,14 @@ import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.avatar.Avatar
|
||||
import org.thoughtcrime.securesms.avatar.AvatarRenderer
|
||||
import org.thoughtcrime.securesms.avatar.Avatars
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
typealias OnAvatarClickListener = (Avatar, Boolean) -> Unit
|
||||
typealias OnAvatarLongClickListener = (View, Avatar) -> Boolean
|
||||
|
||||
object AvatarPickerItem {
|
||||
|
||||
private val SELECTION_CHANGED = Any()
|
||||
|
||||
fun register(adapter: MappingAdapter, onAvatarClickListener: OnAvatarClickListener, onAvatarLongClickListener: OnAvatarLongClickListener) {
|
||||
adapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it, onAvatarClickListener, onAvatarLongClickListener) }, R.layout.avatar_picker_item))
|
||||
}
|
||||
|
||||
class Model(val avatar: Avatar, val isSelected: Boolean) : MappingModel<Model> {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean = avatar.isSameAs(newItem.avatar)
|
||||
|
||||
@@ -45,11 +36,7 @@ object AvatarPickerItem {
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(
|
||||
itemView: View,
|
||||
private val onAvatarClickListener: OnAvatarClickListener? = null,
|
||||
private val onAvatarLongClickListener: OnAvatarLongClickListener? = null
|
||||
) : MappingViewHolder<Model>(itemView) {
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val imageView: ImageView = itemView.findViewById(R.id.avatar_picker_item_image)
|
||||
private val textView: TextView = itemView.findViewById(R.id.avatar_picker_item_text)
|
||||
@@ -81,12 +68,6 @@ object AvatarPickerItem {
|
||||
selectedOverlay?.animate()?.cancel()
|
||||
selectedFader?.animate()?.cancel()
|
||||
|
||||
itemView.setOnLongClickListener {
|
||||
onAvatarLongClickListener?.invoke(itemView, model.avatar) ?: false
|
||||
}
|
||||
|
||||
itemView.setOnClickListener { onAvatarClickListener?.invoke(model.avatar, model.isSelected) }
|
||||
|
||||
if (payload.isNotEmpty() && payload.contains(SELECTION_CHANGED)) {
|
||||
imageView.animate().scaleX(scale).scaleY(scale)
|
||||
textView.animate().scaleX(scale).scaleY(scale)
|
||||
|
||||
+61
-66
@@ -1,15 +1,16 @@
|
||||
package org.thoughtcrime.securesms.avatar.picker
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import arrow.core.Either
|
||||
import arrow.core.left
|
||||
import arrow.core.raise.either
|
||||
import arrow.core.right
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.signal.core.models.media.Media
|
||||
import org.signal.core.util.StreamUtil
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.concurrent.SignalDispatchers
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.avatar.Avatar
|
||||
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage
|
||||
import org.thoughtcrime.securesms.avatar.AvatarRenderer
|
||||
@@ -23,15 +24,14 @@ import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.NameUtil
|
||||
import org.whispersystems.signalservice.api.util.StreamDetails
|
||||
import java.io.IOException
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
private val TAG = Log.tag(AvatarPickerRepository::class.java)
|
||||
|
||||
class AvatarPickerRepository(context: Context) {
|
||||
object AvatarPickerRepository {
|
||||
|
||||
private val applicationContext = context.applicationContext
|
||||
|
||||
fun getAvatarForSelf(): Single<Avatar> = Single.fromCallable {
|
||||
val details: StreamDetails? = AvatarHelper.getSelfProfileAvatarStream(applicationContext)
|
||||
suspend fun getAvatarForSelf(): Avatar = withContext(SignalDispatchers.IO) {
|
||||
val details: StreamDetails? = AvatarHelper.getSelfProfileAvatarStream(AppDependencies.application)
|
||||
if (details != null) {
|
||||
try {
|
||||
val bytes = StreamUtil.readFully(details.stream)
|
||||
@@ -49,15 +49,15 @@ class AvatarPickerRepository(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun getAvatarForGroup(groupId: GroupId): Single<Avatar> = Single.fromCallable {
|
||||
suspend fun getAvatarForGroup(groupId: GroupId): Avatar = withContext(SignalDispatchers.IO) {
|
||||
val recipient = Recipient.externalGroupExact(groupId)
|
||||
|
||||
if (AvatarHelper.hasAvatar(applicationContext, recipient.id)) {
|
||||
if (AvatarHelper.hasAvatar(AppDependencies.application, recipient.id)) {
|
||||
try {
|
||||
val bytes = AvatarHelper.getAvatarBytes(applicationContext, recipient.id)
|
||||
val bytes = AvatarHelper.getAvatarBytes(AppDependencies.application, recipient.id)
|
||||
Avatar.Photo(
|
||||
AppDependencies.blobs.forData(bytes).createForSingleSessionInMemory(),
|
||||
AvatarHelper.getAvatarLength(applicationContext, recipient.id),
|
||||
AvatarHelper.getAvatarLength(AppDependencies.application, recipient.id),
|
||||
Avatar.DatabaseId.DoNotPersist
|
||||
)
|
||||
} catch (e: IOException) {
|
||||
@@ -69,101 +69,97 @@ class AvatarPickerRepository(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun getPersistedAvatarsForSelf(): Single<List<Avatar>> = Single.fromCallable {
|
||||
suspend fun getPersistedAvatarsForSelf(): List<Avatar> = withContext(SignalDispatchers.Default) {
|
||||
SignalDatabase.avatarPicker.getAvatarsForSelf()
|
||||
}
|
||||
|
||||
fun getPersistedAvatarsForGroup(groupId: GroupId): Single<List<Avatar>> = Single.fromCallable {
|
||||
suspend fun getPersistedAvatarsForGroup(groupId: GroupId): List<Avatar> = withContext(SignalDispatchers.Default) {
|
||||
SignalDatabase.avatarPicker.getAvatarsForGroup(groupId)
|
||||
}
|
||||
|
||||
fun getDefaultAvatarsForSelf(): Single<List<Avatar>> = Single.fromCallable {
|
||||
Avatars.defaultAvatarsForSelf.entries.mapIndexed { index, entry ->
|
||||
fun getDefaultAvatarsForSelf(): List<Avatar> {
|
||||
return Avatars.defaultAvatarsForSelf.entries.mapIndexed { index, entry ->
|
||||
Avatar.Vector(entry.key, color = Avatars.colors[index % Avatars.colors.size], Avatar.DatabaseId.NotSet)
|
||||
}
|
||||
}
|
||||
|
||||
fun getDefaultAvatarsForGroup(): Single<List<Avatar>> = Single.fromCallable {
|
||||
Avatars.defaultAvatarsForGroup.entries.mapIndexed { index, entry ->
|
||||
fun getDefaultAvatarsForGroup(): List<Avatar> {
|
||||
return Avatars.defaultAvatarsForGroup.entries.mapIndexed { index, entry ->
|
||||
Avatar.Vector(entry.key, color = Avatars.colors[index % Avatars.colors.size], Avatar.DatabaseId.NotSet)
|
||||
}
|
||||
}
|
||||
|
||||
fun writeMediaToMultiSessionStorage(media: Media, onMediaWrittenToMultiSessionStorage: (Uri) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
onMediaWrittenToMultiSessionStorage(AvatarPickerStorage.save(applicationContext, media))
|
||||
suspend fun writeMediaToMultiSessionStorage(media: Media): Either<Throwable, Uri> = withContext(SignalDispatchers.IO) {
|
||||
try {
|
||||
AvatarPickerStorage.save(AppDependencies.application, media).right()
|
||||
} catch (e: IOException) {
|
||||
e.left()
|
||||
}
|
||||
}
|
||||
|
||||
fun persistAvatarForSelf(avatar: Avatar, onPersisted: (Avatar) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
suspend fun persistAvatarForSelf(avatar: Avatar): Either<Throwable, Avatar> = withContext(SignalDispatchers.Default) {
|
||||
try {
|
||||
val avatarDatabase = SignalDatabase.avatarPicker
|
||||
val savedAvatar = avatarDatabase.saveAvatarForSelf(avatar)
|
||||
avatarDatabase.markUsage(savedAvatar)
|
||||
onPersisted(savedAvatar)
|
||||
savedAvatar.right()
|
||||
} catch (e: Exception) {
|
||||
e.left()
|
||||
}
|
||||
}
|
||||
|
||||
fun persistAvatarForGroup(avatar: Avatar, groupId: GroupId, onPersisted: (Avatar) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
suspend fun persistAvatarForGroup(avatar: Avatar, groupId: GroupId): Either<Throwable, Avatar> = withContext(SignalDispatchers.Default) {
|
||||
try {
|
||||
val avatarDatabase = SignalDatabase.avatarPicker
|
||||
val savedAvatar = avatarDatabase.saveAvatarForGroup(avatar, groupId)
|
||||
avatarDatabase.markUsage(savedAvatar)
|
||||
onPersisted(savedAvatar)
|
||||
savedAvatar.right()
|
||||
} catch (e: Exception) {
|
||||
e.left()
|
||||
}
|
||||
}
|
||||
|
||||
fun persistAndCreateMediaForSelf(avatar: Avatar, onSaved: (Media) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
if (avatar.databaseId !is Avatar.DatabaseId.DoNotPersist) {
|
||||
persistAvatarForSelf(avatar) {
|
||||
AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure)
|
||||
}
|
||||
} else {
|
||||
AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure)
|
||||
}
|
||||
suspend fun persistAndCreateMediaForSelf(avatar: Avatar): Either<Throwable, Media> = either {
|
||||
if (avatar.databaseId !is Avatar.DatabaseId.DoNotPersist) {
|
||||
persistAvatarForSelf(avatar).bind()
|
||||
}
|
||||
|
||||
renderAvatar(avatar).bind()
|
||||
}
|
||||
|
||||
fun persistAndCreateMediaForGroup(avatar: Avatar, groupId: GroupId, onSaved: (Media) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
if (avatar.databaseId !is Avatar.DatabaseId.DoNotPersist) {
|
||||
persistAvatarForGroup(avatar, groupId) {
|
||||
AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure)
|
||||
}
|
||||
} else {
|
||||
AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure)
|
||||
}
|
||||
suspend fun persistAndCreateMediaForGroup(avatar: Avatar, groupId: GroupId): Either<Throwable, Media> = either {
|
||||
if (avatar.databaseId !is Avatar.DatabaseId.DoNotPersist) {
|
||||
persistAvatarForGroup(avatar, groupId).bind()
|
||||
}
|
||||
|
||||
renderAvatar(avatar).bind()
|
||||
}
|
||||
|
||||
fun createMediaForNewGroup(avatar: Avatar, onSaved: (Media) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure)
|
||||
}
|
||||
private suspend fun renderAvatar(avatar: Avatar): Either<Throwable, Media> = suspendCancellableCoroutine { continuation ->
|
||||
AvatarRenderer.renderAvatar(
|
||||
context = AppDependencies.application,
|
||||
avatar = avatar,
|
||||
onAvatarRendered = { continuation.resume(it.right()) },
|
||||
onRenderFailed = { continuation.resume((it ?: IOException("Failed to render avatar.")).left()) }
|
||||
)
|
||||
}
|
||||
|
||||
fun handleRenderFailure(throwable: Throwable?) {
|
||||
Log.w(TAG, "Failed to render avatar.", throwable)
|
||||
ThreadUtil.postToMain {
|
||||
Toast.makeText(applicationContext, R.string.AvatarPickerRepository__failed_to_save_avatar, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
suspend fun createMediaForNewGroup(avatar: Avatar): Either<Throwable, Media> = renderAvatar(avatar)
|
||||
|
||||
fun getDefaultAvatarForSelf(): Avatar {
|
||||
val initials = NameUtil.getAbbreviation(Recipient.self().getDisplayName(applicationContext))
|
||||
suspend fun getDefaultAvatarForSelf(): Avatar = withContext(SignalDispatchers.Default) {
|
||||
val initials = NameUtil.getAbbreviation(Recipient.self().getDisplayName(AppDependencies.application))
|
||||
|
||||
return if (initials.isNullOrBlank()) {
|
||||
if (initials.isNullOrBlank()) {
|
||||
Avatar.getDefaultForSelf()
|
||||
} else {
|
||||
Avatar.Text(initials, requireNotNull(Avatars.colorMap[Recipient.self().avatarColor.serialize()]), Avatar.DatabaseId.DoNotPersist)
|
||||
}
|
||||
}
|
||||
|
||||
fun getDefaultAvatarForGroup(groupId: GroupId): Avatar {
|
||||
suspend fun getDefaultAvatarForGroup(groupId: GroupId): Avatar = withContext(SignalDispatchers.Default) {
|
||||
val recipient = Recipient.externalGroupExact(groupId)
|
||||
|
||||
return getDefaultAvatarForGroup(recipient.avatarColor)
|
||||
getDefaultAvatarForGroup(recipient.avatarColor)
|
||||
}
|
||||
|
||||
fun getDefaultAvatarForGroup(color: AvatarColor?): Avatar {
|
||||
@@ -177,13 +173,12 @@ class AvatarPickerRepository(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(avatar: Avatar, onDelete: () -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
suspend fun delete(avatar: Avatar) {
|
||||
withContext(SignalDispatchers.Default) {
|
||||
if (avatar.databaseId is Avatar.DatabaseId.Saved) {
|
||||
val avatarDatabase = SignalDatabase.avatarPicker
|
||||
avatarDatabase.deleteAvatar(avatar)
|
||||
}
|
||||
onDelete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.avatar.picker
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.lerp
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.signal.core.ui.WindowBreakpoint
|
||||
import org.signal.core.ui.compose.AllDevicePreviews
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.Dividers
|
||||
import org.signal.core.ui.compose.DropdownMenus
|
||||
import org.signal.core.ui.compose.IconButtons
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
import org.signal.core.ui.compose.Snackbars
|
||||
import org.signal.core.ui.compose.horizontalGutters
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.ui.rememberWindowBreakpoint
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.avatar.Avatar
|
||||
import org.thoughtcrime.securesms.avatar.compose.AvatarImage
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
private val AVATAR_SIZE = 72.dp
|
||||
private val SELECTED_AVATAR_SIZE = 56.dp
|
||||
private val AVATAR_SPACING = 24.dp
|
||||
private val AVATAR_GRID_INSET_LARGE = 112.dp
|
||||
|
||||
private const val HEADER_ITEM_COUNT = 1
|
||||
|
||||
@Composable
|
||||
fun AvatarPickerScreen(
|
||||
state: AvatarPickerState,
|
||||
onEvent: (AvatarPickerEvents) -> Unit,
|
||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
title = "",
|
||||
onNavigationClick = { onEvent(AvatarPickerEvents.Close) },
|
||||
navigationIcon = SignalIcons.X.imageVector,
|
||||
snackbarHost = { Snackbars.Host(snackbarHostState) },
|
||||
bottomBar = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.padding(end = 24.dp, bottom = 24.dp),
|
||||
contentAlignment = Alignment.CenterEnd
|
||||
) {
|
||||
Buttons.LargePrimary(
|
||||
onClick = { onEvent(AvatarPickerEvents.Save) },
|
||||
enabled = state.canSave,
|
||||
modifier = Modifier.testTag(AvatarPickerTestTags.SAVE_BUTTON)
|
||||
) {
|
||||
Text(text = stringResource(R.string.AvatarPickerFragment__save))
|
||||
}
|
||||
}
|
||||
}
|
||||
) { contentPadding ->
|
||||
val gridInset = when (rememberWindowBreakpoint()) {
|
||||
is WindowBreakpoint.Small -> 0.dp
|
||||
else -> AVATAR_GRID_INSET_LARGE
|
||||
}
|
||||
|
||||
val gridState = rememberLazyGridState()
|
||||
val avatarHeight = with(LocalDensity.current) { AVATAR_SIZE.roundToPx() }
|
||||
val selectedIndex = state.selectableAvatars.indexOfFirst { it == state.currentAvatar }
|
||||
|
||||
LaunchedEffect(selectedIndex) {
|
||||
gridState.animateItemIntoView(
|
||||
index = if (selectedIndex >= 0) selectedIndex + HEADER_ITEM_COUNT else 0,
|
||||
itemHeight = avatarHeight
|
||||
)
|
||||
}
|
||||
|
||||
BoxWithConstraints(modifier = Modifier.padding(contentPadding)) {
|
||||
val viewportWidth = maxWidth
|
||||
|
||||
LazyVerticalGrid(
|
||||
state = gridState,
|
||||
columns = GridCells.FixedSize(AVATAR_SIZE),
|
||||
horizontalArrangement = spacedBy(AVATAR_SPACING, Alignment.CenterHorizontally),
|
||||
verticalArrangement = spacedBy(AVATAR_SPACING),
|
||||
contentPadding = PaddingValues(
|
||||
start = dimensionResource(CoreUiR.dimen.gutter) + gridInset,
|
||||
end = dimensionResource(CoreUiR.dimen.gutter) + gridInset,
|
||||
bottom = AVATAR_SPACING
|
||||
),
|
||||
modifier = Modifier.testTag(AvatarPickerTestTags.AVATAR_GRID)
|
||||
) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
AvatarPickerHeader(
|
||||
state = state,
|
||||
onEvent = onEvent,
|
||||
// A full span item is only as wide as the cell run, so measure against the viewport instead.
|
||||
modifier = Modifier
|
||||
.wrapContentWidth(unbounded = true)
|
||||
.width(viewportWidth)
|
||||
)
|
||||
}
|
||||
|
||||
items(
|
||||
items = state.selectableAvatars,
|
||||
key = { it.gridKey }
|
||||
) { avatar ->
|
||||
SelectableAvatar(
|
||||
avatar = avatar,
|
||||
isSelected = state.currentAvatar == avatar,
|
||||
onEvent = onEvent,
|
||||
modifier = Modifier.testTag(AvatarPickerTestTags.SELECTABLE_AVATAR)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AvatarPickerHeader(
|
||||
state: AvatarPickerState,
|
||||
onEvent: (AvatarPickerEvents) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
CurrentAvatar(
|
||||
avatar = state.currentAvatar,
|
||||
canClear = state.canClear,
|
||||
onClearClick = { onEvent(AvatarPickerEvents.ClearAvatar) },
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.size(dimensionResource(R.dimen.avatar_picker_image_width))
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = spacedBy(32.dp),
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.padding(top = 32.dp, bottom = 28.dp)
|
||||
.horizontalGutters()
|
||||
) {
|
||||
Buttons.ActionButton(
|
||||
imageVector = SignalIcons.Camera.imageVector,
|
||||
label = stringResource(R.string.AvatarPickerFragment__camera),
|
||||
onClick = { onEvent(AvatarPickerEvents.CapturePhoto) },
|
||||
modifier = Modifier.testTag(AvatarPickerTestTags.CAMERA_BUTTON)
|
||||
)
|
||||
|
||||
Buttons.ActionButton(
|
||||
imageVector = SignalIcons.Photo.imageVector,
|
||||
label = stringResource(R.string.AvatarPickerFragment__photo),
|
||||
onClick = { onEvent(AvatarPickerEvents.SelectPhoto) },
|
||||
modifier = Modifier.testTag(AvatarPickerTestTags.PHOTO_BUTTON)
|
||||
)
|
||||
|
||||
Buttons.ActionButton(
|
||||
imageVector = SignalIcons.Text.imageVector,
|
||||
label = stringResource(R.string.AvatarPickerFragment__text),
|
||||
onClick = { onEvent(AvatarPickerEvents.SelectText) },
|
||||
modifier = Modifier.testTag(AvatarPickerTestTags.TEXT_BUTTON)
|
||||
)
|
||||
}
|
||||
|
||||
Dividers.Default()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CurrentAvatar(
|
||||
avatar: Avatar?,
|
||||
canClear: Boolean,
|
||||
onClearClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Crossfade(
|
||||
targetState = avatar,
|
||||
modifier = modifier
|
||||
) { targetState ->
|
||||
if (targetState == null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(color = MaterialTheme.colorScheme.surfaceVariant, shape = CircleShape)
|
||||
)
|
||||
} else {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
AvatarImage(
|
||||
avatar = targetState,
|
||||
contentDescription = stringResource(R.string.AvatarPickerFragment__avatar_preview),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(CircleShape)
|
||||
)
|
||||
|
||||
if (canClear) {
|
||||
IconButtons.IconButton(
|
||||
onClick = onClearClick,
|
||||
modifier = Modifier
|
||||
.testTag(AvatarPickerTestTags.CLEAR_AVATAR_BUTTON)
|
||||
.padding(4.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
shape = CircleShape
|
||||
)
|
||||
.align(Alignment.TopEnd)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = SignalIcons.X.imageVector,
|
||||
contentDescription = stringResource(R.string.AvatarPickerFragment__clear_avatar)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun SelectableAvatar(
|
||||
avatar: Avatar,
|
||||
isSelected: Boolean,
|
||||
onEvent: (AvatarPickerEvents) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val haptics = LocalHapticFeedback.current
|
||||
val menuController = remember { DropdownMenus.MenuController() }
|
||||
val selectedProgress by animateFloatAsState(targetValue = if (isSelected) 1f else 0f, label = "avatar-selection")
|
||||
val avatarSize = lerp(AVATAR_SIZE, SELECTED_AVATAR_SIZE, selectedProgress)
|
||||
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier
|
||||
.size(AVATAR_SIZE)
|
||||
.border(
|
||||
width = 3.dp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = selectedProgress),
|
||||
shape = CircleShape
|
||||
)
|
||||
.clip(CircleShape)
|
||||
.combinedClickable(
|
||||
role = Role.Button,
|
||||
onClick = {
|
||||
if (!isSelected) {
|
||||
onEvent(AvatarPickerEvents.AvatarSelected(avatar))
|
||||
} else {
|
||||
onEvent(AvatarPickerEvents.EditAvatar(avatar))
|
||||
}
|
||||
},
|
||||
onLongClick = if (avatar.canDelete) {
|
||||
{
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
menuController.show()
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onLongClickLabel = stringResource(R.string.AvatarPickerFragment__avatar_options)
|
||||
)
|
||||
) {
|
||||
AvatarImage(
|
||||
avatar = avatar,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(avatarSize)
|
||||
.clip(CircleShape)
|
||||
)
|
||||
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.alpha(selectedProgress)
|
||||
.background(color = Color.Black.copy(alpha = 0.2f), shape = CircleShape)
|
||||
.size(avatarSize)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = SignalIcons.Edit.imageVector,
|
||||
contentDescription = stringResource(R.string.AvatarPickerFragment__edit_avatar),
|
||||
tint = SignalTheme.colors.colorOnCustom
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenus.Menu(
|
||||
controller = menuController,
|
||||
offsetX = 0.dp,
|
||||
offsetY = 4.dp
|
||||
) { controller ->
|
||||
DropdownMenus.Item(
|
||||
text = { Text(text = stringResource(R.string.delete)) },
|
||||
onClick = {
|
||||
controller.hide()
|
||||
onEvent(AvatarPickerEvents.DeleteAvatar(avatar))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Scrolls [index] into view with the smallest scroll possible. Items already fully on screen are left alone. */
|
||||
private suspend fun LazyGridState.animateItemIntoView(index: Int, itemHeight: Int) {
|
||||
snapshotFlow { layoutInfo.visibleItemsInfo }.first { it.isNotEmpty() }
|
||||
|
||||
val viewportHeight = layoutInfo.viewportSize.height
|
||||
val item = layoutInfo.visibleItemsInfo.firstOrNull { it.index == index }
|
||||
|
||||
if (item != null && item.offset.y >= 0 && item.offset.y + item.size.height <= viewportHeight) {
|
||||
return
|
||||
}
|
||||
|
||||
if (index > firstVisibleItemIndex) {
|
||||
animateScrollToItem(index, scrollOffset = -(viewportHeight - itemHeight))
|
||||
} else {
|
||||
animateScrollToItem(index)
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable identity so item state follows the avatar rather than its position. */
|
||||
private val Avatar.gridKey: String
|
||||
get() {
|
||||
val id = (databaseId as? Avatar.DatabaseId.Saved)?.id
|
||||
return when (this) {
|
||||
is Avatar.Photo -> "photo:$id:$uri"
|
||||
is Avatar.Text -> "text:$id:$text"
|
||||
is Avatar.Vector -> "vector:$id:$key"
|
||||
is Avatar.Resource -> "resource:$resourceId"
|
||||
}
|
||||
}
|
||||
|
||||
/** Only persisted avatars the user created can be deleted. */
|
||||
private val Avatar.canDelete: Boolean
|
||||
get() = databaseId is Avatar.DatabaseId.Saved && (this is Avatar.Photo || this is Avatar.Text)
|
||||
|
||||
@AllDevicePreviews
|
||||
@Composable
|
||||
private fun AvatarPickerScreenPreview() {
|
||||
Previews.Preview {
|
||||
val avatars = remember { AvatarPickerRepository.getDefaultAvatarsForSelf() }
|
||||
|
||||
AvatarPickerScreen(
|
||||
state = remember {
|
||||
AvatarPickerState(
|
||||
currentAvatar = avatars.first(),
|
||||
selectableAvatars = avatars,
|
||||
canSave = true,
|
||||
canClear = true
|
||||
)
|
||||
},
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.avatar.picker
|
||||
|
||||
object AvatarPickerTestTags {
|
||||
const val AVATAR_GRID = "avatar_grid"
|
||||
const val CAMERA_BUTTON = "camera_button"
|
||||
const val PHOTO_BUTTON = "photo_button"
|
||||
const val TEXT_BUTTON = "text_button"
|
||||
const val SAVE_BUTTON = "save_button"
|
||||
const val CLEAR_AVATAR_BUTTON = "clear_avatar_button"
|
||||
|
||||
/** Shared by every avatar in the grid, in display order. */
|
||||
const val SELECTABLE_AVATAR = "selectable_avatar"
|
||||
}
|
||||
+138
-104
@@ -1,126 +1,170 @@
|
||||
package org.thoughtcrime.securesms.avatar.picker
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import arrow.core.Either
|
||||
import arrow.core.raise.either
|
||||
import arrow.core.right
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.models.media.Media
|
||||
import org.signal.core.ui.compose.EventDrivenViewModel
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.avatar.Avatar
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
|
||||
sealed class AvatarPickerViewModel(private val repository: AvatarPickerRepository) : ViewModel() {
|
||||
private val TAG = Log.tag(AvatarPickerViewModel::class.java)
|
||||
|
||||
private val disposables = CompositeDisposable()
|
||||
private val store = Store(AvatarPickerState())
|
||||
sealed class AvatarPickerViewModel : EventDrivenViewModel<AvatarPickerEvents>(TAG) {
|
||||
|
||||
val state: LiveData<AvatarPickerState> = store.stateLiveData
|
||||
private val internalState = MutableStateFlow(AvatarPickerState())
|
||||
val state: StateFlow<AvatarPickerState> = internalState
|
||||
|
||||
protected abstract fun getAvatar(): Single<Avatar>
|
||||
protected abstract fun getDefaultAvatarFromRepository(): Avatar
|
||||
protected abstract fun getPersistedAvatars(): Single<List<Avatar>>
|
||||
protected abstract fun getDefaultAvatars(): Single<List<Avatar>>
|
||||
protected abstract fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit)
|
||||
protected abstract fun persistAndCreateMedia(avatar: Avatar, onSaved: (Media) -> Unit)
|
||||
private val internalActions = Channel<AvatarPickerActions>(Channel.BUFFERED)
|
||||
val actions: Flow<AvatarPickerActions> = internalActions.receiveAsFlow()
|
||||
|
||||
fun delete(avatar: Avatar) {
|
||||
repository.delete(avatar) {
|
||||
protected abstract suspend fun getAvatar(): Avatar
|
||||
protected abstract suspend fun getDefaultAvatarFromRepository(): Avatar
|
||||
protected abstract suspend fun getPersistedAvatars(): List<Avatar>
|
||||
protected abstract suspend fun getDefaultAvatars(): List<Avatar>
|
||||
protected abstract suspend fun persistAvatar(avatar: Avatar): Either<Throwable, Avatar>
|
||||
protected abstract suspend fun persistAndCreateMedia(avatar: Avatar): Either<Throwable, Media>
|
||||
|
||||
override suspend fun processEvent(event: AvatarPickerEvents) {
|
||||
when (event) {
|
||||
AvatarPickerEvents.ClearAvatar -> clearAvatar()
|
||||
is AvatarPickerEvents.DeleteAvatar -> delete(event.avatar)
|
||||
is AvatarPickerEvents.AvatarEdited -> onAvatarEditCompleted(event.avatar)
|
||||
AvatarPickerEvents.Save -> save()
|
||||
is AvatarPickerEvents.AvatarSelected -> onAvatarSelectedFromGrid(event.avatar)
|
||||
is AvatarPickerEvents.PhotoSelected -> onAvatarPhotoSelectionCompleted(event.media)
|
||||
is AvatarPickerEvents.EditAvatar -> internalActions.send(AvatarPickerActions.LaunchAvatarEditor(event.avatar))
|
||||
AvatarPickerEvents.Close -> internalActions.send(AvatarPickerActions.Close)
|
||||
AvatarPickerEvents.CapturePhoto -> internalActions.send(AvatarPickerActions.LaunchCameraCapture)
|
||||
AvatarPickerEvents.SelectPhoto -> internalActions.send(AvatarPickerActions.LaunchPhotoSelection)
|
||||
AvatarPickerEvents.SelectText -> internalActions.send(AvatarPickerActions.LaunchTextAvatarCreation)
|
||||
}
|
||||
}
|
||||
|
||||
private fun delete(avatar: Avatar) {
|
||||
viewModelScope.launch {
|
||||
AvatarPickerRepository.delete(avatar)
|
||||
refreshAvatar()
|
||||
refreshSelectableAvatars()
|
||||
}
|
||||
}
|
||||
|
||||
fun clearAvatar() {
|
||||
store.update {
|
||||
val avatar = getDefaultAvatarFromRepository()
|
||||
it.copy(currentAvatar = avatar, canSave = true, canClear = false, isCleared = true)
|
||||
private suspend fun clearAvatar() {
|
||||
val avatar = getDefaultAvatarFromRepository()
|
||||
|
||||
internalState.update { it.copy(currentAvatar = avatar, canSave = true, canClear = false, isCleared = true) }
|
||||
}
|
||||
|
||||
/** Saving closes the picker, so [AvatarPickerState.canSave] guards against doing it twice. */
|
||||
private suspend fun save() {
|
||||
if (!internalState.value.canSave) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
fun save(onSaved: (Media) -> Unit, onCleared: () -> Unit) {
|
||||
if (store.state.isCleared) {
|
||||
onCleared()
|
||||
} else {
|
||||
val avatar = store.state.currentAvatar ?: throw AssertionError()
|
||||
persistAndCreateMedia(avatar, onSaved)
|
||||
internalState.update { it.copy(canSave = false) }
|
||||
|
||||
if (internalState.value.isCleared) {
|
||||
internalActions.send(AvatarPickerActions.FinishWithClearedAvatar)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
fun onAvatarSelectedFromGrid(avatar: Avatar) {
|
||||
store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = true, isCleared = false) }
|
||||
}
|
||||
val avatar = internalState.value.currentAvatar ?: throw AssertionError()
|
||||
|
||||
fun onAvatarEditCompleted(avatar: Avatar) {
|
||||
persistAvatar(avatar) { saved ->
|
||||
store.update { it.copy(currentAvatar = saved, canSave = isSaveable(saved), canClear = true, isCleared = false) }
|
||||
refreshSelectableAvatars()
|
||||
}
|
||||
}
|
||||
|
||||
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, isCleared = false) }
|
||||
refreshSelectableAvatars()
|
||||
persistAndCreateMedia(avatar)
|
||||
.onRight { internalActions.send(AvatarPickerActions.FinishWithAvatar(it)) }
|
||||
.onLeft {
|
||||
Log.w(TAG, "Failed to save avatar.", it)
|
||||
internalState.update { state -> state.copy(canSave = true) }
|
||||
internalActions.send(AvatarPickerActions.ShowSaveFailed)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onAvatarSelectedFromGrid(avatar: Avatar) {
|
||||
internalState.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = true, isCleared = false) }
|
||||
}
|
||||
|
||||
private fun onAvatarEditCompleted(avatar: Avatar) {
|
||||
viewModelScope.launch {
|
||||
persistAvatar(avatar)
|
||||
.onRight { saved ->
|
||||
internalState.update { it.copy(currentAvatar = saved, canSave = isSaveable(saved), canClear = true, isCleared = false) }
|
||||
refreshSelectableAvatars()
|
||||
}
|
||||
.onLeft { Log.w(TAG, "Failed to persist edited avatar.", it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun onAvatarPhotoSelectionCompleted(media: Media) {
|
||||
viewModelScope.launch {
|
||||
either {
|
||||
val multiSessionUri = AvatarPickerRepository.writeMediaToMultiSessionStorage(media).bind()
|
||||
persistAvatar(Avatar.Photo(multiSessionUri, media.size, Avatar.DatabaseId.NotSet)).bind()
|
||||
}
|
||||
.onRight { avatar ->
|
||||
internalState.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = true, isCleared = false) }
|
||||
refreshSelectableAvatars()
|
||||
}
|
||||
.onLeft { Log.w(TAG, "Failed to persist selected photo.", it) }
|
||||
}
|
||||
}
|
||||
|
||||
protected fun refreshAvatar() {
|
||||
disposables.add(
|
||||
getAvatar().subscribeOn(Schedulers.io()).subscribe { avatar ->
|
||||
store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = avatar is Avatar.Photo && !isSaveable(avatar), isCleared = false) }
|
||||
}
|
||||
)
|
||||
viewModelScope.launch {
|
||||
val avatar = getAvatar()
|
||||
internalState.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = avatar is Avatar.Photo && !isSaveable(avatar), isCleared = false) }
|
||||
}
|
||||
}
|
||||
|
||||
protected fun refreshSelectableAvatars() {
|
||||
disposables.add(
|
||||
Single.zip(getPersistedAvatars(), getDefaultAvatars()) { custom, def ->
|
||||
val customKeys = custom.filterIsInstance(Avatar.Vector::class.java).map { it.key }
|
||||
custom + def.filterNot {
|
||||
it is Avatar.Vector && customKeys.contains(it.key)
|
||||
}
|
||||
}.subscribeOn(Schedulers.io()).subscribe { avatars ->
|
||||
store.update { it.copy(selectableAvatars = avatars) }
|
||||
}
|
||||
)
|
||||
viewModelScope.launch {
|
||||
val custom = getPersistedAvatars()
|
||||
val default = getDefaultAvatars()
|
||||
val customKeys = custom.filterIsInstance<Avatar.Vector>().map { it.key }
|
||||
|
||||
val avatars = custom + default.filterNot { it is Avatar.Vector && customKeys.contains(it.key) }
|
||||
|
||||
internalState.update { it.copy(selectableAvatars = avatars) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSaveable(avatar: Avatar) = avatar.databaseId != Avatar.DatabaseId.DoNotPersist
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.dispose()
|
||||
}
|
||||
|
||||
private class SelfAvatarPickerViewModel(private val repository: AvatarPickerRepository) : AvatarPickerViewModel(repository) {
|
||||
private class SelfAvatarPickerViewModel : AvatarPickerViewModel() {
|
||||
|
||||
init {
|
||||
refreshAvatar()
|
||||
refreshSelectableAvatars()
|
||||
}
|
||||
|
||||
override fun getAvatar(): Single<Avatar> = repository.getAvatarForSelf()
|
||||
override fun getDefaultAvatarFromRepository(): Avatar = repository.getDefaultAvatarForSelf()
|
||||
override fun getPersistedAvatars(): Single<List<Avatar>> = repository.getPersistedAvatarsForSelf()
|
||||
override fun getDefaultAvatars(): Single<List<Avatar>> = repository.getDefaultAvatarsForSelf()
|
||||
override suspend fun getAvatar(): Avatar = AvatarPickerRepository.getAvatarForSelf()
|
||||
override suspend fun getDefaultAvatarFromRepository(): Avatar = AvatarPickerRepository.getDefaultAvatarForSelf()
|
||||
override suspend fun getPersistedAvatars(): List<Avatar> = AvatarPickerRepository.getPersistedAvatarsForSelf()
|
||||
override suspend fun getDefaultAvatars(): List<Avatar> = AvatarPickerRepository.getDefaultAvatarsForSelf()
|
||||
|
||||
override fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit) {
|
||||
repository.persistAvatarForSelf(avatar, onPersisted)
|
||||
override suspend fun persistAvatar(avatar: Avatar): Either<Throwable, Avatar> {
|
||||
return AvatarPickerRepository.persistAvatarForSelf(avatar)
|
||||
}
|
||||
|
||||
override fun persistAndCreateMedia(avatar: Avatar, onSaved: (Media) -> Unit) {
|
||||
repository.persistAndCreateMediaForSelf(avatar, onSaved)
|
||||
override suspend fun persistAndCreateMedia(avatar: Avatar): Either<Throwable, Media> {
|
||||
return AvatarPickerRepository.persistAndCreateMediaForSelf(avatar)
|
||||
}
|
||||
}
|
||||
|
||||
private class GroupAvatarPickerViewModel(
|
||||
private val groupId: GroupId,
|
||||
private val repository: AvatarPickerRepository,
|
||||
groupAvatarMedia: Media?
|
||||
) : AvatarPickerViewModel(repository) {
|
||||
) : AvatarPickerViewModel() {
|
||||
|
||||
private val initialAvatar: Avatar? = groupAvatarMedia?.let { Avatar.Photo(it.uri, it.size, Avatar.DatabaseId.DoNotPersist) }
|
||||
|
||||
@@ -129,31 +173,26 @@ sealed class AvatarPickerViewModel(private val repository: AvatarPickerRepositor
|
||||
refreshSelectableAvatars()
|
||||
}
|
||||
|
||||
override fun getAvatar(): Single<Avatar> {
|
||||
return if (initialAvatar != null) {
|
||||
Single.just(initialAvatar)
|
||||
} else {
|
||||
repository.getAvatarForGroup(groupId)
|
||||
}
|
||||
override suspend fun getAvatar(): Avatar {
|
||||
return initialAvatar ?: AvatarPickerRepository.getAvatarForGroup(groupId)
|
||||
}
|
||||
|
||||
override fun getDefaultAvatarFromRepository(): Avatar = repository.getDefaultAvatarForGroup(groupId)
|
||||
override fun getPersistedAvatars(): Single<List<Avatar>> = repository.getPersistedAvatarsForGroup(groupId)
|
||||
override fun getDefaultAvatars(): Single<List<Avatar>> = repository.getDefaultAvatarsForGroup()
|
||||
override suspend fun getDefaultAvatarFromRepository(): Avatar = AvatarPickerRepository.getDefaultAvatarForGroup(groupId)
|
||||
override suspend fun getPersistedAvatars(): List<Avatar> = AvatarPickerRepository.getPersistedAvatarsForGroup(groupId)
|
||||
override suspend fun getDefaultAvatars(): List<Avatar> = AvatarPickerRepository.getDefaultAvatarsForGroup()
|
||||
|
||||
override fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit) {
|
||||
repository.persistAvatarForGroup(avatar, groupId, onPersisted)
|
||||
override suspend fun persistAvatar(avatar: Avatar): Either<Throwable, Avatar> {
|
||||
return AvatarPickerRepository.persistAvatarForGroup(avatar, groupId)
|
||||
}
|
||||
|
||||
override fun persistAndCreateMedia(avatar: Avatar, onSaved: (Media) -> Unit) {
|
||||
repository.persistAndCreateMediaForGroup(avatar, groupId, onSaved)
|
||||
override suspend fun persistAndCreateMedia(avatar: Avatar): Either<Throwable, Media> {
|
||||
return AvatarPickerRepository.persistAndCreateMediaForGroup(avatar, groupId)
|
||||
}
|
||||
}
|
||||
|
||||
private class NewGroupAvatarPickerViewModel(
|
||||
private val repository: AvatarPickerRepository,
|
||||
initialMedia: Media?
|
||||
) : AvatarPickerViewModel(repository) {
|
||||
) : AvatarPickerViewModel() {
|
||||
|
||||
private val initialAvatar: Avatar? = initialMedia?.let { Avatar.Photo(it.uri, it.size, Avatar.DatabaseId.DoNotPersist) }
|
||||
|
||||
@@ -162,34 +201,29 @@ sealed class AvatarPickerViewModel(private val repository: AvatarPickerRepositor
|
||||
refreshSelectableAvatars()
|
||||
}
|
||||
|
||||
override fun getAvatar(): Single<Avatar> {
|
||||
return if (initialAvatar != null) {
|
||||
Single.just(initialAvatar)
|
||||
} else {
|
||||
Single.fromCallable { getDefaultAvatarFromRepository() }
|
||||
}
|
||||
override suspend fun getAvatar(): Avatar {
|
||||
return initialAvatar ?: getDefaultAvatarFromRepository()
|
||||
}
|
||||
|
||||
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)
|
||||
override fun persistAndCreateMedia(avatar: Avatar, onSaved: (Media) -> Unit) = repository.createMediaForNewGroup(avatar, onSaved)
|
||||
override suspend fun getDefaultAvatarFromRepository(): Avatar = AvatarPickerRepository.getDefaultAvatarForGroup(null)
|
||||
override suspend fun getPersistedAvatars(): List<Avatar> = emptyList()
|
||||
override suspend fun getDefaultAvatars(): List<Avatar> = AvatarPickerRepository.getDefaultAvatarsForGroup()
|
||||
override suspend fun persistAvatar(avatar: Avatar): Either<Throwable, Avatar> = avatar.right()
|
||||
override suspend fun persistAndCreateMedia(avatar: Avatar): Either<Throwable, Media> = AvatarPickerRepository.createMediaForNewGroup(avatar)
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val repository: AvatarPickerRepository,
|
||||
private val groupId: GroupId?,
|
||||
private val isNewGroup: Boolean,
|
||||
private val groupAvatarMedia: Media?
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
val viewModel = if (groupId == null && !isNewGroup) {
|
||||
SelfAvatarPickerViewModel(repository)
|
||||
SelfAvatarPickerViewModel()
|
||||
} else if (groupId == null) {
|
||||
NewGroupAvatarPickerViewModel(repository, groupAvatarMedia)
|
||||
NewGroupAvatarPickerViewModel(groupAvatarMedia)
|
||||
} else {
|
||||
GroupAvatarPickerViewModel(groupId, repository, groupAvatarMedia)
|
||||
GroupAvatarPickerViewModel(groupId, groupAvatarMedia)
|
||||
}
|
||||
|
||||
return requireNotNull(modelClass.cast(viewModel))
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
<?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="match_parent">
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<com.google.android.material.imageview.ShapeableImageView
|
||||
android:id="@+id/avatar_picker_item_image"
|
||||
android:layout_width="@dimen/avatar_picker_image_width"
|
||||
android:layout_height="@dimen/avatar_picker_image_width"
|
||||
android:layout_marginTop="?actionBarSize"
|
||||
android:background="@drawable/circle_tintable"
|
||||
android:contentDescription="@string/AvatarPickerFragment__avatar_preview"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Signal.Circle"
|
||||
tools:backgroundTint="#DEE3FF" />
|
||||
|
||||
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
|
||||
android:id="@+id/avatar_picker_item_text"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:fontFamily="sans-serif-medium"
|
||||
android:gravity="center"
|
||||
app:emoji_forceJumbo="true"
|
||||
app:layout_constraintBottom_toBottomOf="@id/avatar_picker_item_image"
|
||||
app:layout_constraintEnd_toEndOf="@id/avatar_picker_item_image"
|
||||
app:layout_constraintStart_toStartOf="@id/avatar_picker_item_image"
|
||||
app:layout_constraintTop_toTopOf="@id/avatar_picker_item_image"
|
||||
tools:ignore="SpUsage"
|
||||
tools:text="AF" />
|
||||
|
||||
<org.thoughtcrime.securesms.components.ButtonStripItemView
|
||||
android:id="@+id/avatar_picker_camera"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="32dp"
|
||||
app:bsiv_icon="@drawable/ic_camera_24"
|
||||
app:bsiv_icon_contentDescription="@string/AvatarPickerFragment__take_a_picture"
|
||||
app:bsiv_label="@string/AvatarPickerFragment__camera"
|
||||
app:layout_constraintEnd_toStartOf="@id/avatar_picker_photo"
|
||||
app:layout_constraintHorizontal_chainStyle="packed"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/avatar_picker_item_image" />
|
||||
|
||||
<org.thoughtcrime.securesms.components.ButtonStripItemView
|
||||
android:id="@+id/avatar_picker_photo"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="32dp"
|
||||
app:bsiv_icon="@drawable/ic_photo_24"
|
||||
app:bsiv_icon_contentDescription="@string/AvatarPickerFragment__choose_a_photo"
|
||||
app:bsiv_label="@string/AvatarPickerFragment__photo"
|
||||
app:layout_constraintEnd_toStartOf="@id/avatar_picker_text"
|
||||
app:layout_constraintStart_toEndOf="@id/avatar_picker_camera"
|
||||
app:layout_constraintTop_toBottomOf="@id/avatar_picker_item_image" />
|
||||
|
||||
<org.thoughtcrime.securesms.components.ButtonStripItemView
|
||||
android:id="@+id/avatar_picker_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="32dp"
|
||||
app:bsiv_icon="@drawable/ic_text_24"
|
||||
app:bsiv_icon_contentDescription="@string/AvatarPickerFragment__text"
|
||||
app:bsiv_label="@string/AvatarPickerFragment__text"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/avatar_picker_photo"
|
||||
app:layout_constraintTop_toBottomOf="@id/avatar_picker_item_image" />
|
||||
|
||||
<View
|
||||
android:id="@+id/avatar_picker_divider"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1.5dp"
|
||||
android:layout_marginTop="16.25dp"
|
||||
android:layout_marginBottom="16.25dp"
|
||||
android:alpha="0.16"
|
||||
android:background="@color/signal_colorOutline"
|
||||
app:layout_constraintTop_toBottomOf="@id/avatar_picker_text" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/avatar_picker_recycler"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginStart="@dimen/dsl_settings_gutter"
|
||||
android:layout_marginTop="40dp"
|
||||
android:layout_marginEnd="@dimen/dsl_settings_gutter"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
android:overScrollMode="never"
|
||||
android:paddingBottom="80dp"
|
||||
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHeight_min="160dp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/avatar_picker_divider"
|
||||
app:spanCount="4"
|
||||
tools:listitem="@layout/avatar_picker_item" />
|
||||
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/avatar_picker_clear"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:contentDescription="@string/AvatarPickerFragment__clear_avatar"
|
||||
android:visibility="gone"
|
||||
app:backgroundTint="@color/signal_background_primary"
|
||||
app:fabSize="mini"
|
||||
app:layout_constraintEnd_toEndOf="@id/avatar_picker_item_image"
|
||||
app:layout_constraintTop_toTopOf="@id/avatar_picker_item_image"
|
||||
app:srcCompat="@drawable/ic_x_20"
|
||||
tools:visibility="visible" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</ScrollView>
|
||||
|
||||
<androidx.appcompat.widget.Toolbar
|
||||
android:id="@+id/avatar_picker_toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="?actionBarSize"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:navigationIcon="@drawable/ic_x_tinted" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/avatar_picker_save"
|
||||
style="@style/Signal.Widget.Button.Large.Tonal"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:text="@string/AvatarPickerFragment__save"
|
||||
app:cornerRadius="28dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:id="@+id/action_delete" android:title="@string/delete" />
|
||||
</menu>
|
||||
@@ -1,15 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<navigation 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"
|
||||
android:id="@+id/avatar_picker"
|
||||
app:startDestination="@id/avatarPickerFragment">
|
||||
|
||||
<fragment
|
||||
android:id="@+id/avatarPickerFragment"
|
||||
android:name="org.thoughtcrime.securesms.avatar.picker.AvatarPickerFragment"
|
||||
android:label="fragment_pick_avatar"
|
||||
tools:layout="@layout/avatar_picker_fragment">
|
||||
android:label="fragment_pick_avatar">
|
||||
<action
|
||||
android:id="@+id/action_avatarPickerFragment_to_textAvatarCreationFragment"
|
||||
app:destination="@id/textAvatarCreationFragment"
|
||||
|
||||
@@ -6321,13 +6321,15 @@
|
||||
<!-- AvatarPickerFragment -->
|
||||
<string name="AvatarPickerFragment__avatar_preview">Avatar preview</string>
|
||||
<string name="AvatarPickerFragment__camera">Camera</string>
|
||||
<string name="AvatarPickerFragment__take_a_picture">Take a picture</string>
|
||||
<string name="AvatarPickerFragment__choose_a_photo">Choose a photo</string>
|
||||
<string name="AvatarPickerFragment__photo">Photo</string>
|
||||
<!-- Button label during avatar selection/creation that when tapped lets you generate an avatar from typed in text. -->
|
||||
<string name="AvatarPickerFragment__text">Text</string>
|
||||
<string name="AvatarPickerFragment__save">Save</string>
|
||||
<string name="AvatarPickerFragment__clear_avatar">Clear avatar</string>
|
||||
<!-- Content description for the edit icon displayed over the currently selected avatar. -->
|
||||
<string name="AvatarPickerFragment__edit_avatar">Edit avatar</string>
|
||||
<!-- Accessibility label describing the menu shown when long pressing one of your saved avatars. -->
|
||||
<string name="AvatarPickerFragment__avatar_options">Avatar options</string>
|
||||
<string name="AvatarPickerRepository__failed_to_save_avatar">Failed to save avatar</string>
|
||||
|
||||
<!-- TextAvatarCreationFragment -->
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.avatar.picker
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.longClick
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollToIndex
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import io.mockk.every
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.signal.core.ui.CoreUiDependenciesRule
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.emoji.EmojiDependencies
|
||||
import org.thoughtcrime.securesms.avatar.Avatar
|
||||
import org.thoughtcrime.securesms.avatar.Avatars
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
|
||||
import org.thoughtcrime.securesms.dependencies.EmojiDependenciesProvider
|
||||
import org.thoughtcrime.securesms.keyvalue.InternalValues
|
||||
import org.thoughtcrime.securesms.testutil.MockSignalStoreRule
|
||||
|
||||
/**
|
||||
* Tests for AvatarPickerScreen that validate event emissions and state driven visibility.
|
||||
* Uses Robolectric to run fast JUnit tests without an emulator.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(application = Application::class)
|
||||
class AvatarPickerScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@get:Rule
|
||||
val coreUiDependenciesRule = CoreUiDependenciesRule(ApplicationProvider.getApplicationContext())
|
||||
|
||||
@get:Rule
|
||||
val signalStore = MockSignalStoreRule(relaxed = setOf(InternalValues::class))
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
// Only the first init per Robolectric sandbox wins, so install the same provider AppDependencies
|
||||
// would, then keep it on the system emoji font so text avatars render without the emoji sheets.
|
||||
EmojiDependencies.init(ApplicationProvider.getApplicationContext(), EmojiDependenciesProvider)
|
||||
every { signalStore.settings.isPreferSystemEmoji } returns true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when camera is clicked, CapturePhoto event is emitted`() {
|
||||
val emittedEvents = setContent(state())
|
||||
|
||||
composeTestRule.onNodeWithTag(AvatarPickerTestTags.CAMERA_BUTTON).performClick()
|
||||
|
||||
assert(emittedEvents == listOf(AvatarPickerEvents.CapturePhoto))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when photo is clicked, SelectPhoto event is emitted`() {
|
||||
val emittedEvents = setContent(state())
|
||||
|
||||
composeTestRule.onNodeWithTag(AvatarPickerTestTags.PHOTO_BUTTON).performClick()
|
||||
|
||||
assert(emittedEvents == listOf(AvatarPickerEvents.SelectPhoto))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when text is clicked, SelectText event is emitted`() {
|
||||
val emittedEvents = setContent(state())
|
||||
|
||||
composeTestRule.onNodeWithTag(AvatarPickerTestTags.TEXT_BUTTON).performClick()
|
||||
|
||||
assert(emittedEvents == listOf(AvatarPickerEvents.SelectText))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when save is clicked, Save event is emitted`() {
|
||||
val emittedEvents = setContent(state(canSave = true))
|
||||
|
||||
composeTestRule.onNodeWithTag(AvatarPickerTestTags.SAVE_BUTTON).assertIsEnabled().performClick()
|
||||
|
||||
assert(emittedEvents == listOf(AvatarPickerEvents.Save))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when there is nothing to save, save button is disabled`() {
|
||||
setContent(state(canSave = false))
|
||||
|
||||
composeTestRule.onNodeWithTag(AvatarPickerTestTags.SAVE_BUTTON).assertIsNotEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when clear avatar is clicked, ClearAvatar event is emitted`() {
|
||||
val emittedEvents = setContent(state(currentAvatar = avatars()[1], canClear = true))
|
||||
|
||||
composeTestRule.onNodeWithTag(AvatarPickerTestTags.CLEAR_AVATAR_BUTTON).performClick()
|
||||
|
||||
assert(emittedEvents == listOf(AvatarPickerEvents.ClearAvatar))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when the avatar cannot be cleared, clear button is hidden`() {
|
||||
setContent(state(currentAvatar = avatars()[1], canClear = false))
|
||||
|
||||
assert(composeTestRule.onAllNodesWithTag(AvatarPickerTestTags.CLEAR_AVATAR_BUTTON).fetchSemanticsNodes().isEmpty()) {
|
||||
"Expected no clear button when the avatar cannot be cleared"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when an unselected avatar is clicked, AvatarSelected event is emitted`() {
|
||||
val avatars = avatars()
|
||||
val emittedEvents = setContent(state(currentAvatar = avatars[0], selectableAvatars = avatars))
|
||||
|
||||
scrollToAvatars()
|
||||
composeTestRule.onAllNodesWithTag(AvatarPickerTestTags.SELECTABLE_AVATAR)[1].performClick()
|
||||
|
||||
assert(emittedEvents == listOf(AvatarPickerEvents.AvatarSelected(avatars[1])))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when the selected avatar is clicked, EditAvatar event is emitted`() {
|
||||
val avatars = avatars()
|
||||
val emittedEvents = setContent(state(currentAvatar = avatars[0], selectableAvatars = avatars))
|
||||
|
||||
scrollToAvatars()
|
||||
composeTestRule.onAllNodesWithTag(AvatarPickerTestTags.SELECTABLE_AVATAR)[0].performClick()
|
||||
|
||||
assert(emittedEvents == listOf(AvatarPickerEvents.EditAvatar(avatars[0])))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when a saved avatar is long pressed, delete emits DeleteAvatar event`() {
|
||||
val avatars = avatars()
|
||||
val emittedEvents = setContent(state(selectableAvatars = avatars))
|
||||
|
||||
scrollToAvatars()
|
||||
composeTestRule.onAllNodesWithTag(AvatarPickerTestTags.SELECTABLE_AVATAR)[0].performTouchInput { longClick() }
|
||||
composeTestRule.onNodeWithText(DELETE_LABEL).assertIsDisplayed().performClick()
|
||||
|
||||
assert(emittedEvents == listOf(AvatarPickerEvents.DeleteAvatar(avatars[0])))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when a default avatar is long pressed, no menu is shown`() {
|
||||
val avatars = avatars()
|
||||
val emittedEvents = setContent(state(selectableAvatars = avatars))
|
||||
|
||||
scrollToAvatars()
|
||||
composeTestRule.onAllNodesWithTag(AvatarPickerTestTags.SELECTABLE_AVATAR)[1].performTouchInput { longClick() }
|
||||
|
||||
assert(composeTestRule.onAllNodesWithText(DELETE_LABEL).fetchSemanticsNodes().isEmpty()) {
|
||||
"Expected no context menu for an avatar that cannot be deleted"
|
||||
}
|
||||
|
||||
// With no long press handler the gesture falls through to a click.
|
||||
assert(emittedEvents == listOf(AvatarPickerEvents.AvatarSelected(avatars[1])))
|
||||
}
|
||||
|
||||
/** The header fills a small viewport, so no avatar is composed until the grid scrolls. */
|
||||
private fun scrollToAvatars() {
|
||||
composeTestRule.onNodeWithTag(AvatarPickerTestTags.AVATAR_GRID).performScrollToIndex(FIRST_AVATAR_INDEX)
|
||||
}
|
||||
|
||||
private fun setContent(state: AvatarPickerState): List<AvatarPickerEvents> {
|
||||
val emittedEvents = mutableListOf<AvatarPickerEvents>()
|
||||
|
||||
composeTestRule.setContent {
|
||||
SignalTheme {
|
||||
AvatarPickerScreen(
|
||||
state = state,
|
||||
onEvent = { emittedEvents += it }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return emittedEvents
|
||||
}
|
||||
|
||||
private fun state(
|
||||
currentAvatar: Avatar? = null,
|
||||
selectableAvatars: List<Avatar> = emptyList(),
|
||||
canSave: Boolean = false,
|
||||
canClear: Boolean = false
|
||||
): AvatarPickerState {
|
||||
return AvatarPickerState(
|
||||
currentAvatar = currentAvatar,
|
||||
selectableAvatars = selectableAvatars,
|
||||
canSave = canSave,
|
||||
canClear = canClear
|
||||
)
|
||||
}
|
||||
|
||||
/** A deletable avatar followed by two defaults, all in the first row. */
|
||||
private fun avatars(): List<Avatar> {
|
||||
return listOf(
|
||||
Avatar.Text(text = "AH", color = COLOR, databaseId = Avatar.DatabaseId.Saved(1)),
|
||||
Avatar.Vector(key = "avatar_cat", color = COLOR, databaseId = Avatar.DatabaseId.NotSet),
|
||||
Avatar.Vector(key = "avatar_dog", color = COLOR, databaseId = Avatar.DatabaseId.NotSet)
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DELETE_LABEL = "Delete"
|
||||
|
||||
private const val FIRST_AVATAR_INDEX = 1
|
||||
|
||||
private val COLOR = Avatars.ColorPair(
|
||||
foregroundAvatarColor = Avatars.ForegroundColor.A210,
|
||||
backgroundAvatarColor = AvatarColor.A210
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
@@ -175,6 +176,29 @@ object Buttons {
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ActionButton(
|
||||
onClick: () -> Unit,
|
||||
imageVector: ImageVector,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true
|
||||
) {
|
||||
ActionButton(
|
||||
enabled = enabled,
|
||||
onClick = onClick,
|
||||
label = label,
|
||||
modifier = modifier
|
||||
) {
|
||||
Icon(
|
||||
imageVector = imageVector,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ActionButton(
|
||||
onClick: () -> Unit,
|
||||
|
||||
@@ -93,6 +93,7 @@ object Scaffolds {
|
||||
},
|
||||
snackbarHost: @Composable () -> Unit = {},
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
bottomBar: @Composable () -> Unit = {},
|
||||
content: @Composable (PaddingValues) -> Unit
|
||||
) {
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
@@ -110,6 +111,7 @@ object Scaffolds {
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
},
|
||||
bottomBar = bottomBar,
|
||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
content = content
|
||||
)
|
||||
|
||||
@@ -78,6 +78,7 @@ enum class SignalIcons(private val icon: SignalIcon) : SignalIcon by icon {
|
||||
Open(icon(R.drawable.symbol_open_24)),
|
||||
PersonCircle(icon(R.drawable.symbol_person_circle_24)),
|
||||
Phone(icon(R.drawable.symbol_phone_24)),
|
||||
Photo(icon(R.drawable.symbol_photo_24)),
|
||||
Plus(icon(R.drawable.symbol_plus_24)),
|
||||
QrCode(icon(R.drawable.symbol_qrcode_24)),
|
||||
QualityHigh(icon(R.drawable.symbol_quality_high_24)),
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M6 9.13C6 7.95 6.95 7 8.13 7c1.17 0 2.12 0.95 2.12 2.13 0 1.17-0.95 2.12-2.13 2.12C6.96 11.25 6 10.3 6 9.12Z"/>
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M7.56 3.13h8.88c0.95 0 1.71 0 2.33 0.05 0.64 0.05 1.2 0.16 1.72 0.42 0.82 0.42 1.49 1.1 1.9 1.91 0.27 0.52 0.38 1.08 0.43 1.72 0.05 0.62 0.05 1.38 0.05 2.33v4.88c0 0.95 0 1.71-0.05 2.33-0.05 0.64-0.16 1.2-0.42 1.72-0.42 0.82-1.1 1.49-1.91 1.9-0.52 0.27-1.08 0.38-1.72 0.43-0.62 0.05-1.38 0.05-2.33 0.05H7.56c-0.95 0-1.71 0-2.33-0.05-0.64-0.05-1.2-0.16-1.72-0.42-0.82-0.42-1.49-1.1-1.9-1.91-0.27-0.52-0.38-1.08-0.43-1.72-0.06-0.62-0.06-1.38-0.05-2.33V9.56c0-0.95 0-1.71 0.05-2.33 0.05-0.64 0.16-1.2 0.42-1.72 0.42-0.82 1.1-1.49 1.91-1.9 0.52-0.27 1.08-0.38 1.72-0.43 0.62-0.06 1.38-0.06 2.33-0.06ZM5.37 4.92C4.84 4.96 4.54 5.04 4.31 5.16 3.8 5.41 3.4 5.81 3.16 6.31c-0.12 0.23-0.2 0.53-0.24 1.06C2.88 7.91 2.88 8.61 2.88 9.6v4.8 0.3l1.66-1.43c0.84-0.72 2.08-0.72 2.92 0l1.7 1.46 4.46-3.82c0.94-0.8 2.32-0.8 3.26 0l4.25 3.64V9.6c0-1 0-1.69-0.05-2.23-0.04-0.53-0.12-0.83-0.24-1.06-0.25-0.5-0.65-0.9-1.15-1.15-0.23-0.12-0.53-0.2-1.06-0.24-0.54-0.04-1.24-0.04-2.23-0.04H7.6c-1 0-1.69 0-2.23 0.04Zm4.95 10.8l0.54 0.46c0.32 0.27 0.35 0.74 0.08 1.06-0.27 0.31-0.74 0.35-1.05 0.08l-3.4-2.91c-0.28-0.24-0.7-0.24-0.98 0l-2.59 2.22c0.04 0.53 0.12 0.83 0.24 1.06 0.25 0.5 0.65 0.9 1.15 1.15 0.23 0.12 0.53 0.2 1.06 0.24 0.54 0.04 1.24 0.05 2.23 0.05h8.8c1 0 1.69 0 2.23-0.05 0.53-0.04 0.83-0.12 1.06-0.24 0.5-0.25 0.9-0.65 1.15-1.15 0.12-0.23 0.2-0.53 0.24-1.06l0.01-0.14-5.19-4.44c-0.37-0.33-0.93-0.33-1.3 0l-4.28 3.66Z"/>
|
||||
</vector>
|
||||
Reference in New Issue
Block a user