Source added

This commit is contained in:
Fr4nz D13trich 2025-11-20 09:26:33 +01:00
parent b2864b500e
commit ba28ca859e
8352 changed files with 1487182 additions and 1 deletions

35
core-ui/build.gradle.kts Normal file
View file

@ -0,0 +1,35 @@
plugins {
id("signal-library")
alias(libs.plugins.compose.compiler)
alias(libs.plugins.kotlinx.serialization)
}
android {
namespace = "org.signal.core.ui"
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.4"
}
}
dependencies {
lintChecks(project(":lintchecks"))
platform(libs.androidx.compose.bom).let { composeBom ->
api(composeBom)
androidTestApi(composeBom)
}
api(libs.androidx.compose.material3)
api(libs.androidx.compose.material3.adaptive)
api(libs.androidx.compose.material3.adaptive.layout)
api(libs.androidx.compose.material3.adaptive.navigation)
api(libs.androidx.compose.ui.tooling.preview)
debugApi(libs.androidx.compose.ui.tooling.core)
api(libs.androidx.fragment.compose)
implementation(libs.kotlinx.serialization.json)
}

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View file

@ -0,0 +1,44 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose
import android.os.Build
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.core.app.PictureInPictureModeChangedInfo
import androidx.core.util.Consumer
/**
* Returns whether the screen is currently in the system picture-in-picture mode.
*
* This requires an AppCompatActivity context, so it cannot be utilized in Composables
* that require a preview.
*/
@Composable
fun rememberIsInPipMode(): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = LocalContext.current as AppCompatActivity
var pipMode: Boolean by remember { mutableStateOf(activity.isInPictureInPictureMode) }
DisposableEffect(activity) {
val observer = Consumer<PictureInPictureModeChangedInfo> { info ->
pipMode = info.isInPictureInPictureMode
}
activity.addOnPictureInPictureModeChangedListener(
observer
)
onDispose { activity.removeOnPictureInPictureModeChangedListener(observer) }
}
return pipMode
} else {
return false
}
}

View file

@ -0,0 +1,41 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.runtime.Stable
import kotlin.time.Duration.Companion.milliseconds
object Animations {
private val NAV_HOST_DEFAULT_ANIMATION_DURATION = 200.milliseconds
@Stable
fun <T> navHostDefaultAnimationSpec(): FiniteAnimationSpec<T> {
return tween<T>(
durationMillis = NAV_HOST_DEFAULT_ANIMATION_DURATION.inWholeMilliseconds.toInt()
)
}
fun navHostSlideInTransition(initialOffsetX: (Int) -> Int): EnterTransition {
return slideInHorizontally(
animationSpec = navHostDefaultAnimationSpec(),
initialOffsetX = initialOffsetX
)
}
fun navHostSlideOutTransition(targetOffsetX: (Int) -> Int): ExitTransition {
return slideOutHorizontally(
animationSpec = navHostDefaultAnimationSpec(),
targetOffsetX = targetOffsetX
)
}
}

View file

@ -0,0 +1,43 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.theme.SignalTheme
object BottomSheets {
/**
* Handle for bottom sheets
*/
@Composable
fun Handle(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.size(width = 48.dp, height = 22.dp)
.padding(vertical = 10.dp)
.clip(RoundedCornerShape(1000.dp))
.background(MaterialTheme.colorScheme.outline)
)
}
}
@Preview
@Composable
private fun HandlePreview() {
SignalTheme(isDarkMode = false) {
BottomSheets.Handle()
}
}

View file

@ -0,0 +1,401 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ButtonElevation
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.theme.SignalTheme
object Buttons {
private val largeButtonContentPadding = PaddingValues(
horizontal = 24.dp,
vertical = 12.dp
)
private val mediumButtonContentPadding = PaddingValues(
horizontal = 24.dp,
vertical = 10.dp
)
private val smallButtonContentPadding = PaddingValues(
horizontal = 16.dp,
vertical = 8.dp
)
@Composable
fun LargePrimary(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
shape: Shape = ButtonDefaults.shape,
colors: ButtonColors = ButtonDefaults.buttonColors(),
elevation: ButtonElevation? = ButtonDefaults.buttonElevation(),
border: BorderStroke? = null,
contentPadding: PaddingValues = largeButtonContentPadding,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
content: @Composable RowScope.() -> Unit
) {
Button(
onClick = onClick,
modifier = modifier,
enabled = enabled,
shape = shape,
colors = colors,
elevation = elevation,
border = border,
contentPadding = contentPadding,
interactionSource = interactionSource,
content = content
)
}
@Composable
fun LargeTonal(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
shape: Shape = ButtonDefaults.filledTonalShape,
colors: ButtonColors = ButtonDefaults.filledTonalButtonColors(),
elevation: ButtonElevation? = ButtonDefaults.filledTonalButtonElevation(),
border: BorderStroke? = null,
contentPadding: PaddingValues = largeButtonContentPadding,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
content: @Composable RowScope.() -> Unit
) {
FilledTonalButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
shape = shape,
colors = colors,
elevation = elevation,
border = border,
contentPadding = contentPadding,
interactionSource = interactionSource,
content = content
)
}
@Composable
fun MediumTonal(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
shape: Shape = ButtonDefaults.filledTonalShape,
colors: ButtonColors = ButtonDefaults.filledTonalButtonColors(),
elevation: ButtonElevation? = ButtonDefaults.filledTonalButtonElevation(),
border: BorderStroke? = null,
contentPadding: PaddingValues = mediumButtonContentPadding,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
content: @Composable RowScope.() -> Unit
) {
FilledTonalButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
shape = shape,
colors = colors,
elevation = elevation,
border = border,
contentPadding = contentPadding,
interactionSource = interactionSource,
content = content
)
}
@Composable
fun Small(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
shape: Shape = ButtonDefaults.shape,
colors: ButtonColors = ButtonDefaults.buttonColors(
containerColor = SignalTheme.colors.colorSurface2,
contentColor = MaterialTheme.colorScheme.onSurface
),
elevation: ButtonElevation? = ButtonDefaults.buttonElevation(),
border: BorderStroke? = null,
contentPadding: PaddingValues = smallButtonContentPadding,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
content: @Composable RowScope.() -> Unit
) {
Button(
onClick = onClick,
modifier = modifier.heightIn(min = 32.dp),
enabled = enabled,
shape = shape,
colors = colors,
elevation = elevation,
border = border,
contentPadding = contentPadding,
interactionSource = interactionSource,
content = {
ProvideTextStyle(value = MaterialTheme.typography.labelMedium) {
content()
}
}
)
}
@Composable
fun ActionButton(
onClick: () -> Unit,
@DrawableRes iconResId: Int,
@StringRes labelResId: Int,
modifier: Modifier = Modifier,
enabled: Boolean = true
) {
ActionButton(
enabled = enabled,
onClick = onClick,
label = stringResource(labelResId),
modifier = modifier
) {
Image(
painter = painterResource(iconResId),
contentDescription = null,
modifier = Modifier.padding(16.dp),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSecondaryContainer)
)
}
}
@Composable
fun ActionButton(
onClick: () -> Unit,
label: String,
modifier: Modifier = Modifier,
enabled: Boolean = true,
imageContent: @Composable () -> Unit
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally
) {
FilledTonalIconButton(
onClick = onClick,
shape = RoundedCornerShape(18.dp),
modifier = Modifier.size(56.dp),
enabled = enabled,
content = imageContent
)
Text(
text = label,
modifier = Modifier.padding(top = 12.dp),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
@Composable
private fun SampleBox(
darkMode: Boolean,
content: @Composable BoxScope.() -> Unit
) {
SignalTheme(isDarkMode = darkMode) {
Surface {
Box(modifier = Modifier.padding(8.dp)) {
content()
}
}
}
}
@Preview(name = "Buttons.LargePrimaryButton")
@Composable
private fun LargePrimaryButtonPreview() {
Column {
Row {
LargePrimaryButtonSample(darkMode = false, enabled = true)
LargePrimaryButtonSample(darkMode = true, enabled = true)
}
Row {
LargePrimaryButtonSample(darkMode = false, enabled = false)
LargePrimaryButtonSample(darkMode = true, enabled = false)
}
}
}
@Composable
private fun LargePrimaryButtonSample(
darkMode: Boolean,
enabled: Boolean
) {
SampleBox(darkMode) {
Buttons.LargePrimary(
onClick = {},
enabled = enabled
) {
Text("Button")
}
}
}
@Preview(name = "Buttons.LargeTonalButton")
@Composable
private fun LargeTonalButtonPreview() {
Column {
Row {
LargeTonalButtonSample(darkMode = false, enabled = true)
LargeTonalButtonSample(darkMode = true, enabled = true)
}
Row {
LargeTonalButtonSample(darkMode = false, enabled = false)
LargeTonalButtonSample(darkMode = true, enabled = false)
}
}
}
@Composable
private fun LargeTonalButtonSample(
darkMode: Boolean,
enabled: Boolean
) {
SampleBox(darkMode) {
Buttons.LargeTonal(
onClick = {},
enabled = enabled
) {
Text("Button")
}
}
}
@Preview(name = "Buttons.MediumTonalButton")
@Composable
private fun MediumTonalButtonPreview() {
Column {
Row {
MediumTonalButtonSample(darkMode = false, enabled = true)
MediumTonalButtonSample(darkMode = true, enabled = true)
}
Row {
MediumTonalButtonSample(darkMode = false, enabled = false)
MediumTonalButtonSample(darkMode = true, enabled = false)
}
}
}
@Composable
private fun MediumTonalButtonSample(
darkMode: Boolean,
enabled: Boolean
) {
SampleBox(darkMode) {
Buttons.MediumTonal(
onClick = {},
enabled = enabled
) {
Text("Button")
}
}
}
@Preview(name = "Buttons.SmallButton")
@Composable
private fun SmallButtonPreview() {
Column {
Row {
SmallButtonSample(darkMode = false, enabled = true)
SmallButtonSample(darkMode = true, enabled = true)
}
Row {
SmallButtonSample(darkMode = false, enabled = false)
SmallButtonSample(darkMode = true, enabled = false)
}
}
}
@Composable
private fun SmallButtonSample(
darkMode: Boolean,
enabled: Boolean
) {
SampleBox(darkMode) {
Buttons.Small(
onClick = {},
enabled = enabled
) {
Text("Button")
}
}
}
@Preview(name = "Buttons.ActionButton")
@Composable
private fun ActionButtonPreview() {
Column {
Row {
ActionButtonSample(darkMode = false, enabled = true)
ActionButtonSample(darkMode = true, enabled = true)
}
Row {
ActionButtonSample(darkMode = false, enabled = false)
ActionButtonSample(darkMode = true, enabled = false)
}
}
}
@Composable
private fun ActionButtonSample(
darkMode: Boolean,
enabled: Boolean
) {
SampleBox(darkMode = darkMode) {
Buttons.ActionButton(
onClick = {},
enabled = enabled,
label = "Share"
) {
Icon(
painter = painterResource(android.R.drawable.ic_menu_camera),
tint = MaterialTheme.colorScheme.onSecondaryContainer,
contentDescription = null
)
}
}
}

View file

@ -0,0 +1,124 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterExitState
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntSize
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.seconds
/**
* Utilizes a circular reveal animation to display and hide the content given.
* When the content is hidden via settings [isLoading] to true, we display a
* circular progress indicator.
*
* This component will automatically size itself according to the content passed
* in via [content]
*/
@Composable
fun CircularProgressWrapper(
isLoading: Boolean,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
) {
var size by remember { mutableStateOf(IntSize.Zero) }
val dpSize = with(LocalDensity.current) {
DpSize(size.width.toDp(), size.height.toDp())
}
AnimatedVisibility(
visible = isLoading,
enter = fadeIn(),
exit = fadeOut()
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(dpSize)
) {
CircularProgressIndicator()
}
}
AnimatedVisibility(
visible = !isLoading,
enter = EnterTransition.None,
exit = ExitTransition.None
) {
val visibility = transition.animateFloat(
transitionSpec = { tween(durationMillis = 400, easing = LinearOutSlowInEasing) },
label = "CircularProgressWrapper-Visibility"
) { state ->
if (state == EnterExitState.Visible) 1f else 0f
}
Box(
modifier = Modifier
.onSizeChanged { s ->
size = s
}
.circularReveal(visibility)
) {
content()
}
}
}
}
@DayNightPreviews
@Composable
fun CircularProgressWrapperPreview() {
var isLoading by remember {
mutableStateOf(false)
}
LaunchedEffect(isLoading) {
if (isLoading) {
delay(3.seconds)
isLoading = false
}
}
Previews.Preview {
CircularProgressWrapper(
isLoading = isLoading,
content = {
Buttons.LargeTonal(onClick = {
isLoading = true
}) {
Text(text = "Next")
}
}
)
}
}

View file

