Merge pull request #2849 from element-hq/feature/bma/roomNameEdition
Improve room setting edition
This commit is contained in:
@@ -29,12 +29,10 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
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.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
@@ -63,9 +61,7 @@ import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList
|
||||
import io.element.android.libraries.matrix.ui.components.UnsavedAvatar
|
||||
import io.element.android.libraries.permissions.api.PermissionsView
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun ConfigureRoomView(
|
||||
state: ConfigureRoomState,
|
||||
@@ -73,17 +69,12 @@ fun ConfigureRoomView(
|
||||
onRoomCreated: (RoomId) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val focusManager = LocalFocusManager.current
|
||||
val itemActionsBottomSheetState = rememberModalBottomSheetState(
|
||||
initialValue = ModalBottomSheetValue.Hidden,
|
||||
)
|
||||
val isAvatarActionsSheetVisible = remember { mutableStateOf(false) }
|
||||
|
||||
fun onAvatarClicked() {
|
||||
focusManager.clearFocus()
|
||||
coroutineScope.launch {
|
||||
itemActionsBottomSheetState.show()
|
||||
}
|
||||
isAvatarActionsSheetVisible.value = true
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
@@ -143,7 +134,8 @@ fun ConfigureRoomView(
|
||||
|
||||
AvatarActionBottomSheet(
|
||||
actions = state.avatarActions,
|
||||
modalBottomSheetState = itemActionsBottomSheetState,
|
||||
isVisible = isAvatarActionsSheetVisible.value,
|
||||
onDismiss = { isAvatarActionsSheetVisible.value = false },
|
||||
onActionSelected = { state.eventSink(ConfigureRoomEvents.HandleAvatarAction(it)) }
|
||||
)
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import io.element.android.libraries.permissions.api.PermissionsState
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class EditUserProfileState(
|
||||
val userId: UserId?,
|
||||
val userId: UserId,
|
||||
val displayName: String,
|
||||
val userAvatarUrl: Uri?,
|
||||
val avatarActions: ImmutableList<AvatarAction>,
|
||||
|
||||
@@ -25,12 +25,10 @@ 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.rememberCoroutineScope
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
@@ -57,9 +55,8 @@ import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
|
||||
import io.element.android.libraries.matrix.ui.components.EditableAvatarView
|
||||
import io.element.android.libraries.permissions.api.PermissionsView
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EditUserProfileView(
|
||||
state: EditUserProfileState,
|
||||
@@ -67,17 +64,12 @@ fun EditUserProfileView(
|
||||
onProfileEdited: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val focusManager = LocalFocusManager.current
|
||||
val itemActionsBottomSheetState = rememberModalBottomSheetState(
|
||||
initialValue = ModalBottomSheetValue.Hidden,
|
||||
)
|
||||
val isAvatarActionsSheetVisible = remember { mutableStateOf(false) }
|
||||
|
||||
fun onAvatarClicked() {
|
||||
focusManager.clearFocus()
|
||||
coroutineScope.launch {
|
||||
itemActionsBottomSheetState.show()
|
||||
}
|
||||
isAvatarActionsSheetVisible.value = true
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
@@ -114,7 +106,7 @@ fun EditUserProfileView(
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
EditableAvatarView(
|
||||
userId = state.userId?.value,
|
||||
matrixId = state.userId.value,
|
||||
displayName = state.displayName,
|
||||
avatarUrl = state.userAvatarUrl,
|
||||
avatarSize = AvatarSize.RoomHeader,
|
||||
@@ -122,14 +114,12 @@ fun EditUserProfileView(
|
||||
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,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = state.userId.value,
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
LabelledOutlinedTextField(
|
||||
label = stringResource(R.string.screen_edit_profile_display_name),
|
||||
@@ -142,7 +132,8 @@ fun EditUserProfileView(
|
||||
|
||||
AvatarActionBottomSheet(
|
||||
actions = state.avatarActions,
|
||||
modalBottomSheetState = itemActionsBottomSheetState,
|
||||
isVisible = isAvatarActionsSheetVisible.value,
|
||||
onDismiss = { isAvatarActionsSheetVisible.value = false },
|
||||
onActionSelected = { state.eventSink(EditUserProfileEvents.HandleAvatarAction(it)) }
|
||||
)
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.StateEventType
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.canSendState
|
||||
import io.element.android.libraries.matrix.ui.media.AvatarAction
|
||||
import io.element.android.libraries.matrix.ui.room.avatarUrl
|
||||
import io.element.android.libraries.matrix.ui.room.rawName
|
||||
import io.element.android.libraries.matrix.ui.room.topic
|
||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||
import io.element.android.libraries.permissions.api.PermissionsEvents
|
||||
@@ -61,23 +64,35 @@ class RoomDetailsEditPresenter @Inject constructor(
|
||||
val cameraPermissionState = cameraPermissionPresenter.present()
|
||||
val roomSyncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
||||
|
||||
// Since there is no way to obtain the new avatar uri after uploading a new avatar,
|
||||
// just erase the local value when the room field has changed
|
||||
var roomAvatarUri by rememberSaveable(room.avatarUrl) { mutableStateOf(room.avatarUrl?.toUri()) }
|
||||
val roomAvatarUri = room.avatarUrl()?.toUri()
|
||||
var roomAvatarUriEdited by rememberSaveable { mutableStateOf<Uri?>(null) }
|
||||
LaunchedEffect(roomAvatarUri) {
|
||||
// Every time the roomAvatar change (from sync), we can set the new avatar.
|
||||
roomAvatarUriEdited = roomAvatarUri
|
||||
}
|
||||
|
||||
var roomName by rememberSaveable { mutableStateOf(room.displayName.trim()) }
|
||||
var roomTopic by rememberSaveable { mutableStateOf(room.topic?.trim()) }
|
||||
val roomRawNameTrimmed = room.rawName().orEmpty().trim()
|
||||
var roomRawNameEdited by rememberSaveable { mutableStateOf("") }
|
||||
LaunchedEffect(roomRawNameTrimmed) {
|
||||
// Every time the rawName change (from sync), we can set the new name.
|
||||
roomRawNameEdited = roomRawNameTrimmed
|
||||
}
|
||||
val roomTopicTrimmed = room.topic().orEmpty().trim()
|
||||
var roomTopicEdited by rememberSaveable { mutableStateOf("") }
|
||||
LaunchedEffect(roomTopicTrimmed) {
|
||||
// Every time the topic change (from sync), we can set the new topic.
|
||||
roomTopicEdited = roomTopicTrimmed
|
||||
}
|
||||
|
||||
val saveButtonEnabled by remember(
|
||||
roomSyncUpdateFlow.value,
|
||||
roomName,
|
||||
roomTopic,
|
||||
roomRawNameTrimmed,
|
||||
roomTopicTrimmed,
|
||||
roomAvatarUri,
|
||||
) {
|
||||
derivedStateOf {
|
||||
roomAvatarUri?.toString()?.trim() != room.avatarUrl?.toUri()?.toString()?.trim() ||
|
||||
roomName.trim() != room.displayName.trim() ||
|
||||
roomTopic.orEmpty().trim() != room.topic.orEmpty().trim()
|
||||
roomRawNameTrimmed != roomRawNameEdited.trim() ||
|
||||
roomTopicTrimmed != roomTopicEdited.trim() ||
|
||||
roomAvatarUri != roomAvatarUriEdited
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,17 +100,17 @@ class RoomDetailsEditPresenter @Inject constructor(
|
||||
var canChangeTopic by remember { mutableStateOf(false) }
|
||||
var canChangeAvatar by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
LaunchedEffect(roomSyncUpdateFlow.value) {
|
||||
canChangeName = room.canSendState(StateEventType.ROOM_NAME).getOrElse { false }
|
||||
canChangeTopic = room.canSendState(StateEventType.ROOM_TOPIC).getOrElse { false }
|
||||
canChangeAvatar = room.canSendState(StateEventType.ROOM_AVATAR).getOrElse { false }
|
||||
}
|
||||
|
||||
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
|
||||
onResult = { uri -> if (uri != null) roomAvatarUri = uri }
|
||||
onResult = { uri -> if (uri != null) roomAvatarUriEdited = uri }
|
||||
)
|
||||
val galleryImagePicker = mediaPickerProvider.registerGalleryImagePicker(
|
||||
onResult = { uri -> if (uri != null) roomAvatarUri = uri }
|
||||
onResult = { uri -> if (uri != null) roomAvatarUriEdited = uri }
|
||||
)
|
||||
|
||||
LaunchedEffect(cameraPermissionState.permissionGranted) {
|
||||
@@ -105,12 +120,12 @@ class RoomDetailsEditPresenter @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
val avatarActions by remember(roomAvatarUri) {
|
||||
val avatarActions by remember(roomAvatarUriEdited) {
|
||||
derivedStateOf {
|
||||
listOfNotNull(
|
||||
AvatarAction.TakePhoto,
|
||||
AvatarAction.ChoosePhoto,
|
||||
AvatarAction.Remove.takeIf { roomAvatarUri != null },
|
||||
AvatarAction.Remove.takeIf { roomAvatarUriEdited != null },
|
||||
).toImmutableList()
|
||||
}
|
||||
}
|
||||
@@ -119,7 +134,15 @@ class RoomDetailsEditPresenter @Inject constructor(
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
fun handleEvents(event: RoomDetailsEditEvents) {
|
||||
when (event) {
|
||||
is RoomDetailsEditEvents.Save -> localCoroutineScope.saveChanges(roomName, roomTopic, roomAvatarUri, saveAction)
|
||||
is RoomDetailsEditEvents.Save -> localCoroutineScope.saveChanges(
|
||||
currentNameTrimmed = roomRawNameTrimmed,
|
||||
newNameTrimmed = roomRawNameEdited.trim(),
|
||||
currentTopicTrimmed = roomTopicTrimmed,
|
||||
newTopicTrimmed = roomTopicEdited.trim(),
|
||||
currentAvatar = roomAvatarUri,
|
||||
newAvatarUri = roomAvatarUriEdited,
|
||||
action = saveAction,
|
||||
)
|
||||
is RoomDetailsEditEvents.HandleAvatarAction -> {
|
||||
when (event.action) {
|
||||
AvatarAction.ChoosePhoto -> galleryImagePicker.launch()
|
||||
@@ -129,23 +152,23 @@ class RoomDetailsEditPresenter @Inject constructor(
|
||||
pendingPermissionRequest = true
|
||||
cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions)
|
||||
}
|
||||
AvatarAction.Remove -> roomAvatarUri = null
|
||||
AvatarAction.Remove -> roomAvatarUriEdited = null
|
||||
}
|
||||
}
|
||||
|
||||
is RoomDetailsEditEvents.UpdateRoomName -> roomName = event.name
|
||||
is RoomDetailsEditEvents.UpdateRoomTopic -> roomTopic = event.topic.takeUnless { it.isEmpty() }
|
||||
is RoomDetailsEditEvents.UpdateRoomName -> roomRawNameEdited = event.name
|
||||
is RoomDetailsEditEvents.UpdateRoomTopic -> roomTopicEdited = event.topic
|
||||
RoomDetailsEditEvents.CancelSaveChanges -> saveAction.value = AsyncAction.Uninitialized
|
||||
}
|
||||
}
|
||||
|
||||
return RoomDetailsEditState(
|
||||
roomId = room.roomId.value,
|
||||
roomName = roomName,
|
||||
roomId = room.roomId,
|
||||
roomRawName = roomRawNameEdited,
|
||||
canChangeName = canChangeName,
|
||||
roomTopic = roomTopic.orEmpty(),
|
||||
roomTopic = roomTopicEdited,
|
||||
canChangeTopic = canChangeTopic,
|
||||
roomAvatarUrl = roomAvatarUri,
|
||||
roomAvatarUrl = roomAvatarUriEdited,
|
||||
canChangeAvatar = canChangeAvatar,
|
||||
avatarActions = avatarActions,
|
||||
saveButtonEnabled = saveButtonEnabled,
|
||||
@@ -156,25 +179,28 @@ class RoomDetailsEditPresenter @Inject constructor(
|
||||
}
|
||||
|
||||
private fun CoroutineScope.saveChanges(
|
||||
name: String,
|
||||
topic: String?,
|
||||
avatarUri: Uri?,
|
||||
currentNameTrimmed: String,
|
||||
newNameTrimmed: String,
|
||||
currentTopicTrimmed: String,
|
||||
newTopicTrimmed: String,
|
||||
currentAvatar: Uri?,
|
||||
newAvatarUri: Uri?,
|
||||
action: MutableState<AsyncAction<Unit>>,
|
||||
) = launch {
|
||||
val results = mutableListOf<Result<Unit>>()
|
||||
suspend {
|
||||
if (topic.orEmpty().trim() != room.topic.orEmpty().trim()) {
|
||||
results.add(room.setTopic(topic.orEmpty()).onFailure {
|
||||
if (newTopicTrimmed != currentTopicTrimmed) {
|
||||
results.add(room.setTopic(newTopicTrimmed).onFailure {
|
||||
Timber.e(it, "Failed to set room topic")
|
||||
})
|
||||
}
|
||||
if (name.isNotEmpty() && name.trim() != room.displayName.trim()) {
|
||||
results.add(room.setName(name).onFailure {
|
||||
if (newNameTrimmed.isNotEmpty() && newNameTrimmed != currentNameTrimmed) {
|
||||
results.add(room.setName(newNameTrimmed).onFailure {
|
||||
Timber.e(it, "Failed to set room name")
|
||||
})
|
||||
}
|
||||
if (avatarUri?.toString()?.trim() != room.avatarUrl?.trim()) {
|
||||
results.add(updateAvatar(avatarUri).onFailure {
|
||||
if (newAvatarUri != currentAvatar) {
|
||||
results.add(updateAvatar(newAvatarUri).onFailure {
|
||||
Timber.e(it, "Failed to update avatar")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,13 +18,15 @@ package io.element.android.features.roomdetails.impl.edit
|
||||
|
||||
import android.net.Uri
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.ui.media.AvatarAction
|
||||
import io.element.android.libraries.permissions.api.PermissionsState
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class RoomDetailsEditState(
|
||||
val roomId: String,
|
||||
val roomName: String,
|
||||
val roomId: RoomId,
|
||||
/** The raw room name (i.e. the room name from the state event `m.room.name`), not the display name. */
|
||||
val roomRawName: String,
|
||||
val canChangeName: Boolean,
|
||||
val roomTopic: String,
|
||||
val canChangeTopic: Boolean,
|
||||
|
||||
@@ -19,33 +19,50 @@ package io.element.android.features.roomdetails.impl.edit
|
||||
import android.net.Uri
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.ui.media.AvatarAction
|
||||
import io.element.android.libraries.permissions.api.PermissionsState
|
||||
import io.element.android.libraries.permissions.api.aPermissionsState
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
open class RoomDetailsEditStateProvider : PreviewParameterProvider<RoomDetailsEditState> {
|
||||
override val values: Sequence<RoomDetailsEditState>
|
||||
get() = sequenceOf(
|
||||
aRoomDetailsEditState(),
|
||||
aRoomDetailsEditState().copy(roomTopic = ""),
|
||||
aRoomDetailsEditState().copy(roomAvatarUrl = Uri.parse("example://uri")),
|
||||
aRoomDetailsEditState().copy(canChangeName = true, canChangeTopic = false, canChangeAvatar = true, saveButtonEnabled = false),
|
||||
aRoomDetailsEditState().copy(canChangeName = false, canChangeTopic = true, canChangeAvatar = false, saveButtonEnabled = false),
|
||||
aRoomDetailsEditState().copy(saveAction = AsyncAction.Loading),
|
||||
aRoomDetailsEditState().copy(saveAction = AsyncAction.Failure(Throwable("Whelp")))
|
||||
aRoomDetailsEditState(roomTopic = ""),
|
||||
aRoomDetailsEditState(roomRawName = ""),
|
||||
aRoomDetailsEditState(roomAvatarUrl = Uri.parse("example://uri")),
|
||||
aRoomDetailsEditState(canChangeName = true, canChangeTopic = false, canChangeAvatar = true, saveButtonEnabled = false),
|
||||
aRoomDetailsEditState(canChangeName = false, canChangeTopic = true, canChangeAvatar = false, saveButtonEnabled = false),
|
||||
aRoomDetailsEditState(saveAction = AsyncAction.Loading),
|
||||
aRoomDetailsEditState(saveAction = AsyncAction.Failure(Throwable("Whelp"))),
|
||||
)
|
||||
}
|
||||
|
||||
fun aRoomDetailsEditState() = RoomDetailsEditState(
|
||||
roomId = "a room id",
|
||||
roomName = "Marketing",
|
||||
canChangeName = true,
|
||||
roomTopic = "a room topic that is quite long so should wrap onto multiple lines",
|
||||
canChangeTopic = true,
|
||||
roomAvatarUrl = null,
|
||||
canChangeAvatar = true,
|
||||
avatarActions = persistentListOf(),
|
||||
saveButtonEnabled = true,
|
||||
saveAction = AsyncAction.Uninitialized,
|
||||
cameraPermissionState = aPermissionsState(showDialog = false),
|
||||
eventSink = {}
|
||||
fun aRoomDetailsEditState(
|
||||
roomId: RoomId = RoomId("!aRoomId:aDomain"),
|
||||
roomRawName: String = "Marketing",
|
||||
canChangeName: Boolean = true,
|
||||
roomTopic: String = "a room topic that is quite long so should wrap onto multiple lines",
|
||||
canChangeTopic: Boolean = true,
|
||||
roomAvatarUrl: Uri? = null,
|
||||
canChangeAvatar: Boolean = true,
|
||||
avatarActions: List<AvatarAction> = emptyList(),
|
||||
saveButtonEnabled: Boolean = true,
|
||||
saveAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
cameraPermissionState: PermissionsState = aPermissionsState(showDialog = false),
|
||||
eventSink: (RoomDetailsEditEvents) -> Unit = {},
|
||||
) = RoomDetailsEditState(
|
||||
roomId = roomId,
|
||||
roomRawName = roomRawName,
|
||||
canChangeName = canChangeName,
|
||||
roomTopic = roomTopic,
|
||||
canChangeTopic = canChangeTopic,
|
||||
roomAvatarUrl = roomAvatarUrl,
|
||||
canChangeAvatar = canChangeAvatar,
|
||||
avatarActions = avatarActions.toImmutableList(),
|
||||
saveButtonEnabled = saveButtonEnabled,
|
||||
saveAction = saveAction,
|
||||
cameraPermissionState = cameraPermissionState,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
|
||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
package io.element.android.features.roomdetails.impl.edit
|
||||
|
||||
@@ -29,13 +29,11 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
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.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -61,9 +59,7 @@ import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
|
||||
import io.element.android.libraries.matrix.ui.components.EditableAvatarView
|
||||
import io.element.android.libraries.permissions.api.PermissionsView
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RoomDetailsEditView(
|
||||
state: RoomDetailsEditState,
|
||||
@@ -71,17 +67,12 @@ fun RoomDetailsEditView(
|
||||
onRoomEdited: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val focusManager = LocalFocusManager.current
|
||||
val itemActionsBottomSheetState = rememberModalBottomSheetState(
|
||||
initialValue = ModalBottomSheetValue.Hidden,
|
||||
)
|
||||
val isAvatarActionsSheetVisible = remember { mutableStateOf(false) }
|
||||
|
||||
fun onAvatarClicked() {
|
||||
focusManager.clearFocus()
|
||||
coroutineScope.launch {
|
||||
itemActionsBottomSheetState.show()
|
||||
}
|
||||
isAvatarActionsSheetVisible.value = true
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
@@ -118,8 +109,9 @@ fun RoomDetailsEditView(
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
EditableAvatarView(
|
||||
userId = state.roomId,
|
||||
displayName = state.roomName,
|
||||
matrixId = state.roomId.value,
|
||||
// As per Element Web, we use the raw name for the avatar as well
|
||||
displayName = state.roomRawName,
|
||||
avatarUrl = state.roomAvatarUrl,
|
||||
avatarSize = AvatarSize.EditRoomDetails,
|
||||
onAvatarClicked = ::onAvatarClicked,
|
||||
@@ -130,7 +122,7 @@ fun RoomDetailsEditView(
|
||||
if (state.canChangeName) {
|
||||
LabelledTextField(
|
||||
label = stringResource(id = R.string.screen_room_details_room_name_label),
|
||||
value = state.roomName,
|
||||
value = state.roomRawName,
|
||||
placeholder = stringResource(CommonStrings.common_room_name_placeholder),
|
||||
singleLine = true,
|
||||
onValueChange = { state.eventSink(RoomDetailsEditEvents.UpdateRoomName(it)) },
|
||||
@@ -138,7 +130,7 @@ fun RoomDetailsEditView(
|
||||
} else {
|
||||
LabelledReadOnlyField(
|
||||
title = stringResource(R.string.screen_room_details_room_name_label),
|
||||
value = state.roomName
|
||||
value = state.roomRawName
|
||||
)
|
||||
}
|
||||
|
||||
@@ -166,7 +158,8 @@ fun RoomDetailsEditView(
|
||||
|
||||
AvatarActionBottomSheet(
|
||||
actions = state.avatarActions,
|
||||
modalBottomSheetState = itemActionsBottomSheetState,
|
||||
isVisible = isAvatarActionsSheetVisible.value,
|
||||
onDismiss = { isAvatarActionsSheetVisible.value = false },
|
||||
onActionSelected = { state.eventSink(RoomDetailsEditEvents.HandleAvatarAction(it)) }
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright (c) 2024 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.roomdetails
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_NAME
|
||||
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||
|
||||
fun aMatrixRoom(
|
||||
roomId: RoomId = A_ROOM_ID,
|
||||
displayName: String = A_ROOM_NAME,
|
||||
rawName: String? = displayName,
|
||||
topic: String? = "A topic",
|
||||
avatarUrl: String? = "https://matrix.org/avatar.jpg",
|
||||
isEncrypted: Boolean = true,
|
||||
isPublic: Boolean = true,
|
||||
isDirect: Boolean = false,
|
||||
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
|
||||
emitRoomInfo: Boolean = false,
|
||||
) = FakeMatrixRoom(
|
||||
roomId = roomId,
|
||||
displayName = displayName,
|
||||
topic = topic,
|
||||
avatarUrl = avatarUrl,
|
||||
isEncrypted = isEncrypted,
|
||||
isPublic = isPublic,
|
||||
isDirect = isDirect,
|
||||
notificationSettingsService = notificationSettingsService
|
||||
).apply {
|
||||
if (emitRoomInfo) {
|
||||
givenRoomInfo(
|
||||
aRoomInfo(
|
||||
name = displayName,
|
||||
rawName = rawName,
|
||||
topic = topic,
|
||||
avatarUrl = avatarUrl,
|
||||
isDirect = isDirect,
|
||||
isPublic = isPublic,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -37,14 +37,11 @@ import io.element.android.features.roomdetails.impl.members.details.RoomMemberDe
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
import io.element.android.libraries.matrix.api.room.StateEventType
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_NAME
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
@@ -473,23 +470,3 @@ class RoomDetailsPresenterTests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun aMatrixRoom(
|
||||
roomId: RoomId = A_ROOM_ID,
|
||||
displayName: String = A_ROOM_NAME,
|
||||
topic: String? = "A topic",
|
||||
avatarUrl: String? = "https://matrix.org/avatar.jpg",
|
||||
isEncrypted: Boolean = true,
|
||||
isPublic: Boolean = true,
|
||||
isDirect: Boolean = false,
|
||||
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService()
|
||||
) = FakeMatrixRoom(
|
||||
roomId = roomId,
|
||||
displayName = displayName,
|
||||
topic = topic,
|
||||
avatarUrl = avatarUrl,
|
||||
isEncrypted = isEncrypted,
|
||||
isPublic = isPublic,
|
||||
isDirect = isDirect,
|
||||
notificationSettingsService = notificationSettingsService
|
||||
)
|
||||
|
||||
@@ -19,6 +19,7 @@ package io.element.android.features.roomdetails.edit
|
||||
import android.net.Uri
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.ReceiveTurbine
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.roomdetails.aMatrixRoom
|
||||
@@ -28,6 +29,8 @@ import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.StateEventType
|
||||
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_NAME
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_RAW_NAME
|
||||
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
|
||||
@@ -90,15 +93,19 @@ class RoomDetailsEditPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - initial state is created from room info`() = runTest {
|
||||
val room = aMatrixRoom(avatarUrl = AN_AVATAR_URL)
|
||||
val room = aMatrixRoom(
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
displayName = A_ROOM_NAME,
|
||||
rawName = A_ROOM_RAW_NAME,
|
||||
emitRoomInfo = true,
|
||||
)
|
||||
val presenter = createRoomDetailsEditPresenter(room)
|
||||
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.roomId).isEqualTo(room.roomId.value)
|
||||
assertThat(initialState.roomName).isEqualTo(room.displayName)
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.roomId).isEqualTo(room.roomId)
|
||||
assertThat(initialState.roomRawName).isEqualTo(A_ROOM_RAW_NAME)
|
||||
assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri)
|
||||
assertThat(initialState.roomTopic).isEqualTo(room.topic.orEmpty())
|
||||
assertThat(initialState.avatarActions).containsExactly(
|
||||
@@ -119,7 +126,6 @@ class RoomDetailsEditPresenterTest {
|
||||
givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.failure(Throwable("Oops")))
|
||||
}
|
||||
val presenter = createRoomDetailsEditPresenter(room)
|
||||
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -128,7 +134,6 @@ class RoomDetailsEditPresenterTest {
|
||||
assertThat(initialState.canChangeName).isFalse()
|
||||
assertThat(initialState.canChangeAvatar).isFalse()
|
||||
assertThat(initialState.canChangeTopic).isFalse()
|
||||
|
||||
// When the asynchronous check completes, the single field we can edit is true
|
||||
val settledState = awaitItem()
|
||||
assertThat(settledState.canChangeName).isTrue()
|
||||
@@ -145,7 +150,6 @@ class RoomDetailsEditPresenterTest {
|
||||
givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.failure(Throwable("Oops")))
|
||||
}
|
||||
val presenter = createRoomDetailsEditPresenter(room)
|
||||
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -154,7 +158,6 @@ class RoomDetailsEditPresenterTest {
|
||||
assertThat(initialState.canChangeName).isFalse()
|
||||
assertThat(initialState.canChangeAvatar).isFalse()
|
||||
assertThat(initialState.canChangeTopic).isFalse()
|
||||
|
||||
// When the asynchronous check completes, the single field we can edit is true
|
||||
val settledState = awaitItem()
|
||||
assertThat(settledState.canChangeName).isFalse()
|
||||
@@ -171,7 +174,6 @@ class RoomDetailsEditPresenterTest {
|
||||
givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(true))
|
||||
}
|
||||
val presenter = createRoomDetailsEditPresenter(room)
|
||||
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -180,7 +182,6 @@ class RoomDetailsEditPresenterTest {
|
||||
assertThat(initialState.canChangeName).isFalse()
|
||||
assertThat(initialState.canChangeAvatar).isFalse()
|
||||
assertThat(initialState.canChangeTopic).isFalse()
|
||||
|
||||
// When the asynchronous check completes, the single field we can edit is true
|
||||
val settledState = awaitItem()
|
||||
assertThat(settledState.canChangeName).isFalse()
|
||||
@@ -191,42 +192,42 @@ class RoomDetailsEditPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - updates state in response to changes`() = runTest {
|
||||
val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL)
|
||||
val room = aMatrixRoom(
|
||||
topic = "My topic",
|
||||
displayName = "Name",
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
emitRoomInfo = true,
|
||||
)
|
||||
val presenter = createRoomDetailsEditPresenter(room)
|
||||
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.roomTopic).isEqualTo("My topic")
|
||||
assertThat(initialState.roomName).isEqualTo("Name")
|
||||
assertThat(initialState.roomRawName).isEqualTo("Name")
|
||||
assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri)
|
||||
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name II"))
|
||||
awaitItem().apply {
|
||||
assertThat(roomTopic).isEqualTo("My topic")
|
||||
assertThat(roomName).isEqualTo("Name II")
|
||||
assertThat(roomRawName).isEqualTo("Name II")
|
||||
assertThat(roomAvatarUrl).isEqualTo(roomAvatarUri)
|
||||
}
|
||||
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name III"))
|
||||
awaitItem().apply {
|
||||
assertThat(roomTopic).isEqualTo("My topic")
|
||||
assertThat(roomName).isEqualTo("Name III")
|
||||
assertThat(roomRawName).isEqualTo("Name III")
|
||||
assertThat(roomAvatarUrl).isEqualTo(roomAvatarUri)
|
||||
}
|
||||
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("Another topic"))
|
||||
awaitItem().apply {
|
||||
assertThat(roomTopic).isEqualTo("Another topic")
|
||||
assertThat(roomName).isEqualTo("Name III")
|
||||
assertThat(roomRawName).isEqualTo("Name III")
|
||||
assertThat(roomAvatarUrl).isEqualTo(roomAvatarUri)
|
||||
}
|
||||
|
||||
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove))
|
||||
awaitItem().apply {
|
||||
assertThat(roomTopic).isEqualTo("Another topic")
|
||||
assertThat(roomName).isEqualTo("Name III")
|
||||
assertThat(roomRawName).isEqualTo("Name III")
|
||||
assertThat(roomAvatarUrl).isNull()
|
||||
}
|
||||
}
|
||||
@@ -234,18 +235,19 @@ class RoomDetailsEditPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - obtains avatar uris from gallery`() = runTest {
|
||||
val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL)
|
||||
|
||||
val room = aMatrixRoom(
|
||||
topic = "My topic",
|
||||
displayName = "Name",
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
emitRoomInfo = true,
|
||||
)
|
||||
fakePickerProvider.givenResult(anotherAvatarUri)
|
||||
|
||||
val presenter = createRoomDetailsEditPresenter(room)
|
||||
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri)
|
||||
|
||||
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
|
||||
awaitItem().apply {
|
||||
assertThat(roomAvatarUrl).isEqualTo(anotherAvatarUri)
|
||||
@@ -255,19 +257,22 @@ class RoomDetailsEditPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - obtains avatar uris from camera`() = runTest {
|
||||
val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL)
|
||||
|
||||
val room = aMatrixRoom(
|
||||
topic = "My topic",
|
||||
displayName = "Name",
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
emitRoomInfo = true,
|
||||
)
|
||||
fakePickerProvider.givenResult(anotherAvatarUri)
|
||||
val fakePermissionsPresenter = FakePermissionsPresenter()
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
permissionsPresenter = fakePermissionsPresenter,
|
||||
)
|
||||
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri)
|
||||
assertThat(initialState.cameraPermissionState.permissionGranted).isFalse()
|
||||
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.TakePhoto))
|
||||
@@ -288,48 +293,44 @@ class RoomDetailsEditPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - updates save button state`() = runTest {
|
||||
val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL)
|
||||
|
||||
val room = aMatrixRoom(
|
||||
topic = "My topic",
|
||||
displayName = "Name",
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
emitRoomInfo = true,
|
||||
)
|
||||
fakePickerProvider.givenResult(roomAvatarUri)
|
||||
|
||||
val presenter = createRoomDetailsEditPresenter(room)
|
||||
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.saveButtonEnabled).isFalse()
|
||||
|
||||
// Once a change is made, the save button is enabled
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name II"))
|
||||
awaitItem().apply {
|
||||
assertThat(saveButtonEnabled).isTrue()
|
||||
}
|
||||
|
||||
// If it's reverted then the save disables again
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name"))
|
||||
awaitItem().apply {
|
||||
assertThat(saveButtonEnabled).isFalse()
|
||||
}
|
||||
|
||||
// Make a change...
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("Another topic"))
|
||||
awaitItem().apply {
|
||||
assertThat(saveButtonEnabled).isTrue()
|
||||
}
|
||||
|
||||
// Revert it...
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("My topic"))
|
||||
awaitItem().apply {
|
||||
assertThat(saveButtonEnabled).isFalse()
|
||||
}
|
||||
|
||||
// Make a change...
|
||||
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove))
|
||||
awaitItem().apply {
|
||||
assertThat(saveButtonEnabled).isTrue()
|
||||
}
|
||||
|
||||
// Revert it...
|
||||
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
|
||||
awaitItem().apply {
|
||||
@@ -340,48 +341,44 @@ class RoomDetailsEditPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - updates save button state when initial values are null`() = runTest {
|
||||
val room = aMatrixRoom(topic = null, displayName = "fallback", avatarUrl = null)
|
||||
|
||||
val room = aMatrixRoom(
|
||||
topic = null,
|
||||
displayName = "fallback",
|
||||
avatarUrl = null,
|
||||
emitRoomInfo = true,
|
||||
)
|
||||
fakePickerProvider.givenResult(roomAvatarUri)
|
||||
|
||||
val presenter = createRoomDetailsEditPresenter(room)
|
||||
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.saveButtonEnabled).isFalse()
|
||||
|
||||
// Once a change is made, the save button is enabled
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name II"))
|
||||
awaitItem().apply {
|
||||
assertThat(saveButtonEnabled).isTrue()
|
||||
}
|
||||
|
||||
// If it's reverted then the save disables again
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("fallback"))
|
||||
awaitItem().apply {
|
||||
assertThat(saveButtonEnabled).isFalse()
|
||||
}
|
||||
|
||||
// Make a change...
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("Another topic"))
|
||||
awaitItem().apply {
|
||||
assertThat(saveButtonEnabled).isTrue()
|
||||
}
|
||||
|
||||
// Revert it...
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(""))
|
||||
awaitItem().apply {
|
||||
assertThat(saveButtonEnabled).isFalse()
|
||||
}
|
||||
|
||||
// Make a change...
|
||||
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
|
||||
awaitItem().apply {
|
||||
assertThat(saveButtonEnabled).isTrue()
|
||||
}
|
||||
|
||||
// Revert it...
|
||||
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove))
|
||||
awaitItem().apply {
|
||||
@@ -392,15 +389,17 @@ class RoomDetailsEditPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - save changes room details if different`() = runTest {
|
||||
val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL)
|
||||
|
||||
val room = aMatrixRoom(
|
||||
topic = "My topic",
|
||||
displayName = "Name",
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
emitRoomInfo = true,
|
||||
)
|
||||
val presenter = createRoomDetailsEditPresenter(room)
|
||||
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("New name"))
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("New topic"))
|
||||
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove))
|
||||
@@ -410,31 +409,24 @@ class RoomDetailsEditPresenterTest {
|
||||
assertThat(room.newTopic).isEqualTo("New topic")
|
||||
assertThat(room.newAvatarData).isNull()
|
||||
assertThat(room.removedAvatar).isTrue()
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - save doesn't change room details if they're the same trimmed`() = runTest {
|
||||
val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL)
|
||||
|
||||
val presenter = createRoomDetailsEditPresenter(room)
|
||||
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName(" Name "))
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(" My topic "))
|
||||
initialState.eventSink(RoomDetailsEditEvents.Save)
|
||||
|
||||
assertThat(room.newName).isNull()
|
||||
assertThat(room.newTopic).isNull()
|
||||
assertThat(room.newAvatarData).isNull()
|
||||
assertThat(room.removedAvatar).isFalse()
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
@@ -442,22 +434,17 @@ class RoomDetailsEditPresenterTest {
|
||||
@Test
|
||||
fun `present - save doesn't change topic if it was unset and is now blank`() = runTest {
|
||||
val room = aMatrixRoom(topic = null, displayName = "Name", avatarUrl = AN_AVATAR_URL)
|
||||
|
||||
val presenter = createRoomDetailsEditPresenter(room)
|
||||
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(""))
|
||||
initialState.eventSink(RoomDetailsEditEvents.Save)
|
||||
|
||||
assertThat(room.newName).isNull()
|
||||
assertThat(room.newTopic).isNull()
|
||||
assertThat(room.newAvatarData).isNull()
|
||||
assertThat(room.removedAvatar).isFalse()
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
@@ -465,22 +452,17 @@ class RoomDetailsEditPresenterTest {
|
||||
@Test
|
||||
fun `present - save doesn't change name if it's now empty`() = runTest {
|
||||
val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL)
|
||||
|
||||
val presenter = createRoomDetailsEditPresenter(room)
|
||||
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName(""))
|
||||
initialState.eventSink(RoomDetailsEditEvents.Save)
|
||||
|
||||
assertThat(room.newName).isNull()
|
||||
assertThat(room.newTopic).isNull()
|
||||
assertThat(room.newAvatarData).isNull()
|
||||
assertThat(room.removedAvatar).isFalse()
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
@@ -488,20 +470,15 @@ class RoomDetailsEditPresenterTest {
|
||||
@Test
|
||||
fun `present - save processes and sets avatar when processor returns successfully`() = runTest {
|
||||
val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL)
|
||||
|
||||
givenPickerReturnsFile()
|
||||
|
||||
val presenter = createRoomDetailsEditPresenter(room)
|
||||
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
||||
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
|
||||
initialState.eventSink(RoomDetailsEditEvents.Save)
|
||||
skipItems(3)
|
||||
|
||||
assertThat(room.newName).isNull()
|
||||
assertThat(room.newTopic).isNull()
|
||||
assertThat(room.newAvatarData).isSameInstanceAs(fakeFileContents)
|
||||
@@ -512,89 +489,92 @@ class RoomDetailsEditPresenterTest {
|
||||
@Test
|
||||
fun `present - save does not set avatar data if processor fails`() = runTest {
|
||||
val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL)
|
||||
|
||||
fakePickerProvider.givenResult(anotherAvatarUri)
|
||||
fakeMediaPreProcessor.givenResult(Result.failure(Throwable("Oh no")))
|
||||
|
||||
val presenter = createRoomDetailsEditPresenter(room)
|
||||
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
||||
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
|
||||
initialState.eventSink(RoomDetailsEditEvents.Save)
|
||||
skipItems(2)
|
||||
|
||||
assertThat(room.newName).isNull()
|
||||
assertThat(room.newTopic).isNull()
|
||||
assertThat(room.newAvatarData).isNull()
|
||||
assertThat(room.removedAvatar).isFalse()
|
||||
|
||||
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - sets save action to failure if name update fails`() = runTest {
|
||||
val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL).apply {
|
||||
val room = aMatrixRoom(
|
||||
topic = "My topic",
|
||||
displayName = "Name",
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
emitRoomInfo = true,
|
||||
).apply {
|
||||
givenSetNameResult(Result.failure(Throwable("!")))
|
||||
}
|
||||
|
||||
saveAndAssertFailure(room, RoomDetailsEditEvents.UpdateRoomName("New name"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - sets save action to failure if topic update fails`() = runTest {
|
||||
val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL).apply {
|
||||
val room = aMatrixRoom(
|
||||
topic = "My topic",
|
||||
displayName = "Name",
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
emitRoomInfo = true,
|
||||
).apply {
|
||||
givenSetTopicResult(Result.failure(Throwable("!")))
|
||||
}
|
||||
|
||||
saveAndAssertFailure(room, RoomDetailsEditEvents.UpdateRoomTopic("New topic"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - sets save action to failure if removing avatar fails`() = runTest {
|
||||
val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL).apply {
|
||||
val room = aMatrixRoom(
|
||||
topic = "My topic",
|
||||
displayName = "Name",
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
emitRoomInfo = true,
|
||||
).apply {
|
||||
givenRemoveAvatarResult(Result.failure(Throwable("!")))
|
||||
}
|
||||
|
||||
saveAndAssertFailure(room, RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - sets save action to failure if setting avatar fails`() = runTest {
|
||||
givenPickerReturnsFile()
|
||||
|
||||
val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL).apply {
|
||||
val room = aMatrixRoom(
|
||||
topic = "My topic",
|
||||
displayName = "Name",
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
emitRoomInfo = true,
|
||||
).apply {
|
||||
givenUpdateAvatarResult(Result.failure(Throwable("!")))
|
||||
}
|
||||
|
||||
saveAndAssertFailure(room, RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - CancelSaveChanges resets save action state`() = runTest {
|
||||
givenPickerReturnsFile()
|
||||
|
||||
val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL).apply {
|
||||
givenSetTopicResult(Result.failure(Throwable("!")))
|
||||
}
|
||||
|
||||
val presenter = createRoomDetailsEditPresenter(room)
|
||||
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("foo"))
|
||||
initialState.eventSink(RoomDetailsEditEvents.Save)
|
||||
skipItems(2)
|
||||
|
||||
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java)
|
||||
|
||||
initialState.eventSink(RoomDetailsEditEvents.CancelSaveChanges)
|
||||
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
|
||||
}
|
||||
@@ -602,16 +582,13 @@ class RoomDetailsEditPresenterTest {
|
||||
|
||||
private suspend fun saveAndAssertFailure(room: MatrixRoom, event: RoomDetailsEditEvents) {
|
||||
val presenter = createRoomDetailsEditPresenter(room)
|
||||
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink(event)
|
||||
initialState.eventSink(RoomDetailsEditEvents.Save)
|
||||
skipItems(1)
|
||||
|
||||
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java)
|
||||
}
|
||||
@@ -622,7 +599,6 @@ class RoomDetailsEditPresenterTest {
|
||||
val processedFile: File = mockk {
|
||||
every { readBytes() } returns fakeFileContents
|
||||
}
|
||||
|
||||
fakePickerProvider.givenResult(anotherAvatarUri)
|
||||
fakeMediaPreProcessor.givenResult(
|
||||
Result.success(
|
||||
@@ -638,3 +614,8 @@ class RoomDetailsEditPresenterTest {
|
||||
private const val ANOTHER_AVATAR_URL = "example://camera/foo.jpg"
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
|
||||
skipItems(2)
|
||||
return awaitItem()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/*
|
||||
* Copyright (c) 2024 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.roomdetails.edit
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.ui.test.assertHasClickAction
|
||||
import androidx.compose.ui.test.assertHasNoClickAction
|
||||
import androidx.compose.ui.test.hasTestTag
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditEvents
|
||||
import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditState
|
||||
import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditView
|
||||
import io.element.android.features.roomdetails.impl.edit.aRoomDetailsEditState
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.matrix.ui.media.AvatarAction
|
||||
import io.element.android.libraries.testtags.TestTags
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.clickOn
|
||||
import io.element.android.tests.testutils.ensureCalledOnce
|
||||
import io.element.android.tests.testutils.pressBack
|
||||
import org.junit.Ignore
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class RoomDetailsEditViewTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun `clicking on back invoke back callback`() {
|
||||
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>(expectEvents = false)
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setRoomDetailsEditView(
|
||||
aRoomDetailsEditState(
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onBackPressed = callback,
|
||||
)
|
||||
rule.pressBack()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when edition is successful, the expected callback is invoked`() {
|
||||
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>(expectEvents = false)
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setRoomDetailsEditView(
|
||||
aRoomDetailsEditState(
|
||||
eventSink = eventsRecorder,
|
||||
saveAction = AsyncAction.Success(Unit)
|
||||
),
|
||||
onRoomEdited = callback,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when name is changed, the expected Event is emitted`() {
|
||||
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>()
|
||||
rule.setRoomDetailsEditView(
|
||||
aRoomDetailsEditState(
|
||||
eventSink = eventsRecorder,
|
||||
roomRawName = "Marketing",
|
||||
),
|
||||
)
|
||||
rule.onNodeWithText("Marketing").assertHasClickAction()
|
||||
rule.onNodeWithText("Marketing").performTextInput("A")
|
||||
eventsRecorder.assertSingle(RoomDetailsEditEvents.UpdateRoomName("AMarketing"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when user cannot change name, nothing happen`() {
|
||||
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>(expectEvents = false)
|
||||
rule.setRoomDetailsEditView(
|
||||
aRoomDetailsEditState(
|
||||
eventSink = eventsRecorder,
|
||||
roomRawName = "Marketing",
|
||||
canChangeName = false,
|
||||
),
|
||||
)
|
||||
rule.onNodeWithText("Marketing").assertHasNoClickAction()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when topic is changed, the expected Event is emitted`() {
|
||||
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>()
|
||||
rule.setRoomDetailsEditView(
|
||||
aRoomDetailsEditState(
|
||||
eventSink = eventsRecorder,
|
||||
roomTopic = "My Topic",
|
||||
),
|
||||
)
|
||||
rule.onNodeWithText("My Topic").assertHasClickAction()
|
||||
rule.onNodeWithText("My Topic").performTextInput("A")
|
||||
eventsRecorder.assertSingle(RoomDetailsEditEvents.UpdateRoomTopic("AMy Topic"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when user cannot change topic, nothing happen`() {
|
||||
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>(expectEvents = false)
|
||||
rule.setRoomDetailsEditView(
|
||||
aRoomDetailsEditState(
|
||||
eventSink = eventsRecorder,
|
||||
roomTopic = "My Topic",
|
||||
canChangeTopic = false,
|
||||
),
|
||||
)
|
||||
rule.onNodeWithText("My Topic").assertHasNoClickAction()
|
||||
}
|
||||
|
||||
@Ignore("This test is failing because the bottom sheet does not open")
|
||||
@Test
|
||||
fun `when avatar is changed with action to take photo, the expected Event is emitted`() {
|
||||
testAvatarChange(
|
||||
stringActionRes = CommonStrings.action_take_photo,
|
||||
expectedEvent = RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.TakePhoto),
|
||||
)
|
||||
}
|
||||
|
||||
@Ignore("This test is failing because the bottom sheet does not open")
|
||||
@Test
|
||||
fun `when avatar is changed with action to choose photo, the expected Event is emitted`() {
|
||||
testAvatarChange(
|
||||
stringActionRes = CommonStrings.action_choose_photo,
|
||||
expectedEvent = RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto),
|
||||
)
|
||||
}
|
||||
|
||||
@Ignore("This test is failing because the bottom sheet does not open")
|
||||
@Test
|
||||
fun `when avatar is changed with action to remove photo, the expected Event is emitted`() {
|
||||
testAvatarChange(
|
||||
stringActionRes = CommonStrings.action_remove,
|
||||
expectedEvent = RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove),
|
||||
)
|
||||
}
|
||||
|
||||
private fun testAvatarChange(
|
||||
@StringRes stringActionRes: Int,
|
||||
expectedEvent: RoomDetailsEditEvents.HandleAvatarAction,
|
||||
) {
|
||||
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>()
|
||||
rule.setRoomDetailsEditView(
|
||||
aRoomDetailsEditState(
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
// Open the bottom sheet
|
||||
rule.onNode(hasTestTag(TestTags.editAvatar.value)).performClick()
|
||||
rule.onNodeWithText(rule.activity.getString(stringActionRes)).assertExists()
|
||||
rule.clickOn(stringActionRes)
|
||||
eventsRecorder.assertSingle(expectedEvent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when user cannot change avatar, nothing happen`() {
|
||||
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>(expectEvents = false)
|
||||
rule.setRoomDetailsEditView(
|
||||
aRoomDetailsEditState(
|
||||
eventSink = eventsRecorder,
|
||||
canChangeAvatar = false,
|
||||
),
|
||||
)
|
||||
rule.onNode(hasTestTag(TestTags.editAvatar.value)).performClick()
|
||||
rule.onNodeWithText(rule.activity.getString(CommonStrings.action_take_photo)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when save is clicked, the expected Event is emitted`() {
|
||||
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>()
|
||||
rule.setRoomDetailsEditView(
|
||||
aRoomDetailsEditState(
|
||||
eventSink = eventsRecorder,
|
||||
saveButtonEnabled = true,
|
||||
),
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_save)
|
||||
eventsRecorder.assertSingle(RoomDetailsEditEvents.Save)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when save is clicked, but nothing need to be saved, nothing happens`() {
|
||||
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>(expectEvents = false)
|
||||
rule.setRoomDetailsEditView(
|
||||
aRoomDetailsEditState(
|
||||
eventSink = eventsRecorder,
|
||||
saveButtonEnabled = false,
|
||||
),
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_save)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when error is shown, closing the dialog emit the expected Event`() {
|
||||
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>()
|
||||
rule.setRoomDetailsEditView(
|
||||
aRoomDetailsEditState(
|
||||
eventSink = eventsRecorder,
|
||||
saveAction = AsyncAction.Failure(Throwable("Whelp")),
|
||||
),
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_ok)
|
||||
eventsRecorder.assertSingle(RoomDetailsEditEvents.CancelSaveChanges)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomDetailsEditView(
|
||||
state: RoomDetailsEditState,
|
||||
onBackPressed: () -> Unit = EnsureNeverCalled(),
|
||||
onRoomEdited: () -> Unit = EnsureNeverCalled(),
|
||||
) {
|
||||
setContent {
|
||||
RoomDetailsEditView(
|
||||
state = state,
|
||||
onBackPressed = onBackPressed,
|
||||
onRoomEdited = onRoomEdited,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// This is actually expected, as we should remove this component soon and use ModalBottomSheet instead
|
||||
@file:Suppress("UsingMaterialAndMaterial3Libraries")
|
||||
|
||||
package io.element.android.libraries.designsystem.theme.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CornerSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.ModalBottomSheetDefaults
|
||||
import androidx.compose.material.ModalBottomSheetState
|
||||
import androidx.compose.material.ModalBottomSheetValue
|
||||
import androidx.compose.material.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.contentColorFor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
|
||||
import io.element.android.libraries.designsystem.modifiers.applyIf
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.preview.PreviewGroup
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun ModalBottomSheetLayout(
|
||||
sheetContent: @Composable ColumnScope.() -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
sheetState: ModalBottomSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden),
|
||||
sheetShape: Shape = MaterialTheme.shapes.large.copy(bottomStart = CornerSize(0.dp), bottomEnd = CornerSize(0.dp)),
|
||||
sheetElevation: Dp = ModalBottomSheetDefaults.Elevation,
|
||||
sheetBackgroundColor: Color = MaterialTheme.colorScheme.surface,
|
||||
sheetContentColor: Color = contentColorFor(sheetBackgroundColor),
|
||||
scrimColor: Color = ModalBottomSheetDefaults.scrimColor,
|
||||
displayHandle: Boolean = false,
|
||||
useSystemPadding: Boolean = true,
|
||||
content: @Composable () -> Unit = {}
|
||||
) {
|
||||
androidx.compose.material.ModalBottomSheetLayout(
|
||||
sheetContent = {
|
||||
Column(
|
||||
Modifier.fillMaxWidth()
|
||||
.applyIf(useSystemPadding, ifTrue = {
|
||||
navigationBarsPadding()
|
||||
})
|
||||
) {
|
||||
if (displayHandle) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.onSurfaceVariant, RoundedCornerShape(2.dp))
|
||||
.size(width = 32.dp, height = 4.dp)
|
||||
.align(Alignment.CenterHorizontally),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
sheetContent()
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
sheetState = sheetState,
|
||||
sheetShape = sheetShape,
|
||||
sheetElevation = sheetElevation,
|
||||
sheetBackgroundColor = sheetBackgroundColor,
|
||||
sheetContentColor = sheetContentColor,
|
||||
scrimColor = scrimColor,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(group = PreviewGroup.BottomSheets)
|
||||
@Composable
|
||||
internal fun ModalBottomSheetLayoutLightPreview() =
|
||||
ElementPreviewLight { ContentToPreview() }
|
||||
|
||||
@Preview(group = PreviewGroup.BottomSheets)
|
||||
@Composable
|
||||
internal fun ModalBottomSheetLayoutDarkPreview() =
|
||||
ElementPreviewDark { ContentToPreview() }
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@ExcludeFromCoverage
|
||||
@Composable
|
||||
private fun ContentToPreview() {
|
||||
ModalBottomSheetLayout(
|
||||
modifier = Modifier.height(140.dp),
|
||||
displayHandle = true,
|
||||
sheetState = ModalBottomSheetState(ModalBottomSheetValue.Expanded, density = LocalDensity.current),
|
||||
sheetContent = {
|
||||
Text(
|
||||
text = "Sheet Content",
|
||||
modifier = Modifier
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 20.dp)
|
||||
.background(color = Color.Green)
|
||||
)
|
||||
}
|
||||
) {
|
||||
Text(text = "Content", modifier = Modifier.background(color = Color.Red))
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,10 @@ import kotlinx.collections.immutable.ImmutableMap
|
||||
@Immutable
|
||||
data class MatrixRoomInfo(
|
||||
val id: RoomId,
|
||||
/** The room's name from the room state event if received from sync, or one that's been computed otherwise. */
|
||||
val name: String?,
|
||||
/** Room name as defined by the room state event only. */
|
||||
val rawName: String?,
|
||||
val topic: String?,
|
||||
val avatarUrl: String?,
|
||||
val isDirect: Boolean,
|
||||
|
||||
@@ -39,6 +39,7 @@ class MatrixRoomInfoMapper(
|
||||
return MatrixRoomInfo(
|
||||
id = RoomId(it.id),
|
||||
name = it.displayName,
|
||||
rawName = it.rawName,
|
||||
topic = it.topic,
|
||||
avatarUrl = it.avatarUrl,
|
||||
isDirect = it.isDirect,
|
||||
|
||||
@@ -56,6 +56,7 @@ val A_TRANSACTION_ID = TransactionId("aTransactionId")
|
||||
const val A_UNIQUE_ID = "aUniqueId"
|
||||
|
||||
const val A_ROOM_NAME = "A room name"
|
||||
const val A_ROOM_RAW_NAME = "A room raw name"
|
||||
const val A_MESSAGE = "Hello world!"
|
||||
const val A_REPLY = "OK, I'll be there!"
|
||||
const val ANOTHER_MESSAGE = "Hello universe!"
|
||||
|
||||
@@ -735,6 +735,7 @@ data class EndPollInvocation(
|
||||
fun aRoomInfo(
|
||||
id: RoomId = A_ROOM_ID,
|
||||
name: String? = A_ROOM_NAME,
|
||||
rawName: String? = name,
|
||||
topic: String? = "A topic",
|
||||
avatarUrl: String? = AN_AVATAR_URL,
|
||||
isDirect: Boolean = false,
|
||||
@@ -759,6 +760,7 @@ fun aRoomInfo(
|
||||
) = MatrixRoomInfo(
|
||||
id = id,
|
||||
name = name,
|
||||
rawName = rawName,
|
||||
topic = topic,
|
||||
avatarUrl = avatarUrl,
|
||||
isDirect = isDirect,
|
||||
|
||||
@@ -39,6 +39,7 @@ dependencies {
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.libraries.testtags)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.gif)
|
||||
implementation(libs.jsoup)
|
||||
|
||||
@@ -14,25 +14,23 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalMaterialApi::class)
|
||||
@file:Suppress("UsingMaterialAndMaterial3Libraries")
|
||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
package io.element.android.libraries.matrix.ui.components
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.ModalBottomSheetState
|
||||
import androidx.compose.material.ModalBottomSheetValue
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||
@@ -41,33 +39,44 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.IconSource
|
||||
import io.element.android.libraries.designsystem.theme.components.ListItem
|
||||
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
|
||||
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheetLayout
|
||||
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.hide
|
||||
import io.element.android.libraries.matrix.ui.media.AvatarAction
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AvatarActionBottomSheet(
|
||||
actions: ImmutableList<AvatarAction>,
|
||||
modalBottomSheetState: ModalBottomSheetState,
|
||||
isVisible: Boolean,
|
||||
onActionSelected: (action: AvatarAction) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
fun onItemActionClicked(itemAction: AvatarAction) {
|
||||
onActionSelected(itemAction)
|
||||
coroutineScope.launch {
|
||||
modalBottomSheetState.hide()
|
||||
}
|
||||
val sheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true
|
||||
)
|
||||
|
||||
BackHandler(enabled = isVisible) {
|
||||
sheetState.hide(coroutineScope, then = { onDismiss() })
|
||||
}
|
||||
|
||||
ModalBottomSheetLayout(
|
||||
modifier = modifier,
|
||||
sheetState = modalBottomSheetState,
|
||||
displayHandle = true,
|
||||
sheetContent = {
|
||||
fun onItemActionClicked(itemAction: AvatarAction) {
|
||||
onActionSelected(itemAction)
|
||||
sheetState.hide(coroutineScope, then = { onDismiss() })
|
||||
}
|
||||
|
||||
if (isVisible) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = {
|
||||
sheetState.hide(coroutineScope, then = { onDismiss() })
|
||||
},
|
||||
modifier = modifier,
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
AvatarActionBottomSheetContent(
|
||||
actions = actions,
|
||||
onActionClicked = ::onItemActionClicked,
|
||||
@@ -76,7 +85,7 @@ fun AvatarActionBottomSheet(
|
||||
.imePadding()
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -115,10 +124,8 @@ private fun AvatarActionBottomSheetContent(
|
||||
internal fun AvatarActionBottomSheetPreview() = ElementPreview {
|
||||
AvatarActionBottomSheet(
|
||||
actions = persistentListOf(AvatarAction.TakePhoto, AvatarAction.ChoosePhoto, AvatarAction.Remove),
|
||||
modalBottomSheetState = ModalBottomSheetState(
|
||||
initialValue = ModalBottomSheetValue.Expanded,
|
||||
density = LocalDensity.current,
|
||||
),
|
||||
isVisible = true,
|
||||
onActionSelected = { },
|
||||
onDismiss = { },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,23 +33,32 @@ 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.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
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.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.testtags.TestTags
|
||||
import io.element.android.libraries.testtags.testTag
|
||||
|
||||
@Composable
|
||||
fun EditableAvatarView(
|
||||
userId: String?,
|
||||
matrixId: String,
|
||||
displayName: String?,
|
||||
avatarUrl: Uri?,
|
||||
avatarSize: AvatarSize,
|
||||
onAvatarClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(avatarSize.dp)
|
||||
@@ -58,15 +67,14 @@ fun EditableAvatarView(
|
||||
onClick = onAvatarClicked,
|
||||
indication = rememberRipple(bounded = false),
|
||||
)
|
||||
.testTag(TestTags.editAvatar)
|
||||
) {
|
||||
when (avatarUrl?.scheme) {
|
||||
null, "mxc" -> {
|
||||
userId?.let {
|
||||
Avatar(
|
||||
avatarData = AvatarData(it, displayName, avatarUrl?.toString(), size = avatarSize),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
Avatar(
|
||||
avatarData = AvatarData(matrixId, displayName, avatarUrl?.toString(), size = avatarSize),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
UnsavedAvatar(
|
||||
@@ -94,3 +102,26 @@ fun EditableAvatarView(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun EditableAvatarViewPreview(
|
||||
@PreviewParameter(EditableAvatarViewUriProvider::class) uri: Uri?
|
||||
) = ElementPreview {
|
||||
EditableAvatarView(
|
||||
matrixId = "id",
|
||||
displayName = "A room",
|
||||
avatarUrl = uri,
|
||||
avatarSize = AvatarSize.EditRoomDetails,
|
||||
onAvatarClicked = {},
|
||||
)
|
||||
}
|
||||
|
||||
open class EditableAvatarViewUriProvider : PreviewParameterProvider<Uri?> {
|
||||
override val values: Sequence<Uri?>
|
||||
get() = sequenceOf(
|
||||
null,
|
||||
Uri.parse("mxc://matrix.org/123456"),
|
||||
Uri.parse("https://example.com/avatar.jpg"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,3 +62,21 @@ fun MatrixRoom.isOwnUserAdmin(): Boolean {
|
||||
val powerLevel = roomInfo?.userPowerLevels?.get(sessionId) ?: 0L
|
||||
return RoomMember.Role.forPowerLevel(powerLevel) == RoomMember.Role.ADMIN
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MatrixRoom.rawName(): String? {
|
||||
val roomInfo by roomInfoFlow.collectAsState(initial = null)
|
||||
return roomInfo?.rawName
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MatrixRoom.topic(): String? {
|
||||
val roomInfo by roomInfoFlow.collectAsState(initial = null)
|
||||
return roomInfo?.topic
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MatrixRoom.avatarUrl(): String? {
|
||||
val roomInfo by roomInfoFlow.collectAsState(initial = null)
|
||||
return roomInfo?.avatarUrl
|
||||
}
|
||||
|
||||
@@ -64,6 +64,11 @@ object TestTags {
|
||||
*/
|
||||
val memberDetailAvatar = TestTag("member_detail-avatar")
|
||||
|
||||
/**
|
||||
* Edit avatar.
|
||||
*/
|
||||
val editAvatar = TestTag("edit-avatar")
|
||||
|
||||
/**
|
||||
* Welcome screen.
|
||||
*/
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user