First Commit

This commit is contained in:
2025-12-18 16:28:50 +07:00
commit 8c3e4f491f
9974 changed files with 396488 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/build
+49
View File
@@ -0,0 +1,49 @@
import extension.testCommonDependencies
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2024 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.
*/
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.ksp)
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.libraries.designsystem"
buildFeatures {
buildConfig = true
}
buildTypes {
getByName("release") {
consumerProguardFiles("consumer-rules.pro")
}
}
}
dependencies {
api(projects.libraries.compound)
implementation(libs.androidx.compose.material3.windowsizeclass)
implementation(libs.androidx.compose.material3.adaptive)
implementation(libs.coil.compose)
implementation(libs.vanniktech.blurhash)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
ksp(libs.showkase.processor)
implementation(libs.showkase)
testCommonDependencies(libs)
}
+23
View File
@@ -0,0 +1,23 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.kts.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
-keep class io.element.android.libraries.designsystem.showkase.DesignSystemShowkaseRootModuleCodegen { }
@@ -0,0 +1,49 @@
/*
* 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.designsystem
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import io.element.android.compound.theme.ElementTheme
@Composable
fun Boolean.toEnabledColor(): Color {
return if (this) {
ElementTheme.colors.textPrimary
} else {
ElementTheme.colors.textDisabled
}
}
@Composable
fun Boolean.toSecondaryEnabledColor(): Color {
return if (this) {
ElementTheme.colors.textSecondary
} else {
ElementTheme.colors.textDisabled
}
}
@Composable
fun Boolean.toIconEnabledColor(): Color {
return if (this) {
ElementTheme.colors.iconPrimary
} else {
ElementTheme.colors.iconDisabled
}
}
@Composable
fun Boolean.toIconSecondaryEnabledColor(): Color {
return if (this) {
ElementTheme.colors.iconSecondary
} else {
ElementTheme.colors.iconDisabled
}
}
@@ -0,0 +1,41 @@
/*
* 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.designsystem.animation
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalInspectionMode
@Composable
fun alphaAnimation(
fromAlpha: Float = 0f,
toAlpha: Float = 1f,
delayMillis: Int = 150,
durationMillis: Int = 150,
label: String = "AlphaAnimation",
): State<Float> {
val firstAlpha = if (LocalInspectionMode.current) 1f else fromAlpha
var alpha by remember { mutableFloatStateOf(firstAlpha) }
LaunchedEffect(Unit) { alpha = toAlpha }
return animateFloatAsState(
targetValue = alpha,
animationSpec = tween(
delayMillis = delayMillis,
durationMillis = durationMillis,
),
label = label
)
}
@@ -0,0 +1,52 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.designsystem.atomic.atoms
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun BetaLabel(
modifier: Modifier = Modifier,
) {
val shape = RoundedCornerShape(size = 6.dp)
Text(
modifier = modifier
.border(
width = 1.dp,
color = ElementTheme.colors.borderInfoSubtle,
shape = shape,
)
.background(
color = ElementTheme.colors.bgInfoSubtle,
shape = shape,
)
.padding(horizontal = 8.dp, vertical = 4.dp),
text = stringResource(CommonStrings.common_beta).uppercase(),
style = ElementTheme.typography.fontBodySmMedium,
color = ElementTheme.colors.textInfoPrimary,
)
}
@PreviewsDayNight
@Composable
internal fun BetaLabelPreview() = ElementPreview {
BetaLabel()
}
@@ -0,0 +1,99 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.designsystem.atomic.atoms
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
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.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.Text
private const val MAX_COUNT = 99
private const val MAX_COUNT_STRING = "+$MAX_COUNT"
/**
* A counter atom that displays a number in a circle.
* Figma link : https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=2805-2649&m=dev
*
* @param count The number to display. If the number is greater than [MAX_COUNT], the counter will display [MAX_COUNT_STRING].
* If the number is less than 1, the counter will not be displayed.
* @param modifier The modifier to apply to this layout.
* @param textStyle The style to apply to the text inside the counter.
* @param isCritical If true, the counter will use a critical color scheme, otherwise it will use an accent color scheme.
*/
@Composable
fun CounterAtom(
count: Int,
modifier: Modifier = Modifier,
textStyle: TextStyle = CounterAtomDefaults.textStyle,
isCritical: Boolean = false,
) {
if (count < 1) return
val countAsText = when (count) {
in 0..MAX_COUNT -> count.toString()
else -> MAX_COUNT_STRING
}
val textMeasurer = rememberTextMeasurer()
// Measure the maximum count string size
val textLayoutResult = textMeasurer.measure(
text = MAX_COUNT_STRING,
style = textStyle
)
val textSize = textLayoutResult.size
val squareSize = maxOf(textSize.width, textSize.height)
Box(
modifier = modifier
.size(squareSize.toDp() + 1.dp)
.clip(CircleShape)
.background(
if (isCritical) {
ElementTheme.colors.iconCriticalPrimary
} else {
ElementTheme.colors.iconAccentPrimary
}
)
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = countAsText,
style = textStyle,
color = ElementTheme.colors.textOnSolidPrimary,
)
}
}
object CounterAtomDefaults {
val textStyle: TextStyle
@Composable get() = ElementTheme.typography.fontBodyMdMedium
}
@PreviewsDayNight
@Composable
internal fun CounterAtomPreview() = ElementPreview {
Column(verticalArrangement = spacedBy(2.dp)) {
CounterAtom(count = 0)
CounterAtom(count = 4)
CounterAtom(count = 99)
CounterAtom(count = 100)
CounterAtom(count = 4, isCritical = true)
}
}
@@ -0,0 +1,177 @@
/*
* 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.designsystem.atomic.atoms
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.R
import io.element.android.libraries.designsystem.modifiers.blurCompat
import io.element.android.libraries.designsystem.modifiers.blurredShapeShadow
import io.element.android.libraries.designsystem.modifiers.canUseBlurMaskFilter
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@Composable
fun ElementLogoAtom(
size: ElementLogoAtomSize,
modifier: Modifier = Modifier,
useBlurredShadow: Boolean = canUseBlurMaskFilter(),
darkTheme: Boolean = ElementTheme.isLightTheme.not(),
) {
val blur = if (darkTheme) 160.dp else 24.dp
val shadowColor = if (darkTheme) size.shadowColorDark else size.shadowColorLight
val logoShadowColor = if (darkTheme) size.logoShadowColorDark else size.logoShadowColorLight
val backgroundColor = if (darkTheme) Color.White.copy(alpha = 0.2f) else Color.White.copy(alpha = 0.4f)
val borderColor = if (darkTheme) Color.White.copy(alpha = 0.89f) else Color.White
Box(
modifier = modifier
.size(size.outerSize)
.border(size.borderWidth, borderColor, RoundedCornerShape(size.cornerRadius)),
contentAlignment = Alignment.Center,
) {
if (useBlurredShadow) {
Box(
Modifier
.size(size.outerSize)
.blurredShapeShadow(
color = shadowColor,
cornerRadius = size.cornerRadius,
blurRadius = size.shadowRadius,
offsetY = 8.dp,
)
)
} else {
Box(
Modifier
.size(size.outerSize)
.shadow(
elevation = size.shadowRadius,
shape = RoundedCornerShape(size.cornerRadius),
clip = false,
ambientColor = shadowColor
)
)
}
Box(
Modifier
.clip(RoundedCornerShape(size.cornerRadius))
.size(size.outerSize)
.background(backgroundColor)
.blurCompat(blur)
)
Image(
modifier = Modifier
.size(size.logoSize)
// Do the same double shadow than on Figma...
.shadow(
elevation = 35.dp,
clip = false,
shape = CircleShape,
ambientColor = logoShadowColor,
)
.shadow(
elevation = 35.dp,
clip = false,
shape = CircleShape,
ambientColor = Color(0x80000000),
),
painter = painterResource(id = R.drawable.element_logo),
contentDescription = null
)
}
}
sealed class ElementLogoAtomSize(
val outerSize: Dp,
val logoSize: Dp,
val cornerRadius: Dp,
val borderWidth: Dp,
val logoShadowColorDark: Color,
val logoShadowColorLight: Color,
val shadowColorDark: Color,
val shadowColorLight: Color,
val shadowRadius: Dp,
) {
data object Medium : ElementLogoAtomSize(
outerSize = 120.dp,
logoSize = 83.5.dp,
cornerRadius = 33.dp,
borderWidth = 0.38.dp,
logoShadowColorDark = Color(0x4D000000),
logoShadowColorLight = Color(0x66000000),
shadowColorDark = Color.Black.copy(alpha = 0.4f),
shadowColorLight = Color(0x401B1D22),
shadowRadius = 32.dp,
)
data object Large : ElementLogoAtomSize(
outerSize = 158.dp,
logoSize = 110.dp,
cornerRadius = 44.dp,
borderWidth = 0.5.dp,
logoShadowColorDark = Color(0x4D000000),
logoShadowColorLight = Color(0x66000000),
shadowColorDark = Color.Black,
shadowColorLight = Color(0x801B1D22),
shadowRadius = 60.dp,
)
}
@Composable
@PreviewsDayNight
internal fun ElementLogoAtomMediumPreview() = ElementPreview {
ContentToPreview(ElementLogoAtomSize.Medium)
}
@Composable
@PreviewsDayNight
internal fun ElementLogoAtomLargePreview() = ElementPreview {
ContentToPreview(ElementLogoAtomSize.Large)
}
@Composable
@PreviewsDayNight
internal fun ElementLogoAtomMediumNoBlurShadowPreview() = ElementPreview {
ContentToPreview(ElementLogoAtomSize.Medium, useBlurredShadow = false)
}
@Composable
@PreviewsDayNight
internal fun ElementLogoAtomLargeNoBlurShadowPreview() = ElementPreview {
ContentToPreview(ElementLogoAtomSize.Large, useBlurredShadow = false)
}
@ExcludeFromCoverage
@Composable
private fun ContentToPreview(elementLogoAtomSize: ElementLogoAtomSize, useBlurredShadow: Boolean = true) {
Box(
Modifier
.size(elementLogoAtomSize.outerSize + elementLogoAtomSize.shadowRadius * 2)
.background(ElementTheme.colors.bgSubtlePrimary),
contentAlignment = Alignment.Center
) {
ElementLogoAtom(elementLogoAtomSize, useBlurredShadow = useBlurredShadow)
}
}
@@ -0,0 +1,111 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.designsystem.atomic.atoms
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.components.Badge
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
object MatrixBadgeAtom {
data class MatrixBadgeData(
val text: String,
val icon: ImageVector,
val type: Type,
)
enum class Type {
Positive,
Neutral,
Negative,
Info,
}
@Composable
fun View(
data: MatrixBadgeData,
) {
val backgroundColor = when (data.type) {
Type.Positive -> ElementTheme.colors.bgBadgeAccent
Type.Neutral -> ElementTheme.colors.bgBadgeDefault
Type.Negative -> ElementTheme.colors.bgCriticalSubtle
Type.Info -> ElementTheme.colors.bgBadgeInfo
}
val textColor = when (data.type) {
Type.Positive -> ElementTheme.colors.textBadgeAccent
Type.Neutral -> ElementTheme.colors.textPrimary
Type.Negative -> ElementTheme.colors.textCriticalPrimary
Type.Info -> ElementTheme.colors.textBadgeInfo
}
val iconColor = when (data.type) {
Type.Positive -> ElementTheme.colors.iconAccentPrimary
Type.Neutral -> ElementTheme.colors.iconPrimary
Type.Negative -> ElementTheme.colors.iconCriticalPrimary
Type.Info -> ElementTheme.colors.iconInfoPrimary
}
Badge(
text = data.text,
icon = data.icon,
backgroundColor = backgroundColor,
iconColor = iconColor,
textColor = textColor,
)
}
}
@PreviewsDayNight
@Composable
internal fun MatrixBadgeAtomPositivePreview() = ElementPreview {
MatrixBadgeAtom.View(
MatrixBadgeAtom.MatrixBadgeData(
text = "Trusted",
icon = CompoundIcons.Verified(),
type = MatrixBadgeAtom.Type.Positive,
)
)
}
@PreviewsDayNight
@Composable
internal fun MatrixBadgeAtomNeutralPreview() = ElementPreview {
MatrixBadgeAtom.View(
MatrixBadgeAtom.MatrixBadgeData(
text = "Public room",
icon = CompoundIcons.Public(),
type = MatrixBadgeAtom.Type.Neutral,
)
)
}
@PreviewsDayNight
@Composable
internal fun MatrixBadgeAtomNegativePreview() = ElementPreview {
MatrixBadgeAtom.View(
MatrixBadgeAtom.MatrixBadgeData(
text = "Not trusted",
icon = CompoundIcons.ErrorSolid(),
type = MatrixBadgeAtom.Type.Negative,
)
)
}
@PreviewsDayNight
@Composable
internal fun MatrixBadgeAtomInfoPreview() = ElementPreview {
MatrixBadgeAtom.View(
MatrixBadgeAtom.MatrixBadgeData(
text = "Not encrypted",
icon = CompoundIcons.LockOff(),
type = MatrixBadgeAtom.Type.Info,
)
)
}
@@ -0,0 +1,54 @@
/*
* 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.designsystem.atomic.atoms
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.placeholderBackground
@Composable
fun PlaceholderAtom(
width: Dp,
height: Dp,
modifier: Modifier = Modifier,
color: Color = ElementTheme.colors.placeholderBackground,
) {
Box(
modifier = modifier
.width(width)
.height(height)
.background(
color = color,
shape = RoundedCornerShape(size = height / 2)
)
)
}
@PreviewsDayNight
@Composable
internal fun PlaceholderAtomPreview() = ElementPreview {
// Use a Red background to see the shape
Box(modifier = Modifier.background(color = Color.Red)) {
PlaceholderAtom(
width = 80.dp,
height = 12.dp
)
}
}
@@ -0,0 +1,48 @@
/*
* 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.designsystem.atomic.atoms
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@Composable
fun RedIndicatorAtom(
modifier: Modifier = Modifier,
size: Dp = 10.dp,
borderSize: Dp = 1.dp,
color: Color = ElementTheme.colors.bgCriticalPrimary,
) {
Box(
modifier = modifier
.size(size)
.border(borderSize, ElementTheme.colors.bgCanvasDefault, CircleShape)
.padding(borderSize / 2)
.clip(CircleShape)
.background(color)
)
}
@PreviewsDayNight
@Composable
internal fun RedIndicatorAtomPreview() = ElementPreview {
RedIndicatorAtom()
}
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.designsystem.atomic.atoms
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun RoomPreviewDescriptionAtom(
description: String,
modifier: Modifier = Modifier,
maxLines: Int = Int.MAX_VALUE,
) {
Text(
modifier = modifier,
text = description,
style = ElementTheme.typography.fontBodyMdRegular,
textAlign = TextAlign.Center,
color = ElementTheme.colors.textPrimary,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis,
)
}
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.designsystem.atomic.atoms
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun RoomPreviewSubtitleAtom(subtitle: String, modifier: Modifier = Modifier) {
Text(
modifier = modifier,
text = subtitle,
style = ElementTheme.typography.fontBodyLgRegular,
textAlign = TextAlign.Center,
color = ElementTheme.colors.textSecondary,
)
}
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.designsystem.atomic.atoms
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun RoomPreviewTitleAtom(
title: String,
modifier: Modifier = Modifier,
fontStyle: FontStyle? = null,
) {
Text(
modifier = modifier,
text = title,
style = ElementTheme.typography.fontHeadingLgBold,
textAlign = TextAlign.Center,
fontStyle = fontStyle,
color = ElementTheme.colors.textPrimary,
)
}
@@ -0,0 +1,109 @@
/*
* 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.designsystem.atomic.atoms
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
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.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
/**
* RoundedIconAtom is an atom which displays an icon inside a rounded container.
*
* @param modifier the modifier to apply to this layout
* @param size the size of the icon
* @param resourceId the resource id of the icon to display, exclusive with [imageVector]
* @param imageVector the image vector of the icon to display, exclusive with [resourceId]
* @param tint the tint to apply to the icon
* @param backgroundTint the tint to apply to the icon background
*/
@Composable
fun RoundedIconAtom(
modifier: Modifier = Modifier,
size: RoundedIconAtomSize = RoundedIconAtomSize.Big,
resourceId: Int? = null,
imageVector: ImageVector? = null,
tint: Color = ElementTheme.colors.iconSecondary,
backgroundTint: Color = ElementTheme.colors.temporaryColorBgSpecial,
) {
Box(
modifier = modifier
.size(size.toContainerSize())
.background(
color = backgroundTint,
shape = RoundedCornerShape(size.toCornerSize())
)
) {
Icon(
modifier = Modifier
.align(Alignment.Center)
.size(size.toIconSize()),
tint = tint,
resourceId = resourceId,
imageVector = imageVector,
contentDescription = null,
)
}
}
private fun RoundedIconAtomSize.toContainerSize(): Dp {
return when (this) {
RoundedIconAtomSize.Medium -> 30.dp
RoundedIconAtomSize.Big -> 36.dp
}
}
private fun RoundedIconAtomSize.toCornerSize(): Dp {
return when (this) {
RoundedIconAtomSize.Medium -> 8.dp
RoundedIconAtomSize.Big -> 8.dp
}
}
private fun RoundedIconAtomSize.toIconSize(): Dp {
return when (this) {
RoundedIconAtomSize.Medium -> 16.dp
RoundedIconAtomSize.Big -> 24.dp
}
}
@PreviewsDayNight
@Composable
internal fun RoundedIconAtomPreview() = ElementPreview {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
RoundedIconAtom(
size = RoundedIconAtomSize.Medium,
imageVector = CompoundIcons.HomeSolid(),
)
RoundedIconAtom(
size = RoundedIconAtomSize.Big,
imageVector = CompoundIcons.HomeSolid(),
)
}
}
enum class RoundedIconAtomSize {
Medium,
Big,
}
@@ -0,0 +1,82 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.designsystem.atomic.atoms
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.toggleable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
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.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
@Composable
fun SelectedIndicatorAtom(
checked: Boolean,
enabled: Boolean,
modifier: Modifier = Modifier,
) {
if (checked) {
Icon(
modifier = modifier.toggleable(
value = true,
role = Role.Companion.Checkbox,
enabled = enabled,
onValueChange = {},
),
imageVector = CompoundIcons.CheckCircleSolid(),
contentDescription = null,
tint = if (enabled) {
ElementTheme.colors.iconAccentPrimary
} else {
ElementTheme.colors.iconDisabled
},
)
} else {
Box(modifier)
}
}
@Composable
@PreviewsDayNight
internal fun SelectedIndicatorAtomPreview() = ElementPreview {
Column(
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
SelectedIndicatorAtom(
modifier = Modifier.size(24.dp),
checked = false,
enabled = false,
)
SelectedIndicatorAtom(
modifier = Modifier.size(24.dp),
checked = true,
enabled = false,
)
SelectedIndicatorAtom(
modifier = Modifier.size(24.dp),
checked = false,
enabled = true,
)
SelectedIndicatorAtom(
modifier = Modifier.size(24.dp),
checked = true,
enabled = true,
)
}
}
@@ -0,0 +1,51 @@
/*
* 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.designsystem.atomic.atoms
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.unreadIndicator
@Composable
fun UnreadIndicatorAtom(
modifier: Modifier = Modifier,
size: Dp = 12.dp,
color: Color = ElementTheme.colors.unreadIndicator,
isVisible: Boolean = true,
contentDescription: String? = null,
) {
Box(
modifier = modifier
.semantics {
contentDescription?.let { this.contentDescription = it }
}
.size(size)
.clip(CircleShape)
.background(if (isVisible) color else Color.Transparent)
)
}
@PreviewsDayNight
@Composable
internal fun UnreadIndicatorAtomPreview() = ElementPreview {
UnreadIndicatorAtom()
}
@@ -0,0 +1,48 @@
/*
* 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.designsystem.atomic.molecules
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.TextButton
@Composable
fun ButtonColumnMolecule(
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
) {
Column(
modifier = modifier
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
content()
}
}
@PreviewsDayNight
@Composable
internal fun ButtonColumnMoleculePreview() = ElementPreview {
ButtonColumnMolecule {
Button(text = "Button", onClick = {}, modifier = Modifier.fillMaxWidth())
OutlinedButton(text = "OutlinedButton", onClick = {}, modifier = Modifier.fillMaxWidth())
TextButton(text = "TextButton", onClick = {}, modifier = Modifier.fillMaxWidth())
}
}
@@ -0,0 +1,45 @@
/*
* 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.designsystem.atomic.molecules
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.TextButton
@Composable
fun ButtonRowMolecule(
modifier: Modifier = Modifier,
horizontalArrangement: Arrangement.Horizontal = Arrangement.SpaceBetween,
verticalAlignment: Alignment.Vertical = Alignment.Top,
content: @Composable RowScope.() -> Unit
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = horizontalArrangement,
verticalAlignment = verticalAlignment,
) {
content()
}
}
@PreviewsDayNight
@Composable
internal fun ButtonRowMoleculePreview() = ElementPreview {
ButtonRowMolecule {
TextButton(text = "Button 1", onClick = {})
TextButton(text = "Button 2", onClick = {})
}
}
@@ -0,0 +1,151 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.designsystem.atomic.molecules
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
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.AvatarType
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toAnnotatedString
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun ComposerAlertMolecule(
avatar: AvatarData?,
content: AnnotatedString,
onSubmitClick: () -> Unit,
modifier: Modifier = Modifier,
level: ComposerAlertLevel = ComposerAlertLevel.Default,
showIcon: Boolean = false,
submitText: String = stringResource(CommonStrings.action_ok),
) {
Column(
modifier.fillMaxWidth()
) {
val lineColor = when (level) {
ComposerAlertLevel.Default -> ElementTheme.colors.borderInfoSubtle
ComposerAlertLevel.Info -> ElementTheme.colors.borderInfoSubtle
ComposerAlertLevel.Critical -> ElementTheme.colors.borderCriticalSubtle
}
val startColor = when (level) {
ComposerAlertLevel.Default -> ElementTheme.colors.bgInfoSubtle
ComposerAlertLevel.Info -> ElementTheme.colors.bgInfoSubtle
ComposerAlertLevel.Critical -> ElementTheme.colors.bgCriticalSubtle
}
val textColor = when (level) {
ComposerAlertLevel.Default -> ElementTheme.colors.textPrimary
ComposerAlertLevel.Info -> ElementTheme.colors.textInfoPrimary
ComposerAlertLevel.Critical -> ElementTheme.colors.textCriticalPrimary
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.background(lineColor)
)
val brush = Brush.verticalGradient(
listOf(startColor, ElementTheme.colors.bgCanvasDefault),
)
Box(
modifier = Modifier
.background(brush)
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp)
) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
if (avatar != null) {
Avatar(
avatarData = avatar,
avatarType = AvatarType.User,
)
} else if (showIcon) {
val icon = when (level) {
ComposerAlertLevel.Default -> CompoundIcons.Info()
ComposerAlertLevel.Info -> CompoundIcons.Info()
ComposerAlertLevel.Critical -> CompoundIcons.Error()
}
val iconTint = when (level) {
ComposerAlertLevel.Default -> ElementTheme.colors.iconPrimary
ComposerAlertLevel.Info -> ElementTheme.colors.iconInfoPrimary
ComposerAlertLevel.Critical -> ElementTheme.colors.iconCriticalPrimary
}
Icon(
imageVector = icon,
tint = iconTint,
contentDescription = null,
)
}
Text(
text = content,
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyMdRegular,
color = textColor,
textAlign = TextAlign.Start,
)
}
Button(
text = submitText,
size = ButtonSize.Medium,
modifier = Modifier.fillMaxWidth(),
onClick = onSubmitClick,
)
}
}
}
}
enum class ComposerAlertLevel {
Default,
Info,
Critical
}
@PreviewsDayNight
@Composable
internal fun ComposerAlertMoleculePreview(
@PreviewParameter(ComposerAlertMoleculeParamsProvider::class) params: ComposerAlertMoleculeParams,
) = ElementPreview {
ComposerAlertMolecule(
avatar = params.avatar,
content = "Alices verified identity has changed. Learn more".toAnnotatedString(),
level = params.level,
showIcon = params.showIcon,
onSubmitClick = {},
)
}
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.designsystem.atomic.molecules
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
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.anAvatarData
internal data class ComposerAlertMoleculeParams(
val level: ComposerAlertLevel,
val avatar: AvatarData? = null,
val showIcon: Boolean = false,
)
internal class ComposerAlertMoleculeParamsProvider : PreviewParameterProvider<ComposerAlertMoleculeParams> {
private val allLevels = sequenceOf(
ComposerAlertLevel.Default,
ComposerAlertLevel.Info,
ComposerAlertLevel.Critical
)
override val values: Sequence<ComposerAlertMoleculeParams>
get() = allLevels.flatMap { level ->
sequenceOf(
ComposerAlertMoleculeParams(level = level),
ComposerAlertMoleculeParams(level = level, avatar = anAvatarData(size = AvatarSize.ComposerAlert)),
ComposerAlertMoleculeParams(level = level, showIcon = true),
)
}
}
@@ -0,0 +1,63 @@
/*
* 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.designsystem.atomic.molecules
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.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.placeholderBackground
@Composable
fun IconTitlePlaceholdersRowMolecule(
iconSize: Dp,
modifier: Modifier = Modifier,
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
verticalAlignment: Alignment.Vertical = Alignment.CenterVertically,
) {
Row(
modifier = modifier,
horizontalArrangement = horizontalArrangement,
verticalAlignment = verticalAlignment,
) {
Box(
modifier = Modifier
.size(iconSize)
.align(Alignment.CenterVertically)
.background(color = ElementTheme.colors.placeholderBackground, shape = CircleShape)
)
Spacer(modifier = Modifier.width(8.dp))
PlaceholderAtom(width = 20.dp, height = 7.dp)
Spacer(modifier = Modifier.width(7.dp))
PlaceholderAtom(width = 45.dp, height = 7.dp)
Spacer(modifier = Modifier.width(8.dp))
}
}
@PreviewsDayNight
@Composable
internal fun IconTitlePlaceholdersRowMoleculePreview() = ElementPreview {
IconTitlePlaceholdersRowMolecule(
iconSize = AvatarSize.TimelineRoom.dp,
)
}
@@ -0,0 +1,96 @@
/*
* 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.designsystem.atomic.molecules
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
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.atomic.atoms.BetaLabel
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
/**
* IconTitleSubtitleMolecule is a molecule which displays an icon, a title and a subtitle.
*
* @param title the title to display
* @param subTitle the subtitle to display
* @param iconStyle the style of the [BigIcon] to display
* @param modifier the modifier to apply to this layout
* @param showBetaLabel whether to show a "BETA" label next to the title
*/
@Composable
fun IconTitleSubtitleMolecule(
title: String,
subTitle: String?,
iconStyle: BigIcon.Style,
modifier: Modifier = Modifier,
showBetaLabel: Boolean = false,
) {
Column(modifier) {
BigIcon(
modifier = Modifier.align(Alignment.CenterHorizontally),
style = iconStyle,
)
Spacer(modifier = Modifier.height(16.dp))
FlowRow(
modifier = Modifier.fillMaxWidth(),
itemVerticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = title,
modifier = Modifier
.semantics {
heading()
},
textAlign = TextAlign.Center,
style = ElementTheme.typography.fontHeadingMdBold,
color = ElementTheme.colors.textPrimary,
)
if (showBetaLabel) {
BetaLabel()
}
}
if (subTitle != null) {
Spacer(Modifier.height(8.dp))
Text(
text = subTitle,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun IconTitleSubtitleMoleculePreview() = ElementPreview {
IconTitleSubtitleMolecule(
iconStyle = BigIcon.Style.Default(CompoundIcons.Chat()),
title = "Title",
subTitle = "Subtitle",
)
}
@@ -0,0 +1,104 @@
/*
* 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.designsystem.atomic.molecules
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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.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.components.Text
@Composable
fun InfoListItemMolecule(
message: @Composable () -> Unit,
position: InfoListItemPosition,
backgroundColor: Color,
modifier: Modifier = Modifier,
icon: @Composable () -> Unit = {},
) {
val radius = 14.dp
val backgroundShape = remember(position) {
when (position) {
InfoListItemPosition.Single -> RoundedCornerShape(radius)
InfoListItemPosition.Top -> RoundedCornerShape(topStart = radius, topEnd = radius)
InfoListItemPosition.Middle -> RoundedCornerShape(0.dp)
InfoListItemPosition.Bottom -> RoundedCornerShape(bottomStart = radius, bottomEnd = radius)
}
}
Row(
modifier = modifier
.fillMaxWidth()
.background(
color = backgroundColor,
shape = backgroundShape,
)
.padding(vertical = 12.dp, horizontal = 18.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
icon()
message()
}
}
@PreviewsDayNight
@Composable
internal fun InfoListItemMoleculePreview() {
ElementPreview {
val color = ElementTheme.colors.bgSubtleSecondary
Column(
modifier = Modifier.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
InfoListItemMolecule(
message = { Text("A single item") },
icon = { Icon(imageVector = CompoundIcons.InfoSolid(), contentDescription = null) },
position = InfoListItemPosition.Single,
backgroundColor = color,
)
InfoListItemMolecule(
message = { Text("A top item") },
icon = { Icon(imageVector = CompoundIcons.InfoSolid(), contentDescription = null) },
position = InfoListItemPosition.Top,
backgroundColor = color,
)
InfoListItemMolecule(
message = { Text("A middle item") },
icon = { Icon(imageVector = CompoundIcons.InfoSolid(), contentDescription = null) },
position = InfoListItemPosition.Middle,
backgroundColor = color,
)
InfoListItemMolecule(
message = { Text("A bottom item") },
icon = { Icon(imageVector = CompoundIcons.InfoSolid(), contentDescription = null) },
position = InfoListItemPosition.Bottom,
backgroundColor = color,
)
}
}
}
enum class InfoListItemPosition {
Top,
Middle,
Bottom,
Single,
}
@@ -0,0 +1,47 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.designsystem.atomic.molecules
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun InviteButtonsRowMolecule(
onAcceptClick: () -> Unit,
onDeclineClick: () -> Unit,
modifier: Modifier = Modifier,
declineText: String = stringResource(CommonStrings.action_decline),
acceptText: String = stringResource(CommonStrings.action_accept),
) {
Row(
modifier = modifier,
horizontalArrangement = spacedBy(12.dp)
) {
OutlinedButton(
text = declineText,
onClick = onDeclineClick,
size = ButtonSize.MediumLowPadding,
modifier = Modifier.weight(1f),
)
Button(
text = acceptText,
onClick = onAcceptClick,
size = ButtonSize.MediumLowPadding,
modifier = Modifier.weight(1f),
)
}
}
@@ -0,0 +1,34 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.designsystem.atomic.molecules
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.atomic.atoms.MatrixBadgeAtom
import kotlinx.collections.immutable.ImmutableList
@Composable
fun MatrixBadgeRowMolecule(
data: ImmutableList<MatrixBadgeAtom.MatrixBadgeData>,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.padding(start = 16.dp, end = 16.dp, top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
for (badge in data) {
MatrixBadgeAtom.View(badge)
}
}
}
@@ -0,0 +1,64 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.designsystem.atomic.molecules
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.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.components.Text
@Composable
fun MembersCountMolecule(
memberCount: Int,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.background(color = ElementTheme.colors.bgSubtleSecondary, shape = CircleShape)
.padding(start = 2.dp, end = 8.dp, top = 2.dp, bottom = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
imageVector = CompoundIcons.UserProfile(),
contentDescription = null,
tint = ElementTheme.colors.iconSecondary,
)
Text(
text = "$memberCount",
style = ElementTheme.typography.fontBodySmMedium,
color = ElementTheme.colors.textSecondary,
)
}
}
@PreviewsDayNight
@Composable
internal fun MembersCountMoleculePreview() = ElementPreview {
Column(
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
MembersCountMolecule(memberCount = 1)
MembersCountMolecule(memberCount = 888)
MembersCountMolecule(memberCount = 123_456)
}
}
@@ -0,0 +1,59 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.designsystem.atomic.molecules
import androidx.compose.foundation.border
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.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.modifiers.squareSize
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun NumberedListMolecule(
index: Int,
text: AnnotatedString,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ItemNumber(index = index)
Text(text = text, style = ElementTheme.typography.fontBodyMdRegular, color = ElementTheme.colors.textPrimary)
}
}
@Composable
private fun ItemNumber(
index: Int,
) {
val color = ElementTheme.colors.textSecondary
Box(
modifier = Modifier
.border(1.dp, color, CircleShape)
.squareSize()
) {
Text(
modifier = Modifier.padding(1.5.dp),
text = index.toString(),
style = ElementTheme.typography.fontBodySmRegular,
color = color,
textAlign = TextAlign.Center,
)
}
}
@@ -0,0 +1,52 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.designsystem.atomic.molecules
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Text
/**
* Display a label and a text in a column.
* @param label the label to display
* @param text the text to display
* @param modifier the modifier to apply to this layout
* @param spellText if true, the text will be spelled out in the content description for accessibility.
* Useful for deviceId for instance, that the screen reader will read as a list of letters instead of trying to read a
* word of random characters.
*/
@Composable
fun TextWithLabelMolecule(
label: String,
text: String,
modifier: Modifier = Modifier,
spellText: Boolean = false,
) {
Column(modifier = modifier) {
Text(
text = label,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
Text(
modifier = Modifier.semantics {
if (spellText) {
contentDescription = text.toList().joinToString()
}
},
text = text,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textPrimary,
)
}
}
@@ -0,0 +1,117 @@
/*
* 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.designsystem.atomic.organisms
import androidx.annotation.DrawableRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.size
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.atomic.molecules.InfoListItemMolecule
import io.element.android.libraries.designsystem.atomic.molecules.InfoListItemPosition
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.components.Text
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Composable
fun InfoListOrganism(
items: ImmutableList<InfoListItem>,
modifier: Modifier = Modifier,
backgroundColor: Color = ElementTheme.colors.bgSubtleSecondary,
iconTint: Color = LocalContentColor.current,
iconSize: Dp = 20.dp,
textStyle: TextStyle = LocalTextStyle.current,
textColor: Color = ElementTheme.colors.textPrimary,
verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(4.dp),
) {
Column(
modifier = modifier,
verticalArrangement = verticalArrangement,
) {
for ((index, item) in items.withIndex()) {
val position = when {
items.size == 1 -> InfoListItemPosition.Single
index == 0 -> InfoListItemPosition.Top
index == items.size - 1 -> InfoListItemPosition.Bottom
else -> InfoListItemPosition.Middle
}
InfoListItemMolecule(
message = {
if (item.message is AnnotatedString) {
Text(
text = item.message,
style = textStyle,
color = textColor,
)
} else {
Text(
text = item.message.toString(),
style = textStyle,
color = textColor,
)
}
},
icon = {
if (item.iconId != null) {
Icon(
modifier = Modifier.size(iconSize),
resourceId = item.iconId,
contentDescription = null,
tint = iconTint,
)
} else if (item.iconVector != null) {
Icon(
modifier = Modifier.size(iconSize),
imageVector = item.iconVector,
contentDescription = null,
tint = iconTint,
)
} else {
item.iconComposable()
}
},
position = position,
backgroundColor = backgroundColor,
)
}
}
}
data class InfoListItem(
val message: CharSequence,
@DrawableRes val iconId: Int? = null,
val iconVector: ImageVector? = null,
val iconComposable: @Composable () -> Unit = {},
)
@PreviewsDayNight
@Composable
internal fun InfoListOrganismPreview() = ElementPreview {
val items = persistentListOf(
InfoListItem(message = "A top item"),
InfoListItem(message = "A middle item"),
InfoListItem(message = "A bottom item"),
)
InfoListOrganism(
items = items,
)
}
@@ -0,0 +1,34 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.designsystem.atomic.organisms
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.atomic.molecules.NumberedListMolecule
import kotlinx.collections.immutable.ImmutableList
@Composable
fun NumberedListOrganism(
items: ImmutableList<AnnotatedString>,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
itemsIndexed(items) { index, item ->
NumberedListMolecule(index = index + 1, text = item)
}
}
}
@@ -0,0 +1,47 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.designsystem.atomic.organisms
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun RoomPreviewOrganism(
avatar: @Composable () -> Unit,
title: @Composable () -> Unit,
subtitle: @Composable () -> Unit,
modifier: Modifier = Modifier,
description: @Composable (() -> Unit)? = null,
memberCount: @Composable (() -> Unit)? = null,
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
avatar()
Spacer(modifier = Modifier.height(16.dp))
title()
Spacer(modifier = Modifier.height(8.dp))
subtitle()
if (memberCount != null) {
Spacer(modifier = Modifier.height(8.dp))
memberCount()
}
if (description != null) {
Spacer(modifier = Modifier.height(16.dp))
description()
}
}
}
@@ -0,0 +1,115 @@
/*
* 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.designsystem.atomic.pages
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
/**
* A Page with:
* - a top bar as TobAppBar with optional back button (displayed if [onBackClick] is not null)
* - a header, as IconTitleSubtitleMolecule
* - a content.
* - a footer, as ButtonColumnMolecule
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FlowStepPage(
iconStyle: BigIcon.Style,
title: String,
modifier: Modifier = Modifier,
isScrollable: Boolean = false,
onBackClick: (() -> Unit)? = null,
subTitle: String? = null,
buttons: @Composable ColumnScope.() -> Unit = {},
content: @Composable () -> Unit = {},
) {
BackHandler(enabled = onBackClick != null) {
onBackClick?.invoke()
}
HeaderFooterPage(
modifier = modifier,
isScrollable = isScrollable,
topBar = {
TopAppBar(
navigationIcon = {
if (onBackClick != null) {
BackButton(onClick = onBackClick)
}
},
title = {},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
)
},
header = {
IconTitleSubtitleMolecule(
modifier = Modifier.padding(bottom = 16.dp),
title = title,
subTitle = subTitle,
iconStyle = iconStyle,
)
},
content = content,
footer = {
ButtonColumnMolecule(
modifier = Modifier.padding(bottom = 20.dp)
) {
buttons()
}
}
)
}
@PreviewsDayNight
@Composable
internal fun FlowStepPagePreview() = ElementPreview {
FlowStepPage(
onBackClick = {},
title = "Title",
subTitle = "Subtitle",
iconStyle = BigIcon.Style.Default(CompoundIcons.Computer()),
buttons = {
TextButton(text = "A button", onClick = { })
Button(text = "Continue", onClick = { })
}
) {
Box(
Modifier
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "Content",
style = ElementTheme.typography.fontHeadingXlBold
)
}
}
}
@@ -0,0 +1,214 @@
/*
* 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.designsystem.atomic.pages
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
/**
* @param modifier Classical modifier.
* @param contentPadding padding values to apply to the content.
* @param containerColor color of the container. Set to [Color.Transparent] if you provide a background in the [modifier].
* @param isScrollable if the whole content should be scrollable.
* @param background optional background component.
* @param topBar optional topBar.
* @param header optional header.
* @param footer optional footer.
* @param content main content.
*/
@Suppress("NAME_SHADOWING")
@Composable
fun HeaderFooterPage(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(20.dp),
containerColor: Color = ElementTheme.colors.bgCanvasDefault,
isScrollable: Boolean = false,
background: @Composable () -> Unit = {},
topBar: @Composable () -> Unit = {},
header: @Composable () -> Unit = {},
footer: @Composable () -> Unit = {},
content: @Composable () -> Unit = {},
) {
Scaffold(
modifier = modifier,
topBar = topBar,
containerColor = containerColor,
) { insetsPadding ->
val layoutDirection = LocalLayoutDirection.current
val contentInsetsPadding = remember(insetsPadding, layoutDirection) {
PaddingValues(
start = insetsPadding.calculateStartPadding(layoutDirection),
top = insetsPadding.calculateTopPadding(),
end = insetsPadding.calculateEndPadding(layoutDirection),
)
}
val footerInsetsPadding = remember(insetsPadding, layoutDirection) {
PaddingValues(
start = insetsPadding.calculateStartPadding(layoutDirection),
end = insetsPadding.calculateEndPadding(layoutDirection),
bottom = insetsPadding.calculateBottomPadding(),
)
}
Box {
background()
// Render in a Column
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues = contentPadding)
.consumeWindowInsets(insetsPadding)
.imePadding(),
) {
// Content
Column(
modifier = Modifier
.fillMaxWidth()
.run {
if (isScrollable) {
verticalScroll(rememberScrollState())
// Make sure the scrollable content takes the full available height
.height(IntrinsicSize.Max)
} else {
Modifier
}
}
// Apply insets here so if the content is scrollable it can get below the top app bar if needed
.padding(contentInsetsPadding)
.weight(1f, fill = true),
) {
// Header
header()
Box {
content()
}
}
// Footer
Box(
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, top = 16.dp)
.fillMaxWidth()
.padding(footerInsetsPadding)
) {
footer()
}
}
}
}
}
@PreviewsDayNight
@Composable
internal fun HeaderFooterPagePreview() = ElementPreview {
HeaderFooterPage(
content = {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Text(
text = "Content",
style = ElementTheme.typography.fontHeadingXlBold
)
}
},
header = {
Box(
Modifier
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Text(
text = "Header",
style = ElementTheme.typography.fontHeadingXlBold
)
}
},
footer = {
Box(
Modifier
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Text(
text = "Footer",
style = ElementTheme.typography.fontHeadingXlBold
)
}
}
)
}
@PreviewsDayNight
@Composable
internal fun HeaderFooterPageScrollablePreview() = ElementPreview {
HeaderFooterPage(
content = {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Text(
text = "Content",
style = ElementTheme.typography.fontHeadingXlBold
)
}
},
header = {
Box(
Modifier
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Text(
text = "Header",
style = ElementTheme.typography.fontHeadingXlBold
)
}
},
footer = {
Box(
Modifier
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Text(
text = "Footer",
style = ElementTheme.typography.fontHeadingXlBold
)
}
},
isScrollable = true,
)
}
@@ -0,0 +1,114 @@
/*
* 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.designsystem.atomic.pages
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.R
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
/**
* Page for onboarding screens, with content and optional footer.
*
* Ref: https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=133-5427&t=5SHVppfYzjvkEywR-0
* @param modifier Classical modifier.
* @param renderBackground whether to render the background image or not.
* @param contentAlignment horizontal alignment of the contents.
* @param footer optional footer.
* @param content main content.
*/
@Composable
fun OnBoardingPage(
modifier: Modifier = Modifier,
renderBackground: Boolean = true,
contentAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
footer: @Composable () -> Unit = {},
content: @Composable () -> Unit = {},
) {
Box(
modifier = modifier
.fillMaxSize()
) {
// BG
if (renderBackground) {
Image(
modifier = Modifier
.fillMaxSize(),
painter = painterResource(id = R.drawable.onboarding_bg),
contentScale = ContentScale.Crop,
contentDescription = null,
)
}
Column(
modifier = Modifier
.fillMaxSize()
.systemBarsPadding()
.padding(all = 20.dp),
) {
// Content
Column(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
horizontalAlignment = contentAlignment,
) {
content()
}
// Footer
Box(modifier = Modifier.padding(horizontal = 16.dp)) {
footer()
}
}
}
}
@PreviewsDayNight
@Composable
internal fun OnBoardingPagePreview() = ElementPreview {
OnBoardingPage(
content = {
Box(
Modifier
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "Content",
style = ElementTheme.typography.fontHeadingXlBold
)
}
},
footer = {
Box(
Modifier
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Text(
text = "Footer",
style = ElementTheme.typography.fontHeadingXlBold
)
}
}
)
}
@@ -0,0 +1,155 @@
/*
* 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.designsystem.atomic.pages
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.BiasAbsoluteAlignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.element.android.compound.annotations.CoreColorToken
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.internal.DarkColorTokens
import io.element.android.compound.tokens.generated.internal.LightColorTokens
import io.element.android.libraries.designsystem.R
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.withColoredPeriod
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun SunsetPage(
isLoading: Boolean,
title: String,
subtitle: String,
modifier: Modifier = Modifier,
overallContent: @Composable () -> Unit,
) {
ElementTheme(
// Always use the opposite value of the current theme
darkTheme = ElementTheme.isLightTheme,
applySystemBarsUpdate = false,
) {
Box(
modifier = modifier.fillMaxSize()
) {
SunsetBackground()
Box(
modifier = Modifier
.fillMaxSize()
.systemBarsPadding()
.padding(horizontal = 16.dp, vertical = 16.dp)
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = BiasAbsoluteAlignment(
horizontalBias = 0f,
verticalBias = -0.05f
)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
color = ElementTheme.colors.iconPrimary
)
} else {
Spacer(modifier = Modifier.height(24.dp))
}
Spacer(modifier = Modifier.height(18.dp))
Text(
text = withColoredPeriod(title),
style = ElementTheme.typography.fontHeadingXlBold,
textAlign = TextAlign.Center,
color = ElementTheme.colors.textPrimary,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
modifier = Modifier.widthIn(max = 360.dp),
text = subtitle,
style = ElementTheme.typography.fontBodyLgRegular,
textAlign = TextAlign.Center,
color = ElementTheme.colors.textPrimary,
)
}
}
overallContent()
}
}
}
}
@OptIn(CoreColorToken::class)
@Composable
private fun SunsetBackground() {
Column(modifier = Modifier.fillMaxSize()) {
// The top background colors are the opposite of the current theme ones
val topBackgroundColor = if (ElementTheme.isLightTheme) {
DarkColorTokens.colorThemeBg
} else {
LightColorTokens.colorThemeBg
}
// The bottom background colors follow the current theme
val bottomBackgroundColor = if (ElementTheme.isLightTheme) {
LightColorTokens.colorThemeBg
} else {
// The dark background color doesn't 100% match the image, so we use a custom color
Color(0xFF121418)
}
Box(
modifier = Modifier
.fillMaxWidth()
.weight(0.3f)
.background(topBackgroundColor)
)
Image(
modifier = Modifier.fillMaxWidth(),
painter = painterResource(id = R.drawable.bg_migration),
contentScale = ContentScale.Crop,
contentDescription = null,
)
Box(
modifier = Modifier
.fillMaxWidth()
.weight(0.7f)
.background(bottomBackgroundColor)
)
}
}
@PreviewsDayNight
@Composable
internal fun SunsetPagePreview() = ElementPreview {
SunsetPage(
isLoading = true,
title = "Title with a green period.",
subtitle = "Subtitle",
overallContent = {}
)
}
@@ -0,0 +1,55 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.designsystem.background
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.center
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RadialGradientShader
import androidx.compose.ui.graphics.ShaderBrush
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
/**
* Light gradient background for Join room screens.
*/
@Composable
fun LightGradientBackground(
modifier: Modifier = Modifier,
backgroundColor: Color = ElementTheme.colors.bgCanvasDefault,
firstColor: Color = Color(0x1E0DBD8B),
secondColor: Color = Color(0x001273EB),
ratio: Float = 642 / 775f,
) {
Canvas(
modifier = modifier.fillMaxSize()
) {
val biggerDimension = size.width * 1.98f
val gradientShaderBrush = ShaderBrush(
RadialGradientShader(
colors = listOf(firstColor, secondColor),
center = size.center.copy(x = size.width * ratio, y = size.height * ratio),
radius = biggerDimension / 2f,
colorStops = listOf(0f, 0.95f)
)
)
drawRect(backgroundColor, size = size)
drawRect(brush = gradientShaderBrush, size = size)
}
}
@PreviewsDayNight
@Composable
internal fun LightGradientBackgroundPreview() = ElementPreview {
LightGradientBackground()
}
@@ -0,0 +1,84 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.designsystem.background
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.LinearGradientShader
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.utils.drawWithLayer
/**
* Gradient background for FTUE (onboarding) screens.
*/
@Suppress("ModifierMissing")
@Composable
fun OnboardingBackground() {
Box(
modifier = Modifier
.fillMaxSize()
.background(ElementTheme.colors.bgCanvasDefault)
) {
val isLightTheme = ElementTheme.isLightTheme
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(220.dp)
.align(Alignment.BottomCenter)
) {
val gradientBrush = ShaderBrush(
LinearGradientShader(
from = Offset(0f, size.height / 2f),
to = Offset(size.width, size.height / 2f),
colors = listOf(
Color(0xFF0DBDA8),
if (isLightTheme) Color(0xC90D5CBD) else Color(0xFF0D5CBD),
)
)
)
val eraseBrush = ShaderBrush(
LinearGradientShader(
from = Offset(size.width / 2f, 0f),
to = Offset(size.width / 2f, size.height * 2f),
colors = listOf(
Color(0xFF000000),
Color(0x00000000),
)
)
)
drawWithLayer {
drawRect(brush = gradientBrush, size = size)
drawRect(brush = gradientBrush, size = size, blendMode = BlendMode.Overlay)
drawRect(brush = eraseBrush, size = size, blendMode = BlendMode.DstOut)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun OnboardingBackgroundPreview() {
ElementPreview {
OnboardingBackground()
}
}
@@ -0,0 +1,26 @@
/*
* 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.designsystem.colors
import androidx.compose.runtime.Composable
import io.element.android.compound.theme.AvatarColors
import io.element.android.compound.theme.avatarColors
object AvatarColorsProvider {
@Composable
fun provide(id: String): AvatarColors {
return avatarColors().let { colors ->
colors[id.toHash(colors.size)]
}
}
}
internal fun String.toHash(maxSize: Int): Int {
return toList().sumOf { it.code } % maxSize
}
@@ -0,0 +1,45 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.designsystem.colors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.graphics.Color
import io.element.android.compound.theme.ElementTheme
@Composable
@ReadOnlyComposable
fun gradientActionColors(): List<Color> = listOf(
ElementTheme.colors.gradientActionStop1,
ElementTheme.colors.gradientActionStop2,
ElementTheme.colors.gradientActionStop3,
ElementTheme.colors.gradientActionStop4,
)
@Composable
@ReadOnlyComposable
fun gradientSubtleColors(): List<Color> = listOf(
ElementTheme.colors.gradientSubtleStop1,
ElementTheme.colors.gradientSubtleStop2,
ElementTheme.colors.gradientSubtleStop3,
ElementTheme.colors.gradientSubtleStop4,
ElementTheme.colors.gradientSubtleStop5,
ElementTheme.colors.gradientSubtleStop6,
)
@Composable
@ReadOnlyComposable
fun gradientInfoColors(): List<Color> = listOf(
ElementTheme.colors.gradientInfoStop1,
ElementTheme.colors.gradientInfoStop2,
ElementTheme.colors.gradientInfoStop3,
ElementTheme.colors.gradientInfoStop4,
ElementTheme.colors.gradientInfoStop5,
ElementTheme.colors.gradientInfoStop6,
)
@@ -0,0 +1,212 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.designsystem.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
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.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
/**
* Announcement component following design system https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=2002-2154.
*/
@Composable
fun Announcement(
title: String,
description: String?,
type: AnnouncementType,
modifier: Modifier = Modifier,
) {
when (type) {
is AnnouncementType.Informative -> InformativeAnnouncement(
title = title,
description = description,
isError = type.isCritical,
modifier = modifier,
)
is AnnouncementType.Actionable -> ActionableAnnouncement(
title = title,
description = description,
actionText = type.actionText,
onActionClick = type.onActionClick,
onDismissClick = type.onDismissClick,
modifier = modifier,
)
}
}
@Immutable
sealed interface AnnouncementType {
data class Informative(val isCritical: Boolean = false) : AnnouncementType
data class Actionable(
val actionText: String,
val onActionClick: () -> Unit,
val onDismissClick: (() -> Unit)?,
) : AnnouncementType
}
@Composable
private fun ActionableAnnouncement(
title: String,
description: String?,
actionText: String,
onActionClick: () -> Unit,
onDismissClick: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
AnnouncementSurface(modifier) {
Column {
TitleAndDescription(
title = title,
description = description,
trailingContent = onDismissClick?.let {
{
Icon(
modifier = Modifier.clickable(onClick = onDismissClick),
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_close)
)
}
}
)
Spacer(Modifier.height(16.dp))
Button(
text = actionText,
size = ButtonSize.Medium,
onClick = onActionClick,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
@Composable
private fun InformativeAnnouncement(
title: String,
description: String?,
isError: Boolean,
modifier: Modifier = Modifier,
) {
AnnouncementSurface(modifier = modifier) {
Row {
Icon(
imageVector = if (isError) CompoundIcons.ErrorSolid() else CompoundIcons.Info(),
tint = if (isError) ElementTheme.colors.iconCriticalPrimary else ElementTheme.colors.iconPrimary,
contentDescription = null,
)
Spacer(Modifier.width(12.dp))
TitleAndDescription(
title = title,
description = description,
titleColor = if (isError) ElementTheme.colors.textCriticalPrimary else ElementTheme.colors.textPrimary,
)
}
}
}
@Composable
private fun TitleAndDescription(
title: String,
description: String?,
modifier: Modifier = Modifier,
titleColor: Color = ElementTheme.colors.textPrimary,
descriptionColor: Color = ElementTheme.colors.textSecondary,
trailingContent: (@Composable () -> Unit)? = null,
) {
Column(modifier = modifier) {
Row {
Text(
text = title,
style = ElementTheme.typography.fontBodyLgMedium,
color = titleColor,
modifier = Modifier.weight(1f),
)
if (trailingContent != null) {
Spacer(Modifier.width(12.dp))
trailingContent()
}
}
if (description != null) {
Spacer(Modifier.height(4.dp))
Text(
text = description,
style = ElementTheme.typography.fontBodyMdRegular,
color = descriptionColor,
)
}
}
}
@Composable
private fun AnnouncementSurface(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(size = 12.dp),
color = ElementTheme.colors.bgSubtleSecondary
) {
Box(modifier = Modifier.padding(16.dp)) {
content()
}
}
}
@PreviewsDayNight
@Composable
internal fun AnnouncementPreview() = ElementPreview {
Column(
verticalArrangement = spacedBy(16.dp),
modifier = Modifier.padding(16.dp)
) {
Announcement(
title = "Headline",
description = "Text description goes here.",
type = AnnouncementType.Informative(isCritical = false),
)
Announcement(
title = "Headline",
description = "Text description goes here.",
type = AnnouncementType.Informative(isCritical = true),
)
Announcement(
title = "Headline",
description = "Text description goes here.",
type = AnnouncementType.Actionable(
actionText = "Label",
onActionClick = {},
onDismissClick = {},
),
)
}
}
@@ -0,0 +1,83 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.designsystem.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
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.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.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
@Suppress("ModifierMissing")
@Composable
fun Badge(
text: String,
icon: ImageVector,
backgroundColor: Color,
textColor: Color,
iconColor: Color,
shape: Shape = RoundedCornerShape(50),
borderStroke: BorderStroke? = null,
tintIcon: Boolean = true,
) {
Surface(
color = backgroundColor,
contentColor = textColor,
border = borderStroke,
shape = shape,
) {
Row(
modifier = Modifier.padding(start = 8.dp, end = 12.dp, top = 4.5.dp, bottom = 4.5.dp),
horizontalArrangement = Arrangement.spacedBy(5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier.size(16.dp),
imageVector = icon,
contentDescription = null,
tint = if (tintIcon) iconColor else LocalContentColor.current,
)
Text(
text = text,
style = ElementTheme.typography.fontBodySmRegular,
color = textColor,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun BadgePreview() {
ElementPreview {
Badge(
text = "Trusted",
icon = CompoundIcons.Verified(),
backgroundColor = ElementTheme.colors.bgBadgeAccent,
textColor = ElementTheme.colors.textBadgeAccent,
iconColor = ElementTheme.colors.textBadgeAccent,
)
}
}
@@ -0,0 +1,204 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.designsystem.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CatchingPokemon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
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.res.stringResource
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.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.ui.strings.CommonStrings
/**
* Compound component that display a big icon centered in a rounded square.
* Figma: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=1960-553&node-type=frame&m=dev
*/
object BigIcon {
/**
* The style of the [BigIcon].
*/
@Immutable
sealed interface Style {
/**
* The default style.
*
* @param vectorIcon the [ImageVector] to display
* @param contentDescription the content description of the icon, if any. It defaults to `null`
* @param useCriticalTint whether the icon and background should be rendered using critical tint
* @param usePrimaryTint whether the icon should be rendered using primary tint
*/
data class Default(
val vectorIcon: ImageVector,
val contentDescription: String? = null,
val useCriticalTint: Boolean = false,
val usePrimaryTint: Boolean = false,
) : Style
/**
* An alert style with a transparent background.
*/
data object Alert : Style
/**
* An alert style with a tinted background.
*/
data object AlertSolid : Style
/**
* A success style with a transparent background.
*/
data object Success : Style
/**
* A success style with a tinted background.
*/
data object SuccessSolid : Style
/**
* A loading style with the default background color.
*/
data object Loading : Style
}
/**
* Display a [BigIcon].
*
* @param style the style of the icon
* @param modifier the modifier to apply to this layout
*/
@Composable
operator fun invoke(
style: Style,
modifier: Modifier = Modifier,
) {
val backgroundColor = when (style) {
is Style.Default -> if (style.useCriticalTint) {
ElementTheme.colors.bgCriticalSubtle
} else {
ElementTheme.colors.bgSubtleSecondary
}
Style.Alert,
Style.Success -> Color.Transparent
Style.AlertSolid -> ElementTheme.colors.bgCriticalSubtle
Style.SuccessSolid -> ElementTheme.colors.bgSuccessSubtle
Style.Loading -> ElementTheme.colors.bgSubtleSecondary
}
Box(
modifier = modifier
.size(64.dp)
.clip(RoundedCornerShape(14.dp))
.background(backgroundColor),
contentAlignment = Alignment.Center,
) {
if (style is Style.Loading) {
CircularProgressIndicator(
modifier = Modifier.size(27.dp),
color = ElementTheme.colors.iconSecondary,
trackColor = Color.Transparent,
strokeWidth = 3.dp,
)
} else {
val icon = when (style) {
is Style.Default -> style.vectorIcon
Style.Alert,
Style.AlertSolid -> CompoundIcons.ErrorSolid()
Style.Success,
Style.SuccessSolid -> CompoundIcons.CheckCircleSolid()
Style.Loading -> error("This should never be reached")
}
val contentDescription = when (style) {
is Style.Default -> style.contentDescription
Style.Alert,
Style.AlertSolid -> stringResource(CommonStrings.common_error)
Style.Success,
Style.SuccessSolid -> stringResource(CommonStrings.common_success)
Style.Loading -> error("This should never be reached")
}
val iconTint = when (style) {
is Style.Default -> if (style.useCriticalTint) {
ElementTheme.colors.iconCriticalPrimary
} else if (style.usePrimaryTint) {
ElementTheme.colors.iconPrimary
} else {
ElementTheme.colors.iconSecondary
}
Style.Alert,
Style.AlertSolid -> ElementTheme.colors.iconCriticalPrimary
Style.Success,
Style.SuccessSolid -> ElementTheme.colors.iconSuccessPrimary
Style.Loading -> error("This should never be reached")
}
Icon(
modifier = Modifier.size(32.dp),
tint = iconTint,
imageVector = icon,
contentDescription = contentDescription
)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun BigIconPreview() = ElementPreview {
LazyVerticalGrid(
modifier = Modifier
.fillMaxSize()
.padding(10.dp),
columns = GridCells.Adaptive(minSize = 64.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(BigIconStyleProvider().values.toList()) { style ->
Box(
contentAlignment = Alignment.Center
) {
BigIcon(style = style)
}
}
}
}
internal class BigIconStyleProvider : PreviewParameterProvider<BigIcon.Style> {
override val values: Sequence<BigIcon.Style>
get() = sequenceOf(
BigIcon.Style.Default(Icons.Filled.CatchingPokemon),
BigIcon.Style.Alert,
BigIcon.Style.AlertSolid,
BigIcon.Style.Default(Icons.Filled.CatchingPokemon, useCriticalTint = true),
BigIcon.Style.Success,
BigIcon.Style.SuccessSolid,
BigIcon.Style.Loading,
)
}
@@ -0,0 +1,183 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-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.designsystem.components
import android.text.SpannableString
import android.text.style.URLSpan
import android.text.util.Linkify
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.ParagraphStyle
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.text.util.LinkifyCompat
import io.element.android.compound.theme.LinkColor
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.theme.components.Text
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
import timber.log.Timber
const val LINK_TAG = "URL"
@Composable
fun ClickableLinkText(
text: String,
interactionSource: MutableInteractionSource,
modifier: Modifier = Modifier,
linkify: Boolean = true,
linkAnnotationTag: String = LINK_TAG,
onClick: () -> Unit = {},
onLongClick: () -> Unit = {},
style: TextStyle = LocalTextStyle.current,
color: Color = Color.Unspecified,
inlineContent: ImmutableMap<String, InlineTextContent> = persistentMapOf(),
) {
ClickableLinkText(
annotatedString = AnnotatedString(text),
interactionSource = interactionSource,
modifier = modifier,
linkify = linkify,
linkAnnotationTag = linkAnnotationTag,
onClick = onClick,
onLongClick = onLongClick,
style = style,
color = color,
inlineContent = inlineContent,
)
}
@Composable
fun ClickableLinkText(
annotatedString: AnnotatedString,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
linkify: Boolean = true,
linkAnnotationTag: String = LINK_TAG,
onClick: () -> Unit = {},
onLongClick: () -> Unit = {},
style: TextStyle = LocalTextStyle.current,
color: Color = Color.Unspecified,
inlineContent: ImmutableMap<String, InlineTextContent> = persistentMapOf(),
) {
@Suppress("NAME_SHADOWING")
val annotatedString = remember(annotatedString) {
if (linkify) {
annotatedString.linkify(SpanStyle(color = LinkColor))
} else {
annotatedString
}
}
val uriHandler = LocalUriHandler.current
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
val pressIndicator = Modifier.pointerInput(onClick) {
detectTapGestures(
onPress = { offset: Offset ->
val pressInteraction = PressInteraction.Press(offset)
interactionSource.emit(pressInteraction)
val isReleased = tryAwaitRelease()
if (isReleased) {
interactionSource.emit(PressInteraction.Release(pressInteraction))
} else {
interactionSource.emit(PressInteraction.Cancel(pressInteraction))
}
},
onLongPress = {
onLongClick()
}
) { offset ->
layoutResult.value?.let { layoutResult ->
val position = layoutResult.getOffsetForPosition(offset)
val linkUrlAnnotations = annotatedString.getLinkAnnotations(position, position)
.map { AnnotatedString.Range(it.item, it.start, it.end, linkAnnotationTag) }
val linkStringAnnotations = linkUrlAnnotations +
annotatedString.getStringAnnotations(linkAnnotationTag, position, position)
if (linkStringAnnotations.isEmpty()) {
onClick()
} else {
when (val annotation = linkStringAnnotations.first().item) {
is LinkAnnotation.Url -> uriHandler.openUri(annotation.url)
is String -> uriHandler.openUri(annotation)
else -> Timber.e("Unknown link annotation: $annotation")
}
}
}
}
}
Text(
text = annotatedString,
modifier = modifier.then(pressIndicator),
style = style,
color = color,
onTextLayout = {
layoutResult.value = it
},
inlineContent = inlineContent,
)
}
fun AnnotatedString.linkify(linkStyle: SpanStyle): AnnotatedString {
val original = this
val spannable = SpannableString.valueOf(this.text)
LinkifyCompat.addLinks(spannable, Linkify.WEB_URLS or Linkify.PHONE_NUMBERS or Linkify.EMAIL_ADDRESSES)
val spans = spannable.getSpans(0, spannable.length, URLSpan::class.java)
return buildAnnotatedString {
append(original)
for (span in spans) {
val start = spannable.getSpanStart(span)
val end = spannable.getSpanEnd(span)
if (original.getLinkAnnotations(start, end).isEmpty() && original.getStringAnnotations("URL", start, end).isEmpty()) {
// Prevent linkifying domains in user or room handles (@user:domain.com, #room:domain.com)
if (start > 0 && !spannable[start - 1].isWhitespace()) continue
addStyle(
start = start,
end = end,
style = linkStyle,
)
addStringAnnotation(
tag = LINK_TAG,
annotation = span.url,
start = start,
end = end
)
}
}
}
}
@Preview(group = PreviewGroup.Text)
@Composable
internal fun ClickableLinkTextPreview() = ElementThemedPreview {
ClickableLinkText(
annotatedString = AnnotatedString("Hello", ParagraphStyle()),
linkAnnotationTag = "",
onClick = {},
onLongClick = {},
interactionSource = remember { MutableInteractionSource() },
)
}
@@ -0,0 +1,50 @@
/*
* 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.designsystem.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
/**
* Used to create a column where all children have the same width.
* It will first measure all children, get the largest width and re-measure all children with this width as the minWidth.
*
* *Note*: If all children already have the same width, it skips the 2nd measuring and acts like a normal Column.
*/
@Composable
fun EqualWidthColumn(
modifier: Modifier = Modifier,
spacing: Dp = 0.dp,
content: @Composable () -> Unit
) {
SubcomposeLayout(modifier = modifier) { constraints ->
val measurables = subcompose(0, content).map { it.measure(constraints) }
val maxWidth = measurables.maxOf { it.width }
val newConstraints = constraints.copy(minWidth = maxWidth)
val newMeasurables = if (measurables.all { it.width == maxWidth }) {
// Skip re-measuring if all children have the same width
measurables
} else {
// Re-measure with the largest width as the minWidth to have all children constrained to the same width
subcompose(1, content).map { it.measure(newConstraints) }
}
val totalHeight = (newMeasurables.sumOf { it.height } + spacing.toPx() * (newMeasurables.size - 1)).roundToInt()
layout(maxWidth, totalHeight) {
var yPosition = 0
newMeasurables.forEach { measurable ->
measurable.placeRelative(0, yPosition)
yPosition += measurable.height + spacing.roundToPx()
}
}
}
}
@@ -0,0 +1,293 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.designsystem.components
import android.annotation.SuppressLint
import android.content.Context
import android.view.MotionEvent
import android.view.ViewGroup
import android.widget.EditText
import androidx.appcompat.app.ActionBar.LayoutParams
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
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.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import kotlinx.coroutines.launch
import timber.log.Timber
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
@Composable
fun ExpandableBottomSheetLayout(
sheetDragHandle: @Composable BoxScope.(toggleAction: () -> Unit) -> Unit,
bottomSheetContent: @Composable ColumnScope.() -> Unit,
state: ExpandableBottomSheetLayoutState,
maxBottomSheetContentHeight: Dp,
isSwipeGestureEnabled: Boolean,
modifier: Modifier = Modifier,
sheetShape: Shape = RectangleShape,
backgroundColor: Color = Color.Transparent,
content: @Composable () -> Unit,
) {
var minBottomContentHeightPx by remember { mutableIntStateOf(0) }
var currentBottomContentHeightPx by remember { mutableIntStateOf(minBottomContentHeightPx) }
val maxBottomContentHeightPx = with(LocalDensity.current) { maxBottomSheetContentHeight.roundToPx() }
var calculatedMaxBottomContentHeightPx by remember(maxBottomContentHeightPx) { mutableIntStateOf(maxBottomContentHeightPx) }
val animatable = remember { Animatable(0f) }
fun calculatePercentage(currentPos: Int, minPos: Int, maxPos: Int): Float {
val currentProgress = currentPos - minPos
if (currentProgress < 0) {
Timber.e("Invalid current progress: $currentProgress, minPos: $minPos, maxPos: $maxPos")
return 0f
}
val total = (maxPos - minPos).toFloat()
if (total <= 0) {
Timber.e("Invalid total space: $total, minPos: $minPos, maxPos: $maxPos")
return 0f
}
return currentProgress / total
}
LaunchedEffect(animatable.value) {
if (animatable.isRunning && animatable.value != animatable.targetValue) {
currentBottomContentHeightPx = animatable.value.roundToInt()
state.internalDraggingPercentage = calculatePercentage(
currentPos = currentBottomContentHeightPx,
minPos = minBottomContentHeightPx,
maxPos = calculatedMaxBottomContentHeightPx,
)
}
}
val coroutineScope = rememberCoroutineScope()
val composables = @Composable {
content()
Column(
modifier = Modifier
.clip(sheetShape)
.background(backgroundColor)
.run {
if (isSwipeGestureEnabled) {
pointerInput(maxBottomSheetContentHeight) {
detectVerticalDragGestures(
onVerticalDrag = { _, dragAmount ->
val calculatedHeight = max(minBottomContentHeightPx, currentBottomContentHeightPx - dragAmount.roundToInt())
val newHeight = min(calculatedMaxBottomContentHeightPx, calculatedHeight)
state.internalPosition = when (newHeight) {
calculatedMaxBottomContentHeightPx -> ExpandableBottomSheetLayoutState.Position.EXPANDED
minBottomContentHeightPx -> ExpandableBottomSheetLayoutState.Position.COLLAPSED
else -> ExpandableBottomSheetLayoutState.Position.DRAGGING
}
state.internalDraggingPercentage = calculatePercentage(
currentPos = newHeight,
minPos = minBottomContentHeightPx,
maxPos = calculatedMaxBottomContentHeightPx,
)
currentBottomContentHeightPx = newHeight
},
onDragEnd = {
coroutineScope.launch {
val middle = (calculatedMaxBottomContentHeightPx + minBottomContentHeightPx) / 2
animatable.snapTo(currentBottomContentHeightPx.toFloat())
val destination = if (currentBottomContentHeightPx > middle) {
state.internalPosition = ExpandableBottomSheetLayoutState.Position.EXPANDED
calculatedMaxBottomContentHeightPx
} else {
state.internalPosition = ExpandableBottomSheetLayoutState.Position.COLLAPSED
minBottomContentHeightPx
}.toFloat()
animatable.animateTo(destination)
}
}
)
}
} else {
this
}
}
) {
Box(Modifier.fillMaxWidth()) {
sheetDragHandle {
coroutineScope.launch {
val destination = if (state.position == ExpandableBottomSheetLayoutState.Position.EXPANDED) {
state.internalPosition = ExpandableBottomSheetLayoutState.Position.COLLAPSED
minBottomContentHeightPx.toFloat()
} else {
state.internalPosition = ExpandableBottomSheetLayoutState.Position.EXPANDED
calculatedMaxBottomContentHeightPx.toFloat()
}
animatable.snapTo(currentBottomContentHeightPx.toFloat())
animatable.animateTo(destination)
}
}
}
bottomSheetContent()
}
}
Layout(
content = composables,
modifier = modifier,
measurePolicy = { measurables, constraints ->
calculatedMaxBottomContentHeightPx = min(constraints.maxHeight, maxBottomContentHeightPx)
val contentMeasurables = measurables[0]
val bottomContentMeasurables = measurables[1]
val minIntrinsicHeight = bottomContentMeasurables.minIntrinsicHeight(constraints.maxWidth)
val lastMinBottomContentHeightPx = minBottomContentHeightPx
minBottomContentHeightPx = min(minIntrinsicHeight, calculatedMaxBottomContentHeightPx)
val isExpanded = state.position == ExpandableBottomSheetLayoutState.Position.EXPANDED
if (lastMinBottomContentHeightPx != minBottomContentHeightPx && !isExpanded) {
currentBottomContentHeightPx = minBottomContentHeightPx
}
val measuredBottomContent = bottomContentMeasurables.measure(
Constraints.fixed(
constraints.maxWidth,
max(minBottomContentHeightPx, currentBottomContentHeightPx)
)
)
var remainingHeight = constraints.maxHeight - currentBottomContentHeightPx
if (remainingHeight < 0) {
Timber.e("Remaining height is negative: $remainingHeight, resetting to 0")
remainingHeight = 0
}
val contentPlaceable = contentMeasurables.measure(
Constraints.fixed(constraints.maxWidth, remainingHeight)
)
layout(constraints.maxWidth, constraints.maxHeight) {
contentPlaceable.place(0, 0)
measuredBottomContent.place(IntOffset(0, constraints.maxHeight - currentBottomContentHeightPx), zIndex = 10f)
}
}
)
}
@Preview(showBackground = true)
@Composable
@Suppress("UnusedPrivateMember")
internal fun ExpandableBottomSheetLayoutPreview() {
ExpandableBottomSheetLayout(
sheetDragHandle = {
Box(
modifier =
Modifier
.padding(vertical = 6.dp)
.clip(RoundedCornerShape(6.dp))
.align(Alignment.Center)
.size(100.dp, 8.dp)
.background(Color.Gray)
)
},
content = {
Box(Modifier.fillMaxWidth()) {
Text("This is the main content", modifier = Modifier.padding(16.dp).align(Alignment.Center))
}
},
bottomSheetContent = {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f, fill = true)
.padding(horizontal = 10.dp)
.clip(RoundedCornerShape(10.dp))
.background(Color.Blue)
) {
AndroidView(
modifier = Modifier
.fillMaxWidth()
.background(Color.LightGray),
factory = { context ->
PreviewEditText(context).apply {
val initialText = "1111\n2222\n3333\n4444\n5555\n6666"
setText(initialText)
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
}
}
)
}
Text("A footer", modifier = Modifier.padding(vertical = 6.dp, horizontal = 16.dp))
},
maxBottomSheetContentHeight = 1800.dp,
isSwipeGestureEnabled = true,
backgroundColor = Color.White,
state = rememberExpandableBottomSheetLayoutState(),
sheetShape = RoundedCornerShape(20.dp, 20.dp, 0.dp, 0.dp),
modifier = Modifier
.windowInsetsPadding(WindowInsets.statusBars)
.windowInsetsPadding(WindowInsets.ime)
.fillMaxSize()
.background(Color.Red.copy(alpha = 0.2f)),
)
}
// This is just for preview purposes
@SuppressLint("AppCompatCustomView")
private class PreviewEditText(context: Context) : EditText(context) {
override fun onAttachedToWindow() {
super.onAttachedToWindow()
parent?.requestDisallowInterceptTouchEvent(true)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent?): Boolean {
super.onTouchEvent(event)
parent?.requestDisallowInterceptTouchEvent(true)
return true
}
override fun dispatchTouchEvent(event: MotionEvent?): Boolean {
return super.dispatchTouchEvent(event)
}
}
@@ -0,0 +1,63 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.designsystem.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
/**
* Creates and remembers an [ExpandableBottomSheetLayoutState].
*/
@Composable
fun rememberExpandableBottomSheetLayoutState(): ExpandableBottomSheetLayoutState {
return remember { ExpandableBottomSheetLayoutState() }
}
/**
* State for the [ExpandableBottomSheetLayout].
*
* This state holds the current position of the bottom sheet layout and the percentage of the layout that is being dragged.
*/
@Stable
class ExpandableBottomSheetLayoutState {
internal var internalPosition: Position by mutableStateOf(Position.COLLAPSED)
internal var internalDraggingPercentage: Float by mutableFloatStateOf(
if (internalPosition == Position.EXPANDED) 1f else 0f
)
/**
* The current position of the bottom sheet layout.
*/
val position get() = internalPosition
/**
* The percentage of the bottom sheet layout that is currently being dragged.
* This value ranges from `0f` for [Position.COLLAPSED] to `1f` for [Position.EXPANDED].
*/
val draggingPercentage = internalDraggingPercentage
/**
* The position of the bottom sheet layout.
*/
enum class Position {
/** The bottom sheet is collapsed to its minimum visible height. */
COLLAPSED,
/** The bottom sheet is being dragged by user input. */
DRAGGING,
/** The bottom sheet is expanded to its maximum visible height. */
EXPANDED
}
}
@@ -0,0 +1,55 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-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.designsystem.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.theme.components.Checkbox
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun LabelledCheckbox(
checked: Boolean,
text: String,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
Row(
modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange,
enabled = enabled,
)
Text(
text = text,
color = ElementTheme.colors.textPrimary,
)
}
}
@Preview(group = PreviewGroup.Toggles)
@Composable
internal fun LabelledCheckboxPreview() = ElementThemedPreview {
LabelledCheckbox(
checked = true,
onCheckedChange = {},
text = "Some text",
)
}
@@ -0,0 +1,48 @@
/*
* 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.designsystem.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.R
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
@Composable
fun PinIcon(
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.background(ElementTheme.colors.bgSubtlePrimary)
) {
Icon(
modifier = Modifier
.align(Alignment.Center)
.width(22.dp),
resourceId = R.drawable.pin,
contentDescription = null,
tint = Color.Unspecified,
)
}
}
@PreviewsDayNight
@Composable
internal fun PinIconPreview() = ElementPreview {
PinIcon()
}
@@ -0,0 +1,197 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-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.designsystem.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.DialogPreview
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.ui.strings.CommonStrings
import timber.log.Timber
/**
* A progress dialog, with a spinner, and optional text content.
*
* @param modifier
* @param text Optional text to show under the spinner.
* @param type
* @param properties
* @param showCancelButton
* @param onDismissRequest
* @param content Optional additional content to show under the spinner, and above the cancel button (if shown). If both `text` and `content` are supplied,
* `text` is shown above `content`.
*/
@Composable
fun ProgressDialog(
modifier: Modifier = Modifier,
text: String? = null,
type: ProgressDialogType = ProgressDialogType.Indeterminate,
properties: DialogProperties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false),
showCancelButton: Boolean = false,
onDismissRequest: () -> Unit = {},
content: @Composable () -> Unit = {},
) {
DisposableEffect(Unit) {
onDispose {
Timber.v("OnDispose progressDialog")
}
}
Dialog(
onDismissRequest = onDismissRequest,
properties = properties,
) {
ProgressDialogContent(
modifier = modifier,
text = text,
showCancelButton = showCancelButton,
onCancelClick = onDismissRequest,
progressIndicator = {
when (type) {
is ProgressDialogType.Indeterminate -> {
CircularProgressIndicator(
color = ElementTheme.colors.iconPrimary
)
}
is ProgressDialogType.Determinate -> {
CircularProgressIndicator(
progress = { type.progress },
color = ElementTheme.colors.iconPrimary
)
}
}
},
content,
)
}
}
@Immutable
sealed interface ProgressDialogType {
data class Determinate(val progress: Float) : ProgressDialogType
data object Indeterminate : ProgressDialogType
}
@Composable
private fun ProgressDialogContent(
modifier: Modifier = Modifier,
text: String? = null,
showCancelButton: Boolean = false,
onCancelClick: () -> Unit = {},
progressIndicator: @Composable () -> Unit = {
CircularProgressIndicator(
color = ElementTheme.colors.iconPrimary
)
},
content: @Composable () -> Unit,
) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(8.dp)
)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(top = 38.dp, bottom = 32.dp, start = 40.dp, end = 40.dp)
) {
progressIndicator()
if (!text.isNullOrBlank()) {
Spacer(modifier = Modifier.height(22.dp))
Text(
text = text,
color = ElementTheme.colors.textPrimary,
)
}
content()
if (showCancelButton) {
Spacer(modifier = Modifier.height(24.dp))
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.BottomEnd
) {
TextButton(
text = stringResource(id = CommonStrings.action_cancel),
onClick = onCancelClick,
)
}
}
}
}
}
@Preview(group = PreviewGroup.Dialogs)
@Composable
internal fun ProgressDialogContentPreview() = ElementThemedPreview {
DialogPreview {
ProgressDialogContent(text = "test dialog content", showCancelButton = true, content = {})
}
}
@PreviewsDayNight
@Composable
internal fun ProgressDialogPreview() = ElementPreview {
ProgressDialog(text = "test dialog content", showCancelButton = true)
}
@PreviewsDayNight
@Composable
internal fun ProgressDialogWithContentPreview() = ElementPreview {
ProgressDialog(showCancelButton = true) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Heading",
color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontHeadingSmMedium,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Subtext",
color = ElementTheme.colors.textSecondary,
style = MaterialTheme.typography.bodyMedium,
)
}
}
@PreviewsDayNight
@Composable
internal fun ProgressDialogWithTextAndContentPreview() = ElementPreview {
ProgressDialog(text = "Text Content") {
Text(
text = "blah blah",
color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontHeadingSmMedium,
)
}
}
@@ -0,0 +1,68 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.designsystem.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.datasource.LoremIpsum
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.Text
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SimpleModalBottomSheet(
title: String,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
modifier = modifier,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Text(
title,
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,
)
Spacer(Modifier.height(8.dp))
content()
}
}
}
@PreviewsDayNight
@Composable
internal fun SimpleModalBottomSheetPreview() = ElementPreview {
SimpleModalBottomSheet(title = "A title", onDismiss = {}) {
Text(
text = LoremIpsum(20).values.first(),
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodyMdRegular,
)
}
}
@@ -0,0 +1,55 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.designsystem.components
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Surface
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.UiComposable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.Layout
import io.element.android.compound.theme.ElementTheme
/**
* A layout that measures its content to set the height offset limit of a [TopAppBarScrollBehavior].
* It places the content according to the current height offset of the scroll behavior.
*
*/
@ExperimentalMaterial3Api
@Composable
fun TopAppBarScrollBehaviorLayout(
scrollBehavior: TopAppBarScrollBehavior,
modifier: Modifier = Modifier,
backgroundColor: Color = ElementTheme.colors.bgCanvasDefault,
contentColor: Color = contentColorFor(backgroundColor),
content: @Composable @UiComposable () -> Unit,
) {
Surface(
modifier = modifier,
color = backgroundColor,
contentColor = contentColor
) {
Layout(
content = content,
measurePolicy = { measurables, constraints ->
val placeable = measurables.first().measure(constraints)
val contentHeight = placeable.height.toFloat()
scrollBehavior.state.heightOffsetLimit = -contentHeight
val heightOffset = scrollBehavior.state.heightOffset
val layoutHeight = (contentHeight + heightOffset).toInt()
layout(placeable.width, layoutHeight) {
placeable.place(0, heightOffset.toInt())
}
}
)
}
}
@@ -0,0 +1,23 @@
/*
* 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.designsystem.components.async
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
open class AsyncActionProvider : PreviewParameterProvider<AsyncAction<Unit>> {
override val values: Sequence<AsyncAction<Unit>>
get() = sequenceOf(
AsyncAction.Uninitialized,
AsyncAction.ConfirmingNoParams,
AsyncAction.Loading,
AsyncAction.Failure(Exception("An error occurred")),
AsyncAction.Success(Unit),
)
}
@@ -0,0 +1,100 @@
/*
* 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.designsystem.components.async
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialogDefaults
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
/**
* Render an AsyncAction object.
* - If Success, invoke the callback [onSuccess], only once.
* - If Failure, display a dialog with the error, which can be transformed, using [errorMessage]. When
* closed, [onErrorDismiss] will be invoked. If [onRetry] is not null, a retry button will be displayed.
* - When loading, display a loading dialog using [progressDialog]. Pass empty lambda to disable.
*/
@Suppress("ContentSlotReused") // False positive, the lambdas don't add composable views
@Composable
fun <T> AsyncActionView(
async: AsyncAction<T>,
onSuccess: (T) -> Unit,
onErrorDismiss: () -> Unit,
confirmationDialog: @Composable (AsyncAction.Confirming) -> Unit = { },
progressDialog: @Composable () -> Unit = { AsyncActionViewDefaults.ProgressDialog() },
errorTitle: @Composable (Throwable) -> String = { ErrorDialogDefaults.title },
errorMessage: @Composable (Throwable) -> String = { it.message ?: it.toString() },
onRetry: (() -> Unit)? = null,
) {
when (async) {
AsyncAction.Uninitialized -> Unit
is AsyncAction.Confirming -> confirmationDialog(async)
is AsyncAction.Loading -> progressDialog()
is AsyncAction.Failure -> {
if (onRetry == null) {
ErrorDialog(
title = errorTitle(async.error),
content = errorMessage(async.error),
onSubmit = onErrorDismiss
)
} else {
RetryDialog(
title = errorTitle(async.error),
content = errorMessage(async.error),
onDismiss = onErrorDismiss,
onRetry = onRetry,
)
}
}
is AsyncAction.Success -> {
val latestOnSuccess by rememberUpdatedState(onSuccess)
LaunchedEffect(async) {
latestOnSuccess(async.data)
}
}
}
}
object AsyncActionViewDefaults {
@Composable
fun ProgressDialog(progressText: String? = null) {
ProgressDialog(
text = progressText,
)
}
}
@PreviewsDayNight
@Composable
internal fun AsyncActionViewPreview(
@PreviewParameter(AsyncActionProvider::class) async: AsyncAction<Unit>,
) = ElementPreview {
AsyncActionView(
async = async,
onSuccess = {},
onErrorDismiss = {},
confirmationDialog = {
ConfirmationDialog(
title = "Confirmation",
content = "Are you sure?",
onSubmitClick = {},
onDismiss = {},
)
},
)
}
@@ -0,0 +1,57 @@
/*
* 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.designsystem.components.async
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun AsyncFailure(
throwable: Throwable,
onRetry: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = throwable.message ?: stringResource(id = CommonStrings.error_unknown))
if (onRetry != null) {
Spacer(modifier = Modifier.height(24.dp))
Button(
text = stringResource(id = CommonStrings.action_retry),
onClick = onRetry
)
}
}
}
@PreviewsDayNight
@Composable
internal fun AsyncFailurePreview() = ElementPreview {
AsyncFailure(
throwable = IllegalStateException("An error occurred"),
onRetry = {}
)
}
@@ -0,0 +1,109 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.designsystem.components.async
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.progressSemantics
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
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.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon
/**
* A helper to create [AsyncIndicatorView] with some defaults.
*/
@Stable
object AsyncIndicator {
/**
* A loading async indicator.
* @param text The text to display.
* @param modifier The modifier to apply to the indicator.
*/
@Composable
fun Loading(
text: String,
modifier: Modifier = Modifier,
) {
AsyncIndicatorView(
modifier = modifier,
text = text,
spacing = 10.dp,
) {
CircularProgressIndicator(
modifier = Modifier
.progressSemantics()
.size(12.dp),
color = ElementTheme.colors.textPrimary,
strokeWidth = 1.5.dp,
)
}
}
/**
* A failure async indicator.
* @param text The text to display.
* @param modifier The modifier to apply to the indicator.
*/
@Composable
fun Failure(
text: String,
modifier: Modifier = Modifier,
) {
AsyncIndicatorView(
modifier = modifier,
text = text,
spacing = defaultSpacing
) {
Icon(
modifier = Modifier.size(18.dp),
imageVector = CompoundIcons.Close(),
contentDescription = null,
)
}
}
/**
* A custom async indicator.
* @param text The text to display.
* @param modifier The modifier to apply to the indicator.
* @param spacing The spacing between the leading content and the text.
* @param leadingContent The leading content to display.
*/
@Composable
fun Custom(
text: String,
modifier: Modifier = Modifier,
spacing: Dp = defaultSpacing,
leadingContent: @Composable (() -> Unit)? = null,
) {
AsyncIndicatorView(
modifier = modifier,
text = text,
spacing = spacing,
leadingContent = leadingContent,
)
}
/**
* A short duration to display indicators.
*/
const val DURATION_SHORT = 3000L
/**
* A long duration to display indicators.
*/
const val DURATION_LONG = 5000L
private val defaultSpacing = 4.dp
}
@@ -0,0 +1,144 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.designsystem.components.async
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.spring
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Stable
class AsyncIndicatorState {
private val queue = SnapshotStateList<AsyncIndicatorItem>()
val currentItem = mutableStateOf<AsyncIndicatorItem?>(null)
val currentAnimationState = MutableTransitionState(false)
/**
* Enqueue a new indicator to be displayed.
* @param durationMs The duration to display the indicator, if `null` (the default value) it will be displayed indefinitely, until the next indicator is
* displayed or the current one is manually cleared.
* @param composable The composable to display.
*/
fun enqueue(durationMs: Long? = null, composable: @Composable () -> Unit) {
queue.add(AsyncIndicatorItem(composable, durationMs))
if (currentItem.value == null || currentItem.value?.durationMs == null) {
nextState()
}
}
internal fun nextState() {
if (!currentAnimationState.isIdle) return
if (currentItem.value != null && currentAnimationState.currentState && currentAnimationState.isIdle) {
// Is visible and not animating, start the exit animation
currentAnimationState.targetState = false
} else if (currentItem.value == null || !currentAnimationState.currentState && currentAnimationState.isIdle) {
// Not visible or present, start the enter animation for the next item
val newItem = queue.removeFirstOrNull()
if (newItem != null) {
currentItem.value = null
currentAnimationState.targetState = true
}
currentItem.value = newItem
}
}
/**
* Clear the current indicator using its exit animation.
*/
fun clear() {
currentAnimationState.targetState = false
}
}
/**
* An item to be displayed in the [AsyncIndicatorHost].
*/
data class AsyncIndicatorItem(
val composable: @Composable () -> Unit,
val durationMs: Long? = null,
)
/**
* Remember an [AsyncIndicatorState] instance.
*/
@Composable
fun rememberAsyncIndicatorState(): AsyncIndicatorState {
return remember { AsyncIndicatorState() }
}
/**
* A host for displaying async indicators.
* @param modifier The modifier to apply.
* @param state The [AsyncIndicatorState] which values this component will display.
* @param enterTransition The enter transition to use for the displayed indicators.
* @param exitTransition The exit transition to use for the hiding indicators.
*/
@Composable
fun AsyncIndicatorHost(
modifier: Modifier = Modifier,
state: AsyncIndicatorState = rememberAsyncIndicatorState(),
enterTransition: EnterTransition = fadeIn(spring(stiffness = 500F)) + slideInVertically(),
exitTransition: ExitTransition = fadeOut(spring(stiffness = 500F)) + slideOutVertically(),
) {
val coroutineScope = rememberCoroutineScope()
Box(
modifier = modifier.fillMaxWidth(),
contentAlignment = Alignment.TopCenter,
) {
if (LocalInspectionMode.current) {
state.currentItem.value?.composable?.invoke()
} else {
state.currentItem.value?.let { item ->
AnimatedVisibility(
visibleState = state.currentAnimationState,
enter = enterTransition,
exit = exitTransition,
) {
item.composable()
}
if (state.currentAnimationState.hasEntered() && item.durationMs != null) {
SideEffect {
coroutineScope.launch {
delay(item.durationMs)
state.nextState()
}
}
} else if (state.currentAnimationState.hasExited()) {
SideEffect {
state.nextState()
}
}
}
}
}
}
internal fun MutableTransitionState<Boolean>.hasEntered() = currentState && isIdle
internal fun MutableTransitionState<Boolean>.hasExited() = !currentState && isIdle
@@ -0,0 +1,82 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.designsystem.components.async
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.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
internal fun AsyncIndicatorView(
text: String,
spacing: Dp,
modifier: Modifier = Modifier,
elevation: Dp = 8.dp,
leadingContent: @Composable (() -> Unit)?,
) {
Box(
modifier = modifier
.padding(horizontal = 32.dp)
.padding(elevation)
) {
Surface(
shape = RoundedCornerShape(24.dp),
shadowElevation = elevation,
) {
Row(
modifier = Modifier
.background(color = ElementTheme.colors.bgSubtleSecondary)
.padding(horizontal = 24.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(spacing)
) {
leadingContent?.let { view ->
view()
}
Text(
text = text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontBodyMdMedium
)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun AsyncIndicatorLoadingPreview() {
ElementPreview {
AsyncIndicator.Loading(text = "Loading")
}
}
@PreviewsDayNight
@Composable
internal fun AsyncIndicatorFailurePreview() {
ElementPreview {
AsyncIndicator.Failure(text = "Failed")
}
}
@@ -0,0 +1,38 @@
/*
* 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.designsystem.components.async
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
@Composable
fun AsyncLoading(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxWidth()
.height(120.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
@PreviewsDayNight
@Composable
internal fun AsyncLoadingPreview() = ElementPreview {
AsyncLoading()
}
@@ -0,0 +1,119 @@
/*
* 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.designsystem.components.avatar
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.avatar.internal.RoomAvatar
import io.element.android.libraries.designsystem.components.avatar.internal.SpaceAvatar
import io.element.android.libraries.designsystem.components.avatar.internal.UserAvatar
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.utils.CommonDrawables
import kotlinx.collections.immutable.persistentListOf
@Composable
fun Avatar(
avatarData: AvatarData,
avatarType: AvatarType,
modifier: Modifier = Modifier,
contentDescription: String? = null,
// If not null, will be used instead of the size from avatarData
forcedAvatarSize: Dp? = null,
// If true, will show initials even if avatarData.url is not null
hideImage: Boolean = false,
) {
when (avatarType) {
is AvatarType.Room -> RoomAvatar(
avatarData = avatarData,
avatarType = avatarType,
modifier = modifier,
hideAvatarImage = hideImage,
forcedAvatarSize = forcedAvatarSize,
contentDescription = contentDescription,
)
AvatarType.User -> UserAvatar(
avatarData = avatarData,
modifier = modifier,
contentDescription = contentDescription,
forcedAvatarSize = forcedAvatarSize,
hideImage = hideImage,
)
is AvatarType.Space -> SpaceAvatar(
avatarData = avatarData,
avatarType = avatarType,
modifier = modifier,
hideAvatarImage = hideImage,
forcedAvatarSize = forcedAvatarSize,
contentDescription = contentDescription,
)
}
}
@Preview(group = PreviewGroup.Avatars)
@Composable
internal fun AvatarPreview() = ElementThemedPreview(
drawableFallbackForImages = CommonDrawables.sample_background,
) {
Column(
modifier = Modifier.padding(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
listOf(
anAvatarData(size = AvatarSize.UserListItem),
anAvatarData(size = AvatarSize.UserListItem, name = null),
anAvatarData(size = AvatarSize.UserListItem, url = "aUrl"),
).forEach { avatarData ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Avatar(
avatarData = avatarData,
avatarType = AvatarType.User,
)
Avatar(
avatarData = avatarData,
avatarType = AvatarType.Room(isTombstoned = false),
)
Avatar(
avatarData = avatarData,
avatarType = AvatarType.Room(
heroes = persistentListOf(
anAvatarData("@carol:server.org", "Carol", size = AvatarSize.UserListItem),
anAvatarData("@david:server.org", "David", size = AvatarSize.UserListItem),
anAvatarData("@eve:server.org", "Eve", size = AvatarSize.UserListItem),
anAvatarData("@justin:server.org", "Justin", size = AvatarSize.UserListItem),
)
)
)
Avatar(
avatarData = avatarData,
avatarType = AvatarType.Room(isTombstoned = true),
)
Avatar(
avatarData = avatarData,
avatarType = AvatarType.Space(isTombstoned = false),
)
Avatar(
avatarData = avatarData,
avatarType = AvatarType.Space(isTombstoned = true),
)
}
}
}
}
@@ -0,0 +1,68 @@
/*
* 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.designsystem.components.avatar
import io.element.android.libraries.core.data.tryOrNull
import java.text.BreakIterator
data class AvatarData(
val id: String,
val name: String?,
val url: String? = null,
val size: AvatarSize,
) {
val initialLetter by lazy {
// For roomIds, use "#" as initial
(name?.takeIf { it.isNotBlank() } ?: id.takeIf { !it.startsWith("!") } ?: "#")
.let { dn ->
var startIndex = 0
val initial = dn[startIndex]
if (initial in listOf('@', '#', '+') && dn.length > 1) {
startIndex++
}
var next = dn[startIndex]
// LEFT-TO-RIGHT MARK
if (dn.length >= 2 && 0x200e == next.code) {
startIndex++
next = dn[startIndex]
}
while (next.isWhitespace()) {
if (dn.length > startIndex + 1) {
startIndex++
next = dn[startIndex]
} else {
break
}
}
val fullCharacterIterator = BreakIterator.getCharacterInstance()
fullCharacterIterator.setText(dn)
val glyphBoundary = tryOrNull { fullCharacterIterator.following(startIndex) }
?.takeIf { it in startIndex..dn.length }
when {
// Use the found boundary
glyphBoundary != null -> dn.substring(startIndex, glyphBoundary)
// If no boundary was found, default to the next char if possible
startIndex + 1 < dn.length -> dn.substring(startIndex, startIndex + 1)
// Return a fallback character otherwise
else -> "#"
}
}
.uppercase()
}
}
fun AvatarData.getBestName(): String {
return name?.takeIf { it.isNotEmpty() } ?: id
}
@@ -0,0 +1,22 @@
/*
* 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.designsystem.components.avatar
fun anAvatarData(
// Let's the id not start with a 'a'.
id: String = "@id_of_alice:server.org",
name: String? = "Alice",
url: String? = null,
size: AvatarSize = AvatarSize.RoomListItem,
) = AvatarData(
id = id,
name = name,
url = url,
size = size,
)
@@ -0,0 +1,174 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.designsystem.components.avatar
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
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.platform.LocalLayoutDirection
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.avatar.internal.OverlapRatioProvider
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toPx
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
/**
* Draw a row of avatars (they must all have the same size), from start to end.
* @param avatarDataList the avatars to render. Note: they will all be rendered, the caller may
* want to limit the list size
* @param avatarType the type of avatars to render
* @param modifier Jetpack Compose modifier
* @param overlapRatio the overlap ration. When 0f, avatars will render without overlap, when 1f
* only the first avatar will be visible
* @param lastOnTop if true, the last visible avatar will be rendered on top.
*/
@Composable
fun AvatarRow(
avatarDataList: ImmutableList<AvatarData>,
avatarType: AvatarType,
modifier: Modifier = Modifier,
overlapRatio: Float = 0.5f,
lastOnTop: Boolean = false,
) {
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
Box(
modifier = modifier,
) {
val lastItemIndex = avatarDataList.size - 1
val avatarSize = avatarDataList.firstOrNull()?.size?.dp ?: return
val avatarSizePx = avatarSize.toPx()
avatarDataList
.let {
if (lastOnTop) {
it
} else {
it.reversed()
}
}
.forEachIndexed { index, avatarData ->
val startPadding = if (lastOnTop) {
avatarSize * (1 - overlapRatio) * index
} else {
avatarSize * (1 - overlapRatio) * (lastItemIndex - index)
}
Avatar(
modifier = Modifier
.padding(start = startPadding)
.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithContent {
// Draw content and clear the pixels for the avatar on the left (right in RTL) or when lastOnTop is true on
// the right (left in RTL).
drawContent()
if (index < lastItemIndex) {
val xOffset = if (isRtl == lastOnTop) {
avatarSizePx * (overlapRatio - 0.5f)
} else {
size.width - avatarSizePx * (overlapRatio - 0.5f)
}
drawCircle(
color = Color.Black,
center = Offset(
x = xOffset,
y = size.height / 2,
),
radius = avatarSizePx / 2,
blendMode = BlendMode.Clear,
)
}
}
.size(size = avatarSize)
// Keep internal padding, it has the advantage to not reduce the size of the Avatar image,
// which is already small in our use case.
.padding(2.dp),
avatarData = avatarData,
avatarType = avatarType,
)
}
}
}
@Composable
@PreviewsDayNight
internal fun AvatarRowPreview(@PreviewParameter(OverlapRatioProvider::class) overlapRatio: Float) {
ElementPreview {
ContentToPreview(overlapRatio)
}
}
@Composable
@PreviewsDayNight
internal fun AvatarRowLastOnTopPreview(@PreviewParameter(OverlapRatioProvider::class) overlapRatio: Float) {
ElementPreview {
ContentToPreview(
overlapRatio = overlapRatio,
lastOnTop = true,
)
}
}
@Composable
@PreviewsDayNight
internal fun AvatarRowRtlPreview(@PreviewParameter(OverlapRatioProvider::class) overlapRatio: Float) {
CompositionLocalProvider(
LocalLayoutDirection provides LayoutDirection.Rtl,
) {
ElementPreview {
ContentToPreview(overlapRatio)
}
}
}
@Composable
@PreviewsDayNight
internal fun AvatarRowLastOnTopRtlPreview(@PreviewParameter(OverlapRatioProvider::class) overlapRatio: Float) {
CompositionLocalProvider(
LocalLayoutDirection provides LayoutDirection.Rtl,
) {
ElementPreview {
ContentToPreview(
overlapRatio = overlapRatio,
lastOnTop = true,
)
}
}
}
@Composable
private fun ContentToPreview(
overlapRatio: Float,
lastOnTop: Boolean = false,
) {
AvatarRow(
avatarDataList = listOf("A", "B", "C").map {
AvatarData(
id = it,
name = it,
size = AvatarSize.RoomListItem,
)
}.toImmutableList(),
avatarType = AvatarType.User,
overlapRatio = overlapRatio,
lastOnTop = lastOnTop,
)
}
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.designsystem.components.avatar
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp
@Composable
fun AvatarType.User.avatarShape() = CircleShape
@Composable
fun AvatarType.Room.avatarShape() = CircleShape
@Composable
fun AvatarType.Space.avatarShape(avatarSize: Dp) = RoundedCornerShape(avatarSize * 0.25f)
@Composable
fun AvatarType.avatarShape(avatarSize: Dp): Shape {
return when (this) {
is AvatarType.Space -> avatarShape(avatarSize)
is AvatarType.Room -> avatarShape()
is AvatarType.User -> avatarShape()
}
}
@@ -0,0 +1,79 @@
/*
* 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.designsystem.components.avatar
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
enum class AvatarSize(val dp: Dp) {
CurrentUserTopBar(32.dp),
IncomingCall(140.dp),
RoomDetailsHeader(96.dp),
RoomListItem(52.dp),
SpaceListItem(52.dp),
RoomSelectRoomListItem(36.dp),
UserPreference(56.dp),
UserHeader(96.dp),
UserListItem(36.dp),
SelectedUser(52.dp),
SelectedRoom(56.dp),
DmCluster(75.dp),
TimelineRoom(32.dp),
TimelineSender(32.dp),
TimelineReadReceipt(16.dp),
TimelineThreadLatestEventSender(24.dp),
ComposerAlert(32.dp),
ReadReceiptList(32.dp),
MessageActionSender(32.dp),
RoomInviteItem(52.dp),
InviteSender(16.dp),
EditRoomDetails(70.dp),
RoomListManageUser(96.dp),
NotificationsOptIn(32.dp),
CustomRoomNotificationSetting(36.dp),
RoomDirectoryItem(36.dp),
EditProfileDetails(96.dp),
Suggestion(32.dp),
KnockRequestItem(52.dp),
KnockRequestBanner(32.dp),
MediaSender(32.dp),
DmCreationConfirmation(64.dp),
UserVerification(52.dp),
OrganizationHeader(64.dp),
SpaceHeader(64.dp),
RoomPreviewHeader(64.dp),
RoomPreviewInviter(56.dp),
SpaceMember(24.dp),
LeaveSpaceRoom(32.dp),
AccountItem(32.dp),
}
@@ -0,0 +1,27 @@
/*
* 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.designsystem.components.avatar
import androidx.compose.runtime.Immutable
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Immutable
sealed interface AvatarType {
data object User : AvatarType
data class Room(
val isTombstoned: Boolean = false,
val heroes: ImmutableList<AvatarData> = persistentListOf(),
) : AvatarType
data class Space(
val isTombstoned: Boolean = false,
) : AvatarType
}
@@ -0,0 +1,140 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.designsystem.components.avatar
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
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.tooling.preview.Preview
import androidx.compose.ui.unit.LayoutDirection
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.text.toPx
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
/** Ratio between the box size (120 on Figma) and the avatar size (75 on Figma). */
private const val SIZE_RATIO = 1.6f
/**
* https://www.figma.com/design/A2pAEvTEpJZBiOPUlcMnKi/Settings-%2B-Room-Details-(new)?node-id=1787-56333
*/
@Composable
fun DmAvatars(
userAvatarData: AvatarData,
otherUserAvatarData: AvatarData,
openAvatarPreview: (url: String) -> Unit,
openOtherAvatarPreview: (url: String) -> Unit,
modifier: Modifier = Modifier,
) {
val boxSize = userAvatarData.size.dp * SIZE_RATIO
val boxSizePx = boxSize.toPx()
val otherAvatarRadius = otherUserAvatarData.size.dp.toPx() / 2
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
Box(
modifier = modifier.size(boxSize),
) {
// Draw user avatar and cut top end corner
Avatar(
avatarData = userAvatarData,
avatarType = AvatarType.User,
contentDescription = userAvatarData.url?.let { stringResource(CommonStrings.a11y_your_avatar) },
modifier = Modifier
.align(Alignment.BottomStart)
.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithContent {
drawContent()
val xOffset = if (isRtl) {
size.width - boxSizePx + otherAvatarRadius
} else {
boxSizePx - otherAvatarRadius
}
drawCircle(
color = Color.Black,
center = Offset(
x = xOffset,
y = size.height - (boxSizePx - otherAvatarRadius),
),
radius = otherAvatarRadius / 0.9f,
blendMode = BlendMode.Clear,
)
}
.clip(CircleShape)
.clickable(
enabled = userAvatarData.url != null,
onClickLabel = stringResource(CommonStrings.action_view),
) {
userAvatarData.url?.let { openAvatarPreview(it) }
}
)
// Draw other user avatar
Avatar(
avatarData = otherUserAvatarData,
avatarType = AvatarType.User,
contentDescription = otherUserAvatarData.url?.let { stringResource(CommonStrings.a11y_other_user_avatar) },
modifier = Modifier
.align(Alignment.TopEnd)
.clip(CircleShape)
.clickable(
enabled = otherUserAvatarData.url != null,
onClickLabel = stringResource(CommonStrings.action_view),
) {
otherUserAvatarData.url?.let { openOtherAvatarPreview(it) }
}
.testTag(TestTags.memberDetailAvatar)
)
}
}
@Preview(group = PreviewGroup.Avatars)
@Composable
internal fun DmAvatarsPreview() = ElementThemedPreview {
val size = AvatarSize.DmCluster
DmAvatars(
userAvatarData = anAvatarData(
id = "Alice",
name = "Alice",
size = size,
),
otherUserAvatarData = anAvatarData(
id = "Bob",
name = "Bob",
size = size,
),
openAvatarPreview = {},
openOtherAvatarPreview = {},
)
}
@Preview(group = PreviewGroup.Avatars)
@Composable
internal fun DmAvatarsRtlPreview() {
CompositionLocalProvider(
LocalLayoutDirection provides LayoutDirection.Rtl,
) {
DmAvatarsPreview()
}
}
@@ -0,0 +1,150 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.designsystem.components.avatar.internal
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.designsystem.components.avatar.avatarShape
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import java.util.Collections
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
private const val MAX_AVATAR_COUNT = 4
@Composable
internal fun AvatarCluster(
avatars: ImmutableList<AvatarData>,
avatarType: AvatarType,
modifier: Modifier = Modifier,
hideAvatarImages: Boolean = false,
contentDescription: String? = null,
) {
val limitedAvatars = avatars.take(MAX_AVATAR_COUNT)
val numberOfAvatars = limitedAvatars.size
if (numberOfAvatars == 4) {
// Swap 2 and 3 so that the 4th avatar is at the bottom right
Collections.swap(limitedAvatars, 2, 3)
}
when (numberOfAvatars) {
0 -> {
error("Unsupported number of avatars: 0")
}
1 -> {
InitialOrImageAvatar(
avatarData = limitedAvatars[0],
hideAvatarImage = hideAvatarImages,
avatarShape = avatarType.avatarShape(limitedAvatars[0].size.dp),
forcedAvatarSize = null,
modifier = modifier,
contentDescription = contentDescription,
)
}
else -> {
val size = limitedAvatars.first().size
val angle = 2 * Math.PI / numberOfAvatars
val offsetRadius = when (numberOfAvatars) {
2 -> size.dp.value / 4.2
3 -> size.dp.value / 4.0
4 -> size.dp.value / 3.1
else -> error("Unsupported number of heroes: $numberOfAvatars")
}
val heroAvatarSize = when (numberOfAvatars) {
2 -> size.dp / 2.2f
3 -> size.dp / 2.4f
4 -> size.dp / 2.2f
else -> error("Unsupported number of heroes: $numberOfAvatars")
}
val angleOffset = when (numberOfAvatars) {
2 -> PI
3 -> 7 * PI / 6
4 -> 13 * PI / 4
else -> error("Unsupported number of heroes: $numberOfAvatars")
}
Box(
modifier = modifier
.size(size.dp)
.semantics {
this.contentDescription = contentDescription.orEmpty()
},
contentAlignment = Alignment.Center,
) {
limitedAvatars.forEachIndexed { index, heroAvatar ->
val xOffset = (offsetRadius * cos(angle * index.toDouble() + angleOffset)).dp
val yOffset = (offsetRadius * sin(angle * index.toDouble() + angleOffset)).dp
Box(
modifier = Modifier
.size(heroAvatarSize)
.offset(
x = xOffset,
y = yOffset,
)
) {
InitialOrImageAvatar(
avatarData = heroAvatar,
hideAvatarImage = hideAvatarImages,
avatarShape = avatarType.avatarShape(heroAvatarSize),
forcedAvatarSize = heroAvatarSize,
modifier = Modifier,
contentDescription = contentDescription,
)
}
}
}
}
}
}
@Preview(group = PreviewGroup.Avatars)
@Composable
internal fun AvatarClusterPreview() = ElementThemedPreview {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
listOf(
AvatarType.User,
AvatarType.Room(),
AvatarType.Space(),
).forEach { avatarType ->
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
for (ngOfAvatars in 1..5) {
AvatarCluster(
avatars = List(ngOfAvatars) { anAvatarData(it) }.toImmutableList(),
avatarType = avatarType,
)
}
}
}
}
}
private fun anAvatarData(i: Int) = anAvatarData(
id = ('A' + i).toString(),
name = ('A' + i).toString()
)
@@ -0,0 +1,69 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.designsystem.components.avatar.internal
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import timber.log.Timber
@Composable
internal fun ImageAvatar(
avatarData: AvatarData,
avatarShape: Shape,
forcedAvatarSize: Dp?,
modifier: Modifier = Modifier,
contentDescription: String? = null,
) {
val size = forcedAvatarSize ?: avatarData.size.dp
SubcomposeAsyncImage(
model = avatarData,
contentDescription = contentDescription,
contentScale = ContentScale.Companion.Crop,
modifier = modifier
.size(size)
.clip(avatarShape)
) {
val collectedState by painter.state.collectAsState()
when (val state = collectedState) {
is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent()
is AsyncImagePainter.State.Error -> {
SideEffect {
Timber.e(
state.result.throwable,
"Error loading avatar $state\n${state.result}"
)
}
InitialLetterAvatar(
avatarData = avatarData,
avatarShape = avatarShape,
forcedAvatarSize = forcedAvatarSize,
contentDescription = contentDescription,
)
}
else -> InitialLetterAvatar(
avatarData = avatarData,
avatarShape = avatarShape,
forcedAvatarSize = forcedAvatarSize,
contentDescription = contentDescription,
)
}
}
}
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.designsystem.components.avatar.internal
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.components.avatar.AvatarData
@Composable
internal fun InitialLetterAvatar(
avatarData: AvatarData,
avatarShape: Shape,
forcedAvatarSize: Dp?,
contentDescription: String?,
modifier: Modifier = Modifier,
) {
val avatarColors = AvatarColorsProvider.provide(avatarData.id)
TextAvatar(
text = avatarData.initialLetter,
size = forcedAvatarSize ?: avatarData.size.dp,
avatarShape = avatarShape,
colors = avatarColors,
contentDescription = contentDescription,
modifier = modifier
)
}
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.designsystem.components.avatar.internal
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp
import io.element.android.libraries.designsystem.components.avatar.AvatarData
@Composable
internal fun InitialOrImageAvatar(
avatarData: AvatarData,
hideAvatarImage: Boolean,
forcedAvatarSize: Dp?,
avatarShape: Shape,
contentDescription: String?,
modifier: Modifier = Modifier,
) {
when {
avatarData.url.isNullOrBlank() || hideAvatarImage -> InitialLetterAvatar(
avatarData = avatarData,
avatarShape = avatarShape,
forcedAvatarSize = forcedAvatarSize,
modifier = modifier,
contentDescription = contentDescription,
)
else -> ImageAvatar(
avatarData = avatarData,
avatarShape = avatarShape,
forcedAvatarSize = forcedAvatarSize,
modifier = modifier,
contentDescription = contentDescription,
)
}
}
@@ -0,0 +1,21 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.designsystem.components.avatar.internal
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
class OverlapRatioProvider : PreviewParameterProvider<Float> {
override val values: Sequence<Float> = sequenceOf(
0f,
0.25f,
0.5f,
0.75f,
1f
)
}
@@ -0,0 +1,59 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.designsystem.components.avatar.internal
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.avatar.avatarShape
import kotlinx.collections.immutable.toImmutableList
@Composable
internal fun RoomAvatar(
avatarData: AvatarData,
avatarType: AvatarType.Room,
modifier: Modifier = Modifier,
hideAvatarImage: Boolean = false,
forcedAvatarSize: Dp? = null,
contentDescription: String? = null,
) {
when {
avatarType.isTombstoned -> {
TombstonedRoomAvatar(
size = forcedAvatarSize ?: avatarData.size.dp,
modifier = modifier,
avatarShape = avatarType.avatarShape(),
contentDescription = contentDescription
)
}
avatarData.url != null || avatarType.heroes.isEmpty() -> {
InitialOrImageAvatar(
avatarData = avatarData,
hideAvatarImage = hideAvatarImage,
avatarShape = avatarType.avatarShape(),
forcedAvatarSize = forcedAvatarSize,
modifier = modifier,
contentDescription = contentDescription,
)
}
else -> {
AvatarCluster(
// Keep only the first hero for now
avatars = avatarType.heroes.take(1).toImmutableList(),
// Note: even for a room avatar, we use AvatarType.User here to display the avatar of heroes
avatarType = AvatarType.User,
modifier = modifier,
hideAvatarImages = hideAvatarImage,
contentDescription = contentDescription
)
}
}
}
@@ -0,0 +1,76 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.designsystem.components.avatar.internal
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.designsystem.components.avatar.avatarShape
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.utils.CommonDrawables
@Composable
internal fun SpaceAvatar(
avatarData: AvatarData,
avatarType: AvatarType.Space,
modifier: Modifier = Modifier,
forcedAvatarSize: Dp? = null,
hideAvatarImage: Boolean = false,
contentDescription: String? = null,
) {
val size = forcedAvatarSize ?: avatarData.size.dp
when {
avatarType.isTombstoned -> TombstonedRoomAvatar(
size = size,
avatarShape = avatarType.avatarShape(size),
modifier = modifier,
contentDescription = contentDescription,
)
else -> InitialOrImageAvatar(
avatarData = avatarData,
hideAvatarImage = hideAvatarImage,
avatarShape = avatarType.avatarShape(size),
forcedAvatarSize = forcedAvatarSize,
modifier = modifier,
contentDescription = contentDescription,
)
}
}
@Preview(group = PreviewGroup.Avatars)
@Composable
internal fun SpaceAvatarPreview() =
ElementThemedPreview(
drawableFallbackForImages = CommonDrawables.sample_avatar,
) {
Row(
modifier = Modifier.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
SpaceAvatar(
avatarData = anAvatarData(),
avatarType = AvatarType.Space(),
)
SpaceAvatar(
avatarData = anAvatarData(),
avatarType = AvatarType.Space(
isTombstoned = true,
),
)
}
}
@@ -0,0 +1,95 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.designsystem.components.avatar.internal
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.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.compound.theme.AvatarColors
import io.element.android.compound.theme.ElementTheme
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.PreviewGroup
import io.element.android.libraries.designsystem.text.toSp
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
internal fun TextAvatar(
text: String,
size: Dp,
colors: AvatarColors,
contentDescription: String?,
avatarShape: Shape,
modifier: Modifier = Modifier,
) {
Box(
modifier
.size(size)
.clip(avatarShape)
.background(color = colors.background)
) {
val fontSize = size.toSp() / 2
val originalFont = ElementTheme.typography.fontHeadingMdBold
val ratio = fontSize.value / originalFont.fontSize.value
val lineHeight = originalFont.lineHeight * ratio
Text(
modifier = Modifier
.clearAndSetSemantics {
contentDescription?.let {
this.contentDescription = it
}
}
.align(Alignment.Center),
text = text,
style = originalFont.copy(fontSize = fontSize, lineHeight = lineHeight, letterSpacing = 0.sp),
color = colors.foreground,
)
}
}
@Preview(group = PreviewGroup.Avatars)
@Composable
internal fun TextAvatarPreview() = ElementPreview {
Row(
modifier = Modifier.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
listOf(
AvatarType.User,
AvatarType.Room(),
AvatarType.Space(),
).forEach { avatarType ->
TextAvatar(
text = "AB",
size = 40.dp,
colors = AvatarColors(
background = ElementTheme.colors.bgSubtlePrimary,
foreground = ElementTheme.colors.iconPrimary,
),
avatarShape = avatarType.avatarShape(40.dp),
contentDescription = null,
)
}
}
}
@@ -0,0 +1,51 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.designsystem.components.avatar.internal
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.AvatarColors
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
@Composable
internal fun TombstonedRoomAvatar(
size: Dp,
avatarShape: Shape,
modifier: Modifier = Modifier,
contentDescription: String? = null,
) {
TextAvatar(
text = "!",
size = size,
colors = AvatarColors(
background = ElementTheme.colors.bgSubtlePrimary,
foreground = ElementTheme.colors.iconTertiary
),
modifier = modifier,
avatarShape = avatarShape,
contentDescription = contentDescription,
)
}
@Preview(group = PreviewGroup.Avatars)
@Composable
internal fun TombstonedRoomAvatarPreview() = ElementPreview {
TombstonedRoomAvatar(
size = 52.dp,
avatarShape = CircleShape,
contentDescription = null,
)
}
@@ -0,0 +1,34 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.designsystem.components.avatar.internal
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.avatar.avatarShape
@Composable
internal fun UserAvatar(
avatarData: AvatarData,
modifier: Modifier = Modifier,
contentDescription: String? = null,
forcedAvatarSize: Dp? = null,
hideImage: Boolean = false,
) {
InitialOrImageAvatar(
avatarData = avatarData,
hideAvatarImage = hideImage,
avatarShape = AvatarType.User.avatarShape(),
modifier = modifier,
contentDescription = contentDescription,
forcedAvatarSize = forcedAvatarSize,
)
}
@@ -0,0 +1,48 @@
/*
* 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.designsystem.components.avatar.internal
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.avatarColors
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
@PreviewsDayNight
@Composable
internal fun UserAvatarColorsPreview() = ElementPreview {
Column(
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
repeat(avatarColors().size) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Note: it's OK, since the hash of "0" is 0, the hash of "1" is 1, etc.
Avatar(
avatarData = anAvatarData(id = "$it"),
avatarType = AvatarType.User,
)
Text(text = "Color index $it")
}
}
}
}
@@ -0,0 +1,58 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.designsystem.components.blurhash
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImage
@Composable
fun BlurHashAsyncImage(
model: Any?,
blurHash: String?,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Fit,
contentDescription: String? = null,
) {
var isLoading by rememberSaveable(model) { mutableStateOf(true) }
Box(
modifier = modifier,
contentAlignment = Alignment.Center,
) {
AsyncImage(
modifier = Modifier.fillMaxSize(),
model = model,
contentScale = contentScale,
contentDescription = contentDescription,
onSuccess = { isLoading = false }
)
AnimatedVisibility(
visible = isLoading,
enter = fadeIn(),
exit = fadeOut(),
) {
BlurHashImage(
blurHash = blurHash,
contentDescription = contentDescription,
contentScale = ContentScale.FillBounds,
)
}
}
}
@@ -0,0 +1,25 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.designsystem.components.blurhash
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.unit.IntSize
fun Modifier.blurHashBackground(blurHash: String?, alpha: Float = 1f) = this.composed {
val blurHashBitmap = rememberBlurHashImage(blurHash)
if (blurHashBitmap != null) {
Modifier.drawBehind {
drawImage(blurHashBitmap, dstSize = IntSize(size.width.toInt(), size.height.toInt()), alpha = alpha)
}
} else {
this
}
}
@@ -0,0 +1,50 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.designsystem.components.blurhash
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.produceState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode
import com.vanniktech.blurhash.BlurHash
@Suppress("ModifierMissing")
@Composable
fun BlurHashImage(
blurHash: String?,
contentDescription: String? = null,
contentScale: ContentScale = ContentScale.Fit,
) {
if (blurHash == null) return
val blurHashImage = rememberBlurHashImage(blurHash)
blurHashImage?.let { bitmap ->
Image(
modifier = Modifier.fillMaxSize(),
bitmap = bitmap,
contentScale = contentScale,
contentDescription = contentDescription
)
}
}
@Composable
fun rememberBlurHashImage(blurHash: String?): ImageBitmap? {
return if (LocalInspectionMode.current) {
blurHash?.let { BlurHash.decode(it, 10, 10)?.asImageBitmap() }
} else {
produceState<ImageBitmap?>(initialValue = null, blurHash) {
blurHash?.let { value = BlurHash.decode(it, 10, 10)?.asImageBitmap() }
}.value
}
}
@@ -0,0 +1,49 @@
/*
* 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.designsystem.components.button
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun BackButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
// TODO Handle RTL languages
imageVector: ImageVector = CompoundIcons.ArrowLeft(),
contentDescription: String = stringResource(CommonStrings.action_back),
enabled: Boolean = true,
) {
IconButton(
modifier = modifier,
onClick = onClick,
enabled = enabled,
) {
Icon(imageVector, contentDescription = contentDescription)
}
}
@Preview(group = PreviewGroup.Buttons)
@Composable
internal fun BackButtonPreview() = ElementThemedPreview {
Column {
BackButton(onClick = { }, enabled = true)
BackButton(onClick = { }, enabled = false)
}
}
@@ -0,0 +1,46 @@
/*
* 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.designsystem.components.button
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.TextButton
/**
* A sealed interface that represents the different visual styles that a button can have.
*/
@Immutable
sealed interface ButtonVisuals {
val action: () -> Unit
/**
* Creates a [Button] composable based on the visual state.
*/
@Composable
fun Composable()
data class Text(val text: String, override val action: () -> Unit) : ButtonVisuals {
@Composable
override fun Composable() {
TextButton(text = text, onClick = action)
}
}
data class Icon(val iconSource: IconSource, override val action: () -> Unit) : ButtonVisuals {
@Composable
override fun Composable() {
IconButton(onClick = action) {
Icon(iconSource.getPainter(), iconSource.contentDescription)
}
}
}
}
@@ -0,0 +1,135 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.designsystem.components.button
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.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
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.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.center
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.LinearGradientShader
import androidx.compose.ui.graphics.RadialGradientShader
import androidx.compose.ui.graphics.Shader
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
import io.element.android.compound.annotations.CoreColorToken
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.colors.gradientActionColors
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
@OptIn(CoreColorToken::class)
@Composable
fun GradientFloatingActionButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
shape: Shape = RoundedCornerShape(25),
content: @Composable () -> Unit,
) {
val colors = gradientActionColors()
val linearShaderBrush = remember {
object : ShaderBrush() {
override fun createShader(size: Size): Shader {
return LinearGradientShader(
from = Offset(size.width, size.height),
to = Offset(size.width, 0f),
colors = colors,
)
}
}
}
val radialShaderBrush = remember {
object : ShaderBrush() {
override fun createShader(size: Size): Shader {
return RadialGradientShader(
center = size.center,
radius = size.width / 2,
colors = colors,
)
}
}
}
Box(
modifier = modifier
.minimumInteractiveComponentSize()
.graphicsLayer(shape = shape, clip = false)
.clip(shape)
.drawBehind {
drawRect(brush = linearShaderBrush)
drawRect(brush = radialShaderBrush, alpha = 0.4f, blendMode = BlendMode.Overlay)
}
.clickable(
enabled = true,
onClick = onClick,
interactionSource = remember { MutableInteractionSource() },
indication = ripple(color = Color.White)
),
contentAlignment = Alignment.Center
) {
CompositionLocalProvider(LocalContentColor provides Color.White) {
content()
}
}
}
@PreviewsDayNight
@Composable
internal fun GradientFloatingActionButtonPreview() {
ElementPreview {
Box(modifier = Modifier.padding(20.dp)) {
GradientFloatingActionButton(
modifier = Modifier.size(48.dp),
onClick = {},
) {
Icon(imageVector = CompoundIcons.ChatNew(), contentDescription = null)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun GradientFloatingActionButtonCircleShapePreview() {
ElementPreview {
Box(modifier = Modifier.padding(20.dp)) {
GradientFloatingActionButton(
shape = CircleShape,
modifier = Modifier.size(48.dp),
onClick = {},
) {
Icon(
modifier = Modifier.padding(start = 2.dp),
imageVector = CompoundIcons.SendSolid(),
contentDescription = null
)
}
}
}
}
@@ -0,0 +1,107 @@
/*
* 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.designsystem.components.button
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.LocalContentColor
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.graphics.vector.ImageVector
import androidx.compose.ui.text.style.Hyphens
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
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.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun MainActionButton(
title: String,
imageVector: ImageVector,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
val ripple = ripple(bounded = false)
val interactionSource = remember { MutableInteractionSource() }
Column(
modifier
.clickable(
enabled = enabled,
interactionSource = interactionSource,
onClick = onClick,
indication = ripple
)
.widthIn(min = 76.dp, max = 96.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
contentDescription = null,
imageVector = imageVector,
tint = if (enabled) LocalContentColor.current else ElementTheme.colors.iconDisabled,
)
Spacer(modifier = Modifier.height(14.dp))
Text(
title,
style = ElementTheme.typography.fontBodyMdMedium.copy(hyphens = Hyphens.Auto),
color = if (enabled) LocalContentColor.current else ElementTheme.colors.textDisabled,
overflow = TextOverflow.Visible,
textAlign = TextAlign.Center,
)
}
}
@Preview(group = PreviewGroup.Buttons)
@Composable
internal fun MainActionButtonPreview() {
ElementThemedPreview {
ContentsToPreview()
}
}
@Composable
private fun ContentsToPreview() {
Row(
modifier = Modifier.padding(10.dp),
horizontalArrangement = Arrangement.spacedBy(20.dp)
) {
MainActionButton(
title = "Share",
imageVector = CompoundIcons.ShareAndroid(),
onClick = { },
)
MainActionButton(
title = "Share with a long text",
imageVector = CompoundIcons.ShareAndroid(),
onClick = { },
)
MainActionButton(
title = "Share",
imageVector = CompoundIcons.ShareAndroid(),
onClick = { },
enabled = false,
)
}
}
@@ -0,0 +1,187 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.designsystem.components.button
import androidx.compose.foundation.BorderStroke
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.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
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.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.LinearGradientShader
import androidx.compose.ui.graphics.Shader
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.colors.gradientActionColors
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.lowHorizontalPaddingValue
@Composable
fun SuperButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
shape: Shape = RoundedCornerShape(50),
buttonSize: ButtonSize = ButtonSize.Large,
enabled: Boolean = true,
content: @Composable () -> Unit,
) {
val contentPadding = remember(buttonSize) {
when (buttonSize) {
ButtonSize.Large -> PaddingValues(horizontal = 24.dp, vertical = 13.dp)
ButtonSize.LargeLowPadding -> PaddingValues(horizontal = lowHorizontalPaddingValue, vertical = 13.dp)
ButtonSize.Medium -> PaddingValues(horizontal = 20.dp, vertical = 9.dp)
ButtonSize.MediumLowPadding -> PaddingValues(horizontal = lowHorizontalPaddingValue, vertical = 9.dp)
ButtonSize.Small -> PaddingValues(horizontal = 16.dp, vertical = 5.dp)
}
}
val colors = gradientActionColors()
val shaderBrush = remember(colors) {
object : ShaderBrush() {
override fun createShader(size: Size): Shader {
return LinearGradientShader(
from = Offset(0f, 0f),
to = Offset(0f, size.height),
colors = colors,
)
}
}
}
val border = if (enabled) {
BorderStroke(1.dp, shaderBrush)
} else {
BorderStroke(1.dp, ElementTheme.colors.borderDisabled)
}
val backgroundColor = ElementTheme.colors.bgCanvasDefault
Box(
modifier = modifier
.minimumInteractiveComponentSize()
.graphicsLayer(shape = shape, clip = false)
.clip(shape)
.border(border, shape)
.drawBehind {
drawRect(backgroundColor)
drawRect(brush = shaderBrush, alpha = 0.04f)
}
.clickable(
enabled = enabled,
onClick = onClick,
interactionSource = remember { MutableInteractionSource() },
indication = ripple()
)
.padding(contentPadding),
contentAlignment = Alignment.Center
) {
CompositionLocalProvider(
LocalContentColor provides if (enabled) ElementTheme.colors.textPrimary else ElementTheme.colors.textDisabled,
LocalTextStyle provides ElementTheme.typography.fontBodyLgMedium,
) {
content()
}
}
}
@PreviewsDayNight
@Composable
internal fun SuperButtonPreview() {
ElementPreview {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
SuperButton(
modifier = Modifier.padding(10.dp),
buttonSize = ButtonSize.Large,
onClick = {},
) {
Text("Super button!")
}
SuperButton(
modifier = Modifier.padding(10.dp),
buttonSize = ButtonSize.LargeLowPadding,
onClick = {},
) {
Text("Super LargeLowPadding")
}
SuperButton(
modifier = Modifier.padding(10.dp),
buttonSize = ButtonSize.Medium,
onClick = {},
) {
Text("Super button!")
}
SuperButton(
modifier = Modifier.padding(10.dp),
buttonSize = ButtonSize.MediumLowPadding,
onClick = {},
) {
Text("Super MediumLowPadding")
}
SuperButton(
modifier = Modifier.padding(10.dp),
buttonSize = ButtonSize.Small,
onClick = {},
) {
Text("Super button!")
}
HorizontalDivider()
SuperButton(
modifier = Modifier.padding(10.dp),
buttonSize = ButtonSize.Large,
enabled = false,
onClick = {},
) {
Text("Super button!")
}
SuperButton(
modifier = Modifier.padding(10.dp),
buttonSize = ButtonSize.Medium,
enabled = false,
onClick = {},
) {
Text("Super button!")
}
SuperButton(
modifier = Modifier.padding(10.dp),
buttonSize = ButtonSize.Small,
enabled = false,
onClick = {},
) {
Text("Super button!")
}
}
}
}
@@ -0,0 +1,84 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.designsystem.components.dialogs
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.DialogPreview
import io.element.android.libraries.designsystem.theme.components.SimpleAlertDialogContent
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AlertDialog(
content: String,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
title: String? = null,
submitText: String = AlertDialogDefaults.submitText,
) {
BasicAlertDialog(modifier = modifier, onDismissRequest = onDismiss) {
AlertDialogContent(
title = title,
content = content,
submitText = submitText,
onSubmitClick = onDismiss,
)
}
}
@Composable
private fun AlertDialogContent(
content: String,
onSubmitClick: () -> Unit,
title: String? = AlertDialogDefaults.title,
submitText: String = AlertDialogDefaults.submitText,
) {
SimpleAlertDialogContent(
title = title,
content = content,
submitText = submitText,
onSubmitClick = onSubmitClick,
)
}
object AlertDialogDefaults {
val title: String? @Composable get() = null
val submitText: String @Composable get() = stringResource(id = CommonStrings.action_ok)
}
@Preview(group = PreviewGroup.Dialogs)
@Composable
internal fun AlertDialogContentPreview() {
ElementThemedPreview(showBackground = false) {
DialogPreview {
AlertDialogContent(
content = "Content",
onSubmitClick = {},
)
}
}
}
@PreviewsDayNight
@Composable
internal fun AlertDialogPreview() = ElementPreview {
AlertDialog(
content = "Content",
onDismiss = {},
)
}
@@ -0,0 +1,114 @@
/*
* 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.designsystem.components.dialogs
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.DialogPreview
import io.element.android.libraries.designsystem.theme.components.SimpleAlertDialogContent
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConfirmationDialog(
content: String,
onSubmitClick: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
title: String? = null,
submitText: String = stringResource(id = CommonStrings.action_ok),
cancelText: String = stringResource(id = CommonStrings.action_cancel),
destructiveSubmit: Boolean = false,
thirdButtonText: String? = null,
onCancelClick: () -> Unit = onDismiss,
onThirdButtonClick: () -> Unit = {},
icon: @Composable (() -> Unit)? = null,
) {
BasicAlertDialog(modifier = modifier, onDismissRequest = onDismiss) {
ConfirmationDialogContent(
title = title,
content = content,
submitText = submitText,
cancelText = cancelText,
thirdButtonText = thirdButtonText,
destructiveSubmit = destructiveSubmit,
onSubmitClick = onSubmitClick,
onCancelClick = onCancelClick,
onThirdButtonClick = onThirdButtonClick,
icon = icon,
)
}
}
@Composable
private fun ConfirmationDialogContent(
content: String,
submitText: String,
cancelText: String,
onSubmitClick: () -> Unit,
onCancelClick: () -> Unit,
title: String? = null,
thirdButtonText: String? = null,
onThirdButtonClick: () -> Unit = {},
destructiveSubmit: Boolean = false,
icon: @Composable (() -> Unit)? = null,
) {
SimpleAlertDialogContent(
title = title,
content = content,
submitText = submitText,
onSubmitClick = onSubmitClick,
cancelText = cancelText,
onCancelClick = onCancelClick,
thirdButtonText = thirdButtonText,
onThirdButtonClick = onThirdButtonClick,
destructiveSubmit = destructiveSubmit,
icon = icon,
)
}
@Preview(group = PreviewGroup.Dialogs)
@Composable
internal fun ConfirmationDialogContentPreview() =
ElementThemedPreview(showBackground = false) {
DialogPreview {
ConfirmationDialogContent(
content = "Content",
title = "Title",
submitText = "OK",
cancelText = "Cancel",
thirdButtonText = "Disable",
onSubmitClick = {},
onCancelClick = {},
onThirdButtonClick = {},
)
}
}
@PreviewsDayNight
@Composable
internal fun ConfirmationDialogPreview() = ElementPreview {
ConfirmationDialog(
content = "Content",
title = "Title",
submitText = "OK",
cancelText = "Cancel",
thirdButtonText = "Disable",
onSubmitClick = {},
onDismiss = {}
)
}
@@ -0,0 +1,91 @@
/*
* 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.designsystem.components.dialogs
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.window.DialogProperties
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.DialogPreview
import io.element.android.libraries.designsystem.theme.components.SimpleAlertDialogContent
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ErrorDialog(
content: String,
onSubmit: () -> Unit,
modifier: Modifier = Modifier,
title: String? = ErrorDialogDefaults.title,
submitText: String = ErrorDialogDefaults.submitText,
onDismiss: () -> Unit = onSubmit,
canDismiss: Boolean = true,
) {
BasicAlertDialog(
modifier = modifier,
onDismissRequest = onDismiss,
properties = DialogProperties(dismissOnClickOutside = canDismiss, dismissOnBackPress = canDismiss)
) {
ErrorDialogContent(
title = title,
content = content,
submitText = submitText,
onSubmitClick = onSubmit,
)
}
}
@Composable
private fun ErrorDialogContent(
content: String,
onSubmitClick: () -> Unit,
title: String? = ErrorDialogDefaults.title,
submitText: String = ErrorDialogDefaults.submitText,
) {
SimpleAlertDialogContent(
title = title,
content = content,
submitText = submitText,
onSubmitClick = onSubmitClick,
)
}
object ErrorDialogDefaults {
val title: String @Composable get() = stringResource(id = CommonStrings.dialog_title_error)
val submitText: String @Composable get() = stringResource(id = CommonStrings.action_ok)
}
@Preview(group = PreviewGroup.Dialogs)
@Composable
internal fun ErrorDialogContentPreview() {
ElementThemedPreview(showBackground = false) {
DialogPreview {
ErrorDialogContent(
content = "Content",
onSubmitClick = {},
)
}
}
}
@PreviewsDayNight
@Composable
internal fun ErrorDialogPreview() = ElementPreview {
ErrorDialog(
content = "Content",
onSubmit = {},
)
}
@@ -0,0 +1,82 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.designsystem.components.dialogs
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Checkbox
import io.element.android.libraries.designsystem.theme.components.SimpleAlertDialogContent
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ErrorDialogWithDoNotShowAgain(
content: String,
onDismiss: (Boolean) -> Unit,
modifier: Modifier = Modifier,
title: String = ErrorDialogDefaults.title,
submitText: String = ErrorDialogDefaults.submitText,
cancelText: String? = null,
onCancel: () -> Unit = {},
) {
var doNotShowAgain by remember { mutableStateOf(false) }
BasicAlertDialog(
modifier = modifier,
onDismissRequest = { onDismiss(doNotShowAgain) }
) {
SimpleAlertDialogContent(
title = title,
submitText = submitText,
cancelText = cancelText,
onSubmitClick = { onDismiss(doNotShowAgain) },
onCancelClick = onCancel,
) {
Column {
Text(
text = content,
style = ElementTheme.materialTypography.bodyMedium,
)
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = doNotShowAgain, onCheckedChange = { doNotShowAgain = it })
Text(
text = stringResource(id = CommonStrings.common_do_not_show_this_again),
style = ElementTheme.materialTypography.bodyMedium,
)
}
}
}
}
}
@PreviewsDayNight
@Composable
internal fun ErrorDialogWithDoNotShowAgainPreview() = ElementPreview {
ErrorDialogWithDoNotShowAgain(
content = "Content",
onDismiss = {},
)
}
@@ -0,0 +1,152 @@
/*
* 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.designsystem.components.dialogs
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.list.TextFieldListItem
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.DialogPreview
import io.element.android.libraries.designsystem.theme.components.ListSupportingText
import io.element.android.libraries.designsystem.theme.components.SimpleAlertDialogContent
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ListDialog(
onSubmit: () -> Unit,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
title: String? = null,
subtitle: String? = null,
cancelText: String = stringResource(CommonStrings.action_cancel),
submitText: String = stringResource(CommonStrings.action_ok),
enabled: Boolean = true,
applyPaddingToContents: Boolean = true,
destructiveSubmit: Boolean = false,
listItems: LazyListScope.() -> Unit,
) {
val decoratedSubtitle: @Composable (() -> Unit)? = subtitle?.let {
@Composable {
ListSupportingText(
text = it,
modifier = Modifier.padding(start = 8.dp)
)
}
}
BasicAlertDialog(
modifier = modifier,
onDismissRequest = onDismissRequest,
) {
ListDialogContent(
title = title,
subtitle = decoratedSubtitle,
cancelText = cancelText,
submitText = submitText,
onDismissRequest = onDismissRequest,
onSubmitClick = onSubmit,
enabled = enabled,
listItems = listItems,
applyPaddingToContents = applyPaddingToContents,
destructiveSubmit = destructiveSubmit,
)
}
}
@Composable
private fun ListDialogContent(
listItems: LazyListScope.() -> Unit,
onDismissRequest: () -> Unit,
onSubmitClick: () -> Unit,
cancelText: String,
submitText: String,
title: String?,
enabled: Boolean,
applyPaddingToContents: Boolean,
destructiveSubmit: Boolean,
subtitle: @Composable (() -> Unit)? = null,
) {
SimpleAlertDialogContent(
title = title,
subtitle = subtitle,
cancelText = cancelText,
submitText = submitText,
onCancelClick = onDismissRequest,
onSubmitClick = onSubmitClick,
enabled = enabled,
applyPaddingToContents = applyPaddingToContents,
destructiveSubmit = destructiveSubmit,
) {
// No start padding if padding is already applied to the content
val horizontalPadding = if (applyPaddingToContents) 0.dp else 8.dp
LazyColumn(
modifier = Modifier.padding(horizontal = horizontalPadding),
verticalArrangement = Arrangement.spacedBy(16.dp),
) { listItems() }
}
}
@Preview(group = PreviewGroup.Dialogs)
@Composable
internal fun ListDialogContentPreview() {
ElementThemedPreview(showBackground = false) {
DialogPreview {
ListDialogContent(
listItems = {
item {
TextFieldListItem(placeholder = "Text input", text = "", onTextChange = {})
}
item {
TextFieldListItem(placeholder = "Another text input", text = "", onTextChange = {})
}
},
title = "Dialog title",
onDismissRequest = {},
onSubmitClick = {},
cancelText = "Cancel",
submitText = "Save",
enabled = true,
destructiveSubmit = false,
applyPaddingToContents = true,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun ListDialogPreview() = ElementPreview {
ListDialog(
listItems = {
item {
TextFieldListItem(placeholder = "Text input", text = "", onTextChange = {})
}
item {
TextFieldListItem(placeholder = "Another text input", text = "", onTextChange = {})
}
},
title = "Dialog title",
onDismissRequest = {},
onSubmit = {},
cancelText = "Cancel",
submitText = "Save",
)
}
@@ -0,0 +1,25 @@
/*
* 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.designsystem.components.dialogs
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
/**
* Used to store the visual data for a list option.
*/
data class ListOption(
val title: String,
val subtitle: String? = null,
)
/** Creates an immutable list of [ListOption]s from the given [values], using them as titles. */
fun listOptionOf(vararg values: String): ImmutableList<ListOption> {
return values.map { ListOption(it) }.toImmutableList()
}
@@ -0,0 +1,160 @@
/*
* 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.designsystem.components.dialogs
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.list.CheckboxListItem
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.DialogPreview
import io.element.android.libraries.designsystem.theme.components.ListSupportingText
import io.element.android.libraries.designsystem.theme.components.SimpleAlertDialogContent
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MultipleSelectionDialog(
options: ImmutableList<ListOption>,
onConfirmClick: (List<Int>) -> Unit,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
confirmButtonTitle: String = stringResource(CommonStrings.action_confirm),
dismissButtonTitle: String = stringResource(CommonStrings.action_cancel),
title: String? = null,
subtitle: String? = null,
initialSelection: ImmutableList<Int> = persistentListOf(),
) {
val decoratedSubtitle: @Composable (() -> Unit)? = subtitle?.let {
@Composable {
ListSupportingText(
text = it,
modifier = Modifier.padding(start = 8.dp)
)
}
}
BasicAlertDialog(
modifier = modifier,
onDismissRequest = onDismissRequest,
) {
MultipleSelectionDialogContent(
title = title,
subtitle = decoratedSubtitle,
options = options,
confirmButtonTitle = confirmButtonTitle,
onConfirmClick = onConfirmClick,
dismissButtonTitle = dismissButtonTitle,
onDismissRequest = onDismissRequest,
initialSelected = initialSelection,
)
}
}
@Composable
private fun MultipleSelectionDialogContent(
options: ImmutableList<ListOption>,
confirmButtonTitle: String,
onConfirmClick: (List<Int>) -> Unit,
dismissButtonTitle: String,
onDismissRequest: () -> Unit,
title: String? = null,
initialSelected: ImmutableList<Int> = persistentListOf(),
subtitle: @Composable (() -> Unit)? = null,
) {
val selectedOptionIndexes = remember { initialSelected.toMutableStateList() }
fun isSelected(index: Int) = selectedOptionIndexes.any { it == index }
SimpleAlertDialogContent(
title = title,
subtitle = subtitle,
submitText = confirmButtonTitle,
onSubmitClick = {
onConfirmClick(selectedOptionIndexes.toList())
},
cancelText = dismissButtonTitle,
onCancelClick = onDismissRequest,
applyPaddingToContents = false,
) {
LazyColumn {
itemsIndexed(options) { index, option ->
CheckboxListItem(
headline = option.title,
checked = isSelected(index),
onChange = {
if (isSelected(index)) {
selectedOptionIndexes.remove(index)
} else {
selectedOptionIndexes.add(index)
}
},
supportingText = option.subtitle,
compactLayout = true,
modifier = Modifier.padding(start = 8.dp)
)
}
}
}
}
@Preview(group = PreviewGroup.Dialogs)
@Composable
internal fun MultipleSelectionDialogContentPreview() {
ElementThemedPreview(showBackground = false) {
DialogPreview {
val options = persistentListOf(
ListOption("Option 1", "Supporting line text lorem ipsum dolor sit amet, consectetur."),
ListOption("Option 2"),
ListOption("Option 3"),
)
MultipleSelectionDialogContent(
title = "Dialog title",
options = options,
onConfirmClick = {},
onDismissRequest = {},
confirmButtonTitle = "Save",
dismissButtonTitle = "Cancel",
initialSelected = persistentListOf(0),
)
}
}
}
@PreviewsDayNight
@Composable
internal fun MultipleSelectionDialogPreview() = ElementPreview {
val options = persistentListOf(
ListOption("Option 1", "Supporting line text lorem ipsum dolor sit amet, consectetur."),
ListOption("Option 2"),
ListOption("Option 3"),
)
MultipleSelectionDialog(
title = "Dialog title",
options = options,
onConfirmClick = {},
onDismissRequest = {},
confirmButtonTitle = "Save",
dismissButtonTitle = "Cancel",
initialSelection = persistentListOf(0),
)
}
@@ -0,0 +1,95 @@
/*
* 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.designsystem.components.dialogs
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.DialogPreview
import io.element.android.libraries.designsystem.theme.components.SimpleAlertDialogContent
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RetryDialog(
content: String,
onRetry: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
title: String = RetryDialogDefaults.title,
retryText: String = RetryDialogDefaults.retryText,
dismissText: String = RetryDialogDefaults.dismissText,
) {
BasicAlertDialog(modifier = modifier, onDismissRequest = onDismiss) {
RetryDialogContent(
title = title,
content = content,
retryText = retryText,
dismissText = dismissText,
onRetry = onRetry,
onDismiss = onDismiss,
)
}
}
@Composable
private fun RetryDialogContent(
content: String,
onRetry: () -> Unit,
onDismiss: () -> Unit,
title: String = RetryDialogDefaults.title,
retryText: String = RetryDialogDefaults.retryText,
dismissText: String = RetryDialogDefaults.dismissText,
) {
SimpleAlertDialogContent(
title = title,
content = content,
submitText = retryText,
onSubmitClick = onRetry,
cancelText = dismissText,
onCancelClick = onDismiss,
)
}
object RetryDialogDefaults {
val title: String @Composable get() = stringResource(id = CommonStrings.dialog_title_error)
val retryText: String @Composable get() = stringResource(id = CommonStrings.action_retry)
val dismissText: String @Composable get() = stringResource(id = CommonStrings.action_cancel)
}
@Preview(group = PreviewGroup.Dialogs)
@Composable
internal fun RetryDialogContentPreview() {
ElementThemedPreview(showBackground = false) {
DialogPreview {
RetryDialogContent(
content = "Content",
onRetry = {},
onDismiss = {},
)
}
}
}
@PreviewsDayNight
@Composable
internal fun RetryDialogPreview() = ElementPreview {
RetryDialog(
content = "Content",
onRetry = {},
onDismiss = {},
)
}
@@ -0,0 +1,40 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.designsystem.components.dialogs
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun SaveChangesDialog(
onSubmitClick: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
title: String = stringResource(CommonStrings.dialog_unsaved_changes_title),
content: String = stringResource(CommonStrings.dialog_unsaved_changes_description_android),
) = ConfirmationDialog(
modifier = modifier,
title = title,
content = content,
onSubmitClick = onSubmitClick,
onDismiss = onDismiss,
)
@PreviewsDayNight
@Composable
internal fun SaveChangesDialogPreview() = ElementPreview {
SaveChangesDialog(
onSubmitClick = {},
onDismiss = {}
)
}
@@ -0,0 +1,138 @@
/*
* 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.designsystem.components.dialogs
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.list.RadioButtonListItem
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.DialogPreview
import io.element.android.libraries.designsystem.theme.components.ListSupportingText
import io.element.android.libraries.designsystem.theme.components.SimpleAlertDialogContent
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SingleSelectionDialog(
options: ImmutableList<ListOption>,
onSelectOption: (Int) -> Unit,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
title: String? = null,
subtitle: String? = null,
dismissButtonTitle: String = stringResource(CommonStrings.action_cancel),
initialSelection: Int? = null,
) {
val decoratedSubtitle: @Composable (() -> Unit)? = subtitle?.let {
@Composable {
ListSupportingText(
text = it,
modifier = Modifier.padding(start = 8.dp)
)
}
}
BasicAlertDialog(
modifier = modifier,
onDismissRequest = onDismissRequest,
) {
SingleSelectionDialogContent(
title = title,
subtitle = decoratedSubtitle,
options = options,
onOptionClick = onSelectOption,
dismissButtonTitle = dismissButtonTitle,
onDismissRequest = onDismissRequest,
initialSelection = initialSelection,
)
}
}
@Composable
private fun SingleSelectionDialogContent(
options: ImmutableList<ListOption>,
onOptionClick: (Int) -> Unit,
dismissButtonTitle: String,
onDismissRequest: () -> Unit,
title: String? = null,
initialSelection: Int? = null,
subtitle: @Composable (() -> Unit)? = null,
) {
SimpleAlertDialogContent(
title = title,
subtitle = subtitle,
submitText = dismissButtonTitle,
onSubmitClick = onDismissRequest,
applyPaddingToContents = false,
) {
LazyColumn {
itemsIndexed(options) { index, option ->
RadioButtonListItem(
headline = option.title,
supportingText = option.subtitle,
selected = index == initialSelection,
onSelect = { onOptionClick(index) },
compactLayout = true,
modifier = Modifier.padding(start = 8.dp)
)
}
}
}
}
@Preview(group = PreviewGroup.Dialogs)
@Composable
internal fun SingleSelectionDialogContentPreview() {
ElementPreview(showBackground = false) {
DialogPreview {
val options = persistentListOf(
ListOption("Option 1"),
ListOption("Option 2"),
ListOption("Option 3"),
)
SingleSelectionDialogContent(
title = "Dialog title",
options = options,
onOptionClick = {},
onDismissRequest = {},
dismissButtonTitle = "Cancel",
initialSelection = 0
)
}
}
}
@PreviewsDayNight
@Composable
internal fun SingleSelectionDialogPreview() = ElementPreview {
val options = persistentListOf(
ListOption("Option 1"),
ListOption("Option 2"),
ListOption("Option 3"),
)
SingleSelectionDialog(
title = "Dialog title",
options = options,
onSelectOption = {},
onDismissRequest = {},
dismissButtonTitle = "Cancel",
initialSelection = 0
)
}
@@ -0,0 +1,142 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.designsystem.components.dialogs
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.list.TextFieldListItem
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun TextFieldDialog(
title: String,
onSubmit: (String) -> Unit,
onDismissRequest: () -> Unit,
value: String?,
placeholder: String?,
modifier: Modifier = Modifier,
validation: (String?) -> Boolean = { true },
onValidationErrorMessage: String? = null,
autoSelectOnDisplay: Boolean = true,
minLines: Int = 1,
maxLines: Int = minLines,
content: String? = null,
label: String? = null,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
submitText: String = stringResource(CommonStrings.action_ok),
destructiveSubmit: Boolean = false,
) {
val focusRequester = remember { FocusRequester() }
var textFieldContents by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(
TextFieldValue(
value.orEmpty(),
selection = TextRange(value.orEmpty().length)
)
)
}
var error by rememberSaveable { mutableStateOf(if (!validation(value.orEmpty())) onValidationErrorMessage else null) }
var canRequestFocus by rememberSaveable { mutableStateOf(false) }
val canSubmit by remember { derivedStateOf { validation(textFieldContents.text) } }
ListDialog(
title = title,
onSubmit = { onSubmit(textFieldContents.text) },
onDismissRequest = onDismissRequest,
enabled = canSubmit,
submitText = submitText,
destructiveSubmit = destructiveSubmit,
modifier = modifier,
) {
if (content != null) {
item {
Text(
text = content,
style = ElementTheme.materialTypography.bodyMedium,
)
}
}
item {
TextFieldListItem(
placeholder = placeholder.orEmpty(),
label = label,
text = textFieldContents,
onTextChange = {
error = if (!validation(it.text)) onValidationErrorMessage else null
textFieldContents = it
},
error = error,
keyboardOptions = keyboardOptions,
keyboardActions = KeyboardActions(onAny = {
if (validation(textFieldContents.text)) {
onSubmit(textFieldContents.text)
}
}),
minLines = minLines,
maxLines = maxLines,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
)
canRequestFocus = true
}
}
if (autoSelectOnDisplay && canRequestFocus) {
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
}
@PreviewsDayNight
@Composable
internal fun TextFieldDialogPreview() = ElementPreview {
TextFieldDialog(
title = "Title",
value = "",
placeholder = "Placeholder",
onSubmit = {},
onDismissRequest = {},
)
}
@PreviewsDayNight
@Composable
internal fun TextFieldDialogWithErrorPreview() = ElementPreview {
TextFieldDialog(
title = "Title",
content = "Some content",
onSubmit = {},
validation = { false },
onDismissRequest = {},
value = "Value",
placeholder = "Placeholder",
label = "Label",
onValidationErrorMessage = "Error message",
)
}
@@ -0,0 +1,18 @@
/*
* 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.designsystem.components.form
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@Composable
fun textFieldState(stateValue: String): MutableState<String> =
remember(stateValue) { mutableStateOf(stateValue) }
@@ -0,0 +1,43 @@
/*
* 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.designsystem.components.list
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun CheckboxListItem(
headline: String,
checked: Boolean,
onChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
supportingText: String? = null,
trailingContent: ListItemContent? = null,
enabled: Boolean = true,
style: ListItemStyle = ListItemStyle.Default,
compactLayout: Boolean = false,
) {
ListItem(
modifier = modifier,
headlineContent = { Text(headline) },
supportingContent = supportingText?.let { @Composable { Text(it) } },
leadingContent = ListItemContent.Checkbox(
checked = checked,
enabled = enabled,
compact = compactLayout,
),
trailingContent = trailingContent,
style = style,
enabled = enabled,
onClick = { onChange(!checked) },
)
}
@@ -0,0 +1,139 @@
/*
* 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.designsystem.components.list
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.atomic.atoms.CounterAtom
import io.element.android.libraries.designsystem.atomic.atoms.RedIndicatorAtom
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Checkbox as CheckboxComponent
import io.element.android.libraries.designsystem.theme.components.Icon as IconComponent
import io.element.android.libraries.designsystem.theme.components.RadioButton as RadioButtonComponent
import io.element.android.libraries.designsystem.theme.components.Switch as SwitchComponent
import io.element.android.libraries.designsystem.theme.components.Text as TextComponent
/**
* This is a helper to set default leading and trailing content for [ListItem]s.
*/
@Immutable
sealed interface ListItemContent {
/**
* Default Switch content for [ListItem].
* @param checked The current state of the switch.
* @param enabled Whether the switch is enabled or not.
*/
data class Switch(
val checked: Boolean,
val enabled: Boolean = true
) : ListItemContent
/**
* Default Checkbox content for [ListItem].
* @param checked The current state of the checkbox.
* @param enabled Whether the checkbox is enabled or not.
* @param compact Reduces the size of the component to make the wrapping [ListItem] smaller.
* This is especially useful when the [ListItem] is used inside a Dialog. `false` by default.
*/
data class Checkbox(
val checked: Boolean,
val enabled: Boolean = true,
val compact: Boolean = false
) : ListItemContent
/**
* Default RadioButton content for [ListItem].
* @param selected The current state of the radio button.
* @param enabled Whether the radio button is enabled or not.
* @param compact Reduces the size of the component to make the wrapping [ListItem] smaller.
* This is especially useful when the [ListItem] is used inside a Dialog. `false` by default.
*/
data class RadioButton(
val selected: Boolean,
val enabled: Boolean = true,
val compact: Boolean = false
) : ListItemContent
/**
* Default Icon content for [ListItem]. Sets the Icon component to a predefined size.
* @param iconSource The icon to display, using [IconSource.getPainter].
* @param tintColor The tint color for the icon, if any. Defaults to `null`.
*/
data class Icon(val iconSource: IconSource, val tintColor: Color? = null) : ListItemContent
/**
* Default Text content for [ListItem]. Sets the Text component to a max size and clips overflow.
* @param text The text to display.
*/
data class Text(val text: String) : ListItemContent
/** Displays any custom content. */
data class Custom(val content: @Composable () -> Unit) : ListItemContent
/** Displays a badge. */
data object Badge : ListItemContent
/** Displays a counter. */
data class Counter(val count: Int) : ListItemContent
@Composable
fun View(isItemEnabled: Boolean) {
when (this) {
is Switch -> SwitchComponent(
checked = checked,
onCheckedChange = null,
enabled = enabled && isItemEnabled,
)
is Checkbox -> CheckboxComponent(
modifier = if (compact) Modifier.size(maxCompactSize) else Modifier,
checked = checked,
onCheckedChange = null,
enabled = enabled && isItemEnabled,
)
is RadioButton -> RadioButtonComponent(
modifier = if (compact) Modifier.size(maxCompactSize) else Modifier,
selected = selected,
onClick = null,
enabled = enabled && isItemEnabled,
)
is Icon -> {
IconComponent(
modifier = Modifier.size(maxCompactSize),
painter = iconSource.getPainter(),
contentDescription = iconSource.contentDescription,
tint = tintColor ?: LocalContentColor.current,
)
}
is Text -> TextComponent(modifier = Modifier.widthIn(max = 128.dp), text = text, maxLines = 1, overflow = TextOverflow.Ellipsis)
is Badge -> Box(
modifier = Modifier.size(maxCompactSize),
contentAlignment = Alignment.Center,
) {
RedIndicatorAtom()
}
is Counter -> {
CounterAtom(count = count)
}
is Custom -> content()
}
}
}
private val maxCompactSize = DpSize(24.dp, 24.dp)

Some files were not shown because too many files have changed in this diff Show More