Source added
This commit is contained in:
parent
b2864b500e
commit
ba28ca859e
8352 changed files with 1487182 additions and 1 deletions
35
core-ui/build.gradle.kts
Normal file
35
core-ui/build.gradle.kts
Normal 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)
|
||||
}
|
||||
5
core-ui/src/main/AndroidManifest.xml
Normal file
5
core-ui/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
</manifest>
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
401
core-ui/src/main/java/org/signal/core/ui/compose/Buttons.kt
Normal file
401
core-ui/src/main/java/org/signal/core/ui/compose/Buttons.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
706
core-ui/src/main/java/org/signal/core/ui/compose/Dialogs.kt
Normal file
706
core-ui/src/main/java/org/signal/core/ui/compose/Dialogs.kt
Normal 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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
69
core-ui/src/main/java/org/signal/core/ui/compose/Dividers.kt
Normal file
69
core-ui/src/main/java/org/signal/core/ui/compose/Dividers.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
141
core-ui/src/main/java/org/signal/core/ui/compose/IconButtons.kt
Normal file
141
core-ui/src/main/java/org/signal/core/ui/compose/IconButtons.kt
Normal 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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
56
core-ui/src/main/java/org/signal/core/ui/compose/Icons.kt
Normal file
56
core-ui/src/main/java/org/signal/core/ui/compose/Icons.kt
Normal 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))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
)
|
||||
39
core-ui/src/main/java/org/signal/core/ui/compose/Previews.kt
Normal file
39
core-ui/src/main/java/org/signal/core/ui/compose/Previews.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
706
core-ui/src/main/java/org/signal/core/ui/compose/Rows.kt
Normal file
706
core-ui/src/main/java/org/signal/core/ui/compose/Rows.kt
Normal 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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
171
core-ui/src/main/java/org/signal/core/ui/compose/Scaffolds.kt
Normal file
171
core-ui/src/main/java/org/signal/core/ui/compose/Scaffolds.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
197
core-ui/src/main/java/org/signal/core/ui/compose/TextFields.kt
Normal file
197
core-ui/src/main/java/org/signal/core/ui/compose/TextFields.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
91
core-ui/src/main/java/org/signal/core/ui/compose/Texts.kt
Normal file
91
core-ui/src/main/java/org/signal/core/ui/compose/Texts.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
74
core-ui/src/main/java/org/signal/core/ui/compose/Tooltips.kt
Normal file
74
core-ui/src/main/java/org/signal/core/ui/compose/Tooltips.kt
Normal 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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
4
core-ui/src/main/res/values-sw360dp/dimens.xml
Normal file
4
core-ui/src/main/res/values-sw360dp/dimens.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<dimen name="gutter">24dp</dimen>
|
||||
</resources>
|
||||
4
core-ui/src/main/res/values/dimens.xml
Normal file
4
core-ui/src/main/res/values/dimens.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<dimen name="gutter">16dp</dimen>
|
||||
</resources>
|
||||
Loading…
Add table
Add a link
Reference in a new issue