Improve UI/UX for setting status
This commit is contained in:
+1
@@ -69,6 +69,7 @@ class UserStatusPresenter(
|
||||
|
||||
return UserStatusState(
|
||||
displayedStatus = userProfile.displayedStatus,
|
||||
rawStatus = userProfile.rawStatus,
|
||||
pickerState = pickerState,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
|
||||
+2
@@ -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,
|
||||
)
|
||||
|
||||
+7
@@ -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,
|
||||
)
|
||||
|
||||
+75
-34
@@ -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(
|
||||
|
||||
+6
-3
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user