NavigationBar for Spaces

This commit is contained in:
Benoit Marty 2025-06-26 12:55:09 +02:00
parent aca27093e1
commit 9eb57a72bf
12 changed files with 254 additions and 24 deletions

View file

@ -51,6 +51,8 @@ dependencies {
implementation(projects.features.rageshake.api) implementation(projects.features.rageshake.api)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation(libs.haze)
implementation(libs.haze.materials)
implementation(projects.features.reportroom.api) implementation(projects.features.reportroom.api)
api(projects.features.home.api) api(projects.features.home.api)

View file

@ -7,4 +7,6 @@
package io.element.android.features.home.impl package io.element.android.features.home.impl
sealed interface HomeEvents sealed interface HomeEvents {
data class SelectHomeNavigationBarItem(val item: HomeNavigationBarItem) : HomeEvents
}

View file

@ -0,0 +1,38 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import io.element.android.compound.tokens.generated.CompoundIcons
enum class HomeNavigationBarItem(
@StringRes
val labelRes: Int,
) {
Chats(
labelRes = R.string.screen_roomlist_main_space_title
),
Spaces(
// TODO Create a new entry in Localazy
labelRes = R.string.screen_roomlist_main_space_title
);
@Composable
fun icon() = when (this) {
Chats -> CompoundIcons.ChatSolid()
// TODO Spaces -> CompoundIcons.Workspace()
Spaces -> CompoundIcons.Code()
}
companion object {
fun from(index: Int): HomeNavigationBarItem {
return entries.getOrElse(index) { Chats }
}
}
}

View file

@ -10,14 +10,20 @@ package io.element.android.features.home.impl
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import io.element.android.features.home.impl.roomlist.RoomListState import io.element.android.features.home.impl.roomlist.RoomListState
import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.indicator.api.IndicatorService import io.element.android.libraries.indicator.api.IndicatorService
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncService
@ -31,6 +37,7 @@ class HomePresenter @Inject constructor(
private val roomListPresenter: Presenter<RoomListState>, private val roomListPresenter: Presenter<RoomListState>,
private val logoutPresenter: Presenter<DirectLogoutState>, private val logoutPresenter: Presenter<DirectLogoutState>,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability, private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
private val featureFlagService: FeatureFlagService,
) : Presenter<HomeState> { ) : Presenter<HomeState> {
@Composable @Composable
override fun present(): HomeState { override fun present(): HomeState {
@ -38,31 +45,42 @@ class HomePresenter @Inject constructor(
val isOnline by syncService.isOnline.collectAsState() val isOnline by syncService.isOnline.collectAsState()
val canReportBug = remember { rageshakeFeatureAvailability.isAvailable() } val canReportBug = remember { rageshakeFeatureAvailability.isAvailable() }
val roomListState = roomListPresenter.present() val roomListState = roomListPresenter.present()
val isSpaceFeatureEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.Space)
}.collectAsState(initial = false)
var currentHomeNavigationBarItemOrdinal by rememberSaveable { mutableIntStateOf(HomeNavigationBarItem.Chats.ordinal) }
val currentHomeNavigationBarItem by remember {
derivedStateOf {
HomeNavigationBarItem.from(currentHomeNavigationBarItemOrdinal)
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// Force a refresh of the profile // Force a refresh of the profile
client.getUserProfile() client.getUserProfile()
} }
// Avatar indicator // Avatar indicator
val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator() val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator()
val directLogoutState = logoutPresenter.present() val directLogoutState = logoutPresenter.present()
fun handleEvents(event: HomeEvents) { fun handleEvents(event: HomeEvents) {
// TODO when (event) {
is HomeEvents.SelectHomeNavigationBarItem -> {
currentHomeNavigationBarItemOrdinal = event.item.ordinal
}
}
} }
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
return HomeState( return HomeState(
matrixUser = matrixUser.value, matrixUser = matrixUser.value,
showAvatarIndicator = showAvatarIndicator, showAvatarIndicator = showAvatarIndicator,
hasNetworkConnection = isOnline, hasNetworkConnection = isOnline,
currentHomeNavigationBarItem = currentHomeNavigationBarItem,
roomListState = roomListState, roomListState = roomListState,
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
canReportBug = canReportBug, canReportBug = canReportBug,
directLogoutState = directLogoutState, directLogoutState = directLogoutState,
isSpaceFeatureEnabled = isSpaceFeatureEnabled,
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }

View file

@ -18,11 +18,13 @@ data class HomeState(
val matrixUser: MatrixUser, val matrixUser: MatrixUser,
val showAvatarIndicator: Boolean, val showAvatarIndicator: Boolean,
val hasNetworkConnection: Boolean, val hasNetworkConnection: Boolean,
val currentHomeNavigationBarItem: HomeNavigationBarItem,
val roomListState: RoomListState, val roomListState: RoomListState,
val snackbarMessage: SnackbarMessage?, val snackbarMessage: SnackbarMessage?,
val canReportBug: Boolean, val canReportBug: Boolean,
val directLogoutState: DirectLogoutState, val directLogoutState: DirectLogoutState,
val isSpaceFeatureEnabled: Boolean,
val eventSink: (HomeEvents) -> Unit, val eventSink: (HomeEvents) -> Unit,
) { ) {
val displayActions = true val displayActions = currentHomeNavigationBarItem == HomeNavigationBarItem.Chats
} }

View file

@ -11,6 +11,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.home.impl.roomlist.RoomListState import io.element.android.features.home.impl.roomlist.RoomListState
import io.element.android.features.home.impl.roomlist.RoomListStateProvider import io.element.android.features.home.impl.roomlist.RoomListStateProvider
import io.element.android.features.home.impl.roomlist.aRoomListState import io.element.android.features.home.impl.roomlist.aRoomListState
import io.element.android.features.home.impl.roomlist.aRoomsContentState
import io.element.android.features.home.impl.roomlist.generateRoomListRoomSummaryList
import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.features.logout.api.direct.aDirectLogoutState import io.element.android.features.logout.api.direct.aDirectLogoutState
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
@ -24,6 +26,19 @@ open class HomeStateProvider : PreviewParameterProvider<HomeState> {
aHomeState(), aHomeState(),
aHomeState(hasNetworkConnection = false), aHomeState(hasNetworkConnection = false),
aHomeState(snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete)), aHomeState(snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete)),
aHomeState(
isSpaceFeatureEnabled = true,
roomListState = aRoomListState(
// Add more rooms to see the blur effect under the NavigationBar
contentState = aRoomsContentState(
summaries = generateRoomListRoomSummaryList(),
)
),
),
aHomeState(
isSpaceFeatureEnabled = true,
currentHomeNavigationBarItem = HomeNavigationBarItem.Spaces,
),
) + RoomListStateProvider().values.map { ) + RoomListStateProvider().values.map {
aHomeState(roomListState = it) aHomeState(roomListState = it)
} }
@ -34,8 +49,10 @@ internal fun aHomeState(
showAvatarIndicator: Boolean = false, showAvatarIndicator: Boolean = false,
hasNetworkConnection: Boolean = true, hasNetworkConnection: Boolean = true,
snackbarMessage: SnackbarMessage? = null, snackbarMessage: SnackbarMessage? = null,
currentHomeNavigationBarItem: HomeNavigationBarItem = HomeNavigationBarItem.Chats,
roomListState: RoomListState = aRoomListState(), roomListState: RoomListState = aRoomListState(),
canReportBug: Boolean = true, canReportBug: Boolean = true,
isSpaceFeatureEnabled: Boolean = false,
directLogoutState: DirectLogoutState = aDirectLogoutState(), directLogoutState: DirectLogoutState = aDirectLogoutState(),
eventSink: (HomeEvents) -> Unit = {} eventSink: (HomeEvents) -> Unit = {}
) = HomeState( ) = HomeState(
@ -45,6 +62,8 @@ internal fun aHomeState(
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
canReportBug = canReportBug, canReportBug = canReportBug,
directLogoutState = directLogoutState, directLogoutState = directLogoutState,
currentHomeNavigationBarItem = currentHomeNavigationBarItem,
roomListState = roomListState, roomListState = roomListState,
isSpaceFeatureEnabled = isSpaceFeatureEnabled,
eventSink = eventSink, eventSink = eventSink,
) )

