diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/PredefinedUserStatus.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/PredefinedUserStatus.kt deleted file mode 100644 index 78fec19f7a..0000000000 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/PredefinedUserStatus.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright (c) 2026 Element Creations Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.preferences.impl.userstatus - -import androidx.annotation.StringRes -import io.element.android.features.preferences.impl.R - -enum class PredefinedUserStatus(val emoji: String, @StringRes val labelRes: Int) { - IN_A_MEETING("💬", R.string.common_user_status_in_a_meeting), - FOCUS_TIME("💡", R.string.common_user_status_focus_time), - ON_THE_ROAD("🚙", R.string.common_user_status_on_the_road), - BE_RIGHT_BACK("☕", R.string.common_user_status_be_right_back), - AWAY("🌴", R.string.common_user_status_away), -} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusEvent.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusEvent.kt index 703f8dadae..d78be1607b 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusEvent.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusEvent.kt @@ -20,6 +20,10 @@ sealed interface UserStatusEvent { data object OpenCustomInput : UserStatusEvent /** User tapped Cancel in the inline custom input row. */ data object CancelCustomInput : UserStatusEvent + /** User changed the emoji in the custom input row. */ + data class UpdateCustomEmoji(val emoji: String) : UserStatusEvent + /** User changed the text in the custom input row. */ + data class UpdateCustomText(val text: String) : UserStatusEvent /** User tapped the clear action on an existing status. */ data object Clear : UserStatusEvent } 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 a20bcd1b56..d765f35800 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 @@ -14,13 +14,15 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.user.UserStatus import kotlinx.coroutines.launch -@Inject +@ContributesBinding(SessionScope::class) class UserStatusPresenter( private val matrixClient: MatrixClient, ) : Presenter { @@ -38,8 +40,8 @@ class UserStatusPresenter( UserStatusEvent.OpenCustomInput -> { val raw = userProfile.rawStatus pickerState = UserStatusPickerState.CustomInput( - initialEmoji = raw?.emoji ?: "😀", - initialText = raw?.text ?: "", + emoji = raw?.emoji ?: "😀", + text = raw?.text ?: "", ) } is UserStatusEvent.Set -> { @@ -51,6 +53,14 @@ class UserStatusPresenter( coroutineScope.launch { matrixClient.clearUserStatus() } } UserStatusEvent.CancelCustomInput -> pickerState = UserStatusPickerState.Hidden + is UserStatusEvent.UpdateCustomEmoji -> { + val current = pickerState as? UserStatusPickerState.CustomInput ?: return + pickerState = current.copy(emoji = event.emoji) + } + is UserStatusEvent.UpdateCustomText -> { + val current = pickerState as? UserStatusPickerState.CustomInput ?: return + pickerState = current.copy(text = event.text) + } } } 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 5ae1537758..df1a97a5e3 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 @@ -7,6 +7,8 @@ package io.element.android.features.preferences.impl.userstatus +import androidx.annotation.StringRes +import io.element.android.features.preferences.impl.R import io.element.android.libraries.matrix.api.user.DisplayedStatus data class UserStatusState( @@ -19,7 +21,15 @@ sealed interface UserStatusPickerState { data object Hidden : UserStatusPickerState data object ShowingPicker : UserStatusPickerState data class CustomInput( - val initialEmoji: String = "😀", - val initialText: String = "", + val emoji: String = "😀", + val text: String = "", ) : UserStatusPickerState } + +enum class PredefinedUserStatus(val emoji: String, @param:StringRes val labelRes: Int) { + IN_A_MEETING("💬", R.string.common_user_status_in_a_meeting), + FOCUS_TIME("💡", R.string.common_user_status_focus_time), + ON_THE_ROAD("🚙", R.string.common_user_status_on_the_road), + BE_RIGHT_BACK("☕", R.string.common_user_status_be_right_back), + AWAY("🌴", R.string.common_user_status_away), +} 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 78f96042e1..956400fa63 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 @@ -7,6 +7,7 @@ package io.element.android.features.preferences.impl.userstatus +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -17,26 +18,29 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ListItem -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.preferences.impl.R +import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.Icon +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.Surface +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.TextField import io.element.android.libraries.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.ui.strings.CommonStrings @@ -72,11 +76,11 @@ fun UserStatusRow( } is UserStatusPickerState.CustomInput -> { CustomStatusInputRow( - initialEmoji = pickerState.initialEmoji, - initialText = pickerState.initialText, - onConfirm = { emoji, text -> - state.eventSink(UserStatusEvent.Set(UserStatus(emoji, text))) - }, + emoji = pickerState.emoji, + text = pickerState.text, + onEmojiChange = { state.eventSink(UserStatusEvent.UpdateCustomEmoji(it)) }, + onTextChange = { state.eventSink(UserStatusEvent.UpdateCustomText(it)) }, + onConfirm = { state.eventSink(UserStatusEvent.Set(UserStatus(pickerState.emoji, pickerState.text))) }, onCancel = { state.eventSink(UserStatusEvent.CancelCustomInput) }, modifier = modifier, ) @@ -91,14 +95,9 @@ private fun EmptyStatusRow( ) { ListItem( headlineContent = { - Text( - text = stringResource(R.string.screen_preferences_user_status_placeholder), - color = ElementTheme.colors.textSecondary, - ) - }, - leadingContent = { - Text(text = "🙂", style = ElementTheme.typography.fontBodyLgRegular) + Text(text = stringResource(R.string.screen_preferences_user_status_placeholder)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Reaction())), modifier = modifier.clickable(onClick = onClick), ) } @@ -116,15 +115,14 @@ private fun CurrentStatusRow( } ListItem( headlineContent = { Text(text = text) }, - leadingContent = { - Text(text = emoji, style = ElementTheme.typography.fontBodyLgRegular) - }, - trailingContent = { - TextButton(onClick = onClear) { - Text(text = stringResource(CommonStrings.action_clear)) + leadingContent = ListItemContent.Text(emoji), + trailingContent = ListItemContent.Custom({ + IconButton(onClick = onClear) { + Icon(imageVector = CompoundIcons.Close(), contentDescription = null) } - }, - modifier = modifier.clickable(onClick = onClick), + }), + onClick = onClick, + modifier = modifier, ) } @@ -134,16 +132,15 @@ private fun UserStatusPickerBottomSheet( onDismiss: () -> Unit, onSelectPredefined: (UserStatus) -> Unit, onSelectCustom: () -> Unit, + modifier: Modifier = Modifier, ) { - ModalBottomSheet(onDismissRequest = onDismiss) { + ModalBottomSheet(onDismissRequest = onDismiss, scrollable = true, modifier = modifier) { PredefinedUserStatus.entries.forEach { predefined -> val label = stringResource(predefined.labelRes) ListItem( headlineContent = { Text(text = label) }, - leadingContent = { - Text(text = predefined.emoji, style = ElementTheme.typography.fontBodyLgRegular) - }, - modifier = Modifier.clickable { + leadingContent = ListItemContent.Text(text = predefined.emoji), + onClick = { onSelectPredefined(UserStatus(emoji = predefined.emoji, text = label)) }, ) @@ -152,25 +149,54 @@ private fun UserStatusPickerBottomSheet( headlineContent = { Text(text = stringResource(R.string.common_user_status_custom)) }, - leadingContent = { - Text(text = "✏️", style = ElementTheme.typography.fontBodyLgRegular) - }, - modifier = Modifier.clickable(onClick = onSelectCustom), + leadingContent = ListItemContent.Text(text = "✏️"), + onClick = onSelectCustom, ) } } @Composable private fun CustomStatusInputRow( - initialEmoji: String, - initialText: String, - onConfirm: (emoji: String, text: String) -> Unit, + emoji: String, + text: String, + onEmojiChange: (String) -> Unit, + onTextChange: (String) -> Unit, + onConfirm: () -> Unit, onCancel: () -> Unit, modifier: Modifier = Modifier, ) { - var emoji by remember { mutableStateOf(initialEmoji) } - var text by remember { mutableStateOf(initialText) } + ListItem( + headlineContent = { + TextField( + value = text, + onValueChange = { if (it.length <= 30) onTextChange(it) }, + placeholder = stringResource(R.string.screen_preferences_user_status_custom_hint), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { if (text.isNotBlank()) onConfirm() } + ), + singleLine = true, + ) + }, + trailingContent = ListItemContent.Custom({ + TextButton(onClick = onCancel, text = stringResource(CommonStrings.action_cancel)) + }), + leadingContent = ListItemContent.Custom( { + Surface( + shape = CircleShape, + border = BorderStroke(1.dp, ElementTheme.colors.bgSubtleSecondary), + modifier = Modifier + .size(50.dp) + .clickable { }, + ) { + Box(contentAlignment = Alignment.Center) { + Text(text = emoji, style = ElementTheme.typography.fontBodyLgRegular) + } + } + }) + ) + /* Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier @@ -188,25 +214,21 @@ private fun CustomStatusInputRow( Text(text = emoji, style = ElementTheme.typography.fontBodyLgRegular) } } - OutlinedTextField( + TextField( value = text, onValueChange = { if (it.length <= 30) text = it }, - placeholder = { - Text(text = stringResource(R.string.screen_preferences_user_status_custom_hint)) - }, + placeholder = stringResource(R.string.screen_preferences_user_status_custom_hint), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardActions = KeyboardActions( onDone = { if (text.isNotBlank()) onConfirm(emoji, text) } ), singleLine = true, - modifier = Modifier - .weight(1f) - .padding(horizontal = 8.dp), + modifier = Modifier.weight(1f).padding(horizontal = 8.dp), ) - TextButton(onClick = onCancel) { - Text(text = stringResource(CommonStrings.action_cancel)) - } + TextButton(onClick = onCancel, text = stringResource(CommonStrings.action_cancel)) } + + */ } @PreviewsDayNight @@ -239,7 +261,7 @@ internal fun UserStatusRowCustomInputPreview() = ElementPreview { UserStatusRow( state = UserStatusState( displayedStatus = null, - pickerState = UserStatusPickerState.CustomInput(initialEmoji = "😀", initialText = ""), + pickerState = UserStatusPickerState.CustomInput(emoji = "😀", text = ""), eventSink = {}, ) ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/di/UserStatusModule.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/di/UserStatusModule.kt deleted file mode 100644 index b2be2d75d4..0000000000 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/di/UserStatusModule.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright (c) 2026 Element Creations Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.preferences.impl.userstatus.di - -import dev.zacsweers.metro.BindingContainer -import dev.zacsweers.metro.Binds -import dev.zacsweers.metro.ContributesTo -import io.element.android.features.preferences.impl.userstatus.UserStatusPresenter -import io.element.android.features.preferences.impl.userstatus.UserStatusState -import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.di.SessionScope - -@ContributesTo(SessionScope::class) -@BindingContainer -interface UserStatusModule { - @Binds - fun bindUserStatusPresenter(presenter: UserStatusPresenter): Presenter -} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt index d8187462f8..42f1e53323 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt @@ -80,8 +80,8 @@ class UserStatusPresenterTest { awaitItem().eventSink(UserStatusEvent.OpenCustomInput) val state = awaitItem() val pickerState = state.pickerState as UserStatusPickerState.CustomInput - assertThat(pickerState.initialEmoji).isEqualTo("😀") - assertThat(pickerState.initialText).isEqualTo("") + assertThat(pickerState.emoji).isEqualTo("😀") + assertThat(pickerState.text).isEqualTo("") } } @@ -97,8 +97,32 @@ class UserStatusPresenterTest { awaitItem().eventSink(UserStatusEvent.OpenCustomInput) val state = awaitItem() val pickerState = state.pickerState as UserStatusPickerState.CustomInput - assertThat(pickerState.initialEmoji).isEqualTo("🌴") - assertThat(pickerState.initialText).isEqualTo("Away") + assertThat(pickerState.emoji).isEqualTo("🌴") + assertThat(pickerState.text).isEqualTo("Away") + } + } + + @Test + fun `UpdateCustomText updates text in CustomInput state`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + createPresenter().present() + }.test { + awaitItem().eventSink(UserStatusEvent.OpenCustomInput) + awaitItem().eventSink(UserStatusEvent.UpdateCustomText("Focusing")) + val state = awaitItem() + assertThat((state.pickerState as UserStatusPickerState.CustomInput).text).isEqualTo("Focusing") + } + } + + @Test + fun `UpdateCustomEmoji updates emoji in CustomInput state`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + createPresenter().present() + }.test { + awaitItem().eventSink(UserStatusEvent.OpenCustomInput) + awaitItem().eventSink(UserStatusEvent.UpdateCustomEmoji("🚀")) + val state = awaitItem() + assertThat((state.pickerState as UserStatusPickerState.CustomInput).emoji).isEqualTo("🚀") } }