From fa277f062f6e265082fb2fd76cdb12e1b28cb2ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 13 Sep 2023 15:59:05 +0200 Subject: [PATCH 1/6] Add preference screen for user profile --- features/preferences/impl/build.gradle.kts | 4 +- .../preferences/impl/PreferencesFlowNode.kt | 11 + .../impl/root/PreferencesRootNode.kt | 8 +- .../impl/root/PreferencesRootView.kt | 10 +- .../impl/user/screen/UserPreferencesEvents.kt | 26 ++ .../impl/user/screen/UserPreferencesNode.kt | 46 +++ .../user/screen/UserPreferencesPresenter.kt | 146 ++++++++++ .../impl/user/screen/UserPreferencesState.kt | 33 +++ .../screen/UserPreferencesStateProvider.kt | 40 +++ .../impl/user/screen/UserPreferencesView.kt | 271 ++++++++++++++++++ .../libraries/matrix/api/MatrixClient.kt | 3 + libraries/matrix/impl/build.gradle.kts | 4 +- .../libraries/matrix/impl/RustMatrixClient.kt | 17 ++ 13 files changed, 614 insertions(+), 5 deletions(-) create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/screen/UserPreferencesEvents.kt create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/screen/UserPreferencesNode.kt create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/screen/UserPreferencesPresenter.kt create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/screen/UserPreferencesState.kt create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/screen/UserPreferencesStateProvider.kt create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/screen/UserPreferencesView.kt 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 From 41e614310f34e399a6139e170177e68f117eebb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Fri, 15 Sep 2023 14:04:21 +0200 Subject: [PATCH 2/6] Changes: - Improve UI to match designs. - Extract `EditableAvatarView` component. - Create `LabelledOutlinedTextField`. - Get strings from Localazy. --- .../preferences/impl/PreferencesFlowNode.kt | 14 +- .../impl/root/PreferencesRootNode.kt | 7 +- .../impl/root/PreferencesRootView.kt | 4 +- .../EditUserProfileEvents.kt} | 12 +- .../EditUserProfileNode.kt} | 20 ++- .../EditUserProfilePresenter.kt} | 53 +++---- .../EditUserProfileState.kt} | 6 +- .../EditUserProfileStateProvider.kt} | 10 +- .../EditUserProfileView.kt} | 149 +++++------------- .../impl/src/main/res/values/localazy.xml | 9 ++ .../impl/edit/RoomDetailsEditView.kt | 76 ++------- .../components/LabelledOutlinedTextField.kt | 91 +++++++++++ .../components/avatar/AvatarSize.kt | 2 + libraries/matrix/impl/build.gradle.kts | 4 +- .../ui/components/EditableAvatarView.kt | 97 ++++++++++++ tools/localazy/config.json | 6 + 16 files changed, 325 insertions(+), 235 deletions(-) rename features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/{screen/UserPreferencesEvents.kt => editprofile/EditUserProfileEvents.kt} (64%) rename features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/{screen/UserPreferencesNode.kt => editprofile/EditUserProfileNode.kt} (69%) rename features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/{screen/UserPreferencesPresenter.kt => editprofile/EditUserProfilePresenter.kt} (77%) rename features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/{screen/UserPreferencesState.kt => editprofile/EditUserProfileState.kt} (87%) rename features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/{screen/UserPreferencesStateProvider.kt => editprofile/EditUserProfileStateProvider.kt} (78%) rename features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/{screen/UserPreferencesView.kt => editprofile/EditUserProfileView.kt} (57%) create mode 100644 features/preferences/impl/src/main/res/values/localazy.xml create mode 100644 libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LabelledOutlinedTextField.kt create mode 100644 libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/EditableAvatarView.kt 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 0ee29dd1f9..6cf0390db2 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,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.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.features.preferences.impl.user.editprofile.EditUserProfileNode import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler import io.element.android.libraries.architecture.createNode import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.user.MatrixUser import kotlinx.parcelize.Parcelize @ContributesNode(SessionScope::class) @@ -84,7 +85,7 @@ class PreferencesFlowNode @AssistedInject constructor( data class EditDefaultNotificationSetting(val isOneToOne: Boolean) : NavTarget @Parcelize - data object UserProfile : NavTarget + data class UserProfile(val matrixUser: MatrixUser) : NavTarget } override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { @@ -119,8 +120,8 @@ class PreferencesFlowNode @AssistedInject constructor( backstack.push(NavTarget.AdvancedSettings) } - override fun onOpenUserProfile() { - backstack.push(NavTarget.UserProfile) + override fun onOpenUserProfile(matrixUser: MatrixUser) { + backstack.push(NavTarget.UserProfile(matrixUser)) } } createNode(buildContext, plugins = listOf(callback)) @@ -157,8 +158,9 @@ class PreferencesFlowNode @AssistedInject constructor( NavTarget.AdvancedSettings -> { createNode(buildContext) } - NavTarget.UserProfile -> { - createNode(buildContext) + is NavTarget.UserProfile -> { + val inputs = EditUserProfileNode.Inputs(navTarget.matrixUser) + createNode(buildContext, listOf(inputs)) } } } 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 3a12ba4478..407832627b 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 @@ -29,6 +29,7 @@ import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.theme.ElementTheme import timber.log.Timber @@ -47,7 +48,7 @@ class PreferencesRootNode @AssistedInject constructor( fun onOpenDeveloperSettings() fun onOpenNotificationSettings() fun onOpenAdvancedSettings() - fun onOpenUserProfile() + fun onOpenUserProfile(matrixUser: MatrixUser) } private fun onOpenBugReport() { @@ -92,8 +93,8 @@ class PreferencesRootNode @AssistedInject constructor( plugins().forEach { it.onOpenNotificationSettings() } } - private fun onOpenUserProfile() { - plugins().forEach { it.onOpenUserProfile() } + private fun onOpenUserProfile(matrixUser: MatrixUser) { + plugins().forEach { it.onOpenUserProfile(matrixUser) } } @Composable 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 d8a05b0db8..933f22bc13 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 @@ -63,7 +63,7 @@ fun PreferencesRootView( onOpenAdvancedSettings: () -> Unit, onSuccessLogout: (logoutUrlResult: String?) -> Unit, onOpenNotificationSettings: () -> Unit, - onOpenUserProfile: () -> Unit, + onOpenUserProfile: (MatrixUser) -> Unit, modifier: Modifier = Modifier, ) { val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) @@ -77,7 +77,7 @@ fun PreferencesRootView( ) { UserPreferences( modifier = Modifier.clickable { - onOpenUserProfile() + state.myUser?.let(onOpenUserProfile) }, user = state.myUser, ) 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/editprofile/EditUserProfileEvents.kt similarity index 64% rename from features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/screen/UserPreferencesEvents.kt rename to features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileEvents.kt index c54ea98afc..5c53ec23c4 100644 --- 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/editprofile/EditUserProfileEvents.kt @@ -14,13 +14,13 @@ * 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 -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 +sealed interface EditUserProfileEvents { + data class HandleAvatarAction(val action: AvatarAction) : EditUserProfileEvents + data class UpdateDisplayName(val name: String) : EditUserProfileEvents + data object Save : EditUserProfileEvents + data object CancelSaveChanges : EditUserProfileEvents } 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/editprofile/EditUserProfileNode.kt similarity index 69% rename from features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/screen/UserPreferencesNode.kt rename to features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileNode.kt index eaccb6fbbd..738ae4ec6d 100644 --- 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/editprofile/EditUserProfileNode.kt @@ -14,7 +14,7 @@ * 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.ui.Modifier @@ -24,22 +24,32 @@ 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.architecture.NodeInputs +import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.user.MatrixUser @ContributesNode(SessionScope::class) -class UserPreferencesNode @AssistedInject constructor( +class EditUserProfileNode @AssistedInject constructor( @Assisted buildContext: BuildContext, @Assisted plugins: List, - private val presenter: UserPreferencesPresenter, + presenterFactory: EditUserProfilePresenter.Factory, ) : Node(buildContext, plugins = plugins) { + data class Inputs( + val matrixUser: MatrixUser + ) : NodeInputs + + val matrixUser = inputs().matrixUser + val presenter = presenterFactory.create(matrixUser) + @Composable override fun View(modifier: Modifier) { val state = presenter.present() - UserPreferencesView( + EditUserProfileView( state = state, onBackPressed = ::navigateUp, - onProfileEdited = ::navigateUp, // TODO: check if something else is needed + onProfileEdited = ::navigateUp, 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/editprofile/EditUserProfilePresenter.kt similarity index 77% rename from features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/screen/UserPreferencesPresenter.kt rename to features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt index 49e28144bb..6557079de3 100644 --- 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/editprofile/EditUserProfilePresenter.kt @@ -14,19 +14,19 @@ * 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 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 dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runCatchingUpdatingState @@ -41,19 +41,23 @@ 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( +class EditUserProfilePresenter @AssistedInject constructor( + @Assisted private val matrixUser: MatrixUser, private val matrixClient: MatrixClient, private val mediaPickerProvider: PickerProvider, private val mediaPreProcessor: MediaPreProcessor, -) : Presenter { +) : Presenter { + + @AssistedFactory + interface Factory { + fun create(matrixUser: MatrixUser): EditUserProfilePresenter + } @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) } + override fun present(): EditUserProfileState { + var userAvatarUri = remember { matrixUser.avatarUrl?.let { Uri.parse(it) } } + var userDisplayName = remember { matrixUser.displayName } val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker( onResult = { uri -> if (uri != null) userAvatarUri = uri } ) @@ -61,10 +65,6 @@ class UserPreferencesPresenter @Inject constructor( onResult = { uri -> if (uri != null) userAvatarUri = uri } ) - LaunchedEffect(Unit) { - currentUser = matrixClient.getCurrentUser() - } - val avatarActions by remember(userAvatarUri) { derivedStateOf { listOfNotNull( @@ -77,12 +77,10 @@ class UserPreferencesPresenter @Inject constructor( val saveAction: MutableState> = remember { mutableStateOf(Async.Uninitialized) } val localCoroutineScope = rememberCoroutineScope() - fun handleEvents(event: UserPreferencesEvents) { + fun handleEvents(event: EditUserProfileEvents) { when (event) { - is UserPreferencesEvents.Save -> currentUser?.let { - localCoroutineScope.saveChanges(userDisplayName, userAvatarUri, it, saveAction) - } - is UserPreferencesEvents.HandleAvatarAction -> { + is EditUserProfileEvents.Save -> localCoroutineScope.saveChanges(userDisplayName, userAvatarUri, matrixUser, saveAction) + is EditUserProfileEvents.HandleAvatarAction -> { when (event.action) { AvatarAction.ChoosePhoto -> galleryImagePicker.launch() AvatarAction.TakePhoto -> cameraPhotoPicker.launch() @@ -90,19 +88,19 @@ class UserPreferencesPresenter @Inject constructor( } } - is UserPreferencesEvents.UpdateDisplayName -> userDisplayName = event.name - UserPreferencesEvents.CancelSaveChanges -> saveAction.value = Async.Uninitialized + is EditUserProfileEvents.UpdateDisplayName -> userDisplayName = event.name + EditUserProfileEvents.CancelSaveChanges -> saveAction.value = Async.Uninitialized } } - val canSave = remember(userDisplayName, userAvatarUri, currentUser) { - val hasProfileChanged = hasDisplayNameChanged(userDisplayName, currentUser) - || hasAvatarUrlChanged(userAvatarUri, currentUser) + val canSave = remember(userDisplayName, userAvatarUri) { + val hasProfileChanged = hasDisplayNameChanged(userDisplayName, matrixUser) + || hasAvatarUrlChanged(userAvatarUri, matrixUser) !userDisplayName.isNullOrBlank() && hasProfileChanged } - return UserPreferencesState( - userId = currentUser?.userId, + return EditUserProfileState( + userId = matrixUser.userId, displayName = userDisplayName.orEmpty(), userAvatarUrl = userAvatarUri, 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 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()) { 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/editprofile/EditUserProfileState.kt similarity index 87% rename from features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/screen/UserPreferencesState.kt rename to features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileState.kt index ee70194638..87668e6f45 100644 --- 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/editprofile/EditUserProfileState.kt @@ -14,7 +14,7 @@ * 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 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 kotlinx.collections.immutable.ImmutableList -data class UserPreferencesState( +data class EditUserProfileState( val userId: UserId?, val displayName: String, val userAvatarUrl: Uri?, val avatarActions: ImmutableList, val saveButtonEnabled: Boolean, val saveAction: Async, - val eventSink: (UserPreferencesEvents) -> Unit + val eventSink: (EditUserProfileEvents) -> 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/editprofile/EditUserProfileStateProvider.kt similarity index 78% rename from features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/screen/UserPreferencesStateProvider.kt rename to features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileStateProvider.kt index 2c87c591c6..5e4ccb95cb 100644 --- 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/editprofile/EditUserProfileStateProvider.kt @@ -14,22 +14,22 @@ * 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 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 +open class EditUserProfileStateProvider : PreviewParameterProvider { + override val values: Sequence get() = sequenceOf( - aUserPreferencesState(), + aEditUserProfileState(), // Add other states here ) } -fun aUserPreferencesState() = UserPreferencesState( +fun aEditUserProfileState() = EditUserProfileState( userId = UserId("@john.doe:matrix.org"), displayName = "John Doe", userAvatarUrl = null, 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/editprofile/EditUserProfileView.kt similarity index 57% rename from features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/screen/UserPreferencesView.kt rename to features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt index aba122cef4..02423470a9 100644 --- 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/editprofile/EditUserProfileView.kt @@ -14,71 +14,59 @@ * 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.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.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter 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.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.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.matrix.ui.components.EditableAvatarView 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, +fun EditUserProfileView( + state: EditUserProfileState, onBackPressed: () -> Unit, onProfileEdited: () -> Unit, modifier: Modifier = Modifier, @@ -102,7 +90,7 @@ fun UserPreferencesView( TopAppBar( title = { Text( - text = "Edit profile", + text = stringResource(R.string.screen_edit_profile_title), style = ElementTheme.typography.aliasScreenTitle, ) }, @@ -113,7 +101,7 @@ fun UserPreferencesView( enabled = state.saveButtonEnabled, onClick = { focusManager.clearFocus() - state.eventSink(UserPreferencesEvents.Save) + state.eventSink(EditUserProfileEvents.Save) }, ) } @@ -129,33 +117,50 @@ fun UserPreferencesView( .verticalScroll(rememberScrollState()) ) { Spacer(modifier = Modifier.height(24.dp)) - EditableAvatarView(state, ::onAvatarClicked) - Spacer(modifier = Modifier.height(60.dp)) + EditableAvatarView( + 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( - label = "Display name", + LabelledOutlinedTextField( + label = stringResource(R.string.screen_edit_profile_display_name), value = state.displayName, placeholder = stringResource(CommonStrings.common_room_name_placeholder), singleLine = true, - onValueChange = { state.eventSink(UserPreferencesEvents.UpdateDisplayName(it)) }, + onValueChange = { state.eventSink(EditUserProfileEvents.UpdateDisplayName(it)) }, ) } AvatarActionBottomSheet( actions = state.avatarActions, modalBottomSheetState = itemActionsBottomSheetState, - onActionSelected = { state.eventSink(UserPreferencesEvents.HandleAvatarAction(it)) } + onActionSelected = { state.eventSink(EditUserProfileEvents.HandleAvatarAction(it)) } ) when (state.saveAction) { is Async.Loading -> { - ProgressDialog(text = "Updating profile...") + ProgressDialog(text = stringResource(R.string.screen_edit_profile_updating_details)) } is Async.Failure -> { ErrorDialog( - content = "Error updating profile", - onDismiss = { state.eventSink(UserPreferencesEvents.CancelSaveChanges) }, + title = stringResource(R.string.screen_edit_profile_error_title), + 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 = - pointerInput(Unit) { + this.pointerInput(Unit) { detectTapGestures(onTap = { focusManager.clearFocus() }) @@ -253,17 +184,17 @@ private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier = @Preview @Composable -fun UserPreferencesViewLightPreview(@PreviewParameter(UserPreferencesStateProvider::class) state: UserPreferencesState) = +fun EditUserProfileViewLightPreview(@PreviewParameter(EditUserProfileStateProvider::class) state: EditUserProfileState) = ElementPreviewLight { ContentToPreview(state) } @Preview @Composable -fun UserPreferencesViewDarkPreview(@PreviewParameter(UserPreferencesStateProvider::class) state: UserPreferencesState) = +fun EditUserProfileViewDarkPreview(@PreviewParameter(EditUserProfileStateProvider::class) state: EditUserProfileState) = ElementPreviewDark { ContentToPreview(state) } @Composable -private fun ContentToPreview(state: UserPreferencesState) { - UserPreferencesView( +private fun ContentToPreview(state: EditUserProfileState) { + EditUserProfileView( onBackPressed = {}, onProfileEdited = {}, state = state, diff --git a/features/preferences/impl/src/main/res/values/localazy.xml b/features/preferences/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..f01ae2b5e1 --- /dev/null +++ b/features/preferences/impl/src/main/res/values/localazy.xml @@ -0,0 +1,9 @@ + + + "Display name" + "Your display name" + "An unknown error was encountered and the information couldn\'t be changed." + "Unable to update profile" + "Edit profile" + "Updating profile…" + diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt index cd0cbf878e..4553161d73 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt @@ -18,37 +18,27 @@ 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.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.text.KeyboardOptions 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 @@ -61,21 +51,18 @@ import io.element.android.features.roomdetails.impl.R 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.matrix.ui.components.EditableAvatarView import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.coroutines.launch @@ -134,7 +121,14 @@ fun RoomDetailsEditView( .verticalScroll(rememberScrollState()) ) { 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)) 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 private fun LabelledReadOnlyField( title: String, @@ -279,7 +223,7 @@ private fun LabelledReadOnlyField( } private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier = - pointerInput(Unit) { + this.pointerInput(Unit) { detectTapGestures(onTap = { focusManager.clearFocus() }) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LabelledOutlinedTextField.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LabelledOutlinedTextField.kt new file mode 100644 index 0000000000..a273acf25c --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LabelledOutlinedTextField.kt @@ -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", + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt index 38b1df6dce..45d7780393 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt @@ -43,5 +43,7 @@ enum class AvatarSize(val dp: Dp) { RoomInviteItem(52.dp), InviteSender(16.dp), + EditRoomDetails(70.dp), + NotificationsOptIn(32.dp), } diff --git a/libraries/matrix/impl/build.gradle.kts b/libraries/matrix/impl/build.gradle.kts index 97eb7b6864..88cc929e79 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/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/EditableAvatarView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/EditableAvatarView.kt new file mode 100644 index 0000000000..f97227be7e --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/EditableAvatarView.kt @@ -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, + ) + } + } + } +} diff --git a/tools/localazy/config.json b/tools/localazy/config.json index b92e02f670..a69b7d2273 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -128,6 +128,12 @@ "includeRegex": [ "screen_create_poll_.*" ] + }, + { + "name": ":features:preferences:impl", + "includeRegex": [ + "screen_edit_profile_.*" + ] } ] } From 19caf2e7a7424e1354928390985a6aa81197e433 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Fri, 15 Sep 2023 16:00:36 +0200 Subject: [PATCH 3/6] Create tests --- features/preferences/impl/build.gradle.kts | 3 + .../editprofile/EditUserProfilePresenter.kt | 11 +- .../EditUserProfilePresenterTest.kt | 494 ++++++++++++++++++ .../libraries/matrix/test/FakeMatrixClient.kt | 37 ++ .../ui/components/MatrixUserProvider.kt | 7 +- 5 files changed, 546 insertions(+), 6 deletions(-) create mode 100644 features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts index 4d0403809d..b28a068708 100644 --- a/features/preferences/impl/build.gradle.kts +++ b/features/preferences/impl/build.gradle.kts @@ -66,8 +66,11 @@ dependencies { testImplementation(libs.molecule.runtime) testImplementation(libs.test.truth) testImplementation(libs.test.turbine) + testImplementation(libs.test.mockk) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.featureflag.test) + testImplementation(projects.libraries.mediapickers.test) + testImplementation(projects.libraries.mediaupload.test) testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.pushstore.test) testImplementation(projects.features.rageshake.test) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt index 6557079de3..edbb2467f2 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt @@ -24,6 +24,8 @@ 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 dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -33,7 +35,6 @@ 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 @@ -56,8 +57,8 @@ class EditUserProfilePresenter @AssistedInject constructor( @Composable override fun present(): EditUserProfileState { - var userAvatarUri = remember { matrixUser.avatarUrl?.let { Uri.parse(it) } } - var userDisplayName = remember { matrixUser.displayName } + var userAvatarUri by rememberSaveable { mutableStateOf(matrixUser.avatarUrl?.let { Uri.parse(it) }) } + var userDisplayName by rememberSaveable { mutableStateOf(matrixUser.displayName) } val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker( onResult = { uri -> if (uri != null) userAvatarUri = uri } ) @@ -106,7 +107,7 @@ class EditUserProfilePresenter @AssistedInject constructor( avatarActions = avatarActions, saveButtonEnabled = canSave && saveAction.value !is Async.Loading, saveAction = saveAction.value, - eventSink = ::handleEvents + eventSink = { handleEvents(it) }, ) } @@ -138,6 +139,6 @@ class EditUserProfilePresenter @AssistedInject constructor( } else { matrixClient.removeAvatar().getOrThrow() } - } + }.onFailure { it.printStackTrace() } } } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt new file mode 100644 index 0000000000..c377265689 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt @@ -0,0 +1,494 @@ +/* + * 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.editprofile + +import android.net.Uri +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.test.AN_AVATAR_URL +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.matrix.ui.media.AvatarAction +import io.element.android.libraries.mediapickers.test.FakePickerProvider +import io.element.android.libraries.mediaupload.api.MediaUploadInfo +import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor +import io.element.android.tests.testutils.WarmUpRule +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.io.File + +@ExperimentalCoroutinesApi +class EditUserProfilePresenterTest { + + @get:Rule + val warmUpRule = WarmUpRule() + + private lateinit var fakePickerProvider: FakePickerProvider + private lateinit var fakeMediaPreProcessor: FakeMediaPreProcessor + + private val userAvatarUri: Uri = mockk() + private val anotherAvatarUri: Uri = mockk() + + private val fakeFileContents = ByteArray(2) + + @Before + fun setup() { + fakePickerProvider = FakePickerProvider() + fakeMediaPreProcessor = FakeMediaPreProcessor() + mockkStatic(Uri::class) + + every { Uri.parse(AN_AVATAR_URL) } returns userAvatarUri + every { Uri.parse(ANOTHER_AVATAR_URL) } returns anotherAvatarUri + } + + @After + fun tearDown() { + unmockkAll() + } + + private fun anEditUserProfilePresenter( + matrixClient: MatrixClient = FakeMatrixClient(), + matrixUser: MatrixUser = aMatrixUser(), + ): EditUserProfilePresenter { + return EditUserProfilePresenter( + matrixClient = matrixClient, + matrixUser = matrixUser, + mediaPickerProvider = fakePickerProvider, + mediaPreProcessor = fakeMediaPreProcessor, + ) + } + + @Test + fun `present - initial state is created from room info`() = runTest { + val user = aMatrixUser(avatarUrl = AN_AVATAR_URL) + val presenter = anEditUserProfilePresenter(matrixUser = user) + + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.userId).isEqualTo(user.userId.value) + assertThat(initialState.displayName).isEqualTo(user.displayName) + assertThat(initialState.userAvatarUrl).isEqualTo(userAvatarUri) + assertThat(initialState.avatarActions).containsExactly( + AvatarAction.ChoosePhoto, + AvatarAction.TakePhoto, + AvatarAction.Remove + ) + assertThat(initialState.saveButtonEnabled).isEqualTo(false) + assertThat(initialState.saveAction).isInstanceOf(Async.Uninitialized::class.java) + } + } + + @Test + fun `present - updates state in response to changes`() = runTest { + val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) + val presenter = anEditUserProfilePresenter(matrixUser = user) + + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.displayName).isEqualTo("Name") + assertThat(initialState.userAvatarUrl).isEqualTo(userAvatarUri) + + initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name II")) + awaitItem().apply { + assertThat(displayName).isEqualTo("Name II") + assertThat(userAvatarUrl).isEqualTo(userAvatarUri) + } + + initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name III")) + awaitItem().apply { + assertThat(displayName).isEqualTo("Name III") + assertThat(userAvatarUrl).isEqualTo(userAvatarUri) + } + + initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove)) + awaitItem().apply { + assertThat(displayName).isEqualTo("Name III") + assertThat(userAvatarUrl).isNull() + } + } + } + + @Test + fun `present - obtains avatar uris from gallery`() = runTest { + val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) + + fakePickerProvider.givenResult(anotherAvatarUri) + + val presenter = anEditUserProfilePresenter(matrixUser = user) + + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.userAvatarUrl).isEqualTo(userAvatarUri) + + initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) + awaitItem().apply { + assertThat(userAvatarUrl).isEqualTo(anotherAvatarUri) + } + } + } + + @Test + fun `present - obtains avatar uris from camera`() = runTest { + val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) + + fakePickerProvider.givenResult(anotherAvatarUri) + + val presenter = anEditUserProfilePresenter(matrixUser = user) + + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.userAvatarUrl).isEqualTo(userAvatarUri) + + initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.TakePhoto)) + awaitItem().apply { + assertThat(userAvatarUrl).isEqualTo(anotherAvatarUri) + } + } + } + + @Test + fun `present - updates save button state`() = runTest { + val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) + + fakePickerProvider.givenResult(userAvatarUri) + + val presenter = anEditUserProfilePresenter(matrixUser = user) + + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.saveButtonEnabled).isEqualTo(false) + + // Once a change is made, the save button is enabled + initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name II")) + awaitItem().apply { + assertThat(saveButtonEnabled).isEqualTo(true) + } + + // If it's reverted then the save disables again + initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name")) + awaitItem().apply { + assertThat(saveButtonEnabled).isEqualTo(false) + } + + // Make a change... + initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove)) + awaitItem().apply { + assertThat(saveButtonEnabled).isEqualTo(true) + } + + // Revert it... + initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) + awaitItem().apply { + assertThat(saveButtonEnabled).isEqualTo(false) + } + } + } + + @Test + fun `present - updates save button state when initial values are null`() = runTest { + val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = null) + + fakePickerProvider.givenResult(userAvatarUri) + + val presenter = anEditUserProfilePresenter(matrixUser = user) + + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.saveButtonEnabled).isEqualTo(false) + + // Once a change is made, the save button is enabled + initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name II")) + awaitItem().apply { + assertThat(saveButtonEnabled).isEqualTo(true) + } + + // If it's reverted then the save disables again + initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("fallback")) + awaitItem().apply { + assertThat(saveButtonEnabled).isEqualTo(false) + } + + // Make a change... + initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) + awaitItem().apply { + assertThat(saveButtonEnabled).isEqualTo(true) + } + + // Revert it... + initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove)) + awaitItem().apply { + assertThat(saveButtonEnabled).isEqualTo(false) + } + } + } + + @Test + fun `present - save changes room details if different`() = runTest { + val matrixClient = FakeMatrixClient() + val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) + + val presenter = anEditUserProfilePresenter( + matrixClient = matrixClient, + matrixUser = user + ) + + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + + initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("New name")) + initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove)) + initialState.eventSink(EditUserProfileEvents.Save) + skipItems(5) + + assertThat(matrixClient.setDisplayNameCalled).isTrue() + assertThat(matrixClient.removeAvatarCalled).isTrue() + assertThat(matrixClient.uploadAvatarCalled).isFalse() + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - save doesn't change room details if they're the same trimmed`() = runTest { + val matrixClient = FakeMatrixClient() + val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) + + val presenter = anEditUserProfilePresenter( + matrixClient = matrixClient, + matrixUser = user + ) + + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + + initialState.eventSink(EditUserProfileEvents.UpdateDisplayName(" Name ")) + initialState.eventSink(EditUserProfileEvents.Save) + + assertThat(matrixClient.setDisplayNameCalled).isTrue() + assertThat(matrixClient.uploadAvatarCalled).isFalse() + assertThat(matrixClient.removeAvatarCalled).isFalse() + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - save doesn't change name if it's now empty`() = runTest { + val matrixClient = FakeMatrixClient() + val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) + + val presenter = anEditUserProfilePresenter( + matrixClient = matrixClient, + matrixUser = user + ) + + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + + initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("")) + initialState.eventSink(EditUserProfileEvents.Save) + + assertThat(matrixClient.setDisplayNameCalled).isFalse() + assertThat(matrixClient.uploadAvatarCalled).isFalse() + assertThat(matrixClient.removeAvatarCalled).isFalse() + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - save processes and sets avatar when processor returns successfully`() = runTest { + val matrixClient = FakeMatrixClient() + val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) + + givenPickerReturnsFile() + + val presenter = anEditUserProfilePresenter( + matrixClient = matrixClient, + matrixUser = user + ) + + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + + initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) + initialState.eventSink(EditUserProfileEvents.Save) + skipItems(2) + + assertThat(matrixClient.uploadAvatarCalled).isTrue() + } + } + + @Test + fun `present - save does not set avatar data if processor fails`() = runTest { + val matrixClient = FakeMatrixClient() + val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) + + val presenter = anEditUserProfilePresenter( + matrixClient = matrixClient, + matrixUser = user + ) + + fakePickerProvider.givenResult(anotherAvatarUri) + fakeMediaPreProcessor.givenResult(Result.failure(Throwable("Oh no"))) + + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + + initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) + initialState.eventSink(EditUserProfileEvents.Save) + skipItems(2) + + assertThat(matrixClient.uploadAvatarCalled).isFalse() + + assertThat(awaitItem().saveAction).isInstanceOf(Async.Failure::class.java) + } + } + + @Test + fun `present - sets save action to failure if name update fails`() = runTest { + val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) + val matrixClient = FakeMatrixClient().apply { + givenSetDisplayNameResult(Result.failure(Throwable("!"))) + } + + saveAndAssertFailure(user, matrixClient, EditUserProfileEvents.UpdateDisplayName("New name")) + } + + @Test + fun `present - sets save action to failure if removing avatar fails`() = runTest { + val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) + val matrixClient = FakeMatrixClient().apply { + givenRemoveAvatarResult(Result.failure(Throwable("!"))) + } + + saveAndAssertFailure(user, matrixClient, EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove)) + } + + @Test + fun `present - sets save action to failure if setting avatar fails`() = runTest { + givenPickerReturnsFile() + + val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) + val matrixClient = FakeMatrixClient().apply { + givenUploadAvatarResult(Result.failure(Throwable("!"))) + } + + saveAndAssertFailure(user, matrixClient, EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) + } + + @Test + fun `present - CancelSaveChanges resets save action state`() = runTest { + givenPickerReturnsFile() + + val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) + val matrixClient = FakeMatrixClient().apply { + givenSetDisplayNameResult(Result.failure(Throwable("!"))) + } + + val presenter = anEditUserProfilePresenter(matrixUser = user) + + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + + initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("foo")) + initialState.eventSink(EditUserProfileEvents.Save) + skipItems(2) + + assertThat(awaitItem().saveAction).isInstanceOf(Async.Failure::class.java) + + initialState.eventSink(EditUserProfileEvents.CancelSaveChanges) + assertThat(awaitItem().saveAction).isInstanceOf(Async.Uninitialized::class.java) + } + } + + private suspend fun saveAndAssertFailure(matrixUser: MatrixUser, matrixClient: MatrixClient, event: EditUserProfileEvents) { + val presenter = anEditUserProfilePresenter(matrixUser = matrixUser, matrixClient = matrixClient) + + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + + initialState.eventSink(event) + initialState.eventSink(EditUserProfileEvents.Save) + skipItems(1) + + assertThat(awaitItem().saveAction).isInstanceOf(Async.Loading::class.java) + assertThat(awaitItem().saveAction).isInstanceOf(Async.Failure::class.java) + } + } + + private fun givenPickerReturnsFile() { + mockkStatic(File::readBytes) + val processedFile: File = mockk { + every { readBytes() } returns fakeFileContents + } + + fakePickerProvider.givenResult(anotherAvatarUri) + fakeMediaPreProcessor.givenResult( + Result.success( + MediaUploadInfo.AnyFile( + file = processedFile, + fileInfo = mockk(), + ) + ) + ) + } + + companion object { + private const val ANOTHER_AVATAR_URL = "example://camera/foo.jpg" + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index 660c1e268a..ce518730c4 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -58,6 +58,13 @@ class FakeMatrixClient( private val accountManagementUrlString: Result = Result.success(null), ) : MatrixClient { + var setDisplayNameCalled: Boolean = false + private set + var uploadAvatarCalled: Boolean = false + private set + var removeAvatarCalled: Boolean = false + private set + private var ignoreUserResult: Result = Result.success(Unit) private var unignoreUserResult: Result = Result.success(Unit) private var createRoomResult: Result = Result.success(A_ROOM_ID) @@ -69,6 +76,9 @@ class FakeMatrixClient( private val searchUserResults = mutableMapOf>() private val getProfileResults = mutableMapOf>() private var uploadMediaResult: Result = Result.success(AN_AVATAR_URL) + private var setDisplayNameResult: Result = Result.success(Unit) + private var uploadAvatarResult: Result = Result.success(Unit) + private var removeAvatarResult: Result = Result.success(Unit) override suspend fun getRoom(roomId: RoomId): MatrixRoom? { return getRoomResults[roomId] @@ -141,6 +151,21 @@ class FakeMatrixClient( return uploadMediaResult } + override suspend fun setDisplayName(displayName: String): Result { + setDisplayNameCalled = true + return setDisplayNameResult + } + + override suspend fun uploadAvatar(mimeType: String, data: ByteArray): Result { + uploadAvatarCalled = true + return uploadAvatarResult + } + + override suspend fun removeAvatar(): Result { + removeAvatarCalled = true + return removeAvatarResult + } + override fun sessionVerificationService(): SessionVerificationService = sessionVerificationService override fun pushersService(): PushersService = pushersService @@ -197,4 +222,16 @@ class FakeMatrixClient( fun givenUploadMediaResult(result: Result) { uploadMediaResult = result } + + fun givenSetDisplayNameResult(result: Result) { + setDisplayNameResult = result + } + + fun givenUploadAvatarResult(result: Result) { + uploadAvatarResult = result + } + + fun givenRemoveAvatarResult(result: Result) { + removeAvatarResult = result + } } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserProvider.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserProvider.kt index 923afd94ad..1240b4c1b5 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserProvider.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserProvider.kt @@ -28,9 +28,14 @@ open class MatrixUserProvider : PreviewParameterProvider { ) } -fun aMatrixUser(id: String = "@id_of_alice:server.org", displayName: String = "Alice") = MatrixUser( +fun aMatrixUser( + id: String = "@id_of_alice:server.org", + displayName: String = "Alice", + avatarUrl: String? = null, +) = MatrixUser( userId = UserId(id), displayName = displayName, + avatarUrl = avatarUrl, ) fun aMatrixUserList() = listOf( From 574e6199fee76f521f307b2da8e6c131b173da8c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 15 Sep 2023 18:20:44 +0200 Subject: [PATCH 4/6] Cleanup and compact code. Also prefer usage of DayNightPreview. --- .../editprofile/EditUserProfilePresenter.kt | 6 +- .../user/editprofile/EditUserProfileView.kt | 36 +++---- .../EditUserProfilePresenterTest.kt | 95 +++---------------- .../impl/edit/RoomDetailsEditView.kt | 2 +- .../components/LabelledOutlinedTextField.kt | 17 +--- libraries/matrix/impl/build.gradle.kts | 2 +- 6 files changed, 36 insertions(+), 122 deletions(-) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt index edbb2467f2..5d3821931e 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt @@ -95,8 +95,8 @@ class EditUserProfilePresenter @AssistedInject constructor( } val canSave = remember(userDisplayName, userAvatarUri) { - val hasProfileChanged = hasDisplayNameChanged(userDisplayName, matrixUser) - || hasAvatarUrlChanged(userAvatarUri, matrixUser) + val hasProfileChanged = hasDisplayNameChanged(userDisplayName, matrixUser) || + hasAvatarUrlChanged(userAvatarUri, matrixUser) !userDisplayName.isNullOrBlank() && hasProfileChanged } @@ -139,6 +139,6 @@ class EditUserProfilePresenter @AssistedInject constructor( } else { matrixClient.removeAvatar().getOrThrow() } - }.onFailure { it.printStackTrace() } + }.onFailure { Timber.e(it, "Unable to update avatar") } } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt index 02423470a9..5b921c047c 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt @@ -40,7 +40,6 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalFocusManager 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.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.features.preferences.impl.R @@ -50,8 +49,8 @@ import io.element.android.libraries.designsystem.components.ProgressDialog 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.preview.DayNightPreviews +import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.theme.aliasScreenTitle import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Text @@ -135,7 +134,6 @@ fun EditUserProfileView( ) } Spacer(modifier = Modifier.height(40.dp)) - LabelledOutlinedTextField( label = stringResource(R.string.screen_edit_profile_display_name), value = state.displayName, @@ -155,7 +153,6 @@ fun EditUserProfileView( is Async.Loading -> { ProgressDialog(text = stringResource(R.string.screen_edit_profile_updating_details)) } - is Async.Failure -> { ErrorDialog( title = stringResource(R.string.screen_edit_profile_error_title), @@ -163,40 +160,31 @@ fun EditUserProfileView( onDismiss = { state.eventSink(EditUserProfileEvents.CancelSaveChanges) }, ) } - is Async.Success -> { LaunchedEffect(state.saveAction) { onProfileEdited() } } - else -> Unit } } } private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier = - this.pointerInput(Unit) { + pointerInput(Unit) { detectTapGestures(onTap = { focusManager.clearFocus() }) } -@Preview +@DayNightPreviews @Composable -fun EditUserProfileViewLightPreview(@PreviewParameter(EditUserProfileStateProvider::class) state: EditUserProfileState) = - ElementPreviewLight { ContentToPreview(state) } +internal fun EditUserProfileViewPreview(@PreviewParameter(EditUserProfileStateProvider::class) state: EditUserProfileState) = + ElementPreview { + EditUserProfileView( + onBackPressed = {}, + onProfileEdited = {}, + state = state, + ) + } -@Preview -@Composable -fun EditUserProfileViewDarkPreview(@PreviewParameter(EditUserProfileStateProvider::class) state: EditUserProfileState) = - ElementPreviewDark { ContentToPreview(state) } - -@Composable -private fun ContentToPreview(state: EditUserProfileState) { - EditUserProfileView( - onBackPressed = {}, - onProfileEdited = {}, - state = state, - ) -} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt index c377265689..d3580a82b1 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt @@ -74,7 +74,7 @@ class EditUserProfilePresenterTest { unmockkAll() } - private fun anEditUserProfilePresenter( + private fun createEditUserProfilePresenter( matrixClient: MatrixClient = FakeMatrixClient(), matrixUser: MatrixUser = aMatrixUser(), ): EditUserProfilePresenter { @@ -89,8 +89,7 @@ class EditUserProfilePresenterTest { @Test fun `present - initial state is created from room info`() = runTest { val user = aMatrixUser(avatarUrl = AN_AVATAR_URL) - val presenter = anEditUserProfilePresenter(matrixUser = user) - + val presenter = createEditUserProfilePresenter(matrixUser = user) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -111,27 +110,23 @@ class EditUserProfilePresenterTest { @Test fun `present - updates state in response to changes`() = runTest { val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) - val presenter = anEditUserProfilePresenter(matrixUser = user) - + val presenter = createEditUserProfilePresenter(matrixUser = user) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() assertThat(initialState.displayName).isEqualTo("Name") assertThat(initialState.userAvatarUrl).isEqualTo(userAvatarUri) - initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name II")) awaitItem().apply { assertThat(displayName).isEqualTo("Name II") assertThat(userAvatarUrl).isEqualTo(userAvatarUri) } - initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name III")) awaitItem().apply { assertThat(displayName).isEqualTo("Name III") assertThat(userAvatarUrl).isEqualTo(userAvatarUri) } - initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove)) awaitItem().apply { assertThat(displayName).isEqualTo("Name III") @@ -143,17 +138,13 @@ class EditUserProfilePresenterTest { @Test fun `present - obtains avatar uris from gallery`() = runTest { val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) - fakePickerProvider.givenResult(anotherAvatarUri) - - val presenter = anEditUserProfilePresenter(matrixUser = user) - + val presenter = createEditUserProfilePresenter(matrixUser = user) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() assertThat(initialState.userAvatarUrl).isEqualTo(userAvatarUri) - initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) awaitItem().apply { assertThat(userAvatarUrl).isEqualTo(anotherAvatarUri) @@ -164,17 +155,13 @@ class EditUserProfilePresenterTest { @Test fun `present - obtains avatar uris from camera`() = runTest { val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) - fakePickerProvider.givenResult(anotherAvatarUri) - - val presenter = anEditUserProfilePresenter(matrixUser = user) - + val presenter = createEditUserProfilePresenter(matrixUser = user) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() assertThat(initialState.userAvatarUrl).isEqualTo(userAvatarUri) - initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.TakePhoto)) awaitItem().apply { assertThat(userAvatarUrl).isEqualTo(anotherAvatarUri) @@ -185,35 +172,28 @@ class EditUserProfilePresenterTest { @Test fun `present - updates save button state`() = runTest { val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) - fakePickerProvider.givenResult(userAvatarUri) - - val presenter = anEditUserProfilePresenter(matrixUser = user) - + val presenter = createEditUserProfilePresenter(matrixUser = user) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() assertThat(initialState.saveButtonEnabled).isEqualTo(false) - // Once a change is made, the save button is enabled initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name II")) awaitItem().apply { assertThat(saveButtonEnabled).isEqualTo(true) } - // If it's reverted then the save disables again initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name")) awaitItem().apply { assertThat(saveButtonEnabled).isEqualTo(false) } - // Make a change... initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove)) awaitItem().apply { assertThat(saveButtonEnabled).isEqualTo(true) } - // Revert it... initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) awaitItem().apply { @@ -225,35 +205,28 @@ class EditUserProfilePresenterTest { @Test fun `present - updates save button state when initial values are null`() = runTest { val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = null) - fakePickerProvider.givenResult(userAvatarUri) - - val presenter = anEditUserProfilePresenter(matrixUser = user) - + val presenter = createEditUserProfilePresenter(matrixUser = user) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() assertThat(initialState.saveButtonEnabled).isEqualTo(false) - // Once a change is made, the save button is enabled initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name II")) awaitItem().apply { assertThat(saveButtonEnabled).isEqualTo(true) } - // If it's reverted then the save disables again initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("fallback")) awaitItem().apply { assertThat(saveButtonEnabled).isEqualTo(false) } - // Make a change... initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) awaitItem().apply { assertThat(saveButtonEnabled).isEqualTo(true) } - // Revert it... initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove)) awaitItem().apply { @@ -266,26 +239,21 @@ class EditUserProfilePresenterTest { fun `present - save changes room details if different`() = runTest { val matrixClient = FakeMatrixClient() val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) - - val presenter = anEditUserProfilePresenter( + val presenter = createEditUserProfilePresenter( matrixClient = matrixClient, matrixUser = user ) - moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() - initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("New name")) initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove)) initialState.eventSink(EditUserProfileEvents.Save) skipItems(5) - assertThat(matrixClient.setDisplayNameCalled).isTrue() assertThat(matrixClient.removeAvatarCalled).isTrue() assertThat(matrixClient.uploadAvatarCalled).isFalse() - cancelAndIgnoreRemainingEvents() } } @@ -294,24 +262,19 @@ class EditUserProfilePresenterTest { fun `present - save doesn't change room details if they're the same trimmed`() = runTest { val matrixClient = FakeMatrixClient() val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) - - val presenter = anEditUserProfilePresenter( + val presenter = createEditUserProfilePresenter( matrixClient = matrixClient, matrixUser = user ) - moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() - initialState.eventSink(EditUserProfileEvents.UpdateDisplayName(" Name ")) initialState.eventSink(EditUserProfileEvents.Save) - assertThat(matrixClient.setDisplayNameCalled).isTrue() assertThat(matrixClient.uploadAvatarCalled).isFalse() assertThat(matrixClient.removeAvatarCalled).isFalse() - cancelAndIgnoreRemainingEvents() } } @@ -320,24 +283,19 @@ class EditUserProfilePresenterTest { fun `present - save doesn't change name if it's now empty`() = runTest { val matrixClient = FakeMatrixClient() val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) - - val presenter = anEditUserProfilePresenter( + val presenter = createEditUserProfilePresenter( matrixClient = matrixClient, matrixUser = user ) - moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() - initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("")) initialState.eventSink(EditUserProfileEvents.Save) - assertThat(matrixClient.setDisplayNameCalled).isFalse() assertThat(matrixClient.uploadAvatarCalled).isFalse() assertThat(matrixClient.removeAvatarCalled).isFalse() - cancelAndIgnoreRemainingEvents() } } @@ -346,23 +304,18 @@ class EditUserProfilePresenterTest { fun `present - save processes and sets avatar when processor returns successfully`() = runTest { val matrixClient = FakeMatrixClient() val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) - givenPickerReturnsFile() - - val presenter = anEditUserProfilePresenter( + val presenter = createEditUserProfilePresenter( matrixClient = matrixClient, matrixUser = user ) - moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() - initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) initialState.eventSink(EditUserProfileEvents.Save) skipItems(2) - assertThat(matrixClient.uploadAvatarCalled).isTrue() } } @@ -371,26 +324,20 @@ class EditUserProfilePresenterTest { fun `present - save does not set avatar data if processor fails`() = runTest { val matrixClient = FakeMatrixClient() val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) - - val presenter = anEditUserProfilePresenter( + val presenter = createEditUserProfilePresenter( matrixClient = matrixClient, matrixUser = user ) - fakePickerProvider.givenResult(anotherAvatarUri) fakeMediaPreProcessor.givenResult(Result.failure(Throwable("Oh no"))) - moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() - initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) initialState.eventSink(EditUserProfileEvents.Save) skipItems(2) - assertThat(matrixClient.uploadAvatarCalled).isFalse() - assertThat(awaitItem().saveAction).isInstanceOf(Async.Failure::class.java) } } @@ -401,7 +348,6 @@ class EditUserProfilePresenterTest { val matrixClient = FakeMatrixClient().apply { givenSetDisplayNameResult(Result.failure(Throwable("!"))) } - saveAndAssertFailure(user, matrixClient, EditUserProfileEvents.UpdateDisplayName("New name")) } @@ -411,61 +357,49 @@ class EditUserProfilePresenterTest { val matrixClient = FakeMatrixClient().apply { givenRemoveAvatarResult(Result.failure(Throwable("!"))) } - saveAndAssertFailure(user, matrixClient, EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove)) } @Test fun `present - sets save action to failure if setting avatar fails`() = runTest { givenPickerReturnsFile() - val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) val matrixClient = FakeMatrixClient().apply { givenUploadAvatarResult(Result.failure(Throwable("!"))) } - saveAndAssertFailure(user, matrixClient, EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) } @Test fun `present - CancelSaveChanges resets save action state`() = runTest { givenPickerReturnsFile() - val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) val matrixClient = FakeMatrixClient().apply { givenSetDisplayNameResult(Result.failure(Throwable("!"))) } - - val presenter = anEditUserProfilePresenter(matrixUser = user) - + val presenter = createEditUserProfilePresenter(matrixUser = user) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() - initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("foo")) initialState.eventSink(EditUserProfileEvents.Save) skipItems(2) - assertThat(awaitItem().saveAction).isInstanceOf(Async.Failure::class.java) - initialState.eventSink(EditUserProfileEvents.CancelSaveChanges) assertThat(awaitItem().saveAction).isInstanceOf(Async.Uninitialized::class.java) } } private suspend fun saveAndAssertFailure(matrixUser: MatrixUser, matrixClient: MatrixClient, event: EditUserProfileEvents) { - val presenter = anEditUserProfilePresenter(matrixUser = matrixUser, matrixClient = matrixClient) - + val presenter = createEditUserProfilePresenter(matrixUser = matrixUser, matrixClient = matrixClient) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() - initialState.eventSink(event) initialState.eventSink(EditUserProfileEvents.Save) skipItems(1) - assertThat(awaitItem().saveAction).isInstanceOf(Async.Loading::class.java) assertThat(awaitItem().saveAction).isInstanceOf(Async.Failure::class.java) } @@ -476,7 +410,6 @@ class EditUserProfilePresenterTest { val processedFile: File = mockk { every { readBytes() } returns fakeFileContents } - fakePickerProvider.givenResult(anotherAvatarUri) fakeMediaPreProcessor.givenResult( Result.success( diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt index 4553161d73..ac9853a387 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt @@ -223,7 +223,7 @@ private fun LabelledReadOnlyField( } private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier = - this.pointerInput(Unit) { + pointerInput(Unit) { detectTapGestures(onTap = { focusManager.clearFocus() }) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LabelledOutlinedTextField.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LabelledOutlinedTextField.kt index a273acf25c..4939cac38d 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LabelledOutlinedTextField.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LabelledOutlinedTextField.kt @@ -24,10 +24,9 @@ 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.preview.DayNightPreviews +import io.element.android.libraries.designsystem.preview.ElementPreview 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 @@ -66,16 +65,9 @@ fun LabelledOutlinedTextField( } } -@Preview +@DayNightPreviews @Composable -internal fun LabelledOutlinedTextFieldLightPreview() = ElementPreviewLight { ContentToPreview() } - -@Preview -@Composable -internal fun LabelledOutlinedTextFieldDarkPreview() = ElementPreviewDark { ContentToPreview() } - -@Composable -private fun ContentToPreview() { +internal fun LabelledOutlinedTextFieldPreview() = ElementPreview { Column { LabelledOutlinedTextField( label = "Room name", @@ -89,3 +81,4 @@ private fun ContentToPreview() { ) } } + diff --git a/libraries/matrix/impl/build.gradle.kts b/libraries/matrix/impl/build.gradle.kts index 88cc929e79..a2b616f989 100644 --- a/libraries/matrix/impl/build.gradle.kts +++ b/libraries/matrix/impl/build.gradle.kts @@ -29,7 +29,7 @@ anvil { } dependencies { -// implementation(projects.libraries.rustsdk) + // implementation(projects.libraries.rustsdk) implementation(libs.matrix.sdk) implementation(projects.libraries.di) implementation(projects.libraries.androidutils) From 1bbfad4a8fff191473dd3f4373e0624774f43670 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 15 Sep 2023 19:24:43 +0200 Subject: [PATCH 5/6] Fix the test. --- .../editprofile/EditUserProfilePresenter.kt | 9 +++- .../EditUserProfilePresenterTest.kt | 42 ++++++++++--------- .../libraries/matrix/test/FakeMatrixClient.kt | 7 ++-- 3 files changed, 33 insertions(+), 25 deletions(-) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt index 5d3821931e..793c4840d7 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.core.net.toUri import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -111,8 +112,12 @@ class EditUserProfilePresenter @AssistedInject constructor( ) } - 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 hasDisplayNameChanged(name: String?, currentUser: MatrixUser) = + name?.trim() != currentUser.displayName?.trim() + + private fun hasAvatarUrlChanged(avatarUri: Uri?, currentUser: MatrixUser) = + // Need to call `toUri()?.toString()` to make the test pass (we mockk Uri) + avatarUri?.toString()?.trim() != currentUser.avatarUrl?.toUri()?.toString()?.trim() private fun CoroutineScope.saveChanges(name: String?, avatarUri: Uri?, currentUser: MatrixUser, action: MutableState>) = launch { val results = mutableListOf>() diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt index d3580a82b1..beece60c9a 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt @@ -33,6 +33,7 @@ import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic @@ -87,14 +88,14 @@ class EditUserProfilePresenterTest { } @Test - fun `present - initial state is created from room info`() = runTest { + fun `present - initial state is created from user info`() = runTest { val user = aMatrixUser(avatarUrl = AN_AVATAR_URL) val presenter = createEditUserProfilePresenter(matrixUser = user) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() - assertThat(initialState.userId).isEqualTo(user.userId.value) + assertThat(initialState.userId).isEqualTo(user.userId) assertThat(initialState.displayName).isEqualTo(user.displayName) assertThat(initialState.userAvatarUrl).isEqualTo(userAvatarUri) assertThat(initialState.avatarActions).containsExactly( @@ -102,7 +103,7 @@ class EditUserProfilePresenterTest { AvatarAction.TakePhoto, AvatarAction.Remove ) - assertThat(initialState.saveButtonEnabled).isEqualTo(false) + assertThat(initialState.saveButtonEnabled).isFalse() assertThat(initialState.saveAction).isInstanceOf(Async.Uninitialized::class.java) } } @@ -178,26 +179,26 @@ class EditUserProfilePresenterTest { presenter.present() }.test { val initialState = awaitItem() - assertThat(initialState.saveButtonEnabled).isEqualTo(false) + assertThat(initialState.saveButtonEnabled).isFalse() // Once a change is made, the save button is enabled initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name II")) awaitItem().apply { - assertThat(saveButtonEnabled).isEqualTo(true) + assertThat(saveButtonEnabled).isTrue() } // If it's reverted then the save disables again initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name")) awaitItem().apply { - assertThat(saveButtonEnabled).isEqualTo(false) + assertThat(saveButtonEnabled).isFalse() } // Make a change... initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove)) awaitItem().apply { - assertThat(saveButtonEnabled).isEqualTo(true) + assertThat(saveButtonEnabled).isTrue() } // Revert it... initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) awaitItem().apply { - assertThat(saveButtonEnabled).isEqualTo(false) + assertThat(saveButtonEnabled).isFalse() } } } @@ -211,26 +212,26 @@ class EditUserProfilePresenterTest { presenter.present() }.test { val initialState = awaitItem() - assertThat(initialState.saveButtonEnabled).isEqualTo(false) + assertThat(initialState.saveButtonEnabled).isFalse() // Once a change is made, the save button is enabled initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name II")) awaitItem().apply { - assertThat(saveButtonEnabled).isEqualTo(true) + assertThat(saveButtonEnabled).isTrue() } // If it's reverted then the save disables again - initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("fallback")) + initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name")) awaitItem().apply { - assertThat(saveButtonEnabled).isEqualTo(false) + assertThat(saveButtonEnabled).isFalse() } // Make a change... initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) awaitItem().apply { - assertThat(saveButtonEnabled).isEqualTo(true) + assertThat(saveButtonEnabled).isTrue() } // Revert it... initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove)) awaitItem().apply { - assertThat(saveButtonEnabled).isEqualTo(false) + assertThat(saveButtonEnabled).isFalse() } } } @@ -250,7 +251,7 @@ class EditUserProfilePresenterTest { initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("New name")) initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove)) initialState.eventSink(EditUserProfileEvents.Save) - skipItems(5) + consumeItemsUntilPredicate { matrixClient.setDisplayNameCalled && matrixClient.removeAvatarCalled && !matrixClient.uploadAvatarCalled } assertThat(matrixClient.setDisplayNameCalled).isTrue() assertThat(matrixClient.removeAvatarCalled).isTrue() assertThat(matrixClient.uploadAvatarCalled).isFalse() @@ -259,7 +260,7 @@ class EditUserProfilePresenterTest { } @Test - fun `present - save doesn't change room details if they're the same trimmed`() = runTest { + fun `present - save does not change room details if they're the same trimmed`() = runTest { val matrixClient = FakeMatrixClient() val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) val presenter = createEditUserProfilePresenter( @@ -272,7 +273,8 @@ class EditUserProfilePresenterTest { val initialState = awaitItem() initialState.eventSink(EditUserProfileEvents.UpdateDisplayName(" Name ")) initialState.eventSink(EditUserProfileEvents.Save) - assertThat(matrixClient.setDisplayNameCalled).isTrue() + consumeItemsUntilPredicate { matrixClient.setDisplayNameCalled && !matrixClient.removeAvatarCalled && !matrixClient.uploadAvatarCalled } + assertThat(matrixClient.setDisplayNameCalled).isFalse() assertThat(matrixClient.uploadAvatarCalled).isFalse() assertThat(matrixClient.removeAvatarCalled).isFalse() cancelAndIgnoreRemainingEvents() @@ -280,7 +282,7 @@ class EditUserProfilePresenterTest { } @Test - fun `present - save doesn't change name if it's now empty`() = runTest { + fun `present - save does not change name if it's now empty`() = runTest { val matrixClient = FakeMatrixClient() val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) val presenter = createEditUserProfilePresenter( @@ -315,7 +317,7 @@ class EditUserProfilePresenterTest { val initialState = awaitItem() initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) initialState.eventSink(EditUserProfileEvents.Save) - skipItems(2) + consumeItemsUntilPredicate { matrixClient.uploadAvatarCalled } assertThat(matrixClient.uploadAvatarCalled).isTrue() } } @@ -377,7 +379,7 @@ class EditUserProfilePresenterTest { val matrixClient = FakeMatrixClient().apply { givenSetDisplayNameResult(Result.failure(Throwable("!"))) } - val presenter = createEditUserProfilePresenter(matrixUser = user) + val presenter = createEditUserProfilePresenter(matrixUser = user, matrixClient = matrixClient) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index ce518730c4..67a36f0db7 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -143,6 +143,7 @@ class FakeMatrixClient( override suspend fun getAccountManagementUrl(action: AccountManagementAction?): Result { return accountManagementUrlString } + override suspend fun uploadMedia( mimeType: String, data: ByteArray, @@ -151,17 +152,17 @@ class FakeMatrixClient( return uploadMediaResult } - override suspend fun setDisplayName(displayName: String): Result { + override suspend fun setDisplayName(displayName: String): Result = simulateLongTask { setDisplayNameCalled = true return setDisplayNameResult } - override suspend fun uploadAvatar(mimeType: String, data: ByteArray): Result { + override suspend fun uploadAvatar(mimeType: String, data: ByteArray): Result = simulateLongTask { uploadAvatarCalled = true return uploadAvatarResult } - override suspend fun removeAvatar(): Result { + override suspend fun removeAvatar(): Result = simulateLongTask { removeAvatarCalled = true return removeAvatarResult } From ff67cacf7a864e60f9b99722abed8093120ce04e Mon Sep 17 00:00:00 2001 From: ElementBot Date: Fri, 15 Sep 2023 17:40:19 +0000 Subject: [PATCH 6/6] Update screenshots --- ..._null_EditUserProfileView-D-3_4_null_0,NEXUS_5,1.0,en].png | 3 +++ ..._null_EditUserProfileView-N-3_5_null_0,NEXUS_5,1.0,en].png | 3 +++ ..._null_RoomDetailsEditViewDark_0_null_0,NEXUS_5,1.0,en].png | 4 ++-- ..._null_RoomDetailsEditViewDark_0_null_1,NEXUS_5,1.0,en].png | 4 ++-- ..._null_RoomDetailsEditViewDark_0_null_3,NEXUS_5,1.0,en].png | 4 ++-- ..._null_RoomDetailsEditViewDark_0_null_4,NEXUS_5,1.0,en].png | 4 ++-- ..._null_RoomDetailsEditViewDark_0_null_5,NEXUS_5,1.0,en].png | 4 ++-- ..._null_RoomDetailsEditViewDark_0_null_6,NEXUS_5,1.0,en].png | 4 ++-- ...null_RoomDetailsEditViewLight_0_null_0,NEXUS_5,1.0,en].png | 4 ++-- ...null_RoomDetailsEditViewLight_0_null_1,NEXUS_5,1.0,en].png | 4 ++-- ...null_RoomDetailsEditViewLight_0_null_3,NEXUS_5,1.0,en].png | 4 ++-- ...null_RoomDetailsEditViewLight_0_null_4,NEXUS_5,1.0,en].png | 4 ++-- ...null_RoomDetailsEditViewLight_0_null_5,NEXUS_5,1.0,en].png | 4 ++-- ...null_RoomDetailsEditViewLight_0_null_6,NEXUS_5,1.0,en].png | 4 ++-- ...s.avatar_null_Avatars_Avatar_0_null_42,NEXUS_5,1.0,en].png | 4 ++-- ...s.avatar_null_Avatars_Avatar_0_null_43,NEXUS_5,1.0,en].png | 4 ++-- ...s.avatar_null_Avatars_Avatar_0_null_44,NEXUS_5,1.0,en].png | 4 ++-- ...s.avatar_null_Avatars_Avatar_0_null_45,NEXUS_5,1.0,en].png | 3 +++ ...s.avatar_null_Avatars_Avatar_0_null_46,NEXUS_5,1.0,en].png | 3 +++ ...s.avatar_null_Avatars_Avatar_0_null_47,NEXUS_5,1.0,en].png | 3 +++ ...ull_LabelledOutlinedTextField-D_0_null,NEXUS_5,1.0,en].png | 3 +++ ...ull_LabelledOutlinedTextField-N_1_null,NEXUS_5,1.0,en].png | 3 +++ 22 files changed, 51 insertions(+), 30 deletions(-) create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user.editprofile_null_EditUserProfileView-D-3_4_null_0,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user.editprofile_null_EditUserProfileView-N-3_5_null_0,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_45,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_46,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_47,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components_null_LabelledOutlinedTextField-D_0_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components_null_LabelledOutlinedTextField-N_1_null,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user.editprofile_null_EditUserProfileView-D-3_4_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user.editprofile_null_EditUserProfileView-D-3_4_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a030c7b043 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user.editprofile_null_EditUserProfileView-D-3_4_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:717f9e1f59a958df1a2fef727e33280805a9c50e55c4d78d6ba16b428594589b +size 22646 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user.editprofile_null_EditUserProfileView-N-3_5_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user.editprofile_null_EditUserProfileView-N-3_5_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c547d18192 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.user.editprofile_null_EditUserProfileView-N-3_5_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:727a8edc3a35b431583c53f73049c7e6e4e0e8d4cf998fb134d74ac86e077856 +size 21119 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewDark_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewDark_0_null_0,NEXUS_5,1.0,en].png index e0211eacbe..e7b0671305 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewDark_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewDark_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:19ac99f81f3e72f0492d33278a9e549763493ea7ff111412821c62d934a6b9f6 -size 29522 +oid sha256:5b9122206068d76d4e169dbd364d75e28c2fb5102ff2749340f829d15b02b124 +size 29053 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewDark_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewDark_0_null_1,NEXUS_5,1.0,en].png index e548c3c81f..096101e331 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewDark_0_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewDark_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d15154d8ad933acf9a52a73fb650d28489d780cae71b44c7edf10b682cec1ff9 -size 23143 +oid sha256:821f02fa92efca6f5912bbc8675f113e45f78654a0ec78bc30abc0b44c2ab0ed +size 22724 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewDark_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewDark_0_null_3,NEXUS_5,1.0,en].png index 487800c8dd..26474b93d6 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewDark_0_null_3,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewDark_0_null_3,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0958e2a99818be0125b700df470a944a3202e0336d440b073cc73ccc6305173f -size 28482 +oid sha256:840b6dfc827adbf183e04c68c8c4978cb87cd356141127a7e548229234359a0e +size 27984 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewDark_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewDark_0_null_4,NEXUS_5,1.0,en].png index 241c7e1400..116c775557 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewDark_0_null_4,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewDark_0_null_4,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:da07b8de2fd24e9e6bc7fea309e4d5fce68f2143316a5940a41f4270e9d48646 -size 28099 +oid sha256:7e27c699d975a911fbac4cd1456258ad1305d3bfbea7f059f4e736892d33d4c6 +size 28749 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewDark_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewDark_0_null_5,NEXUS_5,1.0,en].png index 1de6e8de96..3b3cb18ea2 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewDark_0_null_5,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewDark_0_null_5,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1a7e0644fff4f219712719b4837cf4e5db5c8cf4c9ec1e21fcd1362859a155f3 -size 28607 +oid sha256:2362fecce2c14174679dbda32e9f163ffb23498ef6b15e4846590460faab1011 +size 28142 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewDark_0_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewDark_0_null_6,NEXUS_5,1.0,en].png index c078ce120e..fb78e13908 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewDark_0_null_6,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewDark_0_null_6,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8444587bfa6ac65c6464b102bbac0bf679fecc0b193cc664540f39518b30c25d -size 24785 +oid sha256:9ad8c77df2595c643f3a394a406592328268f81a5f6bd14ebd1687f95773f305 +size 24396 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewLight_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewLight_0_null_0,NEXUS_5,1.0,en].png index dc1ba40727..104d4e7042 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewLight_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewLight_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:22b0a03bec31400bd88a51c160bf63132f0c8f7e1cf02041a901778c3f1e27ef -size 30856 +oid sha256:a2fa1c83e3ecddef2b489a8ce48db4195953fccd42534d2641bef619d5eb8bd1 +size 30325 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewLight_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewLight_0_null_1,NEXUS_5,1.0,en].png index cc49e4274d..1a66b0af31 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewLight_0_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewLight_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8308dc746a7a51a7bffa49cbf1436097565a175298073afe3d94558d77307510 -size 24145 +oid sha256:5ea00ab4789b78416b938057b7cf5df117042000894b3000536384e61284ffd0 +size 23652 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewLight_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewLight_0_null_3,NEXUS_5,1.0,en].png index ce4af21fcf..dfa5a058c1 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewLight_0_null_3,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewLight_0_null_3,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:32f3030c3bfb1bc9ed4c4378698df073cb29285477538c81592c38668953ffc8 -size 30562 +oid sha256:a9018f1e5339d1ad9fa6deab845e1365215d727bc68f243cccc907ead233a6e1 +size 30041 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewLight_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewLight_0_null_4,NEXUS_5,1.0,en].png index 090bda7bbf..bd9e15f3a5 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewLight_0_null_4,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewLight_0_null_4,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0587640a3ffb707c94d25da38d9f327500c3d3a96d0b57ba92a3c4201014fc11 -size 29448 +oid sha256:4c4af6710212228b3597d2e6cfaed5f6577e6aea90fb27240a7687d87f6f7ad7 +size 30043 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewLight_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewLight_0_null_5,NEXUS_5,1.0,en].png index 03d72aa037..357b55f52d 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewLight_0_null_5,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewLight_0_null_5,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1017fcd62a71f7a2a82dbee3ee2a5233747eb3c888c4b6302f65d9aa02c5492c -size 30673 +oid sha256:baee8e594977d8ae84c71b12e764005c7f04d34e57c80fce92dfa7431a8a6392 +size 30258 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewLight_0_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewLight_0_null_6,NEXUS_5,1.0,en].png index acd3d83443..abcd30f771 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewLight_0_null_6,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.roomdetails.impl.edit_null_RoomDetailsEditViewLight_0_null_6,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4e01d7e598c771260bf4ef91d0ae99081e80f318c1e0300cda3dc1ce780c80ee -size 28078 +oid sha256:b5f05df46a49a9d1de0495655ca3828727c0c7d00afd11abbd643c8fc6712c03 +size 27667 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_42,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_42,NEXUS_5,1.0,en].png index d69da80047..cb0e6b6fc9 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_42,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_42,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:820b23e9b9175b48461a4e7e359c2440b8c45cb598f512ea360519b0815621fc -size 18259 +oid sha256:eac20dcc3e285cde5f3d2515d65416162362de196940b3c208886934592070e4 +size 21346 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_43,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_43,NEXUS_5,1.0,en].png index 4cf9f8f839..f939fb7b9e 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_43,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_43,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3ce4abbf622711cf6a106cb6c74ed729c6e609d329579b7b00ba8c1581b4b953 -size 17463 +oid sha256:96d3a1b71b8372ca80b667ae57bf9f87b5e8167684ea62c8f71d90994113929f +size 19530 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_44,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_44,NEXUS_5,1.0,en].png index a475b2e7d1..f64b9f6a2d 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_44,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_44,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f0cb196c59e3bbd845f0e110150ac358ca78c9c08c212e184423ead5d841f67c -size 20219 +oid sha256:1f3a728b5791495710209e5b308dda4b38defded1a44d00bbd69fb1c45877218 +size 25254 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_45,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_45,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d69da80047 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_45,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:820b23e9b9175b48461a4e7e359c2440b8c45cb598f512ea360519b0815621fc +size 18259 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_46,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_46,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4cf9f8f839 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_46,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3ce4abbf622711cf6a106cb6c74ed729c6e609d329579b7b00ba8c1581b4b953 +size 17463 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_47,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_47,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a475b2e7d1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_47,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f0cb196c59e3bbd845f0e110150ac358ca78c9c08c212e184423ead5d841f67c +size 20219 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components_null_LabelledOutlinedTextField-D_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components_null_LabelledOutlinedTextField-D_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..94e3fe87de --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components_null_LabelledOutlinedTextField-D_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2edcff9494f8dfe74edeb988be57c205d98954887fc885375b850f76271df147 +size 15636 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components_null_LabelledOutlinedTextField-N_1_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components_null_LabelledOutlinedTextField-N_1_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f7a31d7941 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components_null_LabelledOutlinedTextField-N_1_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0aaa6be5eed7879e03fc81d054f3caaf9cd5660f616be9bf47247e8d2503f473 +size 14725