View file

@ -5,10 +5,15 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalHazeMaterialsApi::class)
package io.element.android.features.home.impl package io.element.android.features.home.impl
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -19,10 +24,19 @@ import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.chrisbanes.haze.rememberHazeState
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.home.impl.components.RoomListContentView import io.element.android.features.home.impl.components.RoomListContentView
@ -41,7 +55,10 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.NavigationBar
import io.element.android.libraries.designsystem.theme.components.NavigationBarItem
import io.element.android.libraries.designsystem.theme.components.Scaffold 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.snackbar.SnackbarHost import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -138,10 +155,19 @@ private fun HomeScaffold(
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
val roomListState: RoomListState = state.roomListState val roomListState: RoomListState = state.roomListState
BackHandler(
enabled = state.currentHomeNavigationBarItem != HomeNavigationBarItem.Chats,
) {
state.eventSink(HomeEvents.SelectHomeNavigationBarItem(HomeNavigationBarItem.Chats))
}
val hazeState = rememberHazeState()
Scaffold( Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = { topBar = {
RoomListTopBar( RoomListTopBar(
title = stringResource(state.currentHomeNavigationBarItem.labelRes),
matrixUser = state.matrixUser, matrixUser = state.matrixUser,
showAvatarIndicator = state.showAvatarIndicator, showAvatarIndicator = state.showAvatarIndicator,
areSearchResultsDisplayed = roomListState.searchState.isSearchActive, areSearchResultsDisplayed = roomListState.searchState.isSearchActive,
@ -150,25 +176,83 @@ private fun HomeScaffold(
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
displayMenuItems = state.displayActions, displayMenuItems = state.displayActions,
displayFilters = roomListState.displayFilters, displayFilters = roomListState.displayFilters && state.currentHomeNavigationBarItem == HomeNavigationBarItem.Chats,
filtersState = roomListState.filtersState, filtersState = roomListState.filtersState,
canReportBug = state.canReportBug, canReportBug = state.canReportBug,
) )
}, },
bottomBar = {
if (state.isSpaceFeatureEnabled) {
NavigationBar(
containerColor = Color.Transparent,
modifier = Modifier
.hazeEffect(
state = hazeState,
style = HazeMaterials.regular(),
)
) {
HomeNavigationBarItem.entries.forEach { item ->
NavigationBarItem(
selected = state.currentHomeNavigationBarItem == item,
onClick = {
state.eventSink(HomeEvents.SelectHomeNavigationBarItem(item))
},
icon = {
Icon(
imageVector = item.icon(),
contentDescription = null
)
},
label = {
Text(stringResource(item.labelRes))
}
)
}
}
}
},
content = { padding -> content = { padding ->
RoomListContentView( when (state.currentHomeNavigationBarItem) {
contentState = roomListState.contentState, HomeNavigationBarItem.Chats -> {
filtersState = roomListState.filtersState, RoomListContentView(
hideInvitesAvatars = roomListState.hideInvitesAvatars, contentState = roomListState.contentState,
eventSink = roomListState.eventSink, filtersState = roomListState.filtersState,
onSetUpRecoveryClick = onSetUpRecoveryClick, hideInvitesAvatars = roomListState.hideInvitesAvatars,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, eventSink = roomListState.eventSink,
onRoomClick = ::onRoomClick, onSetUpRecoveryClick = onSetUpRecoveryClick,
onCreateRoomClick = onCreateRoomClick, onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
modifier = Modifier onRoomClick = ::onRoomClick,
.padding(padding) onCreateRoomClick = onCreateRoomClick,
.consumeWindowInsets(padding) // FAB height is 56dp, bottom padding is 16dp, we add 8dp as extra margin -> 56+16+8 = 80,
) // and include provided bottom padding
contentBottomPadding = 80.dp + padding.calculateBottomPadding(),
modifier = Modifier
.padding(
top = padding.calculateTopPadding(),
bottom = 0.dp,
start = padding.calculateStartPadding(LocalLayoutDirection.current),
end = padding.calculateEndPadding(LocalLayoutDirection.current),
)
.consumeWindowInsets(padding)
.hazeSource(state = hazeState)
)
}
HomeNavigationBarItem.Spaces -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.consumeWindowInsets(padding)
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = "Spaces are coming soon!",
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
)
}
}
}
}, },
floatingActionButton = { floatingActionButton = {
if (state.displayActions) { if (state.displayActions) {

View file

@ -31,6 +31,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
@ -66,6 +67,7 @@ fun RoomListContentView(
onConfirmRecoveryKeyClick: () -> Unit, onConfirmRecoveryKeyClick: () -> Unit,
onRoomClick: (RoomListRoomSummary) -> Unit, onRoomClick: (RoomListRoomSummary) -> Unit,
onCreateRoomClick: () -> Unit, onCreateRoomClick: () -> Unit,
contentBottomPadding: Dp,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Box(modifier = modifier) { Box(modifier = modifier) {
@ -93,6 +95,7 @@ fun RoomListContentView(
onSetUpRecoveryClick = onSetUpRecoveryClick, onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
onRoomClick = onRoomClick, onRoomClick = onRoomClick,
contentBottomPadding = contentBottomPadding,
) )
} }
} }
@ -164,6 +167,7 @@ private fun RoomsView(
onSetUpRecoveryClick: () -> Unit, onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit, onConfirmRecoveryKeyClick: () -> Unit,
onRoomClick: (RoomListRoomSummary) -> Unit, onRoomClick: (RoomListRoomSummary) -> Unit,
contentBottomPadding: Dp,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (state.summaries.isEmpty() && filtersState.hasAnyFilterSelected) { if (state.summaries.isEmpty() && filtersState.hasAnyFilterSelected) {
@ -179,6 +183,7 @@ private fun RoomsView(
onSetUpRecoveryClick = onSetUpRecoveryClick, onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
onRoomClick = onRoomClick, onRoomClick = onRoomClick,
contentBottomPadding = contentBottomPadding,
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
) )
} }
@ -192,6 +197,7 @@ private fun RoomsViewList(
onSetUpRecoveryClick: () -> Unit, onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit, onConfirmRecoveryKeyClick: () -> Unit,
onRoomClick: (RoomListRoomSummary) -> Unit, onRoomClick: (RoomListRoomSummary) -> Unit,
contentBottomPadding: Dp,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val lazyListState = rememberLazyListState() val lazyListState = rememberLazyListState()
@ -210,8 +216,7 @@ private fun RoomsViewList(
LazyColumn( LazyColumn(
state = lazyListState, state = lazyListState,
modifier = modifier, modifier = modifier,
// FAB height is 56dp, bottom padding is 16dp, we add 8dp as extra margin -> 56+16+8 = 80 contentPadding = PaddingValues(bottom = contentBottomPadding)
contentPadding = PaddingValues(bottom = 80.dp)
) { ) {
when (state.securityBannerState) { when (state.securityBannerState) {
SecurityBannerState.SetUpRecovery -> { SecurityBannerState.SetUpRecovery -> {
@ -324,5 +329,6 @@ internal fun RoomListContentViewPreview(@PreviewParameter(RoomListContentStatePr
onConfirmRecoveryKeyClick = {}, onConfirmRecoveryKeyClick = {},
onRoomClick = {}, onRoomClick = {},
onCreateRoomClick = {}, onCreateRoomClick = {},
contentBottomPadding = 0.dp,
) )
} }

View file

@ -76,6 +76,7 @@ private val avatarBloomSize = 430.dp
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun RoomListTopBar( fun RoomListTopBar(
title: String,
matrixUser: MatrixUser, matrixUser: MatrixUser,
showAvatarIndicator: Boolean, showAvatarIndicator: Boolean,
areSearchResultsDisplayed: Boolean, areSearchResultsDisplayed: Boolean,
@ -90,6 +91,7 @@ fun RoomListTopBar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
DefaultRoomListTopBar( DefaultRoomListTopBar(
title = title,
matrixUser = matrixUser, matrixUser = matrixUser,
showAvatarIndicator = showAvatarIndicator, showAvatarIndicator = showAvatarIndicator,
areSearchResultsDisplayed = areSearchResultsDisplayed, areSearchResultsDisplayed = areSearchResultsDisplayed,
@ -108,6 +110,7 @@ fun RoomListTopBar(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun DefaultRoomListTopBar( private fun DefaultRoomListTopBar(
title: String,
matrixUser: MatrixUser, matrixUser: MatrixUser,
showAvatarIndicator: Boolean, showAvatarIndicator: Boolean,
areSearchResultsDisplayed: Boolean, areSearchResultsDisplayed: Boolean,
@ -194,7 +197,7 @@ private fun DefaultRoomListTopBar(
scrolledContainerColor = Color.Transparent, scrolledContainerColor = Color.Transparent,
), ),
title = { title = {
Text(text = stringResource(id = R.string.screen_roomlist_main_space_title)) Text(text = title)
}, },
navigationIcon = { navigationIcon = {
NavigationIcon( NavigationIcon(
@ -315,6 +318,7 @@ private fun NavigationIcon(
@Composable @Composable
internal fun DefaultRoomListTopBarPreview() = ElementPreview { internal fun DefaultRoomListTopBarPreview() = ElementPreview {
DefaultRoomListTopBar( DefaultRoomListTopBar(
title = stringResource(R.string.screen_roomlist_main_space_title),
matrixUser = MatrixUser(UserId("@id:domain"), "Alice"), matrixUser = MatrixUser(UserId("@id:domain"), "Alice"),
showAvatarIndicator = false, showAvatarIndicator = false,
areSearchResultsDisplayed = false, areSearchResultsDisplayed = false,
@ -334,6 +338,7 @@ internal fun DefaultRoomListTopBarPreview() = ElementPreview {
@Composable @Composable
internal fun DefaultRoomListTopBarWithIndicatorPreview() = ElementPreview { internal fun DefaultRoomListTopBarWithIndicatorPreview() = ElementPreview {
DefaultRoomListTopBar( DefaultRoomListTopBar(
title = stringResource(R.string.screen_roomlist_main_space_title),
matrixUser = MatrixUser(UserId("@id:domain"), "Alice"), matrixUser = MatrixUser(UserId("@id:domain"), "Alice"),
showAvatarIndicator = true, showAvatarIndicator = true,
areSearchResultsDisplayed = false, areSearchResultsDisplayed = false,

View file

@ -27,6 +27,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.push.api.battery.aBatteryOptimizationState import io.element.android.libraries.push.api.battery.aBatteryOptimizationState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
open class RoomListStateProvider : PreviewParameterProvider<RoomListState> { open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
override val values: Sequence<RoomListState> override val values: Sequence<RoomListState>
@ -113,3 +114,18 @@ internal fun aRoomListRoomSummaryList(): ImmutableList<RoomListRoomSummary> {
), ),
) )
} }
internal fun generateRoomListRoomSummaryList(
numberOfRooms: Int = 10,
): ImmutableList<RoomListRoomSummary> {
return List(numberOfRooms) { index ->
aRoomListRoomSummary(
name = "Room#$index",
numberOfUnreadMessages = 0,
timestamp = "14:16",
lastMessage = "A message",
avatarData = AvatarData("!id$index", "${(65 + index % 26).toChar()}", size = AvatarSize.RoomListItem),
id = "!roomId$index:domain",
)
}.toPersistentList()
}

View file

@ -15,6 +15,9 @@ import io.element.android.features.home.impl.roomlist.aRoomListState
import io.element.android.features.logout.api.direct.aDirectLogoutState import io.element.android.features.logout.api.direct.aDirectLogoutState
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.indicator.api.IndicatorService import io.element.android.libraries.indicator.api.IndicatorService
import io.element.android.libraries.indicator.test.FakeIndicatorService import io.element.android.libraries.indicator.test.FakeIndicatorService
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
@ -27,6 +30,7 @@ import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.sync.FakeSyncService import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
@ -58,6 +62,21 @@ class HomePresenterTest {
assertThat(withUserState.matrixUser.displayName).isEqualTo(A_USER_NAME) assertThat(withUserState.matrixUser.displayName).isEqualTo(A_USER_NAME)
assertThat(withUserState.matrixUser.avatarUrl).isEqualTo(AN_AVATAR_URL) assertThat(withUserState.matrixUser.avatarUrl).isEqualTo(AN_AVATAR_URL)
assertThat(withUserState.showAvatarIndicator).isFalse() assertThat(withUserState.showAvatarIndicator).isFalse()
assertThat(withUserState.isSpaceFeatureEnabled).isFalse()
}
}
@Test
fun `present - space feature enabled`() = runTest {
val presenter = createHomePresenter(
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.Space.key to true),
),
)
presenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.isSpaceFeatureEnabled).isTrue()
} }
} }
@ -95,12 +114,27 @@ class HomePresenterTest {
} }
} }
@Test
fun `present - NavigationBar change`() = runTest {
val presenter = createHomePresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Chats)
initialState.eventSink(HomeEvents.SelectHomeNavigationBarItem(HomeNavigationBarItem.Spaces))
val finalState = awaitItem()
assertThat(finalState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Spaces)
}
}
private fun TestScope.createHomePresenter( private fun TestScope.createHomePresenter(
client: MatrixClient = FakeMatrixClient(), client: MatrixClient = FakeMatrixClient(),
syncService: SyncService = FakeSyncService(), syncService: SyncService = FakeSyncService(),
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { true }, rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { true },
indicatorService: IndicatorService = FakeIndicatorService(), indicatorService: IndicatorService = FakeIndicatorService(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService()
) = HomePresenter( ) = HomePresenter(
client = client, client = client,
syncService = syncService, syncService = syncService,
@ -109,5 +143,6 @@ class HomePresenterTest {
logoutPresenter = { aDirectLogoutState() }, logoutPresenter = { aDirectLogoutState() },
roomListPresenter = { aRoomListState() }, roomListPresenter = { aRoomListState() },
rageshakeFeatureAvailability = rageshakeFeatureAvailability, rageshakeFeatureAvailability = rageshakeFeatureAvailability,
featureFlagService = featureFlagService,
) )
} }

View file

@ -44,6 +44,7 @@ appyx = "1.7.1"
sqldelight = "2.1.0" sqldelight = "2.1.0"
wysiwyg = "2.38.4" wysiwyg = "2.38.4"
telephoto = "0.16.0" telephoto = "0.16.0"
haze = "1.6.4"
# Dependency analysis # Dependency analysis
dependencyAnalysis = "2.19.0" dependencyAnalysis = "2.19.0"
@ -191,6 +192,8 @@ maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2"
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2" maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2"
opusencoder = "io.element.android:opusencoder:1.2.0" opusencoder = "io.element.android:opusencoder:1.2.0"
zxing_cpp = "io.github.zxing-cpp:android:2.3.0" zxing_cpp = "io.github.zxing-cpp:android:2.3.0"
haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
haze_materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" }
# Analytics # Analytics
posthog = "com.posthog:posthog-android:3.19.0" posthog = "com.posthog:posthog-android:3.19.0"