From b39021570112484a7e22b558df6bdfefbf0c67fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Fri, 2 Feb 2024 12:39:09 +0100 Subject: [PATCH] Add an empty state to the room list. - Make `RoomListDataSource.allRooms` a `SharedFlow` so we can know when we don't have a value yet. - Map its output in `RoomListPresenter` to `AsyncData`. - Display the new empty state when the room list has loaded and has no items. --- changelog.d/2330.feature | 1 + .../roomlist/impl/RoomListPresenter.kt | 6 +- .../features/roomlist/impl/RoomListState.kt | 3 +- .../roomlist/impl/RoomListStateProvider.kt | 5 +- .../features/roomlist/impl/RoomListView.kt | 132 ++++++++++++------ .../impl/datasource/RoomListDataSource.kt | 6 +- .../roomlist/impl/RoomListPresenterTests.kt | 20 +-- 7 files changed, 115 insertions(+), 58 deletions(-) create mode 100644 changelog.d/2330.feature diff --git a/changelog.d/2330.feature b/changelog.d/2330.feature new file mode 100644 index 0000000000..c501ded646 --- /dev/null +++ b/changelog.d/2330.feature @@ -0,0 +1 @@ +Add empty state to the room list. diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt index 8ab1bde3f4..421cdb0c62 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt @@ -23,6 +23,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -32,6 +33,7 @@ import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.roomlist.impl.datasource.InviteStateDataSource import io.element.android.features.roomlist.impl.datasource.RoomListDataSource +import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState @@ -68,7 +70,9 @@ class RoomListPresenter @Inject constructor( val matrixUser: MutableState = rememberSaveable { mutableStateOf(null) } - val roomList by roomListDataSource.allRooms.collectAsState() + val roomList by produceState(initialValue = AsyncData.Loading()) { + roomListDataSource.allRooms.collect { value = AsyncData.Success(it) } + } val filteredRoomList by roomListDataSource.filteredRooms.collectAsState() val filter by roomListDataSource.filter.collectAsState() val networkConnectionStatus by networkMonitor.connectivity.collectAsState() diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt index 7003da36e5..b1180aecb3 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt @@ -19,6 +19,7 @@ package io.element.android.features.roomlist.impl import androidx.compose.runtime.Immutable import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.roomlist.impl.model.RoomListRoomSummary +import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.user.MatrixUser @@ -28,7 +29,7 @@ import kotlinx.collections.immutable.ImmutableList data class RoomListState( val matrixUser: MatrixUser?, val showAvatarIndicator: Boolean, - val roomList: ImmutableList, + val roomList: AsyncData>, val filter: String?, val filteredRoomList: ImmutableList, val displayVerificationPrompt: Boolean, diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt index b1e43f147e..fd97725778 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.leaveroom.api.aLeaveRoomState import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.features.roomlist.impl.model.aRoomListRoomSummary +import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage @@ -49,13 +50,15 @@ open class RoomListStateProvider : PreviewParameterProvider { ) ), aRoomListState().copy(displayRecoveryKeyPrompt = true), + aRoomListState().copy(roomList = AsyncData.Success(persistentListOf())), + aRoomListState().copy(roomList = AsyncData.Loading()), ) } internal fun aRoomListState() = RoomListState( matrixUser = MatrixUser(userId = UserId("@id:domain"), displayName = "User#1"), showAvatarIndicator = false, - roomList = aRoomListRoomSummaryList(), + roomList = AsyncData.Success(aRoomListRoomSummaryList()), filter = "filter", filteredRoomList = aRoomListRoomSummaryList(), hasNetworkConnection = true, diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt index 0a9f4f523b..cf4ee97c72 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt @@ -17,7 +17,9 @@ package io.element.android.features.roomlist.impl import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -35,6 +37,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -42,6 +45,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme import io.element.android.features.leaveroom.api.LeaveRoomView import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorContainer import io.element.android.features.roomlist.impl.components.ConfirmRecoveryKeyBanner @@ -51,17 +55,22 @@ import io.element.android.features.roomlist.impl.components.RoomListTopBar import io.element.android.features.roomlist.impl.components.RoomSummaryRow import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.features.roomlist.impl.search.RoomListSearchResultView +import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.FloatingActionButton import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.designsystem.utils.LogCompositions import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.ui.strings.CommonStrings @Composable fun RoomListView( @@ -122,6 +131,35 @@ fun RoomListView( } } +@Composable +private fun EmptyRoomListView( + onCreateRoomClicked: () -> Unit, +) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(R.string.screen_roomlist_empty_title), + style = ElementTheme.typography.fontBodyLgRegular, + color = ElementTheme.colors.textSecondary, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.screen_roomlist_empty_message), + style = ElementTheme.typography.fontBodyLgRegular, + color = ElementTheme.colors.textSecondary, + ) + Spacer(modifier = Modifier.height(16.dp)) + Button( + text = stringResource(CommonStrings.action_start_chat), + leadingIcon = IconSource.Resource(CommonDrawables.ic_new_message), + onClick = onCreateRoomClicked, + ) + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun RoomListContent( @@ -182,56 +220,62 @@ private fun RoomListContent( ) }, content = { padding -> - LazyColumn( - modifier = Modifier - .padding(padding) - .consumeWindowInsets(padding) - .nestedScroll(nestedScrollConnection), - state = lazyListState, - ) { - when { - state.displayVerificationPrompt -> { - item { - RequestVerificationHeader( - onVerifyClicked = onVerifyClicked, - onDismissClicked = { state.eventSink(RoomListEvents.DismissRequestVerificationPrompt) } - ) + println(state.roomList) + if (state.roomList is AsyncData.Success && state.roomList.data.isEmpty()) { + EmptyRoomListView(onCreateRoomClicked) + } else { + LazyColumn( + modifier = Modifier + .padding(padding) + .consumeWindowInsets(padding) + .nestedScroll(nestedScrollConnection), + state = lazyListState, + ) { + when { + state.displayVerificationPrompt -> { + item { + RequestVerificationHeader( + onVerifyClicked = onVerifyClicked, + onDismissClicked = { state.eventSink(RoomListEvents.DismissRequestVerificationPrompt) } + ) + } + } + state.displayRecoveryKeyPrompt -> { + item { + ConfirmRecoveryKeyBanner( + onContinueClicked = onOpenSettings, + onDismissClicked = { state.eventSink(RoomListEvents.DismissRecoveryKeyPrompt) } + ) + } } } - state.displayRecoveryKeyPrompt -> { - item { - ConfirmRecoveryKeyBanner( - onContinueClicked = onOpenSettings, - onDismissClicked = { state.eventSink(RoomListEvents.DismissRecoveryKeyPrompt) } - ) - } - } - } - if (state.invitesState != InvitesState.NoInvites) { + if (state.invitesState != InvitesState.NoInvites) { + item { + InvitesEntryPointView(onInvitesClicked, state.invitesState) + } + } + + val roomList = state.roomList.dataOrNull().orEmpty() + itemsIndexed( + items = roomList, + contentType = { _, room -> room.contentType() }, + ) { index, room -> + RoomSummaryRow( + room = room, + onClick = ::onRoomClicked, + onLongClick = onRoomLongClicked, + ) + if (index != roomList.lastIndex) { + HorizontalDivider() + } + } + // Add a last Spacer item to ensure that the FAB does not hide the last room item + // FAB height is 56dp, bottom padding is 16dp, we add 8dp as extra margin -> 56+16+8 = 80 item { - InvitesEntryPointView(onInvitesClicked, state.invitesState) + Spacer(modifier = Modifier.height(80.dp)) } } - - itemsIndexed( - items = state.roomList, - contentType = { _, room -> room.contentType() }, - ) { index, room -> - RoomSummaryRow( - room = room, - onClick = ::onRoomClicked, - onLongClick = onRoomLongClicked, - ) - if (index != state.roomList.lastIndex) { - HorizontalDivider() - } - } - // Add a last Spacer item to ensure that the FAB does not hide the last room item - // FAB height is 56dp, bottom padding is 16dp, we add 8dp as extra margin -> 56+16+8 = 80 - item { - Spacer(modifier = Modifier.height(80.dp)) - } } }, floatingActionButton = { diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSource.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSource.kt index a255767d7e..a9f584b565 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSource.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSource.kt @@ -28,7 +28,9 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce @@ -52,7 +54,7 @@ class RoomListDataSource @Inject constructor( } private val _filter = MutableStateFlow("") - private val _allRooms = MutableStateFlow>(persistentListOf()) + private val _allRooms = MutableSharedFlow>(replay = 1) private val _filteredRooms = MutableStateFlow>(persistentListOf()) private val lock = Mutex() @@ -90,7 +92,7 @@ class RoomListDataSource @Inject constructor( } val filter: StateFlow = _filter - val allRooms: StateFlow> = _allRooms + val allRooms: SharedFlow> = _allRooms val filteredRooms: StateFlow> = _filteredRooms @OptIn(FlowPreview::class) diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt index 839fa04cf7..09d6f12e0c 100644 --- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt @@ -166,14 +166,16 @@ class RoomListPresenterTests { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - val initialState = consumeItemsUntilPredicate { state -> state.roomList.size == 16 }.last() + val initialState = consumeItemsUntilPredicate { state -> state.roomList.dataOrNull()?.size == 16 }.last() // Room list is loaded with 16 placeholders - assertThat(initialState.roomList.size).isEqualTo(16) - assertThat(initialState.roomList.all { it.isPlaceholder }).isTrue() + val initialItems = initialState.roomList.dataOrNull().orEmpty() + assertThat(initialItems.size).isEqualTo(16) + assertThat(initialItems.all { it.isPlaceholder }).isTrue() roomListService.postAllRooms(listOf(aRoomSummaryFilled())) - val withRoomState = consumeItemsUntilPredicate { state -> state.roomList.size == 1 }.last() - assertThat(withRoomState.roomList.size).isEqualTo(1) - assertThat(withRoomState.roomList.first()) + val withRoomState = consumeItemsUntilPredicate { state -> state.roomList.dataOrNull()?.size == 1 }.last() + val withRoomStateItems = withRoomState.roomList.dataOrNull().orEmpty() + assertThat(withRoomStateItems.size).isEqualTo(1) + assertThat(withRoomStateItems.first()) .isEqualTo(aRoomListRoomSummary) scope.cancel() } @@ -194,7 +196,7 @@ class RoomListPresenterTests { skipItems(3) val loadedState = awaitItem() // Test filtering with result - assertThat(loadedState.roomList.size).isEqualTo(1) + assertThat(loadedState.roomList.dataOrNull().orEmpty().size).isEqualTo(1) loadedState.eventSink.invoke(RoomListEvents.UpdateFilter(A_ROOM_NAME.substring(0, 3))) skipItems(1) val withFilteredRoomState = awaitItem() @@ -384,10 +386,10 @@ class RoomListPresenterTests { notificationSettingsService.setRoomNotificationMode(A_ROOM_ID, userDefinedMode) val updatedState = consumeItemsUntilPredicate { state -> - state.roomList.any { it.id == A_ROOM_ID.value && it.userDefinedNotificationMode == userDefinedMode } + state.roomList.dataOrNull().orEmpty().any { it.id == A_ROOM_ID.value && it.userDefinedNotificationMode == userDefinedMode } }.last() - val room = updatedState.roomList.find { it.id == A_ROOM_ID.value } + val room = updatedState.roomList.dataOrNull()?.find { it.id == A_ROOM_ID.value } assertThat(room?.userDefinedNotificationMode).isEqualTo(userDefinedMode) cancelAndIgnoreRemainingEvents() scope.cancel()