Merge branch 'develop' of https://github.com/vector-im/element-x-android into yostyle/notifications_global_settings

This commit is contained in:
David Langley 2023-08-30 15:02:59 +01:00
commit 5e2ec8b504
315 changed files with 3724 additions and 1216 deletions

View file

@ -105,7 +105,7 @@ sealed class ElementLogoAtomSize(
val shadowColorLight: Color,
val shadowRadius: Dp,
) {
object Medium : ElementLogoAtomSize(
data object Medium : ElementLogoAtomSize(
outerSize = 120.dp,
logoSize = 83.5.dp,
cornerRadius = 33.dp,
@ -115,7 +115,7 @@ sealed class ElementLogoAtomSize(
shadowRadius = 32.dp,
)
object Large : ElementLogoAtomSize(
data object Large : ElementLogoAtomSize(
outerSize = 158.dp,
logoSize = 110.dp,
cornerRadius = 44.dp,

View file

@ -0,0 +1,147 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
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.libraries.designsystem.R
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
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
import io.element.android.libraries.theme.ElementTheme
@Composable
fun SunsetPage(
isLoading: Boolean,
title: String,
subtitle: String,
modifier: Modifier = Modifier,
overallContent: @Composable () -> Unit,
) {
ElementTheme(
darkTheme = true
) {
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()
}
}
}
}
@Composable
private fun SunsetBackground(
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(0.3f)
.background(Color.White)
)
Image(
modifier = Modifier
.fillMaxWidth(),
painter = painterResource(id = R.drawable.light_dark),
contentScale = ContentScale.Crop,
contentDescription = null,
)
Box(
modifier = Modifier
.fillMaxWidth()
.weight(0.7f)
.background(Color(0xFF121418))
)
}
}
@DayNightPreviews
@Composable
internal fun SunsetPagePreview() = ElementPreview {
SunsetPage(
isLoading = true,
title = "Title with a green period.",
subtitle = "Subtitle",
overallContent = {}
)
}

View file

@ -24,7 +24,6 @@ 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.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -79,8 +78,8 @@ fun ClickableLinkText(
@Composable
fun ClickableLinkText(
annotatedString: AnnotatedString,
interactionSource: MutableInteractionSource,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
linkify: Boolean = true,
linkAnnotationTag: String = LINK_TAG,
onClick: () -> Unit = {},
@ -136,7 +135,6 @@ fun ClickableLinkText(
layoutResult.value = it
},
inlineContent = inlineContent,
color = MaterialTheme.colorScheme.primary,
)
}

View file

@ -88,7 +88,7 @@ fun ProgressDialog(
@Immutable
sealed interface ProgressDialogType {
data class Determinate(val progress: Float) : ProgressDialogType
object Indeterminate : ProgressDialogType
data object Indeterminate : ProgressDialogType
}
@Composable

View file

@ -59,6 +59,7 @@ fun String.toAnnotatedString(): AnnotatedString = buildAnnotatedString {
* @param color the color to apply to the string
* @param underline whether to underline the string
* @param bold whether to bold the string
* @param tagAndLink an optional pair of tag and link to add to the styled part of the string, as StringAnnotation
*/
@Composable
fun buildAnnotatedStringWithStyledPart(
@ -67,6 +68,7 @@ fun buildAnnotatedStringWithStyledPart(
color: Color = LinkColor,
underline: Boolean = true,
bold: Boolean = false,
tagAndLink: Pair<String, String>? = null,
) = buildAnnotatedString {
val coloredPart = stringResource(coloredTextRes)
val fullText = stringResource(fullTextRes, coloredPart)
@ -81,4 +83,31 @@ fun buildAnnotatedStringWithStyledPart(
start = startIndex,
end = startIndex + coloredPart.length,
)
if (tagAndLink != null) {
addStringAnnotation(
tag = tagAndLink.first,
annotation = tagAndLink.second,
start = startIndex,
end = startIndex + coloredPart.length
)
}
}
/**
* Convert a string to an [AnnotatedString] with colored end period if present.
*/
fun withColoredPeriod(
text: String,
) = buildAnnotatedString {
append(text)
if (text.endsWith(".")) {
addStyle(
style = SpanStyle(
// Light.colorGreen700
color = Color(0xff0bc491),
),
start = text.length - 1,
end = text.length,
)
}
}

View file

@ -0,0 +1,89 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.theme.components
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.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.IconToggleButtonColors
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.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
@Composable
fun IconToggleButton(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
colors: IconToggleButtonColors = IconButtonDefaults.iconToggleButtonColors(),
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
content: @Composable () -> Unit
) {
androidx.compose.material3.IconToggleButton(
checked = checked,
onCheckedChange = onCheckedChange,
modifier = modifier,
enabled = enabled,
colors = colors,
interactionSource = interactionSource,
content = content,
)
}
@Preview(group = PreviewGroup.Toggles)
@Composable
internal fun IconToggleButtonPreview() = ElementThemedPreview(vertical = false) { ContentToPreview() }
@Composable
private fun ContentToPreview() {
var checked by remember { mutableStateOf(false) }
Column {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
val icon: @Composable () -> Unit = {
Icon(
imageVector = if (checked) Icons.Default.CheckCircle else Icons.Default.RadioButtonUnchecked,
contentDescription = "IconToggleButton"
)
}
IconToggleButton(checked = checked, enabled = true, onCheckedChange = { checked = !checked }, content = icon)
IconToggleButton(checked = checked, enabled = false, onCheckedChange = { checked = !checked }, content = icon)
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
val icon: @Composable () -> Unit = {
Icon(
imageVector = if (!checked) Icons.Default.CheckCircle else Icons.Default.RadioButtonUnchecked,
contentDescription = "IconToggleButton"
)
}
IconToggleButton(checked = !checked, enabled = true, onCheckedChange = { checked = !checked }, content = icon)
IconToggleButton(checked = !checked, enabled = false, onCheckedChange = { checked = !checked }, content = icon)
}
}
}

View file

@ -119,7 +119,7 @@ fun ListItem(
androidx.compose.material3.ListItem(
headlineContent = decoratedHeadlineContent,
modifier = Modifier.clickable(enabled = enabled && onClick != null, onClick = onClick ?: {}).then(modifier),
modifier = if (onClick != null) Modifier.clickable(enabled = enabled, onClick = onClick).then(modifier) else modifier,
overlineContent = null,
supportingContent = decoratedSupportingContent,
leadingContent = decoratedLeadingContent,
@ -134,9 +134,9 @@ fun ListItem(
* The style to use for a [ListItem].
*/
sealed interface ListItemStyle {
object Default : ListItemStyle
object Primary: ListItemStyle
object Destructive : ListItemStyle
data object Default : ListItemStyle
data object Primary: ListItemStyle
data object Destructive : ListItemStyle
@Composable fun headlineColor() = when (this) {
Default, Primary -> ListItemDefaultColors.headline

View file

@ -22,7 +22,6 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
@ -30,9 +29,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.ExperimentalTextApi
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.ClickableLinkText
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
@ -103,17 +104,21 @@ fun ListSupportingText(
* @param modifier The modifier to be applied to the text.
* @param contentPadding The padding to apply to the text. Default is [ListSupportingTextDefaults.Padding.Default].
*/
@OptIn(ExperimentalTextApi::class)
@Composable
fun ListSupportingText(
annotatedString: AnnotatedString,
modifier: Modifier = Modifier,
contentPadding: ListSupportingTextDefaults.Padding = ListSupportingTextDefaults.Padding.Default,
) {
Text(
text = annotatedString,
modifier = modifier.padding(contentPadding.paddingValues()),
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
val style = ElementTheme.typography.fontBodySmRegular
.copy(color = ElementTheme.colors.textSecondary)
val paddedModifier = modifier.padding(contentPadding.paddingValues())
ClickableLinkText(
annotatedString = annotatedString,
modifier = paddedModifier,
style = style,
linkify = false,
)
}
@ -122,13 +127,13 @@ object ListSupportingTextDefaults {
/** Specifies the padding to use for the supporting text. */
sealed interface Padding {
/** No padding. */
object None : Padding
data object None : Padding
/** Default padding, it will align fine with a [ListItem] with no leading content. */
object Default : Padding
data object Default : Padding
/** It will align to a [ListItem] with an [Icon] or [Checkbox] as leading content. */
object SmallLeadingContent : Padding
data object SmallLeadingContent : Padding
/** It will align to with a [ListItem] with a [Switch] as leading content. */
object LargeLeadingContent : Padding
data object LargeLeadingContent : Padding
/** It will align to with a [ListItem] with a custom start [padding]. */
data class Custom(val padding: Dp) : Padding

View file

@ -23,7 +23,10 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.material3.RadioButtonColors
import androidx.compose.material3.RadioButtonDefaults
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.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@ -67,14 +70,15 @@ internal fun RadioButtonPreview() = ElementThemedPreview(vertical = false) { Con
@Composable
private fun ContentToPreview() {
var checked by remember { mutableStateOf(false) }
Column {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
RadioButton(selected = false, onClick = {})
RadioButton(selected = false, enabled = false, onClick = {})
RadioButton(selected = checked, enabled = true, onClick = { checked = !checked })
RadioButton(selected = checked, enabled = false, onClick = { checked = !checked })
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
RadioButton(selected = true, onClick = {})
RadioButton(selected = true, enabled = false, onClick = {})
RadioButton(selected = !checked, enabled = true, onClick = { checked = !checked })
RadioButton(selected = !checked, enabled = false, onClick = { checked = !checked })
}
}
}

View file

@ -34,33 +34,40 @@ import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.button.ButtonVisuals
import io.element.android.libraries.designsystem.theme.components.IconSource
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.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.concurrent.atomic.AtomicBoolean
/**
* A global dispatcher of [SnackbarMessage] to be displayed in [Snackbar] via a [SnackbarHostState].
*/
class SnackbarDispatcher {
private val mutex = Mutex()
private val _snackbarMessage = MutableStateFlow<SnackbarMessage?>(null)
val snackbarMessage: Flow<SnackbarMessage?> = _snackbarMessage.asStateFlow()
suspend fun post(message: SnackbarMessage) {
mutex.withLock {
_snackbarMessage.update { message }
private val queueMutex = Mutex()
private val snackBarMessageQueue = ArrayDeque<SnackbarMessage>()
val snackbarMessage: Flow<SnackbarMessage?> = flow {
while (currentCoroutineContext().isActive) {
queueMutex.lock()
emit(snackBarMessageQueue.firstOrNull())
}
}
suspend fun clear() {
mutex.withLock {
_snackbarMessage.update { null }
suspend fun post(message: SnackbarMessage) {
if (snackBarMessageQueue.isEmpty()) {
snackBarMessageQueue.add(message)
if (queueMutex.isLocked) queueMutex.unlock()
} else {
snackBarMessageQueue.add(message)
}
}
fun clear() {
if (snackBarMessageQueue.isNotEmpty()) {
snackBarMessageQueue.removeFirstOrNull()
if (queueMutex.isLocked) queueMutex.unlock()
}
}
}
@ -87,31 +94,51 @@ fun SnackbarHost(hostState: SnackbarHostState, modifier: Modifier = Modifier) {
}
}
/**
* Helper method to display a [SnackbarMessage] in a [SnackbarHostState] handling cancellations.
*/
@Composable
fun rememberSnackbarHostState(snackbarMessage: SnackbarMessage?): SnackbarHostState {
val snackbarHostState = remember { SnackbarHostState() }
val snackbarMessageText = snackbarMessage?.let {
stringResource(id = snackbarMessage.messageResId)
}
} ?: return snackbarHostState
val dispatcher = LocalSnackbarDispatcher.current
LaunchedEffect(snackbarMessage) {
if (snackbarMessageText == null) return@LaunchedEffect
launch {
snackbarHostState.showSnackbar(
message = snackbarMessageText,
duration = snackbarMessage.duration,
)
if (isActive) {
LaunchedEffect(snackbarMessageText) {
// If the message wasn't already displayed, do it now, and mark it as displayed
// This will prevent the message from appearing in any other active SnackbarHosts
if (snackbarMessage.isDisplayed.getAndSet(true) == false) {
try {
snackbarHostState.showSnackbar(
message = snackbarMessageText,
duration = snackbarMessage.duration,
)
// The snackbar item was displayed and dismissed, clear its message
dispatcher.clear()
} catch (e: CancellationException) {
// The snackbar was being displayed when the coroutine was cancelled,
// so we need to clear its message
dispatcher.clear()
throw e
}
}
}
return snackbarHostState
}
/**
* A message to be displayed in a [Snackbar].
* @param messageResId The message to be displayed.
* @param duration The duration of the message. The default value is [SnackbarDuration.Short].
* @param actionResId The action text to be displayed. The default value is `null`.
* @param isDisplayed Used to track if the current message is already displayed or not.
* @param action The action to be performed when the action is clicked.
*/
data class SnackbarMessage(
@StringRes val messageResId: Int,
val duration: SnackbarDuration = SnackbarDuration.Short,
@StringRes val actionResId: Int? = null,
val isDisplayed: AtomicBoolean = AtomicBoolean(false),
val action: () -> Unit = {},
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

View file

@ -0,0 +1,91 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.utils
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import org.junit.Test
class SnackbarDispatcherTests {
@Test
fun `given an empty queue the flow emits a null item`() = runTest {
val snackbarDispatcher = SnackbarDispatcher()
snackbarDispatcher.snackbarMessage.test {
assertThat(awaitItem()).isNull()
}
}
@Test
fun `given an empty queue calling clear does nothing`() = runTest {
val snackbarDispatcher = SnackbarDispatcher()
snackbarDispatcher.snackbarMessage.test {
assertThat(awaitItem()).isNull()
snackbarDispatcher.clear()
expectNoEvents()
}
}
@Test
fun `given a non-empty queue the flow emits an item`() = runTest {
val snackbarDispatcher = SnackbarDispatcher()
snackbarDispatcher.snackbarMessage.test {
snackbarDispatcher.post(SnackbarMessage(0))
val result = expectMostRecentItem()
assertThat(result).isNotNull()
}
}
@Test
fun `given a call to clear, the current message is cleared`() = runTest {
val snackbarDispatcher = SnackbarDispatcher()
snackbarDispatcher.snackbarMessage.test {
snackbarDispatcher.post(SnackbarMessage(0))
val item = expectMostRecentItem()
assertThat(item).isNotNull()
snackbarDispatcher.clear()
assertThat(awaitItem()).isNull()
}
}
@Test
fun `given 2 message emissions, the next message is displayed only after a call to clear`() = runTest {
val snackbarDispatcher = SnackbarDispatcher()
snackbarDispatcher.snackbarMessage.test {
val messageA = SnackbarMessage(0)
val messageB = SnackbarMessage(1)
// Send message A - it is the most recent item
snackbarDispatcher.post(messageA)
assertThat(expectMostRecentItem()).isEqualTo(messageA)
// Send message B - message A is still the most recent item
snackbarDispatcher.post(messageB)
expectNoEvents()
// Clear the last message - message B is now the most recent item
snackbarDispatcher.clear()
assertThat(expectMostRecentItem()).isEqualTo(messageB)
// Clear again - the queue is empty
snackbarDispatcher.clear()
assertThat(awaitItem()).isNull()
}
}
}