Merge branch 'develop' of https://github.com/vector-im/element-x-android into langleyd/live_waveform
This commit is contained in:
commit
37f8195956
352 changed files with 2007 additions and 254 deletions
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* 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.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.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
|
||||
@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.materialColors.background, CircleShape)
|
||||
.padding(borderSize / 2)
|
||||
.clip(CircleShape)
|
||||
.background(color)
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun RedIndicatorAtomPreview() = ElementPreview {
|
||||
RedIndicatorAtom()
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
/*
|
||||
* 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.molecules
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
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.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
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.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.designsystem.utils.CommonDrawables
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun DialogLikeBannerMolecule(
|
||||
title: String,
|
||||
content: String,
|
||||
onSubmitClicked: () -> Unit,
|
||||
onDismissClicked: (() -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Surface(
|
||||
Modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
) {
|
||||
Row {
|
||||
Text(
|
||||
text = title,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textAlign = TextAlign.Start,
|
||||
)
|
||||
if (onDismissClicked != null) {
|
||||
Icon(
|
||||
modifier = Modifier.clickable(onClick = onDismissClicked),
|
||||
resourceId = CommonDrawables.ic_compound_close,
|
||||
contentDescription = stringResource(CommonStrings.action_close)
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = content,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Button(
|
||||
text = stringResource(CommonStrings.action_continue),
|
||||
size = ButtonSize.Medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = onSubmitClicked,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun DialogLikeBannerMoleculePreview() = ElementPreview {
|
||||
DialogLikeBannerMolecule(
|
||||
title = "Title",
|
||||
content = "Content",
|
||||
onSubmitClicked = {},
|
||||
onDismissClicked = {}
|
||||
)
|
||||
}
|
||||
|
|
@ -38,6 +38,7 @@ fun ConfirmationDialog(
|
|||
title: String? = null,
|
||||
submitText: String = stringResource(id = CommonStrings.action_ok),
|
||||
cancelText: String = stringResource(id = CommonStrings.action_cancel),
|
||||
destructiveSubmit: Boolean = false,
|
||||
thirdButtonText: String? = null,
|
||||
onCancelClicked: () -> Unit = onDismiss,
|
||||
onThirdButtonClicked: () -> Unit = {},
|
||||
|
|
@ -49,6 +50,7 @@ fun ConfirmationDialog(
|
|||
submitText = submitText,
|
||||
cancelText = cancelText,
|
||||
thirdButtonText = thirdButtonText,
|
||||
destructiveSubmit = destructiveSubmit,
|
||||
onSubmitClicked = onSubmitClicked,
|
||||
onCancelClicked = onCancelClicked,
|
||||
onThirdButtonClicked = onThirdButtonClicked,
|
||||
|
|
@ -67,6 +69,7 @@ private fun ConfirmationDialogContent(
|
|||
title: String? = null,
|
||||
thirdButtonText: String? = null,
|
||||
onThirdButtonClicked: () -> Unit = {},
|
||||
destructiveSubmit: Boolean = false,
|
||||
icon: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
SimpleAlertDialogContent(
|
||||
|
|
@ -79,6 +82,7 @@ private fun ConfirmationDialogContent(
|
|||
onCancelClicked = onCancelClicked,
|
||||
thirdButtonText = thirdButtonText,
|
||||
onThirdButtonClicked = onThirdButtonClicked,
|
||||
destructiveSubmit = destructiveSubmit,
|
||||
icon = icon,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ fun PreferenceText(
|
|||
icon: ImageVector? = null,
|
||||
@DrawableRes iconResourceId: Int? = null,
|
||||
showIconAreaIfNoIcon: Boolean = false,
|
||||
showIconBadge: Boolean = false,
|
||||
tintColor: Color? = null,
|
||||
onClick: () -> Unit = {},
|
||||
) {
|
||||
|
|
@ -73,6 +74,7 @@ fun PreferenceText(
|
|||
PreferenceIcon(
|
||||
icon = icon,
|
||||
iconResourceId = iconResourceId,
|
||||
showIconBadge = showIconBadge,
|
||||
enabled = enabled,
|
||||
isVisible = showIconAreaIfNoIcon,
|
||||
tintColor = tintColor ?: enabled.toSecondaryEnabledColor(),
|
||||
|
|
|
|||
|
|
@ -17,17 +17,20 @@
|
|||
package io.element.android.libraries.designsystem.components.preferences.components
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
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.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.RedIndicatorAtom
|
||||
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
|
||||
|
|
@ -38,20 +41,30 @@ fun PreferenceIcon(
|
|||
modifier: Modifier = Modifier,
|
||||
icon: ImageVector? = null,
|
||||
@DrawableRes iconResourceId: Int? = null,
|
||||
showIconBadge: Boolean = false,
|
||||
tintColor: Color? = null,
|
||||
enabled: Boolean = true,
|
||||
isVisible: Boolean = true,
|
||||
) {
|
||||
if (icon != null || iconResourceId != null) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
resourceId = iconResourceId,
|
||||
contentDescription = "",
|
||||
tint = tintColor ?: enabled.toSecondaryEnabledColor(),
|
||||
modifier = modifier
|
||||
.padding(end = 16.dp)
|
||||
.size(24.dp),
|
||||
)
|
||||
Box(modifier = modifier) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
resourceId = iconResourceId,
|
||||
contentDescription = "",
|
||||
tint = tintColor ?: enabled.toSecondaryEnabledColor(),
|
||||
modifier = Modifier
|
||||
.padding(end = 16.dp)
|
||||
.size(24.dp),
|
||||
)
|
||||
if (showIconBadge) {
|
||||
RedIndicatorAtom(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(end = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (isVisible) {
|
||||
Spacer(modifier = modifier.width(40.dp))
|
||||
}
|
||||
|
|
@ -60,9 +73,19 @@ fun PreferenceIcon(
|
|||
@Preview(group = PreviewGroup.Preferences)
|
||||
@Composable
|
||||
internal fun PreferenceIconPreview(@PreviewParameter(ImageVectorProvider::class) content: ImageVector?) =
|
||||
ElementThemedPreview { ContentToPreview(content) }
|
||||
ElementThemedPreview {
|
||||
PreferenceIcon(
|
||||
icon = content,
|
||||
showIconBadge = false,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(group = PreviewGroup.Preferences)
|
||||
@Composable
|
||||
private fun ContentToPreview(content: ImageVector?) {
|
||||
PreferenceIcon(icon = content)
|
||||
}
|
||||
internal fun PreferenceIconWithBadgePreview(@PreviewParameter(ImageVectorProvider::class) content: ImageVector?) =
|
||||
ElementThemedPreview {
|
||||
PreferenceIcon(
|
||||
icon = content,
|
||||
showIconBadge = true,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ internal fun SimpleAlertDialogContent(
|
|||
title: String? = null,
|
||||
subtitle: @Composable (() -> Unit)? = null,
|
||||
submitText: String? = null,
|
||||
destructiveSubmit: Boolean = false,
|
||||
onSubmitClicked: () -> Unit = {},
|
||||
thirdButtonText: String? = null,
|
||||
onThirdButtonClicked: () -> Unit = {},
|
||||
|
|
@ -76,6 +77,7 @@ internal fun SimpleAlertDialogContent(
|
|||
title = title,
|
||||
subtitle = subtitle,
|
||||
submitText = submitText,
|
||||
destructiveSubmit = destructiveSubmit,
|
||||
onSubmitClicked = onSubmitClicked,
|
||||
thirdButtonText = thirdButtonText,
|
||||
onThirdButtonClicked = onThirdButtonClicked,
|
||||
|
|
@ -92,6 +94,7 @@ internal fun SimpleAlertDialogContent(
|
|||
title: String? = null,
|
||||
subtitle: @Composable (() -> Unit)? = null,
|
||||
submitText: String? = null,
|
||||
destructiveSubmit: Boolean = false,
|
||||
onSubmitClicked: () -> Unit = {},
|
||||
thirdButtonText: String? = null,
|
||||
onThirdButtonClicked: () -> Unit = {},
|
||||
|
|
@ -126,6 +129,7 @@ internal fun SimpleAlertDialogContent(
|
|||
enabled = enabled,
|
||||
size = ButtonSize.Medium,
|
||||
onClick = onSubmitClicked,
|
||||
destructive = destructiveSubmit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -345,8 +349,10 @@ private fun AlertDialogFlowRow(
|
|||
val arrangement = Arrangement.End
|
||||
val mainAxisPositions = IntArray(childrenMainAxisSizes.size) { 0 }
|
||||
with(arrangement) {
|
||||
arrange(mainAxisLayoutSize, childrenMainAxisSizes,
|
||||
layoutDirection, mainAxisPositions)
|
||||
arrange(
|
||||
mainAxisLayoutSize, childrenMainAxisSizes,
|
||||
layoutDirection, mainAxisPositions
|
||||
)
|
||||
}
|
||||
placeables.forEachIndexed { j, placeable ->
|
||||
placeable.place(
|
||||
|
|
@ -385,22 +391,22 @@ internal object DialogContentDefaults {
|
|||
val containerColor: Color
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get()= ElementTheme.colors.bgCanvasDefault
|
||||
get() = ElementTheme.colors.bgCanvasDefault
|
||||
|
||||
val textContentColor: Color
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get()= ElementTheme.materialColors.onSurfaceVariant
|
||||
get() = ElementTheme.materialColors.onSurfaceVariant
|
||||
|
||||
val titleContentColor: Color
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get()= ElementTheme.materialColors.onSurface
|
||||
get() = ElementTheme.materialColors.onSurface
|
||||
|
||||
val iconContentColor: Color
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get()= ElementTheme.materialColors.primary
|
||||
get() = ElementTheme.materialColors.primary
|
||||
}
|
||||
|
||||
// Paddings for each of the dialog's parts. Taken from M3 source code.
|
||||
|
|
@ -462,3 +468,21 @@ internal fun DialogWithOnlyMessageAndOkButtonPreview() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(group = PreviewGroup.Dialogs, name = "Dialog with destructive button")
|
||||
@Composable
|
||||
@Suppress("MaxLineLength")
|
||||
internal fun DialogWithDestructiveButtonPreview() {
|
||||
ElementThemedPreview(showBackground = false) {
|
||||
DialogPreview {
|
||||
SimpleAlertDialogContent(
|
||||
title = "Dialog Title",
|
||||
content = "A dialog with a destructive action",
|
||||
cancelText = "Cancel",
|
||||
submitText = "Delete",
|
||||
destructiveSubmit = true,
|
||||
onCancelClicked = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,18 +19,19 @@ package io.element.android.libraries.designsystem.theme.components
|
|||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.material3.BottomSheetDefaults
|
||||
import androidx.compose.material3.BottomSheetScaffoldState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.contentColorFor
|
||||
import androidx.compose.material3.rememberBottomSheetScaffoldState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import io.element.android.libraries.designsystem.theme.components.bottomsheet.BottomSheetScaffoldState
|
||||
import io.element.android.libraries.designsystem.theme.components.bottomsheet.CustomBottomSheetScaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.bottomsheet.rememberBottomSheetScaffoldState
|
||||
|
||||
@Composable
|
||||
@ExperimentalMaterial3Api
|
||||
|
|
@ -52,7 +53,7 @@ fun BottomSheetScaffold(
|
|||
contentColor: Color = contentColorFor(containerColor),
|
||||
content: @Composable (PaddingValues) -> Unit
|
||||
) {
|
||||
androidx.compose.material3.BottomSheetScaffold(
|
||||
CustomBottomSheetScaffold(
|
||||
sheetContent = sheetContent,
|
||||
modifier = modifier,
|
||||
scaffoldState = scaffoldState,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import androidx.compose.foundation.BorderStroke
|
|||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
|
|
@ -64,6 +65,7 @@ fun Button(
|
|||
enabled: Boolean = true,
|
||||
size: ButtonSize = ButtonSize.Large,
|
||||
showProgress: Boolean = false,
|
||||
destructive: Boolean = false,
|
||||
leadingIcon: IconSource? = null,
|
||||
) = ButtonInternal(
|
||||
text = text,
|
||||
|
|
@ -73,6 +75,7 @@ fun Button(
|
|||
enabled = enabled,
|
||||
size = size,
|
||||
showProgress = showProgress,
|
||||
destructive = destructive,
|
||||
leadingIcon = leadingIcon
|
||||
)
|
||||
|
||||
|
|
@ -84,6 +87,7 @@ fun OutlinedButton(
|
|||
enabled: Boolean = true,
|
||||
size: ButtonSize = ButtonSize.Large,
|
||||
showProgress: Boolean = false,
|
||||
destructive: Boolean = false,
|
||||
leadingIcon: IconSource? = null,
|
||||
) = ButtonInternal(
|
||||
text = text,
|
||||
|
|
@ -93,6 +97,7 @@ fun OutlinedButton(
|
|||
enabled = enabled,
|
||||
size = size,
|
||||
showProgress = showProgress,
|
||||
destructive = destructive,
|
||||
leadingIcon = leadingIcon
|
||||
)
|
||||
|
||||
|
|
@ -104,6 +109,7 @@ fun TextButton(
|
|||
enabled: Boolean = true,
|
||||
size: ButtonSize = ButtonSize.Large,
|
||||
showProgress: Boolean = false,
|
||||
destructive: Boolean = false,
|
||||
leadingIcon: IconSource? = null,
|
||||
) = ButtonInternal(
|
||||
text = text,
|
||||
|
|
@ -113,6 +119,7 @@ fun TextButton(
|
|||
enabled = enabled,
|
||||
size = size,
|
||||
showProgress = showProgress,
|
||||
destructive = destructive,
|
||||
leadingIcon = leadingIcon
|
||||
)
|
||||
|
||||
|
|
@ -122,7 +129,8 @@ private fun ButtonInternal(
|
|||
onClick: () -> Unit,
|
||||
style: ButtonStyle,
|
||||
modifier: Modifier = Modifier,
|
||||
colors: ButtonColors = style.getColors(),
|
||||
destructive: Boolean = false,
|
||||
colors: ButtonColors = style.getColors(destructive),
|
||||
enabled: Boolean = true,
|
||||
size: ButtonSize = ButtonSize.Large,
|
||||
showProgress: Boolean = false,
|
||||
|
|
@ -170,7 +178,12 @@ private fun ButtonInternal(
|
|||
ButtonStyle.Filled -> null
|
||||
ButtonStyle.Outlined -> BorderStroke(
|
||||
width = 1.dp,
|
||||
color = ElementTheme.colors.borderInteractiveSecondary
|
||||
color = if (destructive)
|
||||
ElementTheme.colors.borderCriticalPrimary.copy(
|
||||
alpha = if (enabled) 1f else 0.5f
|
||||
)
|
||||
else
|
||||
ElementTheme.colors.borderInteractiveSecondary
|
||||
)
|
||||
ButtonStyle.Text -> null
|
||||
}
|
||||
|
|
@ -246,26 +259,52 @@ internal enum class ButtonStyle {
|
|||
Filled, Outlined, Text;
|
||||
|
||||
@Composable
|
||||
fun getColors(): ButtonColors = when (this) {
|
||||
fun getColors(destructive: Boolean): ButtonColors = when (this) {
|
||||
Filled -> ButtonDefaults.buttonColors(
|
||||
containerColor = ElementTheme.materialColors.primary,
|
||||
containerColor = getPrimaryColor(destructive),
|
||||
contentColor = ElementTheme.materialColors.onPrimary,
|
||||
disabledContainerColor = ElementTheme.colors.bgActionPrimaryDisabled,
|
||||
disabledContainerColor = if (destructive) {
|
||||
ElementTheme.colors.bgCriticalPrimary.copy(alpha = 0.5f)
|
||||
} else {
|
||||
ElementTheme.colors.bgActionPrimaryDisabled
|
||||
},
|
||||
disabledContentColor = ElementTheme.colors.textOnSolidPrimary
|
||||
)
|
||||
Outlined -> ButtonDefaults.buttonColors(
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = ElementTheme.materialColors.primary,
|
||||
contentColor = getPrimaryColor(destructive),
|
||||
disabledContainerColor = Color.Transparent,
|
||||
disabledContentColor = ElementTheme.colors.textDisabled,
|
||||
disabledContentColor = getDisabledContentColor(destructive),
|
||||
)
|
||||
Text -> ButtonDefaults.buttonColors(
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = if (LocalContentColor.current.isSpecified) LocalContentColor.current else ElementTheme.materialColors.primary,
|
||||
contentColor = if (destructive) {
|
||||
ElementTheme.colors.textCriticalPrimary
|
||||
} else {
|
||||
if (LocalContentColor.current.isSpecified) LocalContentColor.current else ElementTheme.materialColors.primary
|
||||
},
|
||||
disabledContainerColor = Color.Transparent,
|
||||
disabledContentColor = ElementTheme.colors.textDisabled,
|
||||
disabledContentColor = getDisabledContentColor(destructive),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getPrimaryColor(destructive: Boolean): Color {
|
||||
return if (destructive) {
|
||||
ElementTheme.colors.bgCriticalPrimary
|
||||
} else {
|
||||
ElementTheme.materialColors.primary
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getDisabledContentColor(destructive: Boolean): Color {
|
||||
return if (destructive) {
|
||||
ElementTheme.colors.textCriticalPrimary.copy(alpha = 0.5f)
|
||||
} else {
|
||||
ElementTheme.colors.textDisabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(group = PreviewGroup.Buttons)
|
||||
|
|
@ -326,7 +365,6 @@ internal fun TextButtonLargePreview() {
|
|||
private fun ButtonCombinationPreview(
|
||||
style: ButtonStyle,
|
||||
size: ButtonSize,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
ElementThemedPreview {
|
||||
Column(
|
||||
|
|
@ -335,59 +373,70 @@ private fun ButtonCombinationPreview(
|
|||
.padding(16.dp)
|
||||
.width(IntrinsicSize.Max),
|
||||
) {
|
||||
// Normal
|
||||
ButtonRowPreview(
|
||||
modifier = Modifier.then(modifier),
|
||||
style = style,
|
||||
size = size,
|
||||
)
|
||||
|
||||
// With icon
|
||||
ButtonRowPreview(
|
||||
modifier = Modifier.then(modifier),
|
||||
leadingIcon = IconSource.Resource(CommonDrawables.ic_compound_share_android),
|
||||
style = style,
|
||||
size = size,
|
||||
)
|
||||
|
||||
// With progress
|
||||
ButtonRowPreview(
|
||||
modifier = Modifier.then(modifier),
|
||||
showProgress = true,
|
||||
style = style,
|
||||
size = size,
|
||||
)
|
||||
ButtonMatrixPreview(style = style, size = size, destructive = false)
|
||||
ButtonMatrixPreview(style = style, size = size, destructive = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.ButtonMatrixPreview(
|
||||
style: ButtonStyle,
|
||||
size: ButtonSize,
|
||||
destructive: Boolean,
|
||||
) {
|
||||
// Normal
|
||||
ButtonRowPreview(
|
||||
style = style,
|
||||
size = size,
|
||||
destructive = destructive,
|
||||
)
|
||||
// With icon
|
||||
ButtonRowPreview(
|
||||
leadingIcon = IconSource.Resource(CommonDrawables.ic_compound_share_android),
|
||||
style = style,
|
||||
size = size,
|
||||
destructive = destructive,
|
||||
)
|
||||
// With progress
|
||||
ButtonRowPreview(
|
||||
showProgress = true,
|
||||
style = style,
|
||||
size = size,
|
||||
destructive = destructive,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ButtonRowPreview(
|
||||
style: ButtonStyle,
|
||||
size: ButtonSize,
|
||||
modifier: Modifier = Modifier,
|
||||
leadingIcon: IconSource? = null,
|
||||
showProgress: Boolean = false,
|
||||
destructive: Boolean = false,
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||
) {
|
||||
ButtonInternal(
|
||||
text = "A button",
|
||||
showProgress = showProgress,
|
||||
destructive = destructive,
|
||||
onClick = {},
|
||||
style = style,
|
||||
size = size,
|
||||
leadingIcon = leadingIcon,
|
||||
modifier = Modifier.then(modifier),
|
||||
)
|
||||
ButtonInternal(
|
||||
text = "A button",
|
||||
showProgress = showProgress,
|
||||
destructive = destructive,
|
||||
enabled = false,
|
||||
onClick = {},
|
||||
style = style,
|
||||
size = size,
|
||||
leadingIcon = leadingIcon,
|
||||
modifier = Modifier.then(modifier),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,519 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
@file:OptIn(ExperimentalFoundationApi::class)
|
||||
|
||||
package io.element.android.libraries.designsystem.theme.components.bottomsheet
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.gestures.DraggableAnchors
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.anchoredDraggable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.requiredHeightIn
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.BottomSheetDefaults
|
||||
import androidx.compose.material3.BottomSheetScaffold
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.SheetValue.Expanded
|
||||
import androidx.compose.material3.SheetValue.PartiallyExpanded
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.contentColorFor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.layout.SubcomposeLayout
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.collapse
|
||||
import androidx.compose.ui.semantics.dismiss
|
||||
import androidx.compose.ui.semantics.expand
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
// These are needed until https://issuetracker.google.com/issues/306464779 is fixed
|
||||
|
||||
@Composable
|
||||
@ExperimentalMaterial3Api
|
||||
fun CustomBottomSheetScaffold(
|
||||
sheetContent: @Composable ColumnScope.() -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState(),
|
||||
sheetPeekHeight: Dp = BottomSheetDefaults.SheetPeekHeight,
|
||||
sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth,
|
||||
sheetShape: Shape = BottomSheetDefaults.ExpandedShape,
|
||||
sheetContainerColor: Color = BottomSheetDefaults.ContainerColor,
|
||||
sheetContentColor: Color = contentColorFor(sheetContainerColor),
|
||||
sheetTonalElevation: Dp = BottomSheetDefaults.Elevation,
|
||||
sheetShadowElevation: Dp = BottomSheetDefaults.Elevation,
|
||||
sheetDragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
|
||||
sheetSwipeEnabled: Boolean = true,
|
||||
topBar: @Composable (() -> Unit)? = null,
|
||||
snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) },
|
||||
containerColor: Color = MaterialTheme.colorScheme.surface,
|
||||
contentColor: Color = contentColorFor(containerColor),
|
||||
content: @Composable (PaddingValues) -> Unit
|
||||
) {
|
||||
val peekHeightPx = with(LocalDensity.current) {
|
||||
sheetPeekHeight.roundToPx()
|
||||
}
|
||||
CustomBottomSheetScaffoldLayout(
|
||||
modifier = modifier,
|
||||
topBar = topBar,
|
||||
body = content,
|
||||
snackbarHost = {
|
||||
snackbarHost(scaffoldState.snackbarHostState)
|
||||
},
|
||||
sheetPeekHeight = sheetPeekHeight,
|
||||
sheetOffset = { scaffoldState.bottomSheetState.requireOffset() },
|
||||
sheetState = scaffoldState.bottomSheetState,
|
||||
containerColor = containerColor,
|
||||
contentColor = contentColor,
|
||||
bottomSheet = { layoutHeight ->
|
||||
CustomStandardBottomSheet(
|
||||
state = scaffoldState.bottomSheetState,
|
||||
peekHeight = sheetPeekHeight,
|
||||
sheetMaxWidth = sheetMaxWidth,
|
||||
sheetSwipeEnabled = sheetSwipeEnabled,
|
||||
calculateAnchors = { sheetSize ->
|
||||
val sheetHeight = sheetSize.height
|
||||
io.element.android.libraries.designsystem.theme.components.bottomsheet.DraggableAnchors {
|
||||
if (!scaffoldState.bottomSheetState.skipPartiallyExpanded) {
|
||||
PartiallyExpanded at (layoutHeight - peekHeightPx).toFloat()
|
||||
}
|
||||
if (sheetHeight != peekHeightPx) {
|
||||
Expanded at maxOf(layoutHeight - sheetHeight, 0).toFloat()
|
||||
}
|
||||
if (!scaffoldState.bottomSheetState.skipHiddenState) {
|
||||
SheetValue.Hidden at layoutHeight.toFloat()
|
||||
}
|
||||
}
|
||||
},
|
||||
shape = sheetShape,
|
||||
containerColor = sheetContainerColor,
|
||||
contentColor = sheetContentColor,
|
||||
tonalElevation = sheetTonalElevation,
|
||||
shadowElevation = sheetShadowElevation,
|
||||
dragHandle = sheetDragHandle,
|
||||
content = sheetContent
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressWarnings("ModifierWithoutDefault")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun CustomBottomSheetScaffoldLayout(
|
||||
modifier: Modifier,
|
||||
topBar: @Composable (() -> Unit)?,
|
||||
body: @Composable (innerPadding: PaddingValues) -> Unit,
|
||||
bottomSheet: @Composable (layoutHeight: Int) -> Unit,
|
||||
snackbarHost: @Composable () -> Unit,
|
||||
sheetPeekHeight: Dp,
|
||||
sheetOffset: () -> Float,
|
||||
sheetState: CustomSheetState,
|
||||
containerColor: Color,
|
||||
contentColor: Color,
|
||||
) {
|
||||
// b/291735717 Remove this once deprecated methods without density are removed
|
||||
val density = LocalDensity.current
|
||||
SideEffect {
|
||||
sheetState.density = density
|
||||
}
|
||||
SubcomposeLayout { constraints ->
|
||||
val layoutWidth = constraints.maxWidth
|
||||
val layoutHeight = constraints.maxHeight
|
||||
val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)
|
||||
|
||||
val sheetPlaceable = subcompose(BottomSheetScaffoldLayoutSlot.Sheet) {
|
||||
bottomSheet(layoutHeight)
|
||||
}[0].measure(looseConstraints)
|
||||
|
||||
val topBarPlaceable = topBar?.let {
|
||||
subcompose(BottomSheetScaffoldLayoutSlot.TopBar) { topBar() }[0]
|
||||
.measure(looseConstraints)
|
||||
}
|
||||
val topBarHeight = topBarPlaceable?.height ?: 0
|
||||
|
||||
val bodyConstraints = looseConstraints.copy(maxHeight = layoutHeight - topBarHeight)
|
||||
val bodyPlaceable = subcompose(BottomSheetScaffoldLayoutSlot.Body) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
color = containerColor,
|
||||
contentColor = contentColor,
|
||||
) { body(PaddingValues(bottom = sheetPeekHeight)) }
|
||||
}[0].measure(bodyConstraints)
|
||||
|
||||
val snackbarPlaceable = subcompose(BottomSheetScaffoldLayoutSlot.Snackbar, snackbarHost)[0]
|
||||
.measure(looseConstraints)
|
||||
|
||||
layout(layoutWidth, layoutHeight) {
|
||||
val sheetOffsetY = sheetOffset().roundToInt()
|
||||
val sheetOffsetX = Integer.max(0, (layoutWidth - sheetPlaceable.width) / 2)
|
||||
|
||||
val snackbarOffsetX = (layoutWidth - snackbarPlaceable.width) / 2
|
||||
val snackbarOffsetY = when (sheetState.currentValue) {
|
||||
SheetValue.PartiallyExpanded -> sheetOffsetY - snackbarPlaceable.height
|
||||
SheetValue.Expanded, SheetValue.Hidden -> layoutHeight - snackbarPlaceable.height
|
||||
}
|
||||
|
||||
// Placement order is important for elevation
|
||||
bodyPlaceable.placeRelative(0, topBarHeight)
|
||||
topBarPlaceable?.placeRelative(0, 0)
|
||||
sheetPlaceable.placeRelative(sheetOffsetX, sheetOffsetY)
|
||||
snackbarPlaceable.placeRelative(snackbarOffsetX, snackbarOffsetY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class BottomSheetScaffoldLayoutSlot { TopBar, Body, Sheet, Snackbar }
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun CustomStandardBottomSheet(
|
||||
state: CustomSheetState,
|
||||
@Suppress("PrimitiveInLambda")
|
||||
calculateAnchors: (sheetSize: IntSize) -> DraggableAnchors<SheetValue>,
|
||||
peekHeight: Dp,
|
||||
sheetMaxWidth: Dp,
|
||||
sheetSwipeEnabled: Boolean,
|
||||
shape: Shape,
|
||||
containerColor: Color,
|
||||
contentColor: Color,
|
||||
tonalElevation: Dp,
|
||||
shadowElevation: Dp,
|
||||
dragHandle: @Composable (() -> Unit)?,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val orientation = Orientation.Vertical
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.widthIn(max = sheetMaxWidth)
|
||||
.fillMaxWidth()
|
||||
.requiredHeightIn(min = peekHeight)
|
||||
.apply {
|
||||
if (sheetSwipeEnabled) {
|
||||
nestedScroll(
|
||||
remember(state.anchoredDraggableState) {
|
||||
ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
|
||||
sheetState = state,
|
||||
orientation = orientation,
|
||||
onFling = { scope.launch { state.settle(it) } }
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.anchoredDraggable(
|
||||
state = state.anchoredDraggableState,
|
||||
orientation = orientation,
|
||||
enabled = sheetSwipeEnabled
|
||||
)
|
||||
.onSizeChanged { layoutSize ->
|
||||
val newAnchors = calculateAnchors(layoutSize)
|
||||
val newTarget = when (state.anchoredDraggableState.targetValue) {
|
||||
SheetValue.Hidden, SheetValue.PartiallyExpanded -> SheetValue.PartiallyExpanded
|
||||
SheetValue.Expanded -> {
|
||||
if (newAnchors.hasAnchorFor(SheetValue.Expanded)) SheetValue.Expanded else SheetValue.PartiallyExpanded
|
||||
}
|
||||
}
|
||||
state.anchoredDraggableState.updateAnchors(newAnchors, newTarget)
|
||||
},
|
||||
shape = shape,
|
||||
color = containerColor,
|
||||
contentColor = contentColor,
|
||||
tonalElevation = tonalElevation,
|
||||
shadowElevation = shadowElevation,
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
if (dragHandle != null) {
|
||||
val partialExpandActionLabel =
|
||||
"Partial Expand"
|
||||
val dismissActionLabel = "Dismiss"
|
||||
val expandActionLabel = "Expand"
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.semantics(mergeDescendants = true) {
|
||||
with(state) {
|
||||
// Provides semantics to interact with the bottomsheet if there is more
|
||||
// than one anchor to swipe to and swiping is enabled.
|
||||
if (anchoredDraggableState.anchors.size > 1 && sheetSwipeEnabled) {
|
||||
if (currentValue == SheetValue.PartiallyExpanded) {
|
||||
expand(expandActionLabel) {
|
||||
scope.launch { expand() }; true
|
||||
}
|
||||
} else {
|
||||
collapse(partialExpandActionLabel) {
|
||||
scope.launch { partialExpand() }; true
|
||||
}
|
||||
}
|
||||
if (!state.skipHiddenState) {
|
||||
dismiss(dismissActionLabel) {
|
||||
scope.launch { hide() }
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
dragHandle()
|
||||
}
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [DraggableAnchorsConfig] stores a mutable configuration anchors, comprised of values of [T] and
|
||||
* corresponding [Float] positions. This [DraggableAnchorsConfig] is used to construct an immutable
|
||||
* [DraggableAnchors] instance later on.
|
||||
*/
|
||||
@ExperimentalFoundationApi
|
||||
class DraggableAnchorsConfig<T> {
|
||||
|
||||
internal val anchors = mutableMapOf<T, Float>()
|
||||
|
||||
/**
|
||||
* Set the anchor position for [this] anchor.
|
||||
*
|
||||
* @param position The anchor position.
|
||||
*/
|
||||
@Suppress("BuilderSetStyle")
|
||||
infix fun T.at(position: Float) {
|
||||
anchors[this] = position
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new [DraggableAnchors] instance using a builder function.
|
||||
*
|
||||
* @param T The type of the anchor values.
|
||||
* @param builder A function with a [DraggableAnchorsConfig] that offers APIs to configure anchors
|
||||
* @return A new [DraggableAnchors] instance with the anchor positions set by the `builder`
|
||||
* function.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@ExperimentalMaterial3Api
|
||||
@SuppressWarnings("FunctionName")
|
||||
internal fun <T : Any> DraggableAnchors(
|
||||
builder: DraggableAnchorsConfig<T>.() -> Unit
|
||||
): DraggableAnchors<T> = MapDraggableAnchors(DraggableAnchorsConfig<T>().apply(builder).anchors)
|
||||
|
||||
private class MapDraggableAnchors<T>(private val anchors: Map<T, Float>) : DraggableAnchors<T> {
|
||||
|
||||
override fun positionOf(value: T): Float = anchors[value] ?: Float.NaN
|
||||
override fun hasAnchorFor(value: T) = anchors.containsKey(value)
|
||||
|
||||
override fun closestAnchor(position: Float): T? = anchors.minByOrNull {
|
||||
abs(position - it.value)
|
||||
}?.key
|
||||
|
||||
override fun closestAnchor(
|
||||
position: Float,
|
||||
searchUpwards: Boolean
|
||||
): T? {
|
||||
return anchors.minByOrNull { (_, anchor) ->
|
||||
val delta = if (searchUpwards) anchor - position else position - anchor
|
||||
if (delta < 0) Float.POSITIVE_INFINITY else delta
|
||||
}?.key
|
||||
}
|
||||
|
||||
override fun minAnchor() = anchors.values.minOrNull() ?: Float.NaN
|
||||
|
||||
override fun maxAnchor() = anchors.values.maxOrNull() ?: Float.NaN
|
||||
|
||||
override val size: Int
|
||||
get() = anchors.size
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is MapDraggableAnchors<*>) return false
|
||||
|
||||
return anchors == other.anchors
|
||||
}
|
||||
|
||||
override fun hashCode() = 31 * anchors.hashCode()
|
||||
|
||||
override fun toString() = "MapDraggableAnchors($anchors)"
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@SuppressWarnings("FunctionName")
|
||||
internal fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
|
||||
sheetState: CustomSheetState,
|
||||
orientation: Orientation,
|
||||
onFling: (velocity: Float) -> Unit
|
||||
): NestedScrollConnection = object : NestedScrollConnection {
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
val delta = available.toFloat()
|
||||
return if (delta < 0 && source == NestedScrollSource.Drag) {
|
||||
sheetState.anchoredDraggableState.dispatchRawDelta(delta).toOffset()
|
||||
} else {
|
||||
Offset.Zero
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPostScroll(
|
||||
consumed: Offset,
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset {
|
||||
return if (source == NestedScrollSource.Drag) {
|
||||
sheetState.anchoredDraggableState.dispatchRawDelta(available.toFloat()).toOffset()
|
||||
} else {
|
||||
Offset.Zero
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun onPreFling(available: Velocity): Velocity {
|
||||
val toFling = available.toFloat()
|
||||
val currentOffset = sheetState.requireOffset()
|
||||
val minAnchor = sheetState.anchoredDraggableState.anchors.minAnchor()
|
||||
return if (toFling < 0 && currentOffset > minAnchor) {
|
||||
onFling(toFling)
|
||||
// since we go to the anchor with tween settling, consume all for the best UX
|
||||
available
|
||||
} else {
|
||||
Velocity.Zero
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||
onFling(available.toFloat())
|
||||
return available
|
||||
}
|
||||
|
||||
private fun Float.toOffset(): Offset = Offset(
|
||||
x = if (orientation == Orientation.Horizontal) this else 0f,
|
||||
y = if (orientation == Orientation.Vertical) this else 0f
|
||||
)
|
||||
|
||||
@JvmName("velocityToFloat")
|
||||
private fun Velocity.toFloat() = if (orientation == Orientation.Horizontal) x else y
|
||||
|
||||
@JvmName("offsetToFloat")
|
||||
private fun Offset.toFloat(): Float = if (orientation == Orientation.Horizontal) x else y
|
||||
}
|
||||
|
||||
/**
|
||||
* State of the [BottomSheetScaffold] composable.
|
||||
*
|
||||
* @param bottomSheetState the state of the persistent bottom sheet
|
||||
* @param snackbarHostState the [SnackbarHostState] used to show snackbars inside the scaffold
|
||||
*/
|
||||
@ExperimentalMaterial3Api
|
||||
@Stable
|
||||
@SuppressWarnings("UseDataClass")
|
||||
class BottomSheetScaffoldState(
|
||||
val bottomSheetState: CustomSheetState,
|
||||
val snackbarHostState: SnackbarHostState
|
||||
)
|
||||
|
||||
/**
|
||||
* Create and [remember] a [BottomSheetScaffoldState].
|
||||
*
|
||||
* @param bottomSheetState the state of the standard bottom sheet. See
|
||||
* [rememberStandardBottomSheetState]
|
||||
* @param snackbarHostState the [SnackbarHostState] used to show snackbars inside the scaffold
|
||||
*/
|
||||
@Composable
|
||||
@ExperimentalMaterial3Api
|
||||
fun rememberBottomSheetScaffoldState(
|
||||
bottomSheetState: CustomSheetState = rememberStandardBottomSheetState(),
|
||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }
|
||||
): BottomSheetScaffoldState {
|
||||
return remember(bottomSheetState, snackbarHostState) {
|
||||
BottomSheetScaffoldState(
|
||||
bottomSheetState = bottomSheetState,
|
||||
snackbarHostState = snackbarHostState
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and [remember] a [SheetState] for [BottomSheetScaffold].
|
||||
*
|
||||
* @param initialValue the initial value of the state. Should be either [PartiallyExpanded] or
|
||||
* [Expanded] if [skipHiddenState] is true
|
||||
* @param confirmValueChange optional callback invoked to confirm or veto a pending state change
|
||||
* @param [skipHiddenState] whether Hidden state is skipped for [BottomSheetScaffold]
|
||||
*/
|
||||
@Composable
|
||||
@ExperimentalMaterial3Api
|
||||
fun rememberStandardBottomSheetState(
|
||||
initialValue: SheetValue = PartiallyExpanded,
|
||||
confirmValueChange: (SheetValue) -> Boolean = { true },
|
||||
skipHiddenState: Boolean = true,
|
||||
) = rememberSheetState(false, confirmValueChange, initialValue, skipHiddenState)
|
||||
|
||||
@Composable
|
||||
@ExperimentalMaterial3Api
|
||||
internal fun rememberSheetState(
|
||||
skipPartiallyExpanded: Boolean = false,
|
||||
confirmValueChange: (SheetValue) -> Boolean = { true },
|
||||
initialValue: SheetValue = SheetValue.Hidden,
|
||||
skipHiddenState: Boolean = false,
|
||||
): CustomSheetState {
|
||||
|
||||
val density = LocalDensity.current
|
||||
return rememberSaveable(
|
||||
skipPartiallyExpanded, confirmValueChange,
|
||||
saver = CustomSheetState.Saver(
|
||||
skipPartiallyExpanded = skipPartiallyExpanded,
|
||||
confirmValueChange = confirmValueChange,
|
||||
density = density
|
||||
)
|
||||
) {
|
||||
CustomSheetState(
|
||||
skipPartiallyExpanded,
|
||||
density,
|
||||
initialValue,
|
||||
confirmValueChange,
|
||||
skipHiddenState
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,309 @@
|
|||
/*
|
||||
* 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.bottomsheet
|
||||
|
||||
import androidx.compose.animation.core.SpringSpec
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.gestures.animateTo
|
||||
import androidx.compose.foundation.gestures.snapTo
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.SheetValue.Expanded
|
||||
import androidx.compose.material3.SheetValue.Hidden
|
||||
import androidx.compose.material3.SheetValue.PartiallyExpanded
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Stable
|
||||
@ExperimentalMaterial3Api
|
||||
class CustomSheetState @Deprecated(
|
||||
message = "This constructor is deprecated. " +
|
||||
"Please use the constructor that provides a [Density]",
|
||||
replaceWith = ReplaceWith(
|
||||
"SheetState(" +
|
||||
"skipPartiallyExpanded, LocalDensity.current, initialValue, " +
|
||||
"confirmValueChange, skipHiddenState)"
|
||||
)
|
||||
) constructor(
|
||||
internal val skipPartiallyExpanded: Boolean,
|
||||
initialValue: SheetValue = Hidden,
|
||||
confirmValueChange: (SheetValue) -> Boolean = { true },
|
||||
internal val skipHiddenState: Boolean = false,
|
||||
) {
|
||||
|
||||
/**
|
||||
* State of a sheet composable, such as [ModalBottomSheet]
|
||||
*
|
||||
* Contains states relating to its swipe position as well as animations between state values.
|
||||
*
|
||||
* @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is large
|
||||
* enough, should be skipped. If true, the sheet will always expand to the [Expanded] state and move
|
||||
* to the [Hidden] state if available when hiding the sheet, either programmatically or by user
|
||||
* interaction.
|
||||
* @param density The density that this state can use to convert values to and from dp.
|
||||
* @param initialValue The initial value of the state.
|
||||
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
|
||||
* @param skipHiddenState Whether the hidden state should be skipped. If true, the sheet will always
|
||||
* expand to the [Expanded] state and move to the [PartiallyExpanded] if available, either
|
||||
* programmatically or by user interaction.
|
||||
*/
|
||||
@ExperimentalMaterial3Api
|
||||
@Suppress("Deprecation")
|
||||
constructor(
|
||||
skipPartiallyExpanded: Boolean,
|
||||
density: Density,
|
||||
initialValue: SheetValue = Hidden,
|
||||
confirmValueChange: (SheetValue) -> Boolean = { true },
|
||||
skipHiddenState: Boolean = false,
|
||||
) : this(skipPartiallyExpanded, initialValue, confirmValueChange, skipHiddenState) {
|
||||
this.density = density
|
||||
}
|
||||
init {
|
||||
if (skipPartiallyExpanded) {
|
||||
require(initialValue != PartiallyExpanded) {
|
||||
"The initial value must not be set to PartiallyExpanded if skipPartiallyExpanded " +
|
||||
"is set to true."
|
||||
}
|
||||
}
|
||||
if (skipHiddenState) {
|
||||
require(initialValue != Hidden) {
|
||||
"The initial value must not be set to Hidden if skipHiddenState is set to true."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The current value of the state.
|
||||
*
|
||||
* If no swipe or animation is in progress, this corresponds to the state the bottom sheet is
|
||||
* currently in. If a swipe or an animation is in progress, this corresponds the state the sheet
|
||||
* was in before the swipe or animation started.
|
||||
*/
|
||||
|
||||
val currentValue: SheetValue get() = anchoredDraggableState.currentValue
|
||||
|
||||
/**
|
||||
* The target value of the bottom sheet state.
|
||||
*
|
||||
* If a swipe is in progress, this is the value that the sheet would animate to if the
|
||||
* swipe finishes. If an animation is running, this is the target value of that animation.
|
||||
* Finally, if no swipe or animation is in progress, this is the same as the [currentValue].
|
||||
*/
|
||||
val targetValue: SheetValue get() = anchoredDraggableState.targetValue
|
||||
|
||||
/**
|
||||
* Whether the modal bottom sheet is visible.
|
||||
*/
|
||||
val isVisible: Boolean
|
||||
get() = anchoredDraggableState.currentValue != Hidden
|
||||
|
||||
/**
|
||||
* Require the current offset (in pixels) of the bottom sheet.
|
||||
*
|
||||
* The offset will be initialized during the first measurement phase of the provided sheet
|
||||
* content.
|
||||
*
|
||||
* These are the phases:
|
||||
* Composition { -> Effects } -> Layout { Measurement -> Placement } -> Drawing
|
||||
*
|
||||
* During the first composition, an [IllegalStateException] is thrown. In subsequent
|
||||
* compositions, the offset will be derived from the anchors of the previous pass. Always prefer
|
||||
* accessing the offset from a LaunchedEffect as it will be scheduled to be executed the next
|
||||
* frame, after layout.
|
||||
*
|
||||
* @throws IllegalStateException If the offset has not been initialized yet
|
||||
*/
|
||||
fun requireOffset(): Float = anchoredDraggableState.requireOffset()
|
||||
|
||||
fun getOffset(): Float? = anchoredDraggableState.offset.takeIf { !it.isNaN() }
|
||||
|
||||
/**
|
||||
* Whether the sheet has an expanded state defined.
|
||||
*/
|
||||
|
||||
val hasExpandedState: Boolean
|
||||
get() = anchoredDraggableState.anchors.hasAnchorFor(Expanded)
|
||||
|
||||
/**
|
||||
* Whether the modal bottom sheet has a partially expanded state defined.
|
||||
*/
|
||||
val hasPartiallyExpandedState: Boolean
|
||||
get() = anchoredDraggableState.anchors.hasAnchorFor(PartiallyExpanded)
|
||||
|
||||
/**
|
||||
* Fully expand the bottom sheet with animation and suspend until it is fully expanded or
|
||||
* animation has been cancelled.
|
||||
* *
|
||||
* @throws [CancellationException] if the animation is interrupted
|
||||
*/
|
||||
suspend fun expand() {
|
||||
anchoredDraggableState.animateTo(Expanded)
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate the bottom sheet and suspend until it is partially expanded or animation has been
|
||||
* cancelled.
|
||||
* @throws [CancellationException] if the animation is interrupted
|
||||
* @throws [IllegalStateException] if [skipPartiallyExpanded] is set to true
|
||||
*/
|
||||
suspend fun partialExpand() {
|
||||
check(!skipPartiallyExpanded) {
|
||||
"Attempted to animate to partial expanded when skipPartiallyExpanded was enabled. Set" +
|
||||
" skipPartiallyExpanded to false to use this function."
|
||||
}
|
||||
animateTo(PartiallyExpanded)
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand the bottom sheet with animation and suspend until it is [PartiallyExpanded] if defined
|
||||
* else [Expanded].
|
||||
* @throws [CancellationException] if the animation is interrupted
|
||||
*/
|
||||
suspend fun show() {
|
||||
val targetValue = when {
|
||||
hasPartiallyExpandedState -> PartiallyExpanded
|
||||
else -> Expanded
|
||||
}
|
||||
animateTo(targetValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the bottom sheet with animation and suspend until it is fully hidden or animation has
|
||||
* been cancelled.
|
||||
* @throws [CancellationException] if the animation is interrupted
|
||||
*/
|
||||
suspend fun hide() {
|
||||
check(!skipHiddenState) {
|
||||
"Attempted to animate to hidden when skipHiddenState was enabled. Set skipHiddenState" +
|
||||
" to false to use this function."
|
||||
}
|
||||
animateTo(Hidden)
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate to a [targetValue].
|
||||
* If the [targetValue] is not in the set of anchors, the [currentValue] will be updated to the
|
||||
* [targetValue] without updating the offset.
|
||||
*
|
||||
* @throws CancellationException if the interaction interrupted by another interaction like a
|
||||
* gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
|
||||
*
|
||||
* @param targetValue The target value of the animation
|
||||
* @param velocity The velocity of the animation
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
internal suspend fun animateTo(
|
||||
targetValue: SheetValue,
|
||||
velocity: Float = anchoredDraggableState.lastVelocity
|
||||
) {
|
||||
anchoredDraggableState.animateTo(targetValue, velocity)
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap to a [targetValue] without any animation.
|
||||
*
|
||||
* @throws CancellationException if the interaction interrupted by another interaction like a
|
||||
* gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
|
||||
*
|
||||
* @param targetValue The target value of the animation
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
internal suspend fun snapTo(targetValue: SheetValue) {
|
||||
anchoredDraggableState.snapTo(targetValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the closest anchor taking into account the velocity and settle at it with an animation.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
internal suspend fun settle(velocity: Float) {
|
||||
anchoredDraggableState.settle(velocity)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
internal var anchoredDraggableState = androidx.compose.foundation.gestures.AnchoredDraggableState(
|
||||
initialValue = initialValue,
|
||||
animationSpec = AnchoredDraggableDefaults.AnimationSpec,
|
||||
confirmValueChange = confirmValueChange,
|
||||
positionalThreshold = { with(requireDensity()) { 56.dp.toPx() } },
|
||||
velocityThreshold = { with(requireDensity()) { 125.dp.toPx() } }
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
internal val offset: Float? get() = anchoredDraggableState.offset
|
||||
|
||||
internal var density: Density? = null
|
||||
private fun requireDensity() = requireNotNull(density) {
|
||||
"SheetState did not have a density attached. Are you using SheetState with " +
|
||||
"BottomSheetScaffold or ModalBottomSheet component?"
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The default [Saver] implementation for [SheetState].
|
||||
*/
|
||||
@SuppressWarnings("FunctionName")
|
||||
fun Saver(
|
||||
skipPartiallyExpanded: Boolean,
|
||||
confirmValueChange: (SheetValue) -> Boolean,
|
||||
density: Density
|
||||
) = Saver<CustomSheetState, SheetValue>(
|
||||
save = { it.currentValue },
|
||||
restore = { savedValue ->
|
||||
CustomSheetState(skipPartiallyExpanded, density, savedValue, confirmValueChange)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* The default [Saver] implementation for [SheetState].
|
||||
*/
|
||||
@Deprecated(
|
||||
message = "This function is deprecated. Please use the overload where Density is" +
|
||||
" provided.",
|
||||
replaceWith = ReplaceWith(
|
||||
"Saver(skipPartiallyExpanded, confirmValueChange, LocalDensity.current)"
|
||||
)
|
||||
)
|
||||
@Suppress("Deprecation", "FunctionName")
|
||||
fun Saver(
|
||||
skipPartiallyExpanded: Boolean,
|
||||
confirmValueChange: (SheetValue) -> Boolean
|
||||
) = Saver<CustomSheetState, SheetValue>(
|
||||
save = { it.currentValue },
|
||||
restore = { savedValue ->
|
||||
CustomSheetState(skipPartiallyExpanded, savedValue, confirmValueChange)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
@ExperimentalMaterial3Api
|
||||
internal object AnchoredDraggableDefaults {
|
||||
/**
|
||||
* The default animation used by [AnchoredDraggableState].
|
||||
*/
|
||||
@get:ExperimentalMaterial3Api
|
||||
@Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
|
||||
@ExperimentalMaterial3Api
|
||||
val AnimationSpec = SpringSpec<Float>()
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M12.029,14.15C11.176,14.15 10.446,13.846 9.837,13.238C9.229,12.629 8.925,11.9 8.925,11.05V5.075C8.925,4.221 9.229,3.495 9.837,2.897C10.446,2.299 11.176,2 12.029,2C12.882,2 13.607,2.299 14.204,2.897C14.801,3.495 15.1,4.221 15.1,5.075V11.05C15.1,11.9 14.801,12.629 14.204,13.238C13.607,13.846 12.882,14.15 12.029,14.15ZM12.025,21C11.742,21 11.496,20.896 11.288,20.688C11.079,20.479 10.975,20.233 10.975,19.95V18.005C9.442,17.802 8.142,17.163 7.075,16.087C6.008,15.012 5.35,13.725 5.1,12.225C5.05,11.915 5.131,11.642 5.344,11.405C5.557,11.168 5.842,11.05 6.2,11.05C6.433,11.05 6.642,11.133 6.825,11.3C7.008,11.467 7.125,11.683 7.175,11.95C7.388,13.095 7.946,14.052 8.849,14.821C9.752,15.59 10.81,15.975 12.025,15.975C13.233,15.975 14.286,15.59 15.184,14.821C16.083,14.052 16.638,13.095 16.85,11.95C16.9,11.683 17.017,11.467 17.2,11.3C17.383,11.133 17.6,11.05 17.851,11.05C18.191,11.05 18.468,11.168 18.681,11.405C18.893,11.642 18.975,11.915 18.925,12.225C18.695,13.728 18.046,15.016 16.977,16.09C15.909,17.163 14.608,17.802 13.075,18.005V19.95C13.075,20.233 12.971,20.479 12.762,20.688C12.554,20.896 12.308,21 12.025,21Z"
|
||||
android:fillColor="#1B1D22"/>
|
||||
</vector>
|
||||
|
|
@ -128,7 +128,11 @@ class DefaultRoomLastMessageFormatter @Inject constructor(
|
|||
sp.getString(CommonStrings.common_file)
|
||||
}
|
||||
is AudioMessageType -> {
|
||||
sp.getString(CommonStrings.common_audio)
|
||||
if (messageType.isVoiceMessage) {
|
||||
sp.getString(CommonStrings.common_voice_message)
|
||||
} else {
|
||||
sp.getString(CommonStrings.common_audio)
|
||||
}
|
||||
}
|
||||
is OtherMessageType -> {
|
||||
messageType.body
|
||||
|
|
|
|||
|
|
@ -163,6 +163,7 @@ class DefaultRoomLastMessageFormatterTest {
|
|||
TextMessageType(body, null),
|
||||
VideoMessageType(body, MediaSource("url"), null),
|
||||
AudioMessageType(body, MediaSource("url"), null, null, false),
|
||||
AudioMessageType(body, MediaSource("url"), null, null, true),
|
||||
ImageMessageType(body, MediaSource("url"), null),
|
||||
FileMessageType(body, MediaSource("url"), null),
|
||||
LocationMessageType(body, "geo:1,2", null),
|
||||
|
|
@ -198,7 +199,12 @@ class DefaultRoomLastMessageFormatterTest {
|
|||
for ((type, result) in resultsInDm) {
|
||||
val expectedResult = when (type) {
|
||||
is VideoMessageType -> "Video"
|
||||
is AudioMessageType -> "Audio"
|
||||
is AudioMessageType -> {
|
||||
when (type.isVoiceMessage) {
|
||||
true -> "Voice message"
|
||||
false -> "Audio"
|
||||
}
|
||||
}
|
||||
is ImageMessageType -> "Image"
|
||||
is FileMessageType -> "File"
|
||||
is LocationMessageType -> "Shared location"
|
||||
|
|
@ -216,7 +222,12 @@ class DefaultRoomLastMessageFormatterTest {
|
|||
val string = result.toString()
|
||||
val expectedResult = when (type) {
|
||||
is VideoMessageType -> "$senderName: Video"
|
||||
is AudioMessageType -> "$senderName: Audio"
|
||||
is AudioMessageType -> {
|
||||
when (type.isVoiceMessage) {
|
||||
true -> "$senderName: Voice message"
|
||||
false -> "$senderName: Audio"
|
||||
}
|
||||
}
|
||||
is ImageMessageType -> "$senderName: Image"
|
||||
is FileMessageType -> "$senderName: File"
|
||||
is LocationMessageType -> "$senderName: Shared location"
|
||||
|
|
@ -228,7 +239,12 @@ class DefaultRoomLastMessageFormatterTest {
|
|||
}
|
||||
val shouldCreateAnnotatedString = when (type) {
|
||||
is VideoMessageType -> true
|
||||
is AudioMessageType -> true
|
||||
is AudioMessageType -> {
|
||||
when (type.isVoiceMessage) {
|
||||
true -> true
|
||||
false -> true
|
||||
}
|
||||
}
|
||||
is ImageMessageType -> true
|
||||
is FileMessageType -> true
|
||||
is LocationMessageType -> false
|
||||
|
|
|
|||
|
|
@ -60,5 +60,11 @@ enum class FeatureFlags(
|
|||
title = "Element call in rooms",
|
||||
description = "Allow user to start or join a call in a room",
|
||||
defaultValue = false,
|
||||
),
|
||||
Mentions(
|
||||
key = "feature.mentions",
|
||||
title = "Mentions",
|
||||
description = "Type `@` to get mention suggestions and insert them",
|
||||
defaultValue = false,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ class StaticFeatureFlagProvider @Inject constructor() :
|
|||
FeatureFlags.VoiceMessages -> false
|
||||
FeatureFlags.PinUnlock -> false
|
||||
FeatureFlags.InRoomCalls -> false
|
||||
FeatureFlags.Mentions -> false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
|
|
|
|||
|
|
@ -130,6 +130,8 @@ interface MatrixRoom : Closeable {
|
|||
|
||||
suspend fun canUserSendMessage(userId: UserId, type: MessageEventType): Result<Boolean>
|
||||
|
||||
suspend fun canUserTriggerRoomNotification(userId: UserId): Result<Boolean>
|
||||
|
||||
suspend fun updateAvatar(mimeType: String, data: ByteArray): Result<Unit>
|
||||
|
||||
suspend fun removeAvatar(): Result<Unit>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* 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.matrix.api.user
|
||||
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.di.SingleIn
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import javax.inject.Inject
|
||||
|
||||
@SingleIn(SessionScope::class)
|
||||
class CurrentSessionIdHolder @Inject constructor(matrixClient: MatrixClient) {
|
||||
val current = matrixClient.sessionId
|
||||
|
||||
fun isCurrentSession(sessionId: SessionId?): Boolean = current == sessionId
|
||||
}
|
||||
|
|
@ -349,6 +349,12 @@ class RustMatrixRoom(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun canUserTriggerRoomNotification(userId: UserId): Result<Boolean> {
|
||||
return runCatching {
|
||||
innerRoom.canUserTriggerRoomNotification(userId.value)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> {
|
||||
return sendAttachment(listOf(file, thumbnailFile)) {
|
||||
innerRoom.sendImage(file.path, thumbnailFile.path, imageInfo.map(), progressCallback?.toProgressWatcher())
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ class FakeMatrixRoom(
|
|||
override val alternativeAliases: List<String> = emptyList(),
|
||||
override val isPublic: Boolean = true,
|
||||
override val isDirect: Boolean = false,
|
||||
override val isOneToOne: Boolean = false,
|
||||
override val joinedMemberCount: Long = 123L,
|
||||
override val activeMemberCount: Long = 234L,
|
||||
val notificationSettingsService: NotificationSettingsService = FakeNotificationSettingsService(),
|
||||
|
|
@ -106,6 +107,7 @@ class FakeMatrixRoom(
|
|||
private var progressCallbackValues = emptyList<Pair<Long, Long>>()
|
||||
private var generateWidgetWebViewUrlResult = Result.success("https://call.element.io")
|
||||
private var getWidgetDriverResult: Result<MatrixWidgetDriver> = Result.success(FakeWidgetDriver())
|
||||
private var canUserTriggerRoomNotificationResult: Result<Boolean> = Result.success(true)
|
||||
val editMessageCalls = mutableListOf<Pair<String, String?>>()
|
||||
|
||||
var sendMediaCount = 0
|
||||
|
|
@ -270,6 +272,10 @@ class FakeMatrixRoom(
|
|||
return canSendEventResults[type] ?: Result.failure(IllegalStateException("No fake answer"))
|
||||
}
|
||||
|
||||
override suspend fun canUserTriggerRoomNotification(userId: UserId): Result<Boolean> {
|
||||
return canUserTriggerRoomNotificationResult
|
||||
}
|
||||
|
||||
override suspend fun sendImage(
|
||||
file: File,
|
||||
thumbnailFile: File,
|
||||
|
|
@ -434,6 +440,10 @@ class FakeMatrixRoom(
|
|||
canSendEventResults[type] = result
|
||||
}
|
||||
|
||||
fun givenCanTriggerRoomNotification(result: Result<Boolean>) {
|
||||
canUserTriggerRoomNotificationResult = result
|
||||
}
|
||||
|
||||
fun givenIgnoreResult(result: Result<Unit>) {
|
||||
ignoreResult = result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,12 @@ fun AttachmentThumbnail(
|
|||
contentDescription = info.textContent,
|
||||
)
|
||||
}
|
||||
AttachmentThumbnailType.Voice -> {
|
||||
Icon(
|
||||
resourceId = CommonDrawables.ic_voice_attachment,
|
||||
contentDescription = info.textContent,
|
||||
)
|
||||
}
|
||||
AttachmentThumbnailType.File -> {
|
||||
Icon(
|
||||
resourceId = CommonDrawables.ic_september_attachment,
|
||||
|
|
@ -95,7 +101,7 @@ fun AttachmentThumbnail(
|
|||
|
||||
@Parcelize
|
||||
enum class AttachmentThumbnailType : Parcelable {
|
||||
Image, Video, File, Audio, Location
|
||||
Image, Video, File, Audio, Location, Voice
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -75,6 +76,7 @@ import io.element.android.libraries.textcomposer.model.Message
|
|||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.PressEvent
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
|
||||
import io.element.android.libraries.textcomposer.model.Suggestion
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
|
@ -84,6 +86,7 @@ import kotlinx.collections.immutable.ImmutableList
|
|||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import uniffi.wysiwyg_composer.MenuAction
|
||||
|
||||
@Composable
|
||||
fun TextComposer(
|
||||
|
|
@ -105,6 +108,7 @@ fun TextComposer(
|
|||
onSendVoiceMessage: () -> Unit = {},
|
||||
onDeleteVoiceMessage: () -> Unit = {},
|
||||
onError: (Throwable) -> Unit = {},
|
||||
onSuggestionReceived: (Suggestion?) -> Unit = {},
|
||||
) {
|
||||
val onSendClicked = {
|
||||
val html = if (enableTextFormatting) state.messageHtml else null
|
||||
|
|
@ -123,27 +127,31 @@ fun TextComposer(
|
|||
.fillMaxSize()
|
||||
.height(IntrinsicSize.Min)
|
||||
|
||||
val composerOptionsButton = @Composable {
|
||||
ComposerOptionsButton(
|
||||
modifier = Modifier
|
||||
.size(48.dp),
|
||||
onClick = onAddAttachment
|
||||
)
|
||||
val composerOptionsButton: @Composable () -> Unit = remember {
|
||||
@Composable {
|
||||
ComposerOptionsButton(
|
||||
modifier = Modifier
|
||||
.size(48.dp),
|
||||
onClick = onAddAttachment
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val textInput = @Composable {
|
||||
TextInput(
|
||||
state = state,
|
||||
subcomposing = subcomposing,
|
||||
placeholder = if (composerMode.inThread) {
|
||||
stringResource(id = CommonStrings.action_reply_in_thread)
|
||||
} else {
|
||||
stringResource(id = R.string.rich_text_editor_composer_placeholder)
|
||||
},
|
||||
composerMode = composerMode,
|
||||
onResetComposerMode = onResetComposerMode,
|
||||
onError = onError,
|
||||
)
|
||||
val textInput: @Composable () -> Unit = remember(state, subcomposing, composerMode, onResetComposerMode, onError) {
|
||||
@Composable {
|
||||
TextInput(
|
||||
state = state,
|
||||
subcomposing = subcomposing,
|
||||
placeholder = if (composerMode.inThread) {
|
||||
stringResource(id = CommonStrings.action_reply_in_thread)
|
||||
} else {
|
||||
stringResource(id = R.string.rich_text_editor_composer_placeholder)
|
||||
},
|
||||
composerMode = composerMode,
|
||||
onResetComposerMode = onResetComposerMode,
|
||||
onError = onError,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val canSendMessage by remember { derivedStateOf { state.messageHtml.isNotEmpty() } }
|
||||
|
|
@ -250,6 +258,16 @@ fun TextComposer(
|
|||
|
||||
SoftKeyboardEffect(showTextFormatting, onRequestFocus) { it }
|
||||
}
|
||||
|
||||
val menuAction = state.menuAction
|
||||
LaunchedEffect(menuAction) {
|
||||
if (menuAction is MenuAction.Suggestion) {
|
||||
val suggestion = Suggestion(menuAction.suggestionPattern)
|
||||
onSuggestionReceived(suggestion)
|
||||
} else {
|
||||
onSuggestionReceived(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* 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.textcomposer.model
|
||||
|
||||
import uniffi.wysiwyg_composer.PatternKey
|
||||
import uniffi.wysiwyg_composer.SuggestionPattern
|
||||
|
||||
data class Suggestion(
|
||||
val start: Int,
|
||||
val end: Int,
|
||||
val type: SuggestionType,
|
||||
val text: String,
|
||||
) {
|
||||
constructor(suggestion: SuggestionPattern): this(
|
||||
suggestion.start.toInt(),
|
||||
suggestion.end.toInt(),
|
||||
SuggestionType.fromPatternKey(suggestion.key),
|
||||
suggestion.text,
|
||||
)
|
||||
}
|
||||
|
||||
enum class SuggestionType {
|
||||
Mention,
|
||||
Command,
|
||||
Room;
|
||||
|
||||
companion object {
|
||||
fun fromPatternKey(key: PatternKey): SuggestionType {
|
||||
return when (key) {
|
||||
PatternKey.AT -> Mention
|
||||
PatternKey.SLASH -> Command
|
||||
PatternKey.HASH -> Room
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue