From 9f6dfe0b3f148392ae5467746845f0987dff9c31 Mon Sep 17 00:00:00 2001 From: MaryamShaghaghi <122574719+MaryamShaghaghi@users.noreply.github.com> Date: Mon, 27 Nov 2023 14:35:46 +0100 Subject: [PATCH] Update select location screen Co-Authored-By: Boban Sijuk <49131853+boki91@users.noreply.github.com> --- .../compose/screen/SelectLocationScreen.kt | 123 ++++++++++++------ .../compose/state/SelectLocationUiState.kt | 15 ++- .../compose/textfield/SearchTextField.kt | 18 ++- .../net/mullvad/mullvadvpn/di/UiModule.kt | 6 +- .../net/mullvad/mullvadvpn/ui/MainActivity.kt | 16 ++- .../ui/fragment/SelectLocationFragment.kt | 8 ++ .../viewmodel/SelectLocationViewModel.kt | 101 ++++++++++++-- .../main/res/drawable/icons_more_circle.xml | 13 ++ 8 files changed, 238 insertions(+), 62 deletions(-) create mode 100644 android/lib/resource/src/main/res/drawable/icons_more_circle.xml diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt index c09a0b986a61..55936b392a4f 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt @@ -30,16 +30,16 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.sp import androidx.core.text.HtmlCompat import com.google.accompanist.systemuicontroller.rememberSystemUiController import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.cell.FilterCell import net.mullvad.mullvadvpn.compose.cell.RelayLocationCell import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar @@ -60,8 +60,11 @@ import net.mullvad.mullvadvpn.relaylist.RelayItem private fun PreviewSelectLocationScreen() { val state = SelectLocationUiState.ShowData( + searchTerm = "", countries = listOf(RelayCountry("Country 1", "Code 1", false, emptyList())), - selectedRelay = null + selectedRelay = null, + selectedOwnership = null, + selectedProvidersCount = 0 ) AppTheme { SelectLocationScreen( @@ -80,8 +83,12 @@ fun SelectLocationScreen( enterTransitionEndAction: SharedFlow, onSelectRelay: (item: RelayItem) -> Unit = {}, onSearchTermInput: (searchTerm: String) -> Unit = {}, - onBackClick: () -> Unit = {} + onBackClick: () -> Unit = {}, + onFilterClick: () -> Unit = {}, + removeOwnershipFilter: () -> Unit = {}, + removeProviderFilter: () -> Unit = {} ) { + val backgroundColor = MaterialTheme.colorScheme.background val systemUiController = rememberSystemUiController() @@ -121,10 +128,29 @@ fun SelectLocationScreen( .weight(weight = 1f) .padding(end = Dimens.titleIconSize), textAlign = TextAlign.Center, - style = MaterialTheme.typography.headlineSmall.copy(fontSize = 20.sp), + style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onPrimary ) + Image( + painter = painterResource(id = R.drawable.icons_more_circle), + contentDescription = null, + modifier = Modifier.size(Dimens.titleIconSize).clickable { onFilterClick() } + ) + } + when (uiState) { + SelectLocationUiState.Loading -> {} + is SelectLocationUiState.ShowData -> { + if (uiState.hasFilter) { + FilterCell( + ownershipFilter = uiState.selectedOwnership, + selectedProviderFilter = uiState.selectedProvidersCount, + removeOwnershipFilter = removeOwnershipFilter, + removeProviderFilter = removeProviderFilter + ) + } + } } + SearchTextField( modifier = Modifier.fillMaxWidth() @@ -146,7 +172,7 @@ fun SelectLocationScreen( MaterialTheme.colorScheme.onBackground.copy(alpha = AlphaScrollbar) ), state = lazyListState, - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, ) { when (uiState) { SelectLocationUiState.Loading -> { @@ -157,45 +183,56 @@ fun SelectLocationScreen( } } is SelectLocationUiState.ShowData -> { - items( - count = uiState.countries.size, - key = { index -> uiState.countries[index].hashCode() }, - contentType = { ContentType.ITEM } - ) { index -> - val country = uiState.countries[index] - RelayLocationCell( - relay = country, - selectedItem = uiState.selectedRelay, - onSelectRelay = onSelectRelay, - modifier = Modifier.animateContentSize() - ) - } - } - is SelectLocationUiState.NoSearchResultFound -> { - item(contentType = ContentType.EMPTY_TEXT) { - val firstRow = - HtmlCompat.fromHtml( - textResource( - id = R.string.select_location_empty_text_first_row, - uiState.searchTerm - ), - HtmlCompat.FROM_HTML_MODE_COMPACT - ) - .toAnnotatedString(boldFontWeight = FontWeight.ExtraBold) - Text( - text = - buildAnnotatedString { - append(firstRow) - appendLine() - append( + if (uiState.countries.isEmpty()) { + item(contentType = ContentType.EMPTY_TEXT) { + val firstRow = + HtmlCompat.fromHtml( textResource( - id = R.string.select_location_empty_text_second_row - ) + id = R.string.select_location_empty_text_first_row, + uiState.searchTerm + ), + HtmlCompat.FROM_HTML_MODE_COMPACT ) - }, - style = MaterialTheme.typography.labelMedium, - textAlign = TextAlign.Center - ) + .toAnnotatedString(boldFontWeight = FontWeight.ExtraBold) + val secondRow = + textResource(id = R.string.select_location_empty_text_second_row) + Column( + modifier = + Modifier.padding( + horizontal = Dimens.selectLocationTitlePadding + ), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = firstRow, + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSecondary, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Text( + text = secondRow, + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSecondary + ) + } + } + } else { + items( + count = uiState.countries.size, + key = { index -> uiState.countries[index].hashCode() }, + contentType = { ContentType.ITEM } + ) { index -> + val country = uiState.countries[index] + RelayLocationCell( + relay = country, + selectedItem = uiState.selectedRelay, + onSelectRelay = onSelectRelay, + modifier = Modifier.animateContentSize() + ) + } } } } 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 fece45f0aa91..123bf821e605 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 @@ -1,13 +1,20 @@ package net.mullvad.mullvadvpn.compose.state +import net.mullvad.mullvadvpn.model.Ownership import net.mullvad.mullvadvpn.relaylist.RelayCountry import net.mullvad.mullvadvpn.relaylist.RelayItem sealed interface SelectLocationUiState { - data object Loading : SelectLocationUiState - data class ShowData(val countries: List, val selectedRelay: RelayItem?) : - SelectLocationUiState + data object Loading : SelectLocationUiState - data class NoSearchResultFound(val searchTerm: String) : SelectLocationUiState + data class ShowData( + val searchTerm: String, + val countries: List, + val selectedRelay: RelayItem?, + val selectedOwnership: Ownership?, + val selectedProvidersCount: Int? + ) : SelectLocationUiState { + val hasFilter: Boolean = (selectedProvidersCount != null || selectedOwnership != null) + } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/SearchTextField.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/SearchTextField.kt index 2f743a8d2303..bbee4a969bc1 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/SearchTextField.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/SearchTextField.kt @@ -2,6 +2,7 @@ package net.mullvad.mullvadvpn.compose.textfield import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -80,15 +81,28 @@ fun SearchTextField( modifier = Modifier.size( width = Dimens.searchIconSize, - height = Dimens.searchIconSize + height = Dimens.searchIconSize, ), colorFilter = - ColorFilter.tint(color = MaterialTheme.colorScheme.onSecondary) + ColorFilter.tint(color = MaterialTheme.colorScheme.onSecondary), ) }, placeholder = { Text(text = placeHolder, style = MaterialTheme.typography.labelLarge) }, + trailingIcon = { + if (searchTerm.isNotEmpty()) { + Image( + modifier = + Modifier.size(Dimens.smallIconSize).clickable { + searchTerm = "" + onValueChange.invoke(searchTerm) + }, + painter = painterResource(id = R.drawable.icon_close), + contentDescription = null, + ) + } + }, shape = MaterialTheme.shapes.medium, colors = TextFieldDefaults.colors( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt index 56df6699de97..c76f240f154e 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt @@ -29,6 +29,7 @@ import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase import net.mullvad.mullvadvpn.usecase.PaymentUseCase import net.mullvad.mullvadvpn.usecase.PlayPaymentUseCase import net.mullvad.mullvadvpn.usecase.PortRangeUseCase +import net.mullvad.mullvadvpn.usecase.RelayListFilterUseCase import net.mullvad.mullvadvpn.usecase.RelayListUseCase import net.mullvad.mullvadvpn.usecase.TunnelStateNotificationUseCase import net.mullvad.mullvadvpn.usecase.VersionNotificationUseCase @@ -39,6 +40,7 @@ import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel import net.mullvad.mullvadvpn.viewmodel.DeviceListViewModel import net.mullvad.mullvadvpn.viewmodel.DeviceRevokedViewModel +import net.mullvad.mullvadvpn.viewmodel.FilterViewModel import net.mullvad.mullvadvpn.viewmodel.LoginViewModel import net.mullvad.mullvadvpn.viewmodel.OutOfTimeViewModel import net.mullvad.mullvadvpn.viewmodel.PrivacyDisclaimerViewModel @@ -103,6 +105,7 @@ val uiModule = module { single { ChangelogDataProvider(get()) } + single { RelayListFilterUseCase(get(), get()) } single { RelayListListener(get()) } // Will be resolved using from either of the two PaymentModule.kt classes. @@ -129,7 +132,7 @@ val uiModule = module { viewModel { DeviceRevokedViewModel(get(), get()) } viewModel { LoginViewModel(get(), get(), get()) } viewModel { PrivacyDisclaimerViewModel(get()) } - viewModel { SelectLocationViewModel(get(), get()) } + viewModel { SelectLocationViewModel(get(), get(), get()) } viewModel { SettingsViewModel(get(), get()) } viewModel { VoucherDialogViewModel(get(), get()) } viewModel { VpnSettingsViewModel(get(), get(), get(), get(), get()) } @@ -137,6 +140,7 @@ val uiModule = module { viewModel { ReportProblemViewModel(get(), get()) } viewModel { ViewLogsViewModel(get()) } viewModel { OutOfTimeViewModel(get(), get(), get(), get()) } + viewModel { FilterViewModel(get()) } } const val SELF_PACKAGE_NAME = "SELF_PACKAGE_NAME" diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt index f299b8c956bd..f5e24dacf187 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt @@ -42,6 +42,7 @@ import net.mullvad.mullvadvpn.repository.PrivacyDisclaimerRepository import net.mullvad.mullvadvpn.ui.fragment.AccountFragment import net.mullvad.mullvadvpn.ui.fragment.ConnectFragment import net.mullvad.mullvadvpn.ui.fragment.DeviceRevokedFragment +import net.mullvad.mullvadvpn.ui.fragment.FilterFragment import net.mullvad.mullvadvpn.ui.fragment.LoadingFragment import net.mullvad.mullvadvpn.ui.fragment.LoginFragment import net.mullvad.mullvadvpn.ui.fragment.OutOfTimeFragment @@ -55,7 +56,6 @@ import net.mullvad.mullvadvpn.viewmodel.ChangelogDialogUiState import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel import org.koin.android.ext.android.getKoin import org.koin.core.context.loadKoinModules -import org.koin.dsl.bind open class MainActivity : FragmentActivity() { private val requestNotificationPermissionLauncher = @@ -174,6 +174,20 @@ open class MainActivity : FragmentActivity() { } } + fun openFilter() { + supportFragmentManager.beginTransaction().apply { + setCustomAnimations( + R.anim.fragment_enter_from_right, + R.anim.do_nothing, + R.anim.do_nothing, + R.anim.fragment_exit_to_right + ) + replace(R.id.main_fragment, FilterFragment()) + addToBackStack(null) + commitAllowingStateLoss() + } + } + private fun launchDeviceStateHandler(): Job { return lifecycleScope.launch { launch { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SelectLocationFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SelectLocationFragment.kt index d1c4ac72bfbf..64fdee71f625 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SelectLocationFragment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SelectLocationFragment.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.platform.ComposeView import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.screen.SelectLocationScreen import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.ui.MainActivity import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel import org.koin.androidx.viewmodel.ext.android.viewModel @@ -32,12 +33,19 @@ class SelectLocationFragment : BaseFragment() { onSelectRelay = vm::selectRelay, onSearchTermInput = vm::onSearchTermInput, onBackClick = { activity?.onBackPressedDispatcher?.onBackPressed() }, + removeOwnershipFilter = vm::removeOwnerFilter, + removeProviderFilter = vm::removeProviderFilter, + onFilterClick = ::openFilterView ) } } } } + private fun openFilterView() { + (context as? MainActivity)?.openFilter() + } + override fun onEnterTransitionAnimationEnd() { vm.onTransitionAnimationEnd() } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt index 5e95674e0a6a..caddae313bdc 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt @@ -7,37 +7,75 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState +import net.mullvad.mullvadvpn.compose.state.toNullableOwnership +import net.mullvad.mullvadvpn.compose.state.toSelectedProviders +import net.mullvad.mullvadvpn.model.Constraint +import net.mullvad.mullvadvpn.model.Ownership +import net.mullvad.mullvadvpn.relaylist.Provider import net.mullvad.mullvadvpn.relaylist.RelayItem import net.mullvad.mullvadvpn.relaylist.filterOnSearchTerm import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy +import net.mullvad.mullvadvpn.usecase.RelayListFilterUseCase import net.mullvad.mullvadvpn.usecase.RelayListUseCase class SelectLocationViewModel( private val serviceConnectionManager: ServiceConnectionManager, - private val relayListUseCase: RelayListUseCase + private val relayListUseCase: RelayListUseCase, + private val relayListFilterUseCase: RelayListFilterUseCase ) : ViewModel() { + private val _closeAction = MutableSharedFlow() private val _enterTransitionEndAction = MutableSharedFlow() private val _searchTerm = MutableStateFlow(EMPTY_SEARCH_TERM) val uiState = - combine(relayListUseCase.relayListWithSelection(), _searchTerm) { + combine( + relayListUseCase.relayListWithSelection(), + _searchTerm, + relayListFilterUseCase.selectedOwnership(), + relayListFilterUseCase.availableProviders(), + relayListFilterUseCase.selectedProviders() + ) { (relayCountries, relayItem), - searchTerm -> + searchTerm, + selectedOwnership, + allProviders, + selectedConstraintProviders -> + val selectedProviders = + selectedConstraintProviders.toSelectedProviders(allProviders) + + val selectedProvidersByOwnershipList = + filterSelectedProvidersByOwnership( + selectedProviders, + selectedOwnership.toNullableOwnership() + ) + + val allProvidersByOwnershipListList = + filterAllProvidersByOwnership( + allProviders, + selectedOwnership.toNullableOwnership() + ) + val filteredRelayCountries = relayCountries.filterOnSearchTerm(searchTerm, relayItem) - if (searchTerm.isNotEmpty() && filteredRelayCountries.isEmpty()) { - SelectLocationUiState.NoSearchResultFound(searchTerm = searchTerm) - } else { - SelectLocationUiState.ShowData( - countries = filteredRelayCountries, - selectedRelay = relayItem - ) - } + SelectLocationUiState.ShowData( + searchTerm = searchTerm, + countries = filteredRelayCountries, + selectedRelay = relayItem, + selectedOwnership = selectedOwnership.toNullableOwnership(), + selectedProvidersCount = + if ( + selectedProvidersByOwnershipList.size == + allProvidersByOwnershipListList.size + ) + null + else selectedProvidersByOwnershipList.size + ) } .stateIn( viewModelScope, @@ -47,6 +85,7 @@ class SelectLocationViewModel( @Suppress("konsist.ensure public properties use permitted names") val uiCloseAction = _closeAction.asSharedFlow() + @Suppress("konsist.ensure public properties use permitted names") val enterTransitionEndAction = _enterTransitionEndAction.asSharedFlow() @@ -64,6 +103,46 @@ class SelectLocationViewModel( viewModelScope.launch { _searchTerm.emit(searchTerm) } } + private fun filterSelectedProvidersByOwnership( + selectedProviders: List, + selectedOwnership: Ownership? + ): List { + return when (selectedOwnership) { + Ownership.MullvadOwned -> selectedProviders.filter { it.mullvadOwned } + Ownership.Rented -> selectedProviders.filterNot { it.mullvadOwned } + else -> selectedProviders + } + } + + private fun filterAllProvidersByOwnership( + allProviders: List, + selectedOwnership: Ownership? + ): List { + return when (selectedOwnership) { + Ownership.MullvadOwned -> allProviders.filter { it.mullvadOwned } + Ownership.Rented -> allProviders.filterNot { it.mullvadOwned } + else -> allProviders + } + } + + fun removeOwnerFilter() { + viewModelScope.launch { + relayListFilterUseCase.updateOwnershipAndProviderFilter( + Constraint.Any(), + relayListFilterUseCase.selectedProviders().first() + ) + } + } + + fun removeProviderFilter() { + viewModelScope.launch { + relayListFilterUseCase.updateOwnershipAndProviderFilter( + relayListFilterUseCase.selectedOwnership().first(), + Constraint.Any() + ) + } + } + companion object { private const val EMPTY_SEARCH_TERM = "" } diff --git a/android/lib/resource/src/main/res/drawable/icons_more_circle.xml b/android/lib/resource/src/main/res/drawable/icons_more_circle.xml new file mode 100644 index 000000000000..2f7800ccf377 --- /dev/null +++ b/android/lib/resource/src/main/res/drawable/icons_more_circle.xml @@ -0,0 +1,13 @@ + + +