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 { sealed interface RoomMembersModerationEvents {
data class SelectRoomMember(val roomMember: RoomMember) : RoomMembersModerationEvents data class SelectRoomMember(val roomMember: RoomMember) : RoomMembersModerationEvents
data class KickUser(val reason: String, val needsConfirmation: Boolean) : RoomMembersModerationEvents data object KickUser : RoomMembersModerationEvents
data class BanUser(val reason: String, val needsConfirmation: Boolean) : 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 class UnbanUser(val userId: UserId) : RoomMembersModerationEvents
data object Reset : RoomMembersModerationEvents data object Reset : RoomMembersModerationEvents
} }

View file

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

View file

@ -39,11 +39,7 @@ class RoomMembersModerationStateProvider : PreviewParameterProvider<RoomMembersM
), ),
aRoomMembersModerationState( aRoomMembersModerationState(
selectedRoomMember = anAlice(), selectedRoomMember = anAlice(),
kickUserAsyncAction = ConfirmingWithReason(""), kickUserAsyncAction = AsyncAction.ConfirmingNoParams,
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
kickUserAsyncAction = ConfirmingWithReason("A reason"),
), ),
aRoomMembersModerationState( aRoomMembersModerationState(
selectedRoomMember = anAlice(), selectedRoomMember = anAlice(),
@ -51,11 +47,7 @@ class RoomMembersModerationStateProvider : PreviewParameterProvider<RoomMembersM
), ),
aRoomMembersModerationState( aRoomMembersModerationState(
selectedRoomMember = anAlice(), selectedRoomMember = anAlice(),
banUserAsyncAction = ConfirmingWithReason(""), banUserAsyncAction = AsyncAction.ConfirmingNoParams,
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
banUserAsyncAction = ConfirmingWithReason("A reason"),
), ),
aRoomMembersModerationState( aRoomMembersModerationState(
selectedRoomMember = anAlice(), 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.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize 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.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.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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.IconSource
@ -74,10 +73,10 @@ fun RoomMembersModerationView(
onDisplayMemberProfile(action.userId) onDisplayMemberProfile(action.userId)
} }
is ModerationAction.KickUser -> { is ModerationAction.KickUser -> {
state.eventSink(RoomMembersModerationEvents.KickUser(reason = "", needsConfirmation = true)) state.eventSink(RoomMembersModerationEvents.KickUser)
} }
is ModerationAction.BanUser -> { 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) { when (val action = state.kickUserAsyncAction) {
is AsyncAction.Confirming -> { is AsyncAction.Confirming -> {
if (action is ConfirmingWithReason) { TextFieldDialog(
ListDialog(
title = stringResource(R.string.screen_room_member_list_kick_member_confirmation_title), title = stringResource(R.string.screen_room_member_list_kick_member_confirmation_title),
submitText = stringResource(R.string.screen_room_member_list_kick_member_confirmation_action), submitText = stringResource(R.string.screen_room_member_list_kick_member_confirmation_action),
onSubmit = { onSubmit = { reason ->
state.eventSink( state.eventSink(RoomMembersModerationEvents.DoKickUser(reason = reason))
RoomMembersModerationEvents.KickUser(
reason = action.reason,
needsConfirmation = false,
)
)
}, },
applyPaddingToContents = true,
onDismissRequest = { state.eventSink(RoomMembersModerationEvents.Reset) }, 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), placeholder = stringResource(id = CommonStrings.common_reason),
label = stringResource(id = CommonStrings.common_reason), label = stringResource(id = CommonStrings.common_reason),
withBorder = true, withBorder = true,
text = action.reason, content = stringResource(R.string.screen_room_member_list_kick_member_confirmation_description),
onTextChange = { newText -> value = "",
state.eventSink(
RoomMembersModerationEvents.KickUser(
reason = newText,
needsConfirmation = true,
) )
)
},
)
}
}
}
} }
is AsyncAction.Loading -> { is AsyncAction.Loading -> {
LaunchedEffect(action) { LaunchedEffect(action) {
@ -156,45 +129,19 @@ fun RoomMembersModerationView(
when (val action = state.banUserAsyncAction) { when (val action = state.banUserAsyncAction) {
is AsyncAction.Confirming -> { is AsyncAction.Confirming -> {
if (action is ConfirmingWithReason) { TextFieldDialog(
ListDialog(
title = stringResource(R.string.screen_room_member_list_ban_member_confirmation_title), title = stringResource(R.string.screen_room_member_list_ban_member_confirmation_title),
submitText = stringResource(R.string.screen_room_member_list_ban_member_confirmation_action), submitText = stringResource(R.string.screen_room_member_list_ban_member_confirmation_action),
onSubmit = { onSubmit = { reason ->
state.eventSink( state.eventSink(RoomMembersModerationEvents.DoBanUser(reason = reason))
RoomMembersModerationEvents.BanUser(
reason = action.reason,
needsConfirmation = false,
)
)
}, },
applyPaddingToContents = true,
onDismissRequest = { state.eventSink(RoomMembersModerationEvents.Reset) }, 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), placeholder = stringResource(id = CommonStrings.common_reason),
label = stringResource(id = CommonStrings.common_reason), label = stringResource(id = CommonStrings.common_reason),
withBorder = true, withBorder = true,
text = action.reason, content = stringResource(R.string.screen_room_member_list_ban_member_confirmation_description),
onTextChange = { newText -> value = "",
state.eventSink(
RoomMembersModerationEvents.BanUser(
reason = newText,
needsConfirmation = true,
) )
)
},
)
}
}
}
} }
is AsyncAction.Loading -> { is AsyncAction.Loading -> {
LaunchedEffect(action) { LaunchedEffect(action) {

View file

@ -173,15 +173,11 @@ class RoomMembersModerationPresenterTest {
}.test { }.test {
skipItems(1) skipItems(1)
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(selectedMember)) awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(selectedMember))
awaitItem().eventSink(RoomMembersModerationEvents.KickUser(reason = "", needsConfirmation = true)) awaitItem().eventSink(RoomMembersModerationEvents.KickUser)
val confirmingState = awaitItem() val confirmingState = awaitItem()
assertThat(confirmingState.kickUserAsyncAction).isEqualTo(ConfirmingWithReason("")) assertThat(confirmingState.kickUserAsyncAction).isEqualTo(AsyncAction.ConfirmingNoParams)
// Change the reason
confirmingState.eventSink(RoomMembersModerationEvents.KickUser(reason = A_REASON, needsConfirmation = true))
val confirmingWithReasonState = awaitItem()
assertThat(confirmingWithReasonState.kickUserAsyncAction).isEqualTo(ConfirmingWithReason(A_REASON))
// Confirm // Confirm
confirmingState.eventSink(RoomMembersModerationEvents.KickUser(reason = A_REASON, needsConfirmation = false)) confirmingState.eventSink(RoomMembersModerationEvents.DoKickUser(reason = A_REASON))
skipItems(1) skipItems(1)
val loadingState = awaitItem() val loadingState = awaitItem()
assertThat(loadingState.actions).isEmpty() assertThat(loadingState.actions).isEmpty()
@ -215,15 +211,11 @@ class RoomMembersModerationPresenterTest {
}.test { }.test {
skipItems(1) skipItems(1)
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(selectedMember)) awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(selectedMember))
awaitItem().eventSink(RoomMembersModerationEvents.BanUser(reason = "", needsConfirmation = true)) awaitItem().eventSink(RoomMembersModerationEvents.BanUser)
val confirmingState = awaitItem() val confirmingState = awaitItem()
assertThat(confirmingState.banUserAsyncAction).isEqualTo(ConfirmingWithReason("")) assertThat(confirmingState.banUserAsyncAction).isEqualTo(AsyncAction.ConfirmingNoParams)
// Change the reason
confirmingState.eventSink(RoomMembersModerationEvents.BanUser(reason = A_REASON, needsConfirmation = true))
val confirmingWithReasonState = awaitItem()
assertThat(confirmingWithReasonState.banUserAsyncAction).isEqualTo(ConfirmingWithReason(A_REASON))
// Confirm // Confirm
confirmingState.eventSink(RoomMembersModerationEvents.BanUser(reason = A_REASON, needsConfirmation = false)) confirmingState.eventSink(RoomMembersModerationEvents.DoBanUser(reason = A_REASON))
skipItems(1) skipItems(1)
val loadingItem = awaitItem() val loadingItem = awaitItem()
assertThat(loadingItem.actions).isEmpty() assertThat(loadingItem.actions).isEmpty()
@ -314,7 +306,7 @@ class RoomMembersModerationPresenterTest {
val initialItem = awaitItem() val initialItem = awaitItem()
// Kick user and fail // Kick user and fail
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(aVictor())) awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(aVictor()))
awaitItem().eventSink(RoomMembersModerationEvents.KickUser(reason = "", needsConfirmation = false)) awaitItem().eventSink(RoomMembersModerationEvents.DoKickUser(reason = ""))
skipItems(1) skipItems(1)
assertThat(awaitItem().kickUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(awaitItem().kickUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java)
assertThat(awaitItem().kickUserAsyncAction).isInstanceOf(AsyncAction.Failure::class.java) assertThat(awaitItem().kickUserAsyncAction).isInstanceOf(AsyncAction.Failure::class.java)
@ -324,7 +316,7 @@ class RoomMembersModerationPresenterTest {
// Ban user and fail // Ban user and fail
initialItem.eventSink(RoomMembersModerationEvents.SelectRoomMember(aVictor())) initialItem.eventSink(RoomMembersModerationEvents.SelectRoomMember(aVictor()))
awaitItem().eventSink(RoomMembersModerationEvents.BanUser(reason = "", needsConfirmation = false)) awaitItem().eventSink(RoomMembersModerationEvents.DoBanUser(reason = ""))
skipItems(1) skipItems(1)
assertThat(awaitItem().banUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(awaitItem().banUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java)
assertThat(awaitItem().banUserAsyncAction).isInstanceOf(AsyncAction.Failure::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 androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.roomdetails.impl.R import io.element.android.features.roomdetails.impl.R
import io.element.android.features.roomdetails.impl.members.anAlice 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.api.core.UserId
import io.element.android.libraries.matrix.test.A_REASON import io.element.android.libraries.matrix.test.A_REASON
import io.element.android.libraries.ui.strings.CommonStrings 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) rule.clickOn(R.string.screen_room_member_list_manage_member_remove)
// Give time for the bottom sheet to animate // Give time for the bottom sheet to animate
rule.mainClock.advanceTimeBy(1_000) rule.mainClock.advanceTimeBy(1_000)
eventsRecorder.assertSingle(RoomMembersModerationEvents.KickUser(reason = "", needsConfirmation = true)) eventsRecorder.assertSingle(RoomMembersModerationEvents.KickUser)
} }
@Test @Test
@ -103,7 +104,7 @@ class RoomMembersModerationViewTest {
val roomMember = anAlice() val roomMember = anAlice()
val state = aRoomMembersModerationState( val state = aRoomMembersModerationState(
selectedRoomMember = roomMember, selectedRoomMember = roomMember,
kickUserAsyncAction = ConfirmingWithReason(A_REASON), kickUserAsyncAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder eventSink = eventsRecorder
) )
rule.setRoomMembersModerationView( rule.setRoomMembersModerationView(
@ -115,19 +116,21 @@ class RoomMembersModerationViewTest {
} }
@Test @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 eventsRecorder = EventsRecorder<RoomMembersModerationEvents>()
val roomMember = anAlice() val roomMember = anAlice()
val state = aRoomMembersModerationState( val state = aRoomMembersModerationState(
selectedRoomMember = roomMember, selectedRoomMember = roomMember,
kickUserAsyncAction = ConfirmingWithReason(A_REASON), kickUserAsyncAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder eventSink = eventsRecorder
) )
rule.setRoomMembersModerationView( rule.setRoomMembersModerationView(
state = state, state = state,
) )
rule.onNodeWithText(A_REASON).performTextInput("z") val reason = rule.activity.getString(CommonStrings.common_reason)
eventsRecorder.assertSingle(RoomMembersModerationEvents.KickUser(reason = "z$A_REASON", needsConfirmation = true)) 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 @Test
@ -136,7 +139,7 @@ class RoomMembersModerationViewTest {
val roomMember = anAlice() val roomMember = anAlice()
val state = aRoomMembersModerationState( val state = aRoomMembersModerationState(
selectedRoomMember = roomMember, selectedRoomMember = roomMember,
kickUserAsyncAction = ConfirmingWithReason(A_REASON), kickUserAsyncAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder eventSink = eventsRecorder
) )
rule.setRoomMembersModerationView( rule.setRoomMembersModerationView(
@ -144,7 +147,7 @@ class RoomMembersModerationViewTest {
) )
// Note: the string key semantics is not perfect here :/ // Note: the string key semantics is not perfect here :/
rule.clickOn(R.string.screen_room_member_list_kick_member_confirmation_action) 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") @Config(qualifiers = "h1024dp")
@ -168,7 +171,7 @@ class RoomMembersModerationViewTest {
rule.clickOn(R.string.screen_room_member_list_manage_member_remove_confirmation_ban) rule.clickOn(R.string.screen_room_member_list_manage_member_remove_confirmation_ban)
// Give time for the bottom sheet to animate // Give time for the bottom sheet to animate
rule.mainClock.advanceTimeBy(1_000) rule.mainClock.advanceTimeBy(1_000)
eventsRecorder.assertSingle(RoomMembersModerationEvents.BanUser(reason = "", needsConfirmation = true)) eventsRecorder.assertSingle(RoomMembersModerationEvents.BanUser)
} }
@Test @Test
@ -177,7 +180,7 @@ class RoomMembersModerationViewTest {
val roomMember = anAlice() val roomMember = anAlice()
val state = aRoomMembersModerationState( val state = aRoomMembersModerationState(
selectedRoomMember = roomMember, selectedRoomMember = roomMember,
banUserAsyncAction = ConfirmingWithReason(A_REASON), banUserAsyncAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder eventSink = eventsRecorder
) )
rule.setRoomMembersModerationView( rule.setRoomMembersModerationView(
@ -194,14 +197,16 @@ class RoomMembersModerationViewTest {
val roomMember = anAlice() val roomMember = anAlice()
val state = aRoomMembersModerationState( val state = aRoomMembersModerationState(
selectedRoomMember = roomMember, selectedRoomMember = roomMember,
banUserAsyncAction = ConfirmingWithReason(A_REASON), banUserAsyncAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder eventSink = eventsRecorder
) )
rule.setRoomMembersModerationView( rule.setRoomMembersModerationView(
state = state, state = state,
) )
rule.onNodeWithText(A_REASON).performTextInput("z") val reason = rule.activity.getString(CommonStrings.common_reason)
eventsRecorder.assertSingle(RoomMembersModerationEvents.BanUser(reason = "z$A_REASON", needsConfirmation = true)) 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 @Test
@ -210,7 +215,7 @@ class RoomMembersModerationViewTest {
val roomMember = anAlice() val roomMember = anAlice()
val state = aRoomMembersModerationState( val state = aRoomMembersModerationState(
selectedRoomMember = roomMember, selectedRoomMember = roomMember,
banUserAsyncAction = ConfirmingWithReason(A_REASON), banUserAsyncAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder eventSink = eventsRecorder
) )
rule.setRoomMembersModerationView( rule.setRoomMembersModerationView(
@ -218,7 +223,7 @@ class RoomMembersModerationViewTest {
) )
// Note: the string key semantics is not perfect here :/ // Note: the string key semantics is not perfect here :/
rule.clickOn(R.string.screen_room_member_list_ban_member_confirmation_action) 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 @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, modifier: Modifier = Modifier,
error: String? = null, error: String? = null,
maxLines: Int = 1, maxLines: Int = 1,
withBorder: Boolean = false,
label: String? = null,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default,
) { ) {
@ -79,12 +81,17 @@ fun TextFieldListItem(
value = text, value = text,
onValueChange = { onTextChange(it) }, onValueChange = { onTextChange(it) },
placeholder = placeholder?.let { @Composable { Text(it) } }, placeholder = placeholder?.let { @Composable { Text(it) } },
colors = OutlinedTextFieldDefaults.colors( label = label?.let { @Composable { Text(it) } },
colors = if (withBorder) {
OutlinedTextFieldDefaults.colors()
} else {
OutlinedTextFieldDefaults.colors(
disabledBorderColor = Color.Transparent, disabledBorderColor = Color.Transparent,
errorBorderColor = Color.Transparent, errorBorderColor = Color.Transparent,
focusedBorderColor = Color.Transparent, focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent, unfocusedBorderColor = Color.Transparent,
), )
},
isError = error != null, isError = error != null,
supportingText = error?.let { @Composable { Text(it) } }, supportingText = error?.let { @Composable { Text(it) } },
keyboardOptions = keyboardOptions, keyboardOptions = keyboardOptions,

View file

@ -7,24 +7,15 @@
package io.element.android.libraries.designsystem.components.preferences package io.element.android.libraries.designsystem.components.preferences
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import io.element.android.libraries.designsystem.components.dialogs.TextFieldDialog
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.list.ListItemContent 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.ListItem
import io.element.android.libraries.designsystem.theme.components.ListItemStyle import io.element.android.libraries.designsystem.theme.components.ListItemStyle
import io.element.android.libraries.designsystem.theme.components.Text 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", "TextComposerSimpleNotEncryptedPreview",
"TextComposerVoicePreview", "TextComposerVoicePreview",
"TextComposerVoiceNotEncryptedPreview", "TextComposerVoiceNotEncryptedPreview",
"TextFieldDialogWithBorderPreview",
"TextFieldDialogWithErrorPreview",
"TimelineImageWithCaptionRowPreview", "TimelineImageWithCaptionRowPreview",
"TimelineItemEventRowForDirectRoomPreview", "TimelineItemEventRowForDirectRoomPreview",
"TimelineItemEventRowShieldPreview", "TimelineItemEventRowShieldPreview",

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f83d60c7e1d963ccef24668c219bd9df36494dc8bd8f57af7aa63a3b73ee6882 oid sha256:96a867cb12498cbdc97957bee07855dfaa13602baddaf933aff2b666ef4c7650
size 7545 size 3642

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b323796ccdf27e5c445aa02e3f7b73f65df961dd88f3ee480efcba14d8038281
size 19426

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:96a867cb12498cbdc97957bee07855dfaa13602baddaf933aff2b666ef4c7650
size 3642

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:501ebaae1e09a4f7853b574b788ddb3a73cd470d37d5dbef1f46b2967761e612 oid sha256:2d06a58bb3b3fe7b01740cc04ed3128d24a13717be84cb4a70ced228d2256b41
size 31207 size 9435

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:2d06a58bb3b3fe7b01740cc04ed3128d24a13717be84cb4a70ced228d2256b41 oid sha256:39b34e0940f38ab0d66ba68b61c07ba70a51acfb54f995eedce800231ea6a2c5
size 9435 size 28257

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:39b34e0940f38ab0d66ba68b61c07ba70a51acfb54f995eedce800231ea6a2c5 oid sha256:571207325446195a58251e7ece1ecaabc8a782cbb526315abe2a82f21e6441eb
size 28257 size 9006

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:96e0f2e3c32eedf2c85e243661a7947d28cbfe2eee7df81b239c5331770e7f7c oid sha256:96a867cb12498cbdc97957bee07855dfaa13602baddaf933aff2b666ef4c7650
size 29835 size 3642

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:571207325446195a58251e7ece1ecaabc8a782cbb526315abe2a82f21e6441eb oid sha256:f83d60c7e1d963ccef24668c219bd9df36494dc8bd8f57af7aa63a3b73ee6882
size 9006 size 7545

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:96a867cb12498cbdc97957bee07855dfaa13602baddaf933aff2b666ef4c7650 oid sha256:b323796ccdf27e5c445aa02e3f7b73f65df961dd88f3ee480efcba14d8038281
size 3642 size 19426

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ba213b12d181b04695b9271670cb7d96ce83dfccc3c2031ee002ba1cf180cc8d oid sha256:5bb36ccd718f3fec5b04f1bc812dc7718b5ea7fa4619c8b031466297a8d016fd
size 6408 size 3659

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f9def02bcb5784681dc26327791510a9a25f0772644cdae363d7a4b90e69067c
size 17479

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5bb36ccd718f3fec5b04f1bc812dc7718b5ea7fa4619c8b031466297a8d016fd
size 3659

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:83e72d7332d7574dd2f4ea42b9fa18995359720b18105f08bfde1f7f14cf8725 oid sha256:273043114950e000953fbede780f3ad8adcce9d3d579edea2cad58f8e3c48881
size 29080 size 8175

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:273043114950e000953fbede780f3ad8adcce9d3d579edea2cad58f8e3c48881 oid sha256:19483b4fae4026098273e6e4c941592074f08b3e7f9fa44e759bdab9daf2b27a
size 8175 size 26233

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:19483b4fae4026098273e6e4c941592074f08b3e7f9fa44e759bdab9daf2b27a oid sha256:2b0d51e2c5b255fb2f94959734293d4b7807f002acb0217c84856f0002297293
size 26233 size 7636

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:0c36a1bfdfdf92ac180436b8c4aef3d017911119f10f02e2a940b5ab242ac324 oid sha256:5bb36ccd718f3fec5b04f1bc812dc7718b5ea7fa4619c8b031466297a8d016fd
size 27856 size 3659

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:2b0d51e2c5b255fb2f94959734293d4b7807f002acb0217c84856f0002297293 oid sha256:ba213b12d181b04695b9271670cb7d96ce83dfccc3c2031ee002ba1cf180cc8d
size 7636 size 6408

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:5bb36ccd718f3fec5b04f1bc812dc7718b5ea7fa4619c8b031466297a8d016fd oid sha256:f9def02bcb5784681dc26327791510a9a25f0772644cdae363d7a4b90e69067c
size 3659 size 17479

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1be0efd7f33ee57088b04aedf6c63cb8092abc0356a31636fe028e31d542e28d
size 15671

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e7609a1d51c1be29b0d117dd968bd44b250d02e82e1630fa062da4cd280bb950
size 13934

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c4697eda8d2fb160b2cade0be583148ec0b2e55f35fe44929f98feb4c8535377
size 17761

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2774d56a1e788567c19df9a714f1399968e75d2df6c3a4f404e3c03a30286567
size 15951

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3257be6487ff2f412564bb8f188860e39b0325156533ad519c9d6852000a173a
size 13119

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:baf396f36cdc0ca5e01842f70fe0b93166fa82c11ad981c54a529f59c90b5111
size 11469