@ -0,0 +1,57 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.clipPath
import kotlin.math.sqrt
/**
* Circle Reveal Modifiers found here:
* https://gist.github.com/darvld/eb3844474baf2f3fc6d3ab44a4b4b5f8
*
* A modifier that clips the composable content using a circular shape. The radius of the circle
* will be determined by the [transitionProgress].
*
* The values of the progress should be between 0 and 1.
*
* By default, the circle is centered in the content, but custom positions may be specified using
* [revealFrom]. Specified offsets should be between 0 (left/top) and 1 (right/bottom).
*/
fun Modifier.circularReveal(
transitionProgress: State<Float>,
revealFrom: Offset = Offset(0.5f, 0.5f)
): Modifier {
return drawWithCache {
val path = Path()
val center = revealFrom.mapTo(size)
val radius = calculateRadius(revealFrom, size)
path.addOval(Rect(center, radius * transitionProgress.value))
onDrawWithContent {
clipPath(path) { this@onDrawWithContent.drawContent() }
}
}
}
private fun Offset.mapTo(size: Size): Offset {
return Offset(x * size.width, y * size.height)
}
private fun calculateRadius(normalizedOrigin: Offset, size: Size) = with(normalizedOrigin) {
val x = (if (x > 0.5f) x else 1 - x) * size.width
val y = (if (y > 0.5f) y else 1 - y) * size.height
sqrt(x * x + y * y)
}

View file

@ -0,0 +1,36 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import kotlinx.coroutines.delay
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
/**
* Delays setting the state to [key] for the given [delayDuration].
*
* Useful for reducing animation flickering when displaying loading indicators
* when the process may finish immediately or may take a bit of time.
*/
@Composable
fun <T> rememberDelayedState(
key: T,
delayDuration: Duration = 200.milliseconds
): State<T> {
val delayedState = remember { mutableStateOf(key) }
LaunchedEffect(key, delayDuration) {
delay(delayDuration)
delayedState.value = key
}
return delayedState
}

View file

@ -0,0 +1,706 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose
import android.R
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import org.signal.core.ui.compose.Dialogs.AdvancedAlertDialog
import org.signal.core.ui.compose.Dialogs.PermissionRationaleDialog
import org.signal.core.ui.compose.Dialogs.SimpleAlertDialog
import org.signal.core.ui.compose.Dialogs.SimpleMessageDialog
import org.signal.core.ui.compose.theme.SignalTheme
import kotlin.math.max
object Dialogs {
const val NoTitle = ""
const val NoDismiss = ""
object Defaults {
val shape: Shape @Composable get() = RoundedCornerShape(28.dp)
val containerColor: Color @Composable get() = SignalTheme.colors.colorSurface1
val iconContentColor: Color @Composable get() = MaterialTheme.colorScheme.onSurface
val titleContentColor: Color @Composable get() = MaterialTheme.colorScheme.onSurface
val textContentColor: Color @Composable get() = MaterialTheme.colorScheme.onSurfaceVariant
val TonalElevation: Dp = AlertDialogDefaults.TonalElevation
}
@Composable
fun BaseAlertDialog(
onDismissRequest: () -> Unit,
confirmButton: @Composable () -> Unit,
modifier: Modifier,
dismissButton: (@Composable () -> Unit)? = null,
icon: (@Composable () -> Unit)? = null,
title: (@Composable () -> Unit)? = null,
text: (@Composable () -> Unit)? = null,
shape: Shape = Defaults.shape,
containerColor: Color = Defaults.containerColor,
iconContentColor: Color = Defaults.iconContentColor,
titleContentColor: Color = Defaults.titleContentColor,
textContentColor: Color = Defaults.textContentColor,
tonalElevation: Dp = Defaults.TonalElevation,
properties: DialogProperties = DialogProperties()
) {
androidx.compose.material3.AlertDialog(
onDismissRequest = onDismissRequest,
confirmButton = confirmButton,
modifier = modifier,
dismissButton = dismissButton,
icon = icon,
title = title,
text = text,
shape = shape,
containerColor = containerColor,
iconContentColor = iconContentColor,
titleContentColor = titleContentColor,
textContentColor = textContentColor,
tonalElevation = tonalElevation,
properties = properties
)
}
@Composable
fun SimpleMessageDialog(
message: String,
dismiss: String,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
title: String? = null,
dismissColor: Color = Color.Unspecified,
properties: DialogProperties = DialogProperties()
) {
BaseAlertDialog(
onDismissRequest = onDismiss,
title = if (title == null) {
null
} else {
{ Text(text = title) }
},
text = { Text(text = message) },
confirmButton = {
TextButton(onClick = {
onDismiss()
}) {
Text(text = dismiss, color = dismissColor)
}
},
modifier = modifier,
properties = properties
)
}
@Composable
fun SimpleAlertDialog(
title: String,
body: String,
confirm: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit = {},
onDismissRequest: () -> Unit = onDismiss,
onDeny: () -> Unit = {},
modifier: Modifier = Modifier,
dismiss: String = NoDismiss,
confirmColor: Color = Color.Unspecified,
dismissColor: Color = Color.Unspecified,
properties: DialogProperties = DialogProperties()
) {
BaseAlertDialog(
onDismissRequest = onDismissRequest,
title = if (title.isNotEmpty()) {
{
Text(text = title)
}
} else {
null
},
text = { Text(text = body) },
confirmButton = {
TextButton(onClick = {
onDismiss()
onConfirm()
}) {
Text(text = confirm, color = confirmColor)
}
},
dismissButton = if (dismiss.isNotEmpty()) {
{
TextButton(
onClick =
{
onDismiss()
onDeny()
}
) {
Text(text = dismiss, color = dismissColor)
}
}
} else {
null
},
modifier = modifier,
properties = properties
)
}
/**
* A dialog that *just* shows a spinner. Useful for short actions where you need to
* let the user know that some action is completing.
*/
@Composable
fun IndeterminateProgressDialog(
onDismissRequest: () -> Unit = {}
) {
Dialog(
onDismissRequest = onDismissRequest,
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)
) {
Surface(
modifier = Modifier.size(100.dp),
shape = Defaults.shape,
color = Defaults.containerColor,
tonalElevation = Defaults.TonalElevation
) {
CircularProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.padding(24.dp)
.testTag("dialog-circular-progress-indicator")
)
}
}
}
/**
* Customizable progress spinner that shows [message] below the spinner to let users know
* an action is completing
*/
@Composable
fun IndeterminateProgressDialog(message: String) {
BaseAlertDialog(
onDismissRequest = {},
confirmButton = {},
dismissButton = {},
text = {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
) {
Spacer(modifier = Modifier.size(24.dp))
CircularProgressIndicator()
Spacer(modifier = Modifier.size(20.dp))
Text(text = message, textAlign = TextAlign.Center)
}
},
modifier = Modifier
.size(200.dp)
)
}
/**
* Customizable progress spinner that can be dismissed while showing [message]
* and [caption] below the spinner to let users know an action is completing
*/
@Composable
fun IndeterminateProgressDialog(message: String, caption: String = "", dismiss: String, onDismiss: () -> Unit) {
BaseAlertDialog(
onDismissRequest = {},
confirmButton = {},
dismissButton = {
TextButton(
onClick = onDismiss,
modifier = Modifier.fillMaxWidth(),
content = { Text(text = dismiss) }
)
},
text = {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
Spacer(modifier = Modifier.size(32.dp))
CircularProgressIndicator()
Spacer(modifier = Modifier.size(12.dp))
Text(
text = message,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
if (caption.isNotEmpty()) {
Spacer(modifier = Modifier.size(8.dp))
Text(
text = caption,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
},
modifier = Modifier.width(200.dp)
)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun PermissionRationaleDialog(
icon: Painter,
rationale: String,
confirm: String,
dismiss: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Surface(
modifier = Modifier
.fillMaxWidth(fraction = 0.75f)
.background(
color = SignalTheme.colors.colorSurface2,
shape = AlertDialogDefaults.shape
)
.clip(AlertDialogDefaults.shape)
) {
Column {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.background(color = MaterialTheme.colorScheme.primary)
.padding(48.dp)
) {
Icon(
painter = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(32.dp)
)
}
Text(
text = rationale,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(horizontal = 24.dp, vertical = 16.dp)
)
FlowRow(
horizontalArrangement = Arrangement.End,
modifier = Modifier
.fillMaxWidth()
.padding(start = 24.dp, end = 24.dp, bottom = 24.dp)
) {
TextButton(onClick = onDismiss) {
Text(text = dismiss)
}
TextButton(onClick = onConfirm) {
Text(text = confirm)
}
}
}
}
}
}
@Composable
fun RadioListDialog(
onDismissRequest: () -> Unit,
properties: DialogProperties = DialogProperties(),
title: String,
labels: Array<String>,
values: Array<String>,
selectedIndex: Int,
onSelected: (Int) -> Unit
) {
Dialog(
onDismissRequest = onDismissRequest,
properties = properties
) {
Surface(
modifier = Modifier
.heightIn(min = 0.dp, max = getScreenHeight() - 200.dp)
.background(
color = SignalTheme.colors.colorSurface2,
shape = AlertDialogDefaults.shape
)
.clip(AlertDialogDefaults.shape)
) {
Column {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier
.padding(top = 16.dp)
.horizontalGutters()
)
LazyColumn(
modifier = Modifier.padding(top = 24.dp, bottom = 16.dp),
state = rememberLazyListState(
initialFirstVisibleItemIndex = max(selectedIndex, 0)
)
) {
items(
count = values.size,
key = { values[it] }
) { index ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 48.dp)
.clickable(
enabled = true,
onClick = {
onSelected(index)
onDismissRequest()
}
)
.horizontalGutters()
) {
RadioButton(
enabled = true,
selected = index == selectedIndex,
onClick = null,
modifier = Modifier.padding(end = 24.dp)
)
Text(text = labels[index])
}
}
}
}
}
}
}
@Composable
fun MultiSelectListDialog(
onDismissRequest: () -> Unit,
properties: DialogProperties = DialogProperties(),
title: String,
labels: Array<String>,
values: Array<String>,
selection: Array<String>,
onSelectionChanged: (Array<String>) -> Unit
) {
var selectedIndicies by remember {
mutableStateOf(
values.mapIndexedNotNull { index, value ->
if (value in selection) {
index
} else {
null
}
}
)
}
Dialog(
onDismissRequest = onDismissRequest,
properties = properties
) {
Surface(
modifier = Modifier
.heightIn(min = 0.dp, max = getScreenHeight() - 200.dp)
.background(
color = SignalTheme.colors.colorSurface2,
shape = AlertDialogDefaults.shape
)
.clip(AlertDialogDefaults.shape)
) {
Column {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier
.padding(top = 16.dp)
.horizontalGutters()
)
LazyColumn(
modifier = Modifier.padding(top = 24.dp, bottom = 16.dp)
) {
items(
count = values.size,
key = { values[it] }
) { index ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 48.dp)
.clickable(
enabled = true,
onClick = {
selectedIndicies = if (index in selectedIndicies) {
selectedIndicies - index
} else {
selectedIndicies + index
}
}
)
.horizontalGutters()
) {
Checkbox(
enabled = true,
checked = index in selectedIndicies,
onCheckedChange = null,
modifier = Modifier.padding(end = 24.dp)
)
Text(text = labels[index])
}
}
}
FlowRow(
horizontalArrangement = Arrangement.End,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
) {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(R.string.cancel))
}
TextButton(onClick = {
onSelectionChanged(selectedIndicies.sorted().map { values[it] }.toTypedArray())
onDismissRequest()
}) {
Text(text = stringResource(R.string.ok))
}
}
}
}
}
}
/**
* Alert dialog that supports three options.
* If you only need two options (confirm/dismiss), use [SimpleAlertDialog] instead.
*/
@Composable
fun AdvancedAlertDialog(
title: String = "",
body: String = "",
positive: String,
neutral: String,
negative: String,
onPositive: () -> Unit,
onNegative: () -> Unit,
onNeutral: () -> Unit
) {
Dialog(
onDismissRequest = onNegative,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Surface(
modifier = Modifier
.fillMaxWidth(fraction = 0.75f)
.background(
color = SignalTheme.colors.colorSurface2,
shape = AlertDialogDefaults.shape
)
.clip(AlertDialogDefaults.shape)
) {
Column(modifier = Modifier.padding(24.dp)) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge
)
Text(
text = body,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 16.dp)
)
Column(
horizontalAlignment = Alignment.End,
modifier = Modifier.fillMaxWidth()
) {
TextButton(onClick = onPositive) {
Text(text = positive)
}
TextButton(onClick = onNeutral) {
Text(text = neutral)
}
TextButton(onClick = onNegative) {
Text(text = negative)
}
}
}
}
}
}
@Composable
private fun getScreenHeight(): Dp {
return with(LocalDensity.current) {
LocalWindowInfo.current.containerSize.height.toDp()
}
}
}
@DayNightPreviews
@Composable
private fun PermissionRationaleDialogPreview() {
Previews.Preview {
PermissionRationaleDialog(
icon = painterResource(id = R.drawable.ic_menu_camera),
rationale = "This is rationale text about why we need permission.",
confirm = "Continue",
dismiss = "Not now",
onConfirm = {},
onDismiss = {}
)
}
}
@DayNightPreviews
@Composable
private fun AlertDialogPreview() {
Previews.Preview {
SimpleAlertDialog(
title = "Title Text",
body = "Body text message",
confirm = "Confirm Button",
dismiss = "Dismiss Button",
onConfirm = {},
onDismiss = {}
)
}
}
@DayNightPreviews
@Composable
private fun AdvancedAlertDialogPreview() {
Previews.Preview {
AdvancedAlertDialog(
title = "Title text",
body = "Body message text.",
positive = "Positive",
neutral = "Neutral",
negative = "Negative",
onPositive = {},
onNegative = {},
onNeutral = {}
)
}
}
@DayNightPreviews
@Composable
private fun MessageDialogPreview() {
Previews.Preview {
SimpleMessageDialog(
message = "Message here",
dismiss = "OK",
onDismiss = {}
)
}
}
@DayNightPreviews
@Composable
private fun IndeterminateProgressDialogPreview() {
Previews.Preview {
Dialogs.IndeterminateProgressDialog()
}
}
@DayNightPreviews
@Composable
private fun IndeterminateProgressDialogMessagePreview() {
Previews.Preview {
Dialogs.IndeterminateProgressDialog("Completing...")
}
}
@DayNightPreviews
@Composable
private fun IndeterminateProgressDialogCancellablePreview() {
Previews.Preview {
Dialogs.IndeterminateProgressDialog("Completing...", "Do not close app", "Cancel") {}
}
}
@DayNightPreviews
@Composable
private fun RadioListDialogPreview() {
Previews.Preview {
Dialogs.RadioListDialog(
onDismissRequest = {},
title = "TestDialog",
properties = DialogProperties(),
labels = arrayOf(),
values = arrayOf(),
selectedIndex = -1,
onSelected = {}
)
}
}

