Repo cloned
This commit is contained in:
commit
496ae75f58
7988 changed files with 1451097 additions and 0 deletions
38
core-ui/build.gradle.kts
Normal file
38
core-ui/build.gradle.kts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
plugins {
|
||||
id("signal-library")
|
||||
id("molly")
|
||||
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)
|
||||
api(libs.androidx.activity.compose)
|
||||
debugApi(libs.androidx.compose.ui.tooling.core)
|
||||
api(libs.androidx.fragment.compose)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
api(libs.google.zxing.core)
|
||||
}
|
||||
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.activity.compose.LocalActivity
|
||||
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.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 = LocalActivity.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,263 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.core.ui.compose
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.core.VectorConverter
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.layout.Measurable
|
||||
import androidx.compose.ui.layout.Placeable
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Default values for [AnimatedFlowRow].
|
||||
*/
|
||||
object AnimatedFlowRowDefaults {
|
||||
internal const val ANIMATION_DURATION_MS = 300L
|
||||
|
||||
private val DefaultEasing = CubicBezierEasing(0.42f, 0f, 0.58f, 1f)
|
||||
|
||||
/**
|
||||
* Default animation spec for position animations.
|
||||
*/
|
||||
@Stable
|
||||
val positionAnimationSpec: FiniteAnimationSpec<IntOffset> = tween(
|
||||
durationMillis = ANIMATION_DURATION_MS.toInt(),
|
||||
easing = DefaultEasing
|
||||
)
|
||||
|
||||
/**
|
||||
* Default animation spec for size (height) animations.
|
||||
*/
|
||||
@Stable
|
||||
val sizeAnimationSpec: FiniteAnimationSpec<IntSize> = tween(
|
||||
durationMillis = ANIMATION_DURATION_MS.toInt(),
|
||||
easing = DefaultEasing
|
||||
)
|
||||
|
||||
internal val alphaAnimationSpec: FiniteAnimationSpec<Float> = tween(
|
||||
durationMillis = ANIMATION_DURATION_MS.toInt(),
|
||||
easing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1f)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope for [AnimatedFlowRow] content that provides [item] for adding keyed items.
|
||||
*/
|
||||
class AnimatedFlowRowScope {
|
||||
internal val items = mutableListOf<Pair<Any, @Composable () -> Unit>>()
|
||||
|
||||
/**
|
||||
* Adds an item to the flow row with a stable key for animation tracking.
|
||||
*
|
||||
* @param key A stable, unique key for this item. Items with the same key will
|
||||
* animate smoothly when their position changes.
|
||||
* @param content The composable content for this item.
|
||||
*/
|
||||
fun item(key: Any, content: @Composable () -> Unit) {
|
||||
items.add(key to content)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A FlowRow that animates item position changes smoothly.
|
||||
* Items animate from their previous position to their new position when the layout changes.
|
||||
* New items fade in after existing items have finished animating to their new positions.
|
||||
*
|
||||
* Use the [AnimatedFlowRowScope.item] function to add items with stable keys:
|
||||
* ```
|
||||
* AnimatedFlowRow {
|
||||
* item(key = "audio") { AudioChip() }
|
||||
* item(key = "video") { VideoChip() }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param modifier The modifier to apply to this layout.
|
||||
* @param sizeAnimationSpec Animation spec for container size changes, or null to disable size animation.
|
||||
* Defaults to [AnimatedFlowRowDefaults.sizeAnimationSpec].
|
||||
* @param positionAnimationSpec The animation spec to use for item position animations.
|
||||
* Defaults to [AnimatedFlowRowDefaults.positionAnimationSpec].
|
||||
* @param content The content builder using [AnimatedFlowRowScope].
|
||||
*/
|
||||
@Composable
|
||||
fun AnimatedFlowRow(
|
||||
modifier: Modifier = Modifier,
|
||||
sizeAnimationSpec: FiniteAnimationSpec<IntSize>? = AnimatedFlowRowDefaults.sizeAnimationSpec,
|
||||
positionAnimationSpec: FiniteAnimationSpec<IntOffset> = AnimatedFlowRowDefaults.positionAnimationSpec,
|
||||
content: AnimatedFlowRowScope.() -> Unit
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val positionAnimatables: SnapshotStateMap<Any, Animatable<IntOffset, *>> = remember { mutableStateMapOf() }
|
||||
val alphaAnimatables: SnapshotStateMap<Any, Animatable<Float, *>> = remember { mutableStateMapOf() }
|
||||
val knownKeys = remember { mutableSetOf<Any>() }
|
||||
|
||||
val flowRowScope = remember { AnimatedFlowRowScope() }
|
||||
flowRowScope.items.clear()
|
||||
flowRowScope.content()
|
||||
|
||||
// Key operations run each recomposition to track additions/removals synchronously
|
||||
val itemKeys = flowRowScope.items.map { it.first }
|
||||
val currentKeysSet = itemKeys.toSet()
|
||||
|
||||
// Determine which keys are new (not seen before) - check synchronously
|
||||
val newKeys = currentKeysSet - knownKeys
|
||||
val hasExistingItems = knownKeys.isNotEmpty()
|
||||
|
||||
// Pre-initialize alpha for new items to 0 if there are existing items
|
||||
// This prevents flicker by ensuring they start invisible BEFORE first render
|
||||
newKeys.forEach { key ->
|
||||
if (hasExistingItems) {
|
||||
alphaAnimatables[key] = Animatable(0f)
|
||||
}
|
||||
knownKeys.add(key)
|
||||
}
|
||||
|
||||
// Clean up animatables for removed items
|
||||
val removedKeys = knownKeys - currentKeysSet
|
||||
removedKeys.forEach { key ->
|
||||
positionAnimatables.remove(key)
|
||||
alphaAnimatables.remove(key)
|
||||
knownKeys.remove(key)
|
||||
}
|
||||
|
||||
val layoutModifier = if (sizeAnimationSpec != null) {
|
||||
modifier.animateContentSize(animationSpec = sizeAnimationSpec)
|
||||
} else {
|
||||
modifier
|
||||
}
|
||||
|
||||
Layout(
|
||||
content = {
|
||||
flowRowScope.items.forEach { (itemKey, itemContent) ->
|
||||
key(itemKey) {
|
||||
val alpha = alphaAnimatables[itemKey]?.value ?: 1f
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.layoutId(itemKey)
|
||||
.alpha(alpha)
|
||||
) {
|
||||
itemContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = layoutModifier
|
||||
) { measurables, constraints ->
|
||||
val placeables = measurables.map { it.measure(Constraints()) }
|
||||
val keyToPlaceable = measurables.zip(placeables).associate { (measurable, placeable) ->
|
||||
measurable.layoutId to placeable
|
||||
}
|
||||
|
||||
// Calculate flow row positions (centered, wrapping)
|
||||
val (totalHeight, positions) = calculateFlowRowPositions(measurables, placeables, constraints.maxWidth)
|
||||
|
||||
// Initialize animatables for new items and trigger animations for existing items
|
||||
positions.forEach { (key, targetPosition) ->
|
||||
val existingPosition = positionAnimatables[key]
|
||||
if (existingPosition == null) {
|
||||
// New item - start at target position
|
||||
positionAnimatables[key] = Animatable(targetPosition, IntOffset.VectorConverter)
|
||||
if (hasExistingItems) {
|
||||
// Fade in after position animations complete
|
||||
scope.launch {
|
||||
delay(AnimatedFlowRowDefaults.ANIMATION_DURATION_MS)
|
||||
alphaAnimatables[key]?.animateTo(1f, AnimatedFlowRowDefaults.alphaAnimationSpec)
|
||||
}
|
||||
} else {
|
||||
// First layout, appear immediately
|
||||
if (alphaAnimatables[key] == null) {
|
||||
alphaAnimatables[key] = Animatable(1f)
|
||||
}
|
||||
}
|
||||
} else if (existingPosition.targetValue != targetPosition) {
|
||||
// Item is moving - animate to new position
|
||||
scope.launch {
|
||||
existingPosition.animateTo(targetPosition, positionAnimationSpec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
layout(constraints.maxWidth, totalHeight) {
|
||||
positions.forEach { (key, _) ->
|
||||
val placeable = keyToPlaceable[key]
|
||||
val animatable = positionAnimatables[key]
|
||||
if (placeable != null && animatable != null) {
|
||||
placeable.place(animatable.value.x, animatable.value.y)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates centered flow row positions for placeables.
|
||||
* Returns a pair of (totalHeight, list of (key, position) pairs).
|
||||
*/
|
||||
private fun calculateFlowRowPositions(
|
||||
measurables: List<Measurable>,
|
||||
placeables: List<Placeable>,
|
||||
maxWidth: Int
|
||||
): Pair<Int, List<Pair<Any, IntOffset>>> {
|
||||
if (placeables.isEmpty()) return 0 to emptyList()
|
||||
|
||||
val result = mutableListOf<Pair<Any, IntOffset>>()
|
||||
val rows = mutableListOf<MutableList<Triple<Any, Measurable, Placeable>>>()
|
||||
var currentRow = mutableListOf<Triple<Any, Measurable, Placeable>>()
|
||||
var currentRowWidth = 0
|
||||
|
||||
// Group items into rows
|
||||
measurables.zip(placeables).forEach { (measurable, placeable) ->
|
||||
val key = measurable.layoutId ?: return@forEach
|
||||
if (currentRowWidth + placeable.width > maxWidth && currentRow.isNotEmpty()) {
|
||||
rows.add(currentRow)
|
||||
currentRow = mutableListOf()
|
||||
currentRowWidth = 0
|
||||
}
|
||||
currentRow.add(Triple(key, measurable, placeable))
|
||||
currentRowWidth += placeable.width
|
||||
}
|
||||
if (currentRow.isNotEmpty()) {
|
||||
rows.add(currentRow)
|
||||
}
|
||||
|
||||
// Calculate total height first
|
||||
val totalHeight = rows.sumOf { row -> row.maxOf { it.third.height } }
|
||||
|
||||
// Calculate positions (centered per row, from top to bottom)
|
||||
var y = 0
|
||||
rows.forEach { row ->
|
||||
val rowWidth = row.sumOf { it.third.width }
|
||||
val rowHeight = row.maxOf { it.third.height }
|
||||
var x = (maxWidth - rowWidth) / 2
|
||||
|
||||
row.forEach { (key, _, placeable) ->
|
||||
result.add(key to IntOffset(x, y))
|
||||
x += placeable.width
|
||||
}
|
||||
y += rowHeight
|
||||
}
|
||||
|
||||
return totalHeight to result
|
||||
}
|
||||
|
|
@ -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,64 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
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.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
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
|
||||
|
||||
object BottomSheets {
|
||||
|
||||
@Composable
|
||||
fun BottomSheet(
|
||||
onDismissRequest: () -> Unit,
|
||||
sheetState: SheetState = rememberModalBottomSheetState(),
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
return ModalBottomSheet(
|
||||
onDismissRequest = onDismissRequest,
|
||||
sheetState = sheetState,
|
||||
dragHandle = { Handle() }
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
Previews.Preview {
|
||||
BottomSheets.Handle()
|
||||
}
|
||||
}
|
||||
435
core-ui/src/main/java/org/signal/core/ui/compose/Buttons.kt
Normal file
435
core-ui/src/main/java/org/signal/core/ui/compose/Buttons.kt
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
/*
|
||||
* 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.IconButtonDefaults
|
||||
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.R
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.ui.compose.theme.colorAttribute
|
||||
|
||||
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(
|
||||
containerColor = colorAttribute(R.attr.extended_action_button_container_color),
|
||||
contentColor = colorAttribute(R.attr.extended_action_button_icon_color)
|
||||
),
|
||||
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(
|
||||
containerColor = colorAttribute(R.attr.extended_action_button_container_color),
|
||||
contentColor = colorAttribute(R.attr.extended_action_button_icon_color)
|
||||
),
|
||||
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,
|
||||
tonal: Boolean = false,
|
||||
colors: ButtonColors = if (tonal) ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = colorAttribute(R.attr.extended_action_button_container_color),
|
||||
contentColor = colorAttribute(R.attr.extended_action_button_icon_color)
|
||||
) else ButtonDefaults.buttonColors(),
|
||||
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(colorAttribute(R.attr.extended_action_button_icon_color))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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),
|
||||
colors = IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = colorAttribute(R.attr.extended_action_button_container_color),
|
||||
contentColor = colorAttribute(R.attr.extended_action_button_icon_color)
|
||||
),
|
||||
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,
|
||||
incognitoKeyboardEnabled = false
|
||||
) {
|
||||
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,
|
||||
tonal: Boolean = false,
|
||||
enabled: Boolean
|
||||
) {
|
||||
SampleBox(darkMode) {
|
||||
Buttons.Small(
|
||||
tonal = tonal,
|
||||
onClick = {},
|
||||
enabled = enabled
|
||||
) {
|
||||
Text("Button")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Buttons.SmallButton(tonal = true)")
|
||||
@Composable
|
||||
private fun SmallTonalButtonPreview() {
|
||||
Column {
|
||||
Row {
|
||||
SmallButtonSample(darkMode = false, enabled = true, tonal = true)
|
||||
SmallButtonSample(darkMode = true, enabled = true, tonal = true)
|
||||
}
|
||||
|
||||
Row {
|
||||
SmallButtonSample(darkMode = false, enabled = false, tonal = true)
|
||||
SmallButtonSample(darkMode = true, enabled = false, tonal = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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),
|
||||
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
|
||||
}
|
||||
715
core-ui/src/main/java/org/signal/core/ui/compose/Dialogs.kt
Normal file
715
core-ui/src/main/java/org/signal/core/ui/compose/Dialogs.kt
Normal file
|
|
@ -0,0 +1,715 @@
|
|||
/*
|
||||
* 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.AlertDialog
|
||||
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()
|
||||
) {
|
||||
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,
|
||||
properties: DialogProperties = DialogProperties()
|
||||
) {
|
||||
Dialog(
|
||||
onDismissRequest = onNegative,
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
dismissOnBackPress = properties.dismissOnBackPress,
|
||||
dismissOnClickOutside = properties.dismissOnClickOutside,
|
||||
securePolicy = properties.securePolicy,
|
||||
decorFitsSystemWindows = properties.decorFitsSystemWindows,
|
||||
windowTitle = properties.windowTitle
|
||||
)
|
||||
) {
|
||||
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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
68
core-ui/src/main/java/org/signal/core/ui/compose/Dividers.kt
Normal file
68
core-ui/src/main/java/org/signal/core/ui/compose/Dividers.kt
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
* 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
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
Previews.Preview {
|
||||
Dividers.Default()
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun VerticalPreview() {
|
||||
Previews.Preview {
|
||||
Dividers.Vertical(modifier = Modifier.height(20.dp))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
/*
|
||||
* 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.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
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,
|
||||
enabled: Boolean = true,
|
||||
contentPadding: PaddingValues = PaddingValues(horizontal = 16.dp),
|
||||
text: @Composable () -> Unit,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
contentPadding = contentPadding,
|
||||
text = {
|
||||
CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyLarge) {
|
||||
text()
|
||||
}
|
||||
},
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Properly styled menu item with a leading icon
|
||||
*/
|
||||
@Composable
|
||||
fun ItemWithIcon(
|
||||
menuController: MenuController,
|
||||
@DrawableRes drawableResId: Int,
|
||||
@StringRes stringResId: Int,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = {
|
||||
onClick()
|
||||
menuController.hide()
|
||||
})
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(id = drawableResId),
|
||||
contentDescription = stringResource(stringResId)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(stringResId),
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
139
core-ui/src/main/java/org/signal/core/ui/compose/IconButtons.kt
Normal file
139
core-ui/src/main/java/org/signal/core/ui/compose/IconButtons.kt
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
/*
|
||||
* 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
|
||||
}
|
||||
)
|
||||
91
core-ui/src/main/java/org/signal/core/ui/compose/Previews.kt
Normal file
91
core-ui/src/main/java/org/signal/core/ui/compose/Previews.kt
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
package org.signal.core.ui.compose
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
|
||||
object Previews {
|
||||
/**
|
||||
* The default wrapper for previews. Properly sets the theme and provides a drawing surface.
|
||||
*/
|
||||
@Composable
|
||||
fun Preview(
|
||||
forceRtl: Boolean = false,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val dir = if (forceRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||
|
||||
CompositionLocalProvider(LocalLayoutDirection provides dir) {
|
||||
SignalTheme(
|
||||
incognitoKeyboardEnabled = false
|
||||
) {
|
||||
Surface {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A preview wrapper for bottom sheet content. There will be no bottom sheet UI trimmings, just the content of the sheet. Properly sets the theme and an
|
||||
* appropriate surface color.
|
||||
*/
|
||||
@Composable
|
||||
fun BottomSheetContentPreview(
|
||||
forceRtl: Boolean = false,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val dir = if (forceRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||
|
||||
CompositionLocalProvider(LocalLayoutDirection provides dir) {
|
||||
SignalTheme(incognitoKeyboardEnabled = false) {
|
||||
Surface {
|
||||
Box(modifier = Modifier.background(color = SignalTheme.colors.colorSurface1)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A preview wrapper for a bottom sheet. You'll see the full bottom sheet UI in the expanded state.
|
||||
*/
|
||||
@Composable
|
||||
fun BottomSheetPreview(
|
||||
forceRtl: Boolean = false,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val dir = if (forceRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||
|
||||
CompositionLocalProvider(LocalLayoutDirection provides dir) {
|
||||
SignalTheme(incognitoKeyboardEnabled = false) {
|
||||
val sheetState = SheetState(
|
||||
skipPartiallyExpanded = true,
|
||||
initialValue = SheetValue.Expanded,
|
||||
positionalThreshold = { 1f },
|
||||
velocityThreshold = { 1f }
|
||||
)
|
||||
BottomSheets.BottomSheet(sheetState = sheetState, onDismissRequest = {}) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.core.ui.compose
|
||||
|
||||
import android.os.Build
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.platform.InterceptPlatformTextInput
|
||||
import androidx.compose.ui.platform.PlatformTextInputMethodRequest
|
||||
|
||||
/**
|
||||
* When [enabled]=true, this function sets the [EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING] flag for all text fields within its content to enable the
|
||||
* incognito keyboard.
|
||||
*
|
||||
* This workaround is needed until it's possible to configure granular IME options for a [androidx.compose.material3.TextField].
|
||||
* https://issuetracker.google.com/issues/359257538
|
||||
*/
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun ProvideIncognitoKeyboard(
|
||||
enabled: Boolean,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
if (enabled) {
|
||||
InterceptPlatformTextInput(
|
||||
interceptor = { request, nextHandler ->
|
||||
val modifiedRequest = PlatformTextInputMethodRequest { outAttributes ->
|
||||
request.createInputConnection(outAttributes).also {
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
outAttributes.imeOptions = outAttributes.imeOptions or EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING
|
||||
}
|
||||
}
|
||||
}
|
||||
nextHandler.startInputMethod(modifiedRequest)
|
||||
}
|
||||
) {
|
||||
content()
|
||||
}
|
||||
} else {
|
||||
content()
|
||||
}
|
||||
}
|
||||
163
core-ui/src/main/java/org/signal/core/ui/compose/QrCode.kt
Normal file
163
core-ui/src/main/java/org/signal/core/ui/compose/QrCode.kt
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.core.ui.compose
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.RoundRect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.res.imageResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.R
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.floor
|
||||
|
||||
/**
|
||||
* Shows a QRCode that represents the provided data. Includes a Signal logo in the middle.
|
||||
*/
|
||||
@Composable
|
||||
fun QrCode(
|
||||
data: QrCodeData,
|
||||
modifier: Modifier = Modifier,
|
||||
foregroundColor: Color = Color.Black,
|
||||
backgroundColor: Color = Color.White,
|
||||
deadzonePercent: Float = 0.35f
|
||||
) {
|
||||
val logo = ImageBitmap.imageResource(R.drawable.qrcode_logo)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.drawBehind {
|
||||
drawQr(
|
||||
data = data,
|
||||
foregroundColor = foregroundColor,
|
||||
backgroundColor = backgroundColor,
|
||||
deadzonePercent = deadzonePercent,
|
||||
logo = logo
|
||||
)
|
||||
}
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
fun DrawScope.drawQr(
|
||||
data: QrCodeData,
|
||||
foregroundColor: Color,
|
||||
backgroundColor: Color,
|
||||
deadzonePercent: Float,
|
||||
logo: ImageBitmap?
|
||||
) {
|
||||
val deadzonePaddingPercent = 0.045f
|
||||
|
||||
// We want an even number of dots on either side of the deadzone
|
||||
val deadzoneRadius: Int = if (data.canSupportIconOverlay) {
|
||||
(data.height * (deadzonePercent + deadzonePaddingPercent)).toInt().let { candidateDeadzoneHeight ->
|
||||
if ((data.height - candidateDeadzoneHeight) % 2 == 0) {
|
||||
candidateDeadzoneHeight
|
||||
} else {
|
||||
candidateDeadzoneHeight + 1
|
||||
}
|
||||
} / 2
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
val cellWidthPx: Float = size.width / data.width
|
||||
val cornerRadius = CornerRadius(7f, 7f)
|
||||
val deadzone = Circle(center = IntOffset(data.width / 2, data.height / 2), radius = deadzoneRadius)
|
||||
|
||||
for (x in 0 until data.width) {
|
||||
for (y in 0 until data.height) {
|
||||
val position = IntOffset(x, y)
|
||||
|
||||
if (data.get(position) && !deadzone.contains(position)) {
|
||||
val filledAbove = IntOffset(x, y - 1).let { data.get(it) && !deadzone.contains(it) }
|
||||
val filledBelow = IntOffset(x, y + 1).let { data.get(it) && !deadzone.contains(it) }
|
||||
val filledLeft = IntOffset(x - 1, y).let { data.get(it) && !deadzone.contains(it) }
|
||||
val filledRight = IntOffset(x + 1, y).let { data.get(it) && !deadzone.contains(it) }
|
||||
|
||||
val path = Path().apply {
|
||||
addRoundRect(
|
||||
RoundRect(
|
||||
rect = Rect(
|
||||
topLeft = Offset(floor(x * cellWidthPx), floor(y * cellWidthPx - 1)),
|
||||
bottomRight = Offset(ceil((x + 1) * cellWidthPx), ceil((y + 1) * cellWidthPx + 1))
|
||||
),
|
||||
topLeft = if (filledAbove || filledLeft) CornerRadius.Zero else cornerRadius,
|
||||
topRight = if (filledAbove || filledRight) CornerRadius.Zero else cornerRadius,
|
||||
bottomLeft = if (filledBelow || filledLeft) CornerRadius.Zero else cornerRadius,
|
||||
bottomRight = if (filledBelow || filledRight) CornerRadius.Zero else cornerRadius
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
drawPath(
|
||||
path = path,
|
||||
color = if (data.get(position)) foregroundColor else backgroundColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.canSupportIconOverlay) {
|
||||
// Logo border
|
||||
val logoBorderRadiusPx = ((deadzonePercent - deadzonePaddingPercent) * size.width) / 2
|
||||
drawCircle(
|
||||
color = foregroundColor,
|
||||
radius = logoBorderRadiusPx,
|
||||
style = Stroke(width = cellWidthPx * 0.75f),
|
||||
center = this.center
|
||||
)
|
||||
|
||||
// Logo
|
||||
val logoWidthPx = (((deadzonePercent - deadzonePaddingPercent) * 0.6f) * size.width).toInt()
|
||||
val logoOffsetPx = ((size.width - logoWidthPx) / 2).toInt()
|
||||
if (logo != null) {
|
||||
drawImage(
|
||||
image = logo,
|
||||
dstOffset = IntOffset(logoOffsetPx, logoOffsetPx),
|
||||
dstSize = IntSize(logoWidthPx, logoWidthPx),
|
||||
colorFilter = ColorFilter.tint(foregroundColor)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun Preview() {
|
||||
Surface {
|
||||
QrCode(
|
||||
data = QrCodeData.forData("https://signal.org"),
|
||||
modifier = Modifier.size(350.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class Circle(
|
||||
val center: IntOffset,
|
||||
val radius: Int
|
||||
) {
|
||||
fun contains(position: IntOffset): Boolean {
|
||||
val diff = center - position
|
||||
return diff.x * diff.x + diff.y * diff.y < radius * radius
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.core.ui.compose
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.EncodeHintType
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||
import java.util.BitSet
|
||||
|
||||
/**
|
||||
* Efficient representation of raw QR code data. Stored as an X/Y grid of points, where (0, 0) is the top left corner.
|
||||
* X increases as you move right, and Y increases as you go down.
|
||||
*/
|
||||
class QrCodeData(
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
val canSupportIconOverlay: Boolean,
|
||||
private val bits: BitSet
|
||||
) {
|
||||
|
||||
/**
|
||||
* Returns true if the bit in the QR code is "on" for the specified position, false if it is "off" or out of bounds.
|
||||
*/
|
||||
fun get(position: IntOffset): Boolean {
|
||||
val (x, y) = position
|
||||
return if (x < 0 || y < 0 || x >= width || y >= height) {
|
||||
false
|
||||
} else {
|
||||
bits.get(y * width + x)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* Converts the provided string data into a QR representation.
|
||||
*
|
||||
* @param supportIconOverlay indicates data can be rendered with the icon overlay. Rendering with an icon relies on more error correction
|
||||
* data in the QR which requires a denser rendering which is sometimes not easily scanned by our scanner. Set to false if data is expected to be
|
||||
* long to prevent scanning issues.
|
||||
*/
|
||||
@WorkerThread
|
||||
fun forData(data: String, supportIconOverlay: Boolean = true): QrCodeData {
|
||||
val qrCodeWriter = QRCodeWriter()
|
||||
val hints = mapOf(EncodeHintType.ERROR_CORRECTION to if (supportIconOverlay) ErrorCorrectionLevel.Q.toString() else ErrorCorrectionLevel.L.toString())
|
||||
|
||||
val padded = qrCodeWriter.encode(data, BarcodeFormat.QR_CODE, 64, 64, hints)
|
||||
val dimens = padded.enclosingRectangle
|
||||
val xStart = dimens[0]
|
||||
val yStart = dimens[1]
|
||||
val width = dimens[2]
|
||||
val height = dimens[3]
|
||||
val bitSet = BitSet(width * height)
|
||||
|
||||
for (x in xStart until xStart + width) {
|
||||
for (y in yStart until yStart + height) {
|
||||
if (padded.get(x, y)) {
|
||||
val destX = x - xStart
|
||||
val destY = y - yStart
|
||||
bitSet.set(destY * width + destX)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return QrCodeData(width, height, supportIconOverlay, bitSet)
|
||||
}
|
||||
}
|
||||
}
|
||||
720
core-ui/src/main/java/org/signal/core/ui/compose/Rows.kt
Normal file
720
core-ui/src/main/java/org/signal/core/ui/compose/Rows.kt
Normal file
|
|
@ -0,0 +1,720 @@
|
|||
/*
|
||||
* 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,
|
||||
trailingIcon: (@Composable RowScope.() -> Unit)? = null,
|
||||
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,
|
||||
trailingIcon = trailingIcon,
|
||||
enabled = enabled
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RadioListRow(
|
||||
text: @Composable RowScope.(Int) -> Unit,
|
||||
dialogTitle: String,
|
||||
labels: Array<String>,
|
||||
values: Array<String>,
|
||||
selectedValue: String,
|
||||
onSelected: (String) -> Unit,
|
||||
trailingIcon: (@Composable RowScope.() -> Unit)? = null,
|
||||
enabled: Boolean = true
|
||||
) {
|
||||
val selectedIndex = values.indexOf(selectedValue)
|
||||
var displayDialog by remember { mutableStateOf(false) }
|
||||
|
||||
TextRow(
|
||||
text = { text(selectedIndex) },
|
||||
trailingIcon = trailingIcon,
|
||||
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,
|
||||
trailingIcon: (@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
|
||||
) {
|
||||
Row(modifier = Modifier.weight(1f)) {
|
||||
if (icon != null) {
|
||||
icon()
|
||||
Spacer(modifier = Modifier.width(24.dp))
|
||||
}
|
||||
text()
|
||||
}
|
||||
// MOLLY: Trailing icon aligned to the end
|
||||
if (trailingIcon != null) {
|
||||
trailingIcon()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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,
|
||||
trailingIcon = {
|
||||
Icon(painterResource(android.R.drawable.ic_dialog_alert), contentDescription = null)
|
||||
},
|
||||
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() {
|
||||
Previews.Preview {
|
||||
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() {
|
||||
Previews.Preview {
|
||||
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,33 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.core.ui.compose
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.SheetState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Simple helper to dismiss the sheet and run a callback when the animation is finished.
|
||||
* In unit tests, set skipAnimations = true to invoke the callback immediately.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
fun SheetState.dismissWithAnimation(
|
||||
scope: CoroutineScope,
|
||||
skipAnimations: Boolean = Build.MODEL.equals("robolectric", ignoreCase = true),
|
||||
onComplete: () -> Unit
|
||||
) {
|
||||
if (skipAnimations) {
|
||||
onComplete()
|
||||
return
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
this@dismissWithAnimation.hide()
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.core.ui.compose
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material3.Icon
|
||||
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.painter.Painter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.R
|
||||
|
||||
/**
|
||||
* Signal icon library with all available icons.
|
||||
*/
|
||||
enum class SignalIcons(private val icon: SignalIcon) : SignalIcon by icon {
|
||||
Keyboard(icon(R.drawable.ic_keyboard_24)),
|
||||
Camera(icon(R.drawable.symbol_camera_24)),
|
||||
Phone(icon(R.drawable.symbol_phone_24)),
|
||||
QrCode(icon(R.drawable.symbol_qrcode_24))
|
||||
}
|
||||
|
||||
private fun icon(@DrawableRes id: Int) = SignalIcon.DrawableIcon(id)
|
||||
private fun icon(image: ImageVector) = SignalIcon.ImageVectorIcon(image)
|
||||
|
||||
sealed interface SignalIcon {
|
||||
@get:Composable
|
||||
val painter: Painter
|
||||
|
||||
/**
|
||||
* Icon backed by an XML drawable resource.
|
||||
*/
|
||||
@JvmInline
|
||||
value class DrawableIcon(@get:DrawableRes private val drawableId: Int) : SignalIcon {
|
||||
@get:Composable
|
||||
override val painter: Painter
|
||||
get() = painterResource(drawableId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon backed by an [ImageVector].
|
||||
*/
|
||||
@JvmInline
|
||||
value class ImageVectorIcon(val image: ImageVector) : SignalIcon {
|
||||
@get:Composable
|
||||
override val painter: Painter
|
||||
get() = rememberVectorPainter(image)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun SignalIconsPreview() {
|
||||
Previews.Preview {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 80.dp),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(SignalIcons.entries.sortedBy { it.name }) { icon ->
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = icon.painter,
|
||||
contentDescription = icon.name,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Text(
|
||||
text = icon.name,
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,64 @@
|
|||
/*
|
||||
* 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
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
Previews.Preview {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
90
core-ui/src/main/java/org/signal/core/ui/compose/Texts.kt
Normal file
90
core-ui/src/main/java/org/signal/core/ui/compose/Texts.kt
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
* 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
|
||||
|
||||
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() {
|
||||
Previews.Preview {
|
||||
Texts.SectionHeader("Header")
|
||||
}
|
||||
}
|
||||
75
core-ui/src/main/java/org/signal/core/ui/compose/Tooltips.kt
Normal file
75
core-ui/src/main/java/org/signal/core/ui/compose/Tooltips.kt
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/*
|
||||
* 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,
|
||||
enableUserInput = false,
|
||||
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,130 @@
|
|||
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,450 @@
|
|||
package org.signal.core.ui.compose.theme
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.util.TypedValue
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.Discouraged
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.res.colorResource
|
||||
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
|
||||
import org.signal.core.ui.R
|
||||
import org.signal.core.ui.compose.ProvideIncognitoKeyboard
|
||||
|
||||
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(
|
||||
inversePrimary = Color(0xFFAA98FF),
|
||||
surfaceDim = Color(0xFFDED6FF),
|
||||
inverseSurface = Color(0xFF33276A),
|
||||
surfaceBright = Color(0xFFF6F6FF),
|
||||
surfaceContainerLowest = Color(0xFFF9F8FF),
|
||||
surfaceContainerLow = Color(0xFFF3F0FF),
|
||||
surfaceContainer = Color(0xFFEEEAFF),
|
||||
surfaceContainerHigh = Color(0xFFEAE5FF),
|
||||
surfaceContainerHighest = Color(0xFFE5DFFF),
|
||||
inverseOnSurface = Color(0xFFF1EDF6),
|
||||
outlineVariant = Color(0xFF9384E2),
|
||||
onError = Color(0xFFFFFFFF),
|
||||
onErrorContainer = Color(0xFF6D0028),
|
||||
tertiary = Color(0xFF5A5379),
|
||||
onTertiary = Color(0xFFFFFFFF),
|
||||
tertiaryContainer = Color(0xFFD1C6FB),
|
||||
onTertiaryContainer = Color(0xFF180D49),
|
||||
primary = Color(0xFF5335DD),
|
||||
primaryContainer = Color(0xFFC1B4FB),
|
||||
secondary = Color(0xFFC5C1DD),
|
||||
secondaryContainer = Color(0xFFE3DBF9),
|
||||
surface = Color(0xFFF6F6FF),
|
||||
surfaceVariant = Color(0xFFE7E6F2),
|
||||
background = Color(0xFFF6F6FF),
|
||||
error = Color(0xFFE00052),
|
||||
errorContainer = Color(0xFFFDC7DB),
|
||||
onPrimary = Color(0xFFFFFFFF),
|
||||
onPrimaryContainer = Color(0xFF180D49),
|
||||
onSecondary = Color(0xFFFFFFFF),
|
||||
onSecondaryContainer = Color(0xFF1E1835),
|
||||
onSurface = Color(0xFF181719),
|
||||
onSurfaceVariant = Color(0xFF585563),
|
||||
onBackground = Color(0xFF171719),
|
||||
outline = Color(0xFF82808A)
|
||||
)
|
||||
|
||||
private val lightExtendedColors = ExtendedColors(
|
||||
neutralSurface = Color(0x99FFFFFF),
|
||||
colorOnCustom = Color(0xFFFFFFFF),
|
||||
colorOnCustomVariant = Color(0xB3FFFFFF),
|
||||
colorSurface1 = Color(0xFFF3F0FF),
|
||||
colorSurface2 = Color(0xFFEEEAFF),
|
||||
colorSurface3 = Color(0xFFEAE5FF),
|
||||
colorSurface4 = Color(0xFFE5DFFF),
|
||||
colorSurface5 = Color(0xFFDED6FF),
|
||||
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(0xFF181137),
|
||||
colorSurface2 = Color(0xFF1E1645),
|
||||
colorSurface3 = Color(0xFF231A4E),
|
||||
colorSurface4 = Color(0xFF281E57),
|
||||
colorSurface5 = Color(0xFF33276A),
|
||||
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(
|
||||
inversePrimary = Color(0xFF5335DD),
|
||||
surfaceBright = Color(0xFF33276A),
|
||||
inverseSurface = Color(0xFFE5DFFF),
|
||||
surfaceDim = Color(0xFF100E1F),
|
||||
surfaceContainerLowest = Color(0xFF0C0919),
|
||||
surfaceContainerLow = Color(0xFF181137),
|
||||
surfaceContainer = Color(0xFF1E1645),
|
||||
surfaceContainerHigh = Color(0xFF231A4E),
|
||||
surfaceContainerHighest = Color(0xFF281E57),
|
||||
inverseOnSurface = Color(0xFF31303D),
|
||||
outlineVariant = Color(0xFF464257),
|
||||
onError = Color(0xFF63012C),
|
||||
onErrorContainer = Color(0xFFFFDAD6),
|
||||
tertiary = Color(0xFFB3ABDA),
|
||||
onTertiary = Color(0xFF36304F),
|
||||
tertiaryContainer = Color(0xFF423290),
|
||||
onTertiaryContainer = Color(0xFFEAE5FF),
|
||||
primary = Color(0xFFAA98FF),
|
||||
primaryContainer = Color(0xFF483B6A),
|
||||
secondary = Color(0xFFCBC4DE),
|
||||
secondaryContainer = Color(0xFF434159),
|
||||
surface = Color(0xFF100E1F),
|
||||
surfaceVariant = Color(0xFF302F33),
|
||||
background = Color(0xFF100E1F),
|
||||
error = Color(0xFFFE006E),
|
||||
errorContainer = Color(0xFFA40449),
|
||||
onPrimary = Color(0xFF1E1B38),
|
||||
onPrimaryContainer = Color(0xFFDDDCFC),
|
||||
onSecondary = Color(0xFF2E2A42),
|
||||
onSecondaryContainer = Color(0xFFE3DCF9),
|
||||
onSurface = Color(0xFFE3E1E6),
|
||||
onSurfaceVariant = Color(0xFFBEBCC4),
|
||||
onBackground = Color(0xFFE3E1E6),
|
||||
outline = Color(0xFF5D5D66)
|
||||
)
|
||||
|
||||
// MOLLY: Replaced by snackbarColors()
|
||||
|
||||
@Discouraged("Use org.thoughtcrime.securesms.compose.SignalTheme instead.")
|
||||
@Composable
|
||||
fun SignalTheme(
|
||||
isDarkMode: Boolean = LocalConfiguration.current.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES,
|
||||
incognitoKeyboardEnabled: Boolean = false,
|
||||
useDynamicColors: Boolean? = null,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val maySupportDynamicColor = Build.VERSION.SDK_INT >= 31
|
||||
val dynamicColors = maySupportDynamicColor && (useDynamicColors ?: isThemeUsingDynamicColors(context))
|
||||
|
||||
// MOLLY: Apply dynamic color if supported and enabled:
|
||||
// - API 34+: Use Compose's built-in dynamic scheme (matches system Material You).
|
||||
// - API 31–33: Use mapped color resources to approximate system dynamic palette, avoiding Compose's more saturated fallback.
|
||||
// - Otherwise: Use app light/dark color schemes.
|
||||
val colorScheme = when {
|
||||
dynamicColors -> {
|
||||
if (Build.VERSION.SDK_INT >= 34) {
|
||||
if (isDarkMode) dynamicDarkColorScheme(context)
|
||||
else dynamicLightColorScheme(context)
|
||||
} else {
|
||||
if (isDarkMode) systemAlignedDarkColorScheme()
|
||||
else systemAlignedLightColorScheme()
|
||||
}
|
||||
}
|
||||
isDarkMode -> darkColorScheme
|
||||
else -> lightColorScheme
|
||||
}
|
||||
|
||||
val extendedColors = extendedColors(colorScheme, isDarkMode = isDarkMode, isDynamic = dynamicColors)
|
||||
val snackbarColors = snackbarColors(colorScheme, isDarkMode = isDarkMode, isDynamic = dynamicColors)
|
||||
|
||||
ProvideIncognitoKeyboard(enabled = incognitoKeyboardEnabled) {
|
||||
CompositionLocalProvider(LocalExtendedColors provides extendedColors, LocalSnackbarColors provides snackbarColors) {
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun systemAlignedLightColorScheme() = lightColorScheme(
|
||||
primary = colorResource(R.color.dynamic_primary_light),
|
||||
onPrimary = colorResource(R.color.dynamic_on_primary_light),
|
||||
primaryContainer = colorResource(R.color.dynamic_primary_container_light),
|
||||
onPrimaryContainer = colorResource(R.color.dynamic_on_primary_container_light),
|
||||
inversePrimary = colorResource(R.color.dynamic_primary_inverse_light),
|
||||
secondary = colorResource(R.color.dynamic_secondary_light),
|
||||
onSecondary = colorResource(R.color.dynamic_on_secondary_light),
|
||||
secondaryContainer = colorResource(R.color.dynamic_secondary_container_light),
|
||||
onSecondaryContainer = colorResource(R.color.dynamic_on_secondary_container_light),
|
||||
tertiary = colorResource(R.color.dynamic_tertiary_light),
|
||||
onTertiary = colorResource(R.color.dynamic_on_tertiary_light),
|
||||
tertiaryContainer = colorResource(R.color.dynamic_tertiary_container_light),
|
||||
onTertiaryContainer = colorResource(R.color.dynamic_on_tertiary_container_light),
|
||||
background = colorResource(R.color.dynamic_background_light),
|
||||
onBackground = colorResource(R.color.dynamic_on_background_light),
|
||||
surface = colorResource(R.color.dynamic_surface_light),
|
||||
onSurface = colorResource(R.color.dynamic_on_surface_light),
|
||||
surfaceVariant = colorResource(R.color.dynamic_surface_variant_light),
|
||||
onSurfaceVariant = colorResource(R.color.dynamic_on_surface_variant_light),
|
||||
inverseSurface = colorResource(R.color.dynamic_surface_inverse_light),
|
||||
inverseOnSurface = colorResource(R.color.dynamic_on_surface_inverse_light),
|
||||
error = colorResource(R.color.dynamic_error_light),
|
||||
onError = colorResource(R.color.dynamic_on_error_light),
|
||||
errorContainer = colorResource(R.color.dynamic_error_container_light),
|
||||
onErrorContainer = colorResource(R.color.dynamic_on_error_container_light),
|
||||
outline = colorResource(R.color.dynamic_outline_light),
|
||||
outlineVariant = colorResource(R.color.dynamic_outline_variant_light),
|
||||
surfaceBright = colorResource(R.color.dynamic_surface_bright_light),
|
||||
surfaceContainer = colorResource(R.color.dynamic_surface_container_light),
|
||||
surfaceContainerHigh = colorResource(R.color.dynamic_surface_container_high_light),
|
||||
surfaceContainerHighest = colorResource(R.color.dynamic_surface_container_highest_light),
|
||||
surfaceContainerLow = colorResource(R.color.dynamic_surface_container_low_light),
|
||||
surfaceContainerLowest = colorResource(R.color.dynamic_surface_container_lowest_light),
|
||||
surfaceDim = colorResource(R.color.dynamic_surface_dim_light),
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun systemAlignedDarkColorScheme() = darkColorScheme(
|
||||
primary = colorResource(R.color.dynamic_primary_dark),
|
||||
onPrimary = colorResource(R.color.dynamic_on_primary_dark),
|
||||
primaryContainer = colorResource(R.color.dynamic_primary_container_dark),
|
||||
onPrimaryContainer = colorResource(R.color.dynamic_on_primary_container_dark),
|
||||
inversePrimary = colorResource(R.color.dynamic_primary_inverse_dark),
|
||||
secondary = colorResource(R.color.dynamic_secondary_dark),
|
||||
onSecondary = colorResource(R.color.dynamic_on_secondary_dark),
|
||||
secondaryContainer = colorResource(R.color.dynamic_secondary_container_dark),
|
||||
onSecondaryContainer = colorResource(R.color.dynamic_on_secondary_container_dark),
|
||||
tertiary = colorResource(R.color.dynamic_tertiary_dark),
|
||||
onTertiary = colorResource(R.color.dynamic_on_tertiary_dark),
|
||||
tertiaryContainer = colorResource(R.color.dynamic_tertiary_container_dark),
|
||||
onTertiaryContainer = colorResource(R.color.dynamic_on_tertiary_container_dark),
|
||||
background = colorResource(R.color.dynamic_background_dark),
|
||||
onBackground = colorResource(R.color.dynamic_on_background_dark),
|
||||
surface = colorResource(R.color.dynamic_surface_dark),
|
||||
onSurface = colorResource(R.color.dynamic_on_surface_dark),
|
||||
surfaceVariant = colorResource(R.color.dynamic_surface_variant_dark),
|
||||
onSurfaceVariant = colorResource(R.color.dynamic_on_surface_variant_dark),
|
||||
inverseSurface = colorResource(R.color.dynamic_surface_inverse_dark),
|
||||
inverseOnSurface = colorResource(R.color.dynamic_on_surface_inverse_dark),
|
||||
error = colorResource(R.color.dynamic_error_dark),
|
||||
onError = colorResource(R.color.dynamic_on_error_dark),
|
||||
errorContainer = colorResource(R.color.dynamic_error_container_dark),
|
||||
onErrorContainer = colorResource(R.color.dynamic_on_error_container_dark),
|
||||
outline = colorResource(R.color.dynamic_outline_dark),
|
||||
outlineVariant = colorResource(R.color.dynamic_outline_variant_dark),
|
||||
surfaceBright = colorResource(R.color.dynamic_surface_bright_dark),
|
||||
surfaceContainer = colorResource(R.color.dynamic_surface_container_dark),
|
||||
surfaceContainerHigh = colorResource(R.color.dynamic_surface_container_high_dark),
|
||||
surfaceContainerHighest = colorResource(R.color.dynamic_surface_container_highest_dark),
|
||||
surfaceContainerLow = colorResource(R.color.dynamic_surface_container_low_dark),
|
||||
surfaceContainerLowest = colorResource(R.color.dynamic_surface_container_lowest_dark),
|
||||
surfaceDim = colorResource(R.color.dynamic_surface_dim_dark),
|
||||
)
|
||||
|
||||
private fun isThemeUsingDynamicColors(context: Context): Boolean {
|
||||
val theme = context.theme
|
||||
val typedValue = TypedValue()
|
||||
return theme.resolveAttribute(R.attr.dynamic_colors, typedValue, false)
|
||||
&& typedValue.data != 0
|
||||
}
|
||||
|
||||
private fun extendedColors(colorScheme: ColorScheme, isDarkMode: Boolean, isDynamic: Boolean): ExtendedColors {
|
||||
return when {
|
||||
isDynamic -> lightExtendedColors.copy(
|
||||
colorSurface1 = colorScheme.surfaceContainerLow,
|
||||
colorSurface2 = colorScheme.surfaceContainer,
|
||||
colorSurface3 = colorScheme.surfaceContainerHigh,
|
||||
colorSurface4 = colorScheme.surfaceContainerHighest,
|
||||
colorSurface5 = if (isDarkMode) colorScheme.surfaceBright else colorScheme.surfaceDim
|
||||
)
|
||||
|
||||
isDarkMode -> darkExtendedColors
|
||||
else -> lightExtendedColors
|
||||
}
|
||||
}
|
||||
|
||||
private fun snackbarColors(colorScheme: ColorScheme, isDarkMode: Boolean, isDynamic: Boolean): SnackbarColors {
|
||||
// val surface = if (!isDynamic) colorScheme.surfaceVariant else colorScheme.surface
|
||||
// val onSurface = if (!isDynamic) colorScheme.onSurfaceVariant else colorScheme.onSurface
|
||||
|
||||
return SnackbarColors(
|
||||
color = colorScheme.inverseSurface,
|
||||
contentColor = colorScheme.inverseOnSurface,
|
||||
actionColor = colorScheme.primary,
|
||||
actionContentColor = colorScheme.primary,
|
||||
dismissActionContentColor = colorScheme.inverseOnSurface
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun colorAttribute(@AttrRes id: Int): Color {
|
||||
val theme = LocalContext.current.theme
|
||||
val typedValue = TypedValue()
|
||||
return if (theme.resolveAttribute(id, typedValue, true) && typedValue.resourceId != 0) {
|
||||
colorResource(typedValue.resourceId)
|
||||
} else {
|
||||
Color.Unspecified
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun TypographyPreview() {
|
||||
SignalTheme(
|
||||
isDarkMode = false,
|
||||
incognitoKeyboardEnabled = 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Discouraged("Use org.thoughtcrime.securesms.compose.SignalTheme instead.")
|
||||
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,42 @@
|
|||
/*
|
||||
* Copyright 2025 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.signal.core.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
|
||||
/**
|
||||
* An Effect to provide a result even between different screens
|
||||
*
|
||||
* The trailing lambda provides the result from a flow of results.
|
||||
*
|
||||
* @param resultEventBus the ResultEventBus to retrieve the result from. The default value
|
||||
* is read from the `LocalResultEventBus` composition local.
|
||||
* @param resultKey the key that should be associated with this effect
|
||||
* @param onResult the callback to invoke when a result is received
|
||||
*/
|
||||
@Composable
|
||||
inline fun <reified T> ResultEffect(
|
||||
resultEventBus: ResultEventBus = LocalResultEventBus.current,
|
||||
resultKey: String = T::class.toString(),
|
||||
crossinline onResult: suspend (T) -> Unit
|
||||
) {
|
||||
LaunchedEffect(resultKey, resultEventBus.channelMap[resultKey]) {
|
||||
resultEventBus.getResultFlow<T>(resultKey)?.collect { result ->
|
||||
onResult.invoke(result as T)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
* Copyright 2025 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.signal.core.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ProvidableCompositionLocal
|
||||
import androidx.compose.runtime.ProvidedValue
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.Channel.Factory.BUFFERED
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
|
||||
/**
|
||||
* Local for receiving results in a [ResultEventBus]
|
||||
*/
|
||||
object LocalResultEventBus {
|
||||
private val LocalResultEventBus: ProvidableCompositionLocal<ResultEventBus?> =
|
||||
compositionLocalOf { null }
|
||||
|
||||
/**
|
||||
* The current [ResultEventBus]
|
||||
*/
|
||||
val current: ResultEventBus
|
||||
@Composable
|
||||
get() = LocalResultEventBus.current ?: error("No ResultEventBus has been provided")
|
||||
|
||||
/**
|
||||
* Provides a [ResultEventBus] to the composition
|
||||
*/
|
||||
infix fun provides(
|
||||
bus: ResultEventBus
|
||||
): ProvidedValue<ResultEventBus?> {
|
||||
return LocalResultEventBus.provides(bus)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An EventBus for passing results between multiple sets of screens.
|
||||
*
|
||||
* It provides a solution for event based results.
|
||||
*/
|
||||
class ResultEventBus {
|
||||
/**
|
||||
* Map from the result key to a channel of results.
|
||||
*/
|
||||
val channelMap: MutableMap<String, Channel<Any?>> = mutableMapOf()
|
||||
|
||||
/**
|
||||
* Provides a flow for the given resultKey.
|
||||
*/
|
||||
inline fun <reified T> getResultFlow(resultKey: String = T::class.toString()) = channelMap[resultKey]?.receiveAsFlow()
|
||||
|
||||
/**
|
||||
* Sends a result into the channel associated with the given resultKey.
|
||||
*/
|
||||
inline fun <reified T> sendResult(resultKey: String = T::class.toString(), result: T) {
|
||||
if (!channelMap.contains(resultKey)) {
|
||||
channelMap[resultKey] = Channel(capacity = BUFFERED, onBufferOverflow = BufferOverflow.SUSPEND)
|
||||
}
|
||||
channelMap[resultKey]?.trySend(result)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all results associated with the given key from the store.
|
||||
*/
|
||||
inline fun <reified T> removeResult(resultKey: String = T::class.toString()) {
|
||||
channelMap.remove(resultKey)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
43
core-ui/src/main/res/drawable/ic_keyboard_24.xml
Normal file
43
core-ui/src/main/res/drawable/ic_keyboard_24.xml
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M12,9.75C12.69,9.75 13.25,9.19 13.25,8.5C13.25,7.81 12.69,7.25 12,7.25C11.31,7.25 10.75,7.81 10.75,8.5C10.75,9.19 11.31,9.75 12,9.75Z" />
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M13.25,12C13.25,12.69 12.69,13.25 12,13.25C11.31,13.25 10.75,12.69 10.75,12C10.75,11.31 11.31,10.75 12,10.75C12.69,10.75 13.25,11.31 13.25,12Z" />
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M8.5,9.75C9.19,9.75 9.75,9.19 9.75,8.5C9.75,7.81 9.19,7.25 8.5,7.25C7.81,7.25 7.25,7.81 7.25,8.5C7.25,9.19 7.81,9.75 8.5,9.75Z" />
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M9.75,12C9.75,12.69 9.19,13.25 8.5,13.25C7.81,13.25 7.25,12.69 7.25,12C7.25,11.31 7.81,10.75 8.5,10.75C9.19,10.75 9.75,11.31 9.75,12Z" />
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M15.5,9.75C16.19,9.75 16.75,9.19 16.75,8.5C16.75,7.81 16.19,7.25 15.5,7.25C14.81,7.25 14.25,7.81 14.25,8.5C14.25,9.19 14.81,9.75 15.5,9.75Z" />
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M16.75,12C16.75,12.69 16.19,13.25 15.5,13.25C14.81,13.25 14.25,12.69 14.25,12C14.25,11.31 14.81,10.75 15.5,10.75C16.19,10.75 16.75,11.31 16.75,12Z" />
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M19,9.75C19.69,9.75 20.25,9.19 20.25,8.5C20.25,7.81 19.69,7.25 19,7.25C18.31,7.25 17.75,7.81 17.75,8.5C17.75,9.19 18.31,9.75 19,9.75Z" />
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M20.25,12C20.25,12.69 19.69,13.25 19,13.25C18.31,13.25 17.75,12.69 17.75,12C17.75,11.31 18.31,10.75 19,10.75C19.69,10.75 20.25,11.31 20.25,12Z" />
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M5,9.75C5.69,9.75 6.25,9.19 6.25,8.5C6.25,7.81 5.69,7.25 5,7.25C4.31,7.25 3.75,7.81 3.75,8.5C3.75,9.19 4.31,9.75 5,9.75Z" />
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M6.25,12C6.25,12.69 5.69,13.25 5,13.25C4.31,13.25 3.75,12.69 3.75,12C3.75,11.31 4.31,10.75 5,10.75C5.69,10.75 6.25,11.31 6.25,12Z" />
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M8.25,15C7.698,15 7.25,15.448 7.25,16C7.25,16.552 7.698,17 8.25,17H15.75C16.302,17 16.75,16.552 16.75,16C16.75,15.448 16.302,15 15.75,15H8.25Z" />
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M17.737,3.625H6.263C5.454,3.625 4.794,3.625 4.258,3.669C3.704,3.714 3.206,3.811 2.741,4.047C2.012,4.419 1.419,5.012 1.047,5.741C0.811,6.206 0.714,6.704 0.669,7.258C0.625,7.794 0.625,8.454 0.625,9.263V14.737C0.625,15.546 0.625,16.206 0.669,16.742C0.714,17.296 0.811,17.794 1.047,18.259C1.419,18.988 2.012,19.581 2.741,19.953C3.206,20.19 3.704,20.286 4.258,20.331C4.794,20.375 5.454,20.375 6.263,20.375H17.737C18.546,20.375 19.206,20.375 19.742,20.331C20.296,20.286 20.794,20.19 21.259,19.953C21.988,19.581 22.581,18.988 22.953,18.259C23.19,17.794 23.286,17.296 23.331,16.742C23.375,16.206 23.375,15.546 23.375,14.737V9.263C23.375,8.454 23.375,7.794 23.331,7.258C23.286,6.704 23.19,6.206 22.953,5.741C22.581,5.012 21.988,4.419 21.259,4.047C20.794,3.811 20.296,3.714 19.742,3.669C19.206,3.625 18.546,3.625 17.737,3.625ZM3.535,5.607C3.712,5.516 3.955,5.449 4.401,5.413C4.857,5.376 5.445,5.375 6.3,5.375H17.7C18.555,5.375 19.143,5.376 19.599,5.413C20.045,5.449 20.288,5.516 20.465,5.607C20.865,5.81 21.19,6.135 21.393,6.535C21.484,6.712 21.551,6.955 21.587,7.401C21.624,7.857 21.625,8.445 21.625,9.3V14.7C21.625,15.554 21.624,16.143 21.587,16.599C21.551,17.045 21.484,17.288 21.393,17.465C21.19,17.865 20.865,18.19 20.465,18.393C20.288,18.484 20.045,18.551 19.599,18.587C19.143,18.624 18.555,18.625 17.7,18.625H6.3C5.445,18.625 4.857,18.624 4.401,18.587C3.955,18.551 3.712,18.484 3.535,18.393C3.135,18.19 2.81,17.865 2.607,17.465C2.516,17.288 2.449,17.045 2.413,16.599C2.376,16.143 2.375,15.554 2.375,14.7V9.3C2.375,8.445 2.376,7.857 2.413,7.401C2.449,6.955 2.516,6.712 2.607,6.535C2.81,6.135 3.135,5.81 3.535,5.607Z" />
|
||||
</vector>
|
||||
BIN
core-ui/src/main/res/drawable/qrcode_logo.png
Normal file
BIN
core-ui/src/main/res/drawable/qrcode_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
14
core-ui/src/main/res/drawable/symbol_camera_24.xml
Normal file
14
core-ui/src/main/res/drawable/symbol_camera_24.xml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M12,6.375a5.875,5.875 0,1 0,0 11.75,5.875 5.875,0 0,0 0,-11.75ZM7.875,12.25a4.125,4.125 0,1 1,8.25 0,4.125 4.125,0 0,1 -8.25,0Z"
|
||||
android:fillColor="#000"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="M10.13,1.625c-0.806,0 -1.575,0.338 -2.12,0.932L6.803,3.875H5.5A4.375,4.375 0,0 0,1.125 8.25v9.25A4.375,4.375 0,0 0,5.5 21.875h13a4.375,4.375 0,0 0,4.375 -4.375V8.25A4.375,4.375 0,0 0,18.5 3.875h-1.303L15.99,2.557a2.875,2.875 0,0 0,-2.12 -0.932h-3.74ZM9.3,3.74c0.214,-0.233 0.514,-0.365 0.83,-0.365h3.74c0.316,0 0.616,0.132 0.83,0.365l1.468,1.601c0.165,0.181 0.4,0.284 0.645,0.284H18.5a2.625,2.625 0,0 1,2.625 2.625v9.25a2.625,2.625 0,0 1,-2.625 2.625h-13A2.625,2.625 0,0 1,2.875 17.5V8.25A2.625,2.625 0,0 1,5.5 5.625h1.688a0.875,0.875 0,0 0,0.645 -0.284L9.3,3.74Z"
|
||||
android:fillColor="#000"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
||||
9
core-ui/src/main/res/drawable/symbol_phone_24.xml
Normal file
9
core-ui/src/main/res/drawable/symbol_phone_24.xml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M4.25 2.73c1.25-1.25 3.3-1.1 4.37 0.3l2.17 2.85c0.88 1.15 0.77 2.78-0.26 3.8l-1.05 1.06c0.03 0.09 0.09 0.22 0.18 0.39 0.28 0.5 0.77 1.14 1.42 1.79 0.65 0.65 1.3 1.14 1.8 1.42 0.16 0.09 0.29 0.15 0.38 0.18l1.05-1.05c1.03-1.03 2.66-1.14 3.81-0.26l2.86 2.17c1.4 1.06 1.54 3.12 0.3 4.37l-0.44 0.43c-1.57 1.57-3.91 2.43-6.16 1.66-2.82-0.97-5.46-2.57-7.7-4.81-2.25-2.25-3.85-4.9-4.82-7.7-0.77-2.26 0.09-4.6 1.66-6.17l0.43-0.43Zm2.98 1.35C6.8 3.52 5.99 3.47 5.49 3.96L5.06 4.4C3.84 5.62 3.3 7.28 3.82 8.76c0.88 2.56 2.34 4.98 4.4 7.03 2.04 2.05 4.46 3.51 7.02 4.4 1.48 0.5 3.14-0.03 4.36-1.25l0.44-0.43c0.5-0.5 0.44-1.31-0.12-1.74l-2.85-2.17c-0.46-0.34-1.11-0.3-1.52 0.1l-1.24 1.25c-0.41 0.41-0.96 0.38-1.26 0.32-0.34-0.06-0.7-0.22-1.03-0.4-0.67-0.38-1.44-0.98-2.18-1.71-0.73-0.74-1.33-1.51-1.7-2.18-0.2-0.33-0.35-0.69-0.41-1.03-0.06-0.3-0.1-0.85 0.32-1.26l1.24-1.24C9.7 8.04 9.74 7.39 9.4 6.93L7.24 4.08Z"/>
|
||||
</vector>
|
||||
30
core-ui/src/main/res/drawable/symbol_qrcode_24.xml
Normal file
30
core-ui/src/main/res/drawable/symbol_qrcode_24.xml
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M5.25,6A0.75,0.75 0,0 1,6 5.25h1a0.75,0.75 0,0 1,0.75 0.75v1a0.75,0.75 0,0 1,-0.75 0.75H6A0.75,0.75 0,0 1,5.25 7V6Z"
|
||||
android:fillColor="#000"/>
|
||||
<path
|
||||
android:pathData="M5.117,1.875h2.766c0.392,0 0.737,0 1.023,0.023 0.305,0.025 0.618,0.08 0.922,0.236 0.447,0.228 0.81,0.59 1.038,1.038 0.155,0.304 0.21,0.617 0.236,0.922 0.023,0.286 0.023,0.631 0.023,1.023v2.766c0,0.392 0,0.737 -0.023,1.023 -0.025,0.305 -0.08,0.618 -0.236,0.922 -0.228,0.447 -0.59,0.81 -1.038,1.038 -0.304,0.155 -0.617,0.21 -0.922,0.236 -0.286,0.023 -0.631,0.023 -1.023,0.023L5.117,11.125c-0.392,0 -0.737,0 -1.023,-0.023 -0.305,-0.025 -0.618,-0.08 -0.922,-0.236a2.375,2.375 0,0 1,-1.038 -1.038c-0.155,-0.304 -0.21,-0.617 -0.236,-0.922a13.474,13.474 0,0 1,-0.023 -1.023L1.875,5.117c0,-0.392 0,-0.737 0.023,-1.023 0.025,-0.305 0.08,-0.618 0.236,-0.922 0.228,-0.447 0.59,-0.81 1.038,-1.038 0.304,-0.155 0.617,-0.21 0.922,-0.236 0.286,-0.023 0.631,-0.023 1.023,-0.023ZM4.237,3.643c-0.197,0.016 -0.254,0.042 -0.27,0.05a0.625,0.625 0,0 0,-0.274 0.273c-0.008,0.017 -0.034,0.074 -0.05,0.27 -0.017,0.206 -0.018,0.48 -0.018,0.914v2.7c0,0.434 0,0.708 0.018,0.914 0.016,0.196 0.042,0.253 0.05,0.27 0.06,0.117 0.156,0.213 0.273,0.273 0.017,0.008 0.074,0.034 0.27,0.05 0.206,0.017 0.48,0.018 0.914,0.018h2.7c0.434,0 0.708,0 0.914,-0.018 0.196,-0.016 0.253,-0.042 0.27,-0.05a0.625,0.625 0,0 0,0.273 -0.273c0.008,-0.017 0.034,-0.074 0.05,-0.27 0.017,-0.206 0.018,-0.48 0.018,-0.914v-2.7c0,-0.434 0,-0.708 -0.018,-0.914 -0.016,-0.196 -0.042,-0.253 -0.05,-0.27a0.625,0.625 0,0 0,-0.273 -0.273c-0.017,-0.008 -0.074,-0.034 -0.27,-0.05a12.67,12.67 0,0 0,-0.914 -0.018h-2.7c-0.434,0 -0.708,0 -0.914,0.018Z"
|
||||
android:fillColor="#000"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="M6,16.25a0.75,0.75 0,0 0,-0.75 0.75v1c0,0.414 0.336,0.75 0.75,0.75h1a0.75,0.75 0,0 0,0.75 -0.75v-1a0.75,0.75 0,0 0,-0.75 -0.75H6Z"
|
||||
android:fillColor="#000"/>
|
||||
<path
|
||||
android:pathData="M5.117,12.875h2.766c0.392,0 0.737,0 1.023,0.023 0.305,0.025 0.618,0.08 0.922,0.236 0.447,0.228 0.81,0.59 1.038,1.038 0.155,0.304 0.21,0.617 0.236,0.922 0.023,0.286 0.023,0.631 0.023,1.023v2.766c0,0.392 0,0.737 -0.023,1.024 -0.025,0.304 -0.08,0.617 -0.236,0.921 -0.228,0.447 -0.59,0.81 -1.038,1.038 -0.304,0.155 -0.617,0.21 -0.922,0.236 -0.286,0.023 -0.631,0.023 -1.023,0.023L5.117,22.125c-0.392,0 -0.737,0 -1.023,-0.023 -0.305,-0.025 -0.618,-0.08 -0.922,-0.236a2.375,2.375 0,0 1,-1.038 -1.038c-0.155,-0.304 -0.21,-0.617 -0.236,-0.921a13.476,13.476 0,0 1,-0.023 -1.024v-2.766c0,-0.392 0,-0.737 0.023,-1.023 0.025,-0.305 0.08,-0.618 0.236,-0.922 0.228,-0.447 0.59,-0.81 1.038,-1.038 0.304,-0.155 0.617,-0.21 0.922,-0.236 0.286,-0.023 0.631,-0.023 1.023,-0.023ZM4.237,14.643c-0.197,0.015 -0.254,0.042 -0.27,0.05a0.625,0.625 0,0 0,-0.274 0.273c-0.008,0.017 -0.034,0.074 -0.05,0.27 -0.017,0.206 -0.018,0.48 -0.018,0.914v2.7c0,0.434 0,0.708 0.018,0.914 0.016,0.196 0.042,0.253 0.05,0.27 0.06,0.117 0.156,0.213 0.273,0.273 0.017,0.008 0.074,0.035 0.27,0.05 0.206,0.017 0.48,0.018 0.914,0.018h2.7c0.434,0 0.708,0 0.914,-0.017 0.196,-0.017 0.253,-0.043 0.27,-0.051a0.625,0.625 0,0 0,0.273 -0.273c0.008,-0.017 0.034,-0.074 0.05,-0.27 0.017,-0.206 0.018,-0.48 0.018,-0.914v-2.7c0,-0.434 0,-0.708 -0.018,-0.914 -0.016,-0.196 -0.042,-0.253 -0.05,-0.27a0.625,0.625 0,0 0,-0.273 -0.273c-0.017,-0.008 -0.074,-0.034 -0.27,-0.05 -0.206,-0.017 -0.48,-0.018 -0.914,-0.018h-2.7c-0.434,0 -0.708,0 -0.914,0.018Z"
|
||||
android:fillColor="#000"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="M17,5.25a0.75,0.75 0,0 0,-0.75 0.75v1c0,0.414 0.336,0.75 0.75,0.75h1a0.75,0.75 0,0 0,0.75 -0.75V6a0.75,0.75 0,0 0,-0.75 -0.75h-1Z"
|
||||
android:fillColor="#000"/>
|
||||
<path
|
||||
android:pathData="M16.117,1.875c-0.392,0 -0.737,0 -1.023,0.023 -0.305,0.025 -0.618,0.08 -0.922,0.236 -0.447,0.228 -0.81,0.59 -1.038,1.038 -0.155,0.304 -0.21,0.617 -0.236,0.922 -0.023,0.286 -0.023,0.631 -0.023,1.023v2.766c0,0.392 0,0.737 0.023,1.023 0.025,0.305 0.08,0.618 0.236,0.922 0.228,0.447 0.59,0.81 1.038,1.038 0.304,0.155 0.617,0.21 0.922,0.236 0.286,0.023 0.631,0.023 1.023,0.023h2.766c0.392,0 0.737,0 1.024,-0.023 0.304,-0.025 0.617,-0.08 0.921,-0.236 0.447,-0.228 0.81,-0.59 1.038,-1.038 0.155,-0.304 0.21,-0.617 0.236,-0.922 0.023,-0.286 0.023,-0.631 0.023,-1.023L22.125,5.117c0,-0.392 0,-0.737 -0.023,-1.023 -0.025,-0.305 -0.08,-0.618 -0.236,-0.922a2.375,2.375 0,0 0,-1.038 -1.038c-0.304,-0.155 -0.617,-0.21 -0.921,-0.236a13.476,13.476 0,0 0,-1.024 -0.023h-2.766ZM14.967,3.693c0.016,-0.008 0.073,-0.034 0.27,-0.05 0.205,-0.017 0.479,-0.018 0.913,-0.018h2.7c0.434,0 0.708,0 0.914,0.018 0.196,0.016 0.253,0.042 0.27,0.05 0.117,0.06 0.213,0.156 0.273,0.273 0.008,0.017 0.035,0.074 0.05,0.27 0.017,0.206 0.018,0.48 0.018,0.914v2.7c0,0.434 0,0.708 -0.017,0.914 -0.017,0.196 -0.043,0.253 -0.051,0.27a0.625,0.625 0,0 1,-0.273 0.273c-0.017,0.008 -0.074,0.034 -0.27,0.05 -0.206,0.017 -0.48,0.018 -0.914,0.018h-2.7c-0.434,0 -0.708,0 -0.914,-0.018 -0.196,-0.016 -0.253,-0.042 -0.27,-0.05a0.625,0.625 0,0 1,-0.273 -0.273c-0.008,-0.017 -0.034,-0.074 -0.05,-0.27 -0.017,-0.206 -0.018,-0.48 -0.018,-0.914v-2.7c0,-0.434 0,-0.708 0.018,-0.914 0.015,-0.196 0.042,-0.253 0.05,-0.27a0.625,0.625 0,0 1,0.273 -0.273Z"
|
||||
android:fillColor="#000"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="M14.25,13.5a0.75,0.75 0,0 0,-0.75 0.75v1c0,0.414 0.336,0.75 0.75,0.75h1a0.75,0.75 0,0 0,0.75 -0.75v-1a0.75,0.75 0,0 0,-0.75 -0.75h-1ZM16.25,17a0.75,0.75 0,0 1,0.75 -0.75h1a0.75,0.75 0,0 1,0.75 0.75v1a0.75,0.75 0,0 1,-0.75 0.75h-1a0.75,0.75 0,0 1,-0.75 -0.75v-1ZM19,19.75a0.75,0.75 0,0 1,0.75 -0.75h1a0.75,0.75 0,0 1,0.75 0.75v1a0.75,0.75 0,0 1,-0.75 0.75h-1a0.75,0.75 0,0 1,-0.75 -0.75v-1ZM19.75,13.5a0.75,0.75 0,0 0,-0.75 0.75v1c0,0.414 0.336,0.75 0.75,0.75h1a0.75,0.75 0,0 0,0.75 -0.75v-1a0.75,0.75 0,0 0,-0.75 -0.75h-1ZM13.5,19.75a0.75,0.75 0,0 1,0.75 -0.75h1a0.75,0.75 0,0 1,0.75 0.75v1a0.75,0.75 0,0 1,-0.75 0.75h-1a0.75,0.75 0,0 1,-0.75 -0.75v-1Z"
|
||||
android:fillColor="#000"/>
|
||||
</vector>
|
||||
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>
|
||||
74
core-ui/src/main/res/values-v31/colors_dynamic.xml
Normal file
74
core-ui/src/main/res/values-v31/colors_dynamic.xml
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Light Theme -->
|
||||
<color name="dynamic_primary_inverse_light">@android:color/system_accent1_200</color>
|
||||
<color name="dynamic_primary_light">@android:color/system_accent1_600</color>
|
||||
<color name="dynamic_on_primary_light">@android:color/system_accent1_0</color>
|
||||
<color name="dynamic_primary_container_light">@android:color/system_accent1_100</color>
|
||||
<color name="dynamic_on_primary_container_light">@android:color/system_accent1_900</color>
|
||||
<color name="dynamic_secondary_light">@android:color/system_accent2_600</color>
|
||||
<color name="dynamic_on_secondary_light">@android:color/system_accent2_0</color>
|
||||
<color name="dynamic_secondary_container_light">@android:color/system_accent2_100</color>
|
||||
<color name="dynamic_on_secondary_container_light">@android:color/system_accent2_900</color>
|
||||
<color name="dynamic_surface_inverse_light">@android:color/system_neutral1_800</color>
|
||||
<color name="dynamic_surface_variant_light">@android:color/system_neutral2_100</color>
|
||||
<color name="dynamic_surface_light">#FAF8FF</color>
|
||||
<color name="dynamic_background_light">#FAF8FF</color>
|
||||
<color name="dynamic_surface_dim_light">#DAD9E0</color>
|
||||
<color name="dynamic_surface_container_highest_light">@android:color/system_neutral2_100</color>
|
||||
<color name="dynamic_surface_container_high_light">#E8E7EF</color>
|
||||
<color name="dynamic_surface_container_light">#EEEDF4</color>
|
||||
<color name="dynamic_surface_container_low_light">#F4F3FA</color>
|
||||
<color name="dynamic_surface_bright_light">#FAF8FF</color>
|
||||
<color name="dynamic_surface_container_lowest_light">@android:color/system_neutral2_0</color>
|
||||
<color name="dynamic_on_surface_inverse_light">@android:color/system_neutral1_50</color>
|
||||
<color name="dynamic_on_surface_light">@android:color/system_neutral1_900</color>
|
||||
<color name="dynamic_on_background_light">@android:color/system_neutral1_900</color>
|
||||
<color name="dynamic_on_surface_variant_light">@android:color/system_neutral2_700</color>
|
||||
<color name="dynamic_outline_light">@android:color/system_neutral2_500</color>
|
||||
<color name="dynamic_outline_variant_light">@android:color/system_neutral2_200</color>
|
||||
<color name="dynamic_error_light">#B3261E</color>
|
||||
<color name="dynamic_on_error_light">#FFFFFF</color>
|
||||
<color name="dynamic_error_container_light">#F9DEDC</color>
|
||||
<color name="dynamic_on_error_container_light">#410E0B</color>
|
||||
<color name="dynamic_tertiary_light">@android:color/system_accent3_600</color>
|
||||
<color name="dynamic_on_tertiary_light">@android:color/system_accent3_0</color>
|
||||
<color name="dynamic_tertiary_container_light">@android:color/system_accent3_100</color>
|
||||
<color name="dynamic_on_tertiary_container_light">@android:color/system_accent3_900</color>
|
||||
|
||||
<!-- Dark Theme -->
|
||||
<color name="dynamic_primary_inverse_dark">@android:color/system_accent1_600</color>
|
||||
<color name="dynamic_primary_dark">@android:color/system_accent1_200</color>
|
||||
<color name="dynamic_on_primary_dark">@android:color/system_accent1_800</color>
|
||||
<color name="dynamic_primary_container_dark">@android:color/system_accent1_700</color>
|
||||
<color name="dynamic_on_primary_container_dark">@android:color/system_accent1_100</color>
|
||||
<color name="dynamic_secondary_dark">@android:color/system_accent2_200</color>
|
||||
<color name="dynamic_on_secondary_dark">@android:color/system_accent2_800</color>
|
||||
<color name="dynamic_secondary_container_dark">@android:color/system_accent2_700</color>
|
||||
<color name="dynamic_on_secondary_container_dark">@android:color/system_accent2_100</color>
|
||||
<color name="dynamic_surface_inverse_dark">@android:color/system_neutral1_100</color>
|
||||
<color name="dynamic_surface_variant_dark">@android:color/system_neutral2_700</color>
|
||||
<color name="dynamic_surface_dark">#121318</color>
|
||||
<color name="dynamic_background_dark">#121318</color>
|
||||
<color name="dynamic_surface_bright_dark">#38393F</color>
|
||||
<color name="dynamic_surface_container_highest_dark">#33343A</color>
|
||||
<color name="dynamic_surface_container_high_dark">#282A2F</color>
|
||||
<color name="dynamic_surface_container_dark">#1E1F25</color>
|
||||
<color name="dynamic_surface_container_low_dark">@android:color/system_neutral2_900</color>
|
||||
<color name="dynamic_surface_dim_dark">#121318</color>
|
||||
<color name="dynamic_surface_container_lowest_dark">#0C0E13</color>
|
||||
<color name="dynamic_on_surface_inverse_dark">@android:color/system_neutral1_800</color>
|
||||
<color name="dynamic_on_surface_dark">@android:color/system_neutral1_100</color>
|
||||
<color name="dynamic_on_background_dark">@android:color/system_neutral1_100</color>
|
||||
<color name="dynamic_on_surface_variant_dark">@android:color/system_neutral2_200</color>
|
||||
<color name="dynamic_outline_dark">@android:color/system_neutral2_400</color>
|
||||
<color name="dynamic_outline_variant_dark">@android:color/system_neutral2_700</color>
|
||||
<color name="dynamic_error_dark">#F2B8B5</color>
|
||||
<color name="dynamic_on_error_dark">#601410</color>
|
||||
<color name="dynamic_error_container_dark">#8C1D18</color>
|
||||
<color name="dynamic_on_error_container_dark">#F9DEDC</color>
|
||||
<color name="dynamic_tertiary_dark">@android:color/system_accent3_200</color>
|
||||
<color name="dynamic_on_tertiary_dark">@android:color/system_accent3_800</color>
|
||||
<color name="dynamic_tertiary_container_dark">@android:color/system_accent3_700</color>
|
||||
<color name="dynamic_on_tertiary_container_dark">@android:color/system_accent3_100</color>
|
||||
</resources>
|
||||
74
core-ui/src/main/res/values-v34/colors_dynamic.xml
Normal file
74
core-ui/src/main/res/values-v34/colors_dynamic.xml
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Light Theme -->
|
||||
<color name="dynamic_primary_inverse_light">@android:color/system_primary_dark</color>
|
||||
<color name="dynamic_primary_light">@android:color/system_primary_light</color>
|
||||
<color name="dynamic_on_primary_light">@android:color/system_on_primary_light</color>
|
||||
<color name="dynamic_primary_container_light">@android:color/system_primary_container_light</color>
|
||||
<color name="dynamic_on_primary_container_light">@android:color/system_on_primary_container_light</color>
|
||||
<color name="dynamic_secondary_light">@android:color/system_secondary_light</color>
|
||||
<color name="dynamic_on_secondary_light">@android:color/system_on_secondary_light</color>
|
||||
<color name="dynamic_secondary_container_light">@android:color/system_secondary_container_light</color>
|
||||
<color name="dynamic_on_secondary_container_light">@android:color/system_on_secondary_container_light</color>
|
||||
<color name="dynamic_surface_inverse_light">@android:color/system_surface_dark</color>
|
||||
<color name="dynamic_surface_variant_light">@android:color/system_surface_variant_light</color>
|
||||
<color name="dynamic_surface_light">@android:color/system_surface_light</color>
|
||||
<color name="dynamic_background_light">@android:color/system_background_light</color>
|
||||
<color name="dynamic_surface_dim_light">@android:color/system_surface_dim_light</color>
|
||||
<color name="dynamic_surface_container_highest_light">@android:color/system_surface_container_highest_light</color>
|
||||
<color name="dynamic_surface_container_high_light">@android:color/system_surface_container_high_light</color>
|
||||
<color name="dynamic_surface_container_light">@android:color/system_surface_container_light</color>
|
||||
<color name="dynamic_surface_container_low_light">@android:color/system_surface_container_low_light</color>
|
||||
<color name="dynamic_surface_bright_light">@android:color/system_surface_bright_light</color>
|
||||
<color name="dynamic_surface_container_lowest_light">@android:color/system_surface_container_lowest_light</color>
|
||||
<color name="dynamic_on_surface_inverse_light">@android:color/system_on_surface_dark</color>
|
||||
<color name="dynamic_on_surface_light">@android:color/system_on_surface_light</color>
|
||||
<color name="dynamic_on_background_light">@android:color/system_on_background_light</color>
|
||||
<color name="dynamic_on_surface_variant_light">@android:color/system_on_surface_variant_light</color>
|
||||
<color name="dynamic_outline_light">@android:color/system_outline_light</color>
|
||||
<color name="dynamic_outline_variant_light">@android:color/system_outline_variant_light</color>
|
||||
<color name="dynamic_error_light">@android:color/system_error_light</color>
|
||||
<color name="dynamic_on_error_light">@android:color/system_on_error_light</color>
|
||||
<color name="dynamic_error_container_light">@android:color/system_error_container_light</color>
|
||||
<color name="dynamic_on_error_container_light">@android:color/system_on_error_container_light</color>
|
||||
<color name="dynamic_tertiary_light">@android:color/system_tertiary_light</color>
|
||||
<color name="dynamic_on_tertiary_light">@android:color/system_on_tertiary_light</color>
|
||||
<color name="dynamic_tertiary_container_light">@android:color/system_tertiary_container_light</color>
|
||||
<color name="dynamic_on_tertiary_container_light">@android:color/system_on_tertiary_container_light</color>
|
||||
|
||||
<!-- Dark Theme -->
|
||||
<color name="dynamic_primary_inverse_dark">@android:color/system_primary_light</color>
|
||||
<color name="dynamic_primary_dark">@android:color/system_primary_dark</color>
|
||||
<color name="dynamic_on_primary_dark">@android:color/system_on_primary_dark</color>
|
||||
<color name="dynamic_primary_container_dark">@android:color/system_primary_container_dark</color>
|
||||
<color name="dynamic_on_primary_container_dark">@android:color/system_on_primary_container_dark</color>
|
||||
<color name="dynamic_secondary_dark">@android:color/system_secondary_dark</color>
|
||||
<color name="dynamic_on_secondary_dark">@android:color/system_on_secondary_dark</color>
|
||||
<color name="dynamic_secondary_container_dark">@android:color/system_secondary_container_dark</color>
|
||||
<color name="dynamic_on_secondary_container_dark">@android:color/system_on_secondary_container_dark</color>
|
||||
<color name="dynamic_surface_inverse_dark">@android:color/system_surface_light</color>
|
||||
<color name="dynamic_surface_variant_dark">@android:color/system_surface_variant_dark</color>
|
||||
<color name="dynamic_surface_dark">@android:color/system_surface_dark</color>
|
||||
<color name="dynamic_background_dark">@android:color/system_background_dark</color>
|
||||
<color name="dynamic_surface_bright_dark">@android:color/system_surface_bright_dark</color>
|
||||
<color name="dynamic_surface_container_highest_dark">@android:color/system_surface_container_highest_dark</color>
|
||||
<color name="dynamic_surface_container_high_dark">@android:color/system_surface_container_high_dark</color>
|
||||
<color name="dynamic_surface_container_dark">@android:color/system_surface_container_dark</color>
|
||||
<color name="dynamic_surface_container_low_dark">@android:color/system_surface_container_low_dark</color>
|
||||
<color name="dynamic_surface_dim_dark">@android:color/system_surface_dim_dark</color>
|
||||
<color name="dynamic_surface_container_lowest_dark">@android:color/system_surface_container_lowest_dark</color>
|
||||
<color name="dynamic_on_surface_inverse_dark">@android:color/system_on_surface_light</color>
|
||||
<color name="dynamic_on_surface_dark">@android:color/system_on_surface_dark</color>
|
||||
<color name="dynamic_on_background_dark">@android:color/system_on_background_dark</color>
|
||||
<color name="dynamic_on_surface_variant_dark">@android:color/system_on_surface_variant_dark</color>
|
||||
<color name="dynamic_outline_dark">@android:color/system_outline_dark</color>
|
||||
<color name="dynamic_outline_variant_dark">@android:color/system_outline_variant_dark</color>
|
||||
<color name="dynamic_error_dark">@android:color/system_error_dark</color>
|
||||
<color name="dynamic_on_error_dark">@android:color/system_on_error_dark</color>
|
||||
<color name="dynamic_error_container_dark">@android:color/system_error_container_dark</color>
|
||||
<color name="dynamic_on_error_container_dark">@android:color/system_on_error_container_dark</color>
|
||||
<color name="dynamic_tertiary_dark">@android:color/system_tertiary_dark</color>
|
||||
<color name="dynamic_on_tertiary_dark">@android:color/system_on_tertiary_dark</color>
|
||||
<color name="dynamic_tertiary_container_dark">@android:color/system_tertiary_container_dark</color>
|
||||
<color name="dynamic_on_tertiary_container_dark">@android:color/system_on_tertiary_container_dark</color>
|
||||
</resources>
|
||||
8
core-ui/src/main/res/values/attrs.xml
Normal file
8
core-ui/src/main/res/values/attrs.xml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<attr name="dynamic_colors" format="boolean"/>
|
||||
<attr name="extended_action_button_container_color" format="color"/>
|
||||
<attr name="extended_action_button_icon_color" format="color"/>
|
||||
<attr name="filled_tonal_icon_button_container_color" format="color"/>
|
||||
<attr name="filled_tonal_icon_button_icon_color" format="color"/>
|
||||
</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