Skip to content

Commit 0796cf9

Browse files
authored
Merge pull request #5197 from element-hq/feature/bma/spaceUiComponent
Space UI component
2 parents 033b6fd + dc0d810 commit 0796cf9

File tree

69 files changed

+537
-25
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

69 files changed

+537
-25
lines changed

features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewTitleAt
4646
import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule
4747
import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule
4848
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
49-
import io.element.android.libraries.designsystem.atomic.molecules.RoomPreviewMembersCountMolecule
49+
import io.element.android.libraries.designsystem.atomic.molecules.MembersCountMolecule
5050
import io.element.android.libraries.designsystem.atomic.organisms.RoomPreviewOrganism
5151
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
5252
import io.element.android.libraries.designsystem.components.Announcement
@@ -546,7 +546,7 @@ private fun DefaultLoadedContent(
546546
},
547547
memberCount = {
548548
if (contentState.showMemberCount) {
549-
RoomPreviewMembersCountMolecule(memberCount = contentState.numberOfMembers ?: 0)
549+
MembersCountMolecule(memberCount = contentState.numberOfMembers ?: 0)
550550
}
551551
}
552552
)

features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerView.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import io.element.android.libraries.designsystem.components.async.AsyncIndicator
3838
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost
3939
import io.element.android.libraries.designsystem.components.async.rememberAsyncIndicatorState
4040
import io.element.android.libraries.designsystem.components.avatar.Avatar
41+
import io.element.android.libraries.designsystem.components.avatar.AvatarRow
4142
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
4243
import io.element.android.libraries.designsystem.components.avatar.AvatarType
4344
import io.element.android.libraries.designsystem.preview.ElementPreview
Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import io.element.android.libraries.designsystem.theme.components.Icon
2525
import io.element.android.libraries.designsystem.theme.components.Text
2626

