diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts index 34a33ff80b..4d0403809d 100644 --- a/features/preferences/impl/build.gradle.kts +++ b/features/preferences/impl/build.gradle.kts @@ -44,10 +44,12 @@ dependencies { implementation(projects.libraries.preferences.api) implementation(projects.libraries.testtags) implementation(projects.libraries.uiStrings) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.mediapickers.api) + implementation(projects.libraries.mediaupload.api) implementation(projects.features.rageshake.api) implementation(projects.features.analytics.api) implementation(projects.features.ftue.api) - implementation(projects.libraries.matrixui) implementation(projects.features.logout.api) implementation(projects.services.analytics.api) implementation(projects.services.toolbox.api) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt index 238b4006e7..0ee29dd1f9 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt @@ -38,6 +38,7 @@ 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.edit.EditDefaultNotificationSettingNode import io.element.android.features.preferences.impl.root.PreferencesRootNode +import io.element.android.features.preferences.impl.user.screen.UserPreferencesNode import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler import io.element.android.libraries.architecture.createNode @@ -81,6 +82,9 @@ class PreferencesFlowNode @AssistedInject constructor( @Parcelize data class EditDefaultNotificationSetting(val isOneToOne: Boolean) : NavTarget + + @Parcelize + data object UserProfile : NavTarget } override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { @@ -114,6 +118,10 @@ class PreferencesFlowNode @AssistedInject constructor( override fun onOpenAdvancedSettings() { backstack.push(NavTarget.AdvancedSettings) } + + override fun onOpenUserProfile() { + backstack.push(NavTarget.UserProfile) + } } createNode(buildContext, plugins = listOf(callback)) } @@ -149,6 +157,9 @@ class PreferencesFlowNode @AssistedInject constructor( NavTarget.AdvancedSettings -> { createNode(buildContext) } + NavTarget.UserProfile -> { + createNode(buildContext) + } } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt index b4495d8899..3a12ba4478 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt @@ -47,6 +47,7 @@ class PreferencesRootNode @AssistedInject constructor( fun onOpenDeveloperSettings() fun onOpenNotificationSettings() fun onOpenAdvancedSettings() + fun onOpenUserProfile() } private fun onOpenBugReport() { @@ -91,6 +92,10 @@ class PreferencesRootNode @AssistedInject constructor( plugins().forEach { it.onOpenNotificationSettings() } } + private fun onOpenUserProfile() { + plugins().forEach { it.onOpenUserProfile() } + } + @Composable override fun View(modifier: Modifier) { val state = presenter.present() @@ -108,7 +113,8 @@ class PreferencesRootNode @AssistedInject constructor( onOpenAdvancedSettings = this::onOpenAdvancedSettings, onSuccessLogout = { onSuccessLogout(activity, it) }, onManageAccountClicked = { onManageAccountClicked(activity, it, isDark) }, - onOpenNotificationSettings = this::onOpenNotificationSettings + onOpenNotificationSettings = this::onOpenNotificationSettings, + onOpenUserProfile = this::onOpenUserProfile, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt index 16c7df6b51..d8a05b0db8 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt @@ -16,6 +16,7 @@ package io.element.android.features.preferences.impl.root +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons @@ -62,6 +63,7 @@ fun PreferencesRootView( onOpenAdvancedSettings: () -> Unit, onSuccessLogout: (logoutUrlResult: String?) -> Unit, onOpenNotificationSettings: () -> Unit, + onOpenUserProfile: () -> Unit, modifier: Modifier = Modifier, ) { val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) @@ -73,7 +75,12 @@ fun PreferencesRootView( title = stringResource(id = CommonStrings.common_settings), snackbarHost = { SnackbarHost(snackbarHostState) } ) { - UserPreferences(state.myUser) + UserPreferences( + modifier = Modifier.clickable { + onOpenUserProfile() + }, + user = state.myUser, + ) if (state.showCompleteVerification) { PreferenceText( title = stringResource(id = CommonStrings.action_complete_verification), @@ -181,5 +188,6 @@ private fun ContentToPreview(matrixUser: MatrixUser) { onSuccessLogout = {}, onManageAccountClicked = {}, onOpenNotificationSettings = {}, + onOpenUserProfile = {}, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/screen/UserPreferencesEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/screen/UserPreferencesEvents.kt new file mode 100644 index 0000000000..c54ea98afc --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/screen/UserPreferencesEvents.kt @@ -0,0 +1,26 @@ +/* + * 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.features.preferences.impl.user.screen + +import io.element.android.libraries.matrix.ui.media.AvatarAction + +sealed interface UserPreferencesEvents { + data class HandleAvatarAction(val action: AvatarAction) : UserPreferencesEvents + data class UpdateDisplayName(val name: String) : UserPreferencesEvents + data object Save : UserPreferencesEvents + data object CancelSaveChanges : UserPreferencesEvents +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/screen/UserPreferencesNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/screen/UserPreferencesNode.kt new file mode 100644 index 0000000000..eaccb6fbbd --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/screen/UserPreferencesNode.kt @@ -0,0 +1,46 @@ +/* + * 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.features.preferences.impl.user.screen + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +class UserPreferencesNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: UserPreferencesPresenter, +) : Node(buildContext, plugins = plugins) { + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + UserPreferencesView( + state = state, + onBackPressed = ::navigateUp, + onProfileEdited = ::navigateUp, // TODO: check if something else is needed + modifier = modifier + ) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/screen/UserPreferencesPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/screen/UserPreferencesPresenter.kt new file mode 100644 index 0000000000..49e28144bb --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/screen/UserPreferencesPresenter.kt @@ -0,0 +1,146 @@ +/* + * 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.features.preferences.impl.user.screen + +import android.net.Uri +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.api.user.getCurrentUser +import io.element.android.libraries.matrix.ui.media.AvatarAction +import io.element.android.libraries.mediapickers.api.PickerProvider +import io.element.android.libraries.mediaupload.api.MediaPreProcessor +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +class UserPreferencesPresenter @Inject constructor( + private val matrixClient: MatrixClient, + private val mediaPickerProvider: PickerProvider, + private val mediaPreProcessor: MediaPreProcessor, +) : Presenter { + + @Composable + override fun present(): UserPreferencesState { + var currentUser by remember { mutableStateOf(null) } + var userAvatarUri by rememberSaveable(currentUser) { mutableStateOf(currentUser?.avatarUrl?.let { Uri.parse(it) }) } + var userDisplayName by rememberSaveable(currentUser) { mutableStateOf(currentUser?.displayName) } + val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker( + onResult = { uri -> if (uri != null) userAvatarUri = uri } + ) + val galleryImagePicker = mediaPickerProvider.registerGalleryImagePicker( + onResult = { uri -> if (uri != null) userAvatarUri = uri } + ) + + LaunchedEffect(Unit) { + currentUser = matrixClient.getCurrentUser() + } + + val avatarActions by remember(userAvatarUri) { + derivedStateOf { + listOfNotNull( + AvatarAction.TakePhoto, + AvatarAction.ChoosePhoto, + AvatarAction.Remove.takeIf { userAvatarUri != null }, + ).toImmutableList() + } + } + + val saveAction: MutableState> = remember { mutableStateOf(Async.Uninitialized) } + val localCoroutineScope = rememberCoroutineScope() + fun handleEvents(event: UserPreferencesEvents) { + when (event) { + is UserPreferencesEvents.Save -> currentUser?.let { + localCoroutineScope.saveChanges(userDisplayName, userAvatarUri, it, saveAction) + } + is UserPreferencesEvents.HandleAvatarAction -> { + when (event.action) { + AvatarAction.ChoosePhoto -> galleryImagePicker.launch() + AvatarAction.TakePhoto -> cameraPhotoPicker.launch() + AvatarAction.Remove -> userAvatarUri = null + } + } + + is UserPreferencesEvents.UpdateDisplayName -> userDisplayName = event.name + UserPreferencesEvents.CancelSaveChanges -> saveAction.value = Async.Uninitialized + } + } + + val canSave = remember(userDisplayName, userAvatarUri, currentUser) { + val hasProfileChanged = hasDisplayNameChanged(userDisplayName, currentUser) + || hasAvatarUrlChanged(userAvatarUri, currentUser) + !userDisplayName.isNullOrBlank() && hasProfileChanged + } + + return UserPreferencesState( + userId = currentUser?.userId, + displayName = userDisplayName.orEmpty(), + userAvatarUrl = userAvatarUri, + avatarActions = avatarActions, + saveButtonEnabled = canSave && saveAction.value !is Async.Loading, + saveAction = saveAction.value, + eventSink = ::handleEvents + ) + } + + private fun hasDisplayNameChanged(name: String?, currentUser: MatrixUser?) = name?.trim() != currentUser?.displayName?.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>) = launch { + matrixClient.getCurrentUser() + val results = mutableListOf>() + suspend { + if (!name.isNullOrEmpty() && name.trim() != currentUser.displayName.orEmpty().trim()) { + results.add(matrixClient.setDisplayName(name).onFailure { + Timber.e(it, "Failed to set user's display name") + }) + } + if (avatarUri?.toString()?.trim() != currentUser.avatarUrl?.trim()) { + results.add(updateAvatar(avatarUri).onFailure { + Timber.e(it, "Failed to update user's avatar") + }) + } + if (results.all { it.isSuccess }) Unit else results.first { it.isFailure }.getOrThrow() + }.runCatchingUpdatingState(action) + } + + private suspend fun updateAvatar(avatarUri: Uri?): Result { + return runCatching { + if (avatarUri != null) { + val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow() + matrixClient.uploadAvatar(MimeTypes.Jpeg, preprocessed.file.readBytes()).getOrThrow() + } else { + matrixClient.removeAvatar().getOrThrow() + } + } + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/screen/UserPreferencesState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/screen/UserPreferencesState.kt new file mode 100644 index 0000000000..ee70194638 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/screen/UserPreferencesState.kt @@ -0,0 +1,33 @@ +/* + * 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.features.preferences.impl.user.screen + +import android.net.Uri +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.ui.media.AvatarAction +import kotlinx.collections.immutable.ImmutableList + +data class UserPreferencesState( + val userId: UserId?, + val displayName: String, + val userAvatarUrl: Uri?, + val avatarActions: ImmutableList, + val saveButtonEnabled: Boolean, + val saveAction: Async, + val eventSink: (UserPreferencesEvents) -> Unit +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/screen/UserPreferencesStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/screen/UserPreferencesStateProvider.kt new file mode 100644 index 0000000000..2c87c591c6 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/screen/UserPreferencesStateProvider.kt @@ -0,0 +1,40 @@ +/* + * 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.features.preferences.impl.user.screen + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.core.UserId +import kotlinx.collections.immutable.persistentListOf + +open class UserPreferencesStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aUserPreferencesState(), + // Add other states here + ) +} + +fun aUserPreferencesState() = UserPreferencesState( + userId = UserId("@john.doe:matrix.org"), + displayName = "John Doe", + userAvatarUrl = null, + avatarActions = persistentListOf(), + saveAction = Async.Uninitialized, + saveButtonEnabled = true, + eventSink = {} +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/screen/UserPreferencesView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/screen/UserPreferencesView.kt new file mode 100644 index 0000000000..aba122cef4 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/screen/UserPreferencesView.kt @@ -0,0 +1,271 @@ +/* + * 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.features.preferences.impl.user.screen + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.ExperimentalMaterialApi +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.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.components.LabelledTextField +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.button.BackButton +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +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.Text +import io.element.android.libraries.designsystem.theme.components.TextButton +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.UnsavedAvatar +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class) +@Composable +fun UserPreferencesView( + state: UserPreferencesState, + onBackPressed: () -> Unit, + onProfileEdited: () -> Unit, + modifier: Modifier = Modifier, +) { + val coroutineScope = rememberCoroutineScope() + val focusManager = LocalFocusManager.current + val itemActionsBottomSheetState = rememberModalBottomSheetState( + initialValue = ModalBottomSheetValue.Hidden, + ) + + fun onAvatarClicked() { + focusManager.clearFocus() + coroutineScope.launch { + itemActionsBottomSheetState.show() + } + } + + Scaffold( + modifier = modifier.clearFocusOnTap(focusManager), + topBar = { + TopAppBar( + title = { + Text( + text = "Edit profile", + style = ElementTheme.typography.aliasScreenTitle, + ) + }, + navigationIcon = { BackButton(onClick = onBackPressed) }, + actions = { + TextButton( + text = stringResource(CommonStrings.action_save), + enabled = state.saveButtonEnabled, + onClick = { + focusManager.clearFocus() + state.eventSink(UserPreferencesEvents.Save) + }, + ) + } + ) + }, + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .padding(horizontal = 16.dp) + .navigationBarsPadding() + .imePadding() + .verticalScroll(rememberScrollState()) + ) { + Spacer(modifier = Modifier.height(24.dp)) + EditableAvatarView(state, ::onAvatarClicked) + Spacer(modifier = Modifier.height(60.dp)) + + LabelledTextField( + label = "Display name", + value = state.displayName, + placeholder = stringResource(CommonStrings.common_room_name_placeholder), + singleLine = true, + onValueChange = { state.eventSink(UserPreferencesEvents.UpdateDisplayName(it)) }, + ) + } + + AvatarActionBottomSheet( + actions = state.avatarActions, + modalBottomSheetState = itemActionsBottomSheetState, + onActionSelected = { state.eventSink(UserPreferencesEvents.HandleAvatarAction(it)) } + ) + + when (state.saveAction) { + is Async.Loading -> { + ProgressDialog(text = "Updating profile...") + } + + is Async.Failure -> { + ErrorDialog( + content = "Error updating profile", + onDismiss = { state.eventSink(UserPreferencesEvents.CancelSaveChanges) }, + ) + } + + is Async.Success -> { + LaunchedEffect(state.saveAction) { + onProfileEdited() + } + } + + else -> Unit + } + } +} + +@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 = + pointerInput(Unit) { + detectTapGestures(onTap = { + focusManager.clearFocus() + }) + } + +@Preview +@Composable +fun UserPreferencesViewLightPreview(@PreviewParameter(UserPreferencesStateProvider::class) state: UserPreferencesState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +fun UserPreferencesViewDarkPreview(@PreviewParameter(UserPreferencesStateProvider::class) state: UserPreferencesState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: UserPreferencesState) { + UserPreferencesView( + onBackPressed = {}, + onProfileEdited = {}, + state = state, + ) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index 611cb4303c..fc215e4780 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -47,6 +47,9 @@ interface MatrixClient : Closeable { suspend fun createDM(userId: UserId): Result suspend fun getProfile(userId: UserId): Result suspend fun searchUsers(searchTerm: String, limit: Long): Result + suspend fun setDisplayName(displayName: String): Result + suspend fun uploadAvatar(mimeType: String, data: ByteArray): Result + suspend fun removeAvatar(): Result fun syncService(): SyncService fun sessionVerificationService(): SessionVerificationService fun pushersService(): PushersService diff --git a/libraries/matrix/impl/build.gradle.kts b/libraries/matrix/impl/build.gradle.kts index a2b616f989..97eb7b6864 100644 --- a/libraries/matrix/impl/build.gradle.kts +++ b/libraries/matrix/impl/build.gradle.kts @@ -29,8 +29,8 @@ anvil { } dependencies { - // implementation(projects.libraries.rustsdk) - implementation(libs.matrix.sdk) + implementation(projects.libraries.rustsdk) +// implementation(libs.matrix.sdk) implementation(projects.libraries.di) implementation(projects.libraries.androidutils) implementation(projects.libraries.network) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index c4a3ab3ef2..d99c60b286 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -276,6 +276,23 @@ class RustMatrixClient constructor( } } + override suspend fun setDisplayName(displayName: String): Result = + withContext(sessionDispatcher) { + runCatching { client.setDisplayName(displayName) } + } + + @OptIn(ExperimentalUnsignedTypes::class) + override suspend fun uploadAvatar(mimeType: String, data: ByteArray): Result = + withContext(sessionDispatcher) { + runCatching { client.uploadAvatar(mimeType, data.toUByteArray().toList()) } + } + + override suspend fun removeAvatar(): Result = + withContext(sessionDispatcher) { + runCatching { client.removeAvatar() } + } + + override fun syncService(): SyncService = rustSyncService override fun sessionVerificationService(): SessionVerificationService = verificationService