Merge pull request #1351 from vector-im/feature/jme/1302-allow-users-to-change-their-avatars

Add preference screen for user profile
This commit is contained in:
Benoit Marty 2023-09-18 10:58:42 +02:00 committed by GitHub
commit 3e9f5bbfad
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 1281 additions and 99 deletions

View file

@ -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)
@ -64,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)

View file

@ -38,10 +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.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)
@ -81,6 +83,9 @@ class PreferencesFlowNode @AssistedInject constructor(
@Parcelize
data class EditDefaultNotificationSetting(val isOneToOne: Boolean) : NavTarget
@Parcelize
data class UserProfile(val matrixUser: MatrixUser) : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -114,6 +119,10 @@ class PreferencesFlowNode @AssistedInject constructor(
override fun onOpenAdvancedSettings() {
backstack.push(NavTarget.AdvancedSettings)
}
override fun onOpenUserProfile(matrixUser: MatrixUser) {
backstack.push(NavTarget.UserProfile(matrixUser))
}
}
createNode<PreferencesRootNode>(buildContext, plugins = listOf(callback))
}
@ -149,6 +158,10 @@ class PreferencesFlowNode @AssistedInject constructor(
NavTarget.AdvancedSettings -> {
createNode<AdvancedSettingsNode>(buildContext)
}
is NavTarget.UserProfile -> {
val inputs = EditUserProfileNode.Inputs(navTarget.matrixUser)
createNode<EditUserProfileNode>(buildContext, listOf(inputs))
}
}
}

View file

@ -29,6 +29,7 @@ import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.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,6 +48,7 @@ class PreferencesRootNode @AssistedInject constructor(
fun onOpenDeveloperSettings()
fun onOpenNotificationSettings()
fun onOpenAdvancedSettings()
fun onOpenUserProfile(matrixUser: MatrixUser)
}
private fun onOpenBugReport() {
@ -91,6 +93,10 @@ class PreferencesRootNode @AssistedInject constructor(
plugins<Callback>().forEach { it.onOpenNotificationSettings() }
}
private fun onOpenUserProfile(matrixUser: MatrixUser) {
plugins<Callback>().forEach { it.onOpenUserProfile(matrixUser) }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
@ -108,7 +114,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,
)
}

View file

@ -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: (MatrixUser) -> 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 {
state.myUser?.let(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 = {},
)
}

View file

@ -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.editprofile
import io.element.android.libraries.matrix.ui.media.AvatarAction
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
}

View file