View file

@ -0,0 +1,69 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Divider
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.theme.SignalTheme
/**
* Thin divider lines for separating content.
*/
object Dividers {
@Composable
fun Default(modifier: Modifier = Modifier) {
Divider(
thickness = 1.5.dp,
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = modifier.padding(vertical = 16.25.dp)
)
}
@Composable
fun Vertical(
modifier: Modifier = Modifier,
thickness: Dp = 1.5.dp,
color: Color = MaterialTheme.colorScheme.surfaceVariant
) {
val targetThickness = if (thickness == Dp.Hairline) {
(1f / LocalDensity.current.density).dp
} else {
thickness
}
Box(
modifier
.width(targetThickness)
.background(color = color)
)
}
}
@DayNightPreviews
@Composable
private fun DefaultPreview() {
SignalTheme {
Dividers.Default()
}
}
@DayNightPreviews
@Composable
private fun VerticalPreview() {
SignalTheme {
Dividers.Vertical(modifier = Modifier.height(20.dp))
}
}

View file

@ -0,0 +1,109 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
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.res.dimensionResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import org.signal.core.ui.R
import org.signal.core.ui.compose.copied.androidx.compose.material3.DropdownMenu
import org.signal.core.ui.compose.theme.SignalTheme
/**
* Properly styled dropdown menus and items.
*/
object DropdownMenus {
/**
* Properly styled dropdown menu
*/
@Composable
fun Menu(
modifier: Modifier = Modifier,
controller: MenuController = remember { MenuController() },
offsetX: Dp = dimensionResource(id = R.dimen.gutter),
offsetY: Dp = 0.dp,
content: @Composable ColumnScope.(MenuController) -> Unit
) {
MaterialTheme(shapes = MaterialTheme.shapes.copy(extraSmall = RoundedCornerShape(18.dp))) {
DropdownMenu(
expanded = controller.isShown(),
onDismissRequest = controller::hide,
offset = DpOffset(
x = offsetX,
y = offsetY
),
content = { content(controller) },
modifier = modifier
.background(SignalTheme.colors.colorSurface2)
.widthIn(min = 220.dp)
)
}
}
/**
* Properly styled dropdown menu item
*/
@Composable
fun Item(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(horizontal = 16.dp),
text: @Composable () -> Unit,
onClick: () -> Unit
) {
DropdownMenuItem(
contentPadding = contentPadding,
text = {
CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyLarge) {
text()
}
},
onClick = onClick,
modifier = modifier
)
}
/**
* Menu controller to hold menu display state and allow other components
* to show and hide it.
*/
class MenuController {
private var isMenuShown by mutableStateOf(false)
fun show() {
isMenuShown = true
}
fun hide() {
isMenuShown = false
}
fun toggle() {
if (isShown()) {
hide()
} else {
show()
}
}
fun isShown() = isMenuShown
}
}

View file

@ -0,0 +1,76 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose
import android.os.Bundle
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.platform.LocalInspectionMode
import androidx.fragment.app.Fragment
import androidx.fragment.compose.AndroidFragment
import androidx.fragment.compose.FragmentState
import androidx.fragment.compose.rememberFragmentState
import org.signal.core.ui.compose.Fragments.Fragment
object Fragments {
/**
* Wraps an [Fragment], displaying the fragment at runtime or a placeholder in compose previews to avoid rendering errors that occur when
* using [Fragment] in @Preview composables.
*/
@Composable
inline fun <reified T : Fragment> Fragment(
modifier: Modifier = Modifier,
fragmentState: FragmentState = rememberFragmentState(),
arguments: Bundle = Bundle.EMPTY,
noinline onUpdate: (T) -> Unit = { }
) {
if (!LocalInspectionMode.current) {
AndroidFragment(clazz = T::class.java, modifier, fragmentState, arguments, onUpdate)
} else {
Text(
text = "[${T::class.simpleName}]",
style = MaterialTheme.typography.bodyLarge,
modifier = modifier
.fillMaxSize()
.background(Color.Gray)
.wrapContentSize(Alignment.Center)
)
}
}
/**
* Wraps an [Fragment], displaying the fragment at runtime or a placeholder in compose previews to avoid rendering errors that occur when
* using [Fragment] in @Preview composables.
*/
@Composable
fun <T : Fragment> Fragment(
clazz: Class<T>,
modifier: Modifier = Modifier,
fragmentState: FragmentState = rememberFragmentState(),
arguments: Bundle = Bundle.EMPTY,
onUpdate: (T) -> Unit = { }
) {
if (!LocalInspectionMode.current) {
AndroidFragment(clazz = clazz, modifier, fragmentState, arguments, onUpdate)
} else {
Text(
text = "[${clazz.simpleName}]",
style = MaterialTheme.typography.bodyLarge,
modifier = modifier
.fillMaxSize()
.background(Color.Gray)
.wrapContentSize(Alignment.Center)
)
}
}
}

View file

@ -0,0 +1,141 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.copied.androidx.compose.material3.IconButtonColors
import org.signal.core.ui.compose.copied.androidx.compose.material3.IconToggleButtonColors
object IconButtons {
@Composable
fun iconButtonColors(
containerColor: Color = Color.Transparent,
contentColor: Color = LocalContentColor.current,
disabledContainerColor: Color = Color.Transparent,
disabledContentColor: Color =
contentColor.copy(alpha = 0.38f)
): IconButtonColors =
IconButtonColors(
containerColor = containerColor,
contentColor = contentColor,
disabledContainerColor = disabledContainerColor,
disabledContentColor = disabledContentColor
)
@Composable
fun iconToggleButtonColors(
containerColor: Color = Color.Transparent,
contentColor: Color = LocalContentColor.current,
disabledContainerColor: Color = Color.Transparent,
disabledContentColor: Color =
contentColor.copy(alpha = 0.38f),
checkedContainerColor: Color = Color.Transparent,
checkedContentColor: Color = MaterialTheme.colorScheme.primary
): IconToggleButtonColors =
IconToggleButtonColors(
containerColor = containerColor,
contentColor = contentColor,
disabledContainerColor = disabledContainerColor,
disabledContentColor = disabledContentColor,
checkedContainerColor = checkedContainerColor,
checkedContentColor = checkedContentColor
)
@Composable
fun IconButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
size: Dp = 40.dp,
shape: Shape = CircleShape,
enabled: Boolean = true,
colors: IconButtonColors = iconButtonColors(),
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
content: @Composable () -> Unit
) {
Box(
modifier = modifier
.minimumInteractiveComponentSize()
.size(size)
.clip(shape)
.background(color = colors.containerColor(enabled).value)
.clickable(
onClick = onClick,
enabled = enabled,
role = Role.Button,
interactionSource = interactionSource,
indication = ripple(
bounded = false,
radius = size / 2
)
),
contentAlignment = Alignment.Center
) {
val contentColor = colors.contentColor(enabled).value
CompositionLocalProvider(LocalContentColor provides contentColor, content = content)
}
}
@Composable
fun IconToggleButton(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
size: Dp = 40.dp,
shape: Shape = CircleShape,
enabled: Boolean = true,
colors: IconToggleButtonColors = iconToggleButtonColors(),
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
content: @Composable () -> Unit
) {
@Suppress("DEPRECATION_ERROR")
(
Box(
modifier = modifier
.minimumInteractiveComponentSize()
.size(size)
.clip(shape)
.background(color = colors.containerColor(enabled, checked).value)
.toggleable(
value = checked,
onValueChange = onCheckedChange,
enabled = enabled,
role = Role.Checkbox,
interactionSource = interactionSource,
indication = ripple(
bounded = false,
radius = size / 2
)
),
contentAlignment = Alignment.Center
) {
val contentColor = colors.contentColor(enabled, checked).value
CompositionLocalProvider(LocalContentColor provides contentColor, content = content)
}
)
}
}

View file

@ -0,0 +1,56 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
object Icons {
/**
* Icon that takes a Brush instead of a Color for its foreground
*/
@Composable
fun BrushedForeground(
painter: Painter,
contentDescription: String?,
foregroundBrush: Brush,
modifier: Modifier = Modifier
) {
Icon(
painter = painter,
contentDescription = contentDescription,
modifier = modifier
.graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
.drawWithCache {
onDrawWithContent {
drawContent()
drawRect(foregroundBrush, blendMode = BlendMode.SrcAtop)
}
}
)
}
}
@DayNightPreviews
@Composable
private fun BrushedForegroundPreview() {
Previews.Preview {
Icons.BrushedForeground(
painter = painterResource(id = android.R.drawable.ic_menu_camera),
contentDescription = null,
foregroundBrush = Brush.linearGradient(listOf(Color.Red, Color.Blue))
)
}
}

View file

@ -0,0 +1,62 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose
import androidx.compose.foundation.Indication
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import org.signal.core.ui.R
/**
* Applies sensible horizontal padding to the given component.
*/
@Composable
fun Modifier.horizontalGutters(
gutterSize: Dp = dimensionResource(R.dimen.gutter)
): Modifier {
return padding(horizontal = gutterSize)
}
/**
* Configures a component to be clickable within its bounds and show a default indication when pressed.
*
* This modifier is designed for use on container components, making it easier to create a clickable container with proper accessibility configuration.
*/
@Composable
fun Modifier.clickableContainer(
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
indication: Indication = ripple(bounded = false),
enabled: Boolean = true,
contentDescription: String?,
onClickLabel: String,
role: Role? = null,
onClick: () -> Unit
): Modifier = clickable(
interactionSource = interactionSource,
indication = indication,
enabled = enabled,
onClickLabel = onClickLabel,
role = role,
onClick = onClick
).then(
if (contentDescription != null) {
Modifier.semantics(mergeDescendants = true) {
this.contentDescription = contentDescription
}
} else {
Modifier
}
)

View file

@ -0,0 +1,39 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import org.signal.core.ui.compose.theme.SignalTheme
object Previews {
@Composable
fun Preview(
content: @Composable () -> Unit
) {
SignalTheme {
Surface {
content()
}
}
}
@Composable
fun BottomSheetPreview(
content: @Composable () -> Unit
) {
SignalTheme {
Surface {
Box(modifier = Modifier.background(color = SignalTheme.colors.colorSurface1)) {
content()
}
}
}
}
}

View file

