Improve TextFieldDialog (#4512)

* Extract TextFieldDialog to its own file (no other change).

* Add TextFieldDialogPreview

Enhance TextFieldDialog

* Let RoomMembersModerationView use TextFieldDialog

* Update screenshots

* Konsist.

* Add modifier parameter.

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Benoit Marty
2025-04-02 10:05:56 +02:00
committed by GitHub
parent f0cc6e315e
commit c65b54d3b7
35 changed files with 291 additions and 264 deletions

View File

@@ -1,14 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.roomdetails.impl.members.moderation
import io.element.android.libraries.architecture.AsyncAction
data class ConfirmingWithReason(
val reason: String,
) : AsyncAction.Confirming

View File

@@ -12,8 +12,10 @@ import io.element.android.libraries.matrix.api.room.RoomMember
sealed interface RoomMembersModerationEvents {
data class SelectRoomMember(val roomMember: RoomMember) : RoomMembersModerationEvents
data class KickUser(val reason: String, val needsConfirmation: Boolean) : RoomMembersModerationEvents
data class BanUser(val reason: String, val needsConfirmation: Boolean) : RoomMembersModerationEvents
data object KickUser : RoomMembersModerationEvents
data class DoKickUser(val reason: String) : RoomMembersModerationEvents
data object BanUser : RoomMembersModerationEvents
data class DoBanUser(val reason: String) : RoomMembersModerationEvents
data class UnbanUser(val userId: UserId) : RoomMembersModerationEvents
data object Reset : RoomMembersModerationEvents
}

View File

@@ -96,24 +96,22 @@ class RoomMembersModerationPresenter @Inject constructor(
}
}
is RoomMembersModerationEvents.KickUser -> {
if (event.needsConfirmation) {
kickUserAsyncAction.value = ConfirmingWithReason(event.reason)
} else {
selectedMember?.let {
coroutineScope.kickUser(it.userId, event.reason, kickUserAsyncAction)
}
selectedMember = null
kickUserAsyncAction.value = AsyncAction.ConfirmingNoParams
}
is RoomMembersModerationEvents.DoKickUser -> {
selectedMember?.let {
coroutineScope.kickUser(it.userId, event.reason, kickUserAsyncAction)
}
selectedMember = null
}
is RoomMembersModerationEvents.BanUser -> {
if (event.needsConfirmation) {
banUserAsyncAction.value = ConfirmingWithReason(event.reason)
} else {
selectedMember?.let {
coroutineScope.banUser(it.userId, event.reason, banUserAsyncAction)
}
selectedMember = null
banUserAsyncAction.value = AsyncAction.ConfirmingNoParams
}
is RoomMembersModerationEvents.DoBanUser -> {
selectedMember?.let {
coroutineScope.banUser(it.userId, event.reason, banUserAsyncAction)
}
selectedMember = null
}
is RoomMembersModerationEvents.UnbanUser -> {
// We are already confirming when we are reaching this point

View File

@@ -39,11 +39,7 @@ class RoomMembersModerationStateProvider : PreviewParameterProvider<RoomMembersM
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
kickUserAsyncAction = ConfirmingWithReason(""),
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
kickUserAsyncAction = ConfirmingWithReason("A reason"),
kickUserAsyncAction = AsyncAction.ConfirmingNoParams,
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
@@ -51,11 +47,7 @@ class RoomMembersModerationStateProvider : PreviewParameterProvider<RoomMembersM
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
banUserAsyncAction = ConfirmingWithReason(""),
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
banUserAsyncAction = ConfirmingWithReason("A reason"),
banUserAsyncAction = AsyncAction.ConfirmingNoParams,
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),

View File

@@ -38,9 +38,8 @@ import io.element.android.libraries.designsystem.components.async.rememberAsyncI
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.dialogs.ListDialog
import io.element.android.libraries.designsystem.components.dialogs.TextFieldDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.list.TextFieldListItem
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.IconSource
@@ -74,10 +73,10 @@ fun RoomMembersModerationView(
onDisplayMemberProfile(action.userId)
}
is ModerationAction.KickUser -> {
state.eventSink(RoomMembersModerationEvents.KickUser(reason = "", needsConfirmation = true))
state.eventSink(RoomMembersModerationEvents.KickUser)
}
is ModerationAction.BanUser -> {
state.eventSink(RoomMembersModerationEvents.BanUser(reason = "", needsConfirmation = true))
state.eventSink(RoomMembersModerationEvents.BanUser)
}
}
},
@@ -90,45 +89,19 @@ fun RoomMembersModerationView(
when (val action = state.kickUserAsyncAction) {
is AsyncAction.Confirming -> {
if (action is ConfirmingWithReason) {
ListDialog(
title = stringResource(R.string.screen_room_member_list_kick_member_confirmation_title),
submitText = stringResource(R.string.screen_room_member_list_kick_member_confirmation_action),
onSubmit = {
state.eventSink(
RoomMembersModerationEvents.KickUser(
reason = action.reason,
needsConfirmation = false,
)
)
},
applyPaddingToContents = true,
onDismissRequest = { state.eventSink(RoomMembersModerationEvents.Reset) },
) {
item {
Text(
text = stringResource(R.string.screen_room_member_list_kick_member_confirmation_description),
style = ElementTheme.materialTypography.bodyMedium,
)
}
item {
TextFieldListItem(
placeholder = stringResource(id = CommonStrings.common_reason),
label = stringResource(id = CommonStrings.common_reason),
withBorder = true,
text = action.reason,
onTextChange = { newText ->
state.eventSink(
RoomMembersModerationEvents.KickUser(
reason = newText,
needsConfirmation = true,
)
)
},
)
}
}
}
TextFieldDialog(
title = stringResource(R.string.screen_room_member_list_kick_member_confirmation_title),
submitText = stringResource(R.string.screen_room_member_list_kick_member_confirmation_action),
onSubmit = { reason ->
state.eventSink(RoomMembersModerationEvents.DoKickUser(reason = reason))
},
onDismissRequest = { state.eventSink(RoomMembersModerationEvents.Reset) },
placeholder = stringResource(id = CommonStrings.common_reason),
label = stringResource(id = CommonStrings.common_reason),
withBorder = true,
content = stringResource(R.string.screen_room_member_list_kick_member_confirmation_description),
value = "",
)
}
is AsyncAction.Loading -> {
LaunchedEffect(action) {
@@ -156,45 +129,19 @@ fun RoomMembersModerationView(
when (val action = state.banUserAsyncAction) {
is AsyncAction.Confirming -> {
if (action is ConfirmingWithReason) {
ListDialog(
title = stringResource(R.string.screen_room_member_list_ban_member_confirmation_title),
submitText = stringResource(R.string.screen_room_member_list_ban_member_confirmation_action),
onSubmit = {
state.eventSink(
RoomMembersModerationEvents.BanUser(
reason = action.reason,
needsConfirmation = false,
)
)
},
applyPaddingToContents = true,
onDismissRequest = { state.eventSink(RoomMembersModerationEvents.Reset) },
) {
item {
Text(
text = stringResource(R.string.screen_room_member_list_ban_member_confirmation_description),
style = ElementTheme.materialTypography.bodyMedium,
)
}
item {
TextFieldListItem(
placeholder = stringResource(id = CommonStrings.common_reason),
label = stringResource(id = CommonStrings.common_reason),
withBorder = true,
text = action.reason,
onTextChange = { newText ->
state.eventSink(
RoomMembersModerationEvents.BanUser(
reason = newText,
needsConfirmation = true,
)
)
},
)
}
}
}
TextFieldDialog(
title = stringResource(R.string.screen_room_member_list_ban_member_confirmation_title),
submitText = stringResource(R.string.screen_room_member_list_ban_member_confirmation_action),
onSubmit = { reason ->
state.eventSink(RoomMembersModerationEvents.DoBanUser(reason = reason))
},
onDismissRequest = { state.eventSink(RoomMembersModerationEvents.Reset) },
placeholder = stringResource(id = CommonStrings.common_reason),
label = stringResource(id = CommonStrings.common_reason),
withBorder = true,
content = stringResource(R.string.screen_room_member_list_ban_member_confirmation_description),
value = "",
)
}
is AsyncAction.Loading -> {
LaunchedEffect(action) {

View File

@@ -173,15 +173,11 @@ class RoomMembersModerationPresenterTest {
}.test {
skipItems(1)
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(selectedMember))
awaitItem().eventSink(RoomMembersModerationEvents.KickUser(reason = "", needsConfirmation = true))
awaitItem().eventSink(RoomMembersModerationEvents.KickUser)
val confirmingState = awaitItem()
assertThat(confirmingState.kickUserAsyncAction).isEqualTo(ConfirmingWithReason(""))
// Change the reason
confirmingState.eventSink(RoomMembersModerationEvents.KickUser(reason = A_REASON, needsConfirmation = true))
val confirmingWithReasonState = awaitItem()
assertThat(confirmingWithReasonState.kickUserAsyncAction).isEqualTo(ConfirmingWithReason(A_REASON))
assertThat(confirmingState.kickUserAsyncAction).isEqualTo(AsyncAction.ConfirmingNoParams)
// Confirm
confirmingState.eventSink(RoomMembersModerationEvents.KickUser(reason = A_REASON, needsConfirmation = false))
confirmingState.eventSink(RoomMembersModerationEvents.DoKickUser(reason = A_REASON))
skipItems(1)
val loadingState = awaitItem()
assertThat(loadingState.actions).isEmpty()
@@ -215,15 +211,11 @@ class RoomMembersModerationPresenterTest {
}.test {
skipItems(1)
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(selectedMember))
awaitItem().eventSink(RoomMembersModerationEvents.BanUser(reason = "", needsConfirmation = true))
awaitItem().eventSink(RoomMembersModerationEvents.BanUser)
val confirmingState = awaitItem()
assertThat(confirmingState.banUserAsyncAction).isEqualTo(ConfirmingWithReason(""))
// Change the reason
confirmingState.eventSink(RoomMembersModerationEvents.BanUser(reason = A_REASON, needsConfirmation = true))
val confirmingWithReasonState = awaitItem()
assertThat(confirmingWithReasonState.banUserAsyncAction).isEqualTo(ConfirmingWithReason(A_REASON))
assertThat(confirmingState.banUserAsyncAction).isEqualTo(AsyncAction.ConfirmingNoParams)
// Confirm
confirmingState.eventSink(RoomMembersModerationEvents.BanUser(reason = A_REASON, needsConfirmation = false))
confirmingState.eventSink(RoomMembersModerationEvents.DoBanUser(reason = A_REASON))
skipItems(1)
val loadingItem = awaitItem()
assertThat(loadingItem.actions).isEmpty()
@@ -314,7 +306,7 @@ class RoomMembersModerationPresenterTest {
val initialItem = awaitItem()
// Kick user and fail
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(aVictor()))
awaitItem().eventSink(RoomMembersModerationEvents.KickUser(reason = "", needsConfirmation = false))
awaitItem().eventSink(RoomMembersModerationEvents.DoKickUser(reason = ""))
skipItems(1)
assertThat(awaitItem().kickUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java)
assertThat(awaitItem().kickUserAsyncAction).isInstanceOf(AsyncAction.Failure::class.java)
@@ -324,7 +316,7 @@ class RoomMembersModerationPresenterTest {
// Ban user and fail
initialItem.eventSink(RoomMembersModerationEvents.SelectRoomMember(aVictor()))
awaitItem().eventSink(RoomMembersModerationEvents.BanUser(reason = "", needsConfirmation = false))
awaitItem().eventSink(RoomMembersModerationEvents.DoBanUser(reason = ""))
skipItems(1)
assertThat(awaitItem().banUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java)
assertThat(awaitItem().banUserAsyncAction).isInstanceOf(AsyncAction.Failure::class.java)

View File

@@ -15,6 +15,7 @@ import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.roomdetails.impl.R
import io.element.android.features.roomdetails.impl.members.anAlice
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.test.A_REASON
import io.element.android.libraries.ui.strings.CommonStrings
@@ -94,7 +95,7 @@ class RoomMembersModerationViewTest {
rule.clickOn(R.string.screen_room_member_list_manage_member_remove)
// Give time for the bottom sheet to animate
rule.mainClock.advanceTimeBy(1_000)
eventsRecorder.assertSingle(RoomMembersModerationEvents.KickUser(reason = "", needsConfirmation = true))
eventsRecorder.assertSingle(RoomMembersModerationEvents.KickUser)
}
@Test
@@ -103,7 +104,7 @@ class RoomMembersModerationViewTest {
val roomMember = anAlice()
val state = aRoomMembersModerationState(
selectedRoomMember = roomMember,
kickUserAsyncAction = ConfirmingWithReason(A_REASON),
kickUserAsyncAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder
)
rule.setRoomMembersModerationView(
@@ -115,19 +116,21 @@ class RoomMembersModerationViewTest {
}
@Test
fun `confirming 'Remove member' reason edition emits the expected event`() {
fun `confirming 'Remove member' reason edition then validation emits the expected event`() {
val eventsRecorder = EventsRecorder<RoomMembersModerationEvents>()
val roomMember = anAlice()
val state = aRoomMembersModerationState(
selectedRoomMember = roomMember,
kickUserAsyncAction = ConfirmingWithReason(A_REASON),
kickUserAsyncAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder
)
rule.setRoomMembersModerationView(
state = state,
)
rule.onNodeWithText(A_REASON).performTextInput("z")
eventsRecorder.assertSingle(RoomMembersModerationEvents.KickUser(reason = "z$A_REASON", needsConfirmation = true))
val reason = rule.activity.getString(CommonStrings.common_reason)
rule.onNodeWithText(reason).performTextInput(A_REASON)
rule.clickOn(R.string.screen_room_member_list_kick_member_confirmation_action)
eventsRecorder.assertSingle(RoomMembersModerationEvents.DoKickUser(reason = A_REASON))
}
@Test
@@ -136,7 +139,7 @@ class RoomMembersModerationViewTest {
val roomMember = anAlice()
val state = aRoomMembersModerationState(
selectedRoomMember = roomMember,
kickUserAsyncAction = ConfirmingWithReason(A_REASON),
kickUserAsyncAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder
)
rule.setRoomMembersModerationView(
@@ -144,7 +147,7 @@ class RoomMembersModerationViewTest {
)
// Note: the string key semantics is not perfect here :/
rule.clickOn(R.string.screen_room_member_list_kick_member_confirmation_action)
eventsRecorder.assertSingle(RoomMembersModerationEvents.KickUser(reason = A_REASON, needsConfirmation = false))
eventsRecorder.assertSingle(RoomMembersModerationEvents.DoKickUser(reason = ""))
}
@Config(qualifiers = "h1024dp")
@@ -168,7 +171,7 @@ class RoomMembersModerationViewTest {
rule.clickOn(R.string.screen_room_member_list_manage_member_remove_confirmation_ban)
// Give time for the bottom sheet to animate
rule.mainClock.advanceTimeBy(1_000)
eventsRecorder.assertSingle(RoomMembersModerationEvents.BanUser(reason = "", needsConfirmation = true))
eventsRecorder.assertSingle(RoomMembersModerationEvents.BanUser)
}
@Test
@@ -177,7 +180,7 @@ class RoomMembersModerationViewTest {
val roomMember = anAlice()
val state = aRoomMembersModerationState(
selectedRoomMember = roomMember,
banUserAsyncAction = ConfirmingWithReason(A_REASON),
banUserAsyncAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder
)
rule.setRoomMembersModerationView(
@@ -194,14 +197,16 @@ class RoomMembersModerationViewTest {
val roomMember = anAlice()
val state = aRoomMembersModerationState(
selectedRoomMember = roomMember,
banUserAsyncAction = ConfirmingWithReason(A_REASON),
banUserAsyncAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder
)
rule.setRoomMembersModerationView(
state = state,
)
rule.onNodeWithText(A_REASON).performTextInput("z")
eventsRecorder.assertSingle(RoomMembersModerationEvents.BanUser(reason = "z$A_REASON", needsConfirmation = true))
val reason = rule.activity.getString(CommonStrings.common_reason)
rule.onNodeWithText(reason).performTextInput(A_REASON)
rule.clickOn(R.string.screen_room_member_list_ban_member_confirmation_action)
eventsRecorder.assertSingle(RoomMembersModerationEvents.DoBanUser(reason = A_REASON))
}
@Test
@@ -210,7 +215,7 @@ class RoomMembersModerationViewTest {
val roomMember = anAlice()
val state = aRoomMembersModerationState(
selectedRoomMember = roomMember,
banUserAsyncAction = ConfirmingWithReason(A_REASON),
banUserAsyncAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder
)
rule.setRoomMembersModerationView(
@@ -218,7 +223,7 @@ class RoomMembersModerationViewTest {
)
// Note: the string key semantics is not perfect here :/
rule.clickOn(R.string.screen_room_member_list_ban_member_confirmation_action)
eventsRecorder.assertSingle(RoomMembersModerationEvents.BanUser(reason = A_REASON, needsConfirmation = false))
eventsRecorder.assertSingle(RoomMembersModerationEvents.DoBanUser(reason = ""))
}
@Test

View File

@@ -0,0 +1,154 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.components.dialogs
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.list.TextFieldListItem
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun TextFieldDialog(
title: String,
onSubmit: (String) -> Unit,
onDismissRequest: () -> Unit,
value: String?,
placeholder: String?,
modifier: Modifier = Modifier,
validation: (String?) -> Boolean = { true },
onValidationErrorMessage: String? = null,
autoSelectOnDisplay: Boolean = true,
maxLines: Int = 1,
content: String? = null,
label: String? = null,
withBorder: Boolean = false,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
submitText: String = stringResource(CommonStrings.action_ok),
) {
val focusRequester = remember { FocusRequester() }
var textFieldContents by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(
TextFieldValue(
value.orEmpty(),
selection = TextRange(value.orEmpty().length)
)
)
}
var error by rememberSaveable { mutableStateOf(if (!validation(value.orEmpty())) onValidationErrorMessage else null) }
var canRequestFocus by rememberSaveable { mutableStateOf(false) }
val canSubmit by remember { derivedStateOf { validation(textFieldContents.text) } }
ListDialog(
title = title,
onSubmit = { onSubmit(textFieldContents.text) },
onDismissRequest = onDismissRequest,
enabled = canSubmit,
applyPaddingToContents = content.isNullOrEmpty().not(),
submitText = submitText,
modifier = modifier,
) {
if (content != null) {
item {
Text(
text = content,
style = ElementTheme.materialTypography.bodyMedium,
)
}
}
item {
TextFieldListItem(
placeholder = placeholder.orEmpty(),
label = label,
withBorder = withBorder,
text = textFieldContents,
onTextChange = {
error = if (!validation(it.text)) onValidationErrorMessage else null
textFieldContents = it
},
error = error,
keyboardOptions = keyboardOptions,
keyboardActions = KeyboardActions(onAny = {
if (validation(textFieldContents.text)) {
onSubmit(textFieldContents.text)
}
}),
maxLines = maxLines,
modifier = Modifier.focusRequester(focusRequester),
)
canRequestFocus = true
}
}
if (autoSelectOnDisplay && canRequestFocus) {
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
}
@PreviewsDayNight
@Composable
internal fun TextFieldDialogPreview() = ElementPreview {
TextFieldDialog(
title = "Title",
value = "",
placeholder = "Placeholder",
onSubmit = {},
onDismissRequest = {},
)
}
@PreviewsDayNight
@Composable
internal fun TextFieldDialogWithBorderPreview() = ElementPreview {
TextFieldDialog(
title = "Title",
content = "Some content",
onSubmit = {},
onDismissRequest = {},
value = "Value",
placeholder = "Placeholder",
label = "Label",
withBorder = true,
onValidationErrorMessage = "Error message",
)
}
@PreviewsDayNight
@Composable
internal fun TextFieldDialogWithErrorPreview() = ElementPreview {
TextFieldDialog(
title = "Title",
content = "Some content",
onSubmit = {},
validation = { false },
onDismissRequest = {},
value = "Value",
placeholder = "Placeholder",
label = "Label",
withBorder = true,
onValidationErrorMessage = "Error message",
)
}

View File

@@ -70,6 +70,8 @@ fun TextFieldListItem(
modifier: Modifier = Modifier,
error: String? = null,
maxLines: Int = 1,
withBorder: Boolean = false,
label: String? = null,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
) {
@@ -79,12 +81,17 @@ fun TextFieldListItem(
value = text,
onValueChange = { onTextChange(it) },
placeholder = placeholder?.let { @Composable { Text(it) } },
colors = OutlinedTextFieldDefaults.colors(
disabledBorderColor = Color.Transparent,
errorBorderColor = Color.Transparent,
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
),
label = label?.let { @Composable { Text(it) } },
colors = if (withBorder) {
OutlinedTextFieldDefaults.colors()
} else {
OutlinedTextFieldDefaults.colors(
disabledBorderColor = Color.Transparent,
errorBorderColor = Color.Transparent,
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
)
},
isError = error != null,
supportingText = error?.let { @Composable { Text(it) } },
keyboardOptions = keyboardOptions,

View File

@@ -7,24 +7,15 @@
package io.element.android.libraries.designsystem.components.preferences
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import io.element.android.libraries.designsystem.components.dialogs.ListDialog
import io.element.android.libraries.designsystem.components.dialogs.TextFieldDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.list.TextFieldListItem
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
import io.element.android.libraries.designsystem.theme.components.Text
@@ -74,58 +65,3 @@ fun PreferenceTextField(
)
}
}
@Composable
private fun TextFieldDialog(
title: String,
onSubmit: (String) -> Unit,
onDismissRequest: () -> Unit,
value: String?,
placeholder: String?,
validation: (String?) -> Boolean = { true },
onValidationErrorMessage: String? = null,
autoSelectOnDisplay: Boolean = true,
maxLines: Int = 1,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
) {
val focusRequester = remember { FocusRequester() }
var textFieldContents by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue(value.orEmpty(), selection = TextRange(value.orEmpty().length)))
}
var error by rememberSaveable { mutableStateOf<String?>(null) }
var canRequestFocus by rememberSaveable { mutableStateOf(false) }
val canSubmit by remember { derivedStateOf { validation(textFieldContents.text) } }
ListDialog(
title = title,
onSubmit = { onSubmit(textFieldContents.text) },
onDismissRequest = onDismissRequest,
enabled = canSubmit,
) {
item {
TextFieldListItem(
placeholder = placeholder.orEmpty(),
text = textFieldContents,
onTextChange = {
error = if (!validation(it.text)) onValidationErrorMessage else null
textFieldContents = it
},
error = error,
keyboardOptions = keyboardOptions,
keyboardActions = KeyboardActions(onAny = {
if (validation(textFieldContents.text)) {
onSubmit(textFieldContents.text)
}
}),
maxLines = maxLines,
modifier = Modifier.focusRequester(focusRequester),
)
canRequestFocus = true
}
}
if (autoSelectOnDisplay && canRequestFocus) {
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
}

View File

@@ -120,6 +120,8 @@ class KonsistPreviewTest {
"TextComposerSimpleNotEncryptedPreview",
"TextComposerVoicePreview",
"TextComposerVoiceNotEncryptedPreview",
"TextFieldDialogWithBorderPreview",
"TextFieldDialogWithErrorPreview",
"TimelineImageWithCaptionRowPreview",
"TimelineItemEventRowForDirectRoomPreview",
"TimelineItemEventRowShieldPreview",