From fcfe4e9d311985713fbf9a96317006c1a5bf7367 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 20 Mar 2024 18:32:41 +0100 Subject: [PATCH] Room directory search : start implementing ui with fake data --- features/roomdirectory/impl/build.gradle.kts | 1 + .../impl/search/RoomDirectorySearchNode.kt | 1 + .../search/RoomDirectorySearchPresenter.kt | 49 ++++- .../impl/search/RoomDirectorySearchState.kt | 5 + .../RoomDirectorySearchStateProvider.kt | 45 +++- .../impl/search/RoomDirectorySearchView.kt | 203 +++++++++++++++++- .../RoomDirectorySearchDataSource.kt | 80 +++++++ .../search/model/RoomDirectorySearchResult.kt | 28 +++ .../components/avatar/AvatarSize.kt | 4 +- 9 files changed, 401 insertions(+), 15 deletions(-) create mode 100644 features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/search/datasource/RoomDirectorySearchDataSource.kt create mode 100644 features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/search/model/RoomDirectorySearchResult.kt diff --git a/features/roomdirectory/impl/build.gradle.kts b/features/roomdirectory/impl/build.gradle.kts index 0e35623401..9d56802700 100644 --- a/features/roomdirectory/impl/build.gradle.kts +++ b/features/roomdirectory/impl/build.gradle.kts @@ -40,6 +40,7 @@ dependencies { implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) testImplementation(libs.test.junit) testImplementation(libs.coroutines.test) diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/search/RoomDirectorySearchNode.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/search/RoomDirectorySearchNode.kt index 1f459f404c..1239dc5fab 100644 --- a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/search/RoomDirectorySearchNode.kt +++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/search/RoomDirectorySearchNode.kt @@ -38,6 +38,7 @@ class RoomDirectorySearchNode @AssistedInject constructor( val state = presenter.present() RoomDirectorySearchView( state = state, + onBackPressed = ::navigateUp, modifier = modifier ) } diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/search/RoomDirectorySearchPresenter.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/search/RoomDirectorySearchPresenter.kt index d0cc6d47c4..e0d6a9dbf4 100644 --- a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/search/RoomDirectorySearchPresenter.kt +++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/search/RoomDirectorySearchPresenter.kt @@ -17,24 +17,65 @@ package io.element.android.features.roomdirectory.impl.search import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import io.element.android.features.roomdirectory.impl.search.datasource.RoomDirectorySearchDataSource import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import javax.inject.Inject -class RoomDirectorySearchPresenter @Inject constructor() : Presenter { +class RoomDirectorySearchPresenter @Inject constructor( + private val client: MatrixClient, + private val dataSource: RoomDirectorySearchDataSource, +) : Presenter { @Composable override fun present(): RoomDirectorySearchState { + var searchQuery by rememberSaveable { + mutableStateOf("") + } + + val results by dataSource.searchResults.collectAsState() + + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(searchQuery) { + dataSource.updateSearchQuery(searchQuery) + } + fun handleEvents(event: RoomDirectorySearchEvents) { when (event) { - is RoomDirectorySearchEvents.JoinRoom -> TODO() - RoomDirectorySearchEvents.LoadMore -> TODO() - is RoomDirectorySearchEvents.Search -> TODO() + is RoomDirectorySearchEvents.JoinRoom -> { + coroutineScope.joinRoom(event.roomId) + } + RoomDirectorySearchEvents.LoadMore -> { + coroutineScope.launch { + dataSource.loadMore() + } + } + is RoomDirectorySearchEvents.Search -> { + searchQuery = event.query + } } } return RoomDirectorySearchState( + query = searchQuery, + results = results, eventSink = ::handleEvents ) } + + private fun CoroutineScope.joinRoom(roomId: RoomId) = launch { + client.getRoom(roomId)?.join() + } } diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/search/RoomDirectorySearchState.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/search/RoomDirectorySearchState.kt index 0e77801e12..e1aa7dc832 100644 --- a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/search/RoomDirectorySearchState.kt +++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/search/RoomDirectorySearchState.kt @@ -16,6 +16,11 @@ package io.element.android.features.roomdirectory.impl.search +import io.element.android.features.roomdirectory.impl.search.model.RoomDirectorySearchResult +import kotlinx.collections.immutable.ImmutableList + data class RoomDirectorySearchState( + val query: String, + val results: ImmutableList, val eventSink: (RoomDirectorySearchEvents) -> Unit ) diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/search/RoomDirectorySearchStateProvider.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/search/RoomDirectorySearchStateProvider.kt index 31464fd17c..3dd56c77bb 100644 --- a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/search/RoomDirectorySearchStateProvider.kt +++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/search/RoomDirectorySearchStateProvider.kt @@ -17,15 +17,54 @@ package io.element.android.features.roomdirectory.impl.search import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.roomdirectory.impl.search.model.RoomDirectorySearchResult +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf open class RoomDirectorySearchStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( aRoomDirectorySearchState(), - // Add other states here + aRoomDirectorySearchState( + query = "Element", + results = persistentListOf( + RoomDirectorySearchResult( + roomId = RoomId("@exa:matrix.org"), + name = "Element X Android", + description = "Element X is a secure, private and decentralized messenger.", + avatarData = AvatarData( + id = "@exa:matrix.org", + name = "Element X Android", + url = null, + size = AvatarSize.RoomDirectorySearchItem + ), + canBeJoined = true, + ), + RoomDirectorySearchResult( + roomId = RoomId("@exi:matrix.org"), + name = "Element X iOS", + description = "Element X is a secure, private and decentralized messenger.", + avatarData = AvatarData( + id = "@exi:matrix.org", + name = "Element X iOS", + url = null, + size = AvatarSize.RoomDirectorySearchItem + ), + canBeJoined = false, + ) + ) + ), ) } -fun aRoomDirectorySearchState() = RoomDirectorySearchState( - eventSink = {} +fun aRoomDirectorySearchState( + query: String = "", + results: ImmutableList = persistentListOf(), +) = RoomDirectorySearchState( + query = query, + results = results, + eventSink = { } ) diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/search/RoomDirectorySearchView.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/search/RoomDirectorySearchView.kt index d2e3ad1ec9..2af471c51f 100644 --- a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/search/RoomDirectorySearchView.kt +++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/search/RoomDirectorySearchView.kt @@ -16,27 +16,215 @@ package io.element.android.features.roomdirectory.impl.search -import androidx.compose.foundation.layout.Box -import androidx.compose.material3.MaterialTheme +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment 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.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.roomdirectory.impl.search.model.RoomDirectorySearchResult +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.button.BackButton 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.designsystem.theme.components.IconButton +import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.designsystem.theme.components.TextField +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.ui.strings.CommonStrings @Composable fun RoomDirectorySearchView( state: RoomDirectorySearchState, + onBackPressed: () -> Unit, modifier: Modifier = Modifier, ) { - Box(modifier, contentAlignment = Alignment.Center) { - Text( - "RoomDirectorySearch feature view", - color = MaterialTheme.colorScheme.primary, - ) + + fun onQueryChanged(query: String) { + state.eventSink(RoomDirectorySearchEvents.Search(query)) } + + Scaffold( + modifier = modifier, + topBar = { + RoomDirectorySearchTopBar( + query = state.query, + onQueryChanged = ::onQueryChanged, + onBackPressed = onBackPressed, + ) + }, + content = { padding -> + RoomDirectorySearchContent( + state = state, + onResultClicked = { roomId -> + state.eventSink(RoomDirectorySearchEvents.JoinRoom(roomId)) + }, + modifier = Modifier + .padding(padding) + .consumeWindowInsets(padding) + ) + } + ) +} + +@Composable +private fun RoomDirectorySearchContent( + state: RoomDirectorySearchState, + onResultClicked: (RoomId) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn(modifier = modifier) { + items(state.results) { result -> + RoomDirectorySearchResultRow( + result = result, + onClick = onResultClicked, + ) + } + } +} + +@Composable +private fun RoomDirectorySearchResultRow( + result: RoomDirectorySearchResult, + onClick: (RoomId) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable { onClick(result.roomId) } + .padding( + top = 12.dp, + bottom = 12.dp, + start = 16.dp, + ) + .height(IntrinsicSize.Min), + ) { + Avatar( + avatarData = result.avatarData, + modifier = Modifier.align(Alignment.CenterVertically) + ) + Column( + modifier = Modifier + .weight(1f) + .padding(start = 16.dp) + ) { + Text( + text = result.name, + maxLines = 1, + style = ElementTheme.typography.fontBodyLgRegular, + color = ElementTheme.colors.textPrimary, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = result.description, + maxLines = 1, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + overflow = TextOverflow.Ellipsis, + ) + } + if (result.canBeJoined) { + CompositionLocalProvider(LocalContentColor provides ElementTheme.colors.textSuccessPrimary) { + TextButton( + text = stringResource(id = CommonStrings.action_join), + onClick = { onClick(result.roomId) }, + modifier = Modifier + .align(Alignment.CenterVertically) + .padding(start = 4.dp, end = 12.dp) + ) + } + } else { + Spacer(modifier = Modifier.width(24.dp)) + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun RoomDirectorySearchTopBar( + query: String, + onQueryChanged: (String) -> Unit, + onBackPressed: () -> Unit, + modifier: Modifier = Modifier, +) { + val borderColor = ElementTheme.colors.borderInteractivePrimary + val borderStroke = 1.dp + TopAppBar( + modifier = modifier.drawBehind { + drawLine( + color = borderColor, + start = Offset(0f, size.height), + end = Offset(size.width, size.height), + strokeWidth = borderStroke.value + ) + }, + title = { + val focusRequester = FocusRequester() + TextField( + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + value = query, + singleLine = true, + onValueChange = onQueryChanged, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent, + ), + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = { + onQueryChanged("") + }) { + Icon( + imageVector = CompoundIcons.Close(), + contentDescription = stringResource(CommonStrings.action_cancel), + ) + } + } + } + ) + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + }, + navigationIcon = { BackButton(onClick = onBackPressed) }, + ) } @PreviewsDayNight @@ -44,5 +232,6 @@ fun RoomDirectorySearchView( fun RoomDirectorySearchViewLightPreview(@PreviewParameter(RoomDirectorySearchStateProvider::class) state: RoomDirectorySearchState) = ElementPreview { RoomDirectorySearchView( state = state, + onBackPressed = {}, ) } diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/search/datasource/RoomDirectorySearchDataSource.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/search/datasource/RoomDirectorySearchDataSource.kt new file mode 100644 index 0000000000..6780c97017 --- /dev/null +++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/search/datasource/RoomDirectorySearchDataSource.kt @@ -0,0 +1,80 @@ +/* + * 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.roomdirectory.impl.search.datasource + +import io.element.android.features.roomdirectory.impl.search.model.RoomDirectorySearchResult +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +class RoomDirectorySearchDataSource @Inject constructor( + +) { + + private val _searchResults = MutableStateFlow>(persistentListOf()) + + suspend fun updateSearchQuery(searchQuery: String) { + //TODO branch to matrix sdk + if (searchQuery.isEmpty()) { + _searchResults.value = persistentListOf() + } else { + delay(100) + emitFakeResults() + } + } + + suspend fun loadMore() { + //TODO branch to matrix sdk + } + + private fun emitFakeResults() { + _searchResults.value = persistentListOf( + RoomDirectorySearchResult( + roomId = RoomId("!exa:matrix.org"), + name = "Element X Android", + description = "Element X is a secure, private and decentralized messenger.", + avatarData = AvatarData( + id = "!exa:matrix.org", + name = "Element X Android", + url = null, + size = AvatarSize.RoomDirectorySearchItem + ), + canBeJoined = true, + ), + RoomDirectorySearchResult( + roomId = RoomId("!exi:matrix.org"), + name = "Element X iOS", + description = "Element X is a secure, private and decentralized messenger.", + avatarData = AvatarData( + id = "!exi:matrix.org", + name = "Element X iOS", + url = null, + size = AvatarSize.RoomDirectorySearchItem + ), + canBeJoined = false, + ) + ) + } + + val searchResults: StateFlow> = _searchResults +} diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/search/model/RoomDirectorySearchResult.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/search/model/RoomDirectorySearchResult.kt new file mode 100644 index 0000000000..ef531e716d --- /dev/null +++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/search/model/RoomDirectorySearchResult.kt @@ -0,0 +1,28 @@ +/* + * 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.roomdirectory.impl.search.model + +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.matrix.api.core.RoomId + +data class RoomDirectorySearchResult( + val roomId: RoomId, + val name: String, + val description: String, + val avatarData: AvatarData, + val canBeJoined: Boolean, +) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt index 0451cb5839..382aac5468 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt @@ -51,5 +51,7 @@ enum class AvatarSize(val dp: Dp) { NotificationsOptIn(32.dp), - CustomRoomNotificationSetting(36.dp) + CustomRoomNotificationSetting(36.dp), + + RoomDirectorySearchItem(36.dp), }