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 5c2a069c95
commit 1fdb590ece
35 changed files with 291 additions and 264 deletions
@@ -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
@@ -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
} }
@@ -96,24 +96,22 @@ 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
@@ -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(),
@@ -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 = { reason ->
onSubmit = { state.eventSink(RoomMembersModerationEvents.DoKickUser(reason = reason))
state.eventSink( },
RoomMembersModerationEvents.KickUser( onDismissRequest = { state.eventSink(RoomMembersModerationEvents.Reset) },
reason = action.reason, placeholder = stringResource(id = CommonStrings.common_reason),
needsConfirmation = false, label = stringResource(id = CommonStrings.common_reason),
) withBorder = true,
) content = stringResource(R.string.screen_room_member_list_kick_member_confirmation_description),
}, value = "",
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,
)
)
},
)
}
}
}
} }
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 = { reason ->
onSubmit = { state.eventSink(RoomMembersModerationEvents.DoBanUser(reason = reason))
state.eventSink( },
RoomMembersModerationEvents.BanUser( onDismissRequest = { state.eventSink(RoomMembersModerationEvents.Reset) },
reason = action.reason, placeholder = stringResource(id = CommonStrings.common_reason),
needsConfirmation = false, label = stringResource(id = CommonStrings.common_reason),
) withBorder = true,
) content = stringResource(R.string.screen_room_member_list_ban_member_confirmation_description),
}, value = "",
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,
)
)
},
)
}
}
}
} }
is AsyncAction.Loading -> { is AsyncAction.Loading -> {
LaunchedEffect(action) { LaunchedEffect(action) {
@@ -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)
@@ -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
@@ -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",
)
}
@@ -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) } },
disabledBorderColor = Color.Transparent, colors = if (withBorder) {
errorBorderColor = Color.Transparent, OutlinedTextFieldDefaults.colors()
focusedBorderColor = Color.Transparent, } else {
unfocusedBorderColor = Color.Transparent, OutlinedTextFieldDefaults.colors(
), disabledBorderColor = Color.Transparent,
errorBorderColor = Color.Transparent,
focusedBorderColor = 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,
@@ -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()
}
}
}
@@ -120,6 +120,8 @@ class KonsistPreviewTest {
"TextComposerSimpleNotEncryptedPreview", "TextComposerSimpleNotEncryptedPreview",
"TextComposerVoicePreview", "TextComposerVoicePreview",
"TextComposerVoiceNotEncryptedPreview", "TextComposerVoiceNotEncryptedPreview",
"TextFieldDialogWithBorderPreview",
"TextFieldDialogWithErrorPreview",
"TimelineImageWithCaptionRowPreview", "TimelineImageWithCaptionRowPreview",
"TimelineItemEventRowForDirectRoomPreview", "TimelineItemEventRowForDirectRoomPreview",
"TimelineItemEventRowShieldPreview", "TimelineItemEventRowShieldPreview",
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b323796ccdf27e5c445aa02e3f7b73f65df961dd88f3ee480efcba14d8038281
size 19426
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f9def02bcb5784681dc26327791510a9a25f0772644cdae363d7a4b90e69067c
size 17479
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1be0efd7f33ee57088b04aedf6c63cb8092abc0356a31636fe028e31d542e28d
size 15671
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e7609a1d51c1be29b0d117dd968bd44b250d02e82e1630fa062da4cd280bb950
size 13934
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c4697eda8d2fb160b2cade0be583148ec0b2e55f35fe44929f98feb4c8535377
size 17761
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2774d56a1e788567c19df9a714f1399968e75d2df6c3a4f404e3c03a30286567
size 15951
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3257be6487ff2f412564bb8f188860e39b0325156533ad519c9d6852000a173a
size 13119
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:baf396f36cdc0ca5e01842f70fe0b93166fa82c11ad981c54a529f59c90b5111
size 11469