@ -0,0 +1,56 @@
/*
* 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 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.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 EditUserProfileNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: EditUserProfilePresenter.Factory,
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val matrixUser: MatrixUser
) : NodeInputs
val matrixUser = inputs<Inputs>().matrixUser
val presenter = presenterFactory.create(matrixUser)
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
EditUserProfileView(
state = state,
onBackPressed = ::navigateUp,
onProfileEdited = ::navigateUp,
modifier = modifier
)
}
}

View file

@ -0,0 +1,149 @@
/*
* 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 androidx.compose.runtime.Composable
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 androidx.core.net.toUri
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
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.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
class EditUserProfilePresenter @AssistedInject constructor(
@Assisted private val matrixUser: MatrixUser,
private val matrixClient: MatrixClient,
private val mediaPickerProvider: PickerProvider,
private val mediaPreProcessor: MediaPreProcessor,
) : Presenter<EditUserProfileState> {
@AssistedFactory
interface Factory {
fun create(matrixUser: MatrixUser): EditUserProfilePresenter
}
@Composable
override fun present(): EditUserProfileState {
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 }
)
val galleryImagePicker = mediaPickerProvider.registerGalleryImagePicker(
onResult = { uri -> if (uri != null) userAvatarUri = uri }
)
val avatarActions by remember(userAvatarUri) {
derivedStateOf {
listOfNotNull(
AvatarAction.TakePhoto,
AvatarAction.ChoosePhoto,
AvatarAction.Remove.takeIf { userAvatarUri != null },
).toImmutableList()
}
}
val saveAction: MutableState<Async<Unit>> = remember { mutableStateOf(Async.Uninitialized) }
val localCoroutineScope = rememberCoroutineScope()
fun handleEvents(event: EditUserProfileEvents) {
when (event) {
is EditUserProfileEvents.Save -> localCoroutineScope.saveChanges(userDisplayName, userAvatarUri, matrixUser, saveAction)
is EditUserProfileEvents.HandleAvatarAction -> {
when (event.action) {
AvatarAction.ChoosePhoto -> galleryImagePicker.launch()
AvatarAction.TakePhoto -> cameraPhotoPicker.launch()
AvatarAction.Remove -> userAvatarUri = null
}
}
is EditUserProfileEvents.UpdateDisplayName -> userDisplayName = event.name
EditUserProfileEvents.CancelSaveChanges -> saveAction.value = Async.Uninitialized
}
}
val canSave = remember(userDisplayName, userAvatarUri) {
val hasProfileChanged = hasDisplayNameChanged(userDisplayName, matrixUser) ||
hasAvatarUrlChanged(userAvatarUri, matrixUser)
!userDisplayName.isNullOrBlank() && hasProfileChanged
}
return EditUserProfileState(
userId = matrixUser.userId,
displayName = userDisplayName.orEmpty(),
userAvatarUrl = userAvatarUri,
avatarActions = avatarActions,
saveButtonEnabled = canSave && saveAction.value !is Async.Loading,
saveAction = saveAction.value,
eventSink = { handleEvents(it) },
)
}
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<Async<Unit>>) = launch {
val results = mutableListOf<Result<Unit>>()
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<Unit> {
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()
}
}.onFailure { Timber.e(it, "Unable to update avatar") }
}
}

View file

@ -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.editprofile
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 EditUserProfileState(
val userId: UserId?,
val displayName: String,
val userAvatarUrl: Uri?,
val avatarActions: ImmutableList<AvatarAction>,
val saveButtonEnabled: Boolean,
val saveAction: Async<Unit>,
val eventSink: (EditUserProfileEvents) -> Unit
)

View file

@ -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.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 EditUserProfileStateProvider : PreviewParameterProvider<EditUserProfileState> {
override val values: Sequence<EditUserProfileState>
get() = sequenceOf(
aEditUserProfileState(),
// Add other states here
)
}
fun aEditUserProfileState() = EditUserProfileState(
userId = UserId("@john.doe:matrix.org"),
displayName = "John Doe",
userAvatarUrl = null,
avatarActions = persistentListOf(),
saveAction = Async.Uninitialized,
saveButtonEnabled = true,
eventSink = {}
)

View file

@ -0,0 +1,190 @@
/*
* 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 androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material3.ExperimentalMaterial3Api
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.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.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.LabelledOutlinedTextField
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.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
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.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 EditUserProfileView(
state: EditUserProfileState,
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 = stringResource(R.string.screen_edit_profile_title),
style = ElementTheme.typography.aliasScreenTitle,
)
},
navigationIcon = { BackButton(onClick = onBackPressed) },
actions = {
TextButton(
text = stringResource(CommonStrings.action_save),
enabled = state.saveButtonEnabled,
onClick = {
focusManager.clearFocus()
state.eventSink(EditUserProfileEvents.Save)
},
)
}
)
},
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.padding(horizontal = 16.dp)
.navigationBarsPadding()
.imePadding()
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(24.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))
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(EditUserProfileEvents.UpdateDisplayName(it)) },
)
}
AvatarActionBottomSheet(
actions = state.avatarActions,
modalBottomSheetState = itemActionsBottomSheetState,
onActionSelected = { state.eventSink(EditUserProfileEvents.HandleAvatarAction(it)) }
)
when (state.saveAction) {
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),
content = stringResource(R.string.screen_edit_profile_error),
onDismiss = { state.eventSink(EditUserProfileEvents.CancelSaveChanges) },
)
}
is Async.Success -> {
LaunchedEffect(state.saveAction) {
onProfileEdited()
}
}
else -> Unit
}
}
}
private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier =
pointerInput(Unit) {
detectTapGestures(onTap = {
focusManager.clearFocus()
})
}
@DayNightPreviews
@Composable
internal fun EditUserProfileViewPreview(@PreviewParameter(EditUserProfileStateProvider::class) state: EditUserProfileState) =
ElementPreview {
EditUserProfileView(
onBackPressed = {},
onProfileEdited = {},
state = state,
)
}

View file

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

View file

@ -0,0 +1,429 @@
/*
* 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.element.android.tests.testutils.consumeItemsUntilPredicate
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 createEditUserProfilePresenter(
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 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)
assertThat(initialState.displayName).isEqualTo(user.displayName)
assertThat(initialState.userAvatarUrl).isEqualTo(userAvatarUri)
assertThat(initialState.avatarActions).containsExactly(
AvatarAction.ChoosePhoto,
AvatarAction.TakePhoto,
AvatarAction.Remove
)
assertThat(initialState.saveButtonEnabled).isFalse()
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 = 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")
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 = 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)
}
}
}
@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 = 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)
}
}
}
@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 = createEditUserProfilePresenter(matrixUser = user)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.saveButtonEnabled).isFalse()
// Once a change is made, the save button is enabled
initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name II"))
awaitItem().apply {
assertThat(saveButtonEnabled).isTrue()
}
// If it's reverted then the save disables again
initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name"))
awaitItem().apply {
assertThat(saveButtonEnabled).isFalse()
}
// Make a change...
initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove))
awaitItem().apply {
assertThat(saveButtonEnabled).isTrue()
}
// Revert it...
initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
awaitItem().apply {
assertThat(saveButtonEnabled).isFalse()
}
}
}
@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 = createEditUserProfilePresenter(matrixUser = user)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.saveButtonEnabled).isFalse()
// Once a change is made, the save button is enabled
initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name II"))
awaitItem().apply {
assertThat(saveButtonEnabled).isTrue()
}
// If it's reverted then the save disables again
initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name"))
awaitItem().apply {
assertThat(saveButtonEnabled).isFalse()
}
// Make a change...
initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
awaitItem().apply {
assertThat(saveButtonEnabled).isTrue()
}
// Revert it...
initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove))
awaitItem().apply {
assertThat(saveButtonEnabled).isFalse()
}
}
}
@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 = 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)
consumeItemsUntilPredicate { matrixClient.setDisplayNameCalled && matrixClient.removeAvatarCalled && !matrixClient.uploadAvatarCalled }
assertThat(matrixClient.setDisplayNameCalled).isTrue()
assertThat(matrixClient.removeAvatarCalled).isTrue()
assertThat(matrixClient.uploadAvatarCalled).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@Test
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(
matrixClient = matrixClient,
matrixUser = user
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(EditUserProfileEvents.UpdateDisplayName(" Name "))
initialState.eventSink(EditUserProfileEvents.Save)
consumeItemsUntilPredicate { matrixClient.setDisplayNameCalled && !matrixClient.removeAvatarCalled && !matrixClient.uploadAvatarCalled }
assertThat(matrixClient.setDisplayNameCalled).isFalse()
assertThat(matrixClient.uploadAvatarCalled).isFalse()
assertThat(matrixClient.removeAvatarCalled).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@Test
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(
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 = createEditUserProfilePresenter(
matrixClient = matrixClient,
matrixUser = user
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
initialState.eventSink(EditUserProfileEvents.Save)
consumeItemsUntilPredicate { matrixClient.uploadAvatarCalled }
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 = 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)
}
}
@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 = createEditUserProfilePresenter(matrixUser = user, matrixClient = matrixClient)
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 = 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)
}
}
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"
}
}

View file

@ -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,

View file

@ -0,0 +1,84 @@
/*
* 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.unit.dp
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
@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,
)
}
}
@DayNightPreviews
@Composable
internal fun LabelledOutlinedTextFieldPreview() = ElementPreview {
Column {
LabelledOutlinedTextField(
label = "Room name",
value = "",
placeholder = "e.g. Product Sprint",
)
LabelledOutlinedTextField(
label = "Room name",
value = "a room name",
placeholder = "e.g. Product Sprint",
)
}
}

View file

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

View file

@ -47,6 +47,9 @@ interface MatrixClient : Closeable {
suspend fun createDM(userId: UserId): Result<RoomId>
suspend fun getProfile(userId: UserId): Result<MatrixUser>
suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults>
suspend fun setDisplayName(displayName: String): Result<Unit>
suspend fun uploadAvatar(mimeType: String, data: ByteArray): Result<Unit>
suspend fun removeAvatar(): Result<Unit>
fun syncService(): SyncService
fun sessionVerificationService(): SessionVerificationService
fun pushersService(): PushersService

View file

@ -276,6 +276,23 @@ class RustMatrixClient constructor(
}
}
override suspend fun setDisplayName(displayName: String): Result<Unit> =
withContext(sessionDispatcher) {
runCatching { client.setDisplayName(displayName) }
}
@OptIn(ExperimentalUnsignedTypes::class)
override suspend fun uploadAvatar(mimeType: String, data: ByteArray): Result<Unit> =
withContext(sessionDispatcher) {
runCatching { client.uploadAvatar(mimeType, data.toUByteArray().toList()) }
}
override suspend fun removeAvatar(): Result<Unit> =
withContext(sessionDispatcher) {
runCatching { client.removeAvatar() }
}
override fun syncService(): SyncService = rustSyncService
override fun sessionVerificationService(): SessionVerificationService = verificationService

View file

@ -58,6 +58,13 @@ class FakeMatrixClient(
private val accountManagementUrlString: Result<String?> = 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<Unit> = Result.success(Unit)
private var unignoreUserResult: Result<Unit> = Result.success(Unit)
private var createRoomResult: Result<RoomId> = Result.success(A_ROOM_ID)
@ -69,6 +76,9 @@ class FakeMatrixClient(
private val searchUserResults = mutableMapOf<String, Result<MatrixSearchUserResults>>()
private val getProfileResults = mutableMapOf<UserId, Result<MatrixUser>>()
private var uploadMediaResult: Result<String> = Result.success(AN_AVATAR_URL)
private var setDisplayNameResult: Result<Unit> = Result.success(Unit)
private var uploadAvatarResult: Result<Unit> = Result.success(Unit)
private var removeAvatarResult: Result<Unit> = Result.success(Unit)
override suspend fun getRoom(roomId: RoomId): MatrixRoom? {
return getRoomResults[roomId]
@ -133,6 +143,7 @@ class FakeMatrixClient(
override suspend fun getAccountManagementUrl(action: AccountManagementAction?): Result<String?> {
return accountManagementUrlString
}
override suspend fun uploadMedia(
mimeType: String,
data: ByteArray,
@ -141,6 +152,21 @@ class FakeMatrixClient(
return uploadMediaResult
}
override suspend fun setDisplayName(displayName: String): Result<Unit> = simulateLongTask {
setDisplayNameCalled = true
return setDisplayNameResult
}
override suspend fun uploadAvatar(mimeType: String, data: ByteArray): Result<Unit> = simulateLongTask {
uploadAvatarCalled = true
return uploadAvatarResult
}
override suspend fun removeAvatar(): Result<Unit> = simulateLongTask {
removeAvatarCalled = true
return removeAvatarResult
}
override fun sessionVerificationService(): SessionVerificationService = sessionVerificationService
override fun pushersService(): PushersService = pushersService
@ -197,4 +223,16 @@ class FakeMatrixClient(
fun givenUploadMediaResult(result: Result<String>) {
uploadMediaResult = result
}
fun givenSetDisplayNameResult(result: Result<Unit>) {
setDisplayNameResult = result
}
fun givenUploadAvatarResult(result: Result<Unit>) {
uploadAvatarResult = result
}
fun givenRemoveAvatarResult(result: Result<Unit>) {
removeAvatarResult = result
}
}

View file

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

View file

@ -28,9 +28,14 @@ open class MatrixUserProvider : PreviewParameterProvider<MatrixUser> {
)
}
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(

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:717f9e1f59a958df1a2fef727e33280805a9c50e55c4d78d6ba16b428594589b
size 22646

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:727a8edc3a35b431583c53f73049c7e6e4e0e8d4cf998fb134d74ac86e077856
size 21119

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:19ac99f81f3e72f0492d33278a9e549763493ea7ff111412821c62d934a6b9f6
size 29522
oid sha256:5b9122206068d76d4e169dbd364d75e28c2fb5102ff2749340f829d15b02b124
size 29053

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d15154d8ad933acf9a52a73fb650d28489d780cae71b44c7edf10b682cec1ff9
size 23143
oid sha256:821f02fa92efca6f5912bbc8675f113e45f78654a0ec78bc30abc0b44c2ab0ed
size 22724

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0958e2a99818be0125b700df470a944a3202e0336d440b073cc73ccc6305173f
size 28482
oid sha256:840b6dfc827adbf183e04c68c8c4978cb87cd356141127a7e548229234359a0e
size 27984

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:da07b8de2fd24e9e6bc7fea309e4d5fce68f2143316a5940a41f4270e9d48646
size 28099
oid sha256:7e27c699d975a911fbac4cd1456258ad1305d3bfbea7f059f4e736892d33d4c6
size 28749

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1a7e0644fff4f219712719b4837cf4e5db5c8cf4c9ec1e21fcd1362859a155f3
size 28607
oid sha256:2362fecce2c14174679dbda32e9f163ffb23498ef6b15e4846590460faab1011
size 28142

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8444587bfa6ac65c6464b102bbac0bf679fecc0b193cc664540f39518b30c25d
size 24785
oid sha256:9ad8c77df2595c643f3a394a406592328268f81a5f6bd14ebd1687f95773f305
size 24396

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:22b0a03bec31400bd88a51c160bf63132f0c8f7e1cf02041a901778c3f1e27ef
size 30856
oid sha256:a2fa1c83e3ecddef2b489a8ce48db4195953fccd42534d2641bef619d5eb8bd1
size 30325

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8308dc746a7a51a7bffa49cbf1436097565a175298073afe3d94558d77307510
size 24145
oid sha256:5ea00ab4789b78416b938057b7cf5df117042000894b3000536384e61284ffd0
size 23652

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:32f3030c3bfb1bc9ed4c4378698df073cb29285477538c81592c38668953ffc8
size 30562
oid sha256:a9018f1e5339d1ad9fa6deab845e1365215d727bc68f243cccc907ead233a6e1
size 30041

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0587640a3ffb707c94d25da38d9f327500c3d3a96d0b57ba92a3c4201014fc11
size 29448
oid sha256:4c4af6710212228b3597d2e6cfaed5f6577e6aea90fb27240a7687d87f6f7ad7
size 30043

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1017fcd62a71f7a2a82dbee3ee2a5233747eb3c888c4b6302f65d9aa02c5492c
size 30673
oid sha256:baee8e594977d8ae84c71b12e764005c7f04d34e57c80fce92dfa7431a8a6392
size 30258

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4e01d7e598c771260bf4ef91d0ae99081e80f318c1e0300cda3dc1ce780c80ee
size 28078
oid sha256:b5f05df46a49a9d1de0495655ca3828727c0c7d00afd11abbd643c8fc6712c03
size 27667

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:820b23e9b9175b48461a4e7e359c2440b8c45cb598f512ea360519b0815621fc
size 18259
oid sha256:eac20dcc3e285cde5f3d2515d65416162362de196940b3c208886934592070e4
size 21346

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3ce4abbf622711cf6a106cb6c74ed729c6e609d329579b7b00ba8c1581b4b953
size 17463
oid sha256:96d3a1b71b8372ca80b667ae57bf9f87b5e8167684ea62c8f71d90994113929f
size 19530

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f0cb196c59e3bbd845f0e110150ac358ca78c9c08c212e184423ead5d841f67c
size 20219
oid sha256:1f3a728b5791495710209e5b308dda4b38defded1a44d00bbd69fb1c45877218
size 25254

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:820b23e9b9175b48461a4e7e359c2440b8c45cb598f512ea360519b0815621fc
size 18259

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3ce4abbf622711cf6a106cb6c74ed729c6e609d329579b7b00ba8c1581b4b953
size 17463

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f0cb196c59e3bbd845f0e110150ac358ca78c9c08c212e184423ead5d841f67c
size 20219

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2edcff9494f8dfe74edeb988be57c205d98954887fc885375b850f76271df147
size 15636

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0aaa6be5eed7879e03fc81d054f3caaf9cd5660f616be9bf47247e8d2503f473
size 14725

View file

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