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( return UserStatusState(
displayedStatus = userProfile.displayedStatus, displayedStatus = userProfile.displayedStatus,
rawStatus = userProfile.rawStatus,
pickerState = pickerState, pickerState = pickerState,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
@@ -11,9 +11,11 @@ import androidx.annotation.StringRes
import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.TextFieldState
import io.element.android.features.preferences.impl.R 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.DisplayedStatus
import io.element.android.libraries.matrix.api.user.UserStatus
data class UserStatusState( data class UserStatusState(
val displayedStatus: DisplayedStatus?, val displayedStatus: DisplayedStatus?,
val rawStatus: UserStatus?,
val pickerState: UserStatusPickerState, val pickerState: UserStatusPickerState,
val eventSink: (UserStatusEvent) -> Unit, val eventSink: (UserStatusEvent) -> Unit,
) )
@@ -18,6 +18,11 @@ internal class UserStatusStateProvider : PreviewParameterProvider<UserStatusStat
aUserStatusState(displayedStatus = DisplayedStatus.UserSet(UserStatus("🌴", "Away"))), aUserStatusState(displayedStatus = DisplayedStatus.UserSet(UserStatus("🌴", "Away"))),
aUserStatusState(displayedStatus = DisplayedStatus.InCall(callJoinedTs = 0L)), aUserStatusState(displayedStatus = DisplayedStatus.InCall(callJoinedTs = 0L)),
aUserStatusState(pickerState = UserStatusPickerState.ShowingPicker), 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())),
aUserStatusState(pickerState = UserStatusPickerState.CustomInput(emoji = "🚀", textFieldState = TextFieldState("Working on something"))), aUserStatusState(pickerState = UserStatusPickerState.CustomInput(emoji = "🚀", textFieldState = TextFieldState("Working on something"))),
) )
@@ -25,10 +30,12 @@ internal class UserStatusStateProvider : PreviewParameterProvider<UserStatusStat
fun aUserStatusState( fun aUserStatusState(
displayedStatus: DisplayedStatus? = null, displayedStatus: DisplayedStatus? = null,
rawStatus: UserStatus? = null,
pickerState: UserStatusPickerState = UserStatusPickerState.Hidden, pickerState: UserStatusPickerState = UserStatusPickerState.Hidden,
eventSink: (UserStatusEvent) -> Unit = {}, eventSink: (UserStatusEvent) -> Unit = {},
) = UserStatusState( ) = UserStatusState(
displayedStatus = displayedStatus, displayedStatus = displayedStatus,
rawStatus = rawStatus,
pickerState = pickerState, pickerState = pickerState,
eventSink = eventSink, eventSink = eventSink,
) )
@@ -12,8 +12,6 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.InputTransformation 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.foundation.text.input.maxLength
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember 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.Alignment
import androidx.compose.ui.Modifier 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.res.stringResource
import androidx.compose.ui.text.input.ImeAction 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.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme 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.IconSource
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.ModalBottomSheet 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.Surface
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
@@ -77,6 +82,7 @@ fun UserStatusRow(
} }
if (pickerState == UserStatusPickerState.ShowingPicker) { if (pickerState == UserStatusPickerState.ShowingPicker) {
UserStatusPickerBottomSheet( UserStatusPickerBottomSheet(
currentRawStatus = state.rawStatus,
onDismiss = { state.eventSink(UserStatusEvent.DismissPicker) }, onDismiss = { state.eventSink(UserStatusEvent.DismissPicker) },
onSelectPredefined = { status -> state.eventSink(UserStatusEvent.SetStatus(status)) }, onSelectPredefined = { status -> state.eventSink(UserStatusEvent.SetStatus(status)) },
onSelectCustom = { state.eventSink(UserStatusEvent.OpenCustomInput) }, onSelectCustom = { state.eventSink(UserStatusEvent.OpenCustomInput) },
@@ -87,6 +93,7 @@ fun UserStatusRow(
CustomStatusInputRow( CustomStatusInputRow(
emoji = pickerState.emoji, emoji = pickerState.emoji,
textFieldState = pickerState.textFieldState, textFieldState = pickerState.textFieldState,
rawStatus = state.rawStatus,
onEmojiChange = { state.eventSink(UserStatusEvent.UpdateCustomEmoji(it)) }, onEmojiChange = { state.eventSink(UserStatusEvent.UpdateCustomEmoji(it)) },
onConfirm = { onConfirm = {
state.eventSink( state.eventSink(
@@ -134,7 +141,7 @@ private fun CurrentStatusRow(
ListItem( ListItem(
headlineContent = { Text(text = text, modifier = Modifier.padding(vertical = 16.dp)) }, headlineContent = { Text(text = text, modifier = Modifier.padding(vertical = 16.dp)) },
leadingContent = ListItemContent.Custom({ 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({ trailingContent = ListItemContent.Custom({
IconButton(onClick = onClear) { IconButton(onClick = onClear) {
@@ -149,19 +156,38 @@ private fun CurrentStatusRow(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun UserStatusPickerBottomSheet( private fun UserStatusPickerBottomSheet(
currentRawStatus: UserStatus?,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSelectPredefined: (UserStatus) -> Unit, onSelectPredefined: (UserStatus) -> Unit,
onSelectCustom: () -> Unit, onSelectCustom: () -> Unit,
modifier: Modifier = Modifier, 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 -> PredefinedUserStatus.entries.forEach { predefined ->
val label = stringResource(predefined.labelRes) val label = stringResource(predefined.labelRes)
val predefinedUserStatus = UserStatus(emoji = predefined.emoji, text = label)
val isSelected = currentRawStatus == predefinedUserStatus
ListItem( ListItem(
headlineContent = { Text(text = label) }, headlineContent = { Text(text = label) },
leadingContent = ListItemContent.Text(text = predefined.emoji), leadingContent = ListItemContent.Text(text = predefined.emoji),
trailingContent = if (isSelected) ListItemContent.Custom({
Icon(
imageVector = CompoundIcons.Check(),
contentDescription = null,
tint = ElementTheme.colors.iconAccentPrimary,
)
}) else null,
onClick = { 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)) Text(text = stringResource(R.string.common_user_status_custom))
}, },
leadingContent = ListItemContent.Text(text = "✏️"), leadingContent = ListItemContent.Text(text = "✏️"),
onClick = onSelectCustom, onClick = {
sheetState.hide(coroutineScope) {
onSelectCustom()
}
},
) )
} }
} }
@@ -179,52 +209,63 @@ private fun UserStatusPickerBottomSheet(
private fun CustomStatusInputRow( private fun CustomStatusInputRow(
emoji: String, emoji: String,
textFieldState: TextFieldState, textFieldState: TextFieldState,
rawStatus: UserStatus?,
onEmojiChange: (String) -> Unit, onEmojiChange: (String) -> Unit,
onConfirm: () -> Unit, onConfirm: () -> Unit,
onCancel: () -> Unit, onCancel: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val hasText by remember { derivedStateOf { textFieldState.text.isNotEmpty() } } 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 saveLabel = stringResource(CommonStrings.action_save)
val cancelLabel = stringResource(CommonStrings.action_cancel) val cancelLabel = stringResource(CommonStrings.action_cancel)
val textStyle = ElementTheme.typography.fontBodyLgMedium val focusRequester = remember { FocusRequester() }
val density = LocalDensity.current LaunchedEffect(Unit) { focusRequester.requestFocus() }
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
}
ListItem( ListItem(
headlineContent = { headlineContent = {
TextField( TextField(
modifier = Modifier.focusRequester(focusRequester),
state = textFieldState, state = textFieldState,
placeholder = stringResource(R.string.screen_preferences_user_status_custom_hint), placeholder = stringResource(R.string.screen_preferences_user_status_custom_hint),
inputTransformation = InputTransformation.maxLength(maxLength = 30), inputTransformation = InputTransformation.maxLength(maxLength = 30),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
onKeyboardAction = { if (hasText) onConfirm() }, onKeyboardAction = { if (hasChanges) onConfirm() },
lineLimits = TextFieldLineLimits.SingleLine, lineLimits = TextFieldLineLimits.SingleLine,
trailingIcon = { trailingIcon = if (hasText) {
Box(modifier = Modifier.clickable(enabled = hasText) { {
textFieldState.clearText() Box(modifier = Modifier.clickable { textFieldState.clearText() }) {
}) { Icon(
Icon( imageVector = CompoundIcons.Close(),
imageVector = CompoundIcons.Close(), contentDescription = stringResource(CommonStrings.action_cancel),
contentDescription = stringResource(CommonStrings.action_cancel), modifier = Modifier.size(22.dp),
modifier = Modifier.visible(hasText) )
) }
} }
} } else null
) )
}, },
trailingContent = ListItemContent.Custom({ trailingContent = ListItemContent.Custom({
TextButton( // Layout measures both buttons to determine the max natural width,
onClick = if (hasText) onConfirm else onCancel, // then places only the active one — no hardcoded padding values needed.
text = if (hasText) saveLabel else cancelLabel, Layout(
modifier = Modifier.widthIn(min = actionButtonWidth), 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({ leadingContent = ListItemContent.Custom({
Surface( Surface(
@@ -202,7 +202,8 @@ class PreferencesRootViewTest : RobolectricTest() {
), ),
onOpenAnalytics = callback, 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, 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, onSignOutClick = callback,
) )
clickOn(CommonStrings.action_signout) val text = activity!!.getString(CommonStrings.action_signout)
onNode(hasText(text) and hasClickAction()).performScrollTo().performClick()
} }
} }