@ -0,0 +1,706 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import org.signal.core.ui.R
import org.signal.core.ui.compose.Rows.TextAndLabel
object Rows {
const val DISABLED_ALPHA = 0.4f
/**
* Link row that positions [text] and optional [label] in a [TextAndLabel] to the side of an [icon] on the right.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LinkRow(
text: String,
onClick: (() -> Unit)? = null,
modifier: Modifier = Modifier,
label: String? = null,
textColor: Color = MaterialTheme.colorScheme.onSurface,
enabled: Boolean = true,
icon: ImageVector
) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable(
enabled = enabled,
onClick = onClick ?: {}
)
.padding(defaultPadding()),
verticalAlignment = CenterVertically
) {
TextAndLabel(
text = text,
label = label,
textColor = textColor,
enabled = enabled,
modifier = Modifier.padding(end = 16.dp)
)
Icon(
imageVector = icon,
contentDescription = null
)
}
}
/**
* A row consisting of a radio button and [text] and optional [label] in a [TextAndLabel].
*/
@Composable
fun RadioRow(
selected: Boolean,
text: String,
modifier: Modifier = Modifier,
label: String? = null,
enabled: Boolean = true
) {
RadioRow(
content = {
TextAndLabel(
text = text,
label = label,
enabled = enabled
)
},
selected = selected,
modifier = modifier,
enabled = enabled
)
}
/**
* Customizable radio row that allows [content] to be provided as composable functions instead of primitives.
*/
@Composable
fun RadioRow(
content: @Composable RowScope.() -> Unit,
selected: Boolean,
modifier: Modifier = Modifier,
enabled: Boolean = true
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(defaultPadding()),
verticalAlignment = CenterVertically
) {
RadioButton(
enabled = enabled,
selected = selected,
onClick = null,
modifier = Modifier.padding(end = 24.dp)
)
content()
}
}
@Composable
fun RadioListRow(
text: String,
labels: Array<String>,
values: Array<String>,
selectedValue: String,
onSelected: (String) -> Unit,
enabled: Boolean = true
) {
RadioListRow(
text = { selectedIndex ->
val selectedLabel = if (selectedIndex in labels.indices) {
labels[selectedIndex]
} else {
null
}
TextAndLabel(
text = text,
label = selectedLabel
)
},
dialogTitle = text,
labels = labels,
values = values,
selectedValue = selectedValue,
onSelected = onSelected,
enabled = enabled
)
}
@Composable
fun RadioListRow(
text: @Composable RowScope.(Int) -> Unit,
dialogTitle: String,
labels: Array<String>,
values: Array<String>,
selectedValue: String,
onSelected: (String) -> Unit,
enabled: Boolean = true
) {
val selectedIndex = values.indexOf(selectedValue)
var displayDialog by remember { mutableStateOf(false) }
TextRow(
text = { text(selectedIndex) },
enabled = enabled,
onClick = {
displayDialog = true
},
modifier = Modifier.alpha(if (enabled) 1f else DISABLED_ALPHA)
)
if (displayDialog) {
Dialogs.RadioListDialog(
onDismissRequest = { displayDialog = false },
labels = labels,
values = values,
selectedIndex = selectedIndex,
title = dialogTitle,
onSelected = {
onSelected(values[it])
}
)
}
}
@Composable
fun MultiSelectRow(
text: String,
labels: Array<String>,
values: Array<String>,
selection: Array<String>,
onSelectionChanged: (Array<String>) -> Unit
) {
var displayDialog by remember { mutableStateOf(false) }
TextRow(
text = text,
label = selection.joinToString(", ") {
val index = values.indexOf(it)
if (index == -1) error("not found: $it in ${values.joinToString(", ")}")
labels[index]
},
onClick = {
displayDialog = true
}
)
if (displayDialog) {
Dialogs.MultiSelectListDialog(
onDismissRequest = { displayDialog = false },
labels = labels,
values = values,
selection = selection,
title = text,
onSelectionChanged = onSelectionChanged
)
}
}
/**
* Row that positions [text] and optional [label] in a [TextAndLabel] to the side of a [Switch].
*
* Can display a circular loading indicator by setting isLoaded to true. Setting isLoading to true
* will disable the control by default.
*/
@Composable
fun ToggleRow(
checked: Boolean,
text: String,
onCheckChanged: (Boolean) -> Unit,
modifier: Modifier = Modifier,
label: String? = null,
icon: ImageVector? = null,
textColor: Color = MaterialTheme.colorScheme.onSurface,
enabled: Boolean = true,
isLoading: Boolean = false
) {
ToggleRow(
checked = checked,
text = AnnotatedString(text),
onCheckChanged = onCheckChanged,
modifier = modifier,
label = label?.let { AnnotatedString(it) },
icon = icon,
textColor = textColor,
enabled = enabled,
isLoading = isLoading
)
}
/**
* Row that positions [text] and optional [label] in a [TextAndLabel] to the side of a [Switch].
*
* Can display a circular loading indicator by setting isLoaded to true. Setting isLoading to true
* will disable the control by default.
*/
@Composable
fun ToggleRow(
checked: Boolean,
text: AnnotatedString,
onCheckChanged: (Boolean) -> Unit,
modifier: Modifier = Modifier,
label: AnnotatedString? = null,
icon: ImageVector? = null,
textColor: Color = MaterialTheme.colorScheme.onSurface,
enabled: Boolean = true,
isLoading: Boolean = false,
inlineContent: Map<String, InlineTextContent> = mapOf()
) {
val isEnabled = enabled && !isLoading
Row(
modifier = modifier
.fillMaxWidth()
.clickable(enabled = isEnabled) { onCheckChanged(!checked) }
.padding(defaultPadding()),
verticalAlignment = CenterVertically
) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null
)
Spacer(modifier = Modifier.width(24.dp))
}
TextAndLabel(
text = text,
label = label,
textColor = textColor,
enabled = isEnabled,
modifier = Modifier.padding(end = 16.dp),
inlineContent = inlineContent
)
val loadingContent by rememberDelayedState(isLoading)
val toggleState = remember(checked, loadingContent, isEnabled, onCheckChanged) {
ToggleState(checked, loadingContent, isEnabled, onCheckChanged)
}
AnimatedContent(
toggleState,
label = "toggle-loading-state",
contentKey = { it.isLoading },
transitionSpec = {
fadeIn(animationSpec = tween(220, delayMillis = 90))
.togetherWith(fadeOut(animationSpec = tween(90)))
}
) { state ->
if (state.isLoading) {
CircularProgressIndicator(
modifier = Modifier.minimumInteractiveComponentSize()
)
} else {
Switch(
checked = state.checked,
enabled = state.enabled,
onCheckedChange = state.onCheckChanged,
colors = SwitchDefaults.colors(
checkedTrackColor = MaterialTheme.colorScheme.primary,
uncheckedBorderColor = MaterialTheme.colorScheme.outline,
uncheckedIconColor = MaterialTheme.colorScheme.outline,
uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant
)
)
}
}
}
}
/**
* Text row that positions [text] and optional [label] in a [TextAndLabel] to the side of an optional [icon].
*/
@Composable
fun TextRow(
modifier: Modifier = Modifier,
iconModifier: Modifier = Modifier,
text: String? = null,
label: String? = null,
icon: Painter? = null,
foregroundTint: Color = MaterialTheme.colorScheme.onSurface,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
enabled: Boolean = true
) {
TextRow(
text = remember(text) { text?.let { AnnotatedString(text) } },
label = remember(label) { label?.let { AnnotatedString(label) } },
icon = icon,
modifier = modifier,
iconModifier = iconModifier,
foregroundTint = foregroundTint,
onClick = onClick,
onLongClick = onLongClick,
enabled = enabled
)
}
/**
* Text row that positions [text] and optional [label] in a [TextAndLabel] to the side of an optional [icon].
*/
@Composable
fun TextRow(
modifier: Modifier = Modifier,
iconModifier: Modifier = Modifier,
text: AnnotatedString? = null,
label: AnnotatedString? = null,
icon: Painter? = null,
foregroundTint: Color = MaterialTheme.colorScheme.onSurface,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
enabled: Boolean = true
) {
TextRow(
text = {
TextAndLabel(
text = text,
label = label,
textColor = foregroundTint,
enabled = enabled
)
},
icon = if (icon != null) {
{
Icon(
painter = icon,
contentDescription = null,
tint = foregroundTint,
modifier = iconModifier
)
}
} else {
null
},
modifier = modifier,
onClick = onClick,
onLongClick = onLongClick,
enabled = enabled
)
}
/**
* Text row that positions [text] and optional [label] in a [TextAndLabel] to the side of an [icon] using ImageVector.
*/
@Composable
fun TextRow(
icon: ImageVector?,
modifier: Modifier = Modifier,
iconModifier: Modifier = Modifier,
text: String? = null,
label: String? = null,
foregroundTint: Color = MaterialTheme.colorScheme.onSurface,
iconTint: Color = foregroundTint,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
enabled: Boolean = true
) {
TextRow(
text = {
TextAndLabel(
text = text,
label = label,
textColor = foregroundTint,
enabled = enabled
)
},
icon = if (icon != null) {
{
Icon(
imageVector = icon,
contentDescription = null,
tint = iconTint,
modifier = iconModifier
)
}
} else {
null
},
modifier = modifier,
onClick = onClick,
onLongClick = onLongClick,
enabled = enabled
)
}
/**
* Customizable text row that allows [text] and [icon] to be provided as composable functions instead of primitives.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TextRow(
text: @Composable RowScope.() -> Unit,
modifier: Modifier = Modifier,
icon: (@Composable RowScope.() -> Unit)? = null,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
enabled: Boolean = true
) {
val haptics = LocalHapticFeedback.current
Row(
modifier = modifier
.fillMaxWidth()
.combinedClickable(
enabled = enabled && (onClick != null || onLongClick != null),
onClick = onClick ?: {},
onLongClick = {
if (onLongClick != null) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onLongClick()
}
}
)
.padding(defaultPadding()),
verticalAlignment = CenterVertically
) {
if (icon != null) {
icon()
Spacer(modifier = Modifier.width(24.dp))
}
text()
}
}
@Composable
fun defaultPadding(): PaddingValues {
return PaddingValues(
horizontal = dimensionResource(id = R.dimen.gutter),
vertical = 16.dp
)
}
/**
* Row component to position text above an optional label.
*/
@Composable
fun RowScope.TextAndLabel(
text: String? = null,
modifier: Modifier = Modifier,
label: String? = null,
enabled: Boolean = true,
textColor: Color = MaterialTheme.colorScheme.onSurface,
textStyle: TextStyle = MaterialTheme.typography.bodyLarge
) {
TextAndLabel(
text = remember(text) { text?.let { AnnotatedString(it) } },
label = remember(label) { label?.let { AnnotatedString(it) } },
modifier = modifier,
enabled = enabled,
textColor = textColor,
textStyle = textStyle
)
}
/**
* Row component to position text above an optional label.
*/
@Composable
fun RowScope.TextAndLabel(
text: AnnotatedString? = null,
modifier: Modifier = Modifier,
label: AnnotatedString? = null,
enabled: Boolean = true,
textColor: Color = MaterialTheme.colorScheme.onSurface,
textStyle: TextStyle = MaterialTheme.typography.bodyLarge,
inlineContent: Map<String, InlineTextContent> = mapOf()
) {
Column(
modifier = modifier
.alpha(if (enabled) 1f else DISABLED_ALPHA)
.weight(1f)
) {
if (text != null) {
Text(
text = text,
style = textStyle,
color = textColor,
inlineContent = inlineContent
)
}
if (label != null) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
private data class ToggleState(
val checked: Boolean,
val isLoading: Boolean,
val enabled: Boolean,
val onCheckChanged: (Boolean) -> Unit
)
@DayNightPreviews
@Composable
private fun RadioRowPreview() {
Previews.Preview {
var selected by remember { mutableStateOf(true) }
Rows.RadioRow(
selected,
"RadioRow",
label = "RadioRow Label",
modifier = Modifier.clickable {
selected = !selected
}
)
}
}
@DayNightPreviews
@Composable
private fun ToggleRowPreview() {
Previews.Preview {
var checked by remember { mutableStateOf(false) }
Rows.ToggleRow(
checked = checked,
text = "ToggleRow",
label = "ToggleRow label",
onCheckChanged = {
checked = it
}
)
}
}
@DayNightPreviews
@Composable
private fun ToggleLoadingRowPreview() {
Previews.Preview {
var checked by remember { mutableStateOf(false) }
Rows.ToggleRow(
checked = checked,
text = "ToggleRow",
label = "ToggleRow label",
isLoading = true,
onCheckChanged = {
checked = it
}
)
}
}
@DayNightPreviews
@Composable
private fun TextRowPreview() {
Previews.Preview {
Rows.TextRow(
text = "TextRow",
icon = painterResource(id = android.R.drawable.ic_menu_camera),
onClick = {}
)
}
}
@DayNightPreviews
@Composable
private fun TextAndLabelPreview() {
Previews.Preview {
Row {
TextAndLabel(
text = "TextAndLabel Text",
label = "TextAndLabel Label"
)
TextAndLabel(
text = "TextAndLabel Text",
label = "TextAndLabel Label",
enabled = false
)
}
}
}
@DayNightPreviews
@Composable
private fun RadioListRowPreview() {
var selectedValue by remember { mutableStateOf("b") }
Previews.Preview {
Rows.RadioListRow(
text = "Radio List",
labels = arrayOf("A", "B", "C"),
values = arrayOf("a", "b", "c"),
selectedValue = selectedValue,
onSelected = {
selectedValue = it
}
)
}
}
@DayNightPreviews
@Composable
private fun MultiSelectRowPreview() {
var selectedValues by remember { mutableStateOf(arrayOf("b")) }
Previews.Preview {
Rows.MultiSelectRow(
text = "MultiSelect List",
labels = arrayOf("A", "B", "C"),
values = arrayOf("a", "b", "c"),
selection = selectedValues,
onSelectionChanged = {
selectedValues = it
}
)
}
}

View file

@ -0,0 +1,171 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.theme.SignalTheme
@OptIn(ExperimentalMaterial3Api::class)
object Scaffolds {
/**
* Settings scaffold that takes an icon as an ImageVector.
*
* @param titleContent The title area content. First parameter is the contentOffset.
*/
@Composable
fun Settings(
title: String,
onNavigationClick: () -> Unit,
modifier: Modifier = Modifier,
navigationIcon: ImageVector? = null,
navigationContentDescription: String? = null,
titleContent: @Composable (Float, String) -> Unit = { _, title ->
Text(text = title, style = MaterialTheme.typography.titleLarge)
},
snackbarHost: @Composable () -> Unit = {},
actions: @Composable RowScope.() -> Unit = {},
content: @Composable (PaddingValues) -> Unit
) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
Scaffold(
snackbarHost = snackbarHost,
topBar = {
DefaultTopAppBar(
title = title,
titleContent = titleContent,
onNavigationClick = onNavigationClick,
navigationIcon = navigationIcon,
navigationContentDescription = navigationContentDescription,
actions = actions,
scrollBehavior = scrollBehavior
)
},
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
content = content
)
}
/**
* Top app bar that takes an ImageVector
*/
@Composable
fun DefaultTopAppBar(
title: String,
titleContent: @Composable (Float, String) -> Unit,
onNavigationClick: () -> Unit,
navigationIcon: ImageVector?,
navigationContentDescription: String? = null,
actions: @Composable RowScope.() -> Unit = {},
scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
) {
TopAppBar(
title = {
titleContent(scrollBehavior.state.contentOffset, title)
},
navigationIcon = {
if (navigationIcon != null) {
IconButton(
onClick = onNavigationClick,
Modifier.padding(end = 16.dp)
) {
Icon(
imageVector = navigationIcon,
contentDescription = navigationContentDescription
)
}
}
},
scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.topAppBarColors(
scrolledContainerColor = SignalTheme.colors.colorSurface2
),
actions = actions
)
}
}
@DayNightPreviews
@Composable
private fun SettingsScaffoldPreview() {
SignalTheme {
val vector = remember {
ImageVector.Builder(
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).build()
}
Scaffolds.Settings(
"Settings Scaffold",
onNavigationClick = {},
navigationIcon = vector,
actions = {
IconButton(onClick = {}) {
Icon(painterResource(android.R.drawable.ic_menu_camera), contentDescription = null)
}
}
) { paddingValues ->
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
) {
Text("Content")
}
}
}
}
@DayNightPreviews
@Composable
private fun SettingsScaffoldNoNavIconPreview() {
SignalTheme {
Scaffolds.Settings(
"Settings Scaffold",
onNavigationClick = {},
actions = {
IconButton(onClick = {}) {
Icon(painterResource(android.R.drawable.ic_menu_camera), contentDescription = null)
}
}
) { paddingValues ->
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
) {
Text("Content")
}
}
}
}

