Benoit Marty
10 months ago
21 changed files with 963 additions and 426 deletions
@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
|
||||
/* |
||||
* 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. |
||||
*/ |
||||
plugins { |
||||
id("io.element.android-library") |
||||
} |
||||
|
||||
android { |
||||
namespace = "io.element.android.libraries.roomselect.api" |
||||
} |
||||
|
||||
dependencies { |
||||
implementation(projects.libraries.architecture) |
||||
implementation(projects.libraries.matrix.api) |
||||
} |
@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
|
||||
/* |
||||
* 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.roomselect.api |
||||
|
||||
import com.bumble.appyx.core.modality.BuildContext |
||||
import com.bumble.appyx.core.node.Node |
||||
import com.bumble.appyx.core.plugin.Plugin |
||||
import io.element.android.libraries.architecture.FeatureEntryPoint |
||||
import io.element.android.libraries.matrix.api.core.RoomId |
||||
|
||||
interface RoomSelectEntryPoint : FeatureEntryPoint { |
||||
data class Params( |
||||
val mode: RoomSelectMode, |
||||
) |
||||
|
||||
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder |
||||
interface NodeBuilder { |
||||
fun params(params: Params): NodeBuilder |
||||
fun callback(callback: Callback): NodeBuilder |
||||
fun build(): Node |
||||
} |
||||
|
||||
interface Callback : Plugin { |
||||
fun onRoomSelected(roomIds: List<RoomId>) |
||||
fun onCancel() |
||||
} |
||||
} |
||||
|
@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
|
||||
/* |
||||
* 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.roomselect.api |
||||
|
||||
enum class RoomSelectMode { |
||||
Forward, |
||||
} |
@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
|
||||
/* |
||||
* 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. |
||||
*/ |
||||
|
||||
plugins { |
||||
id("io.element.android-compose-library") |
||||
alias(libs.plugins.anvil) |
||||
alias(libs.plugins.ksp) |
||||
id("kotlin-parcelize") |
||||
} |
||||
|
||||
android { |
||||
namespace = "io.element.android.libraries.roomselect.impl" |
||||
} |
||||
|
||||
anvil { |
||||
generateDaggerFactories.set(true) |
||||
} |
||||
|
||||
dependencies { |
||||
implementation(projects.anvilannotations) |
||||
anvil(projects.anvilcodegen) |
||||
implementation(projects.libraries.core) |
||||
implementation(projects.libraries.androidutils) |
||||
implementation(projects.libraries.architecture) |
||||
implementation(projects.libraries.matrix.api) |
||||
implementation(projects.libraries.matrixui) |
||||
implementation(projects.libraries.designsystem) |
||||
implementation(projects.libraries.uiStrings) |
||||
api(projects.libraries.roomselect.api) |
||||
ksp(libs.showkase.processor) |
||||
|
||||
testImplementation(libs.test.junit) |
||||
testImplementation(libs.coroutines.test) |
||||
testImplementation(libs.molecule.runtime) |
||||
testImplementation(libs.test.truth) |
||||
testImplementation(libs.test.turbine) |
||||
testImplementation(projects.libraries.matrix.test) |
||||
testImplementation(projects.tests.testutils) |
||||
} |
@ -0,0 +1,50 @@
@@ -0,0 +1,50 @@
|
||||
/* |
||||
* 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.roomselect.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.libraries.architecture.createNode |
||||
import io.element.android.libraries.di.SessionScope |
||||
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint |
||||
import javax.inject.Inject |
||||
|
||||
@ContributesBinding(SessionScope::class) |
||||
class DefaultRoomSelectEntryPoint @Inject constructor() : RoomSelectEntryPoint { |
||||
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): RoomSelectEntryPoint.NodeBuilder { |
||||
val plugins = ArrayList<Plugin>() |
||||
|
||||
return object : RoomSelectEntryPoint.NodeBuilder { |
||||
override fun params(params: RoomSelectEntryPoint.Params): RoomSelectEntryPoint.NodeBuilder { |
||||
plugins += RoomSelectNode.Inputs(mode = params.mode) |
||||
return this |
||||
} |
||||
|
||||
override fun callback(callback: RoomSelectEntryPoint.Callback): RoomSelectEntryPoint.NodeBuilder { |
||||
plugins += callback |
||||
return this |
||||
} |
||||
|
||||
override fun build(): Node { |
||||
return parentNode.createNode<RoomSelectNode>(buildContext, plugins) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
@ -0,0 +1,28 @@
@@ -0,0 +1,28 @@
|
||||
/* |
||||
* 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.roomselect.impl |
||||
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails |
||||
|
||||
sealed interface RoomSelectEvents { |
||||
data class SetSelectedRoom(val room: RoomSummaryDetails) : RoomSelectEvents |
||||
|
||||
// TODO remove to restore multi-selection |
||||
data object RemoveSelectedRoom : RoomSelectEvents |
||||
data object ToggleSearchActive : RoomSelectEvents |
||||
data class UpdateQuery(val query: String) : RoomSelectEvents |
||||
} |
@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
|
||||
/* |
||||
* 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.roomselect.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 dagger.assisted.Assisted |
||||
import dagger.assisted.AssistedInject |
||||
import io.element.android.anvilannotations.ContributesNode |
||||
import io.element.android.libraries.architecture.NodeInputs |
||||
import io.element.android.libraries.architecture.inputs |
||||
import io.element.android.libraries.di.SessionScope |
||||
import io.element.android.libraries.matrix.api.core.RoomId |
||||
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint |
||||
import io.element.android.libraries.roomselect.api.RoomSelectMode |
||||
|
||||
@ContributesNode(SessionScope::class) |
||||
class RoomSelectNode @AssistedInject constructor( |
||||
@Assisted buildContext: BuildContext, |
||||
@Assisted plugins: List<Plugin>, |
||||
presenterFactory: RoomSelectPresenter.Factory, |
||||
) : Node(buildContext, plugins = plugins) { |
||||
data class Inputs( |
||||
val mode: RoomSelectMode, |
||||
) : NodeInputs |
||||
|
||||
private val inputs: Inputs = inputs() |
||||
private val presenter = presenterFactory.create(inputs.mode) |
||||
|
||||
private val callbacks = plugins.filterIsInstance<RoomSelectEntryPoint.Callback>() |
||||
|
||||
private fun onDismiss() { |
||||
callbacks.forEach { it.onCancel() } |
||||
} |
||||
|
||||
private fun onSubmit(roomIds: List<RoomId>) { |
||||
callbacks.forEach { it.onRoomSelected(roomIds) } |
||||
} |
||||
|
||||
@Composable |
||||
override fun View(modifier: Modifier) { |
||||
val state = presenter.present() |
||||
RoomSelectView( |
||||
state = state, |
||||
onDismiss = ::onDismiss, |
||||
onSubmit = ::onSubmit, |
||||
modifier = modifier |
||||
) |
||||
} |
||||
} |
@ -0,0 +1,98 @@
@@ -0,0 +1,98 @@
|
||||
/* |
||||
* 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.roomselect.impl |
||||
|
||||
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.remember |
||||
import androidx.compose.runtime.setValue |
||||
import dagger.assisted.Assisted |
||||
import dagger.assisted.AssistedFactory |
||||
import dagger.assisted.AssistedInject |
||||
import io.element.android.libraries.roomselect.api.RoomSelectMode |
||||
import io.element.android.libraries.architecture.Presenter |
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState |
||||
import io.element.android.libraries.matrix.api.MatrixClient |
||||
import io.element.android.libraries.matrix.api.roomlist.RoomSummary |
||||
import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails |
||||
import kotlinx.collections.immutable.ImmutableList |
||||
import kotlinx.collections.immutable.persistentListOf |
||||
import kotlinx.collections.immutable.toPersistentList |
||||
|
||||
class RoomSelectPresenter @AssistedInject constructor( |
||||
@Assisted private val mode: RoomSelectMode, |
||||
private val client: MatrixClient, |
||||
) : Presenter<RoomSelectState> { |
||||
|
||||
@AssistedFactory |
||||
interface Factory { |
||||
fun create(mode: RoomSelectMode): RoomSelectPresenter |
||||
} |
||||
|
||||
@Composable |
||||
override fun present(): RoomSelectState { |
||||
var selectedRooms by remember { mutableStateOf(persistentListOf<RoomSummaryDetails>()) } |
||||
var query by remember { mutableStateOf("") } |
||||
var isSearchActive by remember { mutableStateOf(false) } |
||||
var results: SearchBarResultState<ImmutableList<RoomSummaryDetails>> by remember { mutableStateOf(SearchBarResultState.NotSearching()) } |
||||
|
||||
val summaries by client.roomListService.allRooms.summaries.collectAsState() |
||||
|
||||
LaunchedEffect(query, summaries) { |
||||
val filteredSummaries = summaries.filterIsInstance<RoomSummary.Filled>() |
||||
.map { it.details } |
||||
.filter { it.name.contains(query, ignoreCase = true) } |
||||
.distinctBy { it.roomId } // This should be removed once we're sure no duplicate Rooms can be received |
||||
.toPersistentList() |
||||
results = if (filteredSummaries.isNotEmpty()) { |
||||
SearchBarResultState.Results(filteredSummaries) |
||||
} else { |
||||
SearchBarResultState.NoResults() |
||||
} |
||||
} |
||||
|
||||
fun handleEvents(event: RoomSelectEvents) { |
||||
when (event) { |
||||
is RoomSelectEvents.SetSelectedRoom -> { |
||||
selectedRooms = persistentListOf(event.room) |
||||
// Restore for multi-selection |
||||
// val index = selectedRooms.indexOfFirst { it.roomId == event.room.roomId } |
||||
// selectedRooms = if (index >= 0) { |
||||
// selectedRooms.removeAt(index) |
||||
// } else { |
||||
// selectedRooms.add(event.room) |
||||
// } |
||||
} |
||||
RoomSelectEvents.RemoveSelectedRoom -> selectedRooms = persistentListOf() |
||||
is RoomSelectEvents.UpdateQuery -> query = event.query |
||||
RoomSelectEvents.ToggleSearchActive -> isSearchActive = !isSearchActive |
||||
} |
||||
} |
||||
|
||||
return RoomSelectState( |
||||
mode = mode, |
||||
resultState = results, |
||||
query = query, |
||||
isSearchActive = isSearchActive, |
||||
selectedRooms = selectedRooms, |
||||
eventSink = { handleEvents(it) } |
||||
) |
||||
} |
||||
} |
@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
|
||||
/* |
||||
* 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.roomselect.impl |
||||
|
||||
import io.element.android.libraries.roomselect.api.RoomSelectMode |
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState |
||||
import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails |
||||
import kotlinx.collections.immutable.ImmutableList |
||||
|
||||
data class RoomSelectState( |
||||
val mode: RoomSelectMode, |
||||
val resultState: SearchBarResultState<ImmutableList<RoomSummaryDetails>>, |
||||
val query: String, |
||||
val isSearchActive: Boolean, |
||||
val selectedRooms: ImmutableList<RoomSummaryDetails>, |
||||
val eventSink: (RoomSelectEvents) -> Unit |
||||
) |
@ -0,0 +1,89 @@
@@ -0,0 +1,89 @@
|
||||
/* |
||||
* 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.roomselect.impl |
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider |
||||
import io.element.android.libraries.roomselect.api.RoomSelectMode |
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState |
||||
import io.element.android.libraries.matrix.api.core.RoomId |
||||
import io.element.android.libraries.matrix.api.room.RoomMember |
||||
import io.element.android.libraries.matrix.api.room.message.RoomMessage |
||||
import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails |
||||
import kotlinx.collections.immutable.ImmutableList |
||||
import kotlinx.collections.immutable.persistentListOf |
||||
|
||||
open class RoomSelectStateProvider : PreviewParameterProvider<RoomSelectState> { |
||||
override val values: Sequence<RoomSelectState> |
||||
get() = sequenceOf( |
||||
aRoomSelectState(), |
||||
aRoomSelectState(query = "Test", isSearchActive = true), |
||||
aRoomSelectState(resultState = SearchBarResultState.Results(aForwardMessagesRoomList())), |
||||
aRoomSelectState( |
||||
resultState = SearchBarResultState.Results(aForwardMessagesRoomList()), |
||||
query = "Test", |
||||
isSearchActive = true, |
||||
), |
||||
aRoomSelectState( |
||||
resultState = SearchBarResultState.Results(aForwardMessagesRoomList()), |
||||
query = "Test", |
||||
isSearchActive = true, |
||||
selectedRooms = persistentListOf(aRoomDetailsState(roomId = RoomId("!room2:domain"))) |
||||
), |
||||
// Add other states here |
||||
) |
||||
} |
||||
|
||||
fun aRoomSelectState( |
||||
resultState: SearchBarResultState<ImmutableList<RoomSummaryDetails>> = SearchBarResultState.NotSearching(), |
||||
query: String = "", |
||||
isSearchActive: Boolean = false, |
||||
selectedRooms: ImmutableList<RoomSummaryDetails> = persistentListOf(), |
||||
) = RoomSelectState( |
||||
mode = RoomSelectMode.Forward, |
||||
resultState = resultState, |
||||
query = query, |
||||
isSearchActive = isSearchActive, |
||||
selectedRooms = selectedRooms, |
||||
eventSink = {} |
||||
) |
||||
|
||||
internal fun aForwardMessagesRoomList() = persistentListOf( |
||||
aRoomDetailsState(), |
||||
aRoomDetailsState(roomId = RoomId("!room2:domain"), canonicalAlias = "#element-x-room:matrix.org"), |
||||
) |
||||
|
||||
fun aRoomDetailsState( |
||||
roomId: RoomId = RoomId("!room:domain"), |
||||
name: String = "roomName", |
||||
canonicalAlias: String? = null, |
||||
isDirect: Boolean = true, |
||||
avatarURLString: String? = null, |
||||
lastMessage: RoomMessage? = null, |
||||
lastMessageTimestamp: Long? = null, |
||||
unreadNotificationCount: Int = 0, |
||||
inviter: RoomMember? = null, |
||||
) = RoomSummaryDetails( |
||||
roomId = roomId, |
||||
name = name, |
||||
canonicalAlias = canonicalAlias, |
||||
isDirect = isDirect, |
||||
avatarURLString = avatarURLString, |
||||
lastMessage = lastMessage, |
||||
lastMessageTimestamp = lastMessageTimestamp, |
||||
unreadNotificationCount = unreadNotificationCount, |
||||
inviter = inviter, |
||||
) |
@ -0,0 +1,267 @@
@@ -0,0 +1,267 @@
|
||||
/* |
||||
* 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.roomselect.impl |
||||
|
||||
import androidx.activity.compose.BackHandler |
||||
import androidx.compose.foundation.clickable |
||||
import androidx.compose.foundation.layout.Arrangement |
||||
import androidx.compose.foundation.layout.Column |
||||
import androidx.compose.foundation.layout.PaddingValues |
||||
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.heightIn |
||||
import androidx.compose.foundation.layout.padding |
||||
import androidx.compose.foundation.lazy.LazyColumn |
||||
import androidx.compose.foundation.lazy.LazyRow |
||||
import androidx.compose.foundation.lazy.items |
||||
import androidx.compose.material3.ExperimentalMaterial3Api |
||||
import androidx.compose.material3.MaterialTheme |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.ui.Alignment |
||||
import androidx.compose.ui.Modifier |
||||
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.libraries.roomselect.api.RoomSelectMode |
||||
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.avatar.AvatarSize |
||||
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.aliasScreenTitle |
||||
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider |
||||
import io.element.android.libraries.designsystem.theme.components.RadioButton |
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold |
||||
import io.element.android.libraries.designsystem.theme.components.SearchBar |
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState |
||||
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.TopAppBar |
||||
import io.element.android.libraries.designsystem.theme.roomListRoomMessage |
||||
import io.element.android.libraries.designsystem.theme.roomListRoomName |
||||
import io.element.android.libraries.matrix.api.core.RoomId |
||||
import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails |
||||
import io.element.android.libraries.matrix.ui.components.SelectedRoom |
||||
import io.element.android.libraries.ui.strings.CommonStrings |
||||
import kotlinx.collections.immutable.ImmutableList |
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class) |
||||
@Composable |
||||
fun RoomSelectView( |
||||
state: RoomSelectState, |
||||
onDismiss: () -> Unit, |
||||
onSubmit: (List<RoomId>) -> Unit, |
||||
modifier: Modifier = Modifier, |
||||
) { |
||||
@Suppress("UNUSED_PARAMETER") |
||||
fun onRoomRemoved(roomSummaryDetails: RoomSummaryDetails) { |
||||
// TODO toggle selection when multi-selection is enabled |
||||
state.eventSink(RoomSelectEvents.RemoveSelectedRoom) |
||||
} |
||||
|
||||
@Composable |
||||
fun SelectedRoomsHelper(isForwarding: Boolean, selectedRooms: ImmutableList<RoomSummaryDetails>) { |
||||
if (isForwarding) return |
||||
SelectedRooms( |
||||
selectedRooms = selectedRooms, |
||||
onRoomRemoved = ::onRoomRemoved, |
||||
modifier = Modifier.padding(vertical = 16.dp) |
||||
) |
||||
} |
||||
|
||||
fun onBackButton(state: RoomSelectState) { |
||||
if (state.isSearchActive) { |
||||
state.eventSink(RoomSelectEvents.ToggleSearchActive) |
||||
} else { |
||||
onDismiss() |
||||
} |
||||
} |
||||
|
||||
BackHandler(onBack = { onBackButton(state) }) |
||||
|
||||
Scaffold( |
||||
modifier = modifier, |
||||
topBar = { |
||||
TopAppBar( |
||||
title = { |
||||
Text( |
||||
text = when (state.mode) { |
||||
RoomSelectMode.Forward -> stringResource(CommonStrings.common_forward_message) |
||||
}, |
||||
style = ElementTheme.typography.aliasScreenTitle |
||||
) |
||||
}, |
||||
navigationIcon = { |
||||
BackButton(onClick = { onBackButton(state) }) |
||||
}, |
||||
actions = { |
||||
TextButton( |
||||
text = stringResource(CommonStrings.action_send), |
||||
enabled = state.selectedRooms.isNotEmpty(), |
||||
onClick = { onSubmit(state.selectedRooms.map { it.roomId }) } |
||||
) |
||||
} |
||||
) |
||||
} |
||||
) { paddingValues -> |
||||
Column( |
||||
Modifier |
||||
.padding(paddingValues) |
||||
.consumeWindowInsets(paddingValues) |
||||
) { |
||||
SearchBar( |
||||
placeHolderTitle = stringResource(CommonStrings.action_search), |
||||
query = state.query, |
||||
onQueryChange = { state.eventSink(RoomSelectEvents.UpdateQuery(it)) }, |
||||
active = state.isSearchActive, |
||||
onActiveChange = { state.eventSink(RoomSelectEvents.ToggleSearchActive) }, |
||||
resultState = state.resultState, |
||||
showBackButton = false, |
||||
) { summaries -> |
||||
LazyColumn { |
||||
item { |
||||
SelectedRoomsHelper( |
||||
isForwarding = false, // TODO state.isForwarding, |
||||
selectedRooms = state.selectedRooms |
||||
) |
||||
} |
||||
items(summaries, key = { it.roomId.value }) { roomSummary -> |
||||
Column { |
||||
RoomSummaryView( |
||||
roomSummary, |
||||
isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId }, |
||||
onSelection = { roomSummary -> |
||||
state.eventSink(RoomSelectEvents.SetSelectedRoom(roomSummary)) |
||||
} |
||||
) |
||||
HorizontalDivider(modifier = Modifier.fillMaxWidth()) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
if (!state.isSearchActive) { |
||||
// TODO restore for multi-selection |
||||
// SelectedRoomsHelper( |
||||
// isForwarding = state.isForwarding, |
||||
// selectedRooms = state.selectedRooms |
||||
// ) |
||||
Spacer(modifier = Modifier.height(20.dp)) |
||||
|
||||
if (state.resultState is SearchBarResultState.Results) { |
||||
LazyColumn { |
||||
items(state.resultState.results, key = { it.roomId.value }) { roomSummary -> |
||||
Column { |
||||
RoomSummaryView( |
||||
roomSummary, |
||||
isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId }, |
||||
onSelection = { roomSummary -> |
||||
state.eventSink(RoomSelectEvents.SetSelectedRoom(roomSummary)) |
||||
} |
||||
) |
||||
HorizontalDivider(modifier = Modifier.fillMaxWidth()) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
private fun SelectedRooms( |
||||
selectedRooms: ImmutableList<RoomSummaryDetails>, |
||||
onRoomRemoved: (RoomSummaryDetails) -> Unit, |
||||
modifier: Modifier = Modifier, |
||||
) { |
||||
LazyRow( |
||||
modifier, |
||||
contentPadding = PaddingValues(horizontal = 16.dp), |
||||
horizontalArrangement = Arrangement.spacedBy(32.dp) |
||||
) { |
||||
items(selectedRooms, key = { it.roomId.value }) { roomSummary -> |
||||
SelectedRoom(roomSummary = roomSummary, onRoomRemoved = onRoomRemoved) |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
private fun RoomSummaryView( |
||||
summary: RoomSummaryDetails, |
||||
isSelected: Boolean, |
||||
onSelection: (RoomSummaryDetails) -> Unit, |
||||
modifier: Modifier = Modifier |
||||
) { |
||||
Row( |
||||
modifier = modifier |
||||
.clickable { onSelection(summary) } |
||||
.fillMaxWidth() |
||||
.padding(start = 16.dp, end = 4.dp) |
||||
.heightIn(56.dp), |
||||
verticalAlignment = Alignment.CenterVertically |
||||
) { |
||||
val roomAlias = summary.canonicalAlias ?: summary.roomId.value |
||||
Avatar( |
||||
avatarData = AvatarData( |
||||
id = roomAlias, |
||||
name = summary.name, |
||||
url = summary.avatarURLString, |
||||
size = AvatarSize.ForwardRoomListItem, |
||||
), |
||||
) |
||||
Column( |
||||
modifier = Modifier |
||||
.padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp) |
||||
.weight(1f) |
||||
) { |
||||
// Name |
||||
Text( |
||||
style = ElementTheme.typography.fontBodyLgRegular, |
||||
text = summary.name, |
||||
color = MaterialTheme.roomListRoomName(), |
||||
maxLines = 1, |
||||
overflow = TextOverflow.Ellipsis |
||||
) |
||||
// Id |
||||
Text( |
||||
text = roomAlias, |
||||
color = MaterialTheme.roomListRoomMessage(), |
||||
style = ElementTheme.typography.fontBodySmRegular, |
||||
maxLines = 1, |
||||
overflow = TextOverflow.Ellipsis |
||||
) |
||||
} |
||||
RadioButton(selected = isSelected, onClick = { onSelection(summary) }) |
||||
} |
||||
} |
||||
|
||||
@PreviewsDayNight |
||||
@Composable |
||||
internal fun RoomSelectViewPreview(@PreviewParameter(RoomSelectStateProvider::class) state: RoomSelectState) = ElementPreview { |
||||
RoomSelectView( |
||||
state = state, |
||||
onDismiss = {}, |
||||
onSubmit = {}, |
||||
) |
||||
} |
@ -0,0 +1,117 @@
@@ -0,0 +1,117 @@
|
||||
/* |
||||
* 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.roomselect.impl |
||||
|
||||
import app.cash.molecule.RecompositionMode |
||||
import app.cash.molecule.moleculeFlow |
||||
import app.cash.turbine.test |
||||
import com.google.common.truth.Truth.assertThat |
||||
import io.element.android.libraries.roomselect.api.RoomSelectMode |
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState |
||||
import io.element.android.libraries.matrix.api.roomlist.RoomSummary |
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient |
||||
import io.element.android.libraries.matrix.test.room.aRoomSummaryDetail |
||||
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService |
||||
import io.element.android.tests.testutils.WarmUpRule |
||||
import kotlinx.collections.immutable.persistentListOf |
||||
import kotlinx.coroutines.test.runTest |
||||
import org.junit.Rule |
||||
import org.junit.Test |
||||
|
||||
class RoomSelectPresenterTests { |
||||
|
||||
@get:Rule |
||||
val warmUpRule = WarmUpRule() |
||||
|
||||
@Test |
||||
fun `present - initial state`() = runTest { |
||||
val presenter = aPresenter() |
||||
moleculeFlow(RecompositionMode.Immediate) { |
||||
presenter.present() |
||||
}.test { |
||||
val initialState = awaitItem() |
||||
assertThat(initialState.selectedRooms).isEmpty() |
||||
assertThat(initialState.resultState).isInstanceOf(SearchBarResultState.NotSearching::class.java) |
||||
assertThat(initialState.isSearchActive).isFalse() |
||||
// Search is run automatically |
||||
val searchState = awaitItem() |
||||
assertThat(searchState.resultState).isInstanceOf(SearchBarResultState.NoResults::class.java) |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
fun `present - toggle search active`() = runTest { |
||||
val presenter = aPresenter() |
||||
moleculeFlow(RecompositionMode.Immediate) { |
||||
presenter.present() |
||||
}.test { |
||||
val initialState = awaitItem() |
||||
skipItems(1) |
||||
|
||||
initialState.eventSink(RoomSelectEvents.ToggleSearchActive) |
||||
assertThat(awaitItem().isSearchActive).isTrue() |
||||
|
||||
initialState.eventSink(RoomSelectEvents.ToggleSearchActive) |
||||
assertThat(awaitItem().isSearchActive).isFalse() |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
fun `present - update query`() = runTest { |
||||
val roomListService = FakeRoomListService().apply { |
||||
postAllRooms(listOf(RoomSummary.Filled(aRoomSummaryDetail()))) |
||||
} |
||||
val client = FakeMatrixClient(roomListService = roomListService) |
||||
val presenter = aPresenter(client = client) |
||||
moleculeFlow(RecompositionMode.Immediate) { |
||||
presenter.present() |
||||
}.test { |
||||
val initialState = awaitItem() |
||||
assertThat(awaitItem().resultState as? SearchBarResultState.Results).isEqualTo(SearchBarResultState.Results(listOf(aRoomSummaryDetail()))) |
||||
|
||||
initialState.eventSink(RoomSelectEvents.UpdateQuery("string not contained")) |
||||
assertThat(awaitItem().query).isEqualTo("string not contained") |
||||
assertThat(awaitItem().resultState).isInstanceOf(SearchBarResultState.NoResults::class.java) |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
fun `present - select and remove a room`() = runTest { |
||||
val presenter = aPresenter() |
||||
moleculeFlow(RecompositionMode.Immediate) { |
||||
presenter.present() |
||||
}.test { |
||||
val initialState = awaitItem() |
||||
skipItems(1) |
||||
val summary = aRoomSummaryDetail() |
||||
|
||||
initialState.eventSink(RoomSelectEvents.SetSelectedRoom(summary)) |
||||
assertThat(awaitItem().selectedRooms).isEqualTo(persistentListOf(summary)) |
||||
|
||||
initialState.eventSink(RoomSelectEvents.RemoveSelectedRoom) |
||||
assertThat(awaitItem().selectedRooms).isEmpty() |
||||
} |
||||
} |
||||
|
||||
private fun aPresenter( |
||||
mode: RoomSelectMode = RoomSelectMode.Forward, |
||||
client: FakeMatrixClient = FakeMatrixClient(), |
||||
) = RoomSelectPresenter( |
||||
mode = mode, |
||||
client = client, |
||||
) |
||||
} |
Loading…
Reference in new issue