2727
@Composable
28-
fun RoomPreviewMembersCountMolecule(
28+
fun MembersCountMolecule(
2929
memberCount: Long,
3030
modifier: Modifier = Modifier,
3131
) {
@@ -51,13 +51,13 @@ fun RoomPreviewMembersCountMolecule(
5151

5252
@PreviewsDayNight
5353
@Composable
54-
internal fun RoomPreviewMembersCountMoleculePreview() = ElementPreview {
54+
internal fun MembersCountMoleculePreview() = ElementPreview {
5555
Column(
5656
modifier = Modifier.padding(8.dp),
5757
verticalArrangement = Arrangement.spacedBy(8.dp),
5858
) {
59-
RoomPreviewMembersCountMolecule(memberCount = 1)
60-
RoomPreviewMembersCountMolecule(memberCount = 888)
61-
RoomPreviewMembersCountMolecule(memberCount = 123_456)
59+
MembersCountMolecule(memberCount = 1)
60+
MembersCountMolecule(memberCount = 888)
61+
MembersCountMolecule(memberCount = 123_456)
6262
}
6363
}

libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarDataProvider.kt

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ open class AvatarDataProvider : PreviewParameterProvider<AvatarData> {
1616
.map {
1717
sequenceOf(
1818
anAvatarData(size = it),
19-
anAvatarData(size = it).copy(name = null),
20-
anAvatarData(size = it).copy(url = "aUrl"),
19+
anAvatarData(size = it, name = null),
20+
anAvatarData(size = it, url = "aUrl"),
2121
)
2222
}
2323
.flatten()
@@ -26,10 +26,12 @@ open class AvatarDataProvider : PreviewParameterProvider<AvatarData> {
2626
fun anAvatarData(
2727
// Let's the id not start with a 'a'.
2828
id: String = "@id_of_alice:server.org",
29-
name: String = "Alice",
29+
name: String? = "Alice",
30+
url: String? = null,
3031
size: AvatarSize = AvatarSize.RoomListItem,
3132
) = AvatarData(
3233
id = id,
3334
name = name,
35+
url = url,
3436
size = size,
3537
)
Lines changed: 55 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* Please see LICENSE files in the repository root for full details.
66
*/
77

8-
package io.element.android.features.knockrequests.impl.banner
8+
package io.element.android.libraries.designsystem.components.avatar
99

1010
import androidx.compose.foundation.layout.Box
1111
import androidx.compose.foundation.layout.padding
@@ -23,10 +23,7 @@ import androidx.compose.ui.platform.LocalLayoutDirection
2323
import androidx.compose.ui.tooling.preview.PreviewParameter
2424
import androidx.compose.ui.unit.LayoutDirection
2525
import androidx.compose.ui.unit.dp
26-
import io.element.android.libraries.designsystem.components.avatar.Avatar
27-
import io.element.android.libraries.designsystem.components.avatar.AvatarData
28-
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
29-
import io.element.android.libraries.designsystem.components.avatar.AvatarType
26+
import io.element.android.libraries.designsystem.components.avatar.internal.OverlapRatioProvider
3027
import io.element.android.libraries.designsystem.preview.ElementPreview
3128
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
3229
import io.element.android.libraries.designsystem.text.toPx
@@ -41,13 +38,15 @@ import kotlinx.collections.immutable.toImmutableList
4138
* @param modifier Jetpack Compose modifier
4239
* @param overlapRatio the overlap ration. When 0f, avatars will render without overlap, when 1f
4340
* only the first avatar will be visible
41+
* @param lastOnTop if true, the last visible avatar will be rendered on top.
4442
*/
4543
@Composable
4644
fun AvatarRow(
4745
avatarDataList: ImmutableList<AvatarData>,
4846
avatarType: AvatarType,
4947
modifier: Modifier = Modifier,
5048
overlapRatio: Float = 0.5f,
49+
lastOnTop: Boolean = false,
5150
) {
5251
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
5352
Box(
@@ -57,23 +56,35 @@ fun AvatarRow(
5756
val avatarSize = avatarDataList.firstOrNull()?.size?.dp ?: return
5857
val avatarSizePx = avatarSize.toPx()
5958
avatarDataList
60-
.reversed()
59+
.let {
60+
if (lastOnTop) {
61+
it
62+
} else {
63+
it.reversed()
64+
}
65+
}
6166
.forEachIndexed { index, avatarData ->
67+
val startPadding = if (lastOnTop) {
68+
avatarSize * (1 - overlapRatio) * index
69+
} else {
70+
avatarSize * (1 - overlapRatio) * (lastItemIndex - index)
71+
}
6272
Avatar(
6373
modifier = Modifier
64-
.padding(start = avatarSize * (1 - overlapRatio) * (lastItemIndex - index))
74+
.padding(start = startPadding)
6575
.graphicsLayer {
6676
compositingStrategy = CompositingStrategy.Offscreen
6777
}
6878
.drawWithContent {
69-
// Draw content and clear the pixels for the avatar on the left (right in RTL).
79+
// Draw content and clear the pixels for the avatar on the left (right in RTL) or when lastOnTop is true on
80+
// the right (left in RTL).
7081
drawContent()
71-
val xOffset = if (isRtl) {
72-
size.width - avatarSizePx * (overlapRatio - 0.5f)
73-
} else {
74-
0f + avatarSizePx * (overlapRatio - 0.5f)
75-
}
7682
if (index < lastItemIndex) {
83+
val xOffset = if (isRtl == lastOnTop) {
84+
avatarSizePx * (overlapRatio - 0.5f)
85+
} else {
86+
size.width - avatarSizePx * (overlapRatio - 0.5f)
87+
}
7788
drawCircle(
7889
color = Color.Black,
7990
center = Offset(
@@ -104,6 +115,17 @@ internal fun AvatarRowPreview(@PreviewParameter(OverlapRatioProvider::class) ove
104115
}
105116
}
106117

118+
@Composable
119+
@PreviewsDayNight
120+
internal fun AvatarRowLastOnTopPreview(@PreviewParameter(OverlapRatioProvider::class) overlapRatio: Float) {
121+
ElementPreview {
122+
ContentToPreview(
123+
overlapRatio = overlapRatio,
124+
lastOnTop = true,
125+
)
126+
}
127+
}
128+
107129
@Composable
108130
@PreviewsDayNight
109131
internal fun AvatarRowRtlPreview(@PreviewParameter(OverlapRatioProvider::class) overlapRatio: Float) {
@@ -117,7 +139,25 @@ internal fun AvatarRowRtlPreview(@PreviewParameter(OverlapRatioProvider::class)
117139
}
118140

119141
@Composable
120-
private fun ContentToPreview(overlapRatio: Float) {
142+
@PreviewsDayNight
143+
internal fun AvatarRowLastOnTopRtlPreview(@PreviewParameter(OverlapRatioProvider::class) overlapRatio: Float) {
144+
CompositionLocalProvider(
145+
LocalLayoutDirection provides LayoutDirection.Rtl,
146+
) {
147+
ElementPreview {
148+
ContentToPreview(
149+
overlapRatio = overlapRatio,
150+
lastOnTop = true,
151+
)
152+
}
153+
}
154+
}
155+
156+
@Composable
157+
private fun ContentToPreview(
158+
overlapRatio: Float,
159+
lastOnTop: Boolean = false,
160+
) {
121161
AvatarRow(
122162
avatarDataList = listOf("A", "B", "C").map {
123163
AvatarData(
@@ -128,5 +168,6 @@ private fun ContentToPreview(overlapRatio: Float) {
128168
}.toImmutableList(),
129169
avatarType = AvatarType.User,
130170
overlapRatio = overlapRatio,
171+
lastOnTop = lastOnTop,
131172
)
132173
}

libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,4 +63,7 @@ enum class AvatarSize(val dp: Dp) {
6363
DmCreationConfirmation(64.dp),
6464

6565
UserVerification(52.dp),
66+
67+
OrganizationHeader(64.dp),
68+
SpaceMember(24.dp),
6669
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* Please see LICENSE files in the repository root for full details.
66
*/
77

8-
package io.element.android.features.knockrequests.impl.banner
8+
package io.element.android.libraries.designsystem.components.avatar.internal
99

1010
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
1111

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
package io.element.android.libraries.matrix.ui.components
9+
10+
import androidx.compose.foundation.BorderStroke
11+
import androidx.compose.foundation.clickable
12+
import androidx.compose.foundation.interaction.MutableInteractionSource
13+
import androidx.compose.foundation.layout.Box
14+
import androidx.compose.foundation.layout.padding
15+
import androidx.compose.foundation.layout.size
16+
import androidx.compose.foundation.layout.width
17+
import androidx.compose.foundation.shape.CircleShape
18+
import androidx.compose.material3.ripple
19+
import androidx.compose.runtime.Composable
20+
import androidx.compose.runtime.CompositionLocalProvider
21+
import androidx.compose.runtime.remember
22+
import androidx.compose.ui.Alignment
23+
import androidx.compose.ui.Modifier
24+
import androidx.compose.ui.draw.clip
25+
import androidx.compose.ui.draw.drawWithContent
26+
import androidx.compose.ui.geometry.Offset
27+
import androidx.compose.ui.graphics.BlendMode
28+
import androidx.compose.ui.graphics.Color
29+
import androidx.compose.ui.graphics.CompositingStrategy
30+
import androidx.compose.ui.graphics.graphicsLayer
31+
import androidx.compose.ui.platform.LocalLayoutDirection
32+
import androidx.compose.ui.res.stringResource
33+
import androidx.compose.ui.semantics.clearAndSetSemantics
34+
import androidx.compose.ui.semantics.contentDescription
35+
import androidx.compose.ui.semantics.onClick
36+
import androidx.compose.ui.unit.LayoutDirection
37+
import androidx.compose.ui.unit.dp
38+
import io.element.android.compound.theme.ElementTheme
39+
import io.element.android.compound.tokens.generated.CompoundIcons
40+
import io.element.android.libraries.designsystem.components.avatar.Avatar
41+
import io.element.android.libraries.designsystem.components.avatar.AvatarData
42+
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
43+
import io.element.android.libraries.designsystem.components.avatar.AvatarType
44+
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
45+
import io.element.android.libraries.designsystem.preview.ElementPreview
46+
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
47+
import io.element.android.libraries.designsystem.text.toPx
48+
import io.element.android.libraries.designsystem.theme.components.Icon
49+
import io.element.android.libraries.designsystem.theme.components.Surface
50+
import io.element.android.libraries.ui.strings.CommonStrings
51+
52+
/**
53+
* Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=3643-2678&m=dev
54+
*/
55+
@Composable
56+
fun EditableOrgAvatar(
57+
avatarData: AvatarData,
58+
onEdit: () -> Unit,
59+
modifier: Modifier = Modifier,
60+
) {
61+
val actionEdit = stringResource(id = CommonStrings.action_edit)
62+
val description = stringResource(CommonStrings.a11y_avatar)
63+
Box(
64+
modifier = modifier
65+
.width(avatarData.size.dp + 16.dp)
66+
.clearAndSetSemantics {
67+
contentDescription = description
68+
// Note: this does not set the click effect to the whole Box
69+
// when talkback is not enabled
70+
onClick(
71+
label = actionEdit,
72+
action = {
73+
onEdit()
74+
true
75+
}
76+
)
77+
}
78+
) {
79+
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
80+
val editIconRadius = 17.dp.toPx()
81+
val editIconXOffset = 7.dp.toPx()
82+
val editIconYOffset = 15.dp.toPx()
83+
Avatar(
84+
avatarData = avatarData,
85+
avatarType = AvatarType.Space(false),
86+
modifier = Modifier
87+
.align(Alignment.Center)
88+
.graphicsLayer {
89+
compositingStrategy = CompositingStrategy.Offscreen
90+
}
91+
.drawWithContent {
92+
drawContent()
93+
val xOffset = if (isRtl) {
94+
editIconXOffset
95+
} else {
96+
size.width - editIconXOffset
97+
}
98+
drawCircle(
99+
color = Color.Black,
100+
center = Offset(
101+
x = xOffset,
102+
y = size.height - editIconYOffset,
103+
),
104+
radius = editIconRadius,
105+
blendMode = BlendMode.Clear,
106+
)
107+
},
108+
)
109+
Surface(
110+
color = ElementTheme.colors.bgCanvasDefault,
111+
shape = CircleShape,
112+
border = BorderStroke(1.dp, color = ElementTheme.colors.borderInteractiveSecondary),
113+
modifier = Modifier
114+
.clip(CircleShape)
115+
.size(30.dp)
116+
.align(Alignment.BottomEnd)
117+
.clickable(
118+
indication = ripple(),
119+
interactionSource = remember { MutableInteractionSource() },
120+
onClick = onEdit,
121+
),
122+
) {
123+
Icon(
124+
imageVector = CompoundIcons.Edit(),
125+
// Note: keep the context description for the test
126+
contentDescription = stringResource(id = CommonStrings.action_edit),
127+
tint = ElementTheme.colors.iconPrimary,
128+
modifier = Modifier.padding(6.dp)
129+
)
130+
}
131+
}
132+
}
133+
134+
@PreviewsDayNight
135+
@Composable
136+
internal fun EditableOrgAvatarPreview() = ElementPreview {
137+
EditableOrgAvatar(
138+
avatarData = anAvatarData(
139+
url = "anUrl",
140+
size = AvatarSize.OrganizationHeader,
141+
),
142+
onEdit = {},
143+
)
144+
}
145+
146+
@PreviewsDayNight
147+
@Composable
148+
internal fun EditableOrgAvatarRtlPreview() = CompositionLocalProvider(
149+
LocalLayoutDirection provides LayoutDirection.Rtl,
150+
) {
151+
ElementPreview {
152+
EditableOrgAvatar(
153+
avatarData = anAvatarData(
154+
url = "anUrl",
155+
size = AvatarSize.OrganizationHeader,
156+
),
157+
onEdit = {},
158+
)
159+
}
160+
}

0 commit comments

Comments
 (0)