View file

@ -0,0 +1,45 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose
import android.content.res.Configuration
import androidx.compose.ui.tooling.preview.Preview
/**
* Only generates a dark preview. Useful for screens that are only ever rendered in dark mode (like calling).
*/
@Preview(name = "night mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
annotation class NightPreview()
@Preview(name = "day mode", uiMode = Configuration.UI_MODE_NIGHT_NO)
@NightPreview
annotation class DayNightPreviews
@Preview(name = "phone portrait (day)", uiMode = Configuration.UI_MODE_NIGHT_NO, device = "spec:width=360dp,height=640dp,orientation=portrait")
@Preview(name = "phone portrait (night)", uiMode = Configuration.UI_MODE_NIGHT_YES, device = "spec:width=360dp,height=640dp,orientation=portrait")
@Preview(name = "phone landscape (day)", uiMode = Configuration.UI_MODE_NIGHT_NO, device = "spec:width=640dp,height=360dp,orientation=landscape")
annotation class PhonePreviews
@Preview(name = "foldable portrait (day)", uiMode = Configuration.UI_MODE_NIGHT_NO, device = "spec:width=600dp,height=1024dp,orientation=portrait")
@Preview(name = "foldable landscape (night)", uiMode = Configuration.UI_MODE_NIGHT_YES, device = "spec:width=1024dp,height=600dp,orientation=landscape")
annotation class FoldablePreviews
@Preview(name = "tablet portrait (night)", uiMode = Configuration.UI_MODE_NIGHT_YES, device = "spec:width=840dp,height=1280dp,orientation=portrait")
@Preview(name = "tablet landscape (day)", uiMode = Configuration.UI_MODE_NIGHT_NO, device = "spec:width=1280dp,height=840dp,orientation=landscape")
annotation class TabletPreviews
@Preview(name = "phone portrait (night)", uiMode = Configuration.UI_MODE_NIGHT_YES, device = "spec:width=360dp,height=640dp,orientation=portrait")
@Preview(name = "phone landscape (night)", uiMode = Configuration.UI_MODE_NIGHT_YES, device = "spec:width=640dp,height=360dp,orientation=landscape")
@Preview(name = "foldable portrait (night)", uiMode = Configuration.UI_MODE_NIGHT_YES, device = "spec:width=600dp,height=1024dp,orientation=portrait")
@Preview(name = "foldable landscape (night)", uiMode = Configuration.UI_MODE_NIGHT_YES, device = "spec:width=1024dp,height=600dp,orientation=landscape")
@Preview(name = "tablet portrait (night)", uiMode = Configuration.UI_MODE_NIGHT_YES, device = "spec:width=840dp,height=1280dp,orientation=portrait")
@Preview(name = "tablet landscape (night)", uiMode = Configuration.UI_MODE_NIGHT_YES, device = "spec:width=1280dp,height=840dp,orientation=landscape")
annotation class AllNightPreviews
@PhonePreviews
@FoldablePreviews
@TabletPreviews
annotation class AllDevicePreviews

View file

@ -0,0 +1,65 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarData
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarVisuals
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import org.signal.core.ui.compose.theme.LocalSnackbarColors
import org.signal.core.ui.compose.theme.SignalTheme
/**
* Properly themed Snackbars. Since these use internal color state, we need to
* use a local provider to pass the properly themed colors around. These composables
* allow for quick and easy access to the proper theming for snackbars.
*/
object Snackbars {
@Composable
fun Host(snackbarHostState: SnackbarHostState, modifier: Modifier = Modifier) {
SnackbarHost(hostState = snackbarHostState, modifier = modifier) {
Default(snackbarData = it)
}
}
@Composable
fun Default(snackbarData: SnackbarData) {
val colors = LocalSnackbarColors.current
Snackbar(
snackbarData = snackbarData,
containerColor = colors.color,
contentColor = colors.contentColor,
actionColor = colors.actionColor,
actionContentColor = colors.actionContentColor,
dismissActionContentColor = colors.dismissActionContentColor
)
}
}
@DayNightPreviews
@Composable
private fun SnackbarPreview() {
SignalTheme {
Snackbars.Default(snackbarData = SampleSnackbarData)
}
}
private object SampleSnackbarData : SnackbarData {
override val visuals = object : SnackbarVisuals {
override val actionLabel: String = "Action Label"
override val duration: SnackbarDuration = SnackbarDuration.Short
override val message: String = "Message"
override val withDismissAction: Boolean = true
}
override fun dismiss() = Unit
override fun performAction() = Unit
}

View file

@ -0,0 +1,197 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
object TextFields {
/**
* This is intended to replicate what TextField exposes but allows us to set our own content padding as
* well as resolving the auto-scroll to cursor position issue.
*
* Prefer the base TextField where possible.
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun TextField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
readOnly: Boolean = false,
textStyle: TextStyle = LocalTextStyle.current,
label: @Composable (() -> Unit)? = null,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
prefix: @Composable (() -> Unit)? = null,
suffix: @Composable (() -> Unit)? = null,
supportingText: @Composable (() -> Unit)? = null,
isError: Boolean = false,
visualTransformation: VisualTransformation = VisualTransformation.None,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
singleLine: Boolean = false,
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
minLines: Int = 1,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
shape: Shape = TextFieldDefaults.shape,
colors: TextFieldColors = TextFieldDefaults.colors(),
contentPadding: PaddingValues =
if (label == null) {
TextFieldDefaults.contentPaddingWithoutLabel()
} else {
TextFieldDefaults.contentPaddingWithLabel()
}
) {
// If color is not provided via the text style, use content color as a default
val textColor = textStyle.color.takeOrElse {
LocalContentColor.current
}
val mergedTextStyle = textStyle.merge(TextStyle(color = textColor))
val cursorColor = rememberUpdatedState(newValue = if (isError) MaterialTheme.colorScheme.error else textColor)
// Borrowed from BasicTextField, all this helps reduce recompositions.
var lastTextValue by remember(value) { mutableStateOf(value) }
var textFieldValueState by remember {
mutableStateOf(
TextFieldValue(
text = value,
selection = value.createSelection()
)
)
}
val textFieldValue = textFieldValueState.copy(
text = value,
selection = if (textFieldValueState.text.isBlank()) value.createSelection() else textFieldValueState.selection
)
SideEffect {
if (textFieldValue.selection != textFieldValueState.selection ||
textFieldValue.composition != textFieldValueState.composition
) {
textFieldValueState = textFieldValue
}
}
var hasFocus by remember { mutableStateOf(false) }
// BasicTextField has a bug where it won't scroll down to keep the cursor in view.
val bringIntoViewRequester = remember { BringIntoViewRequester() }
val coroutineScope = rememberCoroutineScope()
CompositionLocalProvider(LocalTextSelectionColors provides TextSelectionColors(handleColor = LocalContentColor.current, LocalContentColor.current.copy(alpha = 0.4f))) {
BasicTextField(
value = textFieldValue,
modifier = modifier
.onFocusChanged { }
.bringIntoViewRequester(bringIntoViewRequester)
.onFocusChanged { focusState -> hasFocus = focusState.hasFocus }
.defaultMinSize(
minWidth = TextFieldDefaults.MinWidth,
minHeight = TextFieldDefaults.MinHeight
),
onValueChange = { newTextFieldValueState ->
textFieldValueState = newTextFieldValueState
val stringChangedSinceLastInvocation = lastTextValue != newTextFieldValueState.text
lastTextValue = newTextFieldValueState.text
if (stringChangedSinceLastInvocation) {
onValueChange(newTextFieldValueState.text)
}
},
enabled = enabled,
readOnly = readOnly,
textStyle = mergedTextStyle,
cursorBrush = SolidColor(cursorColor.value),
visualTransformation = visualTransformation,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
interactionSource = interactionSource,
singleLine = singleLine,
maxLines = maxLines,
minLines = minLines,
onTextLayout = { result ->
if (hasFocus && textFieldValue.selection.collapsed) {
val rect = result.getCursorRect(textFieldValue.selection.start)
coroutineScope.launch {
bringIntoViewRequester.bringIntoView(rect.translate(translateX = 0f, translateY = 72.dp.value))
}
}
},
decorationBox = @Composable { innerTextField ->
// places leading icon, text field with label and placeholder, trailing icon
TextFieldDefaults.DecorationBox(
value = value,
visualTransformation = visualTransformation,
innerTextField = innerTextField,
placeholder = placeholder,
label = label,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
prefix = prefix,
suffix = suffix,
supportingText = supportingText,
shape = shape,
singleLine = singleLine,
enabled = enabled,
isError = isError,
interactionSource = interactionSource,
colors = colors,
contentPadding = contentPadding
)
}
)
}
}
private fun String.createSelection(): TextRange {
return when {
isEmpty() -> TextRange.Zero
else -> TextRange(length, length)
}
}
}

View file

@ -0,0 +1,91 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose
import android.text.Spanned
import android.text.style.URLSpan
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.text.getSpans
import org.signal.core.ui.R
import org.signal.core.ui.compose.theme.SignalTheme
object Texts {
/**
* Header row for settings pages.
*/
@Composable
fun SectionHeader(
text: String,
modifier: Modifier = Modifier
) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
modifier = modifier
.padding(
horizontal = dimensionResource(id = R.dimen.gutter)
)
.padding(top = 16.dp, bottom = 12.dp)
)
}
@Composable
fun LinkifiedText(
textWithUrlSpans: Spanned,
onUrlClick: (String) -> Unit,
modifier: Modifier = Modifier,
style: TextStyle = LocalTextStyle.current
) {
val annotatedText = annotatedStringFromUrlSpans(urlSpanText = textWithUrlSpans)
ClickableText(
text = annotatedText,
style = style,
modifier = modifier,
onClick = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset).firstOrNull()?.let { annotation ->
onUrlClick(annotation.item)
}
}
)
}
@Composable
private fun annotatedStringFromUrlSpans(urlSpanText: Spanned): AnnotatedString {
val builder = AnnotatedString.Builder(urlSpanText.toString())
val urlSpans = urlSpanText.getSpans<URLSpan>()
for (urlSpan in urlSpans) {
val spanStart = urlSpanText.getSpanStart(urlSpan)
val spanEnd = urlSpanText.getSpanEnd(urlSpan)
builder.addStyle(
style = SpanStyle(color = MaterialTheme.colorScheme.primary),
start = spanStart,
end = spanEnd
)
builder.addStringAnnotation("URL", urlSpan.url, spanStart, spanEnd)
}
return builder.toAnnotatedString()
}
}
@Preview
@Composable
private fun SectionHeaderPreview() {
SignalTheme(isDarkMode = false) {
Texts.SectionHeader("Header")
}
}

