Merge pull request #2849 from element-hq/feature/bma/roomNameEdition
Improve room setting edition
This commit is contained in:
commit
9d4cfd8e20
77 changed files with 693 additions and 457 deletions
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0aebfeacf46df5674e4b159fd9dca029288596a2835ff953783d144cbb44c91f
|
||||
size 61153
|
||||
oid sha256:ba10d755e7cefd5946a7519216a6298a1a17cbe80423bff3939e8699cc5531a6
|
||||
size 60967
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2fe73b4f444fd4d42d99de9705ca5a73ee95cc954ab39ec874208277dac9b131
|
||||
size 84224
|
||||
oid sha256:5d2e65da53dd78db7204ffaebc8823e23f6517cdf67fcec044627a6d740361cb
|
||||
size 86948
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d8400f3b6385a3eb0096282b2e8a64d7d593ac51deb2c1a32d55acbbce430244
|
||||
size 57700
|
||||
oid sha256:3609875b66402a3cd7db798005aafb87479b07056f6e8f737cede80950a33665
|
||||
size 57693
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:495521b5b0d4cb90ebd5be62975673965bd103c5bba12c43c3c6b2d6003acbfb
|
||||
size 81045
|
||||
oid sha256:5fc8ac21d7e0e9d15b59f4bc9237dbafd974aba8bd26bd67192b04c1e3c5116c
|
||||
size 83702
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:984d249036bb82d31cfc7d183b0a33c6419f0827e535a79390055f574e36c89a
|
||||
size 22542
|
||||
oid sha256:b0ab108a6bc88c7a811e9589596aa41be765005086863a31eea31bf3cd651aad
|
||||
size 22351
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4bec5a025508cfff5808b3c007dcb31d780635a208d959f05891538e7dd0bba2
|
||||
size 20896
|
||||
oid sha256:82b3c70a99cad62e1cfd4cee89fa96e514f1f0b6418dcf857fcd4981e32a3ba6
|
||||
size 20885
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f238794e2139509c701d363b90ae42cd0ad127e09665d82339e6d292c04c103e
|
||||
size 30221
|
||||
oid sha256:f2c5b052dff4115a1b9b829e68fd46edf865994929a1e8ae0b463f168ef03907
|
||||
size 30399
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8afcab5de05d583820291a8e4578a1799960cf96137bfdae46542504f6a02a06
|
||||
size 23555
|
||||
oid sha256:3c5a165e3c5c81755b739623b872bf1b1b99d6ccd8549a1c2324e59e9c421b58
|
||||
size 23688
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6c7a63959ed475c3aca89125149eb66b87702fdad9e7162fda52341bb1069eca
|
||||
size 55870
|
||||
oid sha256:7eb57ad86edca68df0129f25221758647d1edab14e233e4e10f9ad1c0750e5d1
|
||||
size 31743
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e0e895eee0d379ccff79d44f2ae3e954bbe23597f11086cdfb99c3b0981e392a
|
||||
size 29939
|
||||
oid sha256:3fdd4a80c5a8499d3b79617c1757358de7f40a1fdb43d1c537ce4170af5efb9a
|
||||
size 55730
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:acb90cc495ca721862557a0f213d80b302853309398550553bdbec0984975769
|
||||
size 29940
|
||||
oid sha256:8268170c93b3ec77e9222e926a1396f9889922fe4892d6388d2de015aee94c47
|
||||
size 30088
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c2beb630f1cafc363da0d1d7f508ad66b7738f0624d9cceefb7db35c12787b36
|
||||
size 30153
|
||||
oid sha256:2bc28e45b7d804766d5c0ce0cf217bbdb81459ad19415739835c849d8638c634
|
||||
size 30121
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c8b761beaa0856819a39931fbd57f12a22c30b599d545f59b21b92d0d15ab0eb
|
||||
size 27563
|
||||
oid sha256:f504a2811ed252564a9c14cf7d9c770d2c7993f4e839c5e4e506e555cb4258fc
|
||||
size 30310
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a01beba7ac836b5fdc66687298ced0a81d260fe1af3b3260b468226b90b8df48
|
||||
size 27695
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:cea255e3114cc3e854d9f831cd7ea646f3aa9992d3a56e81bb4a5a77a2ecf66b
|
||||
size 28877
|
||||
oid sha256:f2eb8e41704492332da26e123a48f423a73104532e947246faf73604e7d54a2c
|
||||
size 28844
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9eac310d1a3e7d0451ca0c21bcceeaab9c909b174a9040f31fd48a3b566673a4
|
||||
size 22568
|
||||
oid sha256:deb4b44dcd728ef7b7cef621dd67ac38087922aeeb6efffe3ab5b9ec9aa08b51
|
||||
size 22554
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:fde4132005b1c340aff35cb703d1b2cbd43d0cdea9a552f90b7e124a909138c8
|
||||
size 54156
|
||||
oid sha256:62ee10256d36d435b0604e5014725de6eb4085d56390ece44852f0941115aebb
|
||||
size 30092
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5828dfd60d097c0d571f95c977c3b573ccb0853a50dca736b0024037dbfd7d66
|
||||
size 27797
|
||||
oid sha256:df342f6121b20e20c52bf3464be4cf6880607782d046009cc8af0e6ea3ac4644
|
||||
size 54149
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f2bd04174660f32479aff938b2e9c19a7ddf2be8b90b83f51d775bacf5a5f187
|
||||
size 28584
|
||||
oid sha256:8fd504b944225a0b95bd041bde48865cc6b25f3641c70db32aa9f30e5c532975
|
||||
size 27772
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7d6506e709ce97294d263032aa932eee53356c01b769f0ea0b6dc322ae7da393
|
||||
size 28026
|
||||
oid sha256:1da3677f03a01306116ae83dfd54e4ad29f414082be5611816411616b58bfe85
|
||||
size 28556
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:58c14e6556999a85fde1f898c2fb562fc403f38d9c287f3db44b9f0fb29496cc
|
||||
size 24255
|
||||
oid sha256:d5c26e007cdc2e7d47f79d662c64dae6ee4710e679766bc31f5eb20cfe8ddf57
|
||||
size 28023
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:dcb0135702ee7d8aeea60042289d30111c8b9b0d980860347f23f6ee37bd8c32
|
||||
size 24258
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:761e809239fb9f3610e3c1760e999dd6129708e7ed41208c3eeb61f0dc0bc2bb
|
||||
size 10863
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:898b5ca8642123df13f95b0cbdc9252d6072be9b033e9bd227c32a9703328883
|
||||
size 10972
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:79d5851c4aac88fcf826cd9c1b114df2b82d7db132c34908f46696059404209d
|
||||
size 15176
|
||||
oid sha256:e1938c8062f0a198cbf5bdb17e24f25c76dd8e8b6efcec719cc4d996b4a18b73
|
||||
size 15291
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0c2a1aaa27a9013d7f171e8463b3b32f19ce3be4f4094657a4c3f5c1e25aa88c
|
||||
size 13263
|
||||
oid sha256:f5d21fae9b198a9b7aef195938748ea5e06db9cbed397baa6cdbeb0a341393e5
|
||||
size 13485
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:befba1f1514fcf2f3a69b5f423fc66be7df252d133bff7fcda519c9f6acd4e8b
|
||||
size 8152
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1f9f0c4e62431e9aa5543f28f2784ae3e245cde22ed2745f15ba3fe7be2f8ef0
|
||||
size 10287
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:fcb18ec6ab064604201838b3a1497dd236731c61cc85d7be937071870dfeb5d6
|
||||
size 34082
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3eae0336bcaf91c668a8a26331718085b282f335ad85a0b5eaa0b2c24d890627
|
||||
size 8424
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c2da272b8bc76f7d6dc3b79bdae73014d831c0fd225f830d6e15eaf4c8060737
|
||||
size 10435
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:36dc08ee4cb55da521a2577715de85049ccfde5c9ed10db248fc2cd8418084f9
|
||||
size 33745
|
||||
Loading…
Add table
Add a link
Reference in a new issue