Use TextFieldState in CustomInput state for proper MVI text handling

This commit is contained in:
ganfra
2026-06-15 15:29:50 +02:00
parent eeaaac2969
commit 73a8a3d5d8
5 changed files with 33 additions and 38 deletions
@@ -22,8 +22,6 @@ sealed interface UserStatusEvent {
data object CancelCustomInput : UserStatusEvent
/** User changed the emoji in the custom input row. */
data class UpdateCustomEmoji(val emoji: String) : UserStatusEvent
/** User changed the text in the custom input row. */
data class UpdateCustomText(val text: String) : UserStatusEvent
/** User tapped the clear action on an existing status. */
data object Clear : UserStatusEvent
}
@@ -7,6 +7,9 @@
package io.element.android.features.preferences.impl.userstatus
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -31,6 +34,7 @@ class UserStatusPresenter(
override fun present(): UserStatusState {
val userProfile by matrixClient.userProfile.collectAsState()
var pickerState by remember { mutableStateOf<UserStatusPickerState>(UserStatusPickerState.Hidden) }
val customTextFieldState = remember { TextFieldState() }
val coroutineScope = rememberCoroutineScope()
fun handleEvent(event: UserStatusEvent) {
@@ -39,9 +43,14 @@ class UserStatusPresenter(
UserStatusEvent.Dismiss -> pickerState = UserStatusPickerState.Hidden
UserStatusEvent.OpenCustomInput -> {
val raw = userProfile.rawStatus
if (raw != null) {
customTextFieldState.setTextAndPlaceCursorAtEnd(raw.text)
} else {
customTextFieldState.clearText()
}
pickerState = UserStatusPickerState.CustomInput(
emoji = raw?.emoji ?: "😀",
text = raw?.text ?: "",
textFieldState = customTextFieldState,
)
}
is UserStatusEvent.Set -> {
@@ -57,10 +66,6 @@ class UserStatusPresenter(
val current = pickerState as? UserStatusPickerState.CustomInput ?: return
pickerState = current.copy(emoji = event.emoji)
}
is UserStatusEvent.UpdateCustomText -> {
val current = pickerState as? UserStatusPickerState.CustomInput ?: return
pickerState = current.copy(text = event.text)
}
}
}
@@ -8,6 +8,7 @@
package io.element.android.features.preferences.impl.userstatus
import androidx.annotation.StringRes
import androidx.compose.foundation.text.input.TextFieldState
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.matrix.api.user.DisplayedStatus
@@ -22,7 +23,7 @@ sealed interface UserStatusPickerState {
data object ShowingPicker : UserStatusPickerState
data class CustomInput(
val emoji: String = "😀",
val text: String = "",
val textFieldState: TextFieldState,
) : UserStatusPickerState
}
@@ -15,8 +15,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
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.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -32,6 +34,7 @@ import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.FilledTextField
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.IconSource
@@ -40,7 +43,6 @@ import io.element.android.libraries.designsystem.theme.components.ModalBottomShe
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.UserStatus
import io.element.android.libraries.ui.strings.CommonStrings
@@ -77,10 +79,13 @@ fun UserStatusRow(
is UserStatusPickerState.CustomInput -> {
CustomStatusInputRow(
emoji = pickerState.emoji,
text = pickerState.text,
textFieldState = pickerState.textFieldState,
onEmojiChange = { state.eventSink(UserStatusEvent.UpdateCustomEmoji(it)) },
onTextChange = { state.eventSink(UserStatusEvent.UpdateCustomText(it)) },
onConfirm = { state.eventSink(UserStatusEvent.Set(UserStatus(pickerState.emoji, pickerState.text))) },
onConfirm = {
state.eventSink(
UserStatusEvent.Set(UserStatus(pickerState.emoji, pickerState.textFieldState.text.toString()))
)
},
onCancel = { state.eventSink(UserStatusEvent.CancelCustomInput) },
modifier = modifier,
)
@@ -158,24 +163,21 @@ private fun UserStatusPickerBottomSheet(
@Composable
private fun CustomStatusInputRow(
emoji: String,
text: String,
textFieldState: TextFieldState,
onEmojiChange: (String) -> Unit,
onTextChange: (String) -> Unit,
onConfirm: () -> Unit,
onCancel: () -> Unit,
modifier: Modifier = Modifier,
) {
ListItem(
headlineContent = {
TextField(
value = text,
onValueChange = { if (it.length <= 30) onTextChange(it) },
placeholder = stringResource(R.string.screen_preferences_user_status_custom_hint),
FilledTextField(
state = textFieldState,
placeholder = { Text(stringResource(R.string.screen_preferences_user_status_custom_hint)) },
inputTransformation = InputTransformation { if (length > 30) revertAllChanges() },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(
onDone = { if (text.isNotBlank()) onConfirm() }
),
singleLine = true,
keyboardActions = { if (textFieldState.text.isNotBlank()) onConfirm() },
lineLimits = TextFieldLineLimits.SingleLine,
)
},
trailingContent = ListItemContent.Custom({
@@ -261,7 +263,7 @@ internal fun UserStatusRowCustomInputPreview() = ElementPreview {
UserStatusRow(
state = UserStatusState(
displayedStatus = null,
pickerState = UserStatusPickerState.CustomInput(emoji = "😀", text = ""),
pickerState = UserStatusPickerState.CustomInput(emoji = "😀", textFieldState = TextFieldState()),
eventSink = {},
)
)
@@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.user.UserStatus
import io.element.android.libraries.matrix.test.FakeMatrixClient
import kotlinx.coroutines.test.runTest
import org.junit.Test
import androidx.compose.foundation.text.input.TextFieldState
class UserStatusPresenterTest {
@@ -81,7 +82,7 @@ class UserStatusPresenterTest {
val state = awaitItem()
val pickerState = state.pickerState as UserStatusPickerState.CustomInput
assertThat(pickerState.emoji).isEqualTo("😀")
assertThat(pickerState.text).isEqualTo("")
assertThat(pickerState.textFieldState.text.toString()).isEqualTo("")
}
}
@@ -98,19 +99,7 @@ class UserStatusPresenterTest {
val state = awaitItem()
val pickerState = state.pickerState as UserStatusPickerState.CustomInput
assertThat(pickerState.emoji).isEqualTo("🌴")
assertThat(pickerState.text).isEqualTo("Away")
}
}
@Test
fun `UpdateCustomText updates text in CustomInput state`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
createPresenter().present()
}.test {
awaitItem().eventSink(UserStatusEvent.OpenCustomInput)
awaitItem().eventSink(UserStatusEvent.UpdateCustomText("Focusing"))
val state = awaitItem()
assertThat((state.pickerState as UserStatusPickerState.CustomInput).text).isEqualTo("Focusing")
assertThat(pickerState.textFieldState.text.toString()).isEqualTo("Away")
}
}