mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 21:32:34 +00:00
Merge branch 'develop' into feature/bma/scroll-to-unread-messages
This commit is contained in:
+18
-28
@@ -6,25 +6,19 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.designsystem.atomic.organisms
|
||||
package io.element.android.libraries.designsystem.components.visuallist
|
||||
|
||||
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
|
||||
@@ -32,14 +26,17 @@ import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
/**
|
||||
* Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=2001-140
|
||||
*/
|
||||
@Composable
|
||||
fun InfoListOrganism(
|
||||
items: ImmutableList<InfoListItem>,
|
||||
fun VisualList(
|
||||
items: ImmutableList<VisualListItemData>,
|
||||
modifier: Modifier = Modifier,
|
||||
backgroundColor: Color = ElementTheme.colors.bgSubtleSecondary,
|
||||
iconTint: Color = LocalContentColor.current,
|
||||
iconTint: Color = ElementTheme.colors.iconSecondary,
|
||||
iconSize: Dp = 20.dp,
|
||||
textStyle: TextStyle = LocalTextStyle.current,
|
||||
textStyle: TextStyle = ElementTheme.typography.fontBodyMdMedium,
|
||||
textColor: Color = ElementTheme.colors.textPrimary,
|
||||
verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
@@ -49,12 +46,12 @@ fun InfoListOrganism(
|
||||
) {
|
||||
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
|
||||
items.size == 1 -> VisualListItemPosition.Single
|
||||
index == 0 -> VisualListItemPosition.Top
|
||||
index == items.size - 1 -> VisualListItemPosition.Bottom
|
||||
else -> VisualListItemPosition.Middle
|
||||
}
|
||||
InfoListItemMolecule(
|
||||
VisualListItem(
|
||||
message = {
|
||||
if (item.message is AnnotatedString) {
|
||||
Text(
|
||||
@@ -96,22 +93,15 @@ fun InfoListOrganism(
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
internal fun VisualListPreview() = ElementPreview {
|
||||
val items = persistentListOf(
|
||||
InfoListItem(message = "A top item"),
|
||||
InfoListItem(message = "A middle item"),
|
||||
InfoListItem(message = "A bottom item"),
|
||||
VisualListItemData(message = "A top item"),
|
||||
VisualListItemData(message = "A middle item"),
|
||||
VisualListItemData(message = "A bottom item"),
|
||||
)
|
||||
InfoListOrganism(
|
||||
VisualList(
|
||||
items = items,
|
||||
)
|
||||
}
|
||||
+20
-17
@@ -6,7 +6,7 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.designsystem.atomic.molecules
|
||||
package io.element.android.libraries.designsystem.components.visuallist
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -27,10 +27,13 @@ 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
|
||||
|
||||
/**
|
||||
* Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=2001-159
|
||||
*/
|
||||
@Composable
|
||||
fun InfoListItemMolecule(
|
||||
fun VisualListItem(
|
||||
message: @Composable () -> Unit,
|
||||
position: InfoListItemPosition,
|
||||
position: VisualListItemPosition,
|
||||
backgroundColor: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
icon: @Composable () -> Unit = {},
|
||||
@@ -38,10 +41,10 @@ fun InfoListItemMolecule(
|
||||
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)
|
||||
VisualListItemPosition.Single -> RoundedCornerShape(radius)
|
||||
VisualListItemPosition.Top -> RoundedCornerShape(topStart = radius, topEnd = radius)
|
||||
VisualListItemPosition.Middle -> RoundedCornerShape(0.dp)
|
||||
VisualListItemPosition.Bottom -> RoundedCornerShape(bottomStart = radius, bottomEnd = radius)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
@@ -61,42 +64,42 @@ fun InfoListItemMolecule(
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun InfoListItemMoleculePreview() {
|
||||
internal fun VisualListItemPreview() {
|
||||
ElementPreview {
|
||||
val color = ElementTheme.colors.bgSubtleSecondary
|
||||
Column(
|
||||
modifier = Modifier.padding(10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
InfoListItemMolecule(
|
||||
VisualListItem(
|
||||
message = { Text("A single item") },
|
||||
icon = { Icon(imageVector = CompoundIcons.InfoSolid(), contentDescription = null) },
|
||||
position = InfoListItemPosition.Single,
|
||||
position = VisualListItemPosition.Single,
|
||||
backgroundColor = color,
|
||||
)
|
||||
InfoListItemMolecule(
|
||||
VisualListItem(
|
||||
message = { Text("A top item") },
|
||||
icon = { Icon(imageVector = CompoundIcons.InfoSolid(), contentDescription = null) },
|
||||
position = InfoListItemPosition.Top,
|
||||
position = VisualListItemPosition.Top,
|
||||
backgroundColor = color,
|
||||
)
|
||||
InfoListItemMolecule(
|
||||
VisualListItem(
|
||||
message = { Text("A middle item") },
|
||||
icon = { Icon(imageVector = CompoundIcons.InfoSolid(), contentDescription = null) },
|
||||
position = InfoListItemPosition.Middle,
|
||||
position = VisualListItemPosition.Middle,
|
||||
backgroundColor = color,
|
||||
)
|
||||
InfoListItemMolecule(
|
||||
VisualListItem(
|
||||
message = { Text("A bottom item") },
|
||||
icon = { Icon(imageVector = CompoundIcons.InfoSolid(), contentDescription = null) },
|
||||
position = InfoListItemPosition.Bottom,
|
||||
position = VisualListItemPosition.Bottom,
|
||||
backgroundColor = color,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class InfoListItemPosition {
|
||||
enum class VisualListItemPosition {
|
||||
Top,
|
||||
Middle,
|
||||
Bottom,
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.designsystem.components.visuallist
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
data class VisualListItemData(
|
||||
val message: CharSequence,
|
||||
@DrawableRes val iconId: Int? = null,
|
||||
val iconVector: ImageVector? = null,
|
||||
val iconComposable: @Composable () -> Unit = {},
|
||||
)
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* 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.modifiers
|
||||
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.isImeVisible
|
||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Keeps the focused field visible above the keyboard. Intended for a field inside a scrollable
|
||||
* container: the field is never clipped by a pinned footer, but the IME is shown only after focus
|
||||
* arrives, so we re-request bringIntoView once it is visible to scroll the field back into the
|
||||
* reduced viewport.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun Modifier.bringIntoViewOnImeVisible(): Modifier {
|
||||
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val isImeVisible = WindowInsets.isImeVisible
|
||||
LaunchedEffect(isImeVisible, isFocused) {
|
||||
if (isImeVisible && isFocused) {
|
||||
// Delay to ensure the keyboard is fully shown before scrolling the field into view.
|
||||
delay(100.milliseconds)
|
||||
bringIntoViewRequester.bringIntoView()
|
||||
}
|
||||
}
|
||||
return this
|
||||
.bringIntoViewRequester(bringIntoViewRequester)
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
}
|
||||
+5
-3
@@ -10,7 +10,6 @@ package io.element.android.libraries.designsystem.theme.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.DividerDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -18,6 +17,7 @@ import androidx.compose.ui.graphics.Color
|
||||
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.ElementTheme
|
||||
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewGroup
|
||||
|
||||
@@ -25,7 +25,7 @@ import io.element.android.libraries.designsystem.preview.PreviewGroup
|
||||
fun HorizontalDivider(
|
||||
modifier: Modifier = Modifier,
|
||||
thickness: Dp = ElementDividerDefaults.thickness,
|
||||
color: Color = DividerDefaults.color,
|
||||
color: Color = ElementDividerDefaults.color,
|
||||
) {
|
||||
androidx.compose.material3.HorizontalDivider(
|
||||
modifier = modifier,
|
||||
@@ -35,7 +35,9 @@ fun HorizontalDivider(
|
||||
}
|
||||
|
||||
object ElementDividerDefaults {
|
||||
val thickness = 0.5.dp
|
||||
val thickness = 1.dp
|
||||
val color
|
||||
@Composable get() = ElementTheme.colors.separatorSecondary
|
||||
}
|
||||
|
||||
@Preview(group = PreviewGroup.Dividers)
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.designsystem.theme.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
/**
|
||||
* Show/hide toggle for a password [TextField], intended for its `trailingIcon` slot.
|
||||
* Shared so every password field reveals plaintext the same way and announces the
|
||||
* same accessibility labels.
|
||||
*/
|
||||
@Composable
|
||||
fun PasswordVisibilityToggle(
|
||||
visible: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier.clickable(onClick = onToggle)) {
|
||||
Icon(
|
||||
imageVector = if (visible) CompoundIcons.VisibilityOn() else CompoundIcons.VisibilityOff(),
|
||||
contentDescription = stringResource(
|
||||
if (visible) CommonStrings.a11y_hide_password else CommonStrings.a11y_show_password
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+17
-21
@@ -18,39 +18,35 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import io.element.android.libraries.designsystem.theme.components.Snackbar
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* A global dispatcher of [SnackbarMessage] to be displayed in [Snackbar] via a [SnackbarHostState].
|
||||
*
|
||||
* The current head of the queue is exposed as a [MutableStateFlow] so that every collector — including
|
||||
* hosts that subscribe *after* a message was posted (e.g. a screen recomposing as a flow pops back to
|
||||
* it) — observes the current message. An earlier mutex-gated implementation delivered each message to
|
||||
* whichever single collector happened to be parked on the lock, which could starve the host that was
|
||||
* actually on screen, dropping the snackbar.
|
||||
*/
|
||||
class SnackbarDispatcher {
|
||||
private val queueMutex = Mutex()
|
||||
private val snackBarMessageQueue = ArrayDeque<SnackbarMessage>()
|
||||
val snackbarMessage: Flow<SnackbarMessage?> = flow {
|
||||
while (currentCoroutineContext().isActive) {
|
||||
queueMutex.lock()
|
||||
emit(snackBarMessageQueue.firstOrNull())
|
||||
}
|
||||
}
|
||||
private val currentMessage = MutableStateFlow<SnackbarMessage?>(null)
|
||||
|
||||
val snackbarMessage: Flow<SnackbarMessage?> = currentMessage.asStateFlow()
|
||||
|
||||
@Synchronized
|
||||
fun post(message: SnackbarMessage) {
|
||||
if (snackBarMessageQueue.isEmpty()) {
|
||||
snackBarMessageQueue.add(message)
|
||||
if (queueMutex.isLocked) queueMutex.unlock()
|
||||
} else {
|
||||
snackBarMessageQueue.add(message)
|
||||
}
|
||||
snackBarMessageQueue.add(message)
|
||||
currentMessage.value = snackBarMessageQueue.firstOrNull()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun clear() {
|
||||
if (snackBarMessageQueue.isNotEmpty()) {
|
||||
snackBarMessageQueue.removeFirstOrNull()
|
||||
if (queueMutex.isLocked) queueMutex.unlock()
|
||||
}
|
||||
snackBarMessageQueue.removeFirstOrNull()
|
||||
currentMessage.value = snackBarMessageQueue.firstOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+21
@@ -54,6 +54,27 @@ class SnackbarDispatcherTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a host that subscribes after a message is posted still receives it`() = runTest {
|
||||
val snackbarDispatcher = SnackbarDispatcher()
|
||||
val message = SnackbarMessage(0)
|
||||
// A first host is already collecting (mirrors a background screen still subscribed).
|
||||
snackbarDispatcher.snackbarMessage.test {
|
||||
assertThat(awaitItem()).isNull()
|
||||
snackbarDispatcher.post(message)
|
||||
assertThat(awaitItem()).isEqualTo(message)
|
||||
|
||||
// A second host subscribes only afterwards — e.g. the screen a flow pops back to once the
|
||||
// message has already been posted. It must observe the current message rather than being
|
||||
// starved waiting for the next post (regression: an earlier mutex-gated implementation
|
||||
// delivered each post to a single parked collector, dropping the snackbar for any host
|
||||
// that subscribed later).
|
||||
snackbarDispatcher.snackbarMessage.test {
|
||||
assertThat(awaitItem()).isEqualTo(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given 2 message emissions, the next message is displayed only after a call to clear`() = runTest {
|
||||
val snackbarDispatcher = SnackbarDispatcher()
|
||||
|
||||
Reference in New Issue
Block a user