- Improve UI to match designs.
- Extract `EditableAvatarView` component.
- Create `LabelledOutlinedTextField`.
- Get strings from Localazy.
This commit is contained in:
Jorge Martín 2023-09-15 14:04:21 +02:00
parent fa277f062f
commit 41e614310f
16 changed files with 325 additions and 235 deletions

View file

@ -38,11 +38,12 @@ import io.element.android.features.preferences.impl.developer.tracing.ConfigureT
import io.element.android.features.preferences.impl.notifications.NotificationSettingsNode import io.element.android.features.preferences.impl.notifications.NotificationSettingsNode
import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingNode import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingNode
import io.element.android.features.preferences.impl.root.PreferencesRootNode import io.element.android.features.preferences.impl.root.PreferencesRootNode
import io.element.android.features.preferences.impl.user.screen.UserPreferencesNode import io.element.android.features.preferences.impl.user.editprofile.EditUserProfileNode
import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@ -84,7 +85,7 @@ class PreferencesFlowNode @AssistedInject constructor(
data class EditDefaultNotificationSetting(val isOneToOne: Boolean) : NavTarget data class EditDefaultNotificationSetting(val isOneToOne: Boolean) : NavTarget
@Parcelize @Parcelize
data object UserProfile : NavTarget data class UserProfile(val matrixUser: MatrixUser) : NavTarget
} }
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -119,8 +120,8 @@ class PreferencesFlowNode @AssistedInject constructor(
backstack.push(NavTarget.AdvancedSettings) backstack.push(NavTarget.AdvancedSettings)
} }
override fun onOpenUserProfile() { override fun onOpenUserProfile(matrixUser: MatrixUser) {
backstack.push(NavTarget.UserProfile) backstack.push(NavTarget.UserProfile(matrixUser))
} }
} }
createNode<PreferencesRootNode>(buildContext, plugins = listOf(callback)) createNode<PreferencesRootNode>(buildContext, plugins = listOf(callback))
@ -157,8 +158,9 @@ class PreferencesFlowNode @AssistedInject constructor(
NavTarget.AdvancedSettings -> { NavTarget.AdvancedSettings -> {
createNode<AdvancedSettingsNode>(buildContext) createNode<AdvancedSettingsNode>(buildContext)
} }
NavTarget.UserProfile -> { is NavTarget.UserProfile -> {
createNode<UserPreferencesNode>(buildContext) val inputs = EditUserProfileNode.Inputs(navTarget.matrixUser)
createNode<EditUserProfileNode>(buildContext, listOf(inputs))
} }
} }
} }

View file

