diff --git a/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListEvent.kt b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListEvent.kt new file mode 100644 index 0000000000..b1262708fc --- /dev/null +++ b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListEvent.kt @@ -0,0 +1,12 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.licenses.impl.list + +sealed interface DependencyLicensesListEvent { + data class SetFilter(val filter: String) : DependencyLicensesListEvent +} diff --git a/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListPresenter.kt b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListPresenter.kt index 8b01b00afe..d7ad980dd1 100644 --- a/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListPresenter.kt +++ b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListPresenter.kt @@ -29,6 +29,10 @@ class DependencyLicensesListPresenter @Inject constructor( var licenses by remember { mutableStateOf>>(AsyncData.Loading()) } + var filteredLicenses by remember { + mutableStateOf>>(AsyncData.Loading()) + } + var filter by remember { mutableStateOf("") } LaunchedEffect(Unit) { runCatching { licenses = AsyncData.Success(licensesProvider.provides().toPersistentList()) @@ -36,6 +40,32 @@ class DependencyLicensesListPresenter @Inject constructor( licenses = AsyncData.Failure(it) } } - return DependencyLicensesListState(licenses = licenses) + LaunchedEffect(filter, licenses.dataOrNull()) { + val data = licenses.dataOrNull() + val safeFilter = filter.trim() + if (data != null && safeFilter.isNotEmpty()) { + filteredLicenses = AsyncData.Success(data.filter { + it.safeName.contains(safeFilter, ignoreCase = true) || + it.groupId.contains(safeFilter, ignoreCase = true) || + it.artifactId.contains(safeFilter, ignoreCase = true) + }.toPersistentList()) + } else { + filteredLicenses = licenses + } + } + + fun handleEvent(dependencyLicensesListEvent: DependencyLicensesListEvent) { + when (dependencyLicensesListEvent) { + is DependencyLicensesListEvent.SetFilter -> { + filter = dependencyLicensesListEvent.filter + } + } + } + + return DependencyLicensesListState( + licenses = filteredLicenses, + filter = filter, + eventSink = ::handleEvent, + ) } } diff --git a/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListState.kt b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListState.kt index c60c49c81b..fd9b1ccf1c 100644 --- a/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListState.kt +++ b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListState.kt @@ -13,4 +13,6 @@ import kotlinx.collections.immutable.ImmutableList data class DependencyLicensesListState( val licenses: AsyncData>, + val filter: String, + val eventSink: (DependencyLicensesListEvent) -> Unit, ) diff --git a/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListStateProvider.kt b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListStateProvider.kt index dcbae607cb..74c4e424c0 100644 --- a/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListStateProvider.kt +++ b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListStateProvider.kt @@ -11,28 +11,49 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.licenses.impl.model.DependencyLicenseItem import io.element.android.features.licenses.impl.model.License import io.element.android.libraries.architecture.AsyncData +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf open class DependencyLicensesListStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( - DependencyLicensesListState( + aDependencyLicensesListState( licenses = AsyncData.Loading() ), - DependencyLicensesListState( + aDependencyLicensesListState( licenses = AsyncData.Failure(Exception("Failed to load licenses")) ), - DependencyLicensesListState( + aDependencyLicensesListState( licenses = AsyncData.Success( persistentListOf( aDependencyLicenseItem(), aDependencyLicenseItem(name = null), ) ) - ) + ), + aDependencyLicensesListState( + licenses = AsyncData.Success( + persistentListOf( + aDependencyLicenseItem(), + aDependencyLicenseItem(name = null), + ) + ), + filter = "a filter", + ), ) } +private fun aDependencyLicensesListState( + licenses: AsyncData>, + filter: String = "", +): DependencyLicensesListState { + return DependencyLicensesListState( + licenses = licenses, + filter = filter, + eventSink = {}, + ) +} + internal fun aDependencyLicenseItem( name: String? = "A dependency", ) = DependencyLicenseItem( diff --git a/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListView.kt b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListView.kt index 740025ce17..f8ce40f1cd 100644 --- a/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListView.kt +++ b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListView.kt @@ -7,31 +7,36 @@ package io.element.android.features.licenses.impl.list +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.OutlinedTextField 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.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp +import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.licenses.impl.model.DependencyLicenseItem import io.element.android.libraries.architecture.AsyncData 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.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.ListItem 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.TopAppBar import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun DependencyLicensesListView( state: DependencyLicensesListState, @@ -48,48 +53,64 @@ fun DependencyLicensesListView( ) }, ) { contentPadding -> - LazyColumn( + Column( modifier = Modifier .padding(contentPadding) .padding(horizontal = 16.dp) ) { - when (state.licenses) { - is AsyncData.Failure -> item { - Text( - text = stringResource(CommonStrings.common_error), - modifier = Modifier.padding(16.dp) - ) - } - AsyncData.Uninitialized, - is AsyncData.Loading -> item { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(top = 64.dp) - ) { - CircularProgressIndicator( - modifier = Modifier.align(Alignment.Center) + if (state.licenses.isSuccess()) { + // Search field + OutlinedTextField( + value = state.filter, + onValueChange = { state.eventSink(DependencyLicensesListEvent.SetFilter(it)) }, + leadingIcon = { + Icon( + imageVector = CompoundIcons.Search(), + contentDescription = null, + ) + }, + modifier = Modifier.fillMaxWidth(), + ) + } + LazyColumn { + when (state.licenses) { + is AsyncData.Failure -> item { + Text( + text = stringResource(CommonStrings.common_error), + modifier = Modifier.padding(16.dp) ) } - } - is AsyncData.Success -> items(state.licenses.data) { license -> - ListItem( - headlineContent = { Text(license.safeName) }, - supportingContent = { - Text( - buildString { - append(license.groupId) - append(":") - append(license.artifactId) - append(":") - append(license.version) - } + AsyncData.Uninitialized, + is AsyncData.Loading -> item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 64.dp) + ) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) ) - }, - onClick = { - onOpenLicense(license) } - ) + } + is AsyncData.Success -> items(state.licenses.data) { license -> + ListItem( + headlineContent = { Text(license.safeName) }, + supportingContent = { + Text( + buildString { + append(license.groupId) + append(":") + append(license.artifactId) + append(":") + append(license.version) + } + ) + }, + onClick = { + onOpenLicense(license) + } + ) + } } } } diff --git a/features/licenses/impl/src/test/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListPresenterTest.kt b/features/licenses/impl/src/test/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListPresenterTest.kt index 26c4a1ce6f..46dfa33081 100644 --- a/features/licenses/impl/src/test/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListPresenterTest.kt +++ b/features/licenses/impl/src/test/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListPresenterTest.kt @@ -33,6 +33,7 @@ class DependencyLicensesListPresenterTest { val finalState = awaitItem() assertThat(finalState.licenses.isSuccess()).isTrue() assertThat(finalState.licenses.dataOrNull()).isEmpty() + assertThat(finalState.filter).isEqualTo("") } } @@ -54,6 +55,40 @@ class DependencyLicensesListPresenterTest { } } + @Test + fun `present - initial state, one license, set filter`() = runTest { + val anItem = aDependencyLicenseItem() + val presenter = createPresenter { + listOf(anItem) + } + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.licenses).isInstanceOf(AsyncData.Loading::class.java) + val loadedState = awaitItem() + assertThat(loadedState.licenses.isSuccess()).isTrue() + assertThat(loadedState.licenses.dataOrNull()!!.size).isEqualTo(1) + loadedState.eventSink(DependencyLicensesListEvent.SetFilter("dep")) + awaitItem().let { state -> + assertThat(state.licenses.dataOrNull()!!.size).isEqualTo(1) + assertThat(state.filter).isEqualTo("dep") + } + loadedState.eventSink(DependencyLicensesListEvent.SetFilter("bleh")) + skipItems(1) + awaitItem().let { state -> + assertThat(state.licenses.dataOrNull()!!.size).isEqualTo(0) + assertThat(state.filter).isEqualTo("bleh") + } + loadedState.eventSink(DependencyLicensesListEvent.SetFilter("")) + skipItems(1) + awaitItem().let { state -> + assertThat(state.licenses.dataOrNull()!!.size).isEqualTo(1) + assertThat(state.filter).isEqualTo("") + } + } + } + private fun createPresenter( provideResult: () -> List ) = DependencyLicensesListPresenter(