- Improve UI to match designs.
- Extract `EditableAvatarView` component.
- Create `LabelledOutlinedTextField`.
- Get strings from Localazy.
This commit is contained in:
Jorge Martín
2023-09-15 14:04:21 +02:00
parent fa277f062f
commit 41e614310f
16 changed files with 325 additions and 235 deletions

View File

@@ -38,11 +38,12 @@ import io.element.android.features.preferences.impl.developer.tracing.ConfigureT
import io.element.android.features.preferences.impl.notifications.NotificationSettingsNode
import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingNode
import io.element.android.features.preferences.impl.root.PreferencesRootNode
import io.element.android.features.preferences.impl.user.screen.UserPreferencesNode
import io.element.android.features.preferences.impl.user.editprofile.EditUserProfileNode
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
@@ -84,7 +85,7 @@ class PreferencesFlowNode @AssistedInject constructor(
data class EditDefaultNotificationSetting(val isOneToOne: Boolean) : NavTarget
@Parcelize
data object UserProfile : NavTarget
data class UserProfile(val matrixUser: MatrixUser) : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@@ -119,8 +120,8 @@ class PreferencesFlowNode @AssistedInject constructor(
backstack.push(NavTarget.AdvancedSettings)
}
override fun onOpenUserProfile() {
backstack.push(NavTarget.UserProfile)
override fun onOpenUserProfile(matrixUser: MatrixUser) {
backstack.push(NavTarget.UserProfile(matrixUser))
}
}
createNode<PreferencesRootNode>(buildContext, plugins = listOf(callback))
@@ -157,8 +158,9 @@ class PreferencesFlowNode @AssistedInject constructor(
NavTarget.AdvancedSettings -> {
createNode<AdvancedSettingsNode>(buildContext)
}
NavTarget.UserProfile -> {
createNode<UserPreferencesNode>(buildContext)
is NavTarget.UserProfile -> {
val inputs = EditUserProfileNode.Inputs(navTarget.matrixUser)
createNode<EditUserProfileNode>(buildContext, listOf(inputs))
}
}
}

View File

@@ -29,6 +29,7 @@ import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.theme.ElementTheme
import timber.log.Timber
@@ -47,7 +48,7 @@ class PreferencesRootNode @AssistedInject constructor(
fun onOpenDeveloperSettings()
fun onOpenNotificationSettings()
fun onOpenAdvancedSettings()
fun onOpenUserProfile()
fun onOpenUserProfile(matrixUser: MatrixUser)
}
private fun onOpenBugReport() {
@@ -92,8 +93,8 @@ class PreferencesRootNode @AssistedInject constructor(
plugins<Callback>().forEach { it.onOpenNotificationSettings() }
}
private fun onOpenUserProfile() {
plugins<Callback>().forEach { it.onOpenUserProfile() }
private fun onOpenUserProfile(matrixUser: MatrixUser) {
plugins<Callback>().forEach { it.onOpenUserProfile(matrixUser) }
}
@Composable

View File

@@ -63,7 +63,7 @@ fun PreferencesRootView(
onOpenAdvancedSettings: () -> Unit,
onSuccessLogout: (logoutUrlResult: String?) -> Unit,
onOpenNotificationSettings: () -> Unit,
onOpenUserProfile: () -> Unit,
onOpenUserProfile: (MatrixUser) -> Unit,
modifier: Modifier = Modifier,
) {
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
@@ -77,7 +77,7 @@ fun PreferencesRootView(
) {
UserPreferences(
modifier = Modifier.clickable {
onOpenUserProfile()
state.myUser?.let(onOpenUserProfile)
},
user = state.myUser,
)

View File

@@ -14,13 +14,13 @@
* limitations under the License.
*/
package io.element.android.features.preferences.impl.user.screen
package io.element.android.features.preferences.impl.user.editprofile
import io.element.android.libraries.matrix.ui.media.AvatarAction
sealed interface UserPreferencesEvents {
data class HandleAvatarAction(val action: AvatarAction) : UserPreferencesEvents
data class UpdateDisplayName(val name: String) : UserPreferencesEvents
data object Save : UserPreferencesEvents
data object CancelSaveChanges : UserPreferencesEvents
sealed interface EditUserProfileEvents {
data class HandleAvatarAction(val action: AvatarAction) : EditUserProfileEvents
data class UpdateDisplayName(val name: String) : EditUserProfileEvents
data object Save : EditUserProfileEvents
data object CancelSaveChanges : EditUserProfileEvents
}

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.preferences.impl.user.screen
package io.element.android.features.preferences.impl.user.editprofile
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@@ -24,22 +24,32 @@ import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.user.MatrixUser
@ContributesNode(SessionScope::class)
class UserPreferencesNode @AssistedInject constructor(
class EditUserProfileNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: UserPreferencesPresenter,
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()
UserPreferencesView(
EditUserProfileView(
state = state,
onBackPressed = ::navigateUp,
onProfileEdited = ::navigateUp, // TODO: check if something else is needed
onProfileEdited = ::navigateUp,
modifier = modifier
)
}

View File

@@ -14,19 +14,19 @@
* limitations under the License.
*/
package io.element.android.features.preferences.impl.user.screen
package io.element.android.features.preferences.impl.user.editprofile
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
@@ -41,19 +41,23 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
class UserPreferencesPresenter @Inject constructor(
class EditUserProfilePresenter @AssistedInject constructor(
@Assisted private val matrixUser: MatrixUser,
private val matrixClient: MatrixClient,
private val mediaPickerProvider: PickerProvider,
private val mediaPreProcessor: MediaPreProcessor,
) : Presenter<UserPreferencesState> {
) : Presenter<EditUserProfileState> {
@AssistedFactory
interface Factory {
fun create(matrixUser: MatrixUser): EditUserProfilePresenter
}
@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) }
override fun present(): EditUserProfileState {
var userAvatarUri = remember { matrixUser.avatarUrl?.let { Uri.parse(it) } }
var userDisplayName = remember { matrixUser.displayName }
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
onResult = { uri -> if (uri != null) userAvatarUri = uri }
)
@@ -61,10 +65,6 @@ class UserPreferencesPresenter @Inject constructor(
onResult = { uri -> if (uri != null) userAvatarUri = uri }
)
LaunchedEffect(Unit) {
currentUser = matrixClient.getCurrentUser()
}
val avatarActions by remember(userAvatarUri) {
derivedStateOf {
listOfNotNull(
@@ -77,12 +77,10 @@ class UserPreferencesPresenter @Inject constructor(
val saveAction: MutableState<Async<Unit>> = remember { mutableStateOf(Async.Uninitialized) }
val localCoroutineScope = rememberCoroutineScope()
fun handleEvents(event: UserPreferencesEvents) {
fun handleEvents(event: EditUserProfileEvents) {
when (event) {
is UserPreferencesEvents.Save -> currentUser?.let {
localCoroutineScope.saveChanges(userDisplayName, userAvatarUri, it, saveAction)
}
is UserPreferencesEvents.HandleAvatarAction -> {
is EditUserProfileEvents.Save -> localCoroutineScope.saveChanges(userDisplayName, userAvatarUri, matrixUser, saveAction)
is EditUserProfileEvents.HandleAvatarAction -> {
when (event.action) {
AvatarAction.ChoosePhoto -> galleryImagePicker.launch()
AvatarAction.TakePhoto -> cameraPhotoPicker.launch()
@@ -90,19 +88,19 @@ class UserPreferencesPresenter @Inject constructor(
}
}
is UserPreferencesEvents.UpdateDisplayName -> userDisplayName = event.name
UserPreferencesEvents.CancelSaveChanges -> saveAction.value = Async.Uninitialized
is EditUserProfileEvents.UpdateDisplayName -> userDisplayName = event.name
EditUserProfileEvents.CancelSaveChanges -> saveAction.value = Async.Uninitialized
}
}
val canSave = remember(userDisplayName, userAvatarUri, currentUser) {
val hasProfileChanged = hasDisplayNameChanged(userDisplayName, currentUser)
|| hasAvatarUrlChanged(userAvatarUri, currentUser)
val canSave = remember(userDisplayName, userAvatarUri) {
val hasProfileChanged = hasDisplayNameChanged(userDisplayName, matrixUser)
|| hasAvatarUrlChanged(userAvatarUri, matrixUser)
!userDisplayName.isNullOrBlank() && hasProfileChanged
}
return UserPreferencesState(
userId = currentUser?.userId,
return EditUserProfileState(
userId = matrixUser.userId,
displayName = userDisplayName.orEmpty(),
userAvatarUrl = userAvatarUri,
avatarActions = avatarActions,
@@ -116,7 +114,6 @@ class UserPreferencesPresenter @Inject constructor(
private fun hasAvatarUrlChanged(avatarUri: Uri?, currentUser: MatrixUser?) = avatarUri?.toString()?.trim() != currentUser?.avatarUrl?.trim()
private fun CoroutineScope.saveChanges(name: String?, avatarUri: Uri?, currentUser: MatrixUser, action: MutableState<Async<Unit>>) = launch {
matrixClient.getCurrentUser()
val results = mutableListOf<Result<Unit>>()
suspend {
if (!name.isNullOrEmpty() && name.trim() != currentUser.displayName.orEmpty().trim()) {

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.preferences.impl.user.screen
package io.element.android.features.preferences.impl.user.editprofile
import android.net.Uri
import io.element.android.libraries.architecture.Async
@@ -22,12 +22,12 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.media.AvatarAction
import kotlinx.collections.immutable.ImmutableList
data class UserPreferencesState(
data class EditUserProfileState(
val userId: UserId?,
val displayName: String,
val userAvatarUrl: Uri?,
val avatarActions: ImmutableList<AvatarAction>,
val saveButtonEnabled: Boolean,
val saveAction: Async<Unit>,
val eventSink: (UserPreferencesEvents) -> Unit
val eventSink: (EditUserProfileEvents) -> Unit
)

View File

@@ -14,22 +14,22 @@
* limitations under the License.
*/
package io.element.android.features.preferences.impl.user.screen
package io.element.android.features.preferences.impl.user.editprofile
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.collections.immutable.persistentListOf
open class UserPreferencesStateProvider : PreviewParameterProvider<UserPreferencesState> {
override val values: Sequence<UserPreferencesState>
open class EditUserProfileStateProvider : PreviewParameterProvider<EditUserProfileState> {
override val values: Sequence<EditUserProfileState>
get() = sequenceOf(
aUserPreferencesState(),
aEditUserProfileState(),
// Add other states here
)
}
fun aUserPreferencesState() = UserPreferencesState(
fun aEditUserProfileState() = EditUserProfileState(
userId = UserId("@john.doe:matrix.org"),
displayName = "John Doe",
userAvatarUrl = null,

View File

@@ -14,71 +14,59 @@
* limitations under the License.
*/
package io.element.android.features.preferences.impl.user.screen
package io.element.android.features.preferences.impl.user.editprofile
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AddAPhoto
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.LabelledTextField
import io.element.android.libraries.designsystem.components.LabelledOutlinedTextField
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
import io.element.android.libraries.matrix.ui.components.UnsavedAvatar
import io.element.android.libraries.matrix.ui.components.EditableAvatarView
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
@Composable
fun UserPreferencesView(
state: UserPreferencesState,
fun EditUserProfileView(
state: EditUserProfileState,
onBackPressed: () -> Unit,
onProfileEdited: () -> Unit,
modifier: Modifier = Modifier,
@@ -102,7 +90,7 @@ fun UserPreferencesView(
TopAppBar(
title = {
Text(
text = "Edit profile",
text = stringResource(R.string.screen_edit_profile_title),
style = ElementTheme.typography.aliasScreenTitle,
)
},
@@ -113,7 +101,7 @@ fun UserPreferencesView(
enabled = state.saveButtonEnabled,
onClick = {
focusManager.clearFocus()
state.eventSink(UserPreferencesEvents.Save)
state.eventSink(EditUserProfileEvents.Save)
},
)
}
@@ -129,33 +117,50 @@ fun UserPreferencesView(
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(24.dp))
EditableAvatarView(state, ::onAvatarClicked)
Spacer(modifier = Modifier.height(60.dp))
EditableAvatarView(
userId = state.userId?.value,
displayName = state.displayName,
avatarUrl = state.userAvatarUrl,
avatarSize = AvatarSize.RoomHeader,
onAvatarClicked = { onAvatarClicked() },
modifier = Modifier.align(Alignment.CenterHorizontally),
)
Spacer(modifier = Modifier.height(16.dp))
state.userId?.let {
Text(
modifier = Modifier.fillMaxWidth(),
text = it.value,
style = ElementTheme.typography.fontBodyLgRegular,
textAlign = TextAlign.Center,
)
}
Spacer(modifier = Modifier.height(40.dp))
LabelledTextField(
label = "Display name",
LabelledOutlinedTextField(
label = stringResource(R.string.screen_edit_profile_display_name),
value = state.displayName,
placeholder = stringResource(CommonStrings.common_room_name_placeholder),
singleLine = true,
onValueChange = { state.eventSink(UserPreferencesEvents.UpdateDisplayName(it)) },
onValueChange = { state.eventSink(EditUserProfileEvents.UpdateDisplayName(it)) },
)
}
AvatarActionBottomSheet(
actions = state.avatarActions,
modalBottomSheetState = itemActionsBottomSheetState,
onActionSelected = { state.eventSink(UserPreferencesEvents.HandleAvatarAction(it)) }
onActionSelected = { state.eventSink(EditUserProfileEvents.HandleAvatarAction(it)) }
)
when (state.saveAction) {
is Async.Loading -> {
ProgressDialog(text = "Updating profile...")
ProgressDialog(text = stringResource(R.string.screen_edit_profile_updating_details))
}
is Async.Failure -> {
ErrorDialog(
content = "Error updating profile",
onDismiss = { state.eventSink(UserPreferencesEvents.CancelSaveChanges) },
title = stringResource(R.string.screen_edit_profile_error_title),
content = stringResource(R.string.screen_edit_profile_error),
onDismiss = { state.eventSink(EditUserProfileEvents.CancelSaveChanges) },
)
}
@@ -170,82 +175,8 @@ fun UserPreferencesView(
}
}
@Composable
private fun EditableAvatarView(
state: UserPreferencesState,
onAvatarClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.size(70.dp)
.clickable(onClick = onAvatarClicked)
) {
// TODO this might be able to be simplified into a single component once send/receive media is done
when (state.userAvatarUrl?.scheme) {
null, "mxc" -> {
Avatar(
avatarData = AvatarData(state.userId.toString(), state.displayName, state.userAvatarUrl?.toString(), size = AvatarSize.RoomHeader),
modifier = Modifier.fillMaxSize(),
)
}
else -> {
UnsavedAvatar(
avatarUri = state.userAvatarUrl,
modifier = Modifier.fillMaxSize(),
)
}
}
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.size(24.dp),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(16.dp),
imageVector = Icons.Outlined.AddAPhoto,
contentDescription = "",
tint = MaterialTheme.colorScheme.onPrimary,
)
}
}
}
}
@Composable
private fun LabelledReadOnlyField(
title: String,
value: String,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
modifier = Modifier.padding(horizontal = 16.dp),
style = ElementTheme.typography.fontBodyMdRegular,
color = MaterialTheme.colorScheme.primary,
text = title,
)
Text(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 12.dp),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary,
text = value,
)
}
}
private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier =
pointerInput(Unit) {
this.pointerInput(Unit) {
detectTapGestures(onTap = {
focusManager.clearFocus()
})
@@ -253,17 +184,17 @@ private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier =
@Preview
@Composable
fun UserPreferencesViewLightPreview(@PreviewParameter(UserPreferencesStateProvider::class) state: UserPreferencesState) =
fun EditUserProfileViewLightPreview(@PreviewParameter(EditUserProfileStateProvider::class) state: EditUserProfileState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
fun UserPreferencesViewDarkPreview(@PreviewParameter(UserPreferencesStateProvider::class) state: UserPreferencesState) =
fun EditUserProfileViewDarkPreview(@PreviewParameter(EditUserProfileStateProvider::class) state: EditUserProfileState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: UserPreferencesState) {
UserPreferencesView(
private fun ContentToPreview(state: EditUserProfileState) {
EditUserProfileView(
onBackPressed = {},
onProfileEdited = {},
state = state,

View File

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

View File

@@ -18,37 +18,27 @@
package io.element.android.features.roomdetails.impl.edit
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AddAPhoto
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalFocusManager
@@ -61,21 +51,18 @@ import io.element.android.features.roomdetails.impl.R
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.LabelledTextField
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
import io.element.android.libraries.matrix.ui.components.UnsavedAvatar
import io.element.android.libraries.matrix.ui.components.EditableAvatarView
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
@@ -134,7 +121,14 @@ fun RoomDetailsEditView(
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(24.dp))
EditableAvatarView(state, ::onAvatarClicked)
EditableAvatarView(
userId = state.roomId,
displayName = state.roomName,
avatarUrl = state.roomAvatarUrl,
avatarSize = AvatarSize.EditRoomDetails,
onAvatarClicked = ::onAvatarClicked,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(60.dp))
if (state.canChangeName) {
@@ -202,56 +196,6 @@ fun RoomDetailsEditView(
}
}
@Composable
private fun EditableAvatarView(
state: RoomDetailsEditState,
onAvatarClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.size(70.dp)
.clickable(onClick = onAvatarClicked, enabled = state.canChangeAvatar)
) {
// TODO this might be able to be simplified into a single component once send/receive media is done
when (state.roomAvatarUrl?.scheme) {
null, "mxc" -> {
Avatar(
avatarData = AvatarData(state.roomId, state.roomName, state.roomAvatarUrl?.toString(), size = AvatarSize.RoomHeader),
modifier = Modifier.fillMaxSize(),
)
}
else -> {
UnsavedAvatar(
avatarUri = state.roomAvatarUrl,
modifier = Modifier.fillMaxSize(),
)
}
}
if (state.canChangeAvatar) {
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.size(24.dp),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(16.dp),
imageVector = Icons.Outlined.AddAPhoto,
contentDescription = "",
tint = MaterialTheme.colorScheme.onPrimary,
)
}
}
}
}
}
@Composable
private fun LabelledReadOnlyField(
title: String,
@@ -279,7 +223,7 @@ private fun LabelledReadOnlyField(
}
private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier =
pointerInput(Unit) {
this.pointerInput(Unit) {
detectTapGestures(onTap = {
focusManager.clearFocus()
})

View File

@@ -0,0 +1,91 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.OutlinedTextField
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme
@Composable
fun LabelledOutlinedTextField(
label: String,
value: String,
modifier: Modifier = Modifier,
placeholder: String? = null,
singleLine: Boolean = false,
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
onValueChange: (String) -> Unit = {},
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
modifier = Modifier.padding(horizontal = 16.dp),
style = ElementTheme.typography.fontBodyMdRegular,
color = MaterialTheme.colorScheme.primary,
text = label
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = value,
placeholder = placeholder?.let { { Text(placeholder) } },
onValueChange = onValueChange,
singleLine = singleLine,
maxLines = maxLines,
keyboardOptions = keyboardOptions,
)
}
}
@Preview
@Composable
internal fun LabelledOutlinedTextFieldLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun LabelledOutlinedTextFieldDarkPreview() = ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
Column {
LabelledOutlinedTextField(
label = "Room name",
value = "",
placeholder = "e.g. Product Sprint",
)
LabelledOutlinedTextField(
label = "Room name",
value = "a room name",
placeholder = "e.g. Product Sprint",
)
}
}

View File

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

View File

@@ -29,8 +29,8 @@ anvil {
}
dependencies {
implementation(projects.libraries.rustsdk)
// implementation(libs.matrix.sdk)
// implementation(projects.libraries.rustsdk)
implementation(libs.matrix.sdk)
implementation(projects.libraries.di)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.network)

View File

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

View File

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