@ -29,6 +29,7 @@ import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
import timber.log.Timber import timber.log.Timber
@ -47,7 +48,7 @@ class PreferencesRootNode @AssistedInject constructor(
fun onOpenDeveloperSettings() fun onOpenDeveloperSettings()
fun onOpenNotificationSettings() fun onOpenNotificationSettings()
fun onOpenAdvancedSettings() fun onOpenAdvancedSettings()
fun onOpenUserProfile() fun onOpenUserProfile(matrixUser: MatrixUser)
} }
private fun onOpenBugReport() { private fun onOpenBugReport() {
@ -92,8 +93,8 @@ class PreferencesRootNode @AssistedInject constructor(
plugins<Callback>().forEach { it.onOpenNotificationSettings() } plugins<Callback>().forEach { it.onOpenNotificationSettings() }
} }
private fun onOpenUserProfile() { private fun onOpenUserProfile(matrixUser: MatrixUser) {
plugins<Callback>().forEach { it.onOpenUserProfile() } plugins<Callback>().forEach { it.onOpenUserProfile(matrixUser) }
} }
@Composable @Composable

View file

@ -63,7 +63,7 @@ fun PreferencesRootView(
onOpenAdvancedSettings: () -> Unit, onOpenAdvancedSettings: () -> Unit,
onSuccessLogout: (logoutUrlResult: String?) -> Unit, onSuccessLogout: (logoutUrlResult: String?) -> Unit,
onOpenNotificationSettings: () -> Unit, onOpenNotificationSettings: () -> Unit,
onOpenUserProfile: () -> Unit, onOpenUserProfile: (MatrixUser) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
@ -77,7 +77,7 @@ fun PreferencesRootView(
) { ) {
UserPreferences( UserPreferences(
modifier = Modifier.clickable { modifier = Modifier.clickable {
onOpenUserProfile() state.myUser?.let(onOpenUserProfile)
}, },
user = state.myUser, user = state.myUser,
) )

View file

@ -14,13 +14,13 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.preferences.impl.user.screen package io.element.android.features.preferences.impl.user.editprofile
import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.matrix.ui.media.AvatarAction
sealed interface UserPreferencesEvents { sealed interface EditUserProfileEvents {
data class HandleAvatarAction(val action: AvatarAction) : UserPreferencesEvents data class HandleAvatarAction(val action: AvatarAction) : EditUserProfileEvents
data class UpdateDisplayName(val name: String) : UserPreferencesEvents data class UpdateDisplayName(val name: String) : EditUserProfileEvents
data object Save : UserPreferencesEvents data object Save : EditUserProfileEvents
data object CancelSaveChanges : UserPreferencesEvents data object CancelSaveChanges : EditUserProfileEvents
} }

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.preferences.impl.user.screen package io.element.android.features.preferences.impl.user.editprofile
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -24,22 +24,32 @@ import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.user.MatrixUser
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
class UserPreferencesNode @AssistedInject constructor( class EditUserProfileNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val presenter: UserPreferencesPresenter, presenterFactory: EditUserProfilePresenter.Factory,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
data class Inputs(
val matrixUser: MatrixUser
) : NodeInputs
val matrixUser = inputs<Inputs>().matrixUser
val presenter = presenterFactory.create(matrixUser)
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
UserPreferencesView( EditUserProfileView(
state = state, state = state,
onBackPressed = ::navigateUp, onBackPressed = ::navigateUp,
onProfileEdited = ::navigateUp, // TODO: check if something else is needed onProfileEdited = ::navigateUp,
modifier = modifier modifier = modifier
) )
} }

View file

@ -14,19 +14,19 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.preferences.impl.user.screen package io.element.android.features.preferences.impl.user.editprofile
import android.net.Uri import android.net.Uri
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import dagger.assisted.Assisted
import androidx.compose.runtime.setValue import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.architecture.runCatchingUpdatingState
@ -41,19 +41,23 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject
class UserPreferencesPresenter @Inject constructor( class EditUserProfilePresenter @AssistedInject constructor(
@Assisted private val matrixUser: MatrixUser,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val mediaPickerProvider: PickerProvider, private val mediaPickerProvider: PickerProvider,
private val mediaPreProcessor: MediaPreProcessor, private val mediaPreProcessor: MediaPreProcessor,
) : Presenter<UserPreferencesState> { ) : Presenter<EditUserProfileState> {
@AssistedFactory
interface Factory {
fun create(matrixUser: MatrixUser): EditUserProfilePresenter
}
@Composable @Composable
override fun present(): UserPreferencesState { override fun present(): EditUserProfileState {
var currentUser by remember { mutableStateOf<MatrixUser?>(null) } var userAvatarUri = remember { matrixUser.avatarUrl?.let { Uri.parse(it) } }
var userAvatarUri by rememberSaveable(currentUser) { mutableStateOf(currentUser?.avatarUrl?.let { Uri.parse(it) }) } var userDisplayName = remember { matrixUser.displayName }
var userDisplayName by rememberSaveable(currentUser) { mutableStateOf(currentUser?.displayName) }
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker( val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
onResult = { uri -> if (uri != null) userAvatarUri = uri } onResult = { uri -> if (uri != null) userAvatarUri = uri }
) )
@ -61,10 +65,6 @@ class UserPreferencesPresenter @Inject constructor(
onResult = { uri -> if (uri != null) userAvatarUri = uri } onResult = { uri -> if (uri != null) userAvatarUri = uri }
) )
LaunchedEffect(Unit) {
currentUser = matrixClient.getCurrentUser()
}
val avatarActions by remember(userAvatarUri) { val avatarActions by remember(userAvatarUri) {
derivedStateOf { derivedStateOf {
listOfNotNull( listOfNotNull(
@ -77,12 +77,10 @@ class UserPreferencesPresenter @Inject constructor(
val saveAction: MutableState<Async<Unit>> = remember { mutableStateOf(Async.Uninitialized) } val saveAction: MutableState<Async<Unit>> = remember { mutableStateOf(Async.Uninitialized) }
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
fun handleEvents(event: UserPreferencesEvents) { fun handleEvents(event: EditUserProfileEvents) {
when (event) { when (event) {
is UserPreferencesEvents.Save -> currentUser?.let { is EditUserProfileEvents.Save -> localCoroutineScope.saveChanges(userDisplayName, userAvatarUri, matrixUser, saveAction)
localCoroutineScope.saveChanges(userDisplayName, userAvatarUri, it, saveAction) is EditUserProfileEvents.HandleAvatarAction -> {
}
is UserPreferencesEvents.HandleAvatarAction -> {
when (event.action) { when (event.action) {
AvatarAction.ChoosePhoto -> galleryImagePicker.launch() AvatarAction.ChoosePhoto -> galleryImagePicker.launch()
AvatarAction.TakePhoto -> cameraPhotoPicker.launch() AvatarAction.TakePhoto -> cameraPhotoPicker.launch()
@ -90,19 +88,19 @@ class UserPreferencesPresenter @Inject constructor(
} }
} }
is UserPreferencesEvents.UpdateDisplayName -> userDisplayName = event.name is EditUserProfileEvents.UpdateDisplayName -> userDisplayName = event.name
UserPreferencesEvents.CancelSaveChanges -> saveAction.value = Async.Uninitialized EditUserProfileEvents.CancelSaveChanges -> saveAction.value = Async.Uninitialized
} }
} }
val canSave = remember(userDisplayName, userAvatarUri, currentUser) { val canSave = remember(userDisplayName, userAvatarUri) {
val hasProfileChanged = hasDisplayNameChanged(userDisplayName, currentUser) val hasProfileChanged = hasDisplayNameChanged(userDisplayName, matrixUser)
|| hasAvatarUrlChanged(userAvatarUri, currentUser) || hasAvatarUrlChanged(userAvatarUri, matrixUser)
!userDisplayName.isNullOrBlank() && hasProfileChanged !userDisplayName.isNullOrBlank() && hasProfileChanged
} }
return UserPreferencesState( return EditUserProfileState(
userId = currentUser?.userId, userId = matrixUser.userId,
displayName = userDisplayName.orEmpty(), displayName = userDisplayName.orEmpty(),
userAvatarUrl = userAvatarUri, userAvatarUrl = userAvatarUri,
avatarActions = avatarActions, avatarActions = avatarActions,
@ -116,7 +114,6 @@ class UserPreferencesPresenter @Inject constructor(
private fun hasAvatarUrlChanged(avatarUri: Uri?, currentUser: MatrixUser?) = avatarUri?.toString()?.trim() != currentUser?.avatarUrl?.trim() private fun hasAvatarUrlChanged(avatarUri: Uri?, currentUser: MatrixUser?) = avatarUri?.toString()?.trim() != currentUser?.avatarUrl?.trim()
private fun CoroutineScope.saveChanges(name: String?, avatarUri: Uri?, currentUser: MatrixUser, action: MutableState<Async<Unit>>) = launch { private fun CoroutineScope.saveChanges(name: String?, avatarUri: Uri?, currentUser: MatrixUser, action: MutableState<Async<Unit>>) = launch {
matrixClient.getCurrentUser()
val results = mutableListOf<Result<Unit>>() val results = mutableListOf<Result<Unit>>()
suspend { suspend {
if (!name.isNullOrEmpty() && name.trim() != currentUser.displayName.orEmpty().trim()) { if (!name.isNullOrEmpty() && name.trim() != currentUser.displayName.orEmpty().trim()) {

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.preferences.impl.user.screen package io.element.android.features.preferences.impl.user.editprofile
import android.net.Uri import android.net.Uri
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
@ -22,12 +22,12 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.matrix.ui.media.AvatarAction
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class UserPreferencesState( data class EditUserProfileState(
val userId: UserId?, val userId: UserId?,
val displayName: String, val displayName: String,
val userAvatarUrl: Uri?, val userAvatarUrl: Uri?,
val avatarActions: ImmutableList<AvatarAction>, val avatarActions: ImmutableList<AvatarAction>,
val saveButtonEnabled: Boolean, val saveButtonEnabled: Boolean,
val saveAction: Async<Unit>, val saveAction: Async<Unit>,
val eventSink: (UserPreferencesEvents) -> Unit val eventSink: (EditUserProfileEvents) -> Unit
) )

View file

@ -14,22 +14,22 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.preferences.impl.user.screen package io.element.android.features.preferences.impl.user.editprofile
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
open class UserPreferencesStateProvider : PreviewParameterProvider<UserPreferencesState> { open class EditUserProfileStateProvider : PreviewParameterProvider<EditUserProfileState> {
override val values: Sequence<UserPreferencesState> override val values: Sequence<EditUserProfileState>
get() = sequenceOf( get() = sequenceOf(
aUserPreferencesState(), aEditUserProfileState(),
// Add other states here // Add other states here
) )
} }
fun aUserPreferencesState() = UserPreferencesState( fun aEditUserProfileState() = EditUserProfileState(
userId = UserId("@john.doe:matrix.org"), userId = UserId("@john.doe:matrix.org"),
displayName = "John Doe", displayName = "John Doe",
userAvatarUrl = null, userAvatarUrl = null,

View file

@ -14,71 +14,59 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.preferences.impl.user.screen package io.element.android.features.preferences.impl.user.editprofile
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AddAPhoto
import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
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.features.preferences.impl.R
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.LabelledTextField import io.element.android.libraries.designsystem.components.LabelledOutlinedTextField
import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.aliasScreenTitle import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
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
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
import io.element.android.libraries.matrix.ui.components.UnsavedAvatar import io.element.android.libraries.matrix.ui.components.EditableAvatarView
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
fun UserPreferencesView( fun EditUserProfileView(
state: UserPreferencesState, state: EditUserProfileState,
onBackPressed: () -> Unit, onBackPressed: () -> Unit,
onProfileEdited: () -> Unit, onProfileEdited: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -102,7 +90,7 @@ fun UserPreferencesView(
TopAppBar( TopAppBar(
title = { title = {
Text( Text(
text = "Edit profile", text = stringResource(R.string.screen_edit_profile_title),
style = ElementTheme.typography.aliasScreenTitle, style = ElementTheme.typography.aliasScreenTitle,
) )
}, },
@ -113,7 +101,7 @@ fun UserPreferencesView(
enabled = state.saveButtonEnabled, enabled = state.saveButtonEnabled,
onClick = { onClick = {
focusManager.clearFocus() focusManager.clearFocus()
state.eventSink(UserPreferencesEvents.Save) state.eventSink(EditUserProfileEvents.Save)
}, },
) )
} }
@ -129,33 +117,50 @@ fun UserPreferencesView(
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
) { ) {
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
EditableAvatarView(state, ::onAvatarClicked) EditableAvatarView(
Spacer(modifier = Modifier.height(60.dp)) userId = state.userId?.value,
displayName = state.displayName,
avatarUrl = state.userAvatarUrl,
avatarSize = AvatarSize.RoomHeader,
onAvatarClicked = { onAvatarClicked() },
modifier = Modifier.align(Alignment.CenterHorizontally),
)
Spacer(modifier = Modifier.height(16.dp))
state.userId?.let {
Text(
modifier = Modifier.fillMaxWidth(),
text = it.value,
style = ElementTheme.typography.fontBodyLgRegular,
textAlign = TextAlign.Center,
)
}
Spacer(modifier = Modifier.height(40.dp))
LabelledTextField( LabelledOutlinedTextField(
label = "Display name", label = stringResource(R.string.screen_edit_profile_display_name),
value = state.displayName, value = state.displayName,
placeholder = stringResource(CommonStrings.common_room_name_placeholder), placeholder = stringResource(CommonStrings.common_room_name_placeholder),
singleLine = true, singleLine = true,
onValueChange = { state.eventSink(UserPreferencesEvents.UpdateDisplayName(it)) }, onValueChange = { state.eventSink(EditUserProfileEvents.UpdateDisplayName(it)) },
) )
} }
AvatarActionBottomSheet( AvatarActionBottomSheet(
actions = state.avatarActions, actions = state.avatarActions,
modalBottomSheetState = itemActionsBottomSheetState, modalBottomSheetState = itemActionsBottomSheetState,
onActionSelected = { state.eventSink(UserPreferencesEvents.HandleAvatarAction(it)) } onActionSelected = { state.eventSink(EditUserProfileEvents.HandleAvatarAction(it)) }
) )
when (state.saveAction) { when (state.saveAction) {
is Async.Loading -> { is Async.Loading -> {
ProgressDialog(text = "Updating profile...") ProgressDialog(text = stringResource(R.string.screen_edit_profile_updating_details))
} }
is Async.Failure -> { is Async.Failure -> {
ErrorDialog( ErrorDialog(
content = "Error updating profile", title = stringResource(R.string.screen_edit_profile_error_title),
onDismiss = { state.eventSink(UserPreferencesEvents.CancelSaveChanges) }, content = stringResource(R.string.screen_edit_profile_error),
onDismiss = { state.eventSink(EditUserProfileEvents.CancelSaveChanges) },
) )
} }
@ -170,82 +175,8 @@ fun UserPreferencesView(
} }
} }
@Composable
private fun EditableAvatarView(
state: UserPreferencesState,
onAvatarClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.size(70.dp)
.clickable(onClick = onAvatarClicked)
) {
// TODO this might be able to be simplified into a single component once send/receive media is done
when (state.userAvatarUrl?.scheme) {
null, "mxc" -> {
Avatar(
avatarData = AvatarData(state.userId.toString(), state.displayName, state.userAvatarUrl?.toString(), size = AvatarSize.RoomHeader),
modifier = Modifier.fillMaxSize(),
)
}
else -> {
UnsavedAvatar(
avatarUri = state.userAvatarUrl,
modifier = Modifier.fillMaxSize(),
)
}
}
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.size(24.dp),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(16.dp),
imageVector = Icons.Outlined.AddAPhoto,
contentDescription = "",
tint = MaterialTheme.colorScheme.onPrimary,
)
}
}
}
}
@Composable
private fun LabelledReadOnlyField(
title: String,
value: String,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
modifier = Modifier.padding(horizontal = 16.dp),
style = ElementTheme.typography.fontBodyMdRegular,
color = MaterialTheme.colorScheme.primary,
text = title,
)
Text(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 12.dp),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary,
text = value,
)
}
}
private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier = private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier =
pointerInput(Unit) { this.pointerInput(Unit) {
detectTapGestures(onTap = { detectTapGestures(onTap = {
focusManager.clearFocus() focusManager.clearFocus()
}) })
@ -253,17 +184,17 @@ private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier =
@Preview @Preview
@Composable @Composable
fun UserPreferencesViewLightPreview(@PreviewParameter(UserPreferencesStateProvider::class) state: UserPreferencesState) = fun EditUserProfileViewLightPreview(@PreviewParameter(EditUserProfileStateProvider::class) state: EditUserProfileState) =
ElementPreviewLight { ContentToPreview(state) } ElementPreviewLight { ContentToPreview(state) }
@Preview @Preview
@Composable @Composable
fun UserPreferencesViewDarkPreview(@PreviewParameter(UserPreferencesStateProvider::class) state: UserPreferencesState) = fun EditUserProfileViewDarkPreview(@PreviewParameter(EditUserProfileStateProvider::class) state: EditUserProfileState) =
ElementPreviewDark { ContentToPreview(state) } ElementPreviewDark { ContentToPreview(state) }
@Composable @Composable
private fun ContentToPreview(state: UserPreferencesState) { private fun ContentToPreview(state: EditUserProfileState) {
UserPreferencesView( EditUserProfileView(
onBackPressed = {}, onBackPressed = {},
onProfileEdited = {}, onProfileEdited = {},
state = state, state = state,

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_edit_profile_display_name">"Display name"</string>
<string name="screen_edit_profile_display_name_placeholder">"Your display name"</string>
<string name="screen_edit_profile_error">"An unknown error was encountered and the information couldn\'t be changed."</string>
<string name="screen_edit_profile_error_title">"Unable to update profile"</string>
<string name="screen_edit_profile_title">"Edit profile"</string>
<string name="screen_edit_profile_updating_details">"Updating profile…"</string>
</resources>

View file

@ -18,37 +18,27 @@
package io.element.android.features.roomdetails.impl.edit package io.element.android.features.roomdetails.impl.edit
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AddAPhoto
import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
@ -61,21 +51,18 @@ import io.element.android.features.roomdetails.impl.R
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.LabelledTextField import io.element.android.libraries.designsystem.components.LabelledTextField
import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.aliasScreenTitle import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
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
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
import io.element.android.libraries.matrix.ui.components.UnsavedAvatar import io.element.android.libraries.matrix.ui.components.EditableAvatarView
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -134,7 +121,14 @@ fun RoomDetailsEditView(
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
) { ) {
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
EditableAvatarView(state, ::onAvatarClicked) EditableAvatarView(
userId = state.roomId,
displayName = state.roomName,
avatarUrl = state.roomAvatarUrl,
avatarSize = AvatarSize.EditRoomDetails,
onAvatarClicked = ::onAvatarClicked,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(60.dp)) Spacer(modifier = Modifier.height(60.dp))
if (state.canChangeName) { if (state.canChangeName) {
@ -202,56 +196,6 @@ fun RoomDetailsEditView(
} }
} }
@Composable
private fun EditableAvatarView(
state: RoomDetailsEditState,
onAvatarClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.size(70.dp)
.clickable(onClick = onAvatarClicked, enabled = state.canChangeAvatar)
) {
// TODO this might be able to be simplified into a single component once send/receive media is done
when (state.roomAvatarUrl?.scheme) {
null, "mxc" -> {
Avatar(
avatarData = AvatarData(state.roomId, state.roomName, state.roomAvatarUrl?.toString(), size = AvatarSize.RoomHeader),
modifier = Modifier.fillMaxSize(),
)
}
else -> {
UnsavedAvatar(
avatarUri = state.roomAvatarUrl,
modifier = Modifier.fillMaxSize(),
)
}
}
if (state.canChangeAvatar) {
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.size(24.dp),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(16.dp),
imageVector = Icons.Outlined.AddAPhoto,
contentDescription = "",
tint = MaterialTheme.colorScheme.onPrimary,
)
}
}
}
}
}
@Composable @Composable
private fun LabelledReadOnlyField( private fun LabelledReadOnlyField(
title: String, title: String,
@ -279,7 +223,7 @@ private fun LabelledReadOnlyField(
} }
private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier = private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier =
pointerInput(Unit) { this.pointerInput(Unit) {
detectTapGestures(onTap = { detectTapGestures(onTap = {
focusManager.clearFocus() focusManager.clearFocus()
}) })

View file

@ -0,0 +1,91 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.OutlinedTextField
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme
@Composable
fun LabelledOutlinedTextField(
label: String,
value: String,
modifier: Modifier = Modifier,
placeholder: String? = null,
singleLine: Boolean = false,
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
onValueChange: (String) -> Unit = {},
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
modifier = Modifier.padding(horizontal = 16.dp),
style = ElementTheme.typography.fontBodyMdRegular,
color = MaterialTheme.colorScheme.primary,
text = label
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = value,
placeholder = placeholder?.let { { Text(placeholder) } },
onValueChange = onValueChange,
singleLine = singleLine,
maxLines = maxLines,
keyboardOptions = keyboardOptions,
)
}
}
@Preview
@Composable
internal fun LabelledOutlinedTextFieldLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun LabelledOutlinedTextFieldDarkPreview() = ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
Column {
LabelledOutlinedTextField(
label = "Room name",
value = "",
placeholder = "e.g. Product Sprint",
)
LabelledOutlinedTextField(
label = "Room name",
value = "a room name",
placeholder = "e.g. Product Sprint",
)
}
}

View file

@ -43,5 +43,7 @@ enum class AvatarSize(val dp: Dp) {
RoomInviteItem(52.dp), RoomInviteItem(52.dp),
InviteSender(16.dp), InviteSender(16.dp),
EditRoomDetails(70.dp),
NotificationsOptIn(32.dp), NotificationsOptIn(32.dp),
} }

View file

@ -29,8 +29,8 @@ anvil {
} }
dependencies { dependencies {
implementation(projects.libraries.rustsdk) // implementation(projects.libraries.rustsdk)
// implementation(libs.matrix.sdk) implementation(libs.matrix.sdk)
implementation(projects.libraries.di) implementation(projects.libraries.di)
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(projects.libraries.network) implementation(projects.libraries.network)

View file

@ -0,0 +1,97 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.ui.components
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AddAPhoto
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.theme.components.Icon
@Composable
fun EditableAvatarView(
userId: String?,
displayName: String?,
avatarUrl: Uri?,
avatarSize: AvatarSize,
onAvatarClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.size(avatarSize.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
onClick = onAvatarClicked,
indication = rememberRipple(bounded = false),
)
) {
when (avatarUrl?.scheme) {
null, "mxc" -> {
userId?.let {
Avatar(
avatarData = AvatarData(it, displayName, avatarUrl?.toString(), size = avatarSize),
modifier = Modifier.fillMaxSize(),
)
}
}
else -> {
UnsavedAvatar(
avatarUri = avatarUrl,
modifier = Modifier.fillMaxSize(),
)
}
}
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.size(24.dp),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(16.dp),
imageVector = Icons.Outlined.AddAPhoto,
contentDescription = "",
tint = MaterialTheme.colorScheme.onPrimary,
)
}
}
}
}

View file

@ -128,6 +128,12 @@
"includeRegex": [ "includeRegex": [
"screen_create_poll_.*" "screen_create_poll_.*"
] ]
},
{
"name": ":features:preferences:impl",
"includeRegex": [
"screen_edit_profile_.*"
]
} }
] ]
} }