Improve UI/UX for setting status

This commit is contained in:
ganfra
2026-06-17 15:55:49 +02:00
parent 553a4785e1
commit 1cfd3e4dfb
5 changed files with 91 additions and 37 deletions
@@ -69,6 +69,7 @@ class UserStatusPresenter(
return UserStatusState(
displayedStatus = userProfile.displayedStatus,
rawStatus = userProfile.rawStatus,
pickerState = pickerState,
eventSink = ::handleEvent,
)
@@ -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,
)
@@ -18,6 +18,11 @@ internal class UserStatusStateProvider : PreviewParameterProvider<UserStatusStat
aUserStatusState(displayedStatus = DisplayedStatus.UserSet(UserStatus("🌴", "Away"))),
aUserStatusState(displayedStatus = DisplayedStatus.InCall(callJoinedTs = 0L)),
aUserStatusState(pickerState = UserStatusPickerState.ShowingPicker),
aUserStatusState(
displayedStatus = DisplayedStatus.UserSet(UserStatus("🌴", "Away")),
rawStatus = UserStatus("🌴", "Away"),
pickerState = UserStatusPickerState.ShowingPicker,
),
aUserStatusState(pickerState = UserStatusPickerState.CustomInput(emoji = "😀", textFieldState = TextFieldState())),
aUserStatusState(pickerState = UserStatusPickerState.CustomInput(emoji = "🚀", textFieldState = TextFieldState("Working on something"))),
)
@@ -25,10 +30,12 @@ internal class UserStatusStateProvider : PreviewParameterProvider<UserStatusStat
fun aUserStatusState(
displayedStatus: DisplayedStatus? = null,
rawStatus: UserStatus? = null,
pickerState: UserStatusPickerState = UserStatusPickerState.Hidden,
eventSink: (UserStatusEvent) -> Unit = {},
) = UserStatusState(
displayedStatus = displayedStatus,
rawStatus = rawStatus,
pickerState = pickerState,
eventSink = eventSink,
)
@@ -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(
@@ -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()
}
}