View file

@ -0,0 +1,74 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.graphics.Color
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filterNot
object Tooltips {
/**
* Renders a tooltip below the anchor content regardless of space, aligning the end edge of each.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PlainBelowAnchor(
onDismiss: () -> Unit,
containerColor: Color = MaterialTheme.colorScheme.primary,
contentColor: Color = MaterialTheme.colorScheme.onPrimary,
isTooltipVisible: Boolean,
tooltipContent: @Composable () -> Unit,
anchorContent: @Composable () -> Unit
) {
val tooltipState = rememberTooltipState(
initialIsVisible = isTooltipVisible,
isPersistent = true
)
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Below),
state = tooltipState,
tooltip = {
PlainTooltip(
shape = TooltipDefaults.plainTooltipContainerShape,
caretShape = TooltipDefaults.caretShape(),
containerColor = containerColor,
contentColor = contentColor
) {
tooltipContent()
}
}
) {
anchorContent()
}
LaunchedEffect(isTooltipVisible) {
if (isTooltipVisible) {
tooltipState.show()
} else {
tooltipState.dismiss()
}
}
LaunchedEffect(tooltipState) {
snapshotFlow { tooltipState.isVisible }
.drop(1)
.filterNot { it }
.collect { onDismiss() }
}
}
}

View file

@ -0,0 +1,139 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose
import android.graphics.drawable.ColorDrawable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.roundToIntRect
import androidx.compose.ui.window.DialogWindowProvider
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.signal.core.ui.compose.TriggerAlignedPopupState.Companion.popupTrigger
import kotlin.math.max
import kotlin.math.min
/**
* Stores information related to the positional and display state of a
* [TriggerAlignedPopup].
*/
@Stable
class TriggerAlignedPopupState private constructor(
initialDisplay: Boolean = false,
initialTriggerBounds: IntRect = IntRect.Zero
) {
var display by mutableStateOf(initialDisplay)
private var triggerBounds by mutableStateOf(initialTriggerBounds)
val popupPositionProvider = derivedStateOf<PopupPositionProvider> {
object : PopupPositionProvider {
override fun calculatePosition(anchorBounds: IntRect, windowSize: IntSize, layoutDirection: LayoutDirection, popupContentSize: IntSize): IntOffset {
val desiredXOffset = triggerBounds.left + triggerBounds.width / 2 - popupContentSize.width / 2
val maxXOffset = windowSize.width - popupContentSize.width
return IntOffset(max(0, min(desiredXOffset, maxXOffset)), anchorBounds.top - popupContentSize.height)
}
}
}
companion object {
@Serializable
data class SaveState(
val display: Boolean,
val left: Int,
val top: Int,
val right: Int,
val bottom: Int
)
@Composable
fun rememberTriggerAlignedPopupState(): TriggerAlignedPopupState {
return rememberSaveable(
saver = Saver(
save = {
Json.encodeToString(
SaveState(
display = it.display,
left = it.triggerBounds.left,
right = it.triggerBounds.right,
top = it.triggerBounds.top,
bottom = it.triggerBounds.bottom
)
)
},
restore = {
val saveState: SaveState = Json.decodeFromString(it)
TriggerAlignedPopupState(
saveState.display,
IntRect(saveState.left, saveState.top, saveState.right, saveState.bottom)
)
}
)
) {
TriggerAlignedPopupState()
}
}
/**
* Sets the given composable as the popup trigger. This does NOT
* display the popup. Rather, it just sets positional information
* in the state. It is still up to the caller to call `state.displayed = true`
* in order to display the popup itself.
*/
fun Modifier.popupTrigger(state: TriggerAlignedPopupState): Modifier {
return this.onPlaced {
state.triggerBounds = it.boundsInWindow().roundToIntRect()
}
}
}
}
/**
* Focusable popup window that aligns itself with its trigger, if provided.
*
* See [TriggerAlignedPopupState.Companion.popupTrigger] for more information.
*/
@Composable
fun TriggerAlignedPopup(
state: TriggerAlignedPopupState,
onDismissRequest: () -> Unit = { state.display = false },
content: @Composable () -> Unit
) {
if (state.display) {
val positionProvider by state.popupPositionProvider
Popup(
properties = PopupProperties(focusable = true),
onDismissRequest = onDismissRequest,
popupPositionProvider = positionProvider
) {
(LocalView.current.parent as? DialogWindowProvider)?.apply {
this.window.setBackgroundDrawable(ColorDrawable(0))
}
content()
}
}
}

View file

@ -0,0 +1,237 @@
package org.signal.core.ui.compose.copied.androidx.compose
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.zIndex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.copied.androidx.compose.DragAndDropEvent.OnItemMove
/**
* From AndroidX Compose demo
* https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/LazyColumnDragAndDropDemo.kt
*
* Allows for dragging and dropping to reorder within lazy columns
* Supports adding non-draggable headers and footers.
*/
@Composable
fun rememberDragDropState(
lazyListState: LazyListState,
includeHeader: Boolean,
includeFooter: Boolean,
onEvent: (DragAndDropEvent) -> Unit = {}
): DragDropState {
val scope = rememberCoroutineScope()
val state = remember(lazyListState) {
DragDropState(state = lazyListState, onEvent = onEvent, includeHeader = includeHeader, includeFooter = includeFooter, scope = scope)
}
LaunchedEffect(state) {
while (true) {
val diff = state.scrollChannel.receive()
lazyListState.scrollBy(diff)
}
}
return state
}
class DragDropState
internal constructor(
private val state: LazyListState,
private val scope: CoroutineScope,
private val includeHeader: Boolean,
private val includeFooter: Boolean,
private val onEvent: (DragAndDropEvent) -> Unit
) {
var draggingItemIndex by mutableStateOf<Int?>(null)
private set
internal val scrollChannel = Channel<Float>()
private var draggingItemDraggedDelta by mutableFloatStateOf(0f)
private var draggingItemInitialOffset by mutableIntStateOf(0)
internal val draggingItemOffset: Float
get() =
draggingItemLayoutInfo?.let { item ->
draggingItemInitialOffset + draggingItemDraggedDelta - item.offset
} ?: 0f
private val draggingItemLayoutInfo: LazyListItemInfo?
get() = state.layoutInfo.visibleItemsInfo.firstOrNull { it.index == draggingItemIndex }
internal var previousIndexOfDraggedItem by mutableStateOf<Int?>(null)
private set
internal var previousItemOffset = Animatable(0f)
private set
internal fun onDragStart(offset: Offset) {
state.layoutInfo.visibleItemsInfo
.firstOrNull { item ->
offset.y.toInt() in item.offset..(item.offset + item.size) &&
(!includeHeader || item.index != 0) &&
(!includeFooter || item.index != (state.layoutInfo.totalItemsCount - 1))
}
?.also {
draggingItemIndex = it.index
draggingItemInitialOffset = it.offset
}
}
internal fun onDragEnd() {
onDragInterrupted()
onEvent(DragAndDropEvent.OnItemDrop)
}
internal fun onDragCancel() {
onDragInterrupted()
onEvent(DragAndDropEvent.OnDragCancel)
}
private fun onDragInterrupted() {
if (draggingItemIndex != null) {
previousIndexOfDraggedItem = draggingItemIndex
val startOffset = draggingItemOffset
scope.launch {
previousItemOffset.snapTo(startOffset)
previousItemOffset.animateTo(
0f,
spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 1f)
)
previousIndexOfDraggedItem = null
}
}
draggingItemDraggedDelta = 0f
draggingItemIndex = null
draggingItemInitialOffset = 0
}
internal fun onDrag(offset: Offset) {
if ((includeHeader && draggingItemIndex == 0) ||
(includeFooter && draggingItemIndex == (state.layoutInfo.totalItemsCount - 1))
) return
draggingItemDraggedDelta += offset.y
val draggingItem = draggingItemLayoutInfo ?: return
val startOffset = draggingItem.offset + draggingItemOffset
val endOffset = startOffset + draggingItem.size
val middleOffset = startOffset + (endOffset - startOffset) / 2f
val targetItem =
state.layoutInfo.visibleItemsInfo.find { item ->
middleOffset.toInt() in item.offset..item.offsetEnd &&
item.index != draggingItem.index &&
(!includeHeader || item.index != 0) &&
(!includeFooter || item.index != (state.layoutInfo.totalItemsCount - 1))
}
if (targetItem != null &&
(!includeHeader || targetItem.index != 0) &&
(!includeFooter || targetItem.index != (state.layoutInfo.totalItemsCount - 1))
) {
if (includeHeader) {
onEvent.invoke(OnItemMove(fromIndex = draggingItem.index - 1, toIndex = targetItem.index - 1))
} else {
onEvent.invoke(OnItemMove(fromIndex = draggingItem.index, toIndex = targetItem.index))
}
draggingItemIndex = targetItem.index
} else {
val overscroll =
when {
draggingItemDraggedDelta > 0 ->
(endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f)
draggingItemDraggedDelta < 0 ->
(startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f)
else -> 0f
}
if (overscroll != 0f) {
scrollChannel.trySend(overscroll)
}
}
}
private val LazyListItemInfo.offsetEnd: Int
get() = this.offset + this.size
}
sealed interface DragAndDropEvent {
/**
* Triggered when an item is moving from one position to another.
*
* The ordering of the corresponding UI state should be updated when this event is received.
*/
data class OnItemMove(val fromIndex: Int, val toIndex: Int) : DragAndDropEvent
/**
* Triggered when a dragged item is dropped into its final position.
*/
data object OnItemDrop : DragAndDropEvent
/**
* Triggered when a drag gesture is canceled.
*/
data object OnDragCancel : DragAndDropEvent
}
fun Modifier.dragContainer(
dragDropState: DragDropState,
leftDpOffset: Dp,
rightDpOffset: Dp
): Modifier {
return pointerInput(dragDropState) {
detectDragGestures(
onDrag = { change, offset ->
change.consume()
dragDropState.onDrag(offset = offset)
},
onDragStart = { offset -> dragDropState.onDragStart(offset) },
onDragEnd = { dragDropState.onDragEnd() },
onDragCancel = { dragDropState.onDragCancel() },
leftDpOffset = leftDpOffset,
rightDpOffset = rightDpOffset
)
}
}
@Composable
fun LazyItemScope.DraggableItem(
dragDropState: DragDropState,
index: Int,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.(isDragging: Boolean) -> Unit
) {
val dragging = index == dragDropState.draggingItemIndex
val draggingModifier =
if (dragging) {
Modifier.zIndex(1f).graphicsLayer { translationY = dragDropState.draggingItemOffset }
} else if (index == dragDropState.previousIndexOfDraggedItem) {
Modifier.zIndex(1f).graphicsLayer {
translationY = dragDropState.previousItemOffset.value
}
} else {
Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null)
}
Column(modifier = modifier.then(draggingModifier)) { content(dragging) }
}

View file

