[Room list] Search & menu improvements (#356)

* Remove settings menu item, start splitting search UI. Also, add `applyIf` and `circularReveal` modifiers.

* Split UI & logic for room list search

* Suppress `composed` warning, improve its debuggability

* Add content description to the user's avatar, fix window insets. Also, remove unused `SearchRoomListTopBar`.
This commit is contained in:
Jorge Martin Espinosa
2023-04-25 13:35:36 +02:00
committed by GitHub
parent 4a689267f5
commit e2b2dbf59f
31 changed files with 534 additions and 199 deletions

1
changelog.d/354.feature Normal file
View File

@@ -0,0 +1 @@
Improve room list search and general UI

View File

@@ -71,8 +71,8 @@ class NetworkMonitorImpl @Inject constructor(
private fun listenToConnectionChanges() {
val request = NetworkRequest.Builder()
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
// .addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
// .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, callback)

View File

@@ -20,4 +20,5 @@ sealed interface RoomListEvents {
data class UpdateFilter(val newFilter: String) : RoomListEvents
data class UpdateVisibleRange(val range: IntRange) : RoomListEvents
object DismissRequestVerificationPrompt : RoomListEvents
object ToggleSearchResults : RoomListEvents
}

View File

@@ -79,6 +79,7 @@ class RoomListPresenter @Inject constructor(
Timber.v("RoomSummaries size = ${roomSummaries.size}")
val mappedRoomSummaries: MutableState<ImmutableList<RoomListRoomSummary>> = remember { mutableStateOf(persistentListOf()) }
val filteredRoomSummaries: MutableState<ImmutableList<RoomListRoomSummary>> = remember {
mutableStateOf(persistentListOf())
}
@@ -101,41 +102,51 @@ class RoomListPresenter @Inject constructor(
derivedStateOf { sessionVerifiedStatus == SessionVerifiedStatus.NotVerified && !verificationPromptDismissed }
}
var displaySearchResults by rememberSaveable { mutableStateOf(false) }
fun handleEvents(event: RoomListEvents) {
when (event) {
is RoomListEvents.UpdateFilter -> filter = event.newFilter
is RoomListEvents.UpdateVisibleRange -> updateVisibleRange(event.range)
RoomListEvents.DismissRequestVerificationPrompt -> verificationPromptDismissed = true
RoomListEvents.ToggleSearchResults -> {
if (displaySearchResults) {
filter = ""
}
displaySearchResults =! displaySearchResults
}
}
}
LaunchedEffect(roomSummaries, filter) {
filteredRoomSummaries.value = updateFilteredRoomSummaries(roomSummaries, filter)
mappedRoomSummaries.value = if (roomSummaries.isEmpty()) {
RoomListRoomSummaryPlaceholders.createFakeList(16).toImmutableList()
} else {
mapRoomSummaries(roomSummaries).toImmutableList()
}
filteredRoomSummaries.value = updateFilteredRoomSummaries(mappedRoomSummaries.value, filter)
}
val snackbarMessage = handleSnackbarMessage(snackbarDispatcher)
return RoomListState(
matrixUser = matrixUser.value,
roomList = filteredRoomSummaries.value,
roomList = mappedRoomSummaries.value,
filter = filter,
filteredRoomList = filteredRoomSummaries.value,
displayVerificationPrompt = displayVerificationPrompt,
snackbarMessage = snackbarMessage,
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
displayInvites = invites.isNotEmpty(),
displaySearchResults = displaySearchResults,
eventSink = ::handleEvents
)
}
private suspend fun updateFilteredRoomSummaries(roomSummaries: List<RoomSummary>?, filter: String): ImmutableList<RoomListRoomSummary> {
if (roomSummaries.isNullOrEmpty()) {
return RoomListRoomSummaryPlaceholders.createFakeList(16).toImmutableList()
}
val mappedRoomSummaries = mapRoomSummaries(roomSummaries)
return if (filter.isEmpty()) {
mappedRoomSummaries
} else {
mappedRoomSummaries.filter { it.name.contains(filter, ignoreCase = true) }
private fun updateFilteredRoomSummaries(mappedRoomSummaries: ImmutableList<RoomListRoomSummary>, filter: String): ImmutableList<RoomListRoomSummary> {
return when {
filter.isEmpty() -> emptyList()
else -> mappedRoomSummaries.filter { it.name.contains(filter, ignoreCase = true) }
}.toImmutableList()
}

View File

@@ -26,10 +26,12 @@ import kotlinx.collections.immutable.ImmutableList
data class RoomListState(
val matrixUser: MatrixUser?,
val roomList: ImmutableList<RoomListRoomSummary>,
val filter: String,
val filter: String?,
val filteredRoomList: ImmutableList<RoomListRoomSummary>,
val displayVerificationPrompt: Boolean,
val hasNetworkConnection: Boolean,
val snackbarMessage: SnackbarMessage?,
val displayInvites: Boolean,
val displaySearchResults: Boolean,
val eventSink: (RoomListEvents) -> Unit
)

View File

@@ -36,6 +36,8 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
aRoomListState().copy(snackbarMessage = SnackbarMessage(StringR.string.common_verification_complete)),
aRoomListState().copy(hasNetworkConnection = false),
aRoomListState().copy(displayInvites = true),
aRoomListState().copy(displaySearchResults = true, filter = "", filteredRoomList = persistentListOf()),
aRoomListState().copy(displaySearchResults = true),
)
}
@@ -43,10 +45,12 @@ internal fun aRoomListState() = RoomListState(
matrixUser = MatrixUser(id = UserId("@id:domain"), username = "User#1", avatarData = AvatarData("@id:domain", "U")),
roomList = aRoomListRoomSummaryList(),
filter = "filter",
filteredRoomList = aRoomListRoomSummaryList(),
hasNetworkConnection = true,
snackbarMessage = null,
displayVerificationPrompt = false,
displayInvites = false,
displaySearchResults = false,
eventSink = {}
)

View File

@@ -64,6 +64,8 @@ import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorVi
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.RoomListSearchResultContent
import io.element.android.features.roomlist.impl.search.RoomListSearchResultView
import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
@@ -91,15 +93,27 @@ fun RoomListView(
onCreateRoomClicked: () -> Unit = {},
onInvitesClicked: () -> Unit = {},
) {
RoomListContent(
state = state,
modifier = modifier,
onRoomClicked = onRoomClicked,
onOpenSettings = onOpenSettings,
onVerifyClicked = onVerifyClicked,
onCreateRoomClicked = onCreateRoomClicked,
onInvitesClicked = onInvitesClicked,
)
Column(modifier = modifier) {
ConnectivityIndicatorView(isOnline = state.hasNetworkConnection)
Box {
RoomListContent(
state = state,
onRoomClicked = onRoomClicked,
onOpenSettings = onOpenSettings,
onVerifyClicked = onVerifyClicked,
onCreateRoomClicked = onCreateRoomClicked,
onInvitesClicked = onInvitesClicked,
)
// This overlaid view will only be visible when state.displaySearchResults is true
RoomListSearchResultView(
state = state,
onRoomClicked = onRoomClicked,
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@@ -163,16 +177,14 @@ fun RoomListContent(
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
Column {
ConnectivityIndicatorView(isOnline = state.hasNetworkConnection)
RoomListTopBar(
matrixUser = state.matrixUser,
filter = state.filter,
onFilterChanged = { state.eventSink(RoomListEvents.UpdateFilter(it)) },
onOpenSettings = onOpenSettings,
scrollBehavior = scrollBehavior,
)
}
RoomListTopBar(
matrixUser = state.matrixUser,
areSearchResultsDisplayed = state.displaySearchResults,
onFilterChanged = { state.eventSink(RoomListEvents.UpdateFilter(it)) },
onToggleSearch = { state.eventSink(RoomListEvents.ToggleSearchResults) },
onOpenSettings = onOpenSettings,
scrollBehavior = scrollBehavior,
)
},
content = { padding ->
Column(
@@ -306,7 +318,7 @@ internal fun PreviewRequestVerificationHeaderDark() {
}
}
private fun RoomListRoomSummary.contentType() = isPlaceholder
internal fun RoomListRoomSummary.contentType() = isPlaceholder
@Preview
@Composable
@@ -322,3 +334,11 @@ internal fun RoomListViewDarkPreview(@PreviewParameter(RoomListStateProvider::cl
private fun ContentToPreview(state: RoomListState) {
RoomListView(state)
}
@Preview
@Composable
internal fun RoomListSearchResultContentPreview() {
ElementPreviewLight {
RoomListSearchResultContent(state = aRoomListState(), onRoomClicked = {})
}
}

View File

@@ -20,57 +20,40 @@ package io.element.android.features.roomlist.impl.components
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.ContentAlpha
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.roomlist.impl.R
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.form.textFieldState
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.MediumTopAppBar
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.LogCompositions
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.model.MatrixUser
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RoomListTopBar(
matrixUser: MatrixUser?,
filter: String,
areSearchResultsDisplayed: Boolean,
onFilterChanged: (String) -> Unit,
onToggleSearch: () -> Unit,
onOpenSettings: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior,
modifier: Modifier = Modifier,
@@ -79,124 +62,26 @@ fun RoomListTopBar(
tag = "RoomListScreen",
msg = "TopBar"
)
var searchWidgetStateIsOpened by rememberSaveable { mutableStateOf(false) }
fun closeFilter() {
onFilterChanged("")
searchWidgetStateIsOpened = false
}
BackHandler(enabled = searchWidgetStateIsOpened) {
BackHandler(enabled = areSearchResultsDisplayed) {
closeFilter()
onToggleSearch()
}
if (searchWidgetStateIsOpened) {
SearchRoomListTopBar(
text = filter,
onFilterChanged = onFilterChanged,
onCloseClicked = ::closeFilter,
scrollBehavior = scrollBehavior,
modifier = modifier,
)
} else {
DefaultRoomListTopBar(
matrixUser = matrixUser,
onOpenSettings = onOpenSettings,
onSearchClicked = {
searchWidgetStateIsOpened = true
},
scrollBehavior = scrollBehavior,
modifier = modifier,
)
}
}
@Composable
fun SearchRoomListTopBar(
text: String,
scrollBehavior: TopAppBarScrollBehavior,
modifier: Modifier = Modifier,
onFilterChanged: (String) -> Unit = {},
onCloseClicked: () -> Unit = {},
) {
var filterState by textFieldState(stateValue = text)
val focusRequester = remember { FocusRequester() }
TopAppBar(
modifier = modifier
.nestedScroll(scrollBehavior.nestedScrollConnection),
title = {
TextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
value = filterState,
textStyle = TextStyle(
fontSize = 17.sp
),
onValueChange = {
filterState = it
onFilterChanged(it)
},
placeholder = {
Text(
text = stringResource(id = StringR.string.action_search),
color = MaterialTheme.colorScheme.onBackground.copy(alpha = ContentAlpha.medium)
)
},
singleLine = true,
trailingIcon = {
if (text.isNotEmpty()) {
IconButton(
onClick = {
onFilterChanged("")
}
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "clear",
tint = MaterialTheme.colorScheme.onBackground
)
}
}
},
)
},
navigationIcon = {
IconButton(
onClick = {
onCloseClicked()
}
) {
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = "close",
tint = MaterialTheme.colorScheme.onBackground
)
}
},
windowInsets = WindowInsets(0.dp)
)
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
@Preview
@Composable
internal fun SearchRoomListTopBarLightPreview() = ElementPreviewLight { SearchRoomListTopBarPreview() }
@Preview
@Composable
internal fun SearchRoomListTopBarDarkPreview() = ElementPreviewDark { SearchRoomListTopBarPreview() }
@Composable
private fun SearchRoomListTopBarPreview() {
SearchRoomListTopBar(
text = "Hello",
scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()),
DefaultRoomListTopBar(
matrixUser = matrixUser,
onOpenSettings = onOpenSettings,
onSearchClicked = onToggleSearch,
scrollBehavior = scrollBehavior,
modifier = modifier,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DefaultRoomListTopBar(
matrixUser: MatrixUser?,
@@ -216,8 +101,8 @@ private fun DefaultRoomListTopBar(
},
navigationIcon = {
if (matrixUser != null) {
IconButton(onClick = {}) {
Avatar(matrixUser.avatarData)
IconButton(onClick = onOpenSettings) {
Avatar(matrixUser.avatarData, contentDescription = stringResource(StringR.string.common_settings))
}
}
},
@@ -225,12 +110,7 @@ private fun DefaultRoomListTopBar(
IconButton(
onClick = onSearchClicked
) {
Icon(Icons.Default.Search, contentDescription = "search")
}
IconButton(
onClick = onOpenSettings
) {
Icon(Icons.Default.Settings, contentDescription = "Settings")
Icon(Icons.Default.Search, contentDescription = stringResource(StringR.string.action_search))
}
},
scrollBehavior = scrollBehavior,
@@ -246,6 +126,7 @@ internal fun DefaultRoomListTopBarLightPreview() = ElementPreviewLight { Default
@Composable
internal fun DefaultRoomListTopBarDarkPreview() = ElementPreviewDark { DefaultRoomListTopBarPreview() }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DefaultRoomListTopBarPreview() {
DefaultRoomListTopBar(

View File

@@ -0,0 +1,201 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomlist.impl.search
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import io.element.android.features.roomlist.impl.RoomListEvents
import io.element.android.features.roomlist.impl.RoomListState
import io.element.android.features.roomlist.impl.components.RoomSummaryRow
import io.element.android.features.roomlist.impl.contentType
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.modifiers.applyIf
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.copy
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.R
@Composable
internal fun RoomListSearchResultView(
state: RoomListState,
onRoomClicked: (RoomId) -> Unit,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = state.displaySearchResults,
enter = fadeIn(),
exit = fadeOut(),
) {
Column(
modifier = modifier
.applyIf(state.displaySearchResults, ifTrue = {
// Disable input interaction to underlying views
pointerInput(Unit) {}
})
) {
if (state.displaySearchResults) {
RoomListSearchResultContent(state = state, onRoomClicked = onRoomClicked)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun RoomListSearchResultContent(
state: RoomListState,
onRoomClicked: (RoomId) -> Unit,
modifier: Modifier = Modifier,
) {
val borderColor = MaterialTheme.colorScheme.tertiary
val strokeWidth = 1.dp
fun onBackButtonPressed() {
state.eventSink(RoomListEvents.ToggleSearchResults)
}
fun onRoomClicked(room: RoomListRoomSummary) {
if (room.roomId == null) return
onRoomClicked(room.roomId)
}
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
modifier = Modifier.drawBehind {
drawLine(
color = borderColor,
start = Offset(0f, size.height),
end = Offset(size.width, size.height),
strokeWidth = strokeWidth.value
)
},
navigationIcon = { BackButton(onClick = ::onBackButtonPressed) },
title = {
val filter = state.filter.orEmpty()
val focusRequester = FocusRequester()
TextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
value = filter,
onValueChange = { state.eventSink(RoomListEvents.UpdateFilter(it)) },
colors = TextFieldDefaults.textFieldColors(
containerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent
),
trailingIcon = {
if (filter.isNotEmpty()) {
IconButton(onClick = {
state.eventSink(RoomListEvents.UpdateFilter(""))
}) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.action_cancel)
)
}
}
}
)
LaunchedEffect(state.displaySearchResults) {
if (state.displaySearchResults) {
focusRequester.requestFocus()
}
}
},
windowInsets = TopAppBarDefaults.windowInsets.copy(top = 0)
)
}
) { padding ->
val lazyListState = rememberLazyListState()
val visibleRange by remember {
derivedStateOf {
val layoutInfo = lazyListState.layoutInfo
val firstItemIndex = layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: 0
val size = layoutInfo.visibleItemsInfo.size
firstItemIndex until firstItemIndex + size
}
}
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override suspend fun onPostFling(
consumed: Velocity,
available: Velocity
): Velocity {
state.eventSink(RoomListEvents.UpdateVisibleRange(visibleRange))
return super.onPostFling(consumed, available)
}
}
}
Column(
modifier = Modifier
.padding(padding)
) {
LazyColumn(
modifier = Modifier
.weight(1f)
.nestedScroll(nestedScrollConnection),
state = lazyListState,
) {
items(
items = state.filteredRoomList,
contentType = { room -> room.contentType() },
) { room ->
RoomSummaryRow(room = room, onClick = ::onRoomClicked)
}
}
}
}
}

View File

@@ -112,6 +112,8 @@ class RoomListPresenterTests {
withUserState.eventSink.invoke(RoomListEvents.UpdateFilter("t"))
val withFilterState = awaitItem()
Truth.assertThat(withFilterState.filter).isEqualTo("t")
cancelAndIgnoreRemainingEvents()
}
}
@@ -168,17 +170,18 @@ class RoomListPresenterTests {
val loadedState = awaitItem()
// Test filtering with result
loadedState.eventSink.invoke(RoomListEvents.UpdateFilter(A_ROOM_NAME.substring(0, 3)))
skipItems(1) // Filter update
val withNotFilteredRoomState = awaitItem()
Truth.assertThat(withNotFilteredRoomState.filter).isEqualTo(A_ROOM_NAME.substring(0, 3))
Truth.assertThat(withNotFilteredRoomState.roomList.size).isEqualTo(1)
Truth.assertThat(withNotFilteredRoomState.roomList.first())
Truth.assertThat(withNotFilteredRoomState.filteredRoomList.size).isEqualTo(1)
Truth.assertThat(withNotFilteredRoomState.filteredRoomList.first())
.isEqualTo(aRoomListRoomSummary)
// Test filtering without result
withNotFilteredRoomState.eventSink.invoke(RoomListEvents.UpdateFilter("tada"))
skipItems(1) // Filter update
val withFilteredRoomState = awaitItem()
Truth.assertThat(withFilteredRoomState.filter).isEqualTo("tada")
Truth.assertThat(withFilteredRoomState.roomList).isEmpty()
Truth.assertThat(withFilteredRoomState.filteredRoomList).isEmpty()
}
}

View File

@@ -41,7 +41,11 @@ import io.element.android.libraries.designsystem.theme.components.Text
import timber.log.Timber
@Composable
fun Avatar(avatarData: AvatarData, modifier: Modifier = Modifier) {
fun Avatar(
avatarData: AvatarData,
modifier: Modifier = Modifier,
contentDescription: String? = null,
) {
val commonModifier = modifier
.size(avatarData.size.dp)
.clip(CircleShape)
@@ -54,6 +58,7 @@ fun Avatar(avatarData: AvatarData, modifier: Modifier = Modifier) {
ImageAvatar(
avatarData = avatarData,
modifier = commonModifier,
contentDescription = contentDescription,
)
}
}
@@ -62,13 +67,14 @@ fun Avatar(avatarData: AvatarData, modifier: Modifier = Modifier) {
private fun ImageAvatar(
avatarData: AvatarData,
modifier: Modifier = Modifier,
contentDescription: String? = null,
) {
AsyncImage(
model = avatarData,
onError = {
Timber.e("TAG", "Error $it\n${it.result}", it.result.throwable)
},
contentDescription = null,
contentDescription = contentDescription,
contentScale = ContentScale.Crop,
placeholder = debugPlaceholderAvatar(),
modifier = modifier
@@ -89,7 +95,7 @@ private fun InitialsAvatar(
end = Offset(100f, 0f)
)
Box(
modifier.background(brush = initialsGradient)
modifier.background(brush = initialsGradient),
) {
Text(
modifier = Modifier.align(Alignment.Center),

View File

@@ -0,0 +1,45 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.modifiers
import android.annotation.SuppressLint
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.platform.debugInspectorInfo
/**
* Applies the [ifTrue] modifier when the [condition] is true, [ifFalse] otherwise.
*/
@SuppressLint("UnnecessaryComposedModifier") // It's actually necessary due to the `@Composable` lambdas
fun Modifier.applyIf(
condition: Boolean,
ifTrue: @Composable Modifier.() -> Modifier,
ifFalse: @Composable (Modifier.() -> Modifier)? = null
): Modifier =
composed(
inspectorInfo = debugInspectorInfo {
name = "applyIf"
value = condition
}
) {
when {
condition -> then(ifTrue(Modifier))
ifFalse != null -> then(ifFalse(Modifier))
else -> this
}
}

View File

@@ -0,0 +1,106 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.modifiers
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.updateTransition
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.platform.debugInspectorInfo
import kotlin.math.sqrt
// Note: these modifiers come from https://gist.github.com/darvld/eb3844474baf2f3fc6d3ab44a4b4b5f8
/**
* A modifier that clips the composable content using an animated circle. The circle will
* expand/shrink with an animation whenever [visible] changes.
*
* For more fine-grained control over the transition, see this method's overload, which allows passing
* a [State] object to control the progress of the reveal animation.
*
* By default, the circle is centered in the content, but custom positions may be specified using
* [revealFrom]. Specified offsets should be between 0 (left/top) and 1 (right/bottom).*/
fun Modifier.circularReveal(
visible: Boolean,
showScrim: Boolean = false,
revealFrom: Offset = Offset(0.5f, 0.5f),
): Modifier = composed(
factory = {
val factor = updateTransition(visible, label = "Visibility")
.animateFloat(label = "revealFactor") { if (it) 1f else 0f }
circularReveal(factor, showScrim, revealFrom)
},
inspectorInfo = debugInspectorInfo {
name = "circularReveal"
properties["visible"] = visible
properties["revealFrom"] = revealFrom
}
)
/**
* A modifier that clips the composable content using a circular shape. The radius of the circle
* will be determined by the [transitionProgress].
*
* The values of the progress should be between 0 and 1.
*
* By default, the circle is centered in the content, but custom positions may be specified using
* [revealFrom]. Specified offsets should be between 0 (left/top) and 1 (right/bottom).
* */
fun Modifier.circularReveal(
transitionProgress: State<Float>,
showScrim: Boolean = false,
revealFrom: Offset = Offset(0.5f, 0.5f)
): Modifier {
return drawWithCache {
val path = Path()
val center = revealFrom.mapTo(size)
val radius = calculateRadius(revealFrom, size)
val scrimColor = if (showScrim)
Color.Gray
else
Color.Transparent
path.addOval(Rect(center, radius * transitionProgress.value))
onDrawWithContent {
if (showScrim) {
drawRect(scrimColor, alpha = transitionProgress.value * 0.75f)
}
clipPath(path) { this@onDrawWithContent.drawContent() }
}
}
}
private fun Offset.mapTo(size: Size): Offset {
return Offset(x * size.width, y * size.height)
}
private fun calculateRadius(normalizedOrigin: Offset, size: Size) = with(normalizedOrigin) {
val x = (if (x > 0.5f) x else 1 - x) * size.width
val y = (if (y > 0.5f) y else 1 - y) * size.height
sqrt(x * x + y * y)
}

View File

@@ -0,0 +1,39 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.utils
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
@Composable
fun WindowInsets.copy(
top: Int? = null,
right: Int? = null,
bottom: Int? = null,
left: Int? = null
): WindowInsets {
val density = LocalDensity.current
val direction = LocalLayoutDirection.current
return WindowInsets(
top = top ?: this.getTop(density),
right = right ?: this.getRight(density, direction),
bottom = bottom ?: this.getBottom(density),
left = left ?: this.getLeft(density, direction)
)
}