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:
commit
3e9f5bbfad
43 changed files with 1281 additions and 99 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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") }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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 = {}
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -43,5 +43,7 @@ enum class AvatarSize(val dp: Dp) {
|
|||
RoomInviteItem(52.dp),
|
||||
InviteSender(16.dp),
|
||||
|
||||
EditRoomDetails(70.dp),
|
||||
|
||||
NotificationsOptIn(32.dp),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:717f9e1f59a958df1a2fef727e33280805a9c50e55c4d78d6ba16b428594589b
|
||||
size 22646
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:727a8edc3a35b431583c53f73049c7e6e4e0e8d4cf998fb134d74ac86e077856
|
||||
size 21119
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:19ac99f81f3e72f0492d33278a9e549763493ea7ff111412821c62d934a6b9f6
|
||||
size 29522
|
||||
oid sha256:5b9122206068d76d4e169dbd364d75e28c2fb5102ff2749340f829d15b02b124
|
||||
size 29053
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d15154d8ad933acf9a52a73fb650d28489d780cae71b44c7edf10b682cec1ff9
|
||||
size 23143
|
||||
oid sha256:821f02fa92efca6f5912bbc8675f113e45f78654a0ec78bc30abc0b44c2ab0ed
|
||||
size 22724
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0958e2a99818be0125b700df470a944a3202e0336d440b073cc73ccc6305173f
|
||||
size 28482
|
||||
oid sha256:840b6dfc827adbf183e04c68c8c4978cb87cd356141127a7e548229234359a0e
|
||||
size 27984
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:da07b8de2fd24e9e6bc7fea309e4d5fce68f2143316a5940a41f4270e9d48646
|
||||
size 28099
|
||||
oid sha256:7e27c699d975a911fbac4cd1456258ad1305d3bfbea7f059f4e736892d33d4c6
|
||||
size 28749
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1a7e0644fff4f219712719b4837cf4e5db5c8cf4c9ec1e21fcd1362859a155f3
|
||||
size 28607
|
||||
oid sha256:2362fecce2c14174679dbda32e9f163ffb23498ef6b15e4846590460faab1011
|
||||
size 28142
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8444587bfa6ac65c6464b102bbac0bf679fecc0b193cc664540f39518b30c25d
|
||||
size 24785
|
||||
oid sha256:9ad8c77df2595c643f3a394a406592328268f81a5f6bd14ebd1687f95773f305
|
||||
size 24396
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:22b0a03bec31400bd88a51c160bf63132f0c8f7e1cf02041a901778c3f1e27ef
|
||||
size 30856
|
||||
oid sha256:a2fa1c83e3ecddef2b489a8ce48db4195953fccd42534d2641bef619d5eb8bd1
|
||||
size 30325
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8308dc746a7a51a7bffa49cbf1436097565a175298073afe3d94558d77307510
|
||||
size 24145
|
||||
oid sha256:5ea00ab4789b78416b938057b7cf5df117042000894b3000536384e61284ffd0
|
||||
size 23652
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:32f3030c3bfb1bc9ed4c4378698df073cb29285477538c81592c38668953ffc8
|
||||
size 30562
|
||||
oid sha256:a9018f1e5339d1ad9fa6deab845e1365215d727bc68f243cccc907ead233a6e1
|
||||
size 30041
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0587640a3ffb707c94d25da38d9f327500c3d3a96d0b57ba92a3c4201014fc11
|
||||
size 29448
|
||||
oid sha256:4c4af6710212228b3597d2e6cfaed5f6577e6aea90fb27240a7687d87f6f7ad7
|
||||
size 30043
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1017fcd62a71f7a2a82dbee3ee2a5233747eb3c888c4b6302f65d9aa02c5492c
|
||||
size 30673
|
||||
oid sha256:baee8e594977d8ae84c71b12e764005c7f04d34e57c80fce92dfa7431a8a6392
|
||||
size 30258
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4e01d7e598c771260bf4ef91d0ae99081e80f318c1e0300cda3dc1ce780c80ee
|
||||
size 28078
|
||||
oid sha256:b5f05df46a49a9d1de0495655ca3828727c0c7d00afd11abbd643c8fc6712c03
|
||||
size 27667
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:820b23e9b9175b48461a4e7e359c2440b8c45cb598f512ea360519b0815621fc
|
||||
size 18259
|
||||
oid sha256:eac20dcc3e285cde5f3d2515d65416162362de196940b3c208886934592070e4
|
||||
size 21346
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3ce4abbf622711cf6a106cb6c74ed729c6e609d329579b7b00ba8c1581b4b953
|
||||
size 17463
|
||||
oid sha256:96d3a1b71b8372ca80b667ae57bf9f87b5e8167684ea62c8f71d90994113929f
|
||||
size 19530
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f0cb196c59e3bbd845f0e110150ac358ca78c9c08c212e184423ead5d841f67c
|
||||
size 20219
|
||||
oid sha256:1f3a728b5791495710209e5b308dda4b38defded1a44d00bbd69fb1c45877218
|
||||
size 25254
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:820b23e9b9175b48461a4e7e359c2440b8c45cb598f512ea360519b0815621fc
|
||||
size 18259
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3ce4abbf622711cf6a106cb6c74ed729c6e609d329579b7b00ba8c1581b4b953
|
||||
size 17463
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f0cb196c59e3bbd845f0e110150ac358ca78c9c08c212e184423ead5d841f67c
|
||||
size 20219
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2edcff9494f8dfe74edeb988be57c205d98954887fc885375b850f76271df147
|
||||
size 15636
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0aaa6be5eed7879e03fc81d054f3caaf9cd5660f616be9bf47247e8d2503f473
|
||||
size 14725
|
||||
|
|
@ -129,6 +129,12 @@
|
|||
"screen_create_poll_.*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": ":features:preferences:impl",
|
||||
"includeRegex": [
|
||||
"screen_edit_profile_.*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": ":features:call",
|
||||
"includeRegex": [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue