Skip to content

Commit

Permalink
feat: Highlight mentions in TextInputs (WPB-1895) (#3642)
Browse files Browse the repository at this point in the history
  • Loading branch information
ohassine authored Nov 20, 2024
1 parent 7ea737a commit 4caa4ff
Show file tree
Hide file tree
Showing 21 changed files with 597 additions and 169 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ fun CodeTextField(
maxHorizontalSpacing = maxHorizontalSpacing,
horizontalAlignment = horizontalAlignment,
modifier = modifier,
innerBasicTextField = { decorator, textFieldModifier ->
innerBasicTextField = { decorator, textFieldModifier, _ ->
BasicTextField(
state = textState,
textStyle = textStyle,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ internal fun CodeTextFieldLayout(
}
},
textFieldModifier = Modifier,
decorationBox = {}
)
val bottomText = when {
state is WireTextFieldState.Error && state.errorText != null -> state.errorText
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* Wire
* Copyright (C) 2024 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*/
package com.wire.android.ui.common.textfield

import androidx.compose.ui.text.TextRange

object MentionDeletionHandler {
@Suppress("ReturnCount")
fun handle(
oldText: String,
newText: String,
oldSelection: TextRange,
mentions: List<String>
): String {
if (oldText == newText) {
// No change in text, only cursor movement, return as is
return oldText
}
for (mention in mentions) {
// Find the start position of the mention in the text
val mentionStart = oldText.indexOf(mention)

if (mentionStart == -1) continue

val mentionEnd = mentionStart + mention.length

// Check if the selection (i.e., user's cursor position) is inside the mention's range
if (oldSelection.start in mentionStart + 1..mentionEnd || oldSelection.end in mentionStart + 1..mentionEnd) {
// If the user is deleting inside the mention, remove the entire mention
return oldText.removeRange(mentionStart, mentionEnd)
}
}
return newText
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* Wire
* Copyright (C) 2024 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*/
package com.wire.android.ui.common.textfield

import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.withStyle
import com.wire.android.ui.home.conversations.model.UIMention

class MentionVisualTransformation(
val color: Color,
val mentions: List<UIMention>
) : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val styledText = buildAnnotatedString {
var lastIndex = 0
text.takeIf { it.isNotEmpty() }?.let {
mentions.forEach { mention ->
// Append the text before the mention
append(text.subSequence(lastIndex, mention.start))
// Apply the style to the mention
withStyle(style = SpanStyle(color = color, fontWeight = FontWeight.Bold)) {
append(text.subSequence(mention.start, mention.start + mention.length))
}
lastIndex = mention.start + mention.length
}
}
// Append the remaining text after the last mention
append(text.subSequence(lastIndex, text.length))
}
return TransformedText(
text = styledText,
offsetMapping = object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int = offset
override fun transformedToOriginal(offset: Int): Int = offset
}
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ fun WirePasswordTextField(
modifier = modifier.then(autoFillModifier(autoFillType, textState::setTextAndPlaceCursorAtEnd)),
testTag = testTag,
onTap = onTap,
innerBasicTextField = { decorator, textFieldModifier ->
innerBasicTextField = { decorator, textFieldModifier, _ ->
BasicSecureTextField(
state = textState,
textStyle = textStyle.copy(color = colors.textColor(state = state).value, textDirection = TextDirection.ContentOrLtr),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.KeyboardActionHandler
Expand All @@ -42,6 +43,8 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
Expand All @@ -53,10 +56,13 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.wire.android.ui.common.colorsScheme
import com.wire.android.ui.home.conversations.model.UIMention
import com.wire.android.ui.theme.WireTheme
import com.wire.android.ui.theme.wireDimensions
import com.wire.android.ui.theme.wireTypography
Expand Down Expand Up @@ -134,7 +140,7 @@ internal fun WireTextField(
),
onTap = onTap,
testTag = testTag,
innerBasicTextField = { decorator, textFieldModifier ->
innerBasicTextField = { decorator, textFieldModifier, _ ->
BasicTextField(
state = textState,
textStyle = textStyle.copy(
Expand Down Expand Up @@ -163,6 +169,107 @@ internal fun WireTextField(
)
}

@Composable
internal fun WireTextField(
textFieldValue: State<TextFieldValue>,
onValueChange: (TextFieldValue) -> Unit,
modifier: Modifier = Modifier,
mentions: List<UIMention> = emptyList(),
placeholderText: String? = null,
labelText: String? = null,
labelMandatoryIcon: Boolean = false,
descriptionText: String? = null,
semanticDescription: String? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
state: WireTextFieldState = WireTextFieldState.Default,
keyboardOptions: KeyboardOptions = KeyboardOptions.DefaultText,
keyboardActions: KeyboardActions = KeyboardActions.Default,
singleLine: Boolean = false,
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
minLines: Int = 1,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
textStyle: TextStyle = MaterialTheme.wireTypography.body01,
placeholderTextStyle: TextStyle = MaterialTheme.wireTypography.body01,
placeholderAlignment: Alignment.Horizontal = Alignment.Start,
inputMinHeight: Dp = MaterialTheme.wireDimensions.textFieldMinHeight,
shape: Shape = RoundedCornerShape(MaterialTheme.wireDimensions.textFieldCornerSize),
colors: WireTextFieldColors = wireTextFieldColors(),
onSelectedLineIndexChanged: (Int) -> Unit = { },
onLineBottomYCoordinateChanged: (Float) -> Unit = { },
onTap: ((Offset) -> Unit)? = null,
testTag: String = String.EMPTY
) {
WireTextFieldLayout(
modifier = modifier,
shouldShowPlaceholder = textFieldValue.value.text.isEmpty(),
placeholderText = placeholderText,
labelText = labelText,
labelMandatoryIcon = labelMandatoryIcon,
descriptionText = descriptionText,
semanticDescription = semanticDescription,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
state = state,
interactionSource = interactionSource,
placeholderTextStyle = placeholderTextStyle,
placeholderAlignment = placeholderAlignment,
inputMinHeight = inputMinHeight,
shape = shape,
colors = colors,
onTap = onTap,
testTag = testTag,
innerBasicTextField = { _, textFieldModifier, decoratorBox ->
BasicTextField(
value = textFieldValue.value,
onValueChange = { newText ->
val mentionsByName = mentions.map { it.handler }
val updatedText =
MentionDeletionHandler.handle(
textFieldValue.value.text,
newText.text,
textFieldValue.value.selection,
mentionsByName
)
onValueChange(TextFieldValue(updatedText, newText.selection))
},
textStyle = textStyle.copy(
color = colors.textColor(state = state).value,
textDirection = TextDirection.ContentOrLtr
),
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
readOnly = state is WireTextFieldState.ReadOnly,
enabled = state !is WireTextFieldState.Disabled,
singleLine = singleLine,
maxLines = maxLines,
minLines = minLines,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
interactionSource = interactionSource,
modifier = textFieldModifier,
decorationBox = decoratorBox,
onTextLayout = onTextLayout(
textFieldValue,
onSelectedLineIndexChanged,
onLineBottomYCoordinateChanged
),
visualTransformation = MentionVisualTransformation(colorsScheme().primary, mentions),
)
}
)
}

private fun onTextLayout(
textFieldValue: State<TextFieldValue>,
onSelectedLineIndexChanged: (Int) -> Unit = { },
onLineBottomYCoordinateChanged: (Float) -> Unit = { },
): (TextLayoutResult) -> Unit = {
val lineOfText = it.getLineForOffset(textFieldValue.value.selection.end)
val bottomYCoordinate = it.getLineBottom(lineOfText)
onSelectedLineIndexChanged(lineOfText)
onLineBottomYCoordinateChanged(bottomYCoordinate)
}

private fun onTextLayout(
state: TextFieldState,
onSelectedLineIndexChanged: (Int) -> Unit = { },
Expand Down Expand Up @@ -203,6 +310,16 @@ private fun KeyboardOptions.Companion.defaultEmail(imeAction: ImeAction): Keyboa
)
}

@PreviewMultipleThemes
@Composable
fun PreviewWireTextFieldWithTextFieldValue() = WireTheme {
WireTextField(
modifier = Modifier.padding(16.dp),
textFieldValue = remember { mutableStateOf(TextFieldValue("text")) },
onValueChange = {}
)
}

@PreviewMultipleThemes
@Composable
fun PreviewWireTextField() = WireTheme {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,21 @@ internal fun WireTextFieldLayout(
onTap = onTap,
)
},
decorationBox = { innerTextField ->
InnerTextLayout(
innerTextField = innerTextField,
shouldShowPlaceholder = shouldShowPlaceholder,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
placeholderText = placeholderText,
style = state,
placeholderTextStyle = placeholderTextStyle,
placeholderAlignment = placeholderAlignment,
inputMinHeight = inputMinHeight,
colors = colors,
onTap = onTap,
)
},
textFieldModifier = Modifier
.fillMaxWidth()
.background(color = colors.backgroundColor(state).value, shape = shape)
Expand Down Expand Up @@ -218,5 +233,9 @@ private fun Alignment.Horizontal.toAlignment(): Alignment = Alignment { size, sp

fun interface InnerBasicTextFieldBuilder {
@Composable
fun Build(decorator: TextFieldDecorator, textFieldModifier: Modifier)
fun Build(
decorator: TextFieldDecorator,
textFieldModifier: Modifier,
decorationBox: @Composable (innerTextField: @Composable () -> Unit) -> Unit
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ fun ConversationScreen(
LaunchedEffect(messageDraftViewModel.state.value.quotedMessageId) {
val compositionState = messageDraftViewModel.state.value
if (compositionState.quotedMessage != null) {
messageComposerStateHolder.messageCompositionHolder.updateQuote(compositionState.quotedMessage)
messageComposerStateHolder.messageCompositionHolder.value.updateQuote(compositionState.quotedMessage)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,6 @@ import kotlin.math.absoluteValue
import kotlin.math.min

// TODO: a definite candidate for a refactor and cleanup
@OptIn(ExperimentalFoundationApi::class)
@Suppress("ComplexMethod")
@Composable
fun RegularMessageItem(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import com.wire.android.R
import com.wire.android.ui.theme.WireTheme
import com.wire.android.ui.theme.wireColorScheme
import com.wire.android.ui.theme.wireTypography
import com.wire.android.util.EMPTY
import com.wire.android.util.QueryMatchExtractor
import com.wire.android.util.ui.PreviewMultipleThemes

@Composable
fun HighlightName(
Expand Down Expand Up @@ -93,3 +95,14 @@ fun HighlightName(

@Composable
private fun String.isUnknownUser() = this == stringResource(id = R.string.username_unavailable_label)

@PreviewMultipleThemes
@Composable
fun PreviewHighlightName() {
WireTheme {
HighlightName(
name = "John Doe",
searchQuery = "John"
)
}
}
Loading

0 comments on commit 4caa4ff

Please sign in to comment.