Add kick (remove) confirmation and reason (#4507)

* Add confirmation dialog when kicking someone, ith ability to provide a reason.

Also add the reason for banning people.

* Fix padding issue in dialogs.

* Improve TextField in dialog.

* Update screenshots

* Fix tests

* Format and import

* Add missing UI tests.

* Use `needsConfirmation` as it's already used in the code base.

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Benoit Marty
2025-04-01 11:38:46 +02:00
committed by GitHub
parent 1eff15d6e4
commit 0545c56486
39 changed files with 367 additions and 91 deletions

View File

@@ -0,0 +1,14 @@
/*
* 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,8 @@ import io.element.android.libraries.matrix.api.room.RoomMember
sealed interface RoomMembersModerationEvents {
data class SelectRoomMember(val roomMember: RoomMember) : RoomMembersModerationEvents
data object KickUser : RoomMembersModerationEvents
data object BanUser : RoomMembersModerationEvents
data class KickUser(val reason: String, val needsConfirmation: Boolean) : RoomMembersModerationEvents
data class BanUser(val reason: String, val needsConfirmation: Boolean) : RoomMembersModerationEvents
data class UnbanUser(val userId: UserId) : RoomMembersModerationEvents
data object Reset : RoomMembersModerationEvents
}

View File

@@ -96,19 +96,23 @@ class RoomMembersModerationPresenter @Inject constructor(
}
}
is RoomMembersModerationEvents.KickUser -> {
selectedMember?.let {
coroutineScope.kickUser(it.userId, kickUserAsyncAction)
}
selectedMember = null
}
is RoomMembersModerationEvents.BanUser -> {
if (banUserAsyncAction.value.isConfirming()) {
if (event.needsConfirmation) {
kickUserAsyncAction.value = ConfirmingWithReason(event.reason)
} else {
selectedMember?.let {
coroutineScope.banUser(it.userId, banUserAsyncAction)
coroutineScope.kickUser(it.userId, event.reason, kickUserAsyncAction)
}
selectedMember = null
}
}
is RoomMembersModerationEvents.BanUser -> {
if (event.needsConfirmation) {
banUserAsyncAction.value = ConfirmingWithReason(event.reason)
} else {
banUserAsyncAction.value = AsyncAction.ConfirmingNoParams
selectedMember?.let {
coroutineScope.banUser(it.userId, event.reason, banUserAsyncAction)
}
selectedMember = null
}
}
is RoomMembersModerationEvents.UnbanUser -> {
@@ -138,18 +142,26 @@ class RoomMembersModerationPresenter @Inject constructor(
private fun CoroutineScope.kickUser(
userId: UserId,
reason: String,
kickUserAction: MutableState<AsyncAction<Unit>>,
) = runActionAndWaitForMembershipChange(kickUserAction) {
analyticsService.capture(RoomModeration(RoomModeration.Action.KickMember))
room.kickUser(userId)
room.kickUser(
userId = userId,
reason = reason.takeIf { it.isNotBlank() },
)
}
private fun CoroutineScope.banUser(
userId: UserId,
reason: String,
banUserAction: MutableState<AsyncAction<Unit>>,
) = runActionAndWaitForMembershipChange(banUserAction) {
analyticsService.capture(RoomModeration(RoomModeration.Action.BanMember))
room.banUser(userId)
room.banUser(
userId = userId,
reason = reason.takeIf { it.isNotBlank() },
)
}
private fun CoroutineScope.unbanUser(

View File

@@ -37,10 +37,26 @@ class RoomMembersModerationStateProvider : PreviewParameterProvider<RoomMembersM
ModerationAction.BanUser(userId = anAlice().userId),
),
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
kickUserAsyncAction = ConfirmingWithReason(""),
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
kickUserAsyncAction = ConfirmingWithReason("A reason"),
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
kickUserAsyncAction = AsyncAction.Loading,
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
banUserAsyncAction = ConfirmingWithReason(""),
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
banUserAsyncAction = ConfirmingWithReason("A reason"),
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
banUserAsyncAction = AsyncAction.Loading,
@@ -54,10 +70,6 @@ class RoomMembersModerationStateProvider : PreviewParameterProvider<RoomMembersM
banUserAsyncAction = AsyncAction.Failure(Exception("Failed to ban user")),
unbanUserAsyncAction = AsyncAction.Failure(Exception("Failed to unban user")),
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
banUserAsyncAction = AsyncAction.ConfirmingNoParams,
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
unbanUserAsyncAction = ConfirmingRoomMemberAction(anAlice()),

View File

@@ -38,7 +38,9 @@ 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.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
@@ -72,10 +74,10 @@ fun RoomMembersModerationView(
onDisplayMemberProfile(action.userId)
}
is ModerationAction.KickUser -> {
state.eventSink(RoomMembersModerationEvents.KickUser)
state.eventSink(RoomMembersModerationEvents.KickUser(reason = "", needsConfirmation = true))
}
is ModerationAction.BanUser -> {
state.eventSink(RoomMembersModerationEvents.BanUser)
state.eventSink(RoomMembersModerationEvents.BanUser(reason = "", needsConfirmation = true))
}
}
},
@@ -87,6 +89,47 @@ fun RoomMembersModerationView(
AsyncIndicatorHost(modifier = Modifier.statusBarsPadding(), state = asyncIndicatorState)
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,
)
)
},
)
}
}
}
}
is AsyncAction.Loading -> {
LaunchedEffect(action) {
val userDisplayName = state.selectedRoomMember?.getBestName().orEmpty()
@@ -113,13 +156,45 @@ fun RoomMembersModerationView(
when (val action = state.banUserAsyncAction) {
is AsyncAction.Confirming -> {
ConfirmationDialog(
title = stringResource(R.string.screen_room_member_list_ban_member_confirmation_title),
content = stringResource(R.string.screen_room_member_list_ban_member_confirmation_description),
submitText = stringResource(R.string.screen_room_member_list_ban_member_confirmation_action),
onSubmitClick = { state.eventSink(RoomMembersModerationEvents.BanUser) },
onDismiss = { state.eventSink(RoomMembersModerationEvents.Reset) }
)
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,
)
)
},
)
}
}
}
}
is AsyncAction.Loading -> {
LaunchedEffect(action) {

View File

@@ -75,6 +75,9 @@
<item quantity="one">"%1$d person"</item>
<item quantity="other">"%1$d people"</item>
</plurals>
<string name="screen_room_member_list_kick_member_confirmation_action">"Remove"</string>
<string name="screen_room_member_list_kick_member_confirmation_description">"They will be able to join this room again if invited."</string>
<string name="screen_room_member_list_kick_member_confirmation_title">"Are you sure you want to remove this member?"</string>
<string name="screen_room_member_list_manage_member_ban">"Remove and ban member"</string>
<string name="screen_room_member_list_manage_member_remove">"Remove from room"</string>
<string name="screen_room_member_list_manage_member_remove_confirmation_ban">"Remove and ban member"</string>

View File

@@ -17,14 +17,18 @@ import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.roomdetails.impl.members.aVictor
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.test.A_REASON
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf
@@ -153,13 +157,14 @@ class RoomMembersModerationPresenterTest {
}
@Test
fun `present - Kick removes the user`() = runTest {
fun `present - Kick requires confirmation and then kicks the user`() = runTest {
val analyticsService = FakeAnalyticsService()
val kickUserResult = lambdaRecorder<UserId, String?, Result<Unit>> { _, _ -> Result.success(Unit) }
val room = aMatrixRoom(
canKickResult = { Result.success(true) },
canBanResult = { Result.success(true) },
userRoleResult = { Result.success(RoomMember.Role.ADMIN) },
kickUserResult = { _, _ -> Result.success(Unit) },
kickUserResult = kickUserResult,
)
val selectedMember = aVictor()
val presenter = createRoomMembersModerationPresenter(matrixRoom = room, analyticsService = analyticsService)
@@ -168,7 +173,15 @@ class RoomMembersModerationPresenterTest {
}.test {
skipItems(1)
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(selectedMember))
awaitItem().eventSink(RoomMembersModerationEvents.KickUser)
awaitItem().eventSink(RoomMembersModerationEvents.KickUser(reason = "", needsConfirmation = true))
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))
// Confirm
confirmingState.eventSink(RoomMembersModerationEvents.KickUser(reason = A_REASON, needsConfirmation = false))
skipItems(1)
val loadingState = awaitItem()
assertThat(loadingState.actions).isEmpty()
@@ -178,17 +191,22 @@ class RoomMembersModerationPresenterTest {
assertThat(selectedRoomMember).isNull()
}
assertThat(analyticsService.capturedEvents.last()).isEqualTo(RoomModeration(RoomModeration.Action.KickMember))
kickUserResult.assertions().isCalledOnce().with(
value(selectedMember.userId),
value(A_REASON),
)
}
}
@Test
fun `present - BanUser requires confirmation and then bans the user`() = runTest {
val analyticsService = FakeAnalyticsService()
val banUserResult = lambdaRecorder<UserId, String?, Result<Unit>> { _, _ -> Result.success(Unit) }
val room = aMatrixRoom(
canKickResult = { Result.success(true) },
canBanResult = { Result.success(true) },
userRoleResult = { Result.success(RoomMember.Role.ADMIN) },
banUserResult = { _, _ -> Result.success(Unit) },
banUserResult = banUserResult,
)
val selectedMember = aVictor()
val presenter = createRoomMembersModerationPresenter(matrixRoom = room, analyticsService = analyticsService)
@@ -197,12 +215,15 @@ class RoomMembersModerationPresenterTest {
}.test {
skipItems(1)
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(selectedMember))
awaitItem().eventSink(RoomMembersModerationEvents.BanUser)
awaitItem().eventSink(RoomMembersModerationEvents.BanUser(reason = "", needsConfirmation = true))
val confirmingState = awaitItem()
assertThat(confirmingState.banUserAsyncAction).isEqualTo(AsyncAction.ConfirmingNoParams)
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))
// Confirm
confirmingState.eventSink(RoomMembersModerationEvents.BanUser)
confirmingState.eventSink(RoomMembersModerationEvents.BanUser(reason = A_REASON, needsConfirmation = false))
skipItems(1)
val loadingItem = awaitItem()
assertThat(loadingItem.actions).isEmpty()
@@ -213,6 +234,10 @@ class RoomMembersModerationPresenterTest {
assertThat(selectedRoomMember).isNull()
}
assertThat(analyticsService.capturedEvents.last()).isEqualTo(RoomModeration(RoomModeration.Action.BanMember))
banUserResult.assertions().isCalledOnce().with(
value(selectedMember.userId),
value(A_REASON),
)
}
}
@@ -289,7 +314,7 @@ class RoomMembersModerationPresenterTest {
val initialItem = awaitItem()
// Kick user and fail
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(aVictor()))
awaitItem().eventSink(RoomMembersModerationEvents.KickUser)
awaitItem().eventSink(RoomMembersModerationEvents.KickUser(reason = "", needsConfirmation = false))
skipItems(1)
assertThat(awaitItem().kickUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java)
assertThat(awaitItem().kickUserAsyncAction).isInstanceOf(AsyncAction.Failure::class.java)
@@ -299,8 +324,7 @@ class RoomMembersModerationPresenterTest {
// Ban user and fail
initialItem.eventSink(RoomMembersModerationEvents.SelectRoomMember(aVictor()))
awaitItem().eventSink(RoomMembersModerationEvents.BanUser)
awaitItem().eventSink(RoomMembersModerationEvents.BanUser)
awaitItem().eventSink(RoomMembersModerationEvents.BanUser(reason = "", needsConfirmation = false))
skipItems(1)
assertThat(awaitItem().banUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java)
assertThat(awaitItem().banUserAsyncAction).isInstanceOf(AsyncAction.Failure::class.java)

View File

@@ -10,11 +10,13 @@ package io.element.android.features.roomdetails.impl.members.moderation
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
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
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
@@ -92,7 +94,57 @@ 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)
eventsRecorder.assertSingle(RoomMembersModerationEvents.KickUser(reason = "", needsConfirmation = true))
}
@Test
fun `cancelling 'Remove member' confirmation emits the expected event`() {
val eventsRecorder = EventsRecorder<RoomMembersModerationEvents>()
val roomMember = anAlice()
val state = aRoomMembersModerationState(
selectedRoomMember = roomMember,
kickUserAsyncAction = ConfirmingWithReason(A_REASON),
eventSink = eventsRecorder
)
rule.setRoomMembersModerationView(
state = state,
)
// Note: the string key semantics is not perfect here :/
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(RoomMembersModerationEvents.Reset)
}
@Test
fun `confirming 'Remove member' reason edition emits the expected event`() {
val eventsRecorder = EventsRecorder<RoomMembersModerationEvents>()
val roomMember = anAlice()
val state = aRoomMembersModerationState(
selectedRoomMember = roomMember,
kickUserAsyncAction = ConfirmingWithReason(A_REASON),
eventSink = eventsRecorder
)
rule.setRoomMembersModerationView(
state = state,
)
rule.onNodeWithText(A_REASON).performTextInput("z")
eventsRecorder.assertSingle(RoomMembersModerationEvents.KickUser(reason = "z$A_REASON", needsConfirmation = true))
}
@Test
fun `confirming 'Remove member' confirmation emits the expected event`() {
val eventsRecorder = EventsRecorder<RoomMembersModerationEvents>()
val roomMember = anAlice()
val state = aRoomMembersModerationState(
selectedRoomMember = roomMember,
kickUserAsyncAction = ConfirmingWithReason(A_REASON),
eventSink = eventsRecorder
)
rule.setRoomMembersModerationView(
state = state,
)
// 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))
}
@Config(qualifiers = "h1024dp")
@@ -116,7 +168,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)
eventsRecorder.assertSingle(RoomMembersModerationEvents.BanUser(reason = "", needsConfirmation = true))
}
@Test
@@ -125,7 +177,7 @@ class RoomMembersModerationViewTest {
val roomMember = anAlice()
val state = aRoomMembersModerationState(
selectedRoomMember = roomMember,
banUserAsyncAction = AsyncAction.ConfirmingNoParams,
banUserAsyncAction = ConfirmingWithReason(A_REASON),
eventSink = eventsRecorder
)
rule.setRoomMembersModerationView(
@@ -136,13 +188,29 @@ class RoomMembersModerationViewTest {
eventsRecorder.assertSingle(RoomMembersModerationEvents.Reset)
}
@Test
fun `confirming 'Remove and ban member' reason edition emits the expected event`() {
val eventsRecorder = EventsRecorder<RoomMembersModerationEvents>()
val roomMember = anAlice()
val state = aRoomMembersModerationState(
selectedRoomMember = roomMember,
banUserAsyncAction = ConfirmingWithReason(A_REASON),
eventSink = eventsRecorder
)
rule.setRoomMembersModerationView(
state = state,
)
rule.onNodeWithText(A_REASON).performTextInput("z")
eventsRecorder.assertSingle(RoomMembersModerationEvents.BanUser(reason = "z$A_REASON", needsConfirmation = true))
}
@Test
fun `confirming 'Remove and ban member' confirmation emits the expected event`() {
val eventsRecorder = EventsRecorder<RoomMembersModerationEvents>()
val roomMember = anAlice()
val state = aRoomMembersModerationState(
selectedRoomMember = roomMember,
banUserAsyncAction = AsyncAction.ConfirmingNoParams,
banUserAsyncAction = ConfirmingWithReason(A_REASON),
eventSink = eventsRecorder
)
rule.setRoomMembersModerationView(
@@ -150,7 +218,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)
eventsRecorder.assertSingle(RoomMembersModerationEvents.BanUser(reason = A_REASON, needsConfirmation = false))
}
@Test

View File

@@ -38,6 +38,7 @@ fun ListDialog(
cancelText: String = stringResource(CommonStrings.action_cancel),
submitText: String = stringResource(CommonStrings.action_ok),
enabled: Boolean = true,
applyPaddingToContents: Boolean = false,
listItems: LazyListScope.() -> Unit,
) {
val decoratedSubtitle: @Composable (() -> Unit)? = subtitle?.let {
@@ -61,6 +62,7 @@ fun ListDialog(
onSubmitClick = onSubmit,
enabled = enabled,
listItems = listItems,
applyPaddingToContents = applyPaddingToContents,
)
}
}
@@ -72,8 +74,9 @@ private fun ListDialogContent(
onSubmitClick: () -> Unit,
cancelText: String,
submitText: String,
title: String? = null,
enabled: Boolean = true,
title: String?,
enabled: Boolean,
applyPaddingToContents: Boolean,
subtitle: @Composable (() -> Unit)? = null,
) {
SimpleAlertDialogContent(
@@ -84,10 +87,12 @@ private fun ListDialogContent(
onCancelClick = onDismissRequest,
onSubmitClick = onSubmitClick,
enabled = enabled,
applyPaddingToContents = false,
applyPaddingToContents = applyPaddingToContents,
) {
// No start padding if padding is already applied to the content
val horizontalPadding = if (applyPaddingToContents) 0.dp else 8.dp
LazyColumn(
modifier = Modifier.padding(start = 8.dp)
modifier = Modifier.padding(horizontal = horizontalPadding)
) { listItems() }
}
}
@@ -111,6 +116,8 @@ internal fun ListDialogContentPreview() {
onSubmitClick = {},
cancelText = "Cancel",
submitText = "Save",
enabled = true,
applyPaddingToContents = false,
)
}
}

View File

@@ -29,6 +29,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,
) {
@@ -38,12 +40,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,
@@ -124,3 +131,31 @@ internal fun TextFieldListItemTextFieldValuePreview() {
)
}
}
@Preview("Text field List item with border - empty", group = PreviewGroup.ListItems)
@Composable
internal fun TextFieldListItemWithBorderEmptyPreview() {
ElementThemedPreview {
TextFieldListItem(
placeholder = "Placeholder",
label = "Label",
text = "",
withBorder = true,
onTextChange = {},
)
}
}
@Preview("Text field List item with border - text", group = PreviewGroup.ListItems)
@Composable
internal fun TextFieldListItemWithBorderPreview() {
ElementThemedPreview {
TextFieldListItem(
placeholder = "Placeholder",
label = "Label",
text = "Text",
withBorder = true,
onTextChange = {},
)
}
}

View File

@@ -63,6 +63,7 @@ const val A_MESSAGE = "Hello world!"
const val A_REPLY = "OK, I'll be there!"
const val ANOTHER_MESSAGE = "Hello universe!"
const val A_CAPTION = "A media caption"
const val A_REASON = "A reason"
const val A_REDACTION_REASON = "A redaction reason"

View File

@@ -228,6 +228,7 @@ Reason: %1$s."</string>
<string name="common_privacy_policy">"Privacy policy"</string>
<string name="common_reaction">"Reaction"</string>
<string name="common_reactions">"Reactions"</string>
<string name="common_reason">"Reason"</string>
<string name="common_recovery_key">"Recovery key"</string>
<string name="common_refreshing">"Refreshing…"</string>
<string name="common_replying_to">"Replying to %1$s"</string>