[Architecture] split all feature modules to api/impl

This commit is contained in:
ganfra 2023-03-08 16:13:45 +01:00
parent b2c68d555a
commit 7051bfc14e
214 changed files with 626 additions and 1090 deletions

View file

@ -0,0 +1,48 @@
/*
* 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
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.roomlist.api.RoomListEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultRoomListEntryPoint @Inject constructor() : RoomListEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): RoomListEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>()
return object : RoomListEntryPoint.NodeBuilder {
override fun callback(callback: RoomListEntryPoint.Callback): RoomListEntryPoint.NodeBuilder {
plugins += callback
return this
}
override fun build(): Node {
return parentNode.createNode<RoomListNode>(buildContext, plugins)
}
}
}
}

View file

@ -0,0 +1,22 @@
/*
* 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
sealed interface RoomListEvents {
data class UpdateFilter(val newFilter: String) : RoomListEvents
data class UpdateVisibleRange(val range: IntRange) : RoomListEvents
}

View file

@ -0,0 +1,57 @@
/*
* 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
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.roomlist.api.RoomListEntryPoint
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
@ContributesNode(SessionScope::class)
class RoomListNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: RoomListPresenter,
) : Node(buildContext, plugins = plugins) {
private fun onRoomClicked(roomId: RoomId) {
plugins<RoomListEntryPoint.Callback>().forEach { it.onRoomClicked(roomId) }
}
private fun onOpenSettings() {
plugins<RoomListEntryPoint.Callback>().forEach { it.onSettingsClicked() }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
RoomListView(
state = state,
modifier = modifier,
onRoomClicked = this::onRoomClicked,
onOpenSettings = this::onOpenSettings
)
}
}

View file

@ -0,0 +1,155 @@
/*
* 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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
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 io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryPlaceholders
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.parallelMap
import io.element.android.libraries.dateformatter.api.LastMessageFormatter
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.MatrixClient
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomSummary
import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
private const val extendedRangeSize = 40
class RoomListPresenter @Inject constructor(
private val client: MatrixClient,
private val lastMessageFormatter: LastMessageFormatter,
) : Presenter<RoomListState> {
@Composable
override fun present(): RoomListState {
val matrixUser: MutableState<MatrixUser?> = remember {
mutableStateOf(null)
}
var filter by rememberSaveable { mutableStateOf("") }
val roomSummaries by client
.roomSummaryDataSource()
.roomSummaries()
.collectAsState()
Timber.v("RoomSummaries size = ${roomSummaries.size}")
val filteredRoomSummaries: MutableState<ImmutableList<RoomListRoomSummary>> = remember {
mutableStateOf(persistentListOf())
}
LaunchedEffect(Unit) {
initialLoad(matrixUser)
}
fun handleEvents(event: RoomListEvents) {
when (event) {
is RoomListEvents.UpdateFilter -> filter = event.newFilter
is RoomListEvents.UpdateVisibleRange -> updateVisibleRange(event.range)
}
}
LaunchedEffect(roomSummaries, filter) {
filteredRoomSummaries.value = updateFilteredRoomSummaries(roomSummaries, filter)
}
return RoomListState(
matrixUser = matrixUser.value,
roomList = filteredRoomSummaries.value,
filter = filter,
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) }
}.toImmutableList()
}
private fun CoroutineScope.initialLoad(matrixUser: MutableState<MatrixUser?>) = launch {
val userAvatarUrl = client.loadUserAvatarURLString().getOrNull()
val userDisplayName = client.loadUserDisplayName().getOrNull()
val avatarData =
AvatarData(
id = client.sessionId.value,
name = userDisplayName,
url = userAvatarUrl,
size = AvatarSize.SMALL
)
matrixUser.value = MatrixUser(
id = UserId(client.sessionId.value),
username = userDisplayName ?: client.sessionId.value,
avatarData = avatarData,
)
}
private fun updateVisibleRange(range: IntRange) {
if (range.isEmpty()) return
val midExtendedRangeSize = extendedRangeSize / 2
val extendedRangeStart = (range.first - midExtendedRangeSize).coerceAtLeast(0)
// Safe to give bigger size than room list
val extendedRangeEnd = range.last + midExtendedRangeSize
val extendedRange = IntRange(extendedRangeStart, extendedRangeEnd)
client.roomSummaryDataSource().setSlidingSyncRange(extendedRange)
}
private suspend fun mapRoomSummaries(
roomSummaries: List<RoomSummary>
): List<RoomListRoomSummary> {
return roomSummaries.parallelMap { roomSummary ->
when (roomSummary) {
is RoomSummary.Empty -> RoomListRoomSummaryPlaceholders.create(roomSummary.identifier)
is RoomSummary.Filled -> {
val avatarData = AvatarData(
id = roomSummary.identifier(),
name = roomSummary.details.name,
url = roomSummary.details.avatarURLString
)
RoomListRoomSummary(
id = roomSummary.identifier(),
name = roomSummary.details.name,
hasUnread = roomSummary.details.unreadNotificationCount > 0,
timestamp = lastMessageFormatter.format(roomSummary.details.lastMessageTimestamp),
lastMessage = roomSummary.details.lastMessage,
avatarData = avatarData,
)
}
}
}
}
}

View file

@ -0,0 +1,30 @@
/*
* 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
import androidx.compose.runtime.Immutable
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.collections.immutable.ImmutableList
@Immutable
data class RoomListState(
val matrixUser: MatrixUser?,
val roomList: ImmutableList<RoomListRoomSummary>,
val filter: String,
val eventSink: (RoomListEvents) -> Unit
)

View file

@ -0,0 +1,62 @@
/*
* 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
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryPlaceholders
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
override val values: Sequence<RoomListState>
get() = sequenceOf(
aRoomListState(),
)
}
internal fun aRoomListState() = RoomListState(
matrixUser = MatrixUser(id = UserId("@id"), username = "User#1", avatarData = AvatarData("@id", "U")),
roomList = aRoomListRoomSummaryList(),
filter = "filter",
eventSink = {}
)
internal fun aRoomListRoomSummaryList(): ImmutableList<RoomListRoomSummary> {
return persistentListOf(
RoomListRoomSummary(
name = "Room",
hasUnread = true,
timestamp = "14:18",
lastMessage = "A very very very very long message which suites on two lines",
avatarData = AvatarData("!id", "R"),
id = "roomId"
),
RoomListRoomSummary(
name = "Room#2",
hasUnread = false,
timestamp = "14:16",
lastMessage = "A short message",
avatarData = AvatarData("!id", "Z"),
id = "roomId2"
),
RoomListRoomSummaryPlaceholders.create("roomId2")
)
}

View file

@ -0,0 +1,167 @@
/*
* 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
import androidx.compose.foundation.layout.Column
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.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Velocity
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.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.utils.LogCompositions
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.collections.immutable.ImmutableList
@Composable
fun RoomListView(
state: RoomListState,
modifier: Modifier = Modifier,
onRoomClicked: (RoomId) -> Unit = {},
onOpenSettings: () -> Unit = {},
) {
fun onFilterChanged(filter: String) {
state.eventSink(RoomListEvents.UpdateFilter(filter))
}
fun onVisibleRangedChanged(range: IntRange) {
state.eventSink(RoomListEvents.UpdateVisibleRange(range))
}
RoomListContent(
roomSummaries = state.roomList,
matrixUser = state.matrixUser,
filter = state.filter,
modifier = modifier,
onRoomClicked = onRoomClicked,
onFilterChanged = ::onFilterChanged,
onOpenSettings = onOpenSettings,
onScrollOver = ::onVisibleRangedChanged,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RoomListContent(
roomSummaries: ImmutableList<RoomListRoomSummary>,
matrixUser: MatrixUser?,
filter: String,
modifier: Modifier = Modifier,
onRoomClicked: (RoomId) -> Unit = {},
onFilterChanged: (String) -> Unit = {},
onOpenSettings: () -> Unit = {},
onScrollOver: (IntRange) -> Unit = {},
) {
fun onRoomClicked(room: RoomListRoomSummary) {
onRoomClicked(room.roomId)
}
val appBarState = rememberTopAppBarState()
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 scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(appBarState)
LogCompositions(
tag = "RoomListScreen",
msg = "Content"
)
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
onScrollOver(visibleRange)
return super.onPostFling(consumed, available)
}
}
}
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
RoomListTopBar(
matrixUser = matrixUser,
filter = filter,
onFilterChanged = onFilterChanged,
onOpenSettings = onOpenSettings,
scrollBehavior = scrollBehavior,
modifier = Modifier,
)
},
content = { padding ->
Column(
modifier = Modifier
.padding(padding)
) {
LazyColumn(
modifier = Modifier
.weight(1f)
.nestedScroll(nestedScrollConnection),
state = lazyListState,
) {
items(
items = roomSummaries,
contentType = { room -> room.contentType() },
) { room ->
RoomSummaryRow(room = room, onClick = ::onRoomClicked)
}
}
}
}
)
}
private fun RoomListRoomSummary.contentType() = isPlaceholder
@Preview
@Composable
internal fun RoomListViewLightPreview(@PreviewParameter(RoomListStateProvider::class) state: RoomListState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
internal fun RoomListViewDarkPreview(@PreviewParameter(RoomListStateProvider::class) state: RoomListState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: RoomListState) {
RoomListView(state)
}

View file

@ -0,0 +1,250 @@
/*
* 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.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.features.roomlist.impl.components
import androidx.activity.compose.BackHandler
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.sp
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
@Composable
fun RoomListTopBar(
matrixUser: MatrixUser?,
filter: String,
onFilterChanged: (String) -> Unit,
onOpenSettings: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior,
modifier: Modifier = Modifier,
) {
LogCompositions(
tag = "RoomListScreen",
msg = "TopBar"
)
var searchWidgetStateIsOpened by rememberSaveable { mutableStateOf(false) }
fun closeFilter() {
onFilterChanged("")
searchWidgetStateIsOpened = false
}
BackHandler(enabled = searchWidgetStateIsOpened) {
closeFilter()
}
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 = "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
)
}
},
)
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()),
)
}
@Composable
private fun DefaultRoomListTopBar(
matrixUser: MatrixUser?,
scrollBehavior: TopAppBarScrollBehavior,
modifier: Modifier = Modifier,
onOpenSettings: () -> Unit = {},
onSearchClicked: () -> Unit = {},
) {
MediumTopAppBar(
modifier = modifier
.nestedScroll(scrollBehavior.nestedScrollConnection),
title = {
Text(
fontWeight = FontWeight.Bold,
text = stringResource(id = StringR.string.all_chats)
)
},
navigationIcon = {
if (matrixUser != null) {
IconButton(onClick = {}) {
Avatar(matrixUser.avatarData)
}
}
},
actions = {
IconButton(
onClick = onSearchClicked
) {
Icon(Icons.Default.Search, contentDescription = "search")
}
IconButton(
onClick = onOpenSettings
) {
Icon(Icons.Default.Settings, contentDescription = "Settings")
}
},
scrollBehavior = scrollBehavior,
)
}
@Preview
@Composable
internal fun DefaultRoomListTopBarLightPreview() = ElementPreviewLight { DefaultRoomListTopBarPreview() }
@Preview
@Composable
internal fun DefaultRoomListTopBarDarkPreview() = ElementPreviewDark { DefaultRoomListTopBarPreview() }
@Composable
private fun DefaultRoomListTopBarPreview() {
DefaultRoomListTopBar(
matrixUser = MatrixUser(UserId("@id"), "Alice", AvatarData("@id", "Alice")),
scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()),
)
}

View file

@ -0,0 +1,214 @@
/*
* 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.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.google.accompanist.placeholder.material.placeholder
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryProvider
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.roomListPlaceHolder
import io.element.android.libraries.designsystem.theme.roomListRoomMessage
import io.element.android.libraries.designsystem.theme.roomListRoomMessageDate
import io.element.android.libraries.designsystem.theme.roomListRoomName
import io.element.android.libraries.designsystem.theme.roomListUnreadIndicator
private val minHeight = 72.dp
@Composable
internal fun RoomSummaryRow(
room: RoomListRoomSummary,
modifier: Modifier = Modifier,
onClick: (RoomListRoomSummary) -> Unit = {},
) {
val clickModifier = if (room.isPlaceholder) {
modifier
} else {
modifier.clickable(
onClick = { onClick(room) },
indication = rememberRipple(),
interactionSource = remember { MutableInteractionSource() }
)
}
Box(
modifier = modifier
.fillMaxWidth()
.heightIn(min = minHeight)
.then(clickModifier)
) {
DefaultRoomSummaryRow(room = room)
}
}
@Composable
internal fun DefaultRoomSummaryRow(
room: RoomListRoomSummary,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.height(IntrinsicSize.Min),
verticalAlignment = CenterVertically
) {
Avatar(
room.avatarData,
modifier = Modifier.placeholder(
visible = room.isPlaceholder,
shape = CircleShape,
color = ElementTheme.colors.roomListPlaceHolder(),
)
)
Column(
modifier = Modifier
.padding(start = 12.dp, end = 4.dp, top = 12.dp, bottom = 12.dp)
.alignByBaseline()
.weight(1f)
) {
// Name
Text(
modifier = Modifier.placeholder(
visible = room.isPlaceholder,
shape = TextPlaceholderShape,
color = ElementTheme.colors.roomListPlaceHolder(),
),
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
text = room.name,
color = MaterialTheme.roomListRoomName(),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// Last Message
Text(
modifier = Modifier.placeholder(
visible = room.isPlaceholder,
shape = TextPlaceholderShape,
color = ElementTheme.colors.roomListPlaceHolder(),
),
text = room.lastMessage?.toString().orEmpty(),
color = MaterialTheme.roomListRoomMessage(),
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
// Timestamp and Unread
Column(
modifier = Modifier
.alignByBaseline(),
) {
Text(
modifier = Modifier.placeholder(
visible = room.isPlaceholder,
shape = TextPlaceholderShape,
color = ElementTheme.colors.roomListPlaceHolder(),
),
fontSize = 12.sp,
text = room.timestamp ?: "",
color = MaterialTheme.roomListRoomMessageDate(),
)
Spacer(Modifier.size(4.dp))
val unreadIndicatorColor =
if (room.hasUnread) MaterialTheme.roomListUnreadIndicator() else Color.Transparent
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(unreadIndicatorColor)
.align(Alignment.End),
)
}
}
}
val TextPlaceholderShape = PercentRectangleSizeShape(0.5f)
class PercentRectangleSizeShape(private val percent: Float) : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
val halfPercent = percent / 2f
val path = Path().apply {
val rect = Rect(
0f,
size.height * halfPercent,
size.width,
size.height - (size.height * halfPercent)
)
addRect(rect)
close()
}
return Outline.Generic(path)
}
}
@Preview
@Composable
internal fun RoomSummaryRowLightPreview(@PreviewParameter(RoomListRoomSummaryProvider::class) data: RoomListRoomSummary) =
ElementPreviewLight { ContentToPreview(data) }
@Preview
@Composable
internal fun RoomSummaryRowDarkPreview(@PreviewParameter(RoomListRoomSummaryProvider::class) data: RoomListRoomSummary) =
ElementPreviewDark { ContentToPreview(data) }
@Composable
private fun ContentToPreview(data: RoomListRoomSummary) {
RoomSummaryRow(data)
}

View file

@ -0,0 +1,33 @@
/*
* Copyright (c) 2022 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.model
import androidx.compose.runtime.Immutable
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.RoomId
@Immutable
data class RoomListRoomSummary(
val id: String,
val roomId: RoomId = RoomId(id),
val name: String = "",
val hasUnread: Boolean = false,
val timestamp: String? = null,
val lastMessage: CharSequence? = null,
val avatarData: AvatarData = AvatarData(id, name),
val isPlaceholder: Boolean = false,
)

View file

@ -0,0 +1,41 @@
/*
* 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.model
import io.element.android.libraries.designsystem.components.avatar.AvatarData
object RoomListRoomSummaryPlaceholders {
fun create(id: String): RoomListRoomSummary {
return RoomListRoomSummary(
id = id,
isPlaceholder = true,
name = "Short name",
timestamp = "hh:mm",
lastMessage = "Last message for placeholder",
avatarData = AvatarData(id, "S")
)
}
fun createFakeList(size: Int): List<RoomListRoomSummary> {
return mutableListOf<RoomListRoomSummary>().apply {
repeat(size) {
add(create("\$fakeRoom$it"))
}
}
}
}

View file

@ -0,0 +1,44 @@
/*
* 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.model
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.RoomId
open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSummary> {
override val values: Sequence<RoomListRoomSummary>
get() = sequenceOf(
aRoomListRoomSummary(),
aRoomListRoomSummary().copy(lastMessage = null),
aRoomListRoomSummary().copy(hasUnread = true),
aRoomListRoomSummary().copy(timestamp = "88:88"),
aRoomListRoomSummary().copy(timestamp = "88:88", hasUnread = true),
aRoomListRoomSummary().copy(isPlaceholder = true),
)
}
fun aRoomListRoomSummary() = RoomListRoomSummary(
id = "!roomId",
roomId = RoomId("!roomId"),
name = "Room name",
hasUnread = false,
timestamp = null,
lastMessage = "Last message",
avatarData = AvatarData("!roomId", "Room name"),
isPlaceholder = false,
)

View file

@ -0,0 +1,216 @@
/*
* 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
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.libraries.dateformatter.api.LastMessageFormatter
import io.element.android.libraries.dateformatter.test.FakeLastMessageFormatter
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.test.*
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled
import kotlinx.coroutines.test.runTest
import org.junit.Test
class RoomListPresenterTests {
@Test
fun `present - should start with no user and then load user with success`() = runTest {
val presenter = RoomListPresenter(
FakeMatrixClient(A_SESSION_ID),
createDateFormatter()
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
Truth.assertThat(initialState.matrixUser).isNull()
val withUserState = awaitItem()
Truth.assertThat(withUserState.matrixUser).isNotNull()
Truth.assertThat(withUserState.matrixUser!!.id).isEqualTo(A_USER_ID)
Truth.assertThat(withUserState.matrixUser!!.username).isEqualTo(A_USER_NAME)
Truth.assertThat(withUserState.matrixUser!!.avatarData.name).isEqualTo(A_USER_NAME)
Truth.assertThat(withUserState.matrixUser!!.avatarData.url).isEqualTo(AN_AVATAR_URL)
}
}
@Test
fun `present - should start with no user and then load user with error`() = runTest {
val presenter = RoomListPresenter(
FakeMatrixClient(
A_SESSION_ID,
userDisplayName = Result.failure(AN_EXCEPTION),
userAvatarURLString = Result.failure(AN_EXCEPTION),
),
createDateFormatter()
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
Truth.assertThat(initialState.matrixUser).isNull()
val withUserState = awaitItem()
Truth.assertThat(withUserState.matrixUser).isNotNull()
// username fallback to user id value
Truth.assertThat(withUserState.matrixUser!!.username).isEqualTo(A_USER_ID.value)
}
}
@Test
fun `present - should filter room with success`() = runTest {
val presenter = RoomListPresenter(
FakeMatrixClient(A_SESSION_ID),
createDateFormatter()
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val withUserState = awaitItem()
Truth.assertThat(withUserState.filter).isEqualTo("")
withUserState.eventSink.invoke(RoomListEvents.UpdateFilter("t"))
val withFilterState = awaitItem()
Truth.assertThat(withFilterState.filter).isEqualTo("t")
}
}
@Test
fun `present - load 1 room with success`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource()
val presenter = RoomListPresenter(
FakeMatrixClient(
sessionId = A_SESSION_ID,
roomSummaryDataSource = roomSummaryDataSource
),
createDateFormatter()
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val withUserState = awaitItem()
// Room list is loaded with 16 placeholders
Truth.assertThat(withUserState.roomList.size).isEqualTo(16)
Truth.assertThat(withUserState.roomList.all { it.isPlaceholder }).isTrue()
roomSummaryDataSource.postRoomSummary(listOf(aRoomSummaryFilled()))
skipItems(1)
val withRoomState = awaitItem()
Truth.assertThat(withRoomState.roomList.size).isEqualTo(1)
Truth.assertThat(withRoomState.roomList.first())
.isEqualTo(aRoomListRoomSummary)
}
}
@Test
fun `present - load 1 room with success and filter rooms`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource()
val presenter = RoomListPresenter(
FakeMatrixClient(
sessionId = A_SESSION_ID,
roomSummaryDataSource = roomSummaryDataSource
),
createDateFormatter()
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
roomSummaryDataSource.postRoomSummary(listOf(aRoomSummaryFilled()))
skipItems(3)
val loadedState = awaitItem()
// Test filtering with result
loadedState.eventSink.invoke(RoomListEvents.UpdateFilter(A_ROOM_NAME.substring(0, 3)))
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())
.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()
}
}
@Test
fun `present - update visible range`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource()
val presenter = RoomListPresenter(
FakeMatrixClient(
sessionId = A_SESSION_ID,
roomSummaryDataSource = roomSummaryDataSource
),
createDateFormatter()
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
roomSummaryDataSource.postRoomSummary(listOf(aRoomSummaryFilled()))
skipItems(3)
val loadedState = awaitItem()
// check initial value
Truth.assertThat(roomSummaryDataSource.latestSlidingSyncRange).isNull()
// Test empty range
loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(1, 0)))
Truth.assertThat(roomSummaryDataSource.latestSlidingSyncRange).isNull()
// Update visible range and check that range is transmitted to the SDK after computation
loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(0, 0)))
Truth.assertThat(roomSummaryDataSource.latestSlidingSyncRange)
.isEqualTo(IntRange(0, 20))
loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(0, 1)))
Truth.assertThat(roomSummaryDataSource.latestSlidingSyncRange)
.isEqualTo(IntRange(0, 21))
loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(19, 29)))
Truth.assertThat(roomSummaryDataSource.latestSlidingSyncRange)
.isEqualTo(IntRange(0, 49))
loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(49, 59)))
Truth.assertThat(roomSummaryDataSource.latestSlidingSyncRange)
.isEqualTo(IntRange(29, 79))
loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(149, 159)))
Truth.assertThat(roomSummaryDataSource.latestSlidingSyncRange)
.isEqualTo(IntRange(129, 179))
loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(149, 259)))
Truth.assertThat(roomSummaryDataSource.latestSlidingSyncRange)
.isEqualTo(IntRange(129, 279))
}
}
private fun createDateFormatter(): LastMessageFormatter {
return FakeLastMessageFormatter().apply {
givenFormat(A_FORMATTED_DATE)
}
}
}
private const val A_FORMATTED_DATE = "formatted_date"
private val aRoomListRoomSummary = RoomListRoomSummary(
id = A_ROOM_ID.value,
roomId = A_ROOM_ID,
name = A_ROOM_NAME,
hasUnread = true,
timestamp = A_FORMATTED_DATE,
lastMessage = A_MESSAGE,
avatarData = AvatarData(id = A_ROOM_ID.value, name = A_ROOM_NAME),
isPlaceholder = false,
)