mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-03 16:10:00 +00:00
Improve UI/UX for setting status
This commit is contained in:
+1
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
+2
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
+7
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
+75
-34
@@ -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(
|
||||||
|
|||||||
+6
-3
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user