diff options
| author | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2025-05-13 14:00:35 +0200 |
|---|---|---|
| committer | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2025-05-13 14:00:35 +0200 |
| commit | e1f80b0bfda2a56c1f89beb971b26fbc6faf371e (patch) | |
| tree | 6c4fa73c3fd7a7be6a9202aaf2216c78fc6ae9a0 | |
| parent | d36f215dd5170e50cb2c668fc9c42ae7ecaeac2c (diff) | |
| parent | 365acf9b49139cbdc2afbda524f5fc4f863a726f (diff) | |
| download | mullvadvpn-e1f80b0bfda2a56c1f89beb971b26fbc6faf371e.tar.xz mullvadvpn-e1f80b0bfda2a56c1f89beb971b26fbc6faf371e.zip | |
Merge branch 'search-requires-at-least-2-letters-droid-1605'
58 files changed, 840 insertions, 578 deletions
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreenTest.kt index 049ce6836c..d556fe5d10 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreenTest.kt @@ -12,11 +12,13 @@ import io.mockk.verify import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension import net.mullvad.mullvadvpn.compose.data.DUMMY_RELAY_COUNTRIES import net.mullvad.mullvadvpn.compose.setContentWithTheme +import net.mullvad.mullvadvpn.compose.state.CustomListLocationsData import net.mullvad.mullvadvpn.compose.state.CustomListLocationsUiState import net.mullvad.mullvadvpn.compose.state.RelayLocationListItem import net.mullvad.mullvadvpn.lib.model.RelayItem import net.mullvad.mullvadvpn.lib.ui.tag.CIRCULAR_PROGRESS_INDICATOR_TEST_TAG import net.mullvad.mullvadvpn.lib.ui.tag.SAVE_BUTTON_TEST_TAG +import net.mullvad.mullvadvpn.util.Lce import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension @@ -55,7 +57,9 @@ class CustomListLocationsScreenTest { fun givenLoadingStateShouldShowLoadingSpinner() = composeExtension.use { // Arrange - initScreen(state = CustomListLocationsUiState.Loading(newList = false)) + initScreen( + state = CustomListLocationsUiState(newList = false, content = Lce.Loading(Unit)) + ) // Assert onNodeWithTag(CIRCULAR_PROGRESS_INDICATOR_TEST_TAG).assertExists() @@ -66,7 +70,9 @@ class CustomListLocationsScreenTest { composeExtension.use { // Arrange val newList = true - initScreen(state = CustomListLocationsUiState.Loading(newList = newList)) + initScreen( + state = CustomListLocationsUiState(newList = newList, content = Lce.Loading(Unit)) + ) // Assert onNodeWithText(ADD_LOCATIONS_TEXT).assertExists() @@ -77,7 +83,9 @@ class CustomListLocationsScreenTest { composeExtension.use { // Arrange val newList = false - initScreen(state = CustomListLocationsUiState.Loading(newList = newList)) + initScreen( + state = CustomListLocationsUiState(newList = newList, content = Lce.Loading(Unit)) + ) // Assert onNodeWithText(EDIT_LOCATIONS_TEXT).assertExists() @@ -89,13 +97,27 @@ class CustomListLocationsScreenTest { // Arrange initScreen( state = - CustomListLocationsUiState.Content.Data( - locations = - listOf( - RelayLocationListItem(DUMMY_RELAY_COUNTRIES[0], checked = true), - RelayLocationListItem(DUMMY_RELAY_COUNTRIES[1], checked = false), + CustomListLocationsUiState( + newList = false, + content = + Lce.Content( + CustomListLocationsData( + locations = + listOf( + RelayLocationListItem( + DUMMY_RELAY_COUNTRIES[0], + checked = true, + ), + RelayLocationListItem( + DUMMY_RELAY_COUNTRIES[1], + checked = false, + ), + ), + searchTerm = "", + saveEnabled = false, + hasUnsavedChanges = false, + ) ), - searchTerm = "", ) ) @@ -112,9 +134,20 @@ class CustomListLocationsScreenTest { val mockedOnRelaySelectionClicked: (RelayItem, Boolean) -> Unit = mockk(relaxed = true) initScreen( state = - CustomListLocationsUiState.Content.Data( + CustomListLocationsUiState( newList = false, - locations = listOf(RelayLocationListItem(selectedCountry, checked = true)), + content = + Lce.Content( + CustomListLocationsData( + locations = + listOf( + RelayLocationListItem(selectedCountry, checked = true) + ), + searchTerm = "", + saveEnabled = false, + hasUnsavedChanges = false, + ) + ), ), onRelaySelectionClick = mockedOnRelaySelectionClicked, ) @@ -133,9 +166,17 @@ class CustomListLocationsScreenTest { val mockedSearchTermInput: (String) -> Unit = mockk(relaxed = true) initScreen( state = - CustomListLocationsUiState.Content.Data( + CustomListLocationsUiState( newList = false, - locations = emptyList(), + content = + Lce.Content( + CustomListLocationsData( + locations = emptyList(), + searchTerm = "", + saveEnabled = false, + hasUnsavedChanges = false, + ) + ), ), onSearchTermInput = mockedSearchTermInput, ) @@ -156,9 +197,17 @@ class CustomListLocationsScreenTest { val mockSearchString = "SEARCH" initScreen( state = - CustomListLocationsUiState.Content.Empty( + CustomListLocationsUiState( newList = false, - searchTerm = mockSearchString, + content = + Lce.Content( + CustomListLocationsData( + searchTerm = mockSearchString, + saveEnabled = false, + hasUnsavedChanges = false, + locations = emptyList(), + ) + ), ), onSearchTermInput = mockedSearchTermInput, ) @@ -171,13 +220,8 @@ class CustomListLocationsScreenTest { fun whenRelayListIsEmptyShouldShowNoRelaysText() = composeExtension.use { // Arrange - val emptySearchString = "" initScreen( - state = - CustomListLocationsUiState.Content.Empty( - newList = false, - searchTerm = emptySearchString, - ) + state = CustomListLocationsUiState(newList = false, content = Lce.Error(Unit)) ) // Assert @@ -191,10 +235,17 @@ class CustomListLocationsScreenTest { val mockOnSaveClick: () -> Unit = mockk(relaxed = true) initScreen( state = - CustomListLocationsUiState.Content.Data( + CustomListLocationsUiState( newList = false, - locations = emptyList(), - saveEnabled = true, + content = + Lce.Content( + CustomListLocationsData( + locations = emptyList(), + saveEnabled = true, + hasUnsavedChanges = true, + searchTerm = "", + ) + ), ), onSaveClick = mockOnSaveClick, ) @@ -213,10 +264,17 @@ class CustomListLocationsScreenTest { val mockOnSaveClick: () -> Unit = mockk(relaxed = true) initScreen( state = - CustomListLocationsUiState.Content.Data( + CustomListLocationsUiState( newList = false, - locations = emptyList(), - saveEnabled = false, + content = + Lce.Content( + CustomListLocationsData( + locations = emptyList(), + saveEnabled = false, + hasUnsavedChanges = false, + searchTerm = "", + ) + ), ), onSaveClick = mockOnSaveClick, ) diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ManageDevicesScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ManageDevicesScreenTest.kt index 7059243d81..6fc1ffd5ab 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ManageDevicesScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ManageDevicesScreenTest.kt @@ -37,7 +37,7 @@ class ManageDevicesScreenTest { } private fun ComposeContext.initScreen( - state: Lce<ManageDevicesUiState, GetDeviceListError>, + state: Lce<Unit, ManageDevicesUiState, GetDeviceListError>, snackbarHostState: SnackbarHostState = SnackbarHostState(), onBackClick: () -> Unit = {}, onTryAgainClicked: () -> Unit = {}, @@ -58,7 +58,7 @@ class ManageDevicesScreenTest { fun loadingStateShowsProgressIndicator() { composeExtension.use { // Arrange - initScreen(state = Lce.Loading) + initScreen(state = Lce.Loading(Unit)) // Assert onNodeWithTag(CIRCULAR_PROGRESS_INDICATOR_TEST_TAG).assertIsDisplayed() diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SearchLocationScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SearchLocationScreenTest.kt index 805324a74f..32a91fa61b 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SearchLocationScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SearchLocationScreenTest.kt @@ -18,6 +18,7 @@ import net.mullvad.mullvadvpn.lib.model.CustomListId import net.mullvad.mullvadvpn.lib.model.RelayItem import net.mullvad.mullvadvpn.lib.model.RelayItemId import net.mullvad.mullvadvpn.lib.ui.tag.SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG +import net.mullvad.mullvadvpn.util.Lce import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -38,7 +39,7 @@ class SearchLocationScreenTest { } private fun ComposeContext.initScreen( - state: SearchLocationUiState, + state: Lce<Unit, SearchLocationUiState, Unit>, onSelectRelay: (RelayItem) -> Unit = {}, onToggleExpand: (RelayItemId, CustomListId?, Boolean) -> Unit = { _, _, _ -> }, onSearchInputChanged: (String) -> Unit = {}, @@ -85,7 +86,15 @@ class SearchLocationScreenTest { // Arrange val mockedSearchTermInput: (String) -> Unit = mockk(relaxed = true) initScreen( - state = SearchLocationUiState.NoQuery(searchTerm = "", filterChips = emptyList()), + state = + Lce.Content( + SearchLocationUiState( + searchTerm = "", + filterChips = emptyList(), + relayListItems = emptyList(), + emptyList(), + ) + ), onSearchInputChanged = mockedSearchTermInput, ) val mockSearchString = "SEARCH" @@ -104,11 +113,14 @@ class SearchLocationScreenTest { val mockSearchString = "SEARCH" initScreen( state = - SearchLocationUiState.Content( - searchTerm = mockSearchString, - filterChips = emptyList(), - relayListItems = listOf(RelayListItem.LocationsEmptyText(mockSearchString)), - customLists = emptyList(), + Lce.Content( + SearchLocationUiState( + searchTerm = mockSearchString, + filterChips = emptyList(), + relayListItems = + listOf(RelayListItem.LocationsEmptyText(mockSearchString)), + customLists = emptyList(), + ) ) ) @@ -124,11 +136,13 @@ class SearchLocationScreenTest { val mockSearchString = "SEARCH" initScreen( state = - SearchLocationUiState.Content( - searchTerm = mockSearchString, - filterChips = emptyList(), - relayListItems = emptyList(), - customLists = DUMMY_RELAY_ITEM_CUSTOM_LISTS, + Lce.Content( + SearchLocationUiState( + searchTerm = mockSearchString, + filterChips = emptyList(), + relayListItems = emptyList(), + customLists = DUMMY_RELAY_ITEM_CUSTOM_LISTS, + ) ) ) diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreenTest.kt index 81fa866825..3b259154a4 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreenTest.kt @@ -24,6 +24,8 @@ import net.mullvad.mullvadvpn.lib.model.RelayItem import net.mullvad.mullvadvpn.lib.ui.tag.SELECT_LOCATION_CUSTOM_LIST_BOTTOM_SHEET_TEST_TAG import net.mullvad.mullvadvpn.lib.ui.tag.SELECT_LOCATION_LOCATION_BOTTOM_SHEET_TEST_TAG import net.mullvad.mullvadvpn.performLongClick +import net.mullvad.mullvadvpn.util.Lc +import net.mullvad.mullvadvpn.util.Lce import net.mullvad.mullvadvpn.viewmodel.location.SelectLocationListViewModel import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach @@ -43,7 +45,7 @@ class SelectLocationScreenTest { fun setup() { MockKAnnotations.init(this) loadKoinModules(module { viewModel { listViewModel } }) - every { listViewModel.uiState } returns MutableStateFlow(SelectLocationListUiState.Loading) + every { listViewModel.uiState } returns MutableStateFlow(Lce.Loading(Unit)) } @AfterEach @@ -52,7 +54,7 @@ class SelectLocationScreenTest { } private fun ComposeContext.initScreen( - state: SelectLocationUiState = SelectLocationUiState.Loading, + state: Lc<Unit, SelectLocationUiState> = Lc.Loading(Unit), onSelectRelay: (item: RelayItem) -> Unit = {}, onSearchClick: (RelayListType) -> Unit = {}, onBackClick: () -> Unit = {}, @@ -104,19 +106,25 @@ class SelectLocationScreenTest { // Arrange every { listViewModel.uiState } returns MutableStateFlow( - SelectLocationListUiState.Content( - relayListItems = - DUMMY_RELAY_COUNTRIES.map { RelayListItem.GeoLocationItem(item = it) }, - customLists = emptyList(), + Lce.Content( + SelectLocationListUiState( + relayListItems = + DUMMY_RELAY_COUNTRIES.map { + RelayListItem.GeoLocationItem(item = it) + }, + customLists = emptyList(), + ) ) ) initScreen( state = - SelectLocationUiState.Data( - // searchTerm = "", - filterChips = emptyList(), - multihopEnabled = false, - relayListType = RelayListType.EXIT, + Lc.Content( + SelectLocationUiState( + filterChips = emptyList(), + multihopEnabled = false, + relayListType = RelayListType.EXIT, + isTopBarActionsEnabled = true, + ) ) ) @@ -135,17 +143,22 @@ class SelectLocationScreenTest { // Arrange every { listViewModel.uiState } returns MutableStateFlow( - SelectLocationListUiState.Content( - relayListItems = listOf(RelayListItem.CustomListFooter(false)), - customLists = emptyList(), + Lce.Content( + SelectLocationListUiState( + relayListItems = listOf(RelayListItem.CustomListFooter(false)), + customLists = emptyList(), + ) ) ) initScreen( state = - SelectLocationUiState.Data( - filterChips = emptyList(), - multihopEnabled = false, - relayListType = RelayListType.EXIT, + Lc.Content( + SelectLocationUiState( + filterChips = emptyList(), + multihopEnabled = false, + relayListType = RelayListType.EXIT, + isTopBarActionsEnabled = true, + ) ) ) @@ -160,18 +173,23 @@ class SelectLocationScreenTest { val customList = DUMMY_RELAY_ITEM_CUSTOM_LISTS[0] every { listViewModel.uiState } returns MutableStateFlow( - SelectLocationListUiState.Content( - relayListItems = listOf(RelayListItem.CustomListItem(customList)), - customLists = DUMMY_RELAY_ITEM_CUSTOM_LISTS, + Lce.Content( + SelectLocationListUiState( + relayListItems = listOf(RelayListItem.CustomListItem(customList)), + customLists = DUMMY_RELAY_ITEM_CUSTOM_LISTS, + ) ) ) val mockedOnSelectRelay: (RelayItem) -> Unit = mockk(relaxed = true) initScreen( state = - SelectLocationUiState.Data( - filterChips = emptyList(), - multihopEnabled = false, - relayListType = RelayListType.EXIT, + Lc.Content( + SelectLocationUiState( + filterChips = emptyList(), + multihopEnabled = false, + relayListType = RelayListType.EXIT, + isTopBarActionsEnabled = true, + ) ), onSelectRelay = mockedOnSelectRelay, ) @@ -190,19 +208,25 @@ class SelectLocationScreenTest { val customList = DUMMY_RELAY_ITEM_CUSTOM_LISTS[0] every { listViewModel.uiState } returns MutableStateFlow( - SelectLocationListUiState.Content( - relayListItems = listOf(RelayListItem.CustomListItem(item = customList)), - customLists = DUMMY_RELAY_ITEM_CUSTOM_LISTS, + Lce.Content( + SelectLocationListUiState( + relayListItems = + listOf(RelayListItem.CustomListItem(item = customList)), + customLists = DUMMY_RELAY_ITEM_CUSTOM_LISTS, + ) ) ) val mockedOnSelectRelay: (RelayItem) -> Unit = mockk(relaxed = true) initScreen( state = - SelectLocationUiState.Data( - // searchTerm = "", - filterChips = emptyList(), - multihopEnabled = false, - relayListType = RelayListType.EXIT, + Lc.Content( + SelectLocationUiState( + // searchTerm = "", + filterChips = emptyList(), + multihopEnabled = false, + relayListType = RelayListType.EXIT, + isTopBarActionsEnabled = true, + ) ), onSelectRelay = mockedOnSelectRelay, ) @@ -221,18 +245,23 @@ class SelectLocationScreenTest { val relayItem = DUMMY_RELAY_COUNTRIES[0] every { listViewModel.uiState } returns MutableStateFlow( - SelectLocationListUiState.Content( - relayListItems = listOf(RelayListItem.GeoLocationItem(relayItem)), - customLists = emptyList(), + Lce.Content( + SelectLocationListUiState( + relayListItems = listOf(RelayListItem.GeoLocationItem(relayItem)), + customLists = emptyList(), + ) ) ) val mockedOnSelectRelay: (RelayItem) -> Unit = mockk(relaxed = true) initScreen( state = - SelectLocationUiState.Data( - filterChips = emptyList(), - multihopEnabled = false, - relayListType = RelayListType.EXIT, + Lc.Content( + SelectLocationUiState( + filterChips = emptyList(), + multihopEnabled = false, + relayListType = RelayListType.EXIT, + isTopBarActionsEnabled = true, + ) ), onSelectRelay = mockedOnSelectRelay, ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/EmptyRelayListText.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/EmptyRelayListText.kt new file mode 100644 index 0000000000..5e61257d28 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/EmptyRelayListText.kt @@ -0,0 +1,20 @@ +package net.mullvad.mullvadvpn.compose.component + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.lib.theme.Dimens + +@Composable +fun EmptyRelayListText() { + Text( + text = stringResource(R.string.no_locations_found), + modifier = Modifier.padding(Dimens.screenVerticalMargin), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LocationsEmptyText.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LocationsEmptyText.kt index 579be88bb6..bf3e36f57e 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LocationsEmptyText.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LocationsEmptyText.kt @@ -5,31 +5,20 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.lib.theme.Dimens -import net.mullvad.mullvadvpn.relaylist.MIN_SEARCH_LENGTH @Composable fun LocationsEmptyText(searchTerm: String) { - if (searchTerm.length >= MIN_SEARCH_LENGTH) { - Text( - text = textResource(R.string.search_location_empty_text, searchTerm), - style = MaterialTheme.typography.labelMedium, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(Dimens.screenVerticalMargin), - ) - } else { - Text( - text = stringResource(R.string.no_locations_found), - modifier = Modifier.padding(Dimens.screenVerticalMargin), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + Text( + text = textResource(R.string.search_location_empty_text, searchTerm), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(Dimens.screenVerticalMargin), + ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/CustomListLocationUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/CustomListLocationUiStatePreviewParameterProvider.kt index 7c4253be50..2598c7a9b2 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/CustomListLocationUiStatePreviewParameterProvider.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/CustomListLocationUiStatePreviewParameterProvider.kt @@ -1,43 +1,51 @@ package net.mullvad.mullvadvpn.compose.preview import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import net.mullvad.mullvadvpn.compose.state.CustomListLocationsData import net.mullvad.mullvadvpn.compose.state.CustomListLocationsUiState import net.mullvad.mullvadvpn.compose.state.RelayLocationListItem import net.mullvad.mullvadvpn.lib.model.GeoLocationId +import net.mullvad.mullvadvpn.util.Lce class CustomListLocationUiStatePreviewParameterProvider : PreviewParameterProvider<CustomListLocationsUiState> { override val values = sequenceOf( - CustomListLocationsUiState.Content.Data( + CustomListLocationsUiState( newList = true, - locations = - listOf( - RelayLocationListItem( - item = - RelayItemPreviewData.generateRelayItemCountry( - name = "A relay", - cityNames = listOf("City 1", "City 2"), - relaysPerCity = 2, - active = true, - ) - ), - RelayLocationListItem( - item = - RelayItemPreviewData.generateRelayItemCountry( - name = "Another relay", - cityNames = listOf("City X", "City Y", "City Z"), - relaysPerCity = 1, - active = false, - ) - .copy(id = GeoLocationId.Country("se")) - ), + content = + Lce.Content( + CustomListLocationsData( + locations = + listOf( + RelayLocationListItem( + item = + RelayItemPreviewData.generateRelayItemCountry( + name = "A relay", + cityNames = listOf("City 1", "City 2"), + relaysPerCity = 2, + active = true, + ) + ), + RelayLocationListItem( + item = + RelayItemPreviewData.generateRelayItemCountry( + name = "Another relay", + cityNames = + listOf("City X", "City Y", "City Z"), + relaysPerCity = 1, + active = false, + ) + .copy(id = GeoLocationId.Country("se")) + ), + ), + searchTerm = "", + saveEnabled = true, + hasUnsavedChanges = true, + ) ), - searchTerm = "", - saveEnabled = true, - hasUnsavedChanges = true, ), - CustomListLocationsUiState.Content.Empty(newList = false, searchTerm = "searchTerm"), - CustomListLocationsUiState.Loading(newList = true), + CustomListLocationsUiState(newList = false, content = Lce.Error(Unit)), + CustomListLocationsUiState(newList = false, content = Lce.Loading(Unit)), ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ManageDevicesUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ManageDevicesUiStatePreviewParameterProvider.kt index 41c9d14205..37cc988118 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ManageDevicesUiStatePreviewParameterProvider.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ManageDevicesUiStatePreviewParameterProvider.kt @@ -8,7 +8,7 @@ import net.mullvad.mullvadvpn.lib.model.GetDeviceListError import net.mullvad.mullvadvpn.util.Lce class ManageDevicesUiStatePreviewParameterProvider : - PreviewParameterProvider<Lce<ManageDevicesUiState, GetDeviceListError>> { + PreviewParameterProvider<Lce<Unit, ManageDevicesUiState, GetDeviceListError>> { override val values = sequenceOf( Lce.Content( @@ -24,7 +24,7 @@ class ManageDevicesUiStatePreviewParameterProvider : ) ), Lce.Content(ManageDevicesUiState(emptyList())), - Lce.Loading, + Lce.Loading(Unit), Lce.Error(GetDeviceListError.Unknown(IllegalStateException("Error"))), ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayListItemPreviewData.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayListItemPreviewData.kt index 2c695764d7..58bea4810e 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayListItemPreviewData.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayListItemPreviewData.kt @@ -49,14 +49,14 @@ object RelayListItemPreviewData { val locations = listOf( RelayItemPreviewData.generateRelayItemCountry( - name = "A relay", - cityNames = listOf("City 1", "City 2"), + name = "First Country", + cityNames = listOf("Capital City", "Minor City"), relaysPerCity = 2, active = true, ), RelayItemPreviewData.generateRelayItemCountry( - name = "Another relay", - cityNames = listOf("City X", "City Y", "City Z"), + name = "Second Country", + cityNames = listOf("Medium City", "Small City", "Vivec City"), relaysPerCity = 1, active = false, ), @@ -92,7 +92,7 @@ object RelayListItemPreviewData { state = RelayListItemState.USED_AS_EXIT, ), RelayListItem.GeoLocationItem( - item = locations[0].cities[1].relays[0], + item = locations[0].cities[1].relays[1], isSelected = false, depth = 2, expanded = false, @@ -109,5 +109,12 @@ object RelayListItemPreviewData { ) } - fun generateEmptyList(searchTerm: String) = listOf(RelayListItem.LocationsEmptyText(searchTerm)) + fun generateEmptyList(searchTerm: String, isSearching: Boolean) = + listOf( + if (isSearching) { + RelayListItem.LocationsEmptyText(searchTerm) + } else { + RelayListItem.EmptyRelayList + } + ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SearchLocationsUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SearchLocationsUiStatePreviewParameterProvider.kt index ebed8d229f..50cf464bb5 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SearchLocationsUiStatePreviewParameterProvider.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SearchLocationsUiStatePreviewParameterProvider.kt @@ -3,27 +3,46 @@ package net.mullvad.mullvadvpn.compose.preview import androidx.compose.ui.tooling.preview.PreviewParameterProvider import net.mullvad.mullvadvpn.compose.state.SearchLocationUiState import net.mullvad.mullvadvpn.usecase.FilterChip +import net.mullvad.mullvadvpn.util.Lce class SearchLocationsUiStatePreviewParameterProvider : - PreviewParameterProvider<SearchLocationUiState> { + PreviewParameterProvider<Lce<Unit, SearchLocationUiState, Unit>> { override val values = sequenceOf( - SearchLocationUiState.NoQuery(searchTerm = "", filterChips = listOf(FilterChip.Entry)), - SearchLocationUiState.Content( - searchTerm = "Mullvad", - filterChips = listOf(FilterChip.Entry), - relayListItems = RelayListItemPreviewData.generateEmptyList("Mullvad"), - customLists = emptyList(), + Lce.Loading(Unit), + Lce.Content( + SearchLocationUiState( + searchTerm = "", + filterChips = listOf(FilterChip.Entry), + relayListItems = + RelayListItemPreviewData.generateRelayListItems( + includeCustomLists = true, + isSearching = true, + ), + customLists = emptyList(), + ) ), - SearchLocationUiState.Content( - searchTerm = "Germany", - filterChips = listOf(FilterChip.Entry), - relayListItems = - RelayListItemPreviewData.generateRelayListItems( - includeCustomLists = true, - isSearching = true, - ), - customLists = emptyList(), + Lce.Error(Unit), + Lce.Content( + SearchLocationUiState( + searchTerm = "Mullvad", + filterChips = listOf(FilterChip.Entry), + relayListItems = + RelayListItemPreviewData.generateEmptyList("Mullvad", isSearching = true), + customLists = emptyList(), + ) + ), + Lce.Content( + SearchLocationUiState( + searchTerm = "Germany", + filterChips = listOf(FilterChip.Entry), + relayListItems = + RelayListItemPreviewData.generateRelayListItems( + includeCustomLists = true, + isSearching = true, + ), + customLists = emptyList(), + ) ), ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SelectLocationsUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SelectLocationsUiStatePreviewParameterProvider.kt index 965999c7f1..99bb6993ef 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SelectLocationsUiStatePreviewParameterProvider.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SelectLocationsUiStatePreviewParameterProvider.kt @@ -5,39 +5,52 @@ import net.mullvad.mullvadvpn.compose.state.RelayListType import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState import net.mullvad.mullvadvpn.usecase.FilterChip import net.mullvad.mullvadvpn.usecase.ModelOwnership +import net.mullvad.mullvadvpn.util.Lc class SelectLocationsUiStatePreviewParameterProvider : - PreviewParameterProvider<SelectLocationUiState> { + PreviewParameterProvider<Lc<Unit, SelectLocationUiState>> { override val values = sequenceOf( - SelectLocationUiState.Loading, - SelectLocationUiState.Data( - filterChips = emptyList(), - multihopEnabled = false, - relayListType = RelayListType.EXIT, + Lc.Loading(Unit), + Lc.Content( + SelectLocationUiState( + filterChips = emptyList(), + multihopEnabled = false, + relayListType = RelayListType.EXIT, + isTopBarActionsEnabled = true, + ) ), - SelectLocationUiState.Data( - filterChips = - listOf( - FilterChip.Ownership(ownership = ModelOwnership.Rented), - FilterChip.Provider(PROVIDER_COUNT), - ), - multihopEnabled = false, - relayListType = RelayListType.EXIT, + Lc.Content( + SelectLocationUiState( + filterChips = + listOf( + FilterChip.Ownership(ownership = ModelOwnership.Rented), + FilterChip.Provider(PROVIDER_COUNT), + ), + multihopEnabled = false, + relayListType = RelayListType.EXIT, + isTopBarActionsEnabled = true, + ) ), - SelectLocationUiState.Data( - filterChips = emptyList(), - multihopEnabled = true, - relayListType = RelayListType.ENTRY, + Lc.Content( + SelectLocationUiState( + filterChips = emptyList(), + multihopEnabled = true, + relayListType = RelayListType.ENTRY, + isTopBarActionsEnabled = true, + ) ), - SelectLocationUiState.Data( - filterChips = - listOf( - FilterChip.Ownership(ownership = ModelOwnership.MullvadOwned), - FilterChip.Provider(PROVIDER_COUNT), - ), - multihopEnabled = true, - relayListType = RelayListType.ENTRY, + Lc.Content( + SelectLocationUiState( + filterChips = + listOf( + FilterChip.Ownership(ownership = ModelOwnership.MullvadOwned), + FilterChip.Provider(PROVIDER_COUNT), + ), + multihopEnabled = true, + relayListType = RelayListType.ENTRY, + isTopBarActionsEnabled = true, + ) ), ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreen.kt index a8b1194f3f..5e302c6990 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreen.kt @@ -1,5 +1,6 @@ package net.mullvad.mullvadvpn.compose.screen +import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -34,6 +35,7 @@ import com.ramcosta.composedestinations.result.ResultRecipient import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.cell.CheckableRelayLocationCell import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData +import net.mullvad.mullvadvpn.compose.component.EmptyRelayListText import net.mullvad.mullvadvpn.compose.component.LocationsEmptyText import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton @@ -44,6 +46,7 @@ import net.mullvad.mullvadvpn.compose.constant.ContentType import net.mullvad.mullvadvpn.compose.dialog.info.Confirmed import net.mullvad.mullvadvpn.compose.extensions.animateScrollAndCentralizeItem import net.mullvad.mullvadvpn.compose.preview.CustomListLocationUiStatePreviewParameterProvider +import net.mullvad.mullvadvpn.compose.state.CustomListLocationsData import net.mullvad.mullvadvpn.compose.state.CustomListLocationsUiState import net.mullvad.mullvadvpn.compose.textfield.SearchTextField import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition @@ -55,6 +58,7 @@ import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar import net.mullvad.mullvadvpn.lib.ui.tag.SAVE_BUTTON_TEST_TAG +import net.mullvad.mullvadvpn.util.Lce import net.mullvad.mullvadvpn.viewmodel.CustomListLocationsSideEffect import net.mullvad.mullvadvpn.viewmodel.CustomListLocationsViewModel import org.koin.androidx.compose.koinViewModel @@ -100,7 +104,7 @@ fun CustomListLocations( onExpand = customListsViewModel::onExpand, onBackClick = dropUnlessResumed { - if (state.hasUnsavedChanges) { + if (state.content.contentOrNull()?.hasUnsavedChanges == true) { navigator.navigate(DiscardChangesDestination) } else { backNavigator.navigateBack() @@ -118,6 +122,8 @@ fun CustomListLocationsScreen( onExpand: (RelayItem.Location, selected: Boolean) -> Unit, onBackClick: () -> Unit, ) { + BackHandler(onBack = onBackClick) + ScaffoldWithSmallTopBar( appBarTitle = stringResource( @@ -128,7 +134,12 @@ fun CustomListLocationsScreen( } ), navigationIcon = { NavigateBackIconButton(onNavigateBack = onBackClick) }, - actions = { Actions(isSaveEnabled = state.saveEnabled, onSaveClick = onSaveClick) }, + actions = { + Actions( + isSaveEnabled = state.content.contentOrNull()?.saveEnabled == true, + onSaveClick = onSaveClick, + ) + }, ) { modifier -> Column(modifier = modifier) { SearchTextField( @@ -154,16 +165,16 @@ fun CustomListLocationsScreen( .fillMaxWidth(), state = lazyListState, ) { - when (state) { - is CustomListLocationsUiState.Loading -> { + when (state.content) { + is Lce.Loading -> { loading() } - is CustomListLocationsUiState.Content.Empty -> { - empty(searchTerm = state.searchTerm) + is Lce.Error -> { + empty() } - is CustomListLocationsUiState.Content.Data -> { + is Lce.Content -> { content( - uiState = state, + uiState = state.content.value, onRelaySelectedChanged = onRelaySelectionClick, onExpand = onExpand, ) @@ -171,8 +182,8 @@ fun CustomListLocationsScreen( } } - if (state is CustomListLocationsUiState.Content.Data && !state.newList) { - val firstChecked = state.locations.indexOfFirst { it.checked } + if (state.content is Lce.Content && !state.newList) { + val firstChecked = state.content.value.locations.indexOfFirst { it.checked } LaunchedEffect(Unit) { if (firstChecked != -1) { lazyListState.scrollToItem(firstChecked) @@ -204,33 +215,48 @@ private fun LazyListScope.loading() { } } -private fun LazyListScope.empty(searchTerm: String) { +private fun LazyListScope.empty() { item(key = CommonContentKey.EMPTY, contentType = ContentType.EMPTY_TEXT) { - LocationsEmptyText(searchTerm = searchTerm) + EmptyRelayListText() } } private fun LazyListScope.content( - uiState: CustomListLocationsUiState.Content.Data, + uiState: CustomListLocationsData, onExpand: (RelayItem.Location, expand: Boolean) -> Unit, onRelaySelectedChanged: (RelayItem.Location, selected: Boolean) -> Unit, ) { - itemsIndexed(uiState.locations, key = { index, listItem -> listItem.item.id }) { index, listItem - -> - Column(modifier = Modifier.animateItem()) { - if (index != 0) { - HorizontalDivider() + if (uiState.locations.isEmpty()) { + item(key = CommonContentKey.EMPTY, contentType = ContentType.EMPTY_TEXT) { + LocationsEmptyText(searchTerm = uiState.searchTerm) + } + } else { + itemsIndexed(uiState.locations, key = { index, listItem -> listItem.item.id }) { + index, + listItem -> + Column(modifier = Modifier.animateItem()) { + if (index != 0) { + HorizontalDivider() + } + CheckableRelayLocationCell( + item = listItem.item, + onRelayCheckedChange = { isChecked -> + onRelaySelectedChanged(listItem.item, isChecked) + }, + checked = listItem.checked, + depth = listItem.depth, + onExpand = { expand -> onExpand(listItem.item, expand) }, + expanded = listItem.expanded, + ) } - CheckableRelayLocationCell( - item = listItem.item, - onRelayCheckedChange = { isChecked -> - onRelaySelectedChanged(listItem.item, isChecked) - }, - checked = listItem.checked, - depth = listItem.depth, - onExpand = { expand -> onExpand(listItem.item, expand) }, - expanded = listItem.expanded, - ) } } } + +private fun Lce<Boolean, CustomListLocationsUiState, Boolean>.newList(): Boolean { + return when (this) { + is Lce.Content -> this.value.newList + is Lce.Loading -> this.value + is Lce.Error -> this.error + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ManageDevicesScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ManageDevicesScreen.kt index d6a4e38256..dc36921b9a 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ManageDevicesScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ManageDevicesScreen.kt @@ -54,12 +54,12 @@ import org.koin.androidx.compose.koinViewModel @Preview("Normal|TooMany|Empty|Loading|Error") private fun PreviewDeviceListScreenContent( @PreviewParameter(ManageDevicesUiStatePreviewParameterProvider::class) - state: Lce<ManageDevicesUiState, GetDeviceListError> + state: Lce<Unit, ManageDevicesUiState, GetDeviceListError> ) { AppTheme { ManageDevicesScreen(state = state, SnackbarHostState(), {}, {}, {}) } } -private typealias StateLce = Lce<ManageDevicesUiState, GetDeviceListError> +private typealias StateLce = Lce<Unit, ManageDevicesUiState, GetDeviceListError> @Destination<RootGraph>(style = DefaultTransition::class, navArgs = DeviceListNavArgs::class) @Composable @@ -121,7 +121,7 @@ fun ManageDevicesScreen( is Lce.Content -> Content(modifier, state.value, navigateToRemoveDeviceConfirmationDialog) is Lce.Error -> Error(modifier, onTryAgainClicked) - Lce.Loading -> Loading(modifier) + is Lce.Loading -> Loading(modifier) } } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/RelayListContent.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/RelayListContent.kt index 249093329e..0b5617fc7e 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/RelayListContent.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/RelayListContent.kt @@ -17,6 +17,7 @@ import net.mullvad.mullvadvpn.compose.cell.HeaderCell import net.mullvad.mullvadvpn.compose.cell.StatusRelayItemCell import net.mullvad.mullvadvpn.compose.cell.SwitchComposeSubtitleCell import net.mullvad.mullvadvpn.compose.cell.ThreeDotCell +import net.mullvad.mullvadvpn.compose.component.EmptyRelayListText import net.mullvad.mullvadvpn.compose.component.LocationsEmptyText import net.mullvad.mullvadvpn.compose.screen.location.LocationBottomSheetState.ShowCustomListsBottomSheet import net.mullvad.mullvadvpn.compose.screen.location.LocationBottomSheetState.ShowCustomListsEntryBottomSheet @@ -102,6 +103,7 @@ fun LazyListScope.relayListContent( { expand -> onToggleExpand(listItem.item.id, null, expand) }, ) is RelayListItem.LocationsEmptyText -> LocationsEmptyText(listItem.searchTerm) + is RelayListItem.EmptyRelayList -> EmptyRelayListText() } } }, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SearchLocationScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SearchLocationScreen.kt index be5aa0d306..785739dc61 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SearchLocationScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SearchLocationScreen.kt @@ -37,7 +37,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -56,6 +55,8 @@ import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.cell.FilterRow import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData +import net.mullvad.mullvadvpn.compose.component.EmptyRelayListText +import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge import net.mullvad.mullvadvpn.compose.component.MullvadSnackbar import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar import net.mullvad.mullvadvpn.compose.constant.ContentType @@ -73,15 +74,16 @@ import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar import net.mullvad.mullvadvpn.usecase.FilterChip +import net.mullvad.mullvadvpn.util.Lce import net.mullvad.mullvadvpn.viewmodel.location.SearchLocationSideEffect import net.mullvad.mullvadvpn.viewmodel.location.SearchLocationViewModel import org.koin.androidx.compose.koinViewModel -@Preview("Default|Not found|Results") +@Preview("Loading|Default|No Locations|Not found|Results") @Composable private fun PreviewSearchLocationScreen( @PreviewParameter(SearchLocationsUiStatePreviewParameterProvider::class) - state: SearchLocationUiState + state: Lce<Unit, SearchLocationUiState, Unit> ) { AppTheme { SearchLocationScreen( @@ -218,7 +220,7 @@ fun SearchLocation( @OptIn(ExperimentalMaterial3Api::class) @Composable fun SearchLocationScreen( - state: SearchLocationUiState, + state: Lce<Unit, SearchLocationUiState, Unit>, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, onSelectRelay: (RelayItem) -> Unit, onToggleExpand: (RelayItemId, CustomListId?, Boolean) -> Unit, @@ -262,7 +264,8 @@ fun SearchLocationScreen( LaunchedEffect(Unit) { focusRequester.requestFocus() } SearchBar( modifier = Modifier.focusRequester(focusRequester), - searchTerm = state.searchTerm, + searchTerm = state.contentOrNull()?.searchTerm ?: "", + enabled = state is Lce.Content, backgroundColor = backgroundColor, onBackgroundColor = onBackgroundColor, onSearchInputChanged = onSearchInputChanged, @@ -283,20 +286,24 @@ fun SearchLocationScreen( horizontalAlignment = Alignment.CenterHorizontally, ) { filterRow( - filters = state.filterChips, + filters = state.contentOrNull()?.filterChips ?: emptyList(), onBackgroundColor = onBackgroundColor, onRemoveOwnershipFilter = onRemoveOwnershipFilter, onRemoveProviderFilter = onRemoveProviderFilter, ) when (state) { - is SearchLocationUiState.NoQuery -> { - noQuery() + is Lce.Loading -> { + loading() } - is SearchLocationUiState.Content -> { + is Lce.Error -> { + // Relay list is empty + item { EmptyRelayListText() } + } + is Lce.Content -> { relayListContent( backgroundColor = backgroundColor, - customLists = state.customLists, - relayListItems = state.relayListItems, + customLists = state.value.customLists, + relayListItems = state.value.relayListItems, onSelectRelay = onSelectRelay, onToggleExpand = onToggleExpand, onUpdateBottomSheetState = { newSheetState -> @@ -326,6 +333,7 @@ fun SearchLocationScreen( @Composable private fun SearchBar( searchTerm: String, + enabled: Boolean, backgroundColor: Color, onBackgroundColor: Color, onSearchInputChanged: (String) -> Unit, @@ -336,6 +344,7 @@ private fun SearchBar( SearchBarDefaults.InputField( modifier = modifier.height(Dimens.searchFieldHeightExpanded).fillMaxWidth(), query = searchTerm, + enabled = enabled, onQueryChange = onSearchInputChanged, onSearch = { hideKeyboard() }, expanded = true, @@ -376,18 +385,6 @@ private fun SearchBar( ) } -private fun LazyListScope.noQuery() { - item(contentType = ContentType.DESCRIPTION) { - Text( - text = stringResource(R.string.search_query_empty), - style = MaterialTheme.typography.labelMedium, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(Dimens.mediumPadding), - ) - } -} - private fun LazyListScope.filterRow( filters: List<FilterChip>, onBackgroundColor: Color, @@ -420,3 +417,7 @@ private fun Title(text: String, onBackgroundColor: Color) { style = MaterialTheme.typography.labelMedium, ) } + +private fun LazyListScope.loading() { + item(contentType = ContentType.PROGRESS) { MullvadCircularProgressIndicatorLarge() } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationList.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationList.kt index 33113b03c9..d42177dbac 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationList.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationList.kt @@ -33,10 +33,15 @@ import net.mullvad.mullvadvpn.compose.util.RunOnKeyChange import net.mullvad.mullvadvpn.lib.model.RelayItem import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar +import net.mullvad.mullvadvpn.util.Lce import net.mullvad.mullvadvpn.viewmodel.location.SelectLocationListViewModel import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf +private typealias EntryBlocked = Lce.Error<Unit> + +private typealias Content = Lce.Content<SelectLocationListUiState> + @Composable fun SelectLocationList( backgroundColor: Color, @@ -53,7 +58,7 @@ fun SelectLocationList( val state by viewModel.uiState.collectAsStateWithLifecycle() val lazyListState = rememberLazyListState() val stateActual = state - RunOnKeyChange(stateActual is SelectLocationListUiState.Content) { + RunOnKeyChange(stateActual is Content) { stateActual.indexOfSelectedRelayItem()?.let { index -> lazyListState.scrollToItem(index) lazyListState.animateScrollAndCentralizeItem(index) @@ -69,24 +74,24 @@ fun SelectLocationList( state = lazyListState, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = - if (state is SelectLocationListUiState.EntryBlocked) { + if (state is EntryBlocked) { Arrangement.Center } else { Arrangement.Top }, ) { when (stateActual) { - SelectLocationListUiState.Loading -> { + is Lce.Loading -> { loading() } - SelectLocationListUiState.EntryBlocked -> { + is EntryBlocked -> { entryBlocked(openDaitaSettings = openDaitaSettings) } - is SelectLocationListUiState.Content -> { + is Content -> { relayListContent( backgroundColor = backgroundColor, - relayListItems = stateActual.relayListItems, - customLists = stateActual.customLists, + relayListItems = stateActual.value.relayListItems, + customLists = stateActual.value.customLists, onSelectRelay = onSelectRelay, onToggleExpand = viewModel::onToggleExpand, onUpdateBottomSheetState = onUpdateBottomSheetState, @@ -129,10 +134,10 @@ private fun LazyListScope.entryBlocked(openDaitaSettings: () -> Unit) { } } -private fun SelectLocationListUiState.indexOfSelectedRelayItem(): Int? = - if (this is SelectLocationListUiState.Content) { +private fun Lce<Unit, SelectLocationListUiState, Unit>.indexOfSelectedRelayItem(): Int? = + if (this is Content) { val index = - relayListItems.indexOfFirst { + value.relayListItems.indexOfFirst { when (it) { is RelayListItem.CustomListItem -> it.isSelected is RelayListItem.GeoLocationItem -> it.isSelected @@ -140,7 +145,8 @@ private fun SelectLocationListUiState.indexOfSelectedRelayItem(): Int? = is RelayListItem.CustomListFooter, RelayListItem.CustomListHeader, RelayListItem.LocationHeader, - is RelayListItem.LocationsEmptyText -> false + is RelayListItem.LocationsEmptyText, + is RelayListItem.EmptyRelayList -> false } } if (index >= 0) index else null diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreen.kt index b41a95d64e..d6d3742162 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreen.kt @@ -73,7 +73,10 @@ import net.mullvad.mullvadvpn.lib.model.CustomListId import net.mullvad.mullvadvpn.lib.model.RelayItem import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.lib.theme.color.AlphaDisabled +import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible import net.mullvad.mullvadvpn.lib.ui.tag.SELECT_LOCATION_SCREEN_TEST_TAG +import net.mullvad.mullvadvpn.util.Lc import net.mullvad.mullvadvpn.viewmodel.location.SelectLocationSideEffect import net.mullvad.mullvadvpn.viewmodel.location.SelectLocationViewModel import org.koin.androidx.compose.koinViewModel @@ -82,7 +85,7 @@ import org.koin.androidx.compose.koinViewModel @Composable private fun PreviewSelectLocationScreen( @PreviewParameter(SelectLocationsUiStatePreviewParameterProvider::class) - state: SelectLocationUiState + state: Lc<Unit, SelectLocationUiState> ) { AppTheme { SelectLocationScreen( @@ -227,7 +230,7 @@ fun SelectLocation( @Suppress("LongMethod", "LongParameterList") @Composable fun SelectLocationScreen( - state: SelectLocationUiState, + state: Lc<Unit, SelectLocationUiState>, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, onSelectRelay: (item: RelayItem) -> Unit, onSearchClick: (RelayListType) -> Unit, @@ -261,23 +264,28 @@ fun SelectLocationScreen( modifier = Modifier.testTag(SELECT_LOCATION_SCREEN_TEST_TAG), snackbarHostState = snackbarHostState, actions = { + val isTopBarActionsEnabled = state.contentOrNull()?.isTopBarActionsEnabled == true IconButton( - enabled = state is SelectLocationUiState.Data, - onClick = { - if (state is SelectLocationUiState.Data) onSearchClick(state.relayListType) - }, + enabled = isTopBarActionsEnabled, + onClick = { state.contentOrNull()?.let { onSearchClick(it.relayListType) } }, ) { Icon( imageVector = Icons.Default.Search, contentDescription = stringResource(id = R.string.search), - tint = MaterialTheme.colorScheme.onSurface, + tint = + MaterialTheme.colorScheme.onSurface.copy( + alpha = if (isTopBarActionsEnabled) AlphaVisible else AlphaDisabled + ), ) } - IconButton(enabled = state is SelectLocationUiState.Data, onClick = onFilterClick) { + IconButton(enabled = isTopBarActionsEnabled, onClick = onFilterClick) { Icon( imageVector = Icons.Default.FilterList, contentDescription = stringResource(id = R.string.filter), - tint = MaterialTheme.colorScheme.onSurface, + tint = + MaterialTheme.colorScheme.onSurface.copy( + alpha = if (isTopBarActionsEnabled) AlphaVisible else AlphaDisabled + ), ) } }, @@ -299,17 +307,17 @@ fun SelectLocationScreen( modifier = modifier.background(backgroundColor).fillMaxSize(), verticalArrangement = when (state) { - SelectLocationUiState.Loading -> Arrangement.Center - is SelectLocationUiState.Data -> Arrangement.Top + is Lc.Loading -> Arrangement.Center + is Lc.Content -> Arrangement.Top }, ) { when (state) { - SelectLocationUiState.Loading -> { + is Lc.Loading -> { Loading() } - is SelectLocationUiState.Data -> { + is Lc.Content -> { AnimatedContent( - targetState = state.filterChips, + targetState = state.value.filterChips, label = "Select location top bar", ) { filterChips -> if (filterChips.isNotEmpty()) { @@ -321,16 +329,16 @@ fun SelectLocationScreen( } } - if (state.multihopEnabled) { - MultihopBar(state.relayListType, onSelectRelayList) + if (state.value.multihopEnabled) { + MultihopBar(state.value.relayListType, onSelectRelayList) } - if (state.filterChips.isNotEmpty() || state.multihopEnabled) { + if (state.value.filterChips.isNotEmpty() || state.value.multihopEnabled) { Spacer(modifier = Modifier.height(height = Dimens.verticalSpace)) } RelayLists( - state = state, + state = state.value, backgroundColor = backgroundColor, onSelectRelay = onSelectRelay, openDaitaSettings = openDaitaSettings, @@ -365,7 +373,7 @@ private fun MultihopBar(relayListType: RelayListType, onSelectRelayList: (RelayL @Composable private fun RelayLists( - state: SelectLocationUiState.Data, + state: SelectLocationUiState, backgroundColor: Color, onSelectRelay: (RelayItem) -> Unit, openDaitaSettings: () -> Unit, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListLocationsUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListLocationsUiState.kt index 8a3c5c756b..9f8eae8c53 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListLocationsUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListLocationsUiState.kt @@ -1,34 +1,19 @@ package net.mullvad.mullvadvpn.compose.state import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.util.Lce -sealed interface CustomListLocationsUiState { - val newList: Boolean - val saveEnabled: Boolean - val hasUnsavedChanges: Boolean - - data class Loading(override val newList: Boolean = false) : CustomListLocationsUiState { - override val saveEnabled: Boolean = false - override val hasUnsavedChanges: Boolean = false - } - - sealed interface Content : CustomListLocationsUiState { - val searchTerm: String - - data class Empty(override val newList: Boolean, override val searchTerm: String) : Content { - override val saveEnabled: Boolean = false - override val hasUnsavedChanges: Boolean = false - } +data class CustomListLocationsUiState( + val newList: Boolean, + val content: Lce<Unit, CustomListLocationsData, Unit>, +) - data class Data( - override val newList: Boolean = false, - val locations: List<RelayLocationListItem>, - override val searchTerm: String = "", - override val saveEnabled: Boolean = false, - override val hasUnsavedChanges: Boolean = false, - ) : Content - } -} +data class CustomListLocationsData( + val saveEnabled: Boolean, + val hasUnsavedChanges: Boolean, + val searchTerm: String, + val locations: List<RelayLocationListItem>, +) data class RelayLocationListItem( val item: RelayItem.Location, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayListItem.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayListItem.kt index 34fd369526..028912f15d 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayListItem.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayListItem.kt @@ -12,6 +12,7 @@ enum class RelayListItemContentType { LOCATION_HEADER, LOCATION_ITEM, LOCATIONS_EMPTY_TEXT, + EMPTY_RELAY_LIST, } enum class RelayListItemState { @@ -86,4 +87,9 @@ sealed interface RelayListItem { override val key = "locations_empty_text" override val contentType = RelayListItemContentType.LOCATIONS_EMPTY_TEXT } + + data object EmptyRelayList : RelayListItem { + override val key = "empty_relay_list" + override val contentType = RelayListItemContentType.EMPTY_RELAY_LIST + } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SearchLocationUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SearchLocationUiState.kt index fd35213dac..c377be8814 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SearchLocationUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SearchLocationUiState.kt @@ -3,19 +3,9 @@ package net.mullvad.mullvadvpn.compose.state import net.mullvad.mullvadvpn.lib.model.RelayItem import net.mullvad.mullvadvpn.usecase.FilterChip -sealed interface SearchLocationUiState { - val searchTerm: String - val filterChips: List<FilterChip> - - data class NoQuery( - override val searchTerm: String, - override val filterChips: List<FilterChip>, - ) : SearchLocationUiState - - data class Content( - override val searchTerm: String, - override val filterChips: List<FilterChip>, - val relayListItems: List<RelayListItem>, - val customLists: List<RelayItem.CustomList>, - ) : SearchLocationUiState -} +data class SearchLocationUiState( + val searchTerm: String, + val filterChips: List<FilterChip>, + val relayListItems: List<RelayListItem>, + val customLists: List<RelayItem.CustomList>, +) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationListUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationListUiState.kt index d470187bcf..393286a35e 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationListUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationListUiState.kt @@ -2,14 +2,7 @@ package net.mullvad.mullvadvpn.compose.state import net.mullvad.mullvadvpn.lib.model.RelayItem -sealed interface SelectLocationListUiState { - - data object Loading : SelectLocationListUiState - - data object EntryBlocked : SelectLocationListUiState - - data class Content( - val relayListItems: List<RelayListItem>, - val customLists: List<RelayItem.CustomList>, - ) : SelectLocationListUiState -} +data class SelectLocationListUiState( + val relayListItems: List<RelayListItem>, + val customLists: List<RelayItem.CustomList>, +) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt index fd2abab8c4..6f986f2916 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt @@ -2,12 +2,9 @@ package net.mullvad.mullvadvpn.compose.state import net.mullvad.mullvadvpn.usecase.FilterChip -sealed interface SelectLocationUiState { - data object Loading : SelectLocationUiState - - data class Data( - val filterChips: List<FilterChip>, - val multihopEnabled: Boolean, - val relayListType: RelayListType, - ) : SelectLocationUiState -} +data class SelectLocationUiState( + val filterChips: List<FilterChip>, + val multihopEnabled: Boolean, + val relayListType: RelayListType, + val isTopBarActionsEnabled: Boolean, +) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/CustomListExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/CustomListExtensions.kt index ac03080e21..fac0e77321 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/CustomListExtensions.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/CustomListExtensions.kt @@ -13,7 +13,7 @@ fun CustomList.toRelayItemCustomList( ) fun List<RelayItem.CustomList>.filterOnSearchTerm(searchTerm: String) = - if (searchTerm.length >= MIN_SEARCH_LENGTH) { + if (searchTerm.isNotEmpty()) { this.filter { it.name.contains(searchTerm, ignoreCase = true) } } else { this diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListConstant.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListConstant.kt deleted file mode 100644 index 4fcc5c7902..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListConstant.kt +++ /dev/null @@ -1,3 +0,0 @@ -package net.mullvad.mullvadvpn.relaylist - -const val MIN_SEARCH_LENGTH = 2 diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/LoadingContentError.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/LoadingContentError.kt index c16aa9242f..d3823e1386 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/LoadingContentError.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/LoadingContentError.kt @@ -1,39 +1,25 @@ package net.mullvad.mullvadvpn.util -sealed interface Lce<out T, out E> { - data object Loading : Lce<Nothing, Nothing> +sealed interface Lce<out L, out T, out E> { + data class Loading<L>(val value: L) : Lce<L, Nothing, Nothing> - data class Content<T>(val value: T) : Lce<T, Nothing> + data class Content<T>(val value: T) : Lce<Nothing, T, Nothing> - data class Error<E>(val error: E) : Lce<Nothing, E> + data class Error<E>(val error: E) : Lce<Nothing, Nothing, E> - fun contentOrNull(): T? = - when (this) { - is Loading, - is Error -> null - is Content -> value - } + fun contentOrNull(): T? = (this as? Content<T>)?.value - fun errorOrNull(): E? = - when (this) { - is Loading, - is Content -> null - is Error -> error - } + fun errorOrNull(): E? = (this as? Error<E>)?.error } -fun <T, E> T.toLce(): Lce<T, E> = Lce.Content(this) +fun <L, T, E> T.toLce(): Lce<L, T, E> = Lce.Content(this) -sealed interface Lc<out T> { - data object Loading : Lc<Nothing> +sealed interface Lc<out L, out T> { + data class Loading<L>(val value: L) : Lc<L, Nothing> - data class Content<T>(val value: T) : Lc<T> + data class Content<T>(val value: T) : Lc<Nothing, T> - fun contentOrNull(): T? = - when (this) { - is Content -> value - Loading -> null - } + fun contentOrNull(): T? = (this as? Content<T>)?.value } -fun <T> T.toLc(): Lc<T> = Lc.Content(this) +fun <L, T> T.toLc(): Lc<L, T> = Lc.Content(this) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModel.kt index 320420369d..baadf379cb 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModel.kt @@ -12,8 +12,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update @@ -21,11 +19,11 @@ import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.compose.communication.CustomListAction import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData import net.mullvad.mullvadvpn.compose.communication.LocationsChanged +import net.mullvad.mullvadvpn.compose.state.CustomListLocationsData import net.mullvad.mullvadvpn.compose.state.CustomListLocationsUiState import net.mullvad.mullvadvpn.compose.state.RelayLocationListItem import net.mullvad.mullvadvpn.lib.model.RelayItem import net.mullvad.mullvadvpn.lib.model.RelayItemId -import net.mullvad.mullvadvpn.relaylist.MIN_SEARCH_LENGTH import net.mullvad.mullvadvpn.relaylist.ancestors import net.mullvad.mullvadvpn.relaylist.descendants import net.mullvad.mullvadvpn.relaylist.newFilterOnSearch @@ -33,7 +31,9 @@ import net.mullvad.mullvadvpn.relaylist.withDescendants import net.mullvad.mullvadvpn.repository.RelayListRepository import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase import net.mullvad.mullvadvpn.usecase.customlists.CustomListRelayItemsUseCase +import net.mullvad.mullvadvpn.util.Lce +@Suppress("TooManyFunctions") class CustomListLocationsViewModel( private val relayListRepository: RelayListRepository, private val customListRelayItemsUseCase: CustomListRelayItemsUseCase, @@ -48,62 +48,72 @@ class CustomListLocationsViewModel( private val _initialLocations = MutableStateFlow<Set<RelayItem.Location>>(emptySet()) private val _selectedLocations = MutableStateFlow<Set<RelayItem.Location>?>(null) private val _searchTerm = MutableStateFlow(EMPTY_SEARCH_TERM) - private val _expandedItems = MutableStateFlow<Set<RelayItemId>>(setOf()) + private val _expandOverrides = MutableStateFlow<Map<RelayItemId, Boolean>>(mapOf()) val uiState = - combine(searchRelayListLocations(), _searchTerm, _selectedLocations, _expandedItems) { - relayCountries, + combine(_searchTerm, relayListRepository.relayList, _selectedLocations, _expandOverrides) { searchTerm, + relayCountries, selectedLocations, - expandedLocations -> + expandOverrides -> when { selectedLocations == null -> - CustomListLocationsUiState.Loading(newList = navArgs.newList) + CustomListLocationsUiState( + newList = navArgs.newList, + content = Lce.Loading(Unit), + ) relayCountries.isEmpty() -> - CustomListLocationsUiState.Content.Empty( + CustomListLocationsUiState( newList = navArgs.newList, - searchTerm = searchTerm, + content = Lce.Error(Unit), ) - else -> - CustomListLocationsUiState.Content.Data( + else -> { + val (expandSet, filteredRelayCountries) = + searchRelayListLocations(searchTerm, relayCountries) + val expandedLocations = expandSet.with(expandOverrides) + CustomListLocationsUiState( newList = navArgs.newList, - searchTerm = searchTerm, - locations = - relayCountries.toRelayItems( - isSelected = { it in selectedLocations }, - isExpanded = { it in expandedLocations }, + content = + Lce.Content( + CustomListLocationsData( + searchTerm = searchTerm, + locations = + filteredRelayCountries.toRelayItems( + isSelected = { it in selectedLocations }, + isExpanded = { it in expandedLocations }, + ), + saveEnabled = + selectedLocations.isNotEmpty() && + selectedLocations != _initialLocations.value, + hasUnsavedChanges = + selectedLocations != _initialLocations.value, + ) ), - saveEnabled = - selectedLocations.isNotEmpty() && - selectedLocations != _initialLocations.value, - hasUnsavedChanges = selectedLocations != _initialLocations.value, ) + } } } .stateIn( viewModelScope, SharingStarted.WhileSubscribed(), - CustomListLocationsUiState.Loading(newList = navArgs.newList), + CustomListLocationsUiState(newList = navArgs.newList, content = Lce.Loading(Unit)), ) init { viewModelScope.launch { fetchInitialSelectedLocations() } } - private fun searchRelayListLocations() = - combine(_searchTerm, relayListRepository.relayList) { searchTerm, relayCountries -> - val isSearching = searchTerm.length >= MIN_SEARCH_LENGTH - if (isSearching) { - val (exp, filteredRelayCountries) = relayCountries.newFilterOnSearch(searchTerm) - exp.toSet() to filteredRelayCountries - } else { - initialExpands( - _selectedLocations.value?.calculateLocationsToSave() ?: emptyList() - ) to relayCountries - } - } - .onEach { _expandedItems.value = it.first } - .map { it.second } + private fun searchRelayListLocations( + searchTerm: String, + relayCountries: List<RelayItem.Location.Country>, + ) = + if (searchTerm.isNotEmpty()) { + val (exp, filteredRelayCountries) = relayCountries.newFilterOnSearch(searchTerm) + exp.toSet() to filteredRelayCountries + } else { + initialExpands(_selectedLocations.value?.calculateLocationsToSave() ?: emptyList()) to + relayCountries + } fun save() { viewModelScope.launch { @@ -138,17 +148,14 @@ class CustomListLocationsViewModel( } fun onExpand(relayItem: RelayItem.Location, expand: Boolean) { - _expandedItems.update { - if (expand) { - it + relayItem.id - } else { - it - relayItem.id - } - } + _expandOverrides.update { it + (relayItem.id to expand) } } fun onSearchTermInput(searchTerm: String) { - viewModelScope.launch { _searchTerm.emit(searchTerm) } + viewModelScope.launch { + _expandOverrides.emit(emptyMap()) + _searchTerm.emit(searchTerm) + } } private fun selectLocation(relayItem: RelayItem.Location) { @@ -227,7 +234,7 @@ class CustomListLocationsViewModel( _initialLocations.value = selectedLocations _selectedLocations.value = selectedLocations // Initial expand - _expandedItems.value = initialExpands(locations) + _expandOverrides.value = initialExpands(locations).associate { it to true } } private fun initialExpands(locations: List<RelayItem.Location>): Set<RelayItemId> = @@ -307,6 +314,10 @@ class CustomListLocationsViewModel( } } + private fun Set<RelayItemId>.with(overrides: Map<RelayItemId, Boolean>): Set<RelayItemId> = + this + overrides.filterValues { expanded -> expanded }.keys - + overrides.filterValues { expanded -> !expanded }.keys + companion object { private const val EMPTY_SEARCH_TERM = "" } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ManageDevicesViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ManageDevicesViewModel.kt index 53533264f1..b92fd9845b 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ManageDevicesViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ManageDevicesViewModel.kt @@ -31,13 +31,13 @@ class ManageDevicesViewModel( .filter { it is DeviceListSideEffect.FailedToRemoveDevice } .map { ManageDevicesSideEffect.FailedToRemoveDevice } - val uiState: StateFlow<Lce<ManageDevicesUiState, GetDeviceListError>> = + val uiState: StateFlow<Lce<Unit, ManageDevicesUiState, GetDeviceListError>> = combine( deviceRepository.deviceState.filterIsInstance<DeviceState.LoggedIn>(), deviceListViewModel.uiState, ) { loggedInState, deviceListState -> when (deviceListState) { - DeviceListUiState.Loading -> Lce.Loading + DeviceListUiState.Loading -> Lce.Loading(Unit) is DeviceListUiState.Error -> Lce.Error(deviceListState.error) is DeviceListUiState.Content -> { ManageDevicesUiState( @@ -49,7 +49,7 @@ class ManageDevicesViewModel( } } } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), Lce.Loading) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), Lce.Loading(Unit)) fun fetchDevices() = deviceListViewModel.fetchDevices() diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/Expand.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/Expand.kt index b517619e6b..726aa79674 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/Expand.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/Expand.kt @@ -5,7 +5,7 @@ import kotlinx.coroutines.flow.update import net.mullvad.mullvadvpn.lib.model.CustomListId import net.mullvad.mullvadvpn.lib.model.RelayItemId -internal fun MutableStateFlow<Set<String>>.onToggleExpand( +internal fun MutableStateFlow<Set<String>>.onToggleExpandSet( item: RelayItemId, parent: CustomListId? = null, expand: Boolean, @@ -19,3 +19,14 @@ internal fun MutableStateFlow<Set<String>>.onToggleExpand( } } } + +internal fun MutableStateFlow<Map<String, Boolean>>.onToggleExpandMap( + item: RelayItemId, + parent: CustomListId? = null, + expand: Boolean, +) { + update { + val key = item.expandKey(parent) + it + (key to expand) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/RelayItemListCreator.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/RelayItemListCreator.kt index 6bd1e31573..d80b8fc548 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/RelayItemListCreator.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/RelayItemListCreator.kt @@ -8,11 +8,29 @@ import net.mullvad.mullvadvpn.lib.model.GeoLocationId import net.mullvad.mullvadvpn.lib.model.RelayItem import net.mullvad.mullvadvpn.lib.model.RelayItemId import net.mullvad.mullvadvpn.lib.model.RelayItemSelection -import net.mullvad.mullvadvpn.relaylist.MIN_SEARCH_LENGTH import net.mullvad.mullvadvpn.relaylist.filterOnSearchTerm // Creates a relay list to be displayed by RelayListContent internal fun relayListItems( + relayListType: RelayListType, + relayCountries: List<RelayItem.Location.Country>, + customLists: List<RelayItem.CustomList>, + selectedByThisEntryExitList: RelayItemId?, + selectedByOtherEntryExitList: RelayItemId?, + expandedItems: Set<String>, +): List<RelayListItem> { + return createRelayListItems( + relayListType = relayListType, + selectedByThisEntryExitList = selectedByThisEntryExitList, + selectedByOtherEntryExitList = selectedByOtherEntryExitList, + customLists = customLists, + countries = relayCountries, + ) { + it in expandedItems + } +} + +internal fun relayListItemsSearching( searchTerm: String = "", relayListType: RelayListType, relayCountries: List<RelayItem.Location.Country>, @@ -23,28 +41,35 @@ internal fun relayListItems( ): List<RelayListItem> { val filteredCustomLists = customLists.filterOnSearchTerm(searchTerm) - return buildList { - val relayItems = - createRelayListItems( - isSearching = searchTerm.isSearching(), - relayListType = relayListType, - selectedByThisEntryExitList = selectedByThisEntryExitList, - selectedByOtherEntryExitList = selectedByOtherEntryExitList, - customLists = filteredCustomLists, - countries = relayCountries, - ) { - it in expandedItems - } - if (relayItems.isEmpty()) { - add(RelayListItem.LocationsEmptyText(searchTerm)) - } else { - addAll(relayItems) + return createRelayListItemsSearching( + relayListType = relayListType, + selectedByThisEntryExitList = selectedByThisEntryExitList, + selectedByOtherEntryExitList = selectedByOtherEntryExitList, + customLists = filteredCustomLists, + countries = relayCountries, + ) { + it in expandedItems } - } + .ifEmpty { listOf(RelayListItem.LocationsEmptyText(searchTerm)) } } +internal fun emptyLocationsRelayListItems( + relayListType: RelayListType, + customLists: List<RelayItem.CustomList>, + selectedByThisEntryExitList: RelayItemId?, + selectedByOtherEntryExitList: RelayItemId?, + expandedItems: Set<String>, +) = + createCustomListSection( + relayListType, + selectedByThisEntryExitList, + selectedByOtherEntryExitList, + customLists, + ) { + it in expandedItems + } + RelayListItem.LocationHeader + RelayListItem.EmptyRelayList + private fun createRelayListItems( - isSearching: Boolean, relayListType: RelayListType, selectedByThisEntryExitList: RelayItemId?, selectedByOtherEntryExitList: RelayItemId?, @@ -53,7 +78,6 @@ private fun createRelayListItems( isExpanded: (String) -> Boolean, ): List<RelayListItem> = createCustomListSection( - isSearching, relayListType, selectedByThisEntryExitList, selectedByOtherEntryExitList, @@ -61,7 +85,29 @@ private fun createRelayListItems( isExpanded, ) + createLocationSection( - isSearching, + selectedByThisEntryExitList, + relayListType, + selectedByOtherEntryExitList, + countries, + isExpanded, + ) + +private fun createRelayListItemsSearching( + relayListType: RelayListType, + selectedByThisEntryExitList: RelayItemId?, + selectedByOtherEntryExitList: RelayItemId?, + customLists: List<RelayItem.CustomList>, + countries: List<RelayItem.Location.Country>, + isExpanded: (String) -> Boolean, +): List<RelayListItem> = + createCustomListSectionSearching( + relayListType, + selectedByThisEntryExitList, + selectedByOtherEntryExitList, + customLists, + isExpanded, + ) + + createLocationSectionSearching( selectedByThisEntryExitList, relayListType, selectedByOtherEntryExitList, @@ -70,16 +116,33 @@ private fun createRelayListItems( ) private fun createCustomListSection( - isSearching: Boolean, relayListType: RelayListType, selectedByThisEntryExitList: RelayItemId?, selectedByOtherEntryExitList: RelayItemId?, customLists: List<RelayItem.CustomList>, isExpanded: (String) -> Boolean, ): List<RelayListItem> = buildList { - if (isSearching && customLists.isEmpty()) { - // If we are searching and no results are found don't show header or footer - } else { + add(RelayListItem.CustomListHeader) + val customListItems = + createCustomListRelayItems( + customLists, + relayListType, + selectedByThisEntryExitList, + selectedByOtherEntryExitList, + isExpanded, + ) + addAll(customListItems) + add(RelayListItem.CustomListFooter(customListItems.isNotEmpty())) +} + +private fun createCustomListSectionSearching( + relayListType: RelayListType, + selectedByThisEntryExitList: RelayItemId?, + selectedByOtherEntryExitList: RelayItemId?, + customLists: List<RelayItem.CustomList>, + isExpanded: (String) -> Boolean, +): List<RelayListItem> = buildList { + if (customLists.isNotEmpty()) { add(RelayListItem.CustomListHeader) val customListItems = createCustomListRelayItems( @@ -90,10 +153,6 @@ private fun createCustomListSection( isExpanded, ) addAll(customListItems) - // Do not show the footer in the search view - if (!isSearching) { - add(RelayListItem.CustomListFooter(customListItems.isNotEmpty())) - } } } @@ -138,16 +197,34 @@ private fun createCustomListRelayItems( } private fun createLocationSection( - isSearching: Boolean, selectedByThisEntryExitList: RelayItemId?, relayListType: RelayListType, selectedByOtherEntryExitList: RelayItemId?, countries: List<RelayItem.Location.Country>, isExpanded: (String) -> Boolean, ): List<RelayListItem> = buildList { - if (isSearching && countries.isEmpty()) { - // If we are searching and no results are found don't show header or footer - } else { + add(RelayListItem.LocationHeader) + addAll( + countries.flatMap { country -> + createGeoLocationEntry( + item = country, + selectedByThisEntryExitList = selectedByThisEntryExitList, + relayListType = relayListType, + selectedByOtherEntryExitList = selectedByOtherEntryExitList, + isExpanded = isExpanded, + ) + } + ) +} + +private fun createLocationSectionSearching( + selectedByThisEntryExitList: RelayItemId?, + relayListType: RelayListType, + selectedByOtherEntryExitList: RelayItemId?, + countries: List<RelayItem.Location.Country>, + isExpanded: (String) -> Boolean, +): List<RelayListItem> = buildList { + if (countries.isNotEmpty()) { add(RelayListItem.LocationHeader) addAll( countries.flatMap { country -> @@ -328,8 +405,6 @@ private fun RelayItemId?.singleRelayId(customLists: List<RelayItem.CustomList>): else -> null } -private fun String.isSearching() = length >= MIN_SEARCH_LENGTH - private fun RelayItem.createState( relayListType: RelayListType, selectedByOtherId: RelayItemId?, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModel.kt index 74cecbfdda..5310fe5ca8 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModel.kt @@ -9,8 +9,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -22,7 +20,6 @@ import net.mullvad.mullvadvpn.lib.model.Constraint import net.mullvad.mullvadvpn.lib.model.CustomListId import net.mullvad.mullvadvpn.lib.model.RelayItem import net.mullvad.mullvadvpn.lib.model.RelayItemId -import net.mullvad.mullvadvpn.relaylist.MIN_SEARCH_LENGTH import net.mullvad.mullvadvpn.relaylist.newFilterOnSearch import net.mullvad.mullvadvpn.repository.CustomListsRepository import net.mullvad.mullvadvpn.repository.RelayListFilterRepository @@ -35,17 +32,18 @@ import net.mullvad.mullvadvpn.usecase.SelectedLocationUseCase import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase import net.mullvad.mullvadvpn.usecase.customlists.CustomListsRelayItemUseCase import net.mullvad.mullvadvpn.usecase.customlists.FilterCustomListsRelayItemUseCase +import net.mullvad.mullvadvpn.util.Lce import net.mullvad.mullvadvpn.util.combine @Suppress("LongParameterList") class SearchLocationViewModel( private val wireguardConstraintsRepository: WireguardConstraintsRepository, private val relayListRepository: RelayListRepository, - private val filteredRelayListUseCase: FilteredRelayListUseCase, private val customListActionUseCase: CustomListActionUseCase, private val customListsRepository: CustomListsRepository, private val relayListFilterRepository: RelayListFilterRepository, private val filterChipUseCase: FilterChipUseCase, + filteredRelayListUseCase: FilteredRelayListUseCase, filteredCustomListRelayItemsUseCase: FilterCustomListsRelayItemUseCase, selectedLocationUseCase: SelectedLocationUseCase, customListsRelayItemUseCase: CustomListsRelayItemUseCase, @@ -56,17 +54,17 @@ class SearchLocationViewModel( SearchLocationDestination.argsFrom(savedStateHandle).relayListType private val _searchTerm = MutableStateFlow(EMPTY_SEARCH_TERM) - private val _expandedItems = MutableStateFlow<Set<String>>(emptySet()) + private val _expandOverrides = MutableStateFlow<Map<String, Boolean>>(emptyMap()) - val uiState: StateFlow<SearchLocationUiState> = + val uiState: StateFlow<Lce<Unit, SearchLocationUiState, Unit>> = combine( _searchTerm, - searchRelayListLocations(), + filteredRelayListUseCase(relayListType), filteredCustomListRelayItemsUseCase(relayListType = relayListType), customListsRelayItemUseCase(), selectedLocationUseCase(), filterChips(), - _expandedItems, + _expandOverrides, ) { searchTerm, relayCountries, @@ -74,14 +72,23 @@ class SearchLocationViewModel( customLists, selectedItem, filterChips, - expandedItems -> - if (searchTerm.length >= MIN_SEARCH_LENGTH) { - SearchLocationUiState.Content( + expandOverrides -> + if (relayCountries.isEmpty()) { + return@combine Lce.Error<Unit>(Unit) + } + val (expandSet, relayListLocations) = + searchRelayListLocations( + searchTerm = searchTerm, + relayCountries = relayCountries, + ) + val expandedItems = expandSet.with(expandOverrides) + Lce.Content( + SearchLocationUiState( searchTerm = searchTerm, relayListItems = - relayListItems( + relayListItemsSearching( searchTerm = searchTerm, - relayCountries = relayCountries, + relayCountries = relayListLocations, relayListType = relayListType, customLists = filteredCustomLists, selectedByThisEntryExitList = @@ -96,21 +103,18 @@ class SearchLocationViewModel( customLists = customLists, filterChips = filterChips, ) - } else { - SearchLocationUiState.NoQuery(searchTerm, filterChips) - } + ) } - .stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(), - SearchLocationUiState.NoQuery("", emptyList()), - ) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), Lce.Loading(Unit)) private val _uiSideEffect = Channel<SearchLocationSideEffect>() val uiSideEffect = _uiSideEffect.receiveAsFlow() fun onSearchInputUpdated(searchTerm: String) { - viewModelScope.launch { _searchTerm.emit(searchTerm) } + viewModelScope.launch { + _expandOverrides.emit(emptyMap()) + _searchTerm.emit(searchTerm) + } } fun selectRelay(relayItem: RelayItem) { @@ -128,14 +132,16 @@ class SearchLocationViewModel( } } - private fun searchRelayListLocations() = - combine(_searchTerm, filteredRelayListUseCase(relayListType)) { searchTerm, relayCountries - -> - val (exp, filteredRelayCountries) = relayCountries.newFilterOnSearch(searchTerm) - exp.map { it.expandKey() }.toSet() to filteredRelayCountries - } - .onEach { _expandedItems.value = it.first } - .map { it.second } + private fun searchRelayListLocations( + searchTerm: String, + relayCountries: List<RelayItem.Location.Country>, + ) = + if (searchTerm.isNotEmpty()) { + val (exp, filteredRelayCountries) = relayCountries.newFilterOnSearch(searchTerm) + exp.map { it.expandKey() }.toSet() to filteredRelayCountries + } else { + emptySet<String>() to relayCountries + } private fun filterChips() = combine( @@ -193,9 +199,13 @@ class SearchLocationViewModel( } fun onToggleExpand(item: RelayItemId, parent: CustomListId? = null, expand: Boolean) { - _expandedItems.onToggleExpand(item = item, parent = parent, expand = expand) + _expandOverrides.onToggleExpandMap(item = item, parent = parent, expand = expand) } + private fun Set<String>.with(overrides: Map<String, Boolean>): Set<String> = + this + overrides.filterValues { expanded -> expanded }.keys - + overrides.filterValues { expanded -> !expanded }.keys + companion object { private const val EMPTY_SEARCH_TERM = "" } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationListViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationListViewModel.kt index 46d8ac519d..846a56cdaf 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationListViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationListViewModel.kt @@ -20,6 +20,7 @@ import net.mullvad.mullvadvpn.usecase.FilteredRelayListUseCase import net.mullvad.mullvadvpn.usecase.SelectedLocationUseCase import net.mullvad.mullvadvpn.usecase.customlists.CustomListsRelayItemUseCase import net.mullvad.mullvadvpn.usecase.customlists.FilterCustomListsRelayItemUseCase +import net.mullvad.mullvadvpn.util.Lce class SelectLocationListViewModel( private val relayListType: RelayListType, @@ -34,25 +35,27 @@ class SelectLocationListViewModel( private val _expandedItems: MutableStateFlow<Set<String>> = MutableStateFlow(initialExpand(initialSelection())) - val uiState: StateFlow<SelectLocationListUiState> = + val uiState: StateFlow<Lce<Unit, SelectLocationListUiState, Unit>> = combine( relayListItems(), customListsRelayItemUseCase(), settingsRepository.settingsUpdates, ) { relayListItems, customLists, settings -> if (relayListType == RelayListType.ENTRY && settings?.entryBlocked() == true) { - SelectLocationListUiState.EntryBlocked + Lce.Error(Unit) } else { - SelectLocationListUiState.Content( - relayListItems = relayListItems, - customLists = customLists, + Lce.Content( + SelectLocationListUiState( + relayListItems = relayListItems, + customLists = customLists, + ) ) } } - .stateIn(viewModelScope, SharingStarted.Lazily, SelectLocationListUiState.Loading) + .stateIn(viewModelScope, SharingStarted.Lazily, Lce.Loading(Unit)) fun onToggleExpand(item: RelayItemId, parent: CustomListId? = null, expand: Boolean) { - _expandedItems.onToggleExpand(item, parent, expand) + _expandedItems.onToggleExpandSet(item, parent, expand) } private fun relayListItems() = @@ -62,16 +65,30 @@ class SelectLocationListViewModel( selectedLocationUseCase(), _expandedItems, ) { relayCountries, customLists, selectedItem, expandedItems -> - relayListItems( - relayCountries = relayCountries, - relayListType = relayListType, - customLists = customLists, - selectedByThisEntryExitList = - selectedItem.selectedByThisEntryExitList(relayListType), - selectedByOtherEntryExitList = - selectedItem.selectedByOtherEntryExitList(relayListType, customLists), - expandedItems = expandedItems, - ) + // If we have no locations we have an empty relay list + // and we should show an error + if (relayCountries.isEmpty()) { + emptyLocationsRelayListItems( + relayListType = relayListType, + customLists = customLists, + selectedByThisEntryExitList = + selectedItem.selectedByThisEntryExitList(relayListType), + selectedByOtherEntryExitList = + selectedItem.selectedByOtherEntryExitList(relayListType, customLists), + expandedItems = expandedItems, + ) + } else { + relayListItems( + relayCountries = relayCountries, + relayListType = relayListType, + customLists = customLists, + selectedByThisEntryExitList = + selectedItem.selectedByThisEntryExitList(relayListType), + selectedByOtherEntryExitList = + selectedItem.selectedByOtherEntryExitList(relayListType, customLists), + expandedItems = expandedItems, + ) + } } private fun initialExpand(item: RelayItemId?): Set<String> = buildSet { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModel.kt index bf129044ad..f1a9240187 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModel.kt @@ -24,6 +24,7 @@ import net.mullvad.mullvadvpn.repository.RelayListRepository import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository import net.mullvad.mullvadvpn.usecase.FilterChipUseCase import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase +import net.mullvad.mullvadvpn.util.Lc @OptIn(ExperimentalCoroutinesApi::class) @Suppress("TooManyFunctions") @@ -43,14 +44,18 @@ class SelectLocationViewModel( filterChips(), wireguardConstraintsRepository.wireguardConstraints, _relayListType, - ) { filterChips, wireguardConstraints, relayListSelection -> - SelectLocationUiState.Data( - filterChips = filterChips, - multihopEnabled = wireguardConstraints?.isMultihopEnabled == true, - relayListType = relayListSelection, + relayListRepository.relayList, + ) { filterChips, wireguardConstraints, relayListSelection, relayList -> + Lc.Content( + SelectLocationUiState( + filterChips = filterChips, + multihopEnabled = wireguardConstraints?.isMultihopEnabled == true, + relayListType = relayListSelection, + isTopBarActionsEnabled = relayList.isNotEmpty(), + ) ) } - .stateIn(viewModelScope, SharingStarted.Lazily, SelectLocationUiState.Loading) + .stateIn(viewModelScope, SharingStarted.Lazily, Lc.Loading(Unit)) private val _uiSideEffect = Channel<SelectLocationSideEffect>() val uiSideEffect = _uiSideEffect.receiveAsFlow() diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModelTest.kt index 865b4ce471..edb4a9adfe 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModelTest.kt @@ -13,6 +13,7 @@ import net.mullvad.mullvadvpn.compose.communication.CustomListAction import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData import net.mullvad.mullvadvpn.compose.communication.LocationsChanged import net.mullvad.mullvadvpn.compose.screen.CustomListLocationsNavArgs +import net.mullvad.mullvadvpn.compose.state.CustomListLocationsData import net.mullvad.mullvadvpn.compose.state.CustomListLocationsUiState import net.mullvad.mullvadvpn.compose.state.RelayLocationListItem import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule @@ -29,6 +30,7 @@ import net.mullvad.mullvadvpn.relaylist.withDescendants import net.mullvad.mullvadvpn.repository.RelayListRepository import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase import net.mullvad.mullvadvpn.usecase.customlists.CustomListRelayItemsUseCase +import net.mullvad.mullvadvpn.util.Lce import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -59,10 +61,14 @@ class CustomListLocationsViewModelTest { name = CustomListName.fromString("name"), locations = emptyList(), ) + relayListFlow.value = DUMMY_COUNTRIES val viewModel = createViewModel(customListId = customList.id, newList = newList) // Act, Assert - viewModel.uiState.test { assertEquals(newList, awaitItem().newList) } + viewModel.uiState.test { + val state = awaitItem() + assertEquals(newList, state.newList) + } } @Test @@ -80,7 +86,17 @@ class CustomListLocationsViewModelTest { } val customListId = CustomListId("id") val expectedState = - CustomListLocationsUiState.Content.Data(newList = true, locations = expectedList) + CustomListLocationsUiState( + newList = true, + Lce.Content( + CustomListLocationsData( + saveEnabled = false, + hasUnsavedChanges = false, + searchTerm = "", + locations = expectedList, + ) + ), + ) val viewModel = createViewModel(customListId, true) relayListFlow.value = DUMMY_COUNTRIES @@ -102,8 +118,8 @@ class CustomListLocationsViewModelTest { viewModel.uiState.test { // Check no selected val firstState = awaitItem() - assertIs<CustomListLocationsUiState.Content.Data>(firstState) - assertEquals(emptyList<RelayItem>(), firstState.selectedLocations()) + assertIs<Lce.Content<CustomListLocationsData>>(firstState.content) + assertEquals(emptyList<RelayItem>(), firstState.content.selectedLocations()) // Expand country viewModel.onExpand(DUMMY_COUNTRIES[0], true) awaitItem() @@ -114,8 +130,8 @@ class CustomListLocationsViewModelTest { viewModel.onRelaySelectionClick(DUMMY_COUNTRIES[0], true) // Check all items selected val secondState = awaitItem() - assertIs<CustomListLocationsUiState.Content.Data>(secondState) - assertLists(expectedSelection, secondState.selectedLocations()) + assertIs<Lce.Content<CustomListLocationsData>>(secondState.content) + assertLists(expectedSelection, secondState.content.selectedLocations()) } } @@ -133,21 +149,15 @@ class CustomListLocationsViewModelTest { // Act, Assert viewModel.uiState.test { - awaitItem() - // Expand country - viewModel.onExpand(DUMMY_COUNTRIES[0], true) - awaitItem() - // Expand city - viewModel.onExpand(DUMMY_COUNTRIES[0].cities[0], true) // Check initial selected val firstState = awaitItem() - assertIs<CustomListLocationsUiState.Content.Data>(firstState) - assertEquals(initialSelectionIds, firstState.selectedLocations()) + assertIs<Lce.Content<CustomListLocationsData>>(firstState.content) + assertEquals(initialSelectionIds, firstState.content.selectedLocations()) viewModel.onRelaySelectionClick(DUMMY_COUNTRIES[0].cities[0].relays[0], false) // Check all items selected val secondState = awaitItem() - assertIs<CustomListLocationsUiState.Content.Data>(secondState) - assertEquals(expectedSelection, secondState.selectedLocations()) + assertIs<Lce.Content<CustomListLocationsData>>(secondState.content) + assertEquals(expectedSelection, secondState.content.selectedLocations()) } } @@ -166,21 +176,14 @@ class CustomListLocationsViewModelTest { // Act, Assert viewModel.uiState.test { - awaitItem() - // Expand country - viewModel.onExpand(DUMMY_COUNTRIES[0], true) - awaitItem() - // Expand city - viewModel.onExpand(DUMMY_COUNTRIES[0].cities[0], true) - // Check initial selected val firstState = awaitItem() - assertIs<CustomListLocationsUiState.Content.Data>(firstState) - assertEquals(initialSelectionIds, firstState.selectedLocations()) + assertIs<Lce.Content<CustomListLocationsData>>(firstState.content) + assertEquals(initialSelectionIds, firstState.content.selectedLocations()) viewModel.onRelaySelectionClick(DUMMY_COUNTRIES[0], false) // Check all items selected val secondState = awaitItem() - assertIs<CustomListLocationsUiState.Content.Data>(secondState) - assertEquals(expectedSelection, secondState.selectedLocations()) + assertIs<Lce.Content<CustomListLocationsData>>(secondState.content) + assertEquals(expectedSelection, secondState.content.selectedLocations()) } } @@ -203,13 +206,13 @@ class CustomListLocationsViewModelTest { viewModel.onExpand(DUMMY_COUNTRIES[0].cities[0], true) // Check no selected val firstState = awaitItem() - assertIs<CustomListLocationsUiState.Content.Data>(firstState) - assertEquals(emptyList<RelayItem>(), firstState.selectedLocations()) + assertIs<Lce.Content<CustomListLocationsData>>(firstState.content) + assertEquals(emptyList<RelayItem>(), firstState.content.selectedLocations()) viewModel.onRelaySelectionClick(DUMMY_COUNTRIES[0].cities[0].relays[0], true) // Check all items selected val secondState = awaitItem() - assertIs<CustomListLocationsUiState.Content.Data>(secondState) - assertEquals(expectedSelection, secondState.selectedLocations()) + assertIs<Lce.Content<CustomListLocationsData>>(secondState.content) + assertEquals(expectedSelection, secondState.content.selectedLocations()) } } @@ -323,8 +326,8 @@ class CustomListLocationsViewModelTest { ) } - private fun CustomListLocationsUiState.Content.Data.selectedLocations() = - this.locations.filter { it.checked }.map { it.item.id } + private fun Lce.Content<CustomListLocationsData>.selectedLocations() = + this.value.locations.filter { it.checked }.map { it.item.id } private fun RelayItem.Location.toDepth() = when (this) { diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ManageDevicesViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ManageDevicesViewModelTest.kt index ac5446cc07..7f4c6fe690 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ManageDevicesViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ManageDevicesViewModelTest.kt @@ -78,7 +78,7 @@ class ManageDevicesViewModelTest { @Test fun `initial state should be Loading followed by Content`() = runTest { // Initial state is Loading - assertIs<Lce.Loading>(viewModel.uiState.value) + assertIs<Lce.Loading<Unit>>(viewModel.uiState.value) viewModel.uiState.test { val contentState = awaitItem() diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModelTest.kt index f9c4cace6a..0166bafa98 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModelTest.kt @@ -29,6 +29,7 @@ import net.mullvad.mullvadvpn.usecase.SelectedLocationUseCase import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase import net.mullvad.mullvadvpn.usecase.customlists.CustomListsRelayItemUseCase import net.mullvad.mullvadvpn.usecase.customlists.FilterCustomListsRelayItemUseCase +import net.mullvad.mullvadvpn.util.Lce import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -96,20 +97,17 @@ class SearchLocationViewModelTest { // Act, Assert viewModel.uiState.test { // Wait for first data - assertIs<SearchLocationUiState.NoQuery>(awaitItem()) + awaitItem() // Update search string viewModel.onSearchInputUpdated(mockSearchString) - // We get some unnecessary emissions for now - awaitItem() - val actualState = awaitItem() - assertIs<SearchLocationUiState.Content>(actualState) + assertIs<Lce.Content<SearchLocationUiState>>(actualState) assertTrue( - actualState.relayListItems.filterIsInstance<RelayListItem.GeoLocationItem>().any { - it.item is RelayItem.Location.City && it.item.name == "Gothenburg" - } + actualState.value.relayListItems + .filterIsInstance<RelayListItem.GeoLocationItem>() + .any { it.item is RelayItem.Location.City && it.item.name == "Gothenburg" } ) } } @@ -123,20 +121,17 @@ class SearchLocationViewModelTest { // Act, Assert viewModel.uiState.test { // Wait for first data - assertIs<SearchLocationUiState.NoQuery>(awaitItem()) + awaitItem() // Update search string viewModel.onSearchInputUpdated(mockSearchString) - // We get some unnecessary emissions for now - awaitItem() - // Assert val actualState = awaitItem() - assertIs<SearchLocationUiState.Content>(actualState) + assertIs<Lce.Content<SearchLocationUiState>>(actualState) assertLists( listOf(RelayListItem.LocationsEmptyText(mockSearchString)), - actualState.relayListItems, + actualState.value.relayListItems, ) } } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationListViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationListViewModelTest.kt index acdf3f5c95..46994ead49 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationListViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationListViewModelTest.kt @@ -23,6 +23,7 @@ import net.mullvad.mullvadvpn.usecase.FilteredRelayListUseCase import net.mullvad.mullvadvpn.usecase.SelectedLocationUseCase import net.mullvad.mullvadvpn.usecase.customlists.CustomListsRelayItemUseCase import net.mullvad.mullvadvpn.usecase.customlists.FilterCustomListsRelayItemUseCase +import net.mullvad.mullvadvpn.util.Lce import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach @@ -70,7 +71,7 @@ class SelectLocationListViewModelTest { viewModel = createSelectLocationListViewModel(relayListType = RelayListType.ENTRY) // Assert - assertEquals(SelectLocationListUiState.Loading, viewModel.uiState.value) + assertEquals(Lce.Loading(Unit), viewModel.uiState.value) } @Test @@ -84,13 +85,13 @@ class SelectLocationListViewModelTest { // Act, Assert viewModel.uiState.test { val actualState = awaitItem() - assertIs<SelectLocationListUiState.Content>(actualState) + assertIs<Lce.Content<SelectLocationListUiState>>(actualState) assertLists( testCountries.map { it.id }, - actualState.relayListItems.mapNotNull { it.relayItemId() }, + actualState.value.relayListItems.mapNotNull { it.relayItemId() }, ) assertTrue( - actualState.relayListItems + actualState.value.relayListItems .filterIsInstance<RelayListItem.SelectableItem>() .first { it.relayItemId() == selectedId } .isSelected @@ -108,15 +109,15 @@ class SelectLocationListViewModelTest { // Act, Assert viewModel.uiState.test { val actualState = awaitItem() - assertIs<SelectLocationListUiState.Content>(actualState) + assertIs<Lce.Content<SelectLocationListUiState>>(actualState) assertLists( testCountries.map { it.id }, - actualState.relayListItems.mapNotNull { it.relayItemId() }, + actualState.value.relayListItems.mapNotNull { it.relayItemId() }, ) assertTrue( - actualState.relayListItems.filterIsInstance<RelayListItem.SelectableItem>().all { - !it.isSelected - } + actualState.value.relayListItems + .filterIsInstance<RelayListItem.SelectableItem>() + .all { !it.isSelected } ) } } @@ -139,6 +140,7 @@ class SelectLocationListViewModelTest { RelayListItem.CustomListHeader -> null RelayListItem.LocationHeader -> null is RelayListItem.LocationsEmptyText -> null + is RelayListItem.EmptyRelayList -> null is RelayListItem.CustomListEntryItem -> item.id is RelayListItem.CustomListItem -> item.id is RelayListItem.GeoLocationItem -> item.id diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModelTest.kt index c79d158f82..a7ecbe17f9 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModelTest.kt @@ -38,8 +38,8 @@ import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository import net.mullvad.mullvadvpn.usecase.FilterChip import net.mullvad.mullvadvpn.usecase.FilterChipUseCase import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase +import net.mullvad.mullvadvpn.util.Lc import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -59,6 +59,7 @@ class SelectLocationViewModelTest { private val selectedRelayItemFlow = MutableStateFlow<Constraint<RelayItemId>>(Constraint.Any) private val wireguardConstraints = MutableStateFlow<WireguardConstraints>(mockk(relaxed = true)) private val filterChips = MutableStateFlow<List<FilterChip>>(emptyList()) + private val relayList = MutableStateFlow<List<RelayItem.Location.Country>>(emptyList()) @BeforeEach fun setup() { @@ -67,6 +68,7 @@ class SelectLocationViewModelTest { every { mockWireguardConstraintsRepository.wireguardConstraints } returns wireguardConstraints every { mockFilterChipUseCase(any()) } returns filterChips + every { mockRelayListRepository.relayList } returns relayList mockkStatic(RELAY_LIST_EXTENSIONS) mockkStatic(RELAY_ITEM_EXTENSIONS) @@ -90,7 +92,7 @@ class SelectLocationViewModelTest { @Test fun `initial state should be correct`() = runTest { - Assertions.assertEquals(SelectLocationUiState.Loading, viewModel.uiState.value) + assertIs<Lc.Loading<Unit>>(viewModel.uiState.value) } @Test @@ -128,14 +130,14 @@ class SelectLocationViewModelTest { viewModel.selectRelayList(RelayListType.ENTRY) // Assert relay list type is entry val firstState = awaitItem() - assertIs<SelectLocationUiState.Data>(firstState) - assertEquals(RelayListType.ENTRY, firstState.relayListType) + assertIs<Lc.Content<SelectLocationUiState>>(firstState) + assertEquals(RelayListType.ENTRY, firstState.value.relayListType) // Select entry viewModel.selectRelay(mockRelayItem) // Assert relay list type is exit val secondState = awaitItem() - assertIs<SelectLocationUiState.Data>(secondState) - assertEquals(RelayListType.EXIT, secondState.relayListType) + assertIs<Lc.Content<SelectLocationUiState>>(secondState) + assertEquals(RelayListType.EXIT, secondState.value.relayListType) coVerify { mockWireguardConstraintsRepository.setEntryLocation(relayItemId) } } } diff --git a/android/lib/resource/src/main/res/values-da/strings.xml b/android/lib/resource/src/main/res/values-da/strings.xml index 3e83bea3e2..a20e6e31f4 100644 --- a/android/lib/resource/src/main/res/values-da/strings.xml +++ b/android/lib/resource/src/main/res/values-da/strings.xml @@ -305,7 +305,6 @@ <string name="search">Søg</string> <string name="search_location_empty_text">Intet resultat for \"%1$s\". Prøv en anden søgning</string> <string name="search_placeholder">Søg efter...</string> - <string name="search_query_empty">Indtast mindst 2 tegn for at starte søgningen.</string> <string name="select_location">Vælg placering</string> <string name="send">Send</string> <string name="send_anyway">Send alligevel</string> diff --git a/android/lib/resource/src/main/res/values-de/strings.xml b/android/lib/resource/src/main/res/values-de/strings.xml index ec79fef641..a1776ad4aa 100644 --- a/android/lib/resource/src/main/res/values-de/strings.xml +++ b/android/lib/resource/src/main/res/values-de/strings.xml @@ -305,7 +305,6 @@ <string name="search">Suche</string> <string name="search_location_empty_text">Kein Ergebnis für „%1$s“, bitte versuchen Sie einen anderen Suchbegriff</string> <string name="search_placeholder">Suchen nach …</string> - <string name="search_query_empty">Geben Sie mindestens 2 Zeichen ein, um die Suche zu starten.</string> <string name="select_location">Ort auswählen</string> <string name="send">Senden</string> <string name="send_anyway">Trotzdem senden</string> diff --git a/android/lib/resource/src/main/res/values-es/strings.xml b/android/lib/resource/src/main/res/values-es/strings.xml index 1bc12accd3..676c9d1f11 100644 --- a/android/lib/resource/src/main/res/values-es/strings.xml +++ b/android/lib/resource/src/main/res/values-es/strings.xml @@ -305,7 +305,6 @@ <string name="search">Buscar</string> <string name="search_location_empty_text">No hay resultados para «%1$s», intente una búsqueda diferente</string> <string name="search_placeholder">Buscar...</string> - <string name="search_query_empty">Escriba al menos 2 caracteres para iniciar la búsqueda.</string> <string name="select_location">Seleccionar ubicación</string> <string name="send">Enviar</string> <string name="send_anyway">Enviar de todos modos</string> diff --git a/android/lib/resource/src/main/res/values-fi/strings.xml b/android/lib/resource/src/main/res/values-fi/strings.xml index 74ea35fcec..90e5fd8a25 100644 --- a/android/lib/resource/src/main/res/values-fi/strings.xml +++ b/android/lib/resource/src/main/res/values-fi/strings.xml @@ -305,7 +305,6 @@ <string name="search">Haku</string> <string name="search_location_empty_text">Haulle \"%1$s\" ei löytynyt tuloksia. Kokeile toista hakua.</string> <string name="search_placeholder">Hae...</string> - <string name="search_query_empty">Aloita haku kirjoittamalla vähintään 2 merkkiä.</string> <string name="select_location">Valitse sijainti</string> <string name="send">Lähetä</string> <string name="send_anyway">Lähetä silti</string> diff --git a/android/lib/resource/src/main/res/values-fr/strings.xml b/android/lib/resource/src/main/res/values-fr/strings.xml index 05b42d99d5..d1c8c11364 100644 --- a/android/lib/resource/src/main/res/values-fr/strings.xml +++ b/android/lib/resource/src/main/res/values-fr/strings.xml @@ -305,7 +305,6 @@ <string name="search">Recherche</string> <string name="search_location_empty_text">Aucun résultat pour « %1$s ». Veuillez essayer une autre recherche</string> <string name="search_placeholder">Rechercher...</string> - <string name="search_query_empty">Saisissez au moins 2 caractères pour commencer la recherche.</string> <string name="select_location">Sélectionner une localisation</string> <string name="send">Envoyer</string> <string name="send_anyway">Envoyer quand même</string> diff --git a/android/lib/resource/src/main/res/values-it/strings.xml b/android/lib/resource/src/main/res/values-it/strings.xml index 89109175f8..053ca1bbd3 100644 --- a/android/lib/resource/src/main/res/values-it/strings.xml +++ b/android/lib/resource/src/main/res/values-it/strings.xml @@ -305,7 +305,6 @@ <string name="search">Cerca</string> <string name="search_location_empty_text">Nessun risultato per \"%1$s\", prova una ricerca diversa</string> <string name="search_placeholder">Cerca...</string> - <string name="search_query_empty">Digita almeno 2 caratteri per iniziare la ricerca.</string> <string name="select_location">Seleziona posizione</string> <string name="send">Invia</string> <string name="send_anyway">Invia comunque</string> diff --git a/android/lib/resource/src/main/res/values-ja/strings.xml b/android/lib/resource/src/main/res/values-ja/strings.xml index de93bef8c2..5936212e78 100644 --- a/android/lib/resource/src/main/res/values-ja/strings.xml +++ b/android/lib/resource/src/main/res/values-ja/strings.xml @@ -305,7 +305,6 @@ <string name="search">検索</string> <string name="search_location_empty_text">「%1$s」の結果はありません。別の検索をお試しください。</string> <string name="search_placeholder">検索...</string> - <string name="search_query_empty">検索を開始するには2文字以上を入力してください。</string> <string name="select_location">場所を選択する</string> <string name="send">送信</string> <string name="send_anyway">とにかく送信する</string> diff --git a/android/lib/resource/src/main/res/values-ko/strings.xml b/android/lib/resource/src/main/res/values-ko/strings.xml index 0237af5df8..2c3c265ca0 100644 --- a/android/lib/resource/src/main/res/values-ko/strings.xml +++ b/android/lib/resource/src/main/res/values-ko/strings.xml @@ -305,7 +305,6 @@ <string name="search">검색</string> <string name="search_location_empty_text">\"%1$s\" 검색 결과가 없습니다. 다른 검색어를 시도하세요</string> <string name="search_placeholder">검색...</string> - <string name="search_query_empty">검색을 시작하려면 2자 이상 입력하세요.</string> <string name="select_location">위치 선택</string> <string name="send">전송</string> <string name="send_anyway">그래도 전송</string> diff --git a/android/lib/resource/src/main/res/values-my/strings.xml b/android/lib/resource/src/main/res/values-my/strings.xml index 3fece6dbff..c9b4d7408e 100644 --- a/android/lib/resource/src/main/res/values-my/strings.xml +++ b/android/lib/resource/src/main/res/values-my/strings.xml @@ -305,7 +305,6 @@ <string name="search">ရှာဖွေမှု</string> <string name="search_location_empty_text">\"%1$s\" အတွက် ရလဒ်မရှိပါ၊ အခြားရှာဖွေမှုတစ်ခုကို စမ်းလုပ်ကြည့်ပါ</string> <string name="search_placeholder">ရှာရန်...</string> - <string name="search_query_empty">စတင်ရှာဖွေရန် အနည်းဆုံး အက္ခရာ 2 လုံး ရိုက်ထည့်ပါ။</string> <string name="select_location">တည်နေရာ ရွေးရန်</string> <string name="send">ပို့ရန်</string> <string name="send_anyway">မည်သို့ပင်ဖြစ်စေ ပို့ရန်</string> diff --git a/android/lib/resource/src/main/res/values-nb/strings.xml b/android/lib/resource/src/main/res/values-nb/strings.xml index 8278650ce4..ca5e371606 100644 --- a/android/lib/resource/src/main/res/values-nb/strings.xml +++ b/android/lib/resource/src/main/res/values-nb/strings.xml @@ -305,7 +305,6 @@ <string name="search">Søk</string> <string name="search_location_empty_text">Ingen resultater for «%1$s». Prøv et annet søk</string> <string name="search_placeholder">Søk etter ...</string> - <string name="search_query_empty">Skriv minst 2 tegn for å begynne å søke.</string> <string name="select_location">Velg plassering</string> <string name="send">Send</string> <string name="send_anyway">Send allikevel</string> diff --git a/android/lib/resource/src/main/res/values-nl/strings.xml b/android/lib/resource/src/main/res/values-nl/strings.xml index 31dde41616..e78a6ae581 100644 --- a/android/lib/resource/src/main/res/values-nl/strings.xml +++ b/android/lib/resource/src/main/res/values-nl/strings.xml @@ -305,7 +305,6 @@ <string name="search">Zoeken</string> <string name="search_location_empty_text">Geen resultaat voor \"%1$s\", probeer een andere zoekopdracht</string> <string name="search_placeholder">Zoeken naar...</string> - <string name="search_query_empty">Voer minimaal 2 tekens in om te beginnen met zoeken.</string> <string name="select_location">Locatie selecteren</string> <string name="send">Verzenden</string> <string name="send_anyway">Toch verzenden</string> diff --git a/android/lib/resource/src/main/res/values-pl/strings.xml b/android/lib/resource/src/main/res/values-pl/strings.xml index 9db21876a5..91f41e2d4a 100644 --- a/android/lib/resource/src/main/res/values-pl/strings.xml +++ b/android/lib/resource/src/main/res/values-pl/strings.xml @@ -305,7 +305,6 @@ <string name="search">Wyszukaj</string> <string name="search_location_empty_text">Brak wyników dla hasła „%1$s”, spróbuj innego wyszukiwania</string> <string name="search_placeholder">Wyszukaj...</string> - <string name="search_query_empty">Wpisz co najmniej 2 znaki, aby rozpocząć wyszukiwanie.</string> <string name="select_location">Wybierz lokalizację</string> <string name="send">Wyślij</string> <string name="send_anyway">Mimo to wyślij</string> diff --git a/android/lib/resource/src/main/res/values-pt/strings.xml b/android/lib/resource/src/main/res/values-pt/strings.xml index a07690cd88..d8f226c99c 100644 --- a/android/lib/resource/src/main/res/values-pt/strings.xml +++ b/android/lib/resource/src/main/res/values-pt/strings.xml @@ -305,7 +305,6 @@ <string name="search">Pesquisar</string> <string name="search_location_empty_text">Nenhum resultado para \"%1$s\", tente uma pesquisa diferente</string> <string name="search_placeholder">Pesquisar por...</string> - <string name="search_query_empty">Digite pelo menos 2 caracteres para iniciar a pesquisa.</string> <string name="select_location">Selecionar localização</string> <string name="send">Enviar</string> <string name="send_anyway">Enviar mesmo assim</string> diff --git a/android/lib/resource/src/main/res/values-ru/strings.xml b/android/lib/resource/src/main/res/values-ru/strings.xml index fee90d7ca7..b0ea223ac7 100644 --- a/android/lib/resource/src/main/res/values-ru/strings.xml +++ b/android/lib/resource/src/main/res/values-ru/strings.xml @@ -305,7 +305,6 @@ <string name="search">Поиск</string> <string name="search_location_empty_text">По запросу «%1$s» ничего не найдено — попробуйте другой запрос</string> <string name="search_placeholder">Поиск...</string> - <string name="search_query_empty">Чтобы начать поиск, введите не менее двух символов.</string> <string name="select_location">Выбор местоположения</string> <string name="send">Отправить</string> <string name="send_anyway">Все равно отправить</string> diff --git a/android/lib/resource/src/main/res/values-sv/strings.xml b/android/lib/resource/src/main/res/values-sv/strings.xml index d93fc363c5..1300f16f88 100644 --- a/android/lib/resource/src/main/res/values-sv/strings.xml +++ b/android/lib/resource/src/main/res/values-sv/strings.xml @@ -305,7 +305,6 @@ <string name="search">Sök</string> <string name="search_location_empty_text">Inga resultat hittades för \"%1$s\", försök med en annan sökning</string> <string name="search_placeholder">Sök efter …</string> - <string name="search_query_empty">Ange minst två tecken för att börja söka.</string> <string name="select_location">Välj plats</string> <string name="send">Skicka</string> <string name="send_anyway">Skicka ändå</string> diff --git a/android/lib/resource/src/main/res/values-th/strings.xml b/android/lib/resource/src/main/res/values-th/strings.xml index 0eeee4df5f..6512a46c1f 100644 --- a/android/lib/resource/src/main/res/values-th/strings.xml +++ b/android/lib/resource/src/main/res/values-th/strings.xml @@ -305,7 +305,6 @@ <string name="search">ค้นหา</string> <string name="search_location_empty_text">ไม่พบผลลัพธ์สำหรับ \"%1$s\" โปรดลองใช้คำค้นหาอื่น</string> <string name="search_placeholder">ค้นหา…</string> - <string name="search_query_empty">พิมพ์อย่างน้อย 2 ตัวอักษร เพื่อเริ่มการค้นหา</string> <string name="select_location">เลือกตำแหน่งที่ตั้ง</string> <string name="send">ส่ง</string> <string name="send_anyway">ส่งต่อไป</string> diff --git a/android/lib/resource/src/main/res/values-tr/strings.xml b/android/lib/resource/src/main/res/values-tr/strings.xml index 30a44f01de..d9ea10ef57 100644 --- a/android/lib/resource/src/main/res/values-tr/strings.xml +++ b/android/lib/resource/src/main/res/values-tr/strings.xml @@ -305,7 +305,6 @@ <string name="search">Ara</string> <string name="search_location_empty_text">\"%1$s\" için sonuç yok, lütfen farklı bir arama yapın</string> <string name="search_placeholder">Ara...</string> - <string name="search_query_empty">Arama yapmak için en az 2 karakter girin.</string> <string name="select_location">Konum seçin</string> <string name="send">Gönder</string> <string name="send_anyway">Yine de gönder</string> diff --git a/android/lib/resource/src/main/res/values-zh-rCN/strings.xml b/android/lib/resource/src/main/res/values-zh-rCN/strings.xml index 0ed3d053c7..ada8779910 100644 --- a/android/lib/resource/src/main/res/values-zh-rCN/strings.xml +++ b/android/lib/resource/src/main/res/values-zh-rCN/strings.xml @@ -305,7 +305,6 @@ <string name="search">搜索</string> <string name="search_location_empty_text">“%1$s”无结果,请尝试其他搜索</string> <string name="search_placeholder">搜索…</string> - <string name="search_query_empty">至少输入 2 个字符以开始搜索。</string> <string name="select_location">选择位置</string> <string name="send">发送</string> <string name="send_anyway">仍然发送</string> diff --git a/android/lib/resource/src/main/res/values-zh-rTW/strings.xml b/android/lib/resource/src/main/res/values-zh-rTW/strings.xml index 58c0671eb2..5beb978dd5 100644 --- a/android/lib/resource/src/main/res/values-zh-rTW/strings.xml +++ b/android/lib/resource/src/main/res/values-zh-rTW/strings.xml @@ -305,7 +305,6 @@ <string name="search">搜尋</string> <string name="search_location_empty_text">找不到「%1$s」的結果,請使改用其他搜尋條件。</string> <string name="search_placeholder">搜尋…</string> - <string name="search_query_empty">至少輸入 2 個字元以開始搜尋。</string> <string name="select_location">選擇位置</string> <string name="send">傳送</string> <string name="send_anyway">仍要傳送</string> diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml index d9fe661a21..f96fa9668b 100644 --- a/android/lib/resource/src/main/res/values/strings.xml +++ b/android/lib/resource/src/main/res/values/strings.xml @@ -389,7 +389,6 @@ <string name="x_exit">%s (Exit)</string> <string name="filters">Filters:</string> <string name="removed_provider">%s (removed)</string> - <string name="search_query_empty">Type at least 2 characters to start searching.</string> <string name="daita_description_slide_1_first_paragraph">Attention: This increases network traffic and will also negatively affect speed, latency, and battery usage. Use with caution on limited plans.</string> <string name="daita_description_slide_1_second_paragraph">%1$s (%2$s) hides patterns in your encrypted VPN traffic.</string> <string name="daita_description_slide_1_third_paragraph">By using sophisticated AI it’s possible to analyze the traffic of data packets going in and out of your device (even if the traffic is encrypted).</string> diff --git a/desktop/packages/mullvad-vpn/locales/messages.pot b/desktop/packages/mullvad-vpn/locales/messages.pot index 401139b450..2acddec6f3 100644 --- a/desktop/packages/mullvad-vpn/locales/messages.pot +++ b/desktop/packages/mullvad-vpn/locales/messages.pot @@ -2870,9 +2870,6 @@ msgstr "" msgid "Toggle VPN" msgstr "" -msgid "Type at least 2 characters to start searching." -msgstr "" - msgid "Unable to apply firewall rules. Please troubleshoot or send a problem report." msgstr "" |
