From 1cfd3e4dfb64f2d479098b70f8d06f342a62cb55 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 17 Jun 2026 15:55:49 +0200 Subject: [PATCH] Improve UI/UX for setting status --- .../impl/userstatus/UserStatusPresenter.kt | 1 + .../impl/userstatus/UserStatusState.kt | 2 + .../userstatus/UserStatusStateProvider.kt | 7 ++ .../impl/userstatus/UserStatusView.kt | 109 ++++++++++++------ .../impl/root/PreferencesRootViewTest.kt | 9 +- 5 files changed, 91 insertions(+), 37 deletions(-) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt index 384e09862a..e5c22fd568 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt @@ -69,6 +69,7 @@ class UserStatusPresenter( return UserStatusState( displayedStatus = userProfile.displayedStatus, + rawStatus = userProfile.rawStatus, pickerState = pickerState, eventSink = ::handleEvent, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt index f79ae86d8b..7cfa03fa81 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt @@ -11,9 +11,11 @@ import androidx.annotation.StringRes import androidx.compose.foundation.text.input.TextFieldState import io.element.android.features.preferences.impl.R import io.element.android.libraries.matrix.api.user.DisplayedStatus +import io.element.android.libraries.matrix.api.user.UserStatus data class UserStatusState( val displayedStatus: DisplayedStatus?, + val rawStatus: UserStatus?, val pickerState: UserStatusPickerState, val eventSink: (UserStatusEvent) -> Unit, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusStateProvider.kt index 7a7a1d4dac..087cb85355 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusStateProvider.kt @@ -18,6 +18,11 @@ internal class UserStatusStateProvider : PreviewParameterProvider Unit = {}, ) = UserStatusState( displayedStatus = displayedStatus, + rawStatus = rawStatus, pickerState = pickerState, eventSink = eventSink, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt index e211fb1110..1b0b8a478d 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt @@ -12,8 +12,6 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.visible -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.InputTransformation @@ -23,16 +21,22 @@ import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.maxLength import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.layout.Layout import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme @@ -46,6 +50,7 @@ import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet +import io.element.android.libraries.designsystem.theme.components.hide import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TextButton @@ -77,6 +82,7 @@ fun UserStatusRow( } if (pickerState == UserStatusPickerState.ShowingPicker) { UserStatusPickerBottomSheet( + currentRawStatus = state.rawStatus, onDismiss = { state.eventSink(UserStatusEvent.DismissPicker) }, onSelectPredefined = { status -> state.eventSink(UserStatusEvent.SetStatus(status)) }, onSelectCustom = { state.eventSink(UserStatusEvent.OpenCustomInput) }, @@ -87,6 +93,7 @@ fun UserStatusRow( CustomStatusInputRow( emoji = pickerState.emoji, textFieldState = pickerState.textFieldState, + rawStatus = state.rawStatus, onEmojiChange = { state.eventSink(UserStatusEvent.UpdateCustomEmoji(it)) }, onConfirm = { state.eventSink( @@ -134,7 +141,7 @@ private fun CurrentStatusRow( ListItem( headlineContent = { Text(text = text, modifier = Modifier.padding(vertical = 16.dp)) }, leadingContent = ListItemContent.Custom({ - Text(text = emoji, modifier = Modifier.size(24.dp)) + Text(text = emoji, modifier = Modifier.size(24.dp), textAlign = TextAlign.Center) }), trailingContent = ListItemContent.Custom({ IconButton(onClick = onClear) { @@ -149,19 +156,38 @@ private fun CurrentStatusRow( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun UserStatusPickerBottomSheet( + currentRawStatus: UserStatus?, onDismiss: () -> Unit, onSelectPredefined: (UserStatus) -> Unit, onSelectCustom: () -> Unit, modifier: Modifier = Modifier, ) { - ModalBottomSheet(onDismissRequest = onDismiss, scrollable = true, modifier = modifier) { + val sheetState = rememberModalBottomSheetState() + val coroutineScope = rememberCoroutineScope() + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + scrollable = true, + modifier = modifier, + ) { PredefinedUserStatus.entries.forEach { predefined -> val label = stringResource(predefined.labelRes) + val predefinedUserStatus = UserStatus(emoji = predefined.emoji, text = label) + val isSelected = currentRawStatus == predefinedUserStatus ListItem( headlineContent = { Text(text = label) }, leadingContent = ListItemContent.Text(text = predefined.emoji), + trailingContent = if (isSelected) ListItemContent.Custom({ + Icon( + imageVector = CompoundIcons.Check(), + contentDescription = null, + tint = ElementTheme.colors.iconAccentPrimary, + ) + }) else null, onClick = { - onSelectPredefined(UserStatus(emoji = predefined.emoji, text = label)) + sheetState.hide(coroutineScope) { + onSelectPredefined(predefinedUserStatus) + } }, ) } @@ -170,7 +196,11 @@ private fun UserStatusPickerBottomSheet( Text(text = stringResource(R.string.common_user_status_custom)) }, leadingContent = ListItemContent.Text(text = "✏️"), - onClick = onSelectCustom, + onClick = { + sheetState.hide(coroutineScope) { + onSelectCustom() + } + }, ) } } @@ -179,52 +209,63 @@ private fun UserStatusPickerBottomSheet( private fun CustomStatusInputRow( emoji: String, textFieldState: TextFieldState, + rawStatus: UserStatus?, onEmojiChange: (String) -> Unit, onConfirm: () -> Unit, onCancel: () -> Unit, modifier: Modifier = Modifier, ) { val hasText by remember { derivedStateOf { textFieldState.text.isNotEmpty() } } + val hasChanges by remember(emoji, rawStatus) { + derivedStateOf { + val text = textFieldState.text.toString() + text.isNotBlank() && UserStatus(emoji, text) != rawStatus + } + } val saveLabel = stringResource(CommonStrings.action_save) val cancelLabel = stringResource(CommonStrings.action_cancel) - val textStyle = ElementTheme.typography.fontBodyLgMedium - val density = LocalDensity.current - val textMeasurer = rememberTextMeasurer() - // Measure both labels and derive a stable min-width so the button never resizes. - // 32.dp = 16.dp start + 16.dp end padding of TextButton (Large, no icon). - val actionButtonWidth = remember(textMeasurer, textStyle, saveLabel, cancelLabel) { - val saveWidth = textMeasurer.measure(saveLabel, textStyle).size.width - val cancelWidth = textMeasurer.measure(cancelLabel, textStyle).size.width - with(density) { maxOf(saveWidth, cancelWidth).toDp() } + 32.dp - } + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.requestFocus() } ListItem( headlineContent = { TextField( + modifier = Modifier.focusRequester(focusRequester), state = textFieldState, placeholder = stringResource(R.string.screen_preferences_user_status_custom_hint), inputTransformation = InputTransformation.maxLength(maxLength = 30), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - onKeyboardAction = { if (hasText) onConfirm() }, + onKeyboardAction = { if (hasChanges) onConfirm() }, lineLimits = TextFieldLineLimits.SingleLine, - trailingIcon = { - Box(modifier = Modifier.clickable(enabled = hasText) { - textFieldState.clearText() - }) { - Icon( - imageVector = CompoundIcons.Close(), - contentDescription = stringResource(CommonStrings.action_cancel), - modifier = Modifier.visible(hasText) - ) + trailingIcon = if (hasText) { + { + Box(modifier = Modifier.clickable { textFieldState.clearText() }) { + Icon( + imageVector = CompoundIcons.Close(), + contentDescription = stringResource(CommonStrings.action_cancel), + modifier = Modifier.size(22.dp), + ) + } } - } + } else null ) }, trailingContent = ListItemContent.Custom({ - TextButton( - onClick = if (hasText) onConfirm else onCancel, - text = if (hasText) saveLabel else cancelLabel, - modifier = Modifier.widthIn(min = actionButtonWidth), - ) + // Layout measures both buttons to determine the max natural width, + // then places only the active one — no hardcoded padding values needed. + Layout( + content = { + TextButton(onClick = onConfirm, text = saveLabel) + TextButton(onClick = onCancel, text = cancelLabel) + } + ) { measurables, constraints -> + val placeables = measurables.map { it.measure(Constraints()) } + val maxWidth = placeables.maxOf { it.width } + val maxHeight = placeables.maxOf { it.height } + layout(maxWidth, maxHeight) { + if (hasChanges) placeables[0].placeRelative(0, 0) + else placeables[1].placeRelative(0, 0) + } + } }), leadingContent = ListItemContent.Custom({ Surface( diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt index 5c9d81875e..bf8bd78bf2 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt @@ -202,7 +202,8 @@ class PreferencesRootViewTest : RobolectricTest() { ), onOpenAnalytics = callback, ) - clickOn(CommonStrings.common_analytics) + val text = activity!!.getString(CommonStrings.common_analytics) + onNode(hasText(text) and hasClickAction()).performScrollTo().performClick() } } @@ -229,7 +230,8 @@ class PreferencesRootViewTest : RobolectricTest() { ), onOpenRageShake = callback, ) - clickOn(CommonStrings.common_report_a_problem) + val text = activity!!.getString(CommonStrings.common_report_a_problem) + onNode(hasText(text) and hasClickAction()).performScrollTo().performClick() } } @@ -393,7 +395,8 @@ class PreferencesRootViewTest : RobolectricTest() { ), onSignOutClick = callback, ) - clickOn(CommonStrings.action_signout) + val text = activity!!.getString(CommonStrings.action_signout) + onNode(hasText(text) and hasClickAction()).performScrollTo().performClick() } }