@ -0,0 +1,131 @@
package org.signal.core.ui.compose.copied.androidx.compose
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.drag
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
import androidx.compose.ui.input.pointer.PointerId
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
import androidx.compose.ui.input.pointer.isOutOfBounds
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastAll
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastFirstOrNull
import androidx.compose.ui.util.fastForEach
import kotlinx.coroutines.CancellationException
/**
* Modified version of detectDragGesturesAfterLongPress from [androidx.compose.foundation.gestures.DragGestureDetector]
* that allows you to optionally offset the starting and ending position of the draggable area
*/
suspend fun PointerInputScope.detectDragGestures(
onDragStart: (Offset) -> Unit = { },
onDragEnd: () -> Unit = { },
onDragCancel: () -> Unit = { },
onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit,
leftDpOffset: Dp = 0.dp,
rightDpOffset: Dp
) {
awaitEachGesture {
try {
val down = awaitFirstDown(requireUnconsumed = false)
val drag = awaitLongPressOrCancellation(down.id)
if (drag != null && (drag.position.x > leftDpOffset.toPx()) && (drag.position.x < rightDpOffset.toPx())) {
onDragStart.invoke(drag.position)
if (
drag(drag.id) {
onDrag(it, it.positionChange())
it.consume()
}
) {
// consume up if we quit drag gracefully with the up
currentEvent.changes.fastForEach {
if (it.changedToUp()) it.consume()
}
onDragEnd()
} else {
onDragCancel()
}
}
} catch (c: CancellationException) {
onDragCancel()
throw c
}
}
}
/**
* Modified version of awaitLongPressOrCancellation from [androidx.compose.foundation.gestures.DragGestureDetector] with a reduced long press timeout
*/
suspend fun AwaitPointerEventScope.awaitLongPressOrCancellation(
pointerId: PointerId
): PointerInputChange? {
if (currentEvent.isPointerUp(pointerId)) {
return null // The pointer has already been lifted, so the long press is cancelled.
}
val initialDown =
currentEvent.changes.fastFirstOrNull { it.id == pointerId } ?: return null
var longPress: PointerInputChange? = null
var currentDown = initialDown
val longPressTimeout = (viewConfiguration.longPressTimeoutMillis / 100)
return try {
// wait for first tap up or long press
withTimeout(longPressTimeout) {
var finished = false
while (!finished) {
val event = awaitPointerEvent(PointerEventPass.Main)
if (event.changes.fastAll { it.changedToUpIgnoreConsumed() }) {
// All pointers are up
finished = true
}
if (
event.changes.fastAny {
it.isConsumed || it.isOutOfBounds(size, extendedTouchPadding)
}
) {
finished = true // Canceled
}
// Check for cancel by position consumption. We can look on the Final pass of
// the existing pointer event because it comes after the Main pass we checked
// above.
val consumeCheck = awaitPointerEvent(PointerEventPass.Final)
if (consumeCheck.changes.fastAny { it.isConsumed }) {
finished = true
}
if (event.isPointerUp(currentDown.id)) {
val newPressed = event.changes.fastFirstOrNull { it.pressed }
if (newPressed != null) {
currentDown = newPressed
longPress = currentDown
} else {
// should technically never happen as we checked it above
finished = true
}
// Pointer (id) stayed down.
} else {
longPress = event.changes.fastFirstOrNull { it.id == currentDown.id }
}
}
}
null
} catch (_: PointerEventTimeoutCancellationException) {
longPress ?: initialDown
}
}
private fun PointerEvent.isPointerUp(pointerId: PointerId): Boolean =
changes.fastFirstOrNull { it.id == pointerId }?.pressed != true

View file

@ -0,0 +1,62 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose.copied.androidx.compose.material3
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
/**
* Lifted straight from Compose-Material3
*
* This eliminates the content padding on the dropdown menu.
*/
@Suppress("ModifierParameter")
@Composable
internal fun DropdownMenu(
expanded: Boolean,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
offset: DpOffset = DpOffset(0.dp, 0.dp),
properties: PopupProperties = PopupProperties(focusable = true),
content: @Composable ColumnScope.() -> Unit
) {
val expandedStates = remember { MutableTransitionState(false) }
expandedStates.targetState = expanded
if (expandedStates.currentState || expandedStates.targetState) {
val transformOriginState = remember { mutableStateOf(TransformOrigin.Center) }
val density = LocalDensity.current
val popupPositionProvider = DropdownMenuPositionProvider(
offset,
density
) { parentBounds, menuBounds ->
transformOriginState.value = calculateTransformOrigin(parentBounds, menuBounds)
}
Popup(
onDismissRequest = onDismissRequest,
popupPositionProvider = popupPositionProvider,
properties = properties
) {
DropdownMenuContent(
expandedStates = expandedStates,
transformOriginState = transformOriginState,
modifier = modifier,
content = content
)
}
}
}

View file

@ -0,0 +1,128 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose.copied.androidx.compose.material3
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.State
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.graphics.Color
@Immutable
class IconButtonColors internal constructor(
private val containerColor: Color,
private val contentColor: Color,
private val disabledContainerColor: Color,
private val disabledContentColor: Color
) {
/**
* Represents the container color for this icon button, depending on [enabled].
*
* @param enabled whether the icon button is enabled
*/
@Composable
internal fun containerColor(enabled: Boolean): State<Color> {
return rememberUpdatedState(if (enabled) containerColor else disabledContainerColor)
}
/**
* Represents the content color for this icon button, depending on [enabled].
*
* @param enabled whether the icon button is enabled
*/
@Composable
internal fun contentColor(enabled: Boolean): State<Color> {
return rememberUpdatedState(if (enabled) contentColor else disabledContentColor)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || other !is IconButtonColors) return false
if (containerColor != other.containerColor) return false
if (contentColor != other.contentColor) return false
if (disabledContainerColor != other.disabledContainerColor) return false
if (disabledContentColor != other.disabledContentColor) return false
return true
}
override fun hashCode(): Int {
var result = containerColor.hashCode()
result = 31 * result + contentColor.hashCode()
result = 31 * result + disabledContainerColor.hashCode()
result = 31 * result + disabledContentColor.hashCode()
return result
}
}
@Immutable
class IconToggleButtonColors internal constructor(
private val containerColor: Color,
private val contentColor: Color,
private val disabledContainerColor: Color,
private val disabledContentColor: Color,
private val checkedContainerColor: Color,
private val checkedContentColor: Color
) {
/**
* Represents the container color for this icon button, depending on [enabled] and [checked].
*
* @param enabled whether the icon button is enabled
* @param checked whether the icon button is checked
*/
@Composable
internal fun containerColor(enabled: Boolean, checked: Boolean): State<Color> {
val target = when {
!enabled -> disabledContainerColor
!checked -> containerColor
else -> checkedContainerColor
}
return rememberUpdatedState(target)
}
/**
* Represents the content color for this icon button, depending on [enabled] and [checked].
*
* @param enabled whether the icon button is enabled
* @param checked whether the icon button is checked
*/
@Composable
internal fun contentColor(enabled: Boolean, checked: Boolean): State<Color> {
val target = when {
!enabled -> disabledContentColor
!checked -> contentColor
else -> checkedContentColor
}
return rememberUpdatedState(target)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || other !is IconToggleButtonColors) return false
if (containerColor != other.containerColor) return false
if (contentColor != other.contentColor) return false
if (disabledContainerColor != other.disabledContainerColor) return false
if (disabledContentColor != other.disabledContentColor) return false
if (checkedContainerColor != other.checkedContainerColor) return false
if (checkedContentColor != other.checkedContentColor) return false
return true
}
override fun hashCode(): Int {
var result = containerColor.hashCode()
result = 31 * result + contentColor.hashCode()
result = 31 * result + disabledContainerColor.hashCode()
result = 31 * result + disabledContentColor.hashCode()
result = 31 * result + checkedContainerColor.hashCode()
result = 31 * result + checkedContentColor.hashCode()
return result
}
}

View file

@ -0,0 +1,215 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose.copied.androidx.compose.material3
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.PopupPositionProvider
import kotlin.math.max
import kotlin.math.min
@Suppress("ModifierParameter", "TransitionPropertiesLabel")
@Composable
internal fun DropdownMenuContent(
expandedStates: MutableTransitionState<Boolean>,
transformOriginState: MutableState<TransformOrigin>,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
) {
// Menu open/close animation.
val transition = updateTransition(expandedStates, "DropDownMenu")
val scale by transition.animateFloat(
transitionSpec = {
if (false isTransitioningTo true) {
// Dismissed to expanded
tween(
durationMillis = IN_TRANSITION_DURATION,
easing = LinearOutSlowInEasing
)
} else {
// Expanded to dismissed.
tween(
durationMillis = 1,
delayMillis = OUT_TRANSITION_DURATION - 1
)
}
}
) {
if (it) {
// Menu is expanded.
1f
} else {
// Menu is dismissed.
0.8f
}
}
val alpha by transition.animateFloat(
transitionSpec = {
if (false isTransitioningTo true) {
// Dismissed to expanded
tween(durationMillis = 30)
} else {
// Expanded to dismissed.
tween(durationMillis = OUT_TRANSITION_DURATION)
}
}
) {
if (it) {
// Menu is expanded.
1f
} else {
// Menu is dismissed.
0f
}
}
Surface(
modifier = Modifier.graphicsLayer {
scaleX = scale
scaleY = scale
this.alpha = alpha
transformOrigin = transformOriginState.value
},
shape = MaterialTheme.shapes.extraSmall,
color = MaterialTheme.colorScheme.surface,
tonalElevation = 3.dp,
shadowElevation = 3.dp
) {
Column(
modifier = modifier
.width(IntrinsicSize.Max)
.verticalScroll(rememberScrollState()),
content = content
)
}
}
internal fun calculateTransformOrigin(
parentBounds: IntRect,
menuBounds: IntRect
): TransformOrigin {
val pivotX = when {
menuBounds.left >= parentBounds.right -> 0f
menuBounds.right <= parentBounds.left -> 1f
menuBounds.width == 0 -> 0f
else -> {
val intersectionCenter =
(
max(parentBounds.left, menuBounds.left) +
min(parentBounds.right, menuBounds.right)
) / 2
(intersectionCenter - menuBounds.left).toFloat() / menuBounds.width
}
}
val pivotY = when {
menuBounds.top >= parentBounds.bottom -> 0f
menuBounds.bottom <= parentBounds.top -> 1f
menuBounds.height == 0 -> 0f
else -> {
val intersectionCenter =
(
max(parentBounds.top, menuBounds.top) +
min(parentBounds.bottom, menuBounds.bottom)
) / 2
(intersectionCenter - menuBounds.top).toFloat() / menuBounds.height
}
}
return TransformOrigin(pivotX, pivotY)
}
@Immutable
internal data class DropdownMenuPositionProvider(
val contentOffset: DpOffset,
val density: Density,
val onPositionCalculated: (IntRect, IntRect) -> Unit = { _, _ -> }
) : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize
): IntOffset {
// The min margin above and below the menu, relative to the screen.
val verticalMargin = with(density) { MenuVerticalMargin.roundToPx() }
// The content offset specified using the dropdown offset parameter.
val contentOffsetX = with(density) { contentOffset.x.roundToPx() }
val contentOffsetY = with(density) { contentOffset.y.roundToPx() }
// Compute horizontal position.
val toRight = anchorBounds.left + contentOffsetX
val toLeft = anchorBounds.right - contentOffsetX - popupContentSize.width
val toDisplayRight = windowSize.width - popupContentSize.width
val toDisplayLeft = 0
val x = if (layoutDirection == LayoutDirection.Ltr) {
sequenceOf(
toRight,
toLeft,
// If the anchor gets outside of the window on the left, we want to position
// toDisplayLeft for proximity to the anchor. Otherwise, toDisplayRight.
if (anchorBounds.left >= 0) toDisplayRight else toDisplayLeft
)
} else {
sequenceOf(
toLeft,
toRight,
// If the anchor gets outside of the window on the right, we want to position
// toDisplayRight for proximity to the anchor. Otherwise, toDisplayLeft.
if (anchorBounds.right <= windowSize.width) toDisplayLeft else toDisplayRight
)
}.firstOrNull {
it >= 0 && it + popupContentSize.width <= windowSize.width
} ?: toLeft
// Compute vertical position.
val toBottom = maxOf(anchorBounds.bottom + contentOffsetY, verticalMargin)
val toTop = anchorBounds.top - contentOffsetY - popupContentSize.height
val toCenter = anchorBounds.top - popupContentSize.height / 2
val toDisplayBottom = windowSize.height - popupContentSize.height - verticalMargin
val y = sequenceOf(toBottom, toTop, toCenter, toDisplayBottom).firstOrNull {
it >= verticalMargin &&
it + popupContentSize.height <= windowSize.height - verticalMargin
} ?: toTop
onPositionCalculated(
anchorBounds,
IntRect(x, y, x + popupContentSize.width, y + popupContentSize.height)
)
return IntOffset(x, y)
}
}
// Size defaults.
internal val MenuVerticalMargin = 48.dp
// Menu open/close animation.
internal const val IN_TRANSITION_DURATION = 120
internal const val OUT_TRANSITION_DURATION = 75

View file

