diff options
| author | MaryamShaghaghi <122574719+MaryamShaghaghi@users.noreply.github.com> | 2023-11-27 14:35:11 +0100 |
|---|---|---|
| committer | MaryamShaghaghi <122574719+MaryamShaghaghi@users.noreply.github.com> | 2023-12-04 11:30:23 +0100 |
| commit | 63025aaa3152c4bddea0b960bf663c9a5797d59d (patch) | |
| tree | d10fa9cdebedc4daff175507162d92877a01f833 /android | |
| parent | feb76dafe6cea14b8f121616c778385743e36d8b (diff) | |
| download | mullvadvpn-63025aaa3152c4bddea0b960bf663c9a5797d59d.tar.xz mullvadvpn-63025aaa3152c4bddea0b960bf663c9a5797d59d.zip | |
Add filter screen
Co-Authored-By: Boban Sijuk <49131853+boki91@users.noreply.github.com>
Diffstat (limited to 'android')
6 files changed, 449 insertions, 0 deletions
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/ApplyButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/ApplyButton.kt new file mode 100644 index 0000000000..bea3064548 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/ApplyButton.kt @@ -0,0 +1,38 @@ +package net.mullvad.mullvadvpn.compose.button + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.component.SpacedColumn +import net.mullvad.mullvadvpn.lib.theme.AppTheme + +@Preview +@Composable +private fun PreviewApplyButton() { + AppTheme { + SpacedColumn { + ApplyButton(onClick = {}, isEnabled = true) + ApplyButton(onClick = {}, isEnabled = false) + } + } +} + +@Composable +fun ApplyButton( + modifier: Modifier = Modifier, + background: Color = MaterialTheme.colorScheme.background, + onClick: () -> Unit, + isEnabled: Boolean +) { + VariantButton( + background = background, + text = stringResource(id = R.string.apply), + onClick = onClick, + modifier = modifier, + isEnabled = isEnabled, + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreen.kt new file mode 100644 index 0000000000..844360c16c --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreen.kt @@ -0,0 +1,193 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Divider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.button.ApplyButton +import net.mullvad.mullvadvpn.compose.cell.CheckboxCell +import net.mullvad.mullvadvpn.compose.cell.ExpandableComposeCell +import net.mullvad.mullvadvpn.compose.cell.SelectableCell +import net.mullvad.mullvadvpn.compose.state.RelayFilterState +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.model.Ownership +import net.mullvad.mullvadvpn.relaylist.Provider + +@Preview +@Composable +private fun PreviewFilterScreen() { + val state = + RelayFilterState( + selectedOwnership = null, + allProviders = listOf(), + selectedProviders = listOf(), + ) + AppTheme { + FilterScreen( + uiState = state, + onSelectedOwnership = {}, + onSelectedProviders = { _, _ -> }, + onAllProviderCheckChange = {}, + uiCloseAction = MutableSharedFlow() + ) + } +} + +@Composable +fun FilterScreen( + uiState: RelayFilterState, + onBackClick: () -> Unit = {}, + uiCloseAction: SharedFlow<Unit>, + onApplyClick: () -> Unit = {}, + onSelectedOwnership: (ownership: Ownership?) -> Unit = {}, + onAllProviderCheckChange: (isChecked: Boolean) -> Unit = {}, + onSelectedProviders: (checked: Boolean, provider: Provider) -> Unit +) { + var providerExpanded by rememberSaveable { mutableStateOf(false) } + var ownershipExpanded by rememberSaveable { mutableStateOf(false) } + + val backgroundColor = MaterialTheme.colorScheme.background + + LaunchedEffect(Unit) { uiCloseAction.collect { onBackClick() } } + Scaffold( + topBar = { + Row( + Modifier.padding( + horizontal = Dimens.selectFilterTitlePadding, + vertical = Dimens.selectFilterTitlePadding + ) + .fillMaxWidth(), + ) { + Image( + painter = painterResource(id = R.drawable.icon_back), + contentDescription = null, + modifier = Modifier.size(Dimens.titleIconSize).clickable(onClick = onBackClick) + ) + Text( + text = stringResource(R.string.filter), + modifier = + Modifier.align(Alignment.CenterVertically) + .weight(weight = 1f) + .padding(end = Dimens.titleIconSize), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onPrimary + ) + } + }, + bottomBar = { + Box( + modifier = + Modifier.fillMaxWidth() + .padding(top = Dimens.screenVerticalMargin) + .clickable(enabled = false, onClick = onApplyClick) + .background(color = backgroundColor), + contentAlignment = Alignment.BottomCenter + ) { + ApplyButton( + onClick = onApplyClick, + isEnabled = uiState.isApplyButtonEnabled, + modifier = + Modifier.padding( + start = Dimens.sideMargin, + end = Dimens.sideMargin, + bottom = Dimens.screenVerticalMargin + ), + ) + } + }, + ) { contentPadding -> + LazyColumn( + modifier = Modifier.padding(contentPadding).background(backgroundColor).fillMaxSize() + ) { + item { + Divider() + ExpandableComposeCell( + title = stringResource(R.string.ownership), + isExpanded = ownershipExpanded, + isEnabled = true, + onInfoClicked = null, + onCellClicked = { ownershipExpanded = !ownershipExpanded } + ) + } + if (ownershipExpanded) { + item { + SelectableCell( + title = stringResource(id = R.string.any), + isSelected = uiState.selectedOwnership == null, + onCellClicked = { onSelectedOwnership(null) } + ) + } + items(uiState.filteredOwnershipByProviders) { ownership -> + Divider() + SelectableCell( + title = stringResource(id = ownership.stringResource()), + isSelected = ownership == uiState.selectedOwnership, + onCellClicked = { onSelectedOwnership(ownership) } + ) + } + } + item { + Divider() + ExpandableComposeCell( + title = stringResource(R.string.providers), + isExpanded = providerExpanded, + isEnabled = true, + onInfoClicked = null, + onCellClicked = { providerExpanded = !providerExpanded } + ) + } + if (providerExpanded) { + item { + Divider() + CheckboxCell( + providerName = stringResource(R.string.all_providers), + checked = uiState.isAllProvidersChecked, + onCheckedChange = { isChecked -> onAllProviderCheckChange(isChecked) } + ) + } + items(uiState.filteredProvidersByOwnership) { provider -> + Divider() + CheckboxCell( + providerName = provider.name, + checked = provider in uiState.selectedProviders, + onCheckedChange = { checked -> onSelectedProviders(checked, provider) } + ) + } + } + } + } +} + +private fun Ownership.stringResource(): Int = + when (this) { + Ownership.MullvadOwned -> R.string.mullvad_owned_only + Ownership.Rented -> R.string.rented_only + } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/FilterConstrainExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/FilterConstrainExtensions.kt new file mode 100644 index 0000000000..8a65c64b01 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/FilterConstrainExtensions.kt @@ -0,0 +1,34 @@ +package net.mullvad.mullvadvpn.compose.state + +import net.mullvad.mullvadvpn.model.Constraint +import net.mullvad.mullvadvpn.model.Ownership +import net.mullvad.mullvadvpn.model.Providers +import net.mullvad.mullvadvpn.relaylist.Provider + +fun Constraint<Ownership>.toNullableOwnership(): Ownership? = + when (this) { + is Constraint.Any -> null + is Constraint.Only -> this.value + } + +fun Ownership?.toOwnershipConstraint(): Constraint<Ownership> = + when (this) { + null -> Constraint.Any() + else -> Constraint.Only(this) + } + +fun Constraint<Providers>.toSelectedProviders(allProviders: List<Provider>): List<Provider> = + when (this) { + is Constraint.Any -> allProviders + is Constraint.Only -> + this.value.providers.toList().mapNotNull { providerName -> + allProviders.firstOrNull { it.name == providerName } + } + } + +fun List<Provider>.toConstraintProviders(allProviders: List<Provider>): Constraint<Providers> = + if (size == allProviders.size) { + Constraint.Any() + } else { + Constraint.Only(Providers(map { provider -> provider.name }.toHashSet())) + } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayFilterState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayFilterState.kt new file mode 100644 index 0000000000..664f03ce40 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayFilterState.kt @@ -0,0 +1,35 @@ +package net.mullvad.mullvadvpn.compose.state + +import net.mullvad.mullvadvpn.model.Ownership +import net.mullvad.mullvadvpn.relaylist.Provider + +data class RelayFilterState( + val selectedOwnership: Ownership? = null, + val allProviders: List<Provider> = emptyList(), + val selectedProviders: List<Provider> = allProviders +) { + val isApplyButtonEnabled = selectedProviders.isNotEmpty() + + val filteredOwnershipByProviders = + if (selectedProviders.isEmpty()) { + Ownership.entries + } else { + Ownership.entries.filter { ownership -> + selectedProviders.any { provider -> + if (provider.mullvadOwned) { + ownership == Ownership.MullvadOwned + } else { + ownership == Ownership.Rented + } + } + } + } + val filteredProvidersByOwnership = + when (selectedOwnership) { + Ownership.MullvadOwned -> allProviders.filter { it.mullvadOwned } + Ownership.Rented -> allProviders.filterNot { it.mullvadOwned } + else -> allProviders + } + + val isAllProvidersChecked = allProviders.size == selectedProviders.size +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/FilterFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/FilterFragment.kt new file mode 100644 index 0000000000..17357d8cbf --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/FilterFragment.kt @@ -0,0 +1,42 @@ +package net.mullvad.mullvadvpn.ui.fragment + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.screen.FilterScreen +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.viewmodel.FilterViewModel +import org.koin.androidx.viewmodel.ext.android.viewModel + +class FilterFragment : Fragment() { + + private val vm by viewModel<FilterViewModel>() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_compose, container, false).apply { + findViewById<ComposeView>(R.id.compose_view).setContent { + AppTheme { + val state = vm.uiState.collectAsState().value + FilterScreen( + uiState = state, + onSelectedOwnership = vm::setSelectedOwnership, + onAllProviderCheckChange = vm::setAllProviders, + onSelectedProviders = vm::setSelectedProvider, + uiCloseAction = vm.uiSideEffect, + onBackClick = { activity?.onBackPressedDispatcher?.onBackPressed() }, + onApplyClick = vm::onApplyButtonClicked + ) + } + } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/FilterViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/FilterViewModel.kt new file mode 100644 index 0000000000..9178a22110 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/FilterViewModel.kt @@ -0,0 +1,107 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +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.RelayFilterState +import net.mullvad.mullvadvpn.compose.state.toConstraintProviders +import net.mullvad.mullvadvpn.compose.state.toNullableOwnership +import net.mullvad.mullvadvpn.compose.state.toOwnershipConstraint +import net.mullvad.mullvadvpn.compose.state.toSelectedProviders +import net.mullvad.mullvadvpn.model.Ownership +import net.mullvad.mullvadvpn.relaylist.Provider +import net.mullvad.mullvadvpn.usecase.RelayListFilterUseCase + +class FilterViewModel( + private val relayListFilterUseCase: RelayListFilterUseCase, +) : ViewModel() { + private val _uiSideEffect = MutableSharedFlow<Unit>() + val uiSideEffect = _uiSideEffect.asSharedFlow() + + private val selectedOwnership = MutableStateFlow<Ownership?>(null) + private val selectedProviders = MutableStateFlow<List<Provider>>(emptyList()) + + init { + viewModelScope.launch { + selectedProviders.value = + combine( + relayListFilterUseCase.availableProviders(), + relayListFilterUseCase.selectedProviders(), + ) { allProviders, selectedConstraintProviders -> + selectedConstraintProviders.toSelectedProviders(allProviders) + } + .first() + + val ownershipConstraint = relayListFilterUseCase.selectedOwnership().first() + selectedOwnership.value = ownershipConstraint.toNullableOwnership() + } + } + + val uiState: StateFlow<RelayFilterState> = + combine( + selectedOwnership, + relayListFilterUseCase.availableProviders(), + selectedProviders, + ) { selectedOwnership, allProviders, selectedProviders -> + RelayFilterState( + selectedOwnership = selectedOwnership, + allProviders = allProviders, + selectedProviders = selectedProviders + ) + } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(), + RelayFilterState( + allProviders = emptyList(), + selectedOwnership = null, + selectedProviders = emptyList() + ), + ) + + fun setSelectedOwnership(ownership: Ownership?) { + selectedOwnership.value = ownership + } + + fun setSelectedProvider(checked: Boolean, provider: Provider) { + selectedProviders.value = + if (checked) { + selectedProviders.value + provider + } else { + selectedProviders.value - provider + } + } + + fun setAllProviders(isChecked: Boolean) { + viewModelScope.launch { + selectedProviders.value = + if (isChecked) { + relayListFilterUseCase.availableProviders().first() + } else { + emptyList() + } + } + } + + fun onApplyButtonClicked() { + val newSelectedOwnership = selectedOwnership.value.toOwnershipConstraint() + val newSelectedProviders = + selectedProviders.value.toConstraintProviders(uiState.value.allProviders) + + viewModelScope.launch { + relayListFilterUseCase.updateOwnershipAndProviderFilter( + newSelectedOwnership, + newSelectedProviders + ) + _uiSideEffect.emit(Unit) + } + } +} |
