forked from dsutanto/bChot-android
First Commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 { }
|
||||
+49
@@ -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
|
||||
}
|
||||
}
|
||||
+41
@@ -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
|
||||
)
|
||||
}
|
||||
+52
@@ -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()
|
||||
}
|
||||
+99
@@ -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)
|
||||
}
|
||||
}
|
||||
+177
@@ -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)
|
||||
}
|
||||
}
|
||||
+111
@@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
+54
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+48
@@ -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()
|
||||
}
|
||||
+33
@@ -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,
|
||||
)
|
||||
}
|
||||
+26
@@ -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,
|
||||
)
|
||||
}
|
||||
+32
@@ -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,
|
||||
)
|
||||
}
|
||||
+109
@@ -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,
|
||||
}
|
||||
+82
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+51
@@ -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()
|
||||
}
|
||||
+48
@@ -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())
|
||||
}
|
||||
}
|
||||
+45
@@ -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 = {})
|
||||
}
|
||||
}
|
||||
+151
@@ -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 = "Alice’s verified identity has changed. Learn more".toAnnotatedString(),
|
||||
level = params.level,
|
||||
showIcon = params.showIcon,
|
||||
onSubmitClick = {},
|
||||
)
|
||||
}
|
||||
+37
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+63
@@ -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,
|
||||
)
|
||||
}
|
||||
+96
@@ -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",
|
||||
)
|
||||
}
|
||||
+104
@@ -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,
|
||||
}
|
||||
+47
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+34
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
@@ -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)
|
||||
}
|
||||
}
|
||||
+59
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+52
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+117
@@ -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,
|
||||
)
|
||||
}
|
||||
+34
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+115
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+214
@@ -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,
|
||||
)
|
||||
}
|
||||
+114
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
+155
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
+55
@@ -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()
|
||||
}
|
||||
+84
@@ -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()
|
||||
}
|
||||
}
|
||||
+26
@@ -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
|
||||
}
|
||||
+45
@@ -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,
|
||||
)
|
||||
+212
@@ -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 = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+83
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+204
@@ -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,
|
||||
)
|
||||
}
|
||||
+183
@@ -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() },
|
||||
)
|
||||
}
|
||||
+50
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+293
@@ -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)
|
||||
}
|
||||
}
|
||||
+63
@@ -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
|
||||
}
|
||||
}
|
||||
+55
@@ -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",
|
||||
)
|
||||
}
|
||||
+48
@@ -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()
|
||||
}
|
||||
+197
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+68
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+55
@@ -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())
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
+23
@@ -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),
|
||||
)
|
||||
}
|
||||
+100
@@ -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 = {},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
+57
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
+109
@@ -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
|
||||
}
|
||||
+144
@@ -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
|
||||
+82
@@ -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")
|
||||
}
|
||||
}
|
||||
+38
@@ -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()
|
||||
}
|
||||
+119
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
@@ -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
|
||||
}
|
||||
+22
@@ -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,
|
||||
)
|
||||
+174
@@ -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,
|
||||
)
|
||||
}
|
||||
+33
@@ -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()
|
||||
}
|
||||
}
|
||||
+79
@@ -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),
|
||||
}
|
||||
+27
@@ -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
|
||||
}
|
||||
+140
@@ -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()
|
||||
}
|
||||
}
|
||||
+150
@@ -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()
|
||||
)
|
||||
+69
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -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
|
||||
)
|
||||
}
|
||||
+42
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+21
@@ -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
|
||||
)
|
||||
}
|
||||
+59
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+95
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -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,
|
||||
)
|
||||
}
|
||||
+34
@@ -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,
|
||||
)
|
||||
}
|
||||
+48
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -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
|
||||
}
|
||||
}
|
||||
+50
@@ -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
|
||||
}
|
||||
}
|
||||
+49
@@ -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)
|
||||
}
|
||||
}
|
||||
+46
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+135
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+107
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+187
@@ -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!")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+84
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+114
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
+91
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+82
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+152
@@ -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",
|
||||
)
|
||||
}
|
||||
+25
@@ -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()
|
||||
}
|
||||
+160
@@ -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),
|
||||
)
|
||||
}
|
||||
+95
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+40
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
+138
@@ -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
|
||||
)
|
||||
}
|
||||
+142
@@ -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",
|
||||
)
|
||||
}
|
||||
+18
@@ -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) }
|
||||
+43
@@ -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) },
|
||||
)
|
||||
}
|
||||
+139
@@ -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
Reference in New Issue
Block a user