Implement the majority of the new nicknames and notes feature.

This commit is contained in:
Alex Hart
2024-03-22 16:03:04 -03:00
committed by Nicholas Tinsley
parent 7a24554b68
commit 303929090b
20 changed files with 1504 additions and 23 deletions
@@ -0,0 +1,94 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import org.signal.core.ui.copied.androidx.compose.material3.DropdownMenu
/**
* Properly styled dropdown menus and items.
*/
object DropdownMenus {
/**
* Properly styled dropdown menu
*/
@Composable
fun Menu(
controller: MenuController = remember { MenuController() },
modifier: Modifier = Modifier,
content: @Composable ColumnScope.(MenuController) -> Unit
) {
MaterialTheme(shapes = MaterialTheme.shapes.copy(extraSmall = RoundedCornerShape(18.dp))) {
DropdownMenu(
expanded = controller.isShown(),
onDismissRequest = controller::hide,
offset = DpOffset(
x = dimensionResource(id = R.dimen.core_ui__gutter),
y = 0.dp
),
content = { content(controller) },
modifier = modifier
)
}
}
/**
* Properly styled dropdown menu item
*/
@Composable
fun Item(
contentPadding: PaddingValues = PaddingValues(horizontal = 16.dp),
text: @Composable () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
DropdownMenuItem(
contentPadding = contentPadding,
text = text,
onClick = onClick,
modifier = modifier
)
}
/**
* Menu controller to hold menu display state and allow other components
* to show and hide it.
*/
class MenuController {
private var isMenuShown by mutableStateOf(false)
fun show() {
isMenuShown = true
}
fun hide() {
isMenuShown = false
}
fun toggle() {
if (isShown()) {
hide()
} else {
show()
}
}
fun isShown() = isMenuShown
}
}
@@ -2,8 +2,11 @@ package org.signal.core.ui
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.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -40,6 +43,7 @@ object Scaffolds {
Text(text = title, style = MaterialTheme.typography.titleLarge)
},
snackbarHost: @Composable () -> Unit = {},
actions: @Composable RowScope.() -> Unit = {},
content: @Composable (PaddingValues) -> Unit
) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
@@ -53,7 +57,8 @@ object Scaffolds {
scrollBehavior,
onNavigationClick,
navigationIconPainter,
navigationContentDescription
navigationContentDescription,
actions
)
},
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
@@ -68,7 +73,8 @@ object Scaffolds {
scrollBehavior: TopAppBarScrollBehavior,
onNavigationClick: () -> Unit,
navigationIconPainter: Painter,
navigationContentDescription: String?
navigationContentDescription: String?,
actions: @Composable RowScope.() -> Unit
) {
TopAppBar(
title = {
@@ -88,7 +94,8 @@ object Scaffolds {
scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.topAppBarColors(
scrolledContainerColor = SignalTheme.colors.colorSurface2
)
),
actions = actions
)
}
}
@@ -100,7 +107,12 @@ private fun SettingsScaffoldPreview() {
Scaffolds.Settings(
"Settings Scaffold",
onNavigationClick = {},
navigationIconPainter = ColorPainter(Color.Black)
navigationIconPainter = ColorPainter(Color.Black),
actions = {
IconButton(onClick = {}) {
Icon(Icons.Default.Settings, contentDescription = null)
}
}
) { paddingValues ->
Box(
contentAlignment = Alignment.Center,
@@ -5,9 +5,12 @@
package org.signal.core.ui
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
@@ -21,22 +24,34 @@ 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.
* 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)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun TextField(
value: String,
@@ -76,15 +91,57 @@ object TextFields {
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 = BringIntoViewRequester()
val coroutineScope = rememberCoroutineScope()
CompositionLocalProvider(LocalTextSelectionColors provides TextSelectionColors(handleColor = LocalContentColor.current, LocalContentColor.current.copy(alpha = 0.4f))) {
BasicTextField(
value = value,
value = textFieldValue,
modifier = modifier
.onFocusChanged { }
.bringIntoViewRequester(bringIntoViewRequester)
.onFocusChanged { focusState -> hasFocus = focusState.hasFocus }
.defaultMinSize(
minWidth = TextFieldDefaults.MinWidth,
minHeight = TextFieldDefaults.MinHeight
),
onValueChange = onValueChange,
onValueChange = { newTextFieldValueState ->
textFieldValueState = newTextFieldValueState
val stringChangedSinceLastInvocation = lastTextValue != newTextFieldValueState.text
lastTextValue = newTextFieldValueState.text
if (stringChangedSinceLastInvocation) {
onValueChange(newTextFieldValueState.text)
}
},
enabled = enabled,
readOnly = readOnly,
textStyle = mergedTextStyle,
@@ -96,6 +153,15 @@ object TextFields {
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(
@@ -121,4 +187,11 @@ object TextFields {
)
}
}
private fun String.createSelection(): TextRange {
return when {
isEmpty() -> TextRange.Zero
else -> TextRange(length, length)
}
}
}
@@ -0,0 +1,62 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.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,215 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.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 = InTransitionDuration,
easing = LinearOutSlowInEasing
)
} else {
// Expanded to dismissed.
tween(
durationMillis = 1,
delayMillis = OutTransitionDuration - 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 = OutTransitionDuration)
}
}
) {
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 InTransitionDuration = 120
internal const val OutTransitionDuration = 75