@ -0,0 +1,58 @@
package org.signal.core.ui.compose.theme
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
@Immutable
data class ExtendedColors(
val neutralSurface: Color,
val colorOnCustom: Color,
val colorOnCustomVariant: Color,
val colorSurface1: Color,
val colorSurface2: Color,
val colorSurface3: Color,
val colorSurface4: Color,
val colorSurface5: Color,
val colorTransparent1: Color,
val colorTransparent2: Color,
val colorTransparent3: Color,
val colorTransparent4: Color,
val colorTransparent5: Color,
val colorNeutral: Color,
val colorNeutralVariant: Color,
val colorTransparentInverse1: Color,
val colorTransparentInverse2: Color,
val colorTransparentInverse3: Color,
val colorTransparentInverse4: Color,
val colorTransparentInverse5: Color,
val colorNeutralInverse: Color,
val colorNeutralVariantInverse: Color
)
val LocalExtendedColors = staticCompositionLocalOf {
ExtendedColors(
neutralSurface = Color.Unspecified,
colorOnCustom = Color.Unspecified,
colorOnCustomVariant = Color.Unspecified,
colorSurface1 = Color.Unspecified,
colorSurface2 = Color.Unspecified,
colorSurface3 = Color.Unspecified,
colorSurface4 = Color.Unspecified,
colorSurface5 = Color.Unspecified,
colorTransparent1 = Color.Unspecified,
colorTransparent2 = Color.Unspecified,
colorTransparent3 = Color.Unspecified,
colorTransparent4 = Color.Unspecified,
colorTransparent5 = Color.Unspecified,
colorNeutral = Color.Unspecified,
colorNeutralVariant = Color.Unspecified,
colorTransparentInverse1 = Color.Unspecified,
colorTransparentInverse2 = Color.Unspecified,
colorTransparentInverse3 = Color.Unspecified,
colorTransparentInverse4 = Color.Unspecified,
colorTransparentInverse5 = Color.Unspecified,
colorNeutralInverse = Color.Unspecified,
colorNeutralVariantInverse = Color.Unspecified
)
}

View file

@ -0,0 +1,265 @@
package org.signal.core.ui.compose.theme
import android.content.res.Configuration
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
private val typography = Typography().run {
copy(
headlineLarge = headlineLarge.copy(
fontSize = 32.sp,
lineHeight = 40.sp,
letterSpacing = 0.sp
),
headlineMedium = headlineMedium.copy(
fontSize = 28.sp,
lineHeight = 36.sp,
letterSpacing = 0.sp
),
titleLarge = titleLarge.copy(
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
titleMedium = titleMedium.copy(
fontSize = 18.sp,
lineHeight = 24.sp,
letterSpacing = 0.0125.sp,
fontFamily = FontFamily.SansSerif,
fontStyle = FontStyle.Normal
),
titleSmall = titleSmall.copy(
fontSize = 16.sp,
lineHeight = 22.sp,
letterSpacing = 0.0125.sp
),
bodyLarge = bodyLarge.copy(
fontSize = 16.sp,
lineHeight = 22.sp,
letterSpacing = 0.0125.sp
),
bodyMedium = bodyMedium.copy(
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.0107.sp
),
bodySmall = bodySmall.copy(
fontSize = 13.sp,
lineHeight = 16.sp,
letterSpacing = 0.0192.sp
),
labelLarge = labelLarge.copy(
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.0107.sp
),
labelMedium = labelMedium.copy(
fontSize = 13.sp,
lineHeight = 16.sp,
letterSpacing = 0.0192.sp
),
labelSmall = labelSmall.copy(
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.025.sp
)
)
}
private val lightColorScheme = lightColorScheme(
primary = Color(0xFF2C58C3),
primaryContainer = Color(0xFFD2DFFB),
secondary = Color(0xFF586071),
secondaryContainer = Color(0xFFDCE5F9),
surface = Color(0xFFFBFCFF),
surfaceVariant = Color(0xFFE7EBF3),
background = Color(0xFFFBFCFF),
error = Color(0xFFBA1B1B),
errorContainer = Color(0xFFFFDAD4),
onPrimary = Color(0xFFFFFFFF),
onPrimaryContainer = Color(0xFF051845),
onSecondary = Color(0xFFFFFFFF),
onSecondaryContainer = Color(0xFF151D2C),
onSurface = Color(0xFF1B1B1D),
onSurfaceVariant = Color(0xFF545863),
onBackground = Color(0xFF1B1D1D),
outline = Color(0xFF808389)
)
private val lightExtendedColors = ExtendedColors(
neutralSurface = Color(0x99FFFFFF),
colorOnCustom = Color(0xFFFFFFFF),
colorOnCustomVariant = Color(0xB3FFFFFF),
colorSurface1 = Color(0xFFF2F5F9),
colorSurface2 = Color(0xFFEDF0F6),
colorSurface3 = Color(0xFFE8ECF4),
colorSurface4 = Color(0xFFE6EAF3),
colorSurface5 = Color(0xFFE3E7F1),
colorTransparent1 = Color(0x14FFFFFF),
colorTransparent2 = Color(0x29FFFFFF),
colorTransparent3 = Color(0x8FFFFFFF),
colorTransparent4 = Color(0xB8FFFFFF),
colorTransparent5 = Color(0xF5FFFFFF),
colorNeutral = Color(0xFFFFFFFF),
colorNeutralVariant = Color(0xB8FFFFFF),
colorTransparentInverse1 = Color(0x0A000000),
colorTransparentInverse2 = Color(0x14000000),
colorTransparentInverse3 = Color(0x66000000),
colorTransparentInverse4 = Color(0xB8000000),
colorTransparentInverse5 = Color(0xE0000000),
colorNeutralInverse = Color(0xFF121212),
colorNeutralVariantInverse = Color(0xFF5C5C5C)
)
private val darkExtendedColors = ExtendedColors(
neutralSurface = Color(0x14FFFFFF),
colorOnCustom = Color(0xFFFFFFFF),
colorOnCustomVariant = Color(0xB3FFFFFF),
colorSurface1 = Color(0xFF23242A),
colorSurface2 = Color(0xFF272A31),
colorSurface3 = Color(0xFF2C2F37),
colorSurface4 = Color(0xFF2E3039),
colorSurface5 = Color(0xFF31343E),
colorTransparent1 = Color(0x0AFFFFFF),
colorTransparent2 = Color(0x1FFFFFFF),
colorTransparent3 = Color(0x29FFFFFF),
colorTransparent4 = Color(0x7AFFFFFF),
colorTransparent5 = Color(0xB8FFFFFF),
colorNeutral = Color(0xFF121212),
colorNeutralVariant = Color(0xFF5C5C5C),
colorTransparentInverse1 = Color(0x0A000000),
colorTransparentInverse2 = Color(0x14000000),
colorTransparentInverse3 = Color(0x29000000),
colorTransparentInverse4 = Color(0xB8000000),
colorTransparentInverse5 = Color(0xF5000000),
colorNeutralInverse = Color(0xE0FFFFFF),
colorNeutralVariantInverse = Color(0xA3FFFFFF)
)
private val darkColorScheme = darkColorScheme(
primary = Color(0xFFB6C5FA),
primaryContainer = Color(0xFF464B5C),
secondary = Color(0xFFC1C6DD),
secondaryContainer = Color(0xFF414659),
surface = Color(0xFF1B1C1F),
surfaceVariant = Color(0xFF303133),
background = Color(0xFF1B1C1F),
error = Color(0xFFFFB4A9),
errorContainer = Color(0xFF930006),
onPrimary = Color(0xFF1E2438),
onPrimaryContainer = Color(0xFFDBE1FC),
onSecondary = Color(0xFF2A3042),
onSecondaryContainer = Color(0xFFDCE1F9),
onSurface = Color(0xFFE2E1E5),
onSurfaceVariant = Color(0xFFBEBFC5),
onBackground = Color(0xFFE2E1E5),
outline = Color(0xFF5C5E65)
)
private val lightSnackbarColors = SnackbarColors(
color = darkColorScheme.surface,
contentColor = darkColorScheme.onSurface,
actionColor = darkColorScheme.primary,
actionContentColor = darkColorScheme.primary,
dismissActionContentColor = darkColorScheme.onSurface
)
private val darkSnackbarColors = SnackbarColors(
color = darkColorScheme.surfaceVariant,
contentColor = darkColorScheme.onSurfaceVariant,
actionColor = darkColorScheme.primary,
actionContentColor = darkColorScheme.primary,
dismissActionContentColor = darkColorScheme.onSurfaceVariant
)
@Composable
fun SignalTheme(
isDarkMode: Boolean = LocalConfiguration.current.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES,
content: @Composable () -> Unit
) {
val extendedColors = if (isDarkMode) darkExtendedColors else lightExtendedColors
val snackbarColors = if (isDarkMode) darkSnackbarColors else lightSnackbarColors
CompositionLocalProvider(LocalExtendedColors provides extendedColors, LocalSnackbarColors provides snackbarColors) {
MaterialTheme(
colorScheme = if (isDarkMode) darkColorScheme else lightColorScheme,
typography = typography,
content = content
)
}
}
@Preview(showBackground = true)
@Composable
private fun TypographyPreview() {
SignalTheme(isDarkMode = false) {
Column {
Text(
text = "Headline Small",
style = MaterialTheme.typography.headlineLarge
)
Text(
text = "Headline Small",
style = MaterialTheme.typography.headlineMedium
)
Text(
text = "Headline Small",
style = MaterialTheme.typography.headlineSmall
)
Text(
text = "Title Large",
style = MaterialTheme.typography.titleLarge
)
Text(
text = "Title Medium",
style = MaterialTheme.typography.titleMedium
)
Text(
text = "Title Small",
style = MaterialTheme.typography.titleSmall
)
Text(
text = "Body Large",
style = MaterialTheme.typography.bodyLarge
)
Text(
text = "Body Medium",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Body Small",
style = MaterialTheme.typography.bodySmall
)
Text(
text = "Label Large",
style = MaterialTheme.typography.labelLarge
)
Text(
text = "Label Medium",
style = MaterialTheme.typography.labelMedium
)
Text(
text = "Label Small",
style = MaterialTheme.typography.labelSmall
)
}
}
}
object SignalTheme {
val colors: ExtendedColors
@Composable
get() = LocalExtendedColors.current
}

View file

@ -0,0 +1,35 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose.theme
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
/**
* Borrowed from [androidx.compose.material3.Snackbar]
*
* Works in conjunction with [org.signal.core.ui.Snackbars] for properly
* themed snackbars in light and dark modes.
*/
@Immutable
data class SnackbarColors(
val color: Color,
val contentColor: Color,
val actionColor: Color,
val actionContentColor: Color,
val dismissActionContentColor: Color
)
val LocalSnackbarColors = staticCompositionLocalOf {
SnackbarColors(
color = Color.Unspecified,
contentColor = Color.Unspecified,
actionColor = Color.Unspecified,
actionContentColor = Color.Unspecified,
dismissActionContentColor = Color.Unspecified
)
}

View file

@ -0,0 +1,53 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.view
import androidx.appcompat.app.AlertDialog
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
/**
* Shows a dialog and suspends until user interaction, returning the [AlertDialogResult].
*
* Note: this method will overwrite any existing dialog button click listeners or cancellation listener.
*/
suspend fun AlertDialog.awaitResult(
positiveButtonTextId: Int? = null,
negativeButtonTextId: Int? = null,
neutralButtonTextId: Int? = null
) = awaitResult(
positiveButtonText = positiveButtonTextId?.let(context::getString),
negativeButtonText = negativeButtonTextId?.let(context::getString),
neutralButtonText = neutralButtonTextId?.let(context::getString)
)
/**
* Shows a dialog and suspends until user interaction, returning the [AlertDialogResult].
*
* Note: this method will overwrite any existing dialog button click listeners or cancellation listener.
*/
suspend fun AlertDialog.awaitResult(
positiveButtonText: String? = null,
negativeButtonText: String? = null,
neutralButtonText: String? = null
) = suspendCancellableCoroutine { continuation ->
positiveButtonText?.let { text -> setButton(AlertDialog.BUTTON_POSITIVE, text) { _, _ -> continuation.resume(AlertDialogResult.POSITIVE) } }
negativeButtonText?.let { text -> setButton(AlertDialog.BUTTON_NEGATIVE, text) { _, _ -> continuation.resume(AlertDialogResult.NEGATIVE) } }
neutralButtonText?.let { text -> setButton(AlertDialog.BUTTON_NEUTRAL, text) { _, _ -> continuation.resume(AlertDialogResult.NEUTRAL) } }
setOnCancelListener { continuation.resume(AlertDialogResult.CANCELED) }
continuation.invokeOnCancellation { dismiss() }
show()
}
enum class AlertDialogResult {
POSITIVE,
NEGATIVE,
NEUTRAL,
CANCELED
}

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="gutter">24dp</dimen>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="gutter">16dp</dimen>
</resources>