Create spaces (#5982)
* Allow creating a space with `CreateRoomParameters` * Add 'Create space' menu item in the spaces home screen. Also, imports new strings related to spaces. * Link the 'Create space' button with the screen to create the space * Unify room access and visibility for `ConfigureRoom`, use the updated design * Fix `EditRoomDetails` avatar size (68dp) * Replace `EditableAvatarView` and `UnsavedAvatar` copmonents with `AvatarPickerView` * `AvatarDataFetcherFactory`: Make sure we use a fallback image fetcher when the URL is not an MXC one (a local one, i.e.). This removes the previous need for a separate `UnsavedAvatarView` * Use `AvatarPickerView` in all the screens where `EditableAvatarView` was used * Improve naming and previews * Update strings, remove unused ones for `RoomAccessItem` * Make `isSpace` part of the `CreateRoomConfig` * Ensure the content fits in the screenshots for `AvatarPickerSizesPreview` * Add `AvatarDataFetcherFactoryTest` * Add new feature flag for creating spaces * Fix ripple being too large for the `Pick` state * Tweak margins and section titles a bit * Add preview for `HomeTopBar` with the spaces case * Update screenshots --------- Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
parent
983c012b79
commit
6d1ed5967b
150 changed files with 1097 additions and 778 deletions
|
|
@ -46,7 +46,7 @@ enum class AvatarSize(val dp: Dp) {
|
|||
RoomInviteItem(52.dp),
|
||||
InviteSender(16.dp),
|
||||
|
||||
EditRoomDetails(70.dp),
|
||||
EditRoomDetails(68.dp),
|
||||
RoomListManageUser(96.dp),
|
||||
|
||||
NotificationsOptIn(32.dp),
|
||||
|
|
|
|||
|
|
@ -70,6 +70,13 @@ enum class FeatureFlags(
|
|||
defaultValue = { false },
|
||||
isFinished = false,
|
||||
),
|
||||
CreateSpaces(
|
||||
key = "feature.createSpaces",
|
||||
title = "Create spaces",
|
||||
description = "Allow creating spaces.",
|
||||
defaultValue = { false },
|
||||
isFinished = false,
|
||||
),
|
||||
SpaceSettings(
|
||||
key = "feature.spaceSettings",
|
||||
title = "Space settings",
|
||||
|
|
|
|||
|
|
@ -26,4 +26,5 @@ data class CreateRoomParameters(
|
|||
val joinRuleOverride: JoinRule? = null,
|
||||
val historyVisibilityOverride: RoomHistoryVisibility? = null,
|
||||
val roomAliasName: Optional<String> = Optional.empty(),
|
||||
val isSpace: Boolean = false,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -393,6 +393,7 @@ class RustMatrixClient(
|
|||
joinRuleOverride = createRoomParams.joinRuleOverride?.map(),
|
||||
historyVisibilityOverride = createRoomParams.historyVisibilityOverride?.map(),
|
||||
canonicalAlias = createRoomParams.roomAliasName.getOrNull(),
|
||||
isSpace = createRoomParams.isSpace,
|
||||
)
|
||||
val roomId = RoomId(innerClient.createRoom(rustParams))
|
||||
// Wait to receive the room back from the sync but do not returns failure if it fails.
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.ui.media
|
|||
import coil3.ImageLoader
|
||||
import coil3.fetch.Fetcher
|
||||
import coil3.request.Options
|
||||
import coil3.toUri
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||
|
||||
|
|
@ -21,10 +22,19 @@ internal class AvatarDataFetcherFactory(
|
|||
data: AvatarData,
|
||||
options: Options,
|
||||
imageLoader: ImageLoader
|
||||
): Fetcher {
|
||||
return CoilMediaFetcher(
|
||||
mediaLoader = matrixMediaLoader,
|
||||
mediaData = data.toMediaRequestData(),
|
||||
)
|
||||
): Fetcher? {
|
||||
return when {
|
||||
data.url == null -> null
|
||||
data.url?.startsWith("mxc") == true -> CoilMediaFetcher(
|
||||
mediaLoader = matrixMediaLoader,
|
||||
mediaData = data.toMediaRequestData(),
|
||||
)
|
||||
else -> {
|
||||
// If the URL does not use the mxc scheme, it might be a local one using `content://`, try using a fallback fetcher
|
||||
data.url?.toUri()?.let { uri ->
|
||||
imageLoader.components.newFetcher(uri, options, imageLoader)
|
||||
}?.first
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.ui.media
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import coil3.ComponentRegistry
|
||||
import coil3.ImageLoader
|
||||
import coil3.asImage
|
||||
import coil3.disk.DiskCache
|
||||
import coil3.memory.MemoryCache
|
||||
import coil3.request.Disposable
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.ImageResult
|
||||
import coil3.request.Options
|
||||
import coil3.request.SuccessResult
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
|
||||
import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader
|
||||
import io.mockk.mockk
|
||||
import org.junit.Test
|
||||
|
||||
class AvatarDataFetcherFactoryTest {
|
||||
@Test
|
||||
fun `create - with mxc returns CoilMediaFetcher`() {
|
||||
val factory = AvatarDataFetcherFactory(matrixMediaLoader = FakeMatrixMediaLoader())
|
||||
|
||||
val fetcher = factory.create(anAvatarData(url = "mxc://test"), Options(mockk()), imageLoader = FakeImageLoader())
|
||||
assertThat(fetcher).isInstanceOf(CoilMediaFetcher::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create - with http or https returns null, which means fallback default fetcher will be used`() {
|
||||
val factory = AvatarDataFetcherFactory(matrixMediaLoader = FakeMatrixMediaLoader())
|
||||
|
||||
val fetcherHttp = factory.create(anAvatarData(url = "http://test"), Options(mockk()), imageLoader = FakeImageLoader())
|
||||
assertThat(fetcherHttp).isNull()
|
||||
|
||||
val fetcherHttps = factory.create(anAvatarData(url = "https://test"), Options(mockk()), imageLoader = FakeImageLoader())
|
||||
assertThat(fetcherHttps).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create - with content scheme returns null, which means fallback default fetcher will be used`() {
|
||||
val factory = AvatarDataFetcherFactory(matrixMediaLoader = FakeMatrixMediaLoader())
|
||||
|
||||
val fetcher = factory.create(anAvatarData(url = "content://test"), Options(mockk()), imageLoader = FakeImageLoader())
|
||||
assertThat(fetcher).isNull()
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeImageLoader : ImageLoader {
|
||||
override val defaults: ImageRequest.Defaults = ImageRequest.Defaults.DEFAULT
|
||||
override val components: ComponentRegistry = ComponentRegistry.Builder().build()
|
||||
override val memoryCache: MemoryCache? = null
|
||||
override val diskCache: DiskCache? = null
|
||||
|
||||
override fun enqueue(request: ImageRequest): Disposable {
|
||||
return mockk()
|
||||
}
|
||||
|
||||
override suspend fun execute(request: ImageRequest): ImageResult {
|
||||
return SuccessResult(
|
||||
image = Bitmap.createBitmap(1, 1, Bitmap.Config.ALPHA_8).asImage(),
|
||||
request = request,
|
||||
)
|
||||
}
|
||||
|
||||
override fun shutdown() {}
|
||||
|
||||
override fun newBuilder(): ImageLoader.Builder {
|
||||
return ImageLoader.Builder(mockk())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,436 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.ui.components
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.Immutable
|
||||
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.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarType
|
||||
import io.element.android.libraries.designsystem.icons.CompoundDrawables
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.testtags.TestTags
|
||||
import io.element.android.libraries.testtags.testTag
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
/**
|
||||
* Avatar picker view, based on https://www.figma.com/design/kcnHxunG1LDWXsJhaNuiHz/ER-145--Spaces-on-Element-X?node-id=5918-97417&t=JYDQysgjS33AZb74-4
|
||||
*
|
||||
* It takes a [state], which can be [AvatarPickerState.Pick] for displaying the 'pick avatar' button, or [AvatarPickerState.Selected] when an avatar has
|
||||
* already been selected.
|
||||
*
|
||||
* Note: this function contains lots of 'magic numbers', but those are just the fractions used to scale the different dimensions based on the Figma design.
|
||||
*/
|
||||
@Composable
|
||||
fun AvatarPickerView(
|
||||
state: AvatarPickerState,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (() -> Unit) = {},
|
||||
onClickLabel: String? = stringResource(CommonStrings.a11y_edit_avatar),
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
val a11yAvatar = stringResource(CommonStrings.a11y_avatar)
|
||||
|
||||
val clickableModifier = Modifier.clickable(
|
||||
enabled = enabled,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
onClickLabel = onClickLabel,
|
||||
onClick = onClick,
|
||||
indication = ripple(bounded = false),
|
||||
)
|
||||
.testTag(TestTags.editAvatar)
|
||||
.clearAndSetSemantics {
|
||||
contentDescription = a11yAvatar
|
||||
}
|
||||
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
|
||||
fun eraseBackgroundModifier(
|
||||
parentWidth: Dp,
|
||||
editIconRadius: Dp,
|
||||
) = Modifier
|
||||
.graphicsLayer {
|
||||
compositingStrategy = CompositingStrategy.Offscreen
|
||||
}
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
drawCircle(
|
||||
color = Color.Black,
|
||||
center = Offset(
|
||||
x = if (layoutDirection == LayoutDirection.Ltr) {
|
||||
parentWidth.toPx() - editIconRadius.toPx() * 0.48f
|
||||
} else {
|
||||
editIconRadius.toPx() * 0.48f
|
||||
},
|
||||
y = size.height - editIconRadius.toPx(),
|
||||
),
|
||||
radius = editIconRadius.toPx() * 1.2f,
|
||||
blendMode = BlendMode.Clear,
|
||||
)
|
||||
}
|
||||
|
||||
when (state) {
|
||||
is AvatarPickerState.Pick -> {
|
||||
PickButton(
|
||||
buttonSize = state.buttonSize,
|
||||
iconSize = state.iconSize,
|
||||
iconId = state.iconId,
|
||||
modifier = modifier.padding(state.externalPadding).then(clickableModifier),
|
||||
)
|
||||
}
|
||||
is AvatarPickerState.Selected -> {
|
||||
Box(modifier = modifier) {
|
||||
Avatar(
|
||||
avatarData = state.avatarData,
|
||||
avatarType = state.type,
|
||||
modifier = clickableModifier.then(eraseBackgroundModifier(state.avatarData.size.dp, state.avatarData.size.dp * 0.225f)),
|
||||
)
|
||||
|
||||
OverlayEditButton(editButtonSize = state.avatarData.size.dp * 0.44f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PickButton(
|
||||
buttonSize: Dp,
|
||||
iconSize: Dp,
|
||||
@DrawableRes iconId: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(buttonSize)
|
||||
.clip(CircleShape)
|
||||
.border(BorderStroke(1.dp, ElementTheme.colors.borderInteractiveSecondary), shape = CircleShape)
|
||||
) {
|
||||
Icon(
|
||||
resourceId = iconId,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(iconSize),
|
||||
tint = ElementTheme.colors.iconPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxScope.OverlayEditButton(editButtonSize: Dp) {
|
||||
Box(
|
||||
modifier = Modifier.align(Alignment.BottomEnd)
|
||||
.size(editButtonSize)
|
||||
.offset(x = editButtonSize * 0.266f)
|
||||
.clip(CircleShape)
|
||||
.background(ElementTheme.colors.bgCanvasDefault)
|
||||
.border(BorderStroke(1.dp, ElementTheme.colors.borderInteractiveSecondary), shape = CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(editButtonSize * 0.66f),
|
||||
imageVector = CompoundIcons.Edit(),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
sealed interface AvatarPickerState {
|
||||
data class Pick(
|
||||
val buttonSize: Dp,
|
||||
val iconSize: Dp = buttonSize / 2,
|
||||
val externalPadding: PaddingValues = PaddingValues.Zero,
|
||||
@DrawableRes val iconId: Int = CompoundDrawables.ic_compound_take_photo,
|
||||
) : AvatarPickerState
|
||||
|
||||
data class Selected(
|
||||
val avatarData: AvatarData,
|
||||
val type: AvatarType,
|
||||
) : AvatarPickerState
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun AvatarPickerViewPreview() = ElementPreview {
|
||||
PreviewContent()
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun AvatarPickerViewRtlPreview() = CompositionLocalProvider(
|
||||
LocalLayoutDirection provides LayoutDirection.Rtl,
|
||||
) {
|
||||
ElementPreview { PreviewContent() }
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun AvatarPickerSizesPreview() = ElementPreview {
|
||||
Column {
|
||||
Row {
|
||||
AvatarPickerView(AvatarPickerState.Pick(buttonSize = 24.dp, externalPadding = PaddingValues(6.dp)), onClick = {})
|
||||
AvatarPickerView(AvatarPickerState.Pick(buttonSize = 32.dp, externalPadding = PaddingValues(6.dp)), onClick = {})
|
||||
AvatarPickerView(AvatarPickerState.Pick(buttonSize = 48.dp, externalPadding = PaddingValues(6.dp)), onClick = {})
|
||||
AvatarPickerView(AvatarPickerState.Pick(buttonSize = 64.dp, externalPadding = PaddingValues(6.dp)), onClick = {})
|
||||
AvatarPickerView(AvatarPickerState.Pick(buttonSize = 96.dp, externalPadding = PaddingValues(6.dp)), onClick = {})
|
||||
}
|
||||
Row {
|
||||
AvatarPickerView(
|
||||
AvatarPickerState.Selected(
|
||||
avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.TimelineThreadLatestEventSender),
|
||||
type = AvatarType.User
|
||||
),
|
||||
onClick = {},
|
||||
modifier = Modifier.padding(6.dp)
|
||||
)
|
||||
AvatarPickerView(
|
||||
AvatarPickerState.Selected(
|
||||
avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.ReadReceiptList),
|
||||
type = AvatarType.User
|
||||
),
|
||||
onClick = {},
|
||||
modifier = Modifier.padding(6.dp)
|
||||
)
|
||||
AvatarPickerView(
|
||||
AvatarPickerState.Selected(
|
||||
avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.SelectedUser),
|
||||
type = AvatarType.User
|
||||
),
|
||||
onClick = {},
|
||||
modifier = Modifier.padding(6.dp)
|
||||
)
|
||||
AvatarPickerView(
|
||||
AvatarPickerState.Selected(
|
||||
avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.EditRoomDetails),
|
||||
type = AvatarType.User
|
||||
),
|
||||
onClick = {},
|
||||
modifier = Modifier.padding(6.dp)
|
||||
)
|
||||
AvatarPickerView(
|
||||
AvatarPickerState.Selected(
|
||||
avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.RoomListManageUser),
|
||||
type = AvatarType.User
|
||||
),
|
||||
onClick = {},
|
||||
modifier = Modifier.padding(6.dp)
|
||||
)
|
||||
}
|
||||
Row {
|
||||
AvatarPickerView(
|
||||
AvatarPickerState.Selected(
|
||||
avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.TimelineThreadLatestEventSender),
|
||||
type = AvatarType.Space()
|
||||
),
|
||||
onClick = {},
|
||||
modifier = Modifier.padding(6.dp)
|
||||
)
|
||||
AvatarPickerView(
|
||||
AvatarPickerState.Selected(
|
||||
avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.ReadReceiptList),
|
||||
type = AvatarType.Space()
|
||||
),
|
||||
onClick = {},
|
||||
modifier = Modifier.padding(6.dp)
|
||||
)
|
||||
AvatarPickerView(
|
||||
AvatarPickerState.Selected(
|
||||
avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.SelectedUser),
|
||||
type = AvatarType.Space()
|
||||
),
|
||||
onClick = {},
|
||||
modifier = Modifier.padding(6.dp)
|
||||
)
|
||||
AvatarPickerView(
|
||||
AvatarPickerState.Selected(
|
||||
avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.EditRoomDetails),
|
||||
type = AvatarType.Space()
|
||||
),
|
||||
onClick = {},
|
||||
modifier = Modifier.padding(6.dp)
|
||||
)
|
||||
AvatarPickerView(
|
||||
AvatarPickerState.Selected(
|
||||
avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.RoomListManageUser),
|
||||
type = AvatarType.Space()
|
||||
),
|
||||
onClick = {},
|
||||
modifier = Modifier.padding(6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PreviewContent() {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text("Pick image")
|
||||
AvatarPickerView(AvatarPickerState.Pick(buttonSize = 48.dp, externalPadding = PaddingValues(6.dp)), onClick = {})
|
||||
HorizontalDivider()
|
||||
|
||||
Text("User avatar")
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("No url")
|
||||
AvatarPickerView(
|
||||
AvatarPickerState.Selected(
|
||||
avatarData = AvatarData("@user:example.com", "User", null, size = AvatarSize.EditRoomDetails),
|
||||
type = AvatarType.User
|
||||
),
|
||||
onClick = {},
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Local")
|
||||
AvatarPickerView(
|
||||
AvatarPickerState.Selected(
|
||||
avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.EditRoomDetails),
|
||||
type = AvatarType.User
|
||||
),
|
||||
onClick = {},
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("MXC")
|
||||
AvatarPickerView(
|
||||
AvatarPickerState.Selected(
|
||||
avatarData = AvatarData("@user:example.com", "User", "mxc://test", size = AvatarSize.EditRoomDetails),
|
||||
type = AvatarType.User
|
||||
),
|
||||
onClick = {},
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
|
||||
Text("Room avatar")
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("No url")
|
||||
AvatarPickerView(
|
||||
AvatarPickerState.Selected(
|
||||
avatarData = AvatarData("!room:example.com", "Room", null, size = AvatarSize.EditRoomDetails),
|
||||
type = AvatarType.Room()
|
||||
),
|
||||
onClick = {},
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Local")
|
||||
AvatarPickerView(
|
||||
AvatarPickerState.Selected(
|
||||
avatarData = AvatarData("!room:example.com", "Room", "content://test", size = AvatarSize.EditRoomDetails),
|
||||
type = AvatarType.Room()
|
||||
),
|
||||
onClick = {},
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("MXC")
|
||||
AvatarPickerView(
|
||||
AvatarPickerState.Selected(
|
||||
avatarData = AvatarData("!room:example.com", "Room", "mxc://test", size = AvatarSize.EditRoomDetails),
|
||||
type = AvatarType.Room()
|
||||
),
|
||||
onClick = {},
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
|
||||
Text("Space avatar")
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("No url")
|
||||
AvatarPickerView(
|
||||
AvatarPickerState.Selected(
|
||||
avatarData = AvatarData("!room:example.com", "Space", null, size = AvatarSize.EditRoomDetails),
|
||||
type = AvatarType.Space()
|
||||
),
|
||||
onClick = {},
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Local")
|
||||
AvatarPickerView(
|
||||
AvatarPickerState.Selected(
|
||||
avatarData = AvatarData("!room:example.com", "Space", "content://test", size = AvatarSize.EditRoomDetails),
|
||||
type = AvatarType.Space()
|
||||
),
|
||||
onClick = {},
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("MXC")
|
||||
AvatarPickerView(
|
||||
AvatarPickerState.Selected(
|
||||
avatarData = AvatarData("!room:example.com", "Space", "mxc://test", size = AvatarSize.EditRoomDetails),
|
||||
type = AvatarType.Space()
|
||||
),
|
||||
onClick = {},
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,157 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.ui.components
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.ripple
|
||||
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.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarType
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
import io.element.android.libraries.testtags.TestTags
|
||||
import io.element.android.libraries.testtags.testTag
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun EditableAvatarView(
|
||||
matrixId: String,
|
||||
displayName: String?,
|
||||
avatarUrl: String?,
|
||||
avatarSize: AvatarSize,
|
||||
avatarType: AvatarType,
|
||||
onAvatarClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
val a11yAvatar = stringResource(CommonStrings.a11y_avatar)
|
||||
val editIconRadius = 15.dp
|
||||
val parentHeight = avatarSize.dp
|
||||
val parentWidth = avatarSize.dp + editIconRadius / 2f
|
||||
Box(
|
||||
modifier = modifier
|
||||
.wrapContentSize()
|
||||
.size(height = parentHeight, width = parentWidth)
|
||||
.clickable(
|
||||
enabled = enabled,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
onClickLabel = stringResource(CommonStrings.a11y_edit_avatar),
|
||||
onClick = onAvatarClick,
|
||||
indication = ripple(bounded = false),
|
||||
)
|
||||
.testTag(TestTags.editAvatar)
|
||||
.clearAndSetSemantics {
|
||||
contentDescription = a11yAvatar
|
||||
},
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.graphicsLayer {
|
||||
compositingStrategy = CompositingStrategy.Offscreen
|
||||
}
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
drawCircle(
|
||||
color = Color.Black,
|
||||
center = Offset(
|
||||
x = parentWidth.toPx() - editIconRadius.toPx(),
|
||||
y = size.height - editIconRadius.toPx(),
|
||||
),
|
||||
radius = (editIconRadius + 4.dp).toPx(),
|
||||
blendMode = BlendMode.Clear,
|
||||
)
|
||||
}
|
||||
) {
|
||||
when {
|
||||
avatarUrl == null || avatarUrl.startsWith("mxc://") -> {
|
||||
Avatar(
|
||||
avatarData = AvatarData(
|
||||
id = matrixId,
|
||||
name = displayName,
|
||||
url = avatarUrl,
|
||||
size = avatarSize,
|
||||
),
|
||||
avatarType = avatarType,
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
UnsavedAvatar(
|
||||
avatarUri = avatarUrl,
|
||||
avatarSize = avatarSize,
|
||||
avatarType = avatarType,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.size(editIconRadius * 2)
|
||||
.border(1.dp, ElementTheme.colors.borderInteractiveSecondary, CircleShape)
|
||||
.padding(6.dp),
|
||||
imageVector = CompoundIcons.Edit(),
|
||||
contentDescription = null,
|
||||
tint = ElementTheme.colors.iconPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun EditableAvatarViewPreview(
|
||||
@PreviewParameter(EditableAvatarViewUriProvider::class) uri: String?
|
||||
) = ElementPreview(
|
||||
drawableFallbackForImages = CommonDrawables.sample_avatar,
|
||||
) {
|
||||
EditableAvatarView(
|
||||
matrixId = "id",
|
||||
displayName = "Room",
|
||||
avatarUrl = uri,
|
||||
avatarSize = AvatarSize.RoomDetailsHeader,
|
||||
avatarType = AvatarType.User,
|
||||
onAvatarClick = {},
|
||||
)
|
||||
}
|
||||
|
||||
open class EditableAvatarViewUriProvider : PreviewParameterProvider<String?> {
|
||||
override val values: Sequence<String?>
|
||||
get() = sequenceOf(
|
||||
null,
|
||||
"mxc://matrix.org/123456",
|
||||
"https://example.com/avatar.jpg",
|
||||
)
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.AddAPhoto
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.painter.ColorPainter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.request.ImageRequest
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarType
|
||||
import io.element.android.libraries.designsystem.components.avatar.avatarShape
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.temporaryColorBgSpecial
|
||||
|
||||
/**
|
||||
* An avatar that the user has selected, but which has not yet been uploaded to Matrix.
|
||||
*
|
||||
* The image is loaded from a local resource instead of from a MXC URI.
|
||||
*/
|
||||
@Composable
|
||||
fun UnsavedAvatar(
|
||||
avatarUri: String?,
|
||||
avatarSize: AvatarSize,
|
||||
avatarType: AvatarType,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val commonModifier = modifier
|
||||
.size(avatarSize.dp)
|
||||
.clip(avatarType.avatarShape(avatarSize.dp))
|
||||
|
||||
if (avatarUri != null) {
|
||||
val context = LocalContext.current
|
||||
val model = ImageRequest.Builder(context)
|
||||
.data(avatarUri)
|
||||
.build()
|
||||
AsyncImage(
|
||||
modifier = commonModifier,
|
||||
model = model,
|
||||
placeholder = ColorPainter(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentScale = ContentScale.Crop,
|
||||
contentDescription = null,
|
||||
)
|
||||
} else {
|
||||
Box(modifier = commonModifier.background(ElementTheme.colors.temporaryColorBgSpecial)) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.AddAPhoto,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(avatarSize.dp * 4 / 7),
|
||||
tint = ElementTheme.colors.iconSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun UnsavedAvatarPreview() = ElementPreview {
|
||||
Row(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
UnsavedAvatar(null, AvatarSize.EditRoomDetails, AvatarType.User)
|
||||
UnsavedAvatar("", AvatarSize.EditRoomDetails, AvatarType.User)
|
||||
UnsavedAvatar(null, AvatarSize.EditRoomDetails, AvatarType.Space())
|
||||
UnsavedAvatar("", AvatarSize.EditRoomDetails, AvatarType.Space())
|
||||
}
|
||||
}
|
||||
|
|
@ -27,7 +27,7 @@ fun RoomAddressField(
|
|||
homeserverName: String,
|
||||
addressValidity: RoomAddressValidity,
|
||||
onAddressChange: (String) -> Unit,
|
||||
label: String,
|
||||
label: String?,
|
||||
supportingText: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@
|
|||
<string name="a11y_your_avatar">"Your avatar"</string>
|
||||
<string name="action_accept">"Accept"</string>
|
||||
<string name="action_add_caption">"Add caption"</string>
|
||||
<string name="action_add_existing_rooms">"Add existing rooms"</string>
|
||||
<string name="action_add_to_timeline">"Add to timeline"</string>
|
||||
<string name="action_back">"Back"</string>
|
||||
<string name="action_call">"Call"</string>
|
||||
|
|
@ -75,6 +76,7 @@
|
|||
<string name="action_copy_text">"Copy text"</string>
|
||||
<string name="action_create">"Create"</string>
|
||||
<string name="action_create_a_room">"Create a room"</string>
|
||||
<string name="action_create_space">"Create space"</string>
|
||||
<string name="action_deactivate">"Deactivate"</string>
|
||||
<string name="action_deactivate_account">"Deactivate account"</string>
|
||||
<string name="action_decline">"Decline"</string>
|
||||
|
|
@ -112,6 +114,7 @@
|
|||
<string name="action_load_more">"Load more"</string>
|
||||
<string name="action_manage_account">"Manage account"</string>
|
||||
<string name="action_manage_devices">"Manage devices"</string>
|
||||
<string name="action_manage_rooms">"Manage rooms"</string>
|
||||
<string name="action_message">"Message"</string>
|
||||
<string name="action_minimize">"Minimise"</string>
|
||||
<string name="action_next">"Next"</string>
|
||||
|
|
@ -191,6 +194,7 @@
|
|||
<string name="common_copied_to_clipboard">"Copied to clipboard"</string>
|
||||
<string name="common_copyright">"Copyright"</string>
|
||||
<string name="common_creating_room">"Creating room…"</string>
|
||||
<string name="common_creating_space">"Creating space…"</string>
|
||||
<string name="common_current_user_canceled_knock">"Request canceled"</string>
|
||||
<string name="common_current_user_left_room">"Left room"</string>
|
||||
<string name="common_current_user_left_space">"Left space"</string>
|
||||
|
|
@ -337,6 +341,7 @@ Reason: %1$s."</string>
|
|||
<string name="common_starting_chat">"Starting chat…"</string>
|
||||
<string name="common_sticker">"Sticker"</string>
|
||||
<string name="common_success">"Success"</string>
|
||||
<string name="common_suggested">"Suggested"</string>
|
||||
<string name="common_suggestions">"Suggestions"</string>
|
||||
<string name="common_syncing">"Syncing"</string>
|
||||
<string name="common_system">"System"</string>
|
||||
|
|
@ -473,6 +478,7 @@ Are you sure you want to continue?"</string>
|
|||
<string name="screen_share_this_location_action">"Share this location"</string>
|
||||
<string name="screen_space_list_description">"Spaces you have created or joined."</string>
|
||||
<string name="screen_space_list_details">"%1$s • %2$s"</string>
|
||||
<string name="screen_space_list_empty_state_title">"Create spaces to organize rooms"</string>
|
||||
<string name="screen_space_list_parent_space">"%1$s space"</string>
|
||||
<string name="screen_space_list_title">"Spaces"</string>
|
||||
<string name="screen_space_menu_action_members">"View members"</string>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue