Add preference screen for user profile
This commit is contained in:
parent
fe41d2aa28
commit
fa277f062f
13 changed files with 614 additions and 5 deletions
|
|
@ -44,10 +44,12 @@ dependencies {
|
||||||
implementation(projects.libraries.preferences.api)
|
implementation(projects.libraries.preferences.api)
|
||||||
implementation(projects.libraries.testtags)
|
implementation(projects.libraries.testtags)
|
||||||
implementation(projects.libraries.uiStrings)
|
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.rageshake.api)
|
||||||
implementation(projects.features.analytics.api)
|
implementation(projects.features.analytics.api)
|
||||||
implementation(projects.features.ftue.api)
|
implementation(projects.features.ftue.api)
|
||||||
implementation(projects.libraries.matrixui)
|
|
||||||
implementation(projects.features.logout.api)
|
implementation(projects.features.logout.api)
|
||||||
implementation(projects.services.analytics.api)
|
implementation(projects.services.analytics.api)
|
||||||
implementation(projects.services.toolbox.api)
|
implementation(projects.services.toolbox.api)
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ import io.element.android.features.preferences.impl.developer.tracing.ConfigureT
|
||||||
import io.element.android.features.preferences.impl.notifications.NotificationSettingsNode
|
import io.element.android.features.preferences.impl.notifications.NotificationSettingsNode
|
||||||
import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingNode
|
import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingNode
|
||||||
import io.element.android.features.preferences.impl.root.PreferencesRootNode
|
import io.element.android.features.preferences.impl.root.PreferencesRootNode
|
||||||
|
import io.element.android.features.preferences.impl.user.screen.UserPreferencesNode
|
||||||
import io.element.android.libraries.architecture.BackstackNode
|
import io.element.android.libraries.architecture.BackstackNode
|
||||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||||
import io.element.android.libraries.architecture.createNode
|
import io.element.android.libraries.architecture.createNode
|
||||||
|
|
@ -81,6 +82,9 @@ class PreferencesFlowNode @AssistedInject constructor(
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class EditDefaultNotificationSetting(val isOneToOne: Boolean) : NavTarget
|
data class EditDefaultNotificationSetting(val isOneToOne: Boolean) : NavTarget
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data object UserProfile : NavTarget
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||||
|
|
@ -114,6 +118,10 @@ class PreferencesFlowNode @AssistedInject constructor(
|
||||||
override fun onOpenAdvancedSettings() {
|
override fun onOpenAdvancedSettings() {
|
||||||
backstack.push(NavTarget.AdvancedSettings)
|
backstack.push(NavTarget.AdvancedSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onOpenUserProfile() {
|
||||||
|
backstack.push(NavTarget.UserProfile)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
createNode<PreferencesRootNode>(buildContext, plugins = listOf(callback))
|
createNode<PreferencesRootNode>(buildContext, plugins = listOf(callback))
|
||||||
}
|
}
|
||||||
|
|
@ -149,6 +157,9 @@ class PreferencesFlowNode @AssistedInject constructor(
|
||||||
NavTarget.AdvancedSettings -> {
|
NavTarget.AdvancedSettings -> {
|
||||||
createNode<AdvancedSettingsNode>(buildContext)
|
createNode<AdvancedSettingsNode>(buildContext)
|
||||||
}
|
}
|
||||||
|
NavTarget.UserProfile -> {
|
||||||
|
createNode<UserPreferencesNode>(buildContext)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ class PreferencesRootNode @AssistedInject constructor(
|
||||||
fun onOpenDeveloperSettings()
|
fun onOpenDeveloperSettings()
|
||||||
fun onOpenNotificationSettings()
|
fun onOpenNotificationSettings()
|
||||||
fun onOpenAdvancedSettings()
|
fun onOpenAdvancedSettings()
|
||||||
|
fun onOpenUserProfile()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onOpenBugReport() {
|
private fun onOpenBugReport() {
|
||||||
|
|
@ -91,6 +92,10 @@ class PreferencesRootNode @AssistedInject constructor(
|
||||||
plugins<Callback>().forEach { it.onOpenNotificationSettings() }
|
plugins<Callback>().forEach { it.onOpenNotificationSettings() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onOpenUserProfile() {
|
||||||
|
plugins<Callback>().forEach { it.onOpenUserProfile() }
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
|
|
@ -108,7 +113,8 @@ class PreferencesRootNode @AssistedInject constructor(
|
||||||
onOpenAdvancedSettings = this::onOpenAdvancedSettings,
|
onOpenAdvancedSettings = this::onOpenAdvancedSettings,
|
||||||
onSuccessLogout = { onSuccessLogout(activity, it) },
|
onSuccessLogout = { onSuccessLogout(activity, it) },
|
||||||
onManageAccountClicked = { onManageAccountClicked(activity, it, isDark) },
|
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
|
package io.element.android.features.preferences.impl.root
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
|
@ -62,6 +63,7 @@ fun PreferencesRootView(
|
||||||
onOpenAdvancedSettings: () -> Unit,
|
onOpenAdvancedSettings: () -> Unit,
|
||||||
onSuccessLogout: (logoutUrlResult: String?) -> Unit,
|
onSuccessLogout: (logoutUrlResult: String?) -> Unit,
|
||||||
onOpenNotificationSettings: () -> Unit,
|
onOpenNotificationSettings: () -> Unit,
|
||||||
|
onOpenUserProfile: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
|
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
|
||||||
|
|
@ -73,7 +75,12 @@ fun PreferencesRootView(
|
||||||
title = stringResource(id = CommonStrings.common_settings),
|
title = stringResource(id = CommonStrings.common_settings),
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||||
) {
|
) {
|
||||||
UserPreferences(state.myUser)
|
UserPreferences(
|
||||||
|
modifier = Modifier.clickable {
|
||||||
|
onOpenUserProfile()
|
||||||
|
},
|
||||||
|
user = state.myUser,
|
||||||
|
)
|
||||||
if (state.showCompleteVerification) {
|
if (state.showCompleteVerification) {
|
||||||
PreferenceText(
|
PreferenceText(
|
||||||
title = stringResource(id = CommonStrings.action_complete_verification),
|
title = stringResource(id = CommonStrings.action_complete_verification),
|
||||||
|
|
@ -181,5 +188,6 @@ private fun ContentToPreview(matrixUser: MatrixUser) {
|
||||||
onSuccessLogout = {},
|
onSuccessLogout = {},
|
||||||
onManageAccountClicked = {},
|
onManageAccountClicked = {},
|
||||||
onOpenNotificationSettings = {},
|
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.screen
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.ui.media.AvatarAction
|
||||||
|
|
||||||
|
sealed interface UserPreferencesEvents {
|
||||||
|
data class HandleAvatarAction(val action: AvatarAction) : UserPreferencesEvents
|
||||||
|
data class UpdateDisplayName(val name: String) : UserPreferencesEvents
|
||||||
|
data object Save : UserPreferencesEvents
|
||||||
|
data object CancelSaveChanges : UserPreferencesEvents
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.preferences.impl.user.screen
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
import com.bumble.appyx.core.node.Node
|
||||||
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
|
import io.element.android.libraries.di.SessionScope
|
||||||
|
|
||||||
|
@ContributesNode(SessionScope::class)
|
||||||
|
class UserPreferencesNode @AssistedInject constructor(
|
||||||
|
@Assisted buildContext: BuildContext,
|
||||||
|
@Assisted plugins: List<Plugin>,
|
||||||
|
private val presenter: UserPreferencesPresenter,
|
||||||
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun View(modifier: Modifier) {
|
||||||
|
val state = presenter.present()
|
||||||
|
UserPreferencesView(
|
||||||
|
state = state,
|
||||||
|
onBackPressed = ::navigateUp,
|
||||||
|
onProfileEdited = ::navigateUp, // TODO: check if something else is needed
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.preferences.impl.user.screen
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import io.element.android.libraries.architecture.Async
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||||
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
import io.element.android.libraries.matrix.api.user.getCurrentUser
|
||||||
|
import io.element.android.libraries.matrix.ui.media.AvatarAction
|
||||||
|
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||||
|
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class UserPreferencesPresenter @Inject constructor(
|
||||||
|
private val matrixClient: MatrixClient,
|
||||||
|
private val mediaPickerProvider: PickerProvider,
|
||||||
|
private val mediaPreProcessor: MediaPreProcessor,
|
||||||
|
) : Presenter<UserPreferencesState> {
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun present(): UserPreferencesState {
|
||||||
|
var currentUser by remember { mutableStateOf<MatrixUser?>(null) }
|
||||||
|
var userAvatarUri by rememberSaveable(currentUser) { mutableStateOf(currentUser?.avatarUrl?.let { Uri.parse(it) }) }
|
||||||
|
var userDisplayName by rememberSaveable(currentUser) { mutableStateOf(currentUser?.displayName) }
|
||||||
|
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
|
||||||
|
onResult = { uri -> if (uri != null) userAvatarUri = uri }
|
||||||
|
)
|
||||||
|
val galleryImagePicker = mediaPickerProvider.registerGalleryImagePicker(
|
||||||
|
onResult = { uri -> if (uri != null) userAvatarUri = uri }
|
||||||
|
)
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
currentUser = matrixClient.getCurrentUser()
|
||||||
|
}
|
||||||
|
|
||||||
|
val avatarActions by remember(userAvatarUri) {
|
||||||
|
derivedStateOf {
|
||||||
|
listOfNotNull(
|
||||||
|
AvatarAction.TakePhoto,
|
||||||
|
AvatarAction.ChoosePhoto,
|
||||||
|
AvatarAction.Remove.takeIf { userAvatarUri != null },
|
||||||
|
).toImmutableList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val saveAction: MutableState<Async<Unit>> = remember { mutableStateOf(Async.Uninitialized) }
|
||||||
|
val localCoroutineScope = rememberCoroutineScope()
|
||||||
|
fun handleEvents(event: UserPreferencesEvents) {
|
||||||
|
when (event) {
|
||||||
|
is UserPreferencesEvents.Save -> currentUser?.let {
|
||||||
|
localCoroutineScope.saveChanges(userDisplayName, userAvatarUri, it, saveAction)
|
||||||
|
}
|
||||||
|
is UserPreferencesEvents.HandleAvatarAction -> {
|
||||||
|
when (event.action) {
|
||||||
|
AvatarAction.ChoosePhoto -> galleryImagePicker.launch()
|
||||||
|
AvatarAction.TakePhoto -> cameraPhotoPicker.launch()
|
||||||
|
AvatarAction.Remove -> userAvatarUri = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is UserPreferencesEvents.UpdateDisplayName -> userDisplayName = event.name
|
||||||
|
UserPreferencesEvents.CancelSaveChanges -> saveAction.value = Async.Uninitialized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val canSave = remember(userDisplayName, userAvatarUri, currentUser) {
|
||||||
|
val hasProfileChanged = hasDisplayNameChanged(userDisplayName, currentUser)
|
||||||
|
|| hasAvatarUrlChanged(userAvatarUri, currentUser)
|
||||||
|
!userDisplayName.isNullOrBlank() && hasProfileChanged
|
||||||
|
}
|
||||||
|
|
||||||
|
return UserPreferencesState(
|
||||||
|
userId = currentUser?.userId,
|
||||||
|
displayName = userDisplayName.orEmpty(),
|
||||||
|
userAvatarUrl = userAvatarUri,
|
||||||
|
avatarActions = avatarActions,
|
||||||
|
saveButtonEnabled = canSave && saveAction.value !is Async.Loading,
|
||||||
|
saveAction = saveAction.value,
|
||||||
|
eventSink = ::handleEvents
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasDisplayNameChanged(name: String?, currentUser: MatrixUser?) = name?.trim() != currentUser?.displayName?.trim()
|
||||||
|
private fun hasAvatarUrlChanged(avatarUri: Uri?, currentUser: MatrixUser?) = avatarUri?.toString()?.trim() != currentUser?.avatarUrl?.trim()
|
||||||
|
|
||||||
|
private fun CoroutineScope.saveChanges(name: String?, avatarUri: Uri?, currentUser: MatrixUser, action: MutableState<Async<Unit>>) = launch {
|
||||||
|
matrixClient.getCurrentUser()
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.preferences.impl.user.screen
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import io.element.android.libraries.architecture.Async
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.ui.media.AvatarAction
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
|
data class UserPreferencesState(
|
||||||
|
val userId: UserId?,
|
||||||
|
val displayName: String,
|
||||||
|
val userAvatarUrl: Uri?,
|
||||||
|
val avatarActions: ImmutableList<AvatarAction>,
|
||||||
|
val saveButtonEnabled: Boolean,
|
||||||
|
val saveAction: Async<Unit>,
|
||||||
|
val eventSink: (UserPreferencesEvents) -> 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.screen
|
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.libraries.architecture.Async
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
||||||
|
open class UserPreferencesStateProvider : PreviewParameterProvider<UserPreferencesState> {
|
||||||
|
override val values: Sequence<UserPreferencesState>
|
||||||
|
get() = sequenceOf(
|
||||||
|
aUserPreferencesState(),
|
||||||
|
// Add other states here
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun aUserPreferencesState() = UserPreferencesState(
|
||||||
|
userId = UserId("@john.doe:matrix.org"),
|
||||||
|
displayName = "John Doe",
|
||||||
|
userAvatarUrl = null,
|
||||||
|
avatarActions = persistentListOf(),
|
||||||
|
saveAction = Async.Uninitialized,
|
||||||
|
saveButtonEnabled = true,
|
||||||
|
eventSink = {}
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,271 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.preferences.impl.user.screen
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.ExperimentalMaterialApi
|
||||||
|
import androidx.compose.material.ModalBottomSheetValue
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.AddAPhoto
|
||||||
|
import androidx.compose.material.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.FocusManager
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.libraries.architecture.Async
|
||||||
|
import io.element.android.libraries.designsystem.components.LabelledTextField
|
||||||
|
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
|
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
|
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
|
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
|
||||||
|
import io.element.android.libraries.matrix.ui.components.UnsavedAvatar
|
||||||
|
import io.element.android.libraries.theme.ElementTheme
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun UserPreferencesView(
|
||||||
|
state: UserPreferencesState,
|
||||||
|
onBackPressed: () -> Unit,
|
||||||
|
onProfileEdited: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
val itemActionsBottomSheetState = rememberModalBottomSheetState(
|
||||||
|
initialValue = ModalBottomSheetValue.Hidden,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun onAvatarClicked() {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
coroutineScope.launch {
|
||||||
|
itemActionsBottomSheetState.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier.clearFocusOnTap(focusManager),
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = "Edit profile",
|
||||||
|
style = ElementTheme.typography.aliasScreenTitle,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = { BackButton(onClick = onBackPressed) },
|
||||||
|
actions = {
|
||||||
|
TextButton(
|
||||||
|
text = stringResource(CommonStrings.action_save),
|
||||||
|
enabled = state.saveButtonEnabled,
|
||||||
|
onClick = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
state.eventSink(UserPreferencesEvents.Save)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(padding)
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.imePadding()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
EditableAvatarView(state, ::onAvatarClicked)
|
||||||
|
Spacer(modifier = Modifier.height(60.dp))
|
||||||
|
|
||||||
|
LabelledTextField(
|
||||||
|
label = "Display name",
|
||||||
|
value = state.displayName,
|
||||||
|
placeholder = stringResource(CommonStrings.common_room_name_placeholder),
|
||||||
|
singleLine = true,
|
||||||
|
onValueChange = { state.eventSink(UserPreferencesEvents.UpdateDisplayName(it)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AvatarActionBottomSheet(
|
||||||
|
actions = state.avatarActions,
|
||||||
|
modalBottomSheetState = itemActionsBottomSheetState,
|
||||||
|
onActionSelected = { state.eventSink(UserPreferencesEvents.HandleAvatarAction(it)) }
|
||||||
|
)
|
||||||
|
|
||||||
|
when (state.saveAction) {
|
||||||
|
is Async.Loading -> {
|
||||||
|
ProgressDialog(text = "Updating profile...")
|
||||||
|
}
|
||||||
|
|
||||||
|
is Async.Failure -> {
|
||||||
|
ErrorDialog(
|
||||||
|
content = "Error updating profile",
|
||||||
|
onDismiss = { state.eventSink(UserPreferencesEvents.CancelSaveChanges) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Async.Success -> {
|
||||||
|
LaunchedEffect(state.saveAction) {
|
||||||
|
onProfileEdited()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EditableAvatarView(
|
||||||
|
state: UserPreferencesState,
|
||||||
|
onAvatarClicked: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(70.dp)
|
||||||
|
.clickable(onClick = onAvatarClicked)
|
||||||
|
) {
|
||||||
|
// TODO this might be able to be simplified into a single component once send/receive media is done
|
||||||
|
when (state.userAvatarUrl?.scheme) {
|
||||||
|
null, "mxc" -> {
|
||||||
|
Avatar(
|
||||||
|
avatarData = AvatarData(state.userId.toString(), state.displayName, state.userAvatarUrl?.toString(), size = AvatarSize.RoomHeader),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
UnsavedAvatar(
|
||||||
|
avatarUri = state.userAvatarUrl,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.primary)
|
||||||
|
.size(24.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
imageVector = Icons.Outlined.AddAPhoto,
|
||||||
|
contentDescription = "",
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LabelledReadOnlyField(
|
||||||
|
title: String,
|
||||||
|
value: String,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
style = ElementTheme.typography.fontBodyMdRegular,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
text = title,
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 12.dp),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
text = value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier =
|
||||||
|
pointerInput(Unit) {
|
||||||
|
detectTapGestures(onTap = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun UserPreferencesViewLightPreview(@PreviewParameter(UserPreferencesStateProvider::class) state: UserPreferencesState) =
|
||||||
|
ElementPreviewLight { ContentToPreview(state) }
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun UserPreferencesViewDarkPreview(@PreviewParameter(UserPreferencesStateProvider::class) state: UserPreferencesState) =
|
||||||
|
ElementPreviewDark { ContentToPreview(state) }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContentToPreview(state: UserPreferencesState) {
|
||||||
|
UserPreferencesView(
|
||||||
|
onBackPressed = {},
|
||||||
|
onProfileEdited = {},
|
||||||
|
state = state,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -47,6 +47,9 @@ interface MatrixClient : Closeable {
|
||||||
suspend fun createDM(userId: UserId): Result<RoomId>
|
suspend fun createDM(userId: UserId): Result<RoomId>
|
||||||
suspend fun getProfile(userId: UserId): Result<MatrixUser>
|
suspend fun getProfile(userId: UserId): Result<MatrixUser>
|
||||||
suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults>
|
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 syncService(): SyncService
|
||||||
fun sessionVerificationService(): SessionVerificationService
|
fun sessionVerificationService(): SessionVerificationService
|
||||||
fun pushersService(): PushersService
|
fun pushersService(): PushersService
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,8 @@ anvil {
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// implementation(projects.libraries.rustsdk)
|
implementation(projects.libraries.rustsdk)
|
||||||
implementation(libs.matrix.sdk)
|
// implementation(libs.matrix.sdk)
|
||||||
implementation(projects.libraries.di)
|
implementation(projects.libraries.di)
|
||||||
implementation(projects.libraries.androidutils)
|
implementation(projects.libraries.androidutils)
|
||||||
implementation(projects.libraries.network)
|
implementation(projects.libraries.network)
|
||||||
|
|
|
||||||
|
|
@ -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 syncService(): SyncService = rustSyncService
|
||||||
|
|
||||||
override fun sessionVerificationService(): SessionVerificationService = verificationService
|
override fun sessionVerificationService(): SessionVerificationService = verificationService
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue