From a7dea759ce1fc22f3368e1afec021f276f017ff4 Mon Sep 17 00:00:00 2001 From: srikrishnasakunia Date: Wed, 7 Oct 2026 18:20:27 +0530 Subject: [PATCH 1/2] Add rich text formatting toolbar to Jetchat input Adds an inline rich-text editor to the message input, toggled from a new text-format button next to the attachment button. - FormattedTextState / SpanStyleState wrap TextFieldState and manage inline styles (bold, italic, underline, strikethrough, large font) via an InlineStyle enum, plus a cycling text color. - Collapsed-selection toggles are held as pending overrides and applied to the next inserted text. - Style coverage is computed as a union of ranges, so overlapping ranges are not double-counted; adjacent same-style ranges are coalesced. - Toolbar and send-button state are derived via derivedStateOf to avoid recomposing on every keystroke. - Draft text and styles survive configuration changes via a Saver that delegates to TextFieldState.Saver. - Sent messages and video captions carry their AnnotatedString; the message formatter remaps span offsets through markdown-lite tokens so styles stay aligned when delimiters are stripped. Signed-off-by: srikrishnasakunia --- .../jetchat/conversation/Conversation.kt | 15 +- .../conversation/ConversationUiState.kt | 2 + .../jetchat/conversation/MessageFormatter.kt | 95 +++- .../jetchat/conversation/RichTextState.kt | 459 ++++++++++++++++++ .../compose/jetchat/conversation/UserInput.kt | 274 ++++++++--- .../res/drawable/ic_text_border_color.xml | 5 + .../src/main/res/drawable/ic_text_format.xml | 9 + .../main/res/drawable/ic_text_format_bold.xml | 5 + .../res/drawable/ic_text_format_italic.xml | 5 + .../main/res/drawable/ic_text_format_size.xml | 5 + .../drawable/ic_text_format_underlined.xml | 5 + .../res/drawable/ic_text_strikethrough.xml | 5 + Jetchat/app/src/main/res/values/strings.xml | 8 + 13 files changed, 821 insertions(+), 71 deletions(-) create mode 100644 Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/RichTextState.kt create mode 100644 Jetchat/app/src/main/res/drawable/ic_text_border_color.xml create mode 100644 Jetchat/app/src/main/res/drawable/ic_text_format.xml create mode 100644 Jetchat/app/src/main/res/drawable/ic_text_format_bold.xml create mode 100644 Jetchat/app/src/main/res/drawable/ic_text_format_italic.xml create mode 100644 Jetchat/app/src/main/res/drawable/ic_text_format_size.xml create mode 100644 Jetchat/app/src/main/res/drawable/ic_text_format_underlined.xml create mode 100644 Jetchat/app/src/main/res/drawable/ic_text_strikethrough.xml diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt index eee0ffa1f2..333aa8ec97 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt @@ -223,18 +223,24 @@ fun ConversationContent( }, bottomBar = { UserInput( - onMessageSent = { content -> + onMessageSent = { annotatedContent -> uiState.addMessage( - Message(authorMe, content, timeNow), + Message( + author = authorMe, + content = annotatedContent.text, + timestamp = timeNow, + annotatedContent = annotatedContent.takeIf { it.spanStyles.isNotEmpty() }, + ), ) }, - onVideoMessageSent = { videoUri, content -> + onVideoMessageSent = { videoUri, caption -> uiState.addMessage( Message( author = authorMe, - content = content, + content = caption.text, timestamp = timeNow, videoUri = videoUri, + annotatedContent = caption.takeIf { it.spanStyles.isNotEmpty() }, ), ) }, @@ -903,6 +909,7 @@ fun ClickableMessage(message: Message, authorClicked: (String) -> Unit) { text = message.content, primary = false, onPersonClick = authorClicked, + annotatedContent = message.annotatedContent, ) Text( diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationUiState.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationUiState.kt index a662ec6952..82d616abc7 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationUiState.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationUiState.kt @@ -18,6 +18,7 @@ package com.example.compose.jetchat.conversation import androidx.compose.runtime.Immutable import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.text.AnnotatedString import com.example.compose.jetchat.R class ConversationUiState(val channelName: String, val channelMembers: Int, initialMessages: List) { @@ -47,4 +48,5 @@ data class Message( val videoUri: String? = null, val id: String = "$author-$timestamp-$content", val isLiked: Boolean = false, + val annotatedContent: AnnotatedString? = null, ) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/MessageFormatter.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/MessageFormatter.kt index 4c112dabae..922133e044 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/MessageFormatter.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/MessageFormatter.kt @@ -63,11 +63,22 @@ typealias SymbolAnnotation = Pair * @return AnnotatedString with links for URLs and @mentions */ @Composable -fun messageFormatter(text: String, primary: Boolean, onPersonClick: (String) -> Unit = {}): AnnotatedString { +fun messageFormatter( + text: String, + primary: Boolean, + onPersonClick: (String) -> Unit = {}, + annotatedContent: AnnotatedString? = null, +): AnnotatedString { val colorScheme = MaterialTheme.colorScheme val currentOnPersonClick by rememberUpdatedState(onPersonClick) - return remember(text, primary, colorScheme) { + return remember(text, primary, colorScheme, annotatedContent) { val tokens = symbolPattern.findAll(text) + // Rich-text spans index into the raw [text]. Markdown-lite tokens drop their delimiters + // when rendered, so track where each raw boundary ends up in the built string. Only + // allocated when there are spans to remap. + val extraSpans = annotatedContent?.spanStyles.orEmpty() + val rawToBuilt = if (extraSpans.isNotEmpty()) IntArray(text.length + 1) else null + buildAnnotatedString { var cursorPosition = 0 @@ -79,7 +90,14 @@ fun messageFormatter(text: String, primary: Boolean, onPersonClick: (String) -> } for (token in tokens) { - append(text.slice(cursorPosition until token.range.first)) + val first = token.range.first + if (rawToBuilt != null) { + val segmentStart = length + for (p in cursorPosition until first) { + rawToBuilt[p] = segmentStart + (p - cursorPosition) + } + } + append(text.substring(cursorPosition, first)) val (annotatedString, stringAnnotation) = getSymbolAnnotation( matchResult = token, @@ -88,6 +106,16 @@ fun messageFormatter(text: String, primary: Boolean, onPersonClick: (String) -> codeSnippetBackground = codeSnippetBackground, ) + if (rawToBuilt != null) { + val tokenStart = length + val content = annotatedString.text + // Number of leading delimiter characters stripped from the raw token. + val lead = if (content.isEmpty()) 0 else token.value.indexOf(content).coerceAtLeast(0) + for (p in token.range) { + rawToBuilt[p] = tokenStart + (p - first - lead).coerceIn(0, content.length) + } + } + val link = stringAnnotation?.let { annotation -> when (annotation.tag) { SymbolAnnotationType.LINK.name -> LinkAnnotation.Url(annotation.item) @@ -108,10 +136,63 @@ fun messageFormatter(text: String, primary: Boolean, onPersonClick: (String) -> cursorPosition = token.range.last + 1 } - if (!tokens.none()) { - append(text.slice(cursorPosition..text.lastIndex)) - } else { - append(text) + // Trailing plain text (or the whole text when there were no tokens). + if (rawToBuilt != null) { + val segmentStart = length + for (p in cursorPosition..text.length) { + rawToBuilt[p] = segmentStart + (p - cursorPosition) + } + } + append(text.substring(cursorPosition)) + + if (rawToBuilt != null) { + applyRichTextSpans(extraSpans, rawToBuilt) + } + } + } +} + +// SpanStyle merging replaces (rather than combines) textDecoration, so overlapping underline and +// strikethrough ranges need an explicit combined style. +private val UnderlineLineThroughSpanStyle = SpanStyle( + textDecoration = TextDecoration.combine( + listOf(TextDecoration.Underline, TextDecoration.LineThrough), + ), +) + +/** + * Applies rich-text [spans] (indexed into the raw message text) to this builder, remapping each + * boundary through [rawToBuilt]. + */ +private fun AnnotatedString.Builder.applyRichTextSpans(spans: List>, rawToBuilt: IntArray) { + val maxRaw = rawToBuilt.lastIndex + var underlineRanges: MutableList? = null + var strikethroughRanges: MutableList? = null + + for (span in spans) { + val start = rawToBuilt[span.start.coerceIn(0, maxRaw)] + val end = rawToBuilt[span.end.coerceIn(0, maxRaw)].coerceIn(start, length) + if (start >= end) continue + addStyle(span.item, start, end) + when (span.item.textDecoration) { + TextDecoration.Underline -> + (underlineRanges ?: mutableListOf().also { underlineRanges = it }) + .add(start until end) + + TextDecoration.LineThrough -> + (strikethroughRanges ?: mutableListOf().also { strikethroughRanges = it }) + .add(start until end) + } + } + + val underlines = underlineRanges ?: return + val strikethroughs = strikethroughRanges ?: return + for (u in underlines) { + for (s in strikethroughs) { + val overlapStart = maxOf(u.first, s.first) + val overlapEnd = minOf(u.last, s.last) + 1 + if (overlapStart < overlapEnd) { + addStyle(UnderlineLineThroughSpanStyle, overlapStart, overlapEnd) } } } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/RichTextState.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/RichTextState.kt new file mode 100644 index 0000000000..c7db91daed --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/RichTextState.kt @@ -0,0 +1,459 @@ +/* + * Copyright 2026 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 + * + * https://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 com.example.compose.jetchat.conversation + +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.ExpandPolicy +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.KeyboardActionHandler +import androidx.compose.foundation.text.input.TextFieldBuffer +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +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.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.fastFilter +import androidx.compose.ui.util.fastForEach + +/** + * Toggleable inline text styles. + * + * Each [spanStyle] sets only 1 attribute so that: + * 1. Toggling an attribute only adds, trims, or removes ranges of that constant without touching + * independent attributes on overlapping ranges. + * 2. The same immutable [SpanStyle] instance is reused across all ranges without extra allocations. + */ +enum class InlineStyle(val spanStyle: SpanStyle) { + Bold(SpanStyle(fontWeight = FontWeight.Bold)), + Italic(SpanStyle(fontStyle = FontStyle.Italic)), + Underline(SpanStyle(textDecoration = TextDecoration.Underline)), + Strikethrough(SpanStyle(textDecoration = TextDecoration.LineThrough)), + LargeFont(SpanStyle(fontSize = 28.sp)), + ; + + internal val mask: Int = 1 shl ordinal +} + +private val InlineStyleBySpanStyle: Map = + InlineStyle.entries.associateBy { it.spanStyle } + +internal val RedColorSpanStyle = SpanStyle(color = Color(0xFFD32F2F)) +internal val BlueColorSpanStyle = SpanStyle(color = Color(0xFF1976D2)) +internal val GreenColorSpanStyle = SpanStyle(color = Color(0xFF388E3C)) +internal val PurpleColorSpanStyle = SpanStyle(color = Color(0xFF8E24AA)) +internal val ColorSpanStyles = + listOf(RedColorSpanStyle, BlueColorSpanStyle, GreenColorSpanStyle, PurpleColorSpanStyle) + +/** + * Creates and remembers a [FormattedTextState]. + * + * The text, selection, and inline [SpanStyle] ranges survive configuration changes and process + * death via [FormattedTextState.Saver]. [initialText] is only used when the state is first created. + * Pending collapsed-selection toggles are transient and not saved. + */ +@Composable +fun rememberFormattedTextState(initialText: String = ""): FormattedTextState = rememberSaveable(saver = FormattedTextState.Saver) { + FormattedTextState(initialText) +} + +/** + * A rich-text [BasicTextField] wrapper that accepts a [FormattedTextState]. + */ +@Composable +fun FormattedTextField( + state: FormattedTextState, + modifier: Modifier = Modifier, + textStyle: TextStyle = TextStyle.Default, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + onKeyboardAction: KeyboardActionHandler? = null, + lineLimits: TextFieldLineLimits = TextFieldLineLimits.Default, + cursorBrush: Brush = SolidColor(Color.Black), +) { + BasicTextField( + state = state.textFieldState, + modifier = modifier, + inputTransformation = state.inputTransformation, + textStyle = textStyle, + keyboardOptions = keyboardOptions, + onKeyboardAction = onKeyboardAction, + lineLimits = lineLimits, + cursorBrush = cursorBrush, + ) +} + +/** + * Wraps [TextFieldState] and [SpanStyleState] for rich inline text formatting. + */ +@Stable +class FormattedTextState internal constructor(val textFieldState: TextFieldState) { + constructor(initialText: String = "") : this(TextFieldState(initialText)) + + val spanStyleState: SpanStyleState = SpanStyleState(textFieldState) + + internal val inputTransformation: InputTransformation = InputTransformation { + with(spanStyleState) { applyPendingOverrides() } + } + + val text: CharSequence + get() = textFieldState.text + + /** + * Whether [text] is blank. Backed by `derivedStateOf`, so readers are only invalidated when the + * value flips, not on every keystroke. + */ + val isBlank: Boolean by derivedStateOf { textFieldState.text.isBlank() } + + /** + * Whether [text] is empty. Backed by `derivedStateOf`, so readers are only invalidated when the + * value flips, not on every keystroke. + */ + val isEmpty: Boolean by derivedStateOf { textFieldState.text.isEmpty() } + + /** + * Clears all text, inline [SpanStyle] ranges, and pending style overrides. + */ + fun clear() { + spanStyleState.clearOverrides() + textFieldState.edit { + getSpanStyles(TextRange(0, length)).fastForEach { removeStyle(it) } + replace(0, length, "") + } + } + + /** + * Inserts [newString] at the current selection and places the cursor after the inserted text. + */ + fun addText(newString: String) { + textFieldState.edit { + replace(selection.min, selection.max, newString) + with(spanStyleState) { applyPendingOverrides() } + } + } + + /** + * Builds an [AnnotatedString] containing the current text and all active [SpanStyle] ranges. + */ + fun toAnnotatedString(): AnnotatedString { + val currentText = textFieldState.text.toString() + val spanStyles = textFieldState.textStyles.getSpanStyles(TextRange(0, currentText.length)) + if (spanStyles.isEmpty()) return AnnotatedString(currentText) + return buildAnnotatedString { + append(currentText) + spanStyles.fastForEach { range -> + addStyle(range.item, range.start, range.end) + } + } + } + + companion object { + /** + * Saves the underlying [TextFieldState] (text, selection, undo history and inline styles) + * by delegating to [TextFieldState.Saver]. + */ + val Saver: Saver = Saver( + save = { state -> with(TextFieldState.Saver) { save(state.textFieldState) } }, + restore = { saved -> + TextFieldState.Saver.restore(saved)?.let { FormattedTextState(it) } + }, + ) + } +} + +/** + * Resolved inline-style state for the current selection: a bitmask of active [InlineStyle]s plus + * the active color. Cheap structural equality keeps `derivedStateOf` readers from recomposing + * unless something actually changed. + */ +@Immutable +private data class ResolvedStyles(val activeMask: Int = 0, val activeColorStyle: SpanStyle? = null) { + fun isActive(style: InlineStyle): Boolean = activeMask and style.mask != 0 + + fun withStyle(style: InlineStyle, active: Boolean): ResolvedStyles = copy( + activeMask = if (active) activeMask or style.mask else activeMask and + style.mask.inv(), + ) + + companion object { + val None = ResolvedStyles() + } +} + +/** + * Manages inline [InlineStyle]s and the active color for a [TextFieldState] in a single + * `derivedStateOf` pass, and handles collapsed-selection style overrides for the next insert edit. + */ +@Stable +class SpanStyleState(private val state: TextFieldState) { + + private val computed by derivedStateOf { computeStyles(state.selection) } + + /** + * Computes which styles fully cover the current selection. + * + * Coverage is the *union* of matching ranges (clipped to the query range), so overlapping + * ranges of the same style are never double-counted. A color is only active when a single + * color covers the whole range. + */ + private fun computeStyles(selection: TextRange): ResolvedStyles { + // When selection is collapsed, newly inserted text with ExpandPolicy.AtEnd inherits the + // style of the previous character [selection.start - 1, selection.start). + val queryRange = + if (!selection.collapsed) { + TextRange(selection.min, selection.max) + } else if (selection.start > 0) { + TextRange(selection.start - 1, selection.start) + } else { + return ResolvedStyles.None + } + + val spanStyles = state.textStyles.getSpanStyles(queryRange) + if (spanStyles.isEmpty()) return ResolvedStyles.None + + val queryMin = queryRange.min + val queryMax = queryRange.max + val requiredCoverage = queryMax - queryMin + + // Per-style union coverage. Processing ranges in start order lets us compute the union + // incrementally: each range only contributes the part beyond what was already reached. + val inlineCovered = IntArray(InlineStyle.entries.size) + val inlineReach = IntArray(InlineStyle.entries.size) { queryMin } + val colorCovered = IntArray(ColorSpanStyles.size) + val colorReach = IntArray(ColorSpanStyles.size) { queryMin } + + val sorted = if (spanStyles.size > 1) spanStyles.sortedBy { it.start } else spanStyles + sorted.fastForEach { range -> + val start = maxOf(range.start, queryMin) + val end = minOf(range.end, queryMax) + if (start >= end) return@fastForEach + + val inline = InlineStyleBySpanStyle[range.item] + if (inline != null) { + val i = inline.ordinal + inlineCovered[i] += (end - maxOf(start, inlineReach[i])).coerceAtLeast(0) + inlineReach[i] = maxOf(inlineReach[i], end) + return@fastForEach + } + val c = ColorSpanStyles.indexOf(range.item) + if (c >= 0) { + colorCovered[c] += (end - maxOf(start, colorReach[c])).coerceAtLeast(0) + colorReach[c] = maxOf(colorReach[c], end) + } + } + + var mask = 0 + InlineStyle.entries.fastForEach { style -> + if (inlineCovered[style.ordinal] >= requiredCoverage) mask = mask or style.mask + } + val colorIndex = colorCovered.indexOfFirst { it >= requiredCoverage } + return ResolvedStyles(mask, ColorSpanStyles.getOrNull(colorIndex)) + } + + private var overrideSelection by mutableStateOf(null) + private val inlineOverrides = mutableStateMapOf() + private var hasColorOverride by mutableStateOf(false) + private var colorOverride by mutableStateOf(null) + + private val hasValidOverride: Boolean + get() = state.selection.collapsed && overrideSelection == state.selection + + private fun prepareCollapsedOverride() { + if (overrideSelection != state.selection) { + clearOverrides() + overrideSelection = state.selection + } + } + + internal fun clearOverrides() { + overrideSelection = null + inlineOverrides.clear() + hasColorOverride = false + colorOverride = null + } + + /** + * Pending overrides merged with [computed], resolved in a single `derivedStateOf`. + * + * Readers (e.g. toolbar buttons) are only invalidated when a resolved value actually changes, + * rather than on every selection/text change that [hasValidOverride] would otherwise observe. + */ + private val resolved by derivedStateOf { + val base = computed + if (!hasValidOverride) return@derivedStateOf base + var result = base + inlineOverrides.forEach { (style, active) -> result = result.withStyle(style, active) } + if (hasColorOverride) result = result.copy(activeColorStyle = colorOverride) + result + } + + /** Whether [style] is active for the current selection (including pending toggles). */ + fun isActive(style: InlineStyle): Boolean = resolved.isActive(style) + + val activeColorStyle: SpanStyle? + get() = resolved.activeColorStyle + + val isColorActive: Boolean + get() = activeColorStyle != null + + /** + * Toggles [style]. With a collapsed selection the toggle is held as a pending override and + * applied to the next inserted text; otherwise it is applied to the selected range. + */ + fun toggle(style: InlineStyle) { + if (state.selection.collapsed) { + val next = !isActive(style) + prepareCollapsedOverride() + inlineOverrides[style] = next + } else { + val currentlyActive = computed.isActive(style) + state.edit { + if (currentlyActive) { + removeSpanStyle(selection, style.spanStyle) + } else { + applySpanStyle(selection, style.spanStyle) + } + } + } + } + + fun toggleColor(colorStyle: SpanStyle) { + val isSelected = activeColorStyle == colorStyle + if (state.selection.collapsed) { + prepareCollapsedOverride() + hasColorOverride = true + colorOverride = if (isSelected) null else colorStyle + } else { + state.edit { + ColorSpanStyles.fastForEach { removeSpanStyle(selection, it) } + if (!isSelected) { + applySpanStyle(selection, colorStyle) + } + } + } + } + + /** + * Cycles through [ColorSpanStyles] in order, and turns color off (`null`) when the last color + * in [ColorSpanStyles] is currently active. + */ + fun cycleColor() { + val current = activeColorStyle + val currentIndex = if (current != null) ColorSpanStyles.indexOf(current) else -1 + val targetStyle = + if (currentIndex == -1) { + ColorSpanStyles.first() + } else if (currentIndex < ColorSpanStyles.lastIndex) { + ColorSpanStyles[currentIndex + 1] + } else { + current!! + } + toggleColor(targetStyle) + } + + internal fun TextFieldBuffer.applyPendingOverrides() { + if (changes.changeCount == 0) return + // Only apply the style state to the first change; ignore any following edits' style + // implications. + val originalRange = changes.getOriginalRange(0) + val range = changes.getRange(0) + + // Only override when the first change is insert-only (originalRange.collapsed && + // !range.collapsed) at the cursor where the button was toggled. For delete-only or + // replace (delete then insert), do nothing and let selection change recompute the + // state. + if (originalRange.collapsed && !range.collapsed && overrideSelection == originalRange) { + val base = computed + inlineOverrides.forEach { (style, shouldBeActive) -> + if (shouldBeActive != base.isActive(style)) { + if (shouldBeActive) { + applySpanStyle(range, style.spanStyle) + } else { + removeSpanStyle(range, style.spanStyle) + } + } + } + if (hasColorOverride && colorOverride != base.activeColorStyle) { + ColorSpanStyles.fastForEach { removeSpanStyle(range, it) } + colorOverride?.let { applySpanStyle(range, it) } + } + } + clearOverrides() + } +} + +private fun TextFieldBuffer.removeSpanStyle(targetRange: TextRange, spanStyle: SpanStyle) { + val min = targetRange.min + val max = targetRange.max + val intersectingStyles = getSpanStyles(targetRange).fastFilter { it.spanStyle == spanStyle } + intersectingStyles.fastForEach { style -> + val range = style.textRange + if (range.start >= min && range.end <= max) { + removeStyle(style) + } else if (range.start < min && range.end > max) { + val oldEnd = range.end + style.textRange = TextRange(range.start, min) + // Reuse the same constant SpanStyle instance for the split trailing range with + // ExpandPolicy.AtEnd. + addStyle(spanStyle, TextRange(max, oldEnd), ExpandPolicy.AtEnd) + } else if (range.start < min) { + style.textRange = TextRange(range.start, min) + } else { + style.textRange = TextRange(max, range.end) + } + } +} + +private fun TextFieldBuffer.applySpanStyle(targetRange: TextRange, spanStyle: SpanStyle) { + // getSpanStyles excludes ranges that merely touch the query, so widen it by 1 on each side to + // also coalesce adjacent ranges of the same style. A range intersects the widened query iff it + // overlaps or touches the target. This keeps the span list from fragmenting (e.g. bolding "ab" + // then "cd" yields a single [a, d) range rather than two). + val queryRange = TextRange( + (targetRange.min - 1).coerceAtLeast(0), + (targetRange.max + 1).coerceAtMost(length), + ) + val mergeCandidates = getSpanStyles(queryRange).fastFilter { it.spanStyle == spanStyle } + var mergedStart = targetRange.min + var mergedEnd = targetRange.max + mergeCandidates.fastForEach { style -> + mergedStart = minOf(mergedStart, style.textRange.start) + mergedEnd = maxOf(mergedEnd, style.textRange.end) + removeStyle(style) + } + // Reuse the same constant SpanStyle instance on the coalesced range. + addStyle(spanStyle, TextRange(mergedStart, mergedEnd), ExpandPolicy.AtEnd) +} diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt index b43d51cea1..6d8f48156a 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt @@ -20,6 +20,8 @@ import android.net.Uri import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.animateFloatAsState @@ -50,9 +52,8 @@ import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape 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.TextFieldLineLimits import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon @@ -92,10 +93,9 @@ import androidx.compose.ui.semantics.SemanticsPropertyKey import androidx.compose.ui.semantics.SemanticsPropertyReceiver import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -113,6 +113,7 @@ enum class InputSelector { EMOJI, PHONE, PICTURE, + RICHTEXTEDITOR, } enum class EmojiStickerSelector { @@ -129,10 +130,10 @@ fun UserInputPreview() { @OptIn(ExperimentalFoundationApi::class) @Composable fun UserInput( - onMessageSent: (String) -> Unit, + onMessageSent: (AnnotatedString) -> Unit, modifier: Modifier = Modifier, resetScroll: () -> Unit = {}, - onVideoMessageSent: (videoUri: String, caption: String) -> Unit = { _, _ -> }, + onVideoMessageSent: (videoUri: String, caption: AnnotatedString) -> Unit = { _, _ -> }, ) { var currentInputSelector by rememberSaveable { mutableStateOf(InputSelector.NONE) } val dismissKeyboard = { currentInputSelector = InputSelector.NONE } @@ -142,9 +143,7 @@ fun UserInput( BackHandler(onBack = dismissKeyboard) } - var textState by rememberSaveable(stateSaver = TextFieldValue.Saver) { - mutableStateOf(TextFieldValue()) - } + val formattedTextState = rememberFormattedTextState() var attachedVideoUri by rememberSaveable { mutableStateOf(null) } @@ -162,13 +161,14 @@ fun UserInput( val sendMessage = { val currentVideoUri = attachedVideoUri + val content = formattedTextState.toAnnotatedString() if (currentVideoUri != null) { - onVideoMessageSent(currentVideoUri, textState.text.trim()) + onVideoMessageSent(currentVideoUri, content.trimWhitespace()) attachedVideoUri = null - } else if (textState.text.isNotBlank()) { - onMessageSent(textState.text) + } else if (content.isNotBlank()) { + onMessageSent(content) } - textState = TextFieldValue() + formattedTextState.clear() isRecordingActive = false resetScroll() dismissKeyboard() @@ -178,7 +178,7 @@ fun UserInput( var textFieldFocusState by remember { mutableStateOf(false) } val surfaceColor = MaterialTheme.colorScheme.surfaceContainer - val sendMessageEnabled = textState.text.isNotBlank() || attachedVideoUri != null + val sendMessageEnabled = !formattedTextState.isBlank || attachedVideoUri != null // Animated mesh-gradient glow behind the card, shown while recording is active. val glowAlpha by animateFloatAsState( @@ -257,20 +257,29 @@ fun UserInput( } UserInputText( - textFieldValue = textState, - onTextChanged = { textState = it }, - // Only show the keyboard if there's no input selector and text field has focus - keyboardShown = currentInputSelector == InputSelector.NONE && textFieldFocusState, - // Close extended selector if text field receives focus + formattedTextState = formattedTextState, + // Only show the keyboard if there's no extended input selector (or rich + // text editor is active) and text field has focus + keyboardShown = + ( + currentInputSelector == InputSelector.NONE || + currentInputSelector == InputSelector.RICHTEXTEDITOR + ) && textFieldFocusState, + // Close extended selector if text field receives focus, keeping rich text + // editor open while editing onTextFieldFocused = { focused -> if (focused) { - currentInputSelector = InputSelector.NONE + if (currentInputSelector != InputSelector.RICHTEXTEDITOR) { + currentInputSelector = InputSelector.NONE + } resetScroll() } textFieldFocusState = focused }, onMessageSent = { sendMessage() }, focusState = textFieldFocusState, + showCloseButton = currentInputSelector == InputSelector.RICHTEXTEDITOR, + onCloseRichTextEditor = dismissKeyboard, ) } @@ -314,12 +323,24 @@ fun UserInput( SelectorExpanded( onCloseRequested = dismissKeyboard, - onTextAdded = { textState = textState.addText(it) }, + onTextAdded = { formattedTextState.addText(it) }, currentSelector = currentInputSelector, + spanStyleState = formattedTextState.spanStyleState, ) } } +/** + * Trims leading and trailing whitespace while keeping [SpanStyle] ranges aligned with the trimmed + * text ([AnnotatedString.subSequence] re-bases the ranges). + */ +private fun AnnotatedString.trimWhitespace(): AnnotatedString { + val start = indexOfFirst { !it.isWhitespace() } + if (start == -1) return AnnotatedString("") + val end = indexOfLast { !it.isWhitespace() } + 1 + return if (start == 0 && end == length) this else subSequence(start, end) +} + @Composable private fun AttachedVideoPreview(videoUri: String, onRemove: () -> Unit, modifier: Modifier = Modifier) { Box( @@ -358,27 +379,19 @@ private fun AttachedVideoPreview(videoUri: String, onRemove: () -> Unit, modifie } } -private fun TextFieldValue.addText(newString: String): TextFieldValue { - val newText = this.text.replaceRange( - this.selection.start, - this.selection.end, - newString, - ) - val newSelection = TextRange( - start = newText.length, - end = newText.length, - ) - - return this.copy(text = newText, selection = newSelection) -} - @Composable -private fun SelectorExpanded(currentSelector: InputSelector, onCloseRequested: () -> Unit, onTextAdded: (String) -> Unit) { +private fun SelectorExpanded( + currentSelector: InputSelector, + onCloseRequested: () -> Unit, + onTextAdded: (String) -> Unit, + spanStyleState: SpanStyleState, +) { if (currentSelector == InputSelector.NONE) return // Request focus to force the TextField to lose it val focusRequester = remember { FocusRequester() } - // If the selector is shown, always request focus to trigger a TextField.onFocusChange. + // If the emoji selector is shown, request focus to trigger a TextField.onFocusChange. + // Do not steal focus when RICHTEXTEDITOR is open so the user can keep typing/selecting text. SideEffect { if (currentSelector == InputSelector.EMOJI) { focusRequester.requestFocus() @@ -392,11 +405,113 @@ private fun SelectorExpanded(currentSelector: InputSelector, onCloseRequested: ( InputSelector.PICTURE -> FunctionalityNotAvailablePanel() InputSelector.MAP -> FunctionalityNotAvailablePanel() InputSelector.PHONE -> FunctionalityNotAvailablePanel() + InputSelector.RICHTEXTEDITOR -> RichTextToolbar(spanStyleState = spanStyleState) InputSelector.NONE -> Unit } } } +@Composable +fun RichTextToolbar(spanStyleState: SpanStyleState, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .fillMaxWidth() + .height(56.dp) + .background(MaterialTheme.colorScheme.primary), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + InlineStyleButton( + spanStyleState = spanStyleState, + style = InlineStyle.Bold, + iconRes = R.drawable.ic_text_format_bold, + contentDescriptionRes = R.string.format_bold, + ) + InlineStyleButton( + spanStyleState = spanStyleState, + style = InlineStyle.Underline, + iconRes = R.drawable.ic_text_format_underlined, + contentDescriptionRes = R.string.format_underlined, + ) + InlineStyleButton( + spanStyleState = spanStyleState, + style = InlineStyle.Strikethrough, + iconRes = R.drawable.ic_text_strikethrough, + contentDescriptionRes = R.string.format_strikethrough, + ) + val activeColor = spanStyleState.activeColorStyle?.color + RichTextToolbarButton( + iconRes = R.drawable.ic_text_border_color, + contentDescription = stringResource(id = R.string.format_color), + selected = activeColor != null, + onClick = { spanStyleState.cycleColor() }, + activeContainerColor = Color.White, + iconTint = activeColor ?: MaterialTheme.colorScheme.onPrimary, + ) + InlineStyleButton( + spanStyleState = spanStyleState, + style = InlineStyle.Italic, + iconRes = R.drawable.ic_text_format_italic, + contentDescriptionRes = R.string.format_italic, + ) + InlineStyleButton( + spanStyleState = spanStyleState, + style = InlineStyle.LargeFont, + iconRes = R.drawable.ic_text_format_size, + contentDescriptionRes = R.string.format_size, + ) + } +} + +@Composable +private fun InlineStyleButton( + spanStyleState: SpanStyleState, + style: InlineStyle, + @DrawableRes iconRes: Int, + @StringRes contentDescriptionRes: Int, +) { + RichTextToolbarButton( + iconRes = iconRes, + contentDescription = stringResource(id = contentDescriptionRes), + selected = spanStyleState.isActive(style), + onClick = { spanStyleState.toggle(style) }, + ) +} + +@Composable +private fun RichTextToolbarButton( + iconRes: Int, + contentDescription: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + activeContainerColor: Color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.2f), + iconTint: Color = MaterialTheme.colorScheme.onPrimary, +) { + IconButton( + onClick = onClick, + modifier = modifier + .size(44.dp) + .then( + if (selected) { + Modifier.background( + color = activeContainerColor, + shape = RoundedCornerShape(12.dp), + ) + } else { + Modifier + }, + ), + ) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = contentDescription, + tint = iconTint, + modifier = Modifier.size(24.dp), + ) + } +} + @Composable fun FunctionalityNotAvailablePanel() { AnimatedVisibility( @@ -500,7 +615,27 @@ private fun RowScope.UserInputSelector( ) } - // Add / attachment + val isRichTextSelected = currentInputSelector == InputSelector.RICHTEXTEDITOR + IconButton( + onClick = { + onSelectorChange( + if (isRichTextSelected) { + InputSelector.NONE + } else { + InputSelector.RICHTEXTEDITOR + }, + ) + }, + modifier = Modifier.size(48.dp), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_text_format), + contentDescription = stringResource(id = R.string.format_text), + tint = iconTint, + modifier = Modifier.size(24.dp), + ) + } + IconButton( onClick = onAddClick, modifier = Modifier.size(48.dp), @@ -525,53 +660,72 @@ var SemanticsPropertyReceiver.keyboardShownProperty by KeyboardShownKey @ExperimentalFoundationApi @Composable private fun UserInputText( - keyboardType: KeyboardType = KeyboardType.Text, - onTextChanged: (TextFieldValue) -> Unit, - textFieldValue: TextFieldValue, + formattedTextState: FormattedTextState, keyboardShown: Boolean, onTextFieldFocused: (Boolean) -> Unit, - onMessageSent: (String) -> Unit, + onMessageSent: () -> Unit, focusState: Boolean, + keyboardType: KeyboardType = KeyboardType.Text, + showCloseButton: Boolean = false, + onCloseRichTextEditor: () -> Unit = {}, ) { val a11ylabel = stringResource(id = R.string.textfield_desc) Box( modifier = Modifier .fillMaxWidth() - .padding(start = 24.dp, top = 20.dp, end = 32.dp) + .padding( + start = 24.dp, + top = 20.dp, + end = if (showCloseButton) 16.dp else 32.dp, + ) .heightIn(min = 48.dp), ) { UserInputTextField( - textFieldValue, - onTextChanged, - onTextFieldFocused, - keyboardType, - focusState, - onMessageSent, - Modifier + formattedTextState = formattedTextState, + onTextFieldFocused = onTextFieldFocused, + keyboardType = keyboardType, + focusState = focusState, + onMessageSent = onMessageSent, + modifier = Modifier .fillMaxWidth() + .padding(end = if (showCloseButton) 36.dp else 0.dp) .semantics { contentDescription = a11ylabel keyboardShownProperty = keyboardShown }, ) + + if (showCloseButton) { + IconButton( + onClick = onCloseRichTextEditor, + modifier = Modifier + .align(Alignment.TopEnd) + .size(32.dp), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_close), + contentDescription = stringResource(id = R.string.close_rich_text_editor), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } } } @Composable private fun BoxScope.UserInputTextField( - textFieldValue: TextFieldValue, - onTextChanged: (TextFieldValue) -> Unit, + formattedTextState: FormattedTextState, onTextFieldFocused: (Boolean) -> Unit, keyboardType: KeyboardType, focusState: Boolean, - onMessageSent: (String) -> Unit, + onMessageSent: () -> Unit, modifier: Modifier = Modifier, ) { var lastFocusState by remember { mutableStateOf(false) } - BasicTextField( - value = textFieldValue, - onValueChange = { onTextChanged(it) }, + FormattedTextField( + state = formattedTextState, modifier = modifier .align(Alignment.TopStart) .onFocusChanged { state -> @@ -584,17 +738,17 @@ private fun BoxScope.UserInputTextField( keyboardType = keyboardType, imeAction = ImeAction.Send, ), - keyboardActions = KeyboardActions { - if (textFieldValue.text.isNotBlank()) onMessageSent(textFieldValue.text) + onKeyboardAction = { + if (formattedTextState.text.isNotBlank()) onMessageSent() }, - maxLines = 4, + lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 4), cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), textStyle = MaterialTheme.typography.titleLarge.copy( color = MaterialTheme.colorScheme.primary, ), ) - if (textFieldValue.text.isEmpty() && !focusState) { + if (formattedTextState.isEmpty && !focusState) { Text( modifier = Modifier.align(Alignment.TopStart), text = stringResource(R.string.textfield_hint), diff --git a/Jetchat/app/src/main/res/drawable/ic_text_border_color.xml b/Jetchat/app/src/main/res/drawable/ic_text_border_color.xml new file mode 100644 index 0000000000..67b3128c32 --- /dev/null +++ b/Jetchat/app/src/main/res/drawable/ic_text_border_color.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/Jetchat/app/src/main/res/drawable/ic_text_format.xml b/Jetchat/app/src/main/res/drawable/ic_text_format.xml new file mode 100644 index 0000000000..ab4414f4c1 --- /dev/null +++ b/Jetchat/app/src/main/res/drawable/ic_text_format.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetchat/app/src/main/res/drawable/ic_text_format_bold.xml b/Jetchat/app/src/main/res/drawable/ic_text_format_bold.xml new file mode 100644 index 0000000000..b6a89a052f --- /dev/null +++ b/Jetchat/app/src/main/res/drawable/ic_text_format_bold.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/Jetchat/app/src/main/res/drawable/ic_text_format_italic.xml b/Jetchat/app/src/main/res/drawable/ic_text_format_italic.xml new file mode 100644 index 0000000000..1eb8052e5b --- /dev/null +++ b/Jetchat/app/src/main/res/drawable/ic_text_format_italic.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/Jetchat/app/src/main/res/drawable/ic_text_format_size.xml b/Jetchat/app/src/main/res/drawable/ic_text_format_size.xml new file mode 100644 index 0000000000..180b6b4e12 --- /dev/null +++ b/Jetchat/app/src/main/res/drawable/ic_text_format_size.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/Jetchat/app/src/main/res/drawable/ic_text_format_underlined.xml b/Jetchat/app/src/main/res/drawable/ic_text_format_underlined.xml new file mode 100644 index 0000000000..b02157a9bd --- /dev/null +++ b/Jetchat/app/src/main/res/drawable/ic_text_format_underlined.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/Jetchat/app/src/main/res/drawable/ic_text_strikethrough.xml b/Jetchat/app/src/main/res/drawable/ic_text_strikethrough.xml new file mode 100644 index 0000000000..ef762bd7bc --- /dev/null +++ b/Jetchat/app/src/main/res/drawable/ic_text_strikethrough.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/Jetchat/app/src/main/res/values/strings.xml b/Jetchat/app/src/main/res/values/strings.xml index 1af056790b..4ed4843a01 100644 --- a/Jetchat/app/src/main/res/values/strings.xml +++ b/Jetchat/app/src/main/res/values/strings.xml @@ -81,5 +81,13 @@ Full screen Exit full screen Remove attached video + Format text + Close rich text editor + Bold + Italic + Underline + Strikethrough + Font color + Font size From 6bae56cd7186af389ffee88126ae61825542ad21 Mon Sep 17 00:00:00 2001 From: srikrishnasakunia Date: Fri, 9 Oct 2026 13:14:46 +0530 Subject: [PATCH 2/2] Beautify the UserInput --- .../compose/jetchat/conversation/UserInput.kt | 137 +++++++++++++++--- 1 file changed, 120 insertions(+), 17 deletions(-) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt index 6d8f48156a..cce875a5fe 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt @@ -23,8 +23,13 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.expandHorizontally import androidx.compose.animation.expandVertically @@ -32,6 +37,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkHorizontally import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -56,14 +62,17 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -75,18 +84,21 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.dropShadow import androidx.compose.ui.draw.paint -import androidx.compose.ui.draw.shadow import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusTarget import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.drawscope.scale import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.FirstBaseline +import androidx.compose.ui.layout.layout import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.SemanticsPropertyKey @@ -98,8 +110,10 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.lerp import com.example.compose.jetchat.FunctionalityNotAvailablePopup import com.example.compose.jetchat.R import com.example.compose.jetchat.components.rememberRecordButtonMeshGradientPainter @@ -177,8 +191,48 @@ fun UserInput( // Used to decide if the keyboard should be shown var textFieldFocusState by remember { mutableStateOf(false) } - val surfaceColor = MaterialTheme.colorScheme.surfaceContainer + val surfaceColor by animateColorAsState( + targetValue = if (textFieldFocusState) { + MaterialTheme.colorScheme.surfaceContainerHighest + } else { + MaterialTheme.colorScheme.surfaceContainer + }, + animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing), + label = "inputSurfaceColor", + ) val sendMessageEnabled = !formattedTextState.isBlank || attachedVideoUri != null + val borderColor by animateColorAsState( + targetValue = if (textFieldFocusState && !isRecordingActive) { + MaterialTheme.colorScheme.primary + } else { + Color.Transparent + }, + animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing), + label = "inputBorderColor", + ) + + val focusProgress = animateFloatAsState( + targetValue = if (textFieldFocusState) 1f else 0f, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + label = "inputFocusProgress", + ) + val shadowColor = MaterialTheme.colorScheme.primary + + val focusScale = remember { Animatable(1f) } + LaunchedEffect(textFieldFocusState) { + if (textFieldFocusState) { + focusScale.animateTo(1.03f, tween(durationMillis = 120, easing = FastOutSlowInEasing)) + focusScale.animateTo( + 1f, + spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + ) + } else { + focusScale.animateTo(1f, spring(stiffness = Spring.StiffnessMedium)) + } + } // Animated mesh-gradient glow behind the card, shown while recording is active. val glowAlpha by animateFloatAsState( @@ -204,8 +258,13 @@ fun UserInput( Surface( shape = cardShape, color = surfaceColor, + border = BorderStroke(1.dp, borderColor), modifier = Modifier .fillMaxWidth() + .graphicsLayer { + scaleX = focusScale.value + scaleY = focusScale.value + } // Draw the glow behind the card, scaled past its bounds. .then( if (glowMeshPainter != null) { @@ -221,19 +280,23 @@ fun UserInput( }, ) .padding(end = 4.dp) - // Blue-tinted shadow while idle; the glow replaces it while recording. - // Tinted shadows render on API 28+ (black on older versions). + // Primary-tinted drop shadow while idle; the glow replaces it while recording. + // Soft and centred at rest, tighter/stronger and offset to the + // bottom-left while focused. .then( if (isRecordingActive) { Modifier } else { - Modifier.shadow( - elevation = 16.dp, - shape = cardShape, - clip = false, - ambientColor = MaterialTheme.colorScheme.primary, - spotColor = MaterialTheme.colorScheme.primary, - ) + Modifier.dropShadow(cardShape) { + val p = focusProgress.value + radius = lerp(24.dp.toPx(), 10.dp.toPx(), p) + offset = Offset( + x = lerp(0f, -4.dp.toPx(), p), + y = lerp(4.dp.toPx(), 6.dp.toPx(), p), + ) + color = shadowColor + alpha = lerp(0.25f, 0.6f, p) + } }, ) .heightIn(min = 136.dp), @@ -303,17 +366,27 @@ fun UserInput( onAddClick = { currentInputSelector = InputSelector.MAP }, ) - IconButton( + // Wide filled icon button: primary container when enabled; icon-only (no + // container) when disabled. + FilledIconButton( onClick = sendMessage, enabled = sendMessageEnabled, - modifier = Modifier.size(48.dp), + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + disabledContainerColor = Color.Transparent, + disabledContentColor = + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.54f), + ), + modifier = Modifier.size( + IconButtonDefaults.smallContainerSize( + IconButtonDefaults.IconButtonWidthOption.Wide, + ), + ), ) { Icon( painter = painterResource(id = R.drawable.ic_send), contentDescription = stringResource(id = R.string.send), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy( - alpha = if (sendMessageEnabled) 0.85f else 0.54f, - ), modifier = Modifier.size(24.dp), ) } @@ -398,7 +471,15 @@ private fun SelectorExpanded( } } - Surface(tonalElevation = 8.dp) { + Surface( + tonalElevation = 8.dp, + // The rich-text toolbar is docked to the keyboard rather than inset under the card. + modifier = if (currentSelector == InputSelector.RICHTEXTEDITOR) { + Modifier.dockedToKeyboard() + } else { + Modifier + }, + ) { when (currentSelector) { InputSelector.EMOJI -> EmojiSelector(onTextAdded, focusRequester) InputSelector.DM -> NotAvailablePopup(onCloseRequested) @@ -411,6 +492,28 @@ private fun SelectorExpanded( } } +/** + * Lets the rich-text toolbar break out of [UserInput]'s inset column padding so it spans the full + * width and sits flush on the keyboard (the column itself follows the IME via the caller's + * imePadding). [start], [end] and [bottom] must match that column's padding. + */ +private fun Modifier.dockedToKeyboard(start: Dp = 8.dp, end: Dp = 4.dp, bottom: Dp = 8.dp): Modifier = layout { measurable, constraints -> + val startPx = start.roundToPx() + val extraWidth = startPx + end.roundToPx() + val placeable = measurable.measure( + if (constraints.hasBoundedWidth) { + constraints.copy(maxWidth = constraints.maxWidth + extraWidth) + } else { + constraints + }, + ) + val width = (placeable.width - extraWidth).coerceIn(constraints.minWidth, constraints.maxWidth) + layout(width, placeable.height) { + // Shift left over the start padding and down over the bottom padding. + placeable.place(x = -startPx, y = bottom.roundToPx()) + } +} + @Composable fun RichTextToolbar(spanStyleState: SpanStyleState, modifier: Modifier = Modifier) { Row(