summaryrefslogtreecommitdiffhomepage
path: root/android
diff options
context:
space:
mode:
authorMaryamShaghaghi <122574719+MaryamShaghaghi@users.noreply.github.com>2023-11-27 14:35:11 +0100
committerMaryamShaghaghi <122574719+MaryamShaghaghi@users.noreply.github.com>2023-12-04 11:30:23 +0100
commit63025aaa3152c4bddea0b960bf663c9a5797d59d (patch)
treed10fa9cdebedc4daff175507162d92877a01f833 /android
parentfeb76dafe6cea14b8f121616c778385743e36d8b (diff)
downloadmullvadvpn-63025aaa3152c4bddea0b960bf663c9a5797d59d.tar.xz
mullvadvpn-63025aaa3152c4bddea0b960bf663c9a5797d59d.zip
Add filter screen
Co-Authored-By: Boban Sijuk <49131853+boki91@users.noreply.github.com>
Diffstat (limited to 'android')
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/ApplyButton.kt38
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreen.kt193
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/FilterConstrainExtensions.kt34
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayFilterState.kt35
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/FilterFragment.kt42
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/FilterViewModel.kt107
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)
+ }
+ }
+}