summaryrefslogtreecommitdiffhomepage
path: root/android
diff options
context:
space:
mode:
authorDavid Göransson <david.goransson@mullvad.net>2024-07-25 14:33:50 +0200
committerDavid Göransson <david.goransson@mullvad.net>2024-07-25 14:40:25 +0200
commit2e2ab93340ae05fda8446a3e74f41dd1276789be (patch)
treed5d5003f6050b80ed2b9bf6cb28a05ae2ce850d9 /android
parent5c24fc35aa8369d2d49767dca73ce8ae391a11e0 (diff)
downloadmullvadvpn-2e2ab93340ae05fda8446a3e74f41dd1276789be.tar.xz
mullvadvpn-2e2ab93340ae05fda8446a3e74f41dd1276789be.zip
Convert select location into flat LazyColumn
Diffstat (limited to 'android')
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/data/DummyRelayItems.kt26
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/FilterRow.kt (renamed from android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/FilterCell.kt)60
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt263
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemCheckableCellPreviewParameterProvider.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemPreviewData.kt10
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemStatusCellPreviewParameterProvider.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreen.kt5
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt432
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/FilterConstrainExtensions.kt6
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt100
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/CustomListExtensions.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListExtensions.kt137
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/FilterCustomListsRelayItemUseCase.kt30
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt52
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/FilterViewModel.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt371
-rw-r--r--android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/FromDomain.kt8
-rw-r--r--android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt2
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItem.kt42
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItemId.kt11
-rw-r--r--android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt4
22 files changed, 856 insertions, 720 deletions
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/data/DummyRelayItems.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/data/DummyRelayItems.kt
index 0218e06afd..052f2d897a 100644
--- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/data/DummyRelayItems.kt
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/data/DummyRelayItems.kt
@@ -40,29 +40,25 @@ private val DUMMY_RELAY_2 =
private val DUMMY_RELAY_CITY_1 =
RelayItem.Location.City(
name = "Relay City 1",
- id = GeoLocationId.City(countryCode = GeoLocationId.Country("RCo1"), cityCode = "RCi1"),
+ id = GeoLocationId.City(country = GeoLocationId.Country("RCo1"), code = "RCi1"),
relays = listOf(DUMMY_RELAY_1),
- expanded = false
)
private val DUMMY_RELAY_CITY_2 =
RelayItem.Location.City(
name = "Relay City 2",
- id = GeoLocationId.City(countryCode = GeoLocationId.Country("RCo2"), cityCode = "RCi2"),
+ id = GeoLocationId.City(country = GeoLocationId.Country("RCo2"), code = "RCi2"),
relays = listOf(DUMMY_RELAY_2),
- expanded = false
)
private val DUMMY_RELAY_COUNTRY_1 =
RelayItem.Location.Country(
name = "Relay Country 1",
id = GeoLocationId.Country("RCo1"),
- expanded = false,
cities = listOf(DUMMY_RELAY_CITY_1)
)
private val DUMMY_RELAY_COUNTRY_2 =
RelayItem.Location.Country(
name = "Relay Country 2",
id = GeoLocationId.Country("RCo2"),
- expanded = false,
cities = listOf(DUMMY_RELAY_CITY_2)
)
@@ -80,15 +76,21 @@ val DUMMY_RELAY_LIST =
val DUMMY_RELAY_ITEM_CUSTOM_LISTS =
listOf(
RelayItem.CustomList(
- customListName = CustomListName.fromString("First list"),
- expanded = false,
- id = CustomListId("1"),
+ customList =
+ CustomList(
+ name = CustomListName.fromString("First list"),
+ id = CustomListId("1"),
+ locations = emptyList()
+ ),
locations = DUMMY_RELAY_COUNTRIES
),
RelayItem.CustomList(
- customListName = CustomListName.fromString("Empty list"),
- expanded = false,
- id = CustomListId("2"),
+ customList =
+ CustomList(
+ name = CustomListName.fromString("Empty list"),
+ id = CustomListId("2"),
+ locations = emptyList()
+ ),
locations = emptyList()
)
)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/FilterCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/FilterRow.kt
index 6dfd8f3eb1..39f6396132 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/FilterCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/FilterRow.kt
@@ -1,11 +1,10 @@
package net.mullvad.mullvadvpn.compose.cell
import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -16,6 +15,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.component.MullvadFilterChip
+import net.mullvad.mullvadvpn.compose.state.FilterChip
import net.mullvad.mullvadvpn.lib.model.Ownership
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
@@ -24,21 +24,19 @@ import net.mullvad.mullvadvpn.lib.theme.Dimens
@Composable
private fun PreviewFilterCell() {
AppTheme {
- FilterCell(
- ownershipFilter = Ownership.MullvadOwned,
- selectedProviderFilter = 3,
- removeOwnershipFilter = {},
- removeProviderFilter = {}
+ FilterRow(
+ listOf(FilterChip.Ownership(Ownership.MullvadOwned), FilterChip.Provider(2)),
+ {},
+ {}
)
}
}
@Composable
-fun FilterCell(
- ownershipFilter: Ownership?,
- selectedProviderFilter: Int?,
- removeOwnershipFilter: () -> Unit,
- removeProviderFilter: () -> Unit
+fun FilterRow(
+ filters: List<FilterChip>,
+ onRemoveOwnershipFilter: () -> Unit,
+ onRemoveProviderFilter: () -> Unit
) {
val scrollState = rememberScrollState()
Row(
@@ -49,31 +47,39 @@ fun FilterCell(
horizontal = Dimens.searchFieldHorizontalPadding,
)
.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(Dimens.chipSpace)
) {
Text(
- modifier = Modifier.padding(end = Dimens.filterTittlePadding),
text = stringResource(id = R.string.filtered),
color = MaterialTheme.colorScheme.onPrimary,
style = MaterialTheme.typography.labelMedium
)
-
- if (selectedProviderFilter != null) {
- MullvadFilterChip(
- text = stringResource(id = R.string.number_of_providers, selectedProviderFilter),
- onRemoveClick = removeProviderFilter
- )
- Spacer(modifier = Modifier.size(Dimens.chipSpace))
- }
-
- if (ownershipFilter != null) {
- MullvadFilterChip(
- text = stringResource(ownershipFilter.stringResources()),
- onRemoveClick = removeOwnershipFilter
- )
+ filters.forEach {
+ when (it) {
+ is FilterChip.Ownership ->
+ OwnershipFilterChip(it.ownership, onRemoveOwnershipFilter)
+ is FilterChip.Provider -> ProviderFilterChip(it.count, onRemoveProviderFilter)
+ }
}
}
}
+@Composable
+fun ProviderFilterChip(providers: Int, onRemoveClick: () -> Unit) {
+ MullvadFilterChip(
+ text = stringResource(id = R.string.number_of_providers, providers),
+ onRemoveClick = onRemoveClick
+ )
+}
+
+@Composable
+fun OwnershipFilterChip(ownership: Ownership, onRemoveClick: () -> Unit) {
+ MullvadFilterChip(
+ text = stringResource(ownership.stringResources()),
+ onRemoveClick = onRemoveClick
+ )
+}
+
private fun Ownership.stringResources(): Int =
when (this) {
Ownership.MullvadOwned -> R.string.owned
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt
index 9f1fef4e08..cf9682bcbe 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt
@@ -1,12 +1,10 @@
package net.mullvad.mullvadvpn.compose.cell
-import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
@@ -16,15 +14,12 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@@ -33,34 +28,17 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
-import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.component.Chevron
import net.mullvad.mullvadvpn.compose.component.MullvadCheckbox
import net.mullvad.mullvadvpn.compose.preview.RelayItemCheckableCellPreviewParameterProvider
-import net.mullvad.mullvadvpn.compose.preview.RelayItemStatusCellPreviewParameterProvider
import net.mullvad.mullvadvpn.lib.model.RelayItem
-import net.mullvad.mullvadvpn.lib.model.RelayItemId
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
import net.mullvad.mullvadvpn.lib.theme.color.AlphaInactive
-import net.mullvad.mullvadvpn.lib.theme.color.AlphaInvisible
import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible
import net.mullvad.mullvadvpn.lib.theme.color.selected
-import net.mullvad.mullvadvpn.relaylist.children
-
-@Composable
-@Preview
-private fun PreviewStatusRelayLocationCell(
- @PreviewParameter(RelayItemStatusCellPreviewParameterProvider::class)
- relayItems: List<RelayItem.Location.Country>
-) {
- AppTheme {
- Column(Modifier.background(color = MaterialTheme.colorScheme.background)) {
- relayItems.map { StatusRelayLocationCell(relay = it) }
- }
- }
-}
@Composable
@Preview
@@ -70,169 +48,149 @@ private fun PreviewCheckableRelayLocationCell(
) {
AppTheme {
Column(Modifier.background(color = MaterialTheme.colorScheme.background)) {
- relayItems.map { CheckableRelayLocationCell(relay = it) }
+ relayItems.map {
+ CheckableRelayLocationCell(
+ item = it,
+ checked = false,
+ expanded = false,
+ depth = 0,
+ onExpand = {}
+ )
+ }
}
}
}
+@OptIn(ExperimentalFoundationApi::class)
@Composable
-fun StatusRelayLocationCell(
- relay: RelayItem,
+fun StatusRelayItemCell(
+ item: RelayItem,
+ isSelected: Boolean,
modifier: Modifier = Modifier,
+ onClick: () -> Unit = {},
+ onLongClick: (() -> Unit)? = null,
+ onToggleExpand: ((Boolean) -> Unit) = {},
+ isExpanded: Boolean = false,
+ depth: Int = 0,
activeColor: Color = MaterialTheme.colorScheme.selected,
inactiveColor: Color = MaterialTheme.colorScheme.error,
disabledColor: Color = MaterialTheme.colorScheme.onSecondary,
- selectedItem: RelayItemId? = null,
- onSelectRelay: (item: RelayItem) -> Unit = {},
- onLongClick: (item: RelayItem) -> Unit = {},
) {
- RelayLocationCell(
- relay = relay,
- leadingContent = { relayItem ->
- val selected = selectedItem == relayItem.id
- Box(
- modifier =
- Modifier.align(Alignment.CenterStart)
- .size(Dimens.relayCircleSize)
- .background(
- color =
- when {
- selected -> Color.Unspecified
- relayItem is RelayItem.CustomList && !relayItem.hasChildren ->
- disabledColor
- relayItem.active -> activeColor
- else -> inactiveColor
- },
- shape = CircleShape
- )
- )
- Icon(
- painter = painterResource(id = R.drawable.icon_tick),
- modifier =
- Modifier.align(Alignment.CenterStart)
- .alpha(
- if (selected) {
- AlphaVisible
- } else {
- AlphaInvisible
- }
- ),
- tint = Color.Unspecified,
- contentDescription = null
- )
- },
+
+ RelayItemCell(
modifier = modifier,
- specialBackgroundColor = { relayItem ->
- when {
- selectedItem == relayItem.id -> MaterialTheme.colorScheme.selected
- relayItem is RelayItem.CustomList && !relayItem.active ->
- MaterialTheme.colorScheme.surfaceTint
- else -> null
+ item,
+ isSelected,
+ leadingContent = {
+ if (isSelected) {
+ Icon(
+ painter = painterResource(id = R.drawable.icon_tick),
+ contentDescription = null
+ )
+ } else {
+ Box(
+ modifier =
+ Modifier.padding(4.dp)
+ .size(Dimens.relayCircleSize)
+ .background(
+ color =
+ when {
+ isSelected -> Color.Unspecified
+ item is RelayItem.CustomList && item.locations.isEmpty() ->
+ disabledColor
+ item.active -> activeColor
+ else -> inactiveColor
+ },
+ shape = CircleShape
+ )
+ )
}
},
- onClick = onSelectRelay,
+ onClick = onClick,
onLongClick = onLongClick,
- depth = 0
- )
-}
-
-@Composable
-fun CheckableRelayLocationCell(
- relay: RelayItem,
- modifier: Modifier = Modifier,
- onRelayCheckedChange: (item: RelayItem, isChecked: Boolean) -> Unit = { _, _ -> },
- selectedRelays: Set<RelayItem> = emptySet(),
-) {
- RelayLocationCell(
- relay = relay,
- leadingContent = { relayItem ->
- val checked = selectedRelays.contains(relayItem)
- MullvadCheckbox(
- checked = checked,
- onCheckedChange = { isChecked -> onRelayCheckedChange(relayItem, isChecked) }
- )
- },
- leadingContentStartPadding = Dimens.cellStartPaddingInteractive,
- modifier = modifier,
- onClick = { onRelayCheckedChange(it, !selectedRelays.contains(it)) },
- onLongClick = null,
- depth = 0
+ onToggleExpand = onToggleExpand,
+ isExpanded = isExpanded,
+ depth = depth,
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
-private fun RelayLocationCell(
- relay: RelayItem,
- leadingContent: @Composable BoxScope.(relay: RelayItem) -> Unit,
+fun RelayItemCell(
modifier: Modifier = Modifier,
- leadingContentStartPadding: Dp = Dimens.cellStartPadding,
- leadingContentStarPaddingModifier: Dp = Dimens.mediumPadding,
- specialBackgroundColor: @Composable (relayItem: RelayItem) -> Color? = { null },
- onClick: (item: RelayItem) -> Unit,
- onLongClick: ((item: RelayItem) -> Unit)?,
- depth: Int
+ item: RelayItem,
+ isSelected: Boolean,
+ leadingContent: (@Composable RowScope.() -> Unit)? = null,
+ onClick: () -> Unit,
+ onLongClick: (() -> Unit)? = null,
+ onToggleExpand: ((Boolean) -> Unit),
+ isExpanded: Boolean,
+ depth: Int,
) {
+
+ val leadingContentStartPadding = Dimens.cellStartPadding
+ val leadingContentStarPaddingModifier = Dimens.mediumPadding
val startPadding = leadingContentStartPadding + leadingContentStarPaddingModifier * depth
- val expanded =
- rememberSaveable(key = relay.expanded.toString()) { mutableStateOf(relay.expanded) }
- Column(
+ Row(
modifier =
modifier
.fillMaxWidth()
- .padding(top = Dimens.listItemDivider)
- .wrapContentHeight()
- .fillMaxWidth()
- ) {
- Row(
- modifier =
- Modifier.align(Alignment.Start)
- .wrapContentHeight()
- .height(IntrinsicSize.Min)
- .fillMaxWidth()
- .background(specialBackgroundColor.invoke(relay) ?: depth.toBackgroundColor())
- ) {
- Row(
- modifier =
- Modifier.weight(1f)
- .combinedClickable(
- enabled = relay.active,
- onClick = { onClick(relay) },
- onLongClick = { onLongClick?.invoke(relay) },
- )
- ) {
- Box(
- modifier =
- Modifier.align(Alignment.CenterVertically).padding(start = startPadding)
- ) {
- leadingContent(relay)
- }
- Name(
- modifier = Modifier.weight(1f).align(Alignment.CenterVertically),
- relay = relay
+ .height(IntrinsicSize.Min)
+ .background(
+ when {
+ isSelected -> MaterialTheme.colorScheme.selected
+ item is RelayItem.CustomList && !item.active ->
+ MaterialTheme.colorScheme.surfaceTint
+ else -> depth.toBackgroundColor()
+ }
)
- }
- if (relay.hasChildren) {
- ExpandButton(isExpanded = expanded.value) { expand -> expanded.value = expand }
- }
- }
- if (expanded.value) {
- relay.children().forEach {
- RelayLocationCell(
- relay = it,
+ .combinedClickable(
+ enabled = item.active,
onClick = onClick,
- modifier = Modifier.animateContentSize(),
- leadingContent = leadingContent,
- specialBackgroundColor = specialBackgroundColor,
onLongClick = onLongClick,
- depth = depth + 1,
)
- }
+ .padding(start = startPadding),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (leadingContent != null) {
+ leadingContent()
+ }
+ Name(modifier = Modifier.weight(1f), relay = item)
+
+ if (item.hasChildren) {
+ ExpandButton(isExpanded = isExpanded, onClick = { onToggleExpand(!isExpanded) })
}
}
}
@Composable
+fun CheckableRelayLocationCell(
+ item: RelayItem,
+ modifier: Modifier = Modifier,
+ checked: Boolean,
+ onRelayCheckedChange: (isChecked: Boolean) -> Unit = { _ -> },
+ expanded: Boolean,
+ onExpand: (Boolean) -> Unit,
+ depth: Int
+) {
+ RelayItemCell(
+ modifier = modifier,
+ item = item,
+ isSelected = false,
+ leadingContent = {
+ MullvadCheckbox(
+ checked = checked,
+ onCheckedChange = { isChecked -> onRelayCheckedChange(isChecked) }
+ )
+ },
+ onClick = { onRelayCheckedChange(!checked) },
+ onToggleExpand = onExpand,
+ isExpanded = expanded,
+ depth = depth
+ )
+}
+
+@Composable
private fun Name(modifier: Modifier = Modifier, relay: RelayItem) {
Text(
text = relay.name,
@@ -275,6 +233,5 @@ private fun Int.toBackgroundColor(): Color =
0 -> MaterialTheme.colorScheme.surfaceContainerHighest
1 -> MaterialTheme.colorScheme.surfaceContainerHigh
2 -> MaterialTheme.colorScheme.surfaceContainerLow
- 3 -> MaterialTheme.colorScheme.surfaceContainerLowest
else -> MaterialTheme.colorScheme.surfaceContainerLowest
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemCheckableCellPreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemCheckableCellPreviewParameterProvider.kt
index c0cae0128f..bdf1ace173 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemCheckableCellPreviewParameterProvider.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemCheckableCellPreviewParameterProvider.kt
@@ -18,14 +18,11 @@ class RelayItemCheckableCellPreviewParameterProvider :
name = "Relay country Expanded",
cityNames = listOf("Normal city"),
relaysPerCity = 2,
- expanded = true
),
generateRelayItemCountry(
name = "Country and city Expanded",
cityNames = listOf("Expanded city A", "Expanded city B"),
relaysPerCity = 2,
- expanded = true,
- expandChildren = true
)
)
)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemPreviewData.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemPreviewData.kt
index afaf81ac55..c1b42c9415 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemPreviewData.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemPreviewData.kt
@@ -12,8 +12,6 @@ internal object RelayItemPreviewData {
cityNames: List<String>,
relaysPerCity: Int,
active: Boolean = true,
- expanded: Boolean = false,
- expandChildren: Boolean = false,
) =
RelayItem.Location.Country(
name = name,
@@ -25,10 +23,8 @@ internal object RelayItemPreviewData {
name.generateCountryCode(),
relaysPerCity,
active,
- expandChildren
)
},
- expanded = expanded,
)
}
@@ -37,7 +33,6 @@ private fun generateRelayItemCity(
countryCode: GeoLocationId.Country,
numberOfRelays: Int,
active: Boolean = true,
- expanded: Boolean = false,
) =
RelayItem.Location.City(
name = name,
@@ -50,7 +45,6 @@ private fun generateRelayItemCity(
active
)
},
- expanded = expanded,
)
private fun generateRelayItemRelay(
@@ -62,7 +56,7 @@ private fun generateRelayItemRelay(
id =
GeoLocationId.Hostname(
city = cityCode,
- hostname = hostName,
+ code = hostName,
),
active = active,
provider = Provider(ProviderId("Provider"), Ownership.MullvadOwned),
@@ -75,6 +69,6 @@ private fun String.generateCityCode(countryCode: GeoLocationId.Country) =
GeoLocationId.City(countryCode, take(CITY_CODE_LENGTH).lowercase())
private fun generateHostname(city: GeoLocationId.City, index: Int) =
- "${city.countryCode.countryCode}-${city.cityCode}-wg-${index+1}"
+ "${city.country.code}-${city.code}-wg-${index+1}"
private const val CITY_CODE_LENGTH = 3
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemStatusCellPreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemStatusCellPreviewParameterProvider.kt
index 26ea644185..a825975b0f 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemStatusCellPreviewParameterProvider.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemStatusCellPreviewParameterProvider.kt
@@ -24,14 +24,11 @@ class RelayItemStatusCellPreviewParameterProvider :
name = "Relay country Expanded",
cityNames = listOf("Normal city"),
relaysPerCity = 2,
- expanded = true
),
generateRelayItemCountry(
name = "Country and city Expanded",
cityNames = listOf("Expanded city A", "Expanded city B"),
relaysPerCity = 2,
- expanded = true,
- expandChildren = true
)
)
)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreen.kt
index c3f7662ea2..365328ea93 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreen.kt
@@ -65,10 +65,7 @@ private fun PreviewEditCustomListScreen() {
locations =
listOf(
GeoLocationId.Hostname(
- GeoLocationId.City(
- GeoLocationId.Country("country"),
- cityCode = "city"
- ),
+ GeoLocationId.City(GeoLocationId.Country("country"), code = "city"),
"hostname",
)
)
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 555983d51d..430c1130d5 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
@@ -1,7 +1,6 @@
package net.mullvad.mullvadvpn.compose.screen
import android.content.Context
-import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.animateScrollBy
@@ -13,9 +12,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
-import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
@@ -45,7 +45,9 @@ 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 androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.compose.currentStateAsState
import androidx.lifecycle.compose.dropUnlessResumed
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
@@ -62,10 +64,10 @@ import com.ramcosta.composedestinations.result.ResultRecipient
import com.ramcosta.composedestinations.spec.DestinationSpec
import kotlinx.coroutines.launch
import net.mullvad.mullvadvpn.R
-import net.mullvad.mullvadvpn.compose.cell.FilterCell
+import net.mullvad.mullvadvpn.compose.cell.FilterRow
import net.mullvad.mullvadvpn.compose.cell.HeaderCell
import net.mullvad.mullvadvpn.compose.cell.IconCell
-import net.mullvad.mullvadvpn.compose.cell.StatusRelayLocationCell
+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.communication.Created
@@ -81,6 +83,11 @@ import net.mullvad.mullvadvpn.compose.component.MullvadSnackbar
import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar
import net.mullvad.mullvadvpn.compose.constant.ContentType
import net.mullvad.mullvadvpn.compose.extensions.dropUnlessResumed
+import net.mullvad.mullvadvpn.compose.screen.BottomSheetState.ShowCustomListsBottomSheet
+import net.mullvad.mullvadvpn.compose.screen.BottomSheetState.ShowCustomListsEntryBottomSheet
+import net.mullvad.mullvadvpn.compose.screen.BottomSheetState.ShowEditCustomListBottomSheet
+import net.mullvad.mullvadvpn.compose.screen.BottomSheetState.ShowLocationBottomSheet
+import net.mullvad.mullvadvpn.compose.state.RelayListItem
import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState
import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR
import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_CUSTOM_LIST_BOTTOM_SHEET_TEST_TAG
@@ -92,7 +99,6 @@ import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle
import net.mullvad.mullvadvpn.compose.util.RunOnKeyChange
import net.mullvad.mullvadvpn.compose.util.showSnackbarImmediately
import net.mullvad.mullvadvpn.lib.model.CustomListId
-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.theme.AppTheme
@@ -111,20 +117,9 @@ private fun PreviewSelectLocationScreen() {
val state =
SelectLocationUiState.Content(
searchTerm = "",
- selectedOwnership = null,
- selectedProvidersCount = 0,
- countries =
- listOf(
- RelayItem.Location.Country(
- GeoLocationId.Country("Country 1"),
- "Code 1",
- false,
- emptyList()
- )
- ),
- selectedItem = null,
+ emptyList(),
+ relayListItems = emptyList(),
customLists = emptyList(),
- filteredCustomLists = emptyList()
)
AppTheme {
SelectLocationScreen(
@@ -147,14 +142,16 @@ fun SelectLocation(
ResultRecipient<CustomListLocationsDestination, LocationsChanged>
) {
val vm = koinViewModel<SelectLocationViewModel>()
- val state = vm.uiState.collectAsStateWithLifecycle().value
+ val state = vm.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
-
+ val lazyListState = rememberLazyListState()
CollectSideEffectWithLifecycle(vm.uiSideEffect) {
when (it) {
- SelectLocationSideEffect.CloseScreen -> backNavigator.navigateBack(result = true)
+ SelectLocationSideEffect.CloseScreen -> {
+ backNavigator.navigateBack(result = true)
+ }
is SelectLocationSideEffect.LocationAddedToCustomList ->
launch {
snackbarHostState.showResultSnackbar(
@@ -181,6 +178,15 @@ fun SelectLocation(
}
}
+ val stateActual = state.value
+ RunOnKeyChange(stateActual is SelectLocationUiState.Content) {
+ val index = stateActual.indexOfSelectedRelayItem()
+ if (index != -1) {
+ lazyListState.scrollToItem(index)
+ lazyListState.animateScrollAndCentralizeItem(index)
+ }
+ }
+
createCustomListDialogResultRecipient.OnCustomListNavResult(
snackbarHostState,
vm::performAction
@@ -199,7 +205,8 @@ fun SelectLocation(
updateCustomListResultRecipient.OnCustomListNavResult(snackbarHostState, vm::performAction)
SelectLocationScreen(
- state = state,
+ state = state.value,
+ lazyListState = lazyListState,
snackbarHostState = snackbarHostState,
onSelectRelay = vm::selectRelay,
onSearchTermInput = vm::onSearchTermInput,
@@ -211,6 +218,7 @@ fun SelectLocation(
CreateCustomListDestination(locationCode = relayItem?.id),
)
},
+ onToggleExpand = vm::onToggleExpand,
onEditCustomLists = dropUnlessResumed { navigator.navigate(CustomListsDestination()) },
removeOwnershipFilter = vm::removeOwnerFilter,
removeProviderFilter = vm::removeProviderFilter,
@@ -221,7 +229,7 @@ fun SelectLocation(
navigator.navigate(
EditCustomListNameDestination(
customListId = customList.id,
- initialName = customList.customListName
+ initialName = customList.customList.name
),
)
},
@@ -236,7 +244,7 @@ fun SelectLocation(
navigator.navigate(
DeleteCustomListDestination(
customListId = customList.id,
- name = customList.customListName
+ name = customList.customList.name
),
)
}
@@ -248,6 +256,7 @@ fun SelectLocation(
@Composable
fun SelectLocationScreen(
state: SelectLocationUiState,
+ lazyListState: LazyListState = rememberLazyListState(),
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
onSelectRelay: (item: RelayItem) -> Unit = {},
onSearchTermInput: (searchTerm: String) -> Unit = {},
@@ -260,13 +269,13 @@ fun SelectLocationScreen(
onAddLocationToList: (location: RelayItem.Location, customList: RelayItem.CustomList) -> Unit =
{ _, _ ->
},
- onRemoveLocationFromList:
- (location: RelayItem.Location, customList: RelayItem.CustomList) -> Unit =
+ onRemoveLocationFromList: (location: RelayItem.Location, customListId: CustomListId) -> Unit =
{ _, _ ->
},
onEditCustomListName: (RelayItem.CustomList) -> Unit = {},
onEditLocationsCustomList: (RelayItem.CustomList) -> Unit = {},
- onDeleteCustomList: (RelayItem.CustomList) -> Unit = {}
+ onDeleteCustomList: (RelayItem.CustomList) -> Unit = {},
+ onToggleExpand: (RelayItemId, CustomListId?, Boolean) -> Unit = { _, _, _ -> },
) {
val backgroundColor = MaterialTheme.colorScheme.background
@@ -278,6 +287,8 @@ fun SelectLocationScreen(
)
}
) {
+ val lifecycleState = LocalLifecycleOwner.current.lifecycle.currentStateAsState()
+ Text(text = lifecycleState.value.toString())
var bottomSheetState by remember { mutableStateOf<BottomSheetState?>(null) }
BottomSheets(
bottomSheetState = bottomSheetState,
@@ -294,18 +305,8 @@ fun SelectLocationScreen(
Column(modifier = Modifier.padding(it).background(backgroundColor).fillMaxSize()) {
SelectLocationTopBar(onBackClick = onBackClick, onFilterClick = onFilterClick)
- when (state) {
- SelectLocationUiState.Loading -> {}
- is SelectLocationUiState.Content -> {
- if (state.hasFilter) {
- FilterCell(
- ownershipFilter = state.selectedOwnership,
- selectedProviderFilter = state.selectedProvidersCount,
- removeOwnershipFilter = removeOwnershipFilter,
- removeProviderFilter = removeProviderFilter,
- )
- }
- }
+ if (state is SelectLocationUiState.Content && state.filterChips.isNotEmpty()) {
+ FilterRow(filters = state.filterChips, removeOwnershipFilter, removeProviderFilter)
}
SearchTextField(
@@ -319,16 +320,7 @@ fun SelectLocationScreen(
onSearchTermInput.invoke(searchString)
}
Spacer(modifier = Modifier.height(height = Dimens.verticalSpace))
- val lazyListState = rememberLazyListState()
- val selectedItemCode = (state as? SelectLocationUiState.Content)?.selectedItem ?: ""
- RunOnKeyChange(key = selectedItemCode) {
- val index = state.indexOfSelectedRelayItem()
- if (index >= 0) {
- lazyListState.scrollToItem(index)
- lazyListState.animateScrollAndCentralizeItem(index)
- }
- }
LazyColumn(
modifier =
Modifier.fillMaxSize()
@@ -344,58 +336,87 @@ fun SelectLocationScreen(
loading()
}
is SelectLocationUiState.Content -> {
- if (state.showCustomLists) {
- customLists(
- customLists = state.filteredCustomLists,
- selectedItem = state.selectedItem,
- backgroundColor = backgroundColor,
- onSelectRelay = onSelectRelay,
- onShowCustomListBottomSheet = {
- bottomSheetState =
- BottomSheetState.ShowCustomListsBottomSheet(
- state.customLists.isNotEmpty()
- )
- },
- onShowEditBottomSheet = { customList ->
- bottomSheetState =
- BottomSheetState.ShowEditCustomListBottomSheet(customList)
- },
- onShowEditCustomListEntryBottomSheet = {
- item: RelayItem.Location,
- customList: RelayItem.CustomList ->
- bottomSheetState =
- BottomSheetState.ShowCustomListsEntryBottomSheet(
- customList,
- item,
- )
+
+ itemsIndexed(
+ items = state.relayListItems,
+ key = { index: Int, item: RelayListItem -> item.key },
+ contentType = { _, item -> item.contentType },
+ itemContent = { index: Int, listItem: RelayListItem ->
+ Column(modifier = Modifier.animateItem()) {
+ if (index != 0) {
+ HorizontalDivider(color = backgroundColor)
+ }
+ when (listItem) {
+ RelayListItem.CustomListHeader ->
+ CustomListHeader(
+ onShowCustomListBottomSheet = {
+ bottomSheetState =
+ ShowCustomListsBottomSheet(
+ editListEnabled =
+ state.customLists.isNotEmpty()
+ )
+ }
+ )
+ is RelayListItem.CustomListItem ->
+ CustomListItem(
+ listItem,
+ onSelectRelay,
+ {
+ bottomSheetState =
+ ShowEditCustomListBottomSheet(it)
+ },
+ { customListId, expand ->
+ onToggleExpand(customListId, null, expand)
+ }
+ )
+ is RelayListItem.CustomListEntryItem ->
+ CustomListEntryItem(
+ listItem,
+ { onSelectRelay(listItem.item) },
+ if (listItem.depth == 1) {
+ {
+ bottomSheetState =
+ ShowCustomListsEntryBottomSheet(
+ listItem.parentId,
+ listItem.item
+ )
+ }
+ } else {
+ null
+ },
+ { expand: Boolean ->
+ onToggleExpand(
+ listItem.item.id,
+ listItem.parentId,
+ expand
+ )
+ }
+ )
+ is RelayListItem.CustomListFooter ->
+ CustomListFooter(listItem)
+ RelayListItem.LocationHeader -> RelayLocationHeader()
+ is RelayListItem.GeoLocationItem ->
+ RelayLocationItem(
+ listItem,
+ { onSelectRelay(listItem.item) },
+ {
+ // Only direct children can be removed
+ bottomSheetState =
+ ShowLocationBottomSheet(
+ state.customLists,
+ listItem.item
+ )
+ },
+ { expand ->
+ onToggleExpand(listItem.item.id, null, expand)
+ }
+ )
+ is RelayListItem.LocationsEmptyText ->
+ LocationsEmptyText(listItem.searchTerm)
+ }
}
- )
- item {
- Spacer(
- modifier =
- Modifier.height(Dimens.mediumPadding)
- .animateItemPlacement()
- .animateContentSize()
- )
}
- }
- if (state.countries.isNotEmpty()) {
- relayList(
- countries = state.countries,
- selectedItem = state.selectedItem,
- onSelectRelay = onSelectRelay,
- onShowLocationBottomSheet = { location ->
- bottomSheetState =
- BottomSheetState.ShowLocationBottomSheet(
- customLists = state.customLists,
- item = location
- )
- }
- )
- }
- if (state.showEmpty) {
- item { LocationsEmptyText(searchTerm = state.searchTerm) }
- }
+ )
}
}
}
@@ -404,6 +425,80 @@ fun SelectLocationScreen(
}
@Composable
+fun LazyItemScope.RelayLocationHeader() {
+ HeaderCell(text = stringResource(R.string.all_locations))
+}
+
+@Composable
+fun LazyItemScope.RelayLocationItem(
+ relayItem: RelayListItem.GeoLocationItem,
+ onSelectRelay: () -> Unit,
+ onLongClick: () -> Unit,
+ onExpand: (Boolean) -> Unit,
+) {
+ val location = relayItem.item
+ StatusRelayItemCell(
+ location,
+ relayItem.isSelected,
+ onClick = { onSelectRelay() },
+ onLongClick = { onLongClick() },
+ onToggleExpand = { onExpand(it) },
+ isExpanded = relayItem.expanded,
+ depth = relayItem.depth
+ )
+}
+
+@Composable
+fun LazyItemScope.CustomListItem(
+ itemState: RelayListItem.CustomListItem,
+ onSelectRelay: (item: RelayItem) -> Unit,
+ onShowEditBottomSheet: (RelayItem.CustomList) -> Unit,
+ onExpand: ((CustomListId, Boolean) -> Unit),
+) {
+ val customListItem = itemState.item
+ StatusRelayItemCell(
+ customListItem,
+ itemState.isSelected,
+ onClick = { onSelectRelay(customListItem) },
+ onLongClick = { onShowEditBottomSheet(customListItem) },
+ onToggleExpand = { onExpand(customListItem.id, it) },
+ isExpanded = itemState.expanded
+ )
+}
+
+@Composable
+fun LazyItemScope.CustomListEntryItem(
+ itemState: RelayListItem.CustomListEntryItem,
+ onSelectRelay: () -> Unit,
+ onShowEditCustomListEntryBottomSheet: (() -> Unit)?,
+ onToggleExpand: (Boolean) -> Unit,
+) {
+ val customListEntryItem = itemState.item
+ StatusRelayItemCell(
+ customListEntryItem,
+ false,
+ onClick = onSelectRelay,
+ onLongClick = onShowEditCustomListEntryBottomSheet,
+ onToggleExpand = onToggleExpand,
+ isExpanded = itemState.expanded,
+ depth = itemState.depth
+ )
+}
+
+@Composable
+fun LazyItemScope.CustomListFooter(item: RelayListItem.CustomListFooter) {
+ SwitchComposeSubtitleCell(
+ text =
+ if (item.hasCustomList) {
+ stringResource(R.string.to_add_locations_to_a_list)
+ } else {
+ stringResource(R.string.to_create_a_custom_list)
+ },
+ modifier = Modifier.background(MaterialTheme.colorScheme.background)
+ )
+}
+
+@Composable
private fun SelectLocationTopBar(onBackClick: () -> Unit, onFilterClick: () -> Unit) {
Row(modifier = Modifier.fillMaxWidth()) {
IconButton(onClick = onBackClick) {
@@ -437,95 +532,13 @@ private fun LazyListScope.loading() {
}
}
-@OptIn(ExperimentalFoundationApi::class)
-private fun LazyListScope.customLists(
- customLists: List<RelayItem.CustomList>,
- selectedItem: RelayItemId?,
- backgroundColor: Color,
- onSelectRelay: (item: RelayItem) -> Unit,
- onShowCustomListBottomSheet: () -> Unit,
- onShowEditBottomSheet: (RelayItem.CustomList) -> Unit,
- onShowEditCustomListEntryBottomSheet: (item: RelayItem.Location, RelayItem.CustomList) -> Unit
-) {
- item(
- contentType = { ContentType.HEADER },
- ) {
- ThreeDotCell(
- text = stringResource(R.string.custom_lists),
- onClickDots = onShowCustomListBottomSheet,
- modifier =
- Modifier.testTag(SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG)
- .animateItemPlacement()
- .animateContentSize()
- )
- }
- if (customLists.isNotEmpty()) {
- items(
- items = customLists,
- key = { item -> item.id },
- contentType = { ContentType.ITEM },
- ) { customList ->
- StatusRelayLocationCell(
- relay = customList,
- // Do not show selection for locations in custom lists
- selectedItem = selectedItem as? CustomListId,
- onSelectRelay = onSelectRelay,
- onLongClick = {
- if (it is RelayItem.CustomList) {
- onShowEditBottomSheet(it)
- } else if (it is RelayItem.Location && it in customList.locations) {
- onShowEditCustomListEntryBottomSheet(it, customList)
- }
- },
- modifier = Modifier.animateContentSize().animateItemPlacement(),
- )
- }
- item {
- SwitchComposeSubtitleCell(
- text = stringResource(R.string.to_add_locations_to_a_list),
- modifier =
- Modifier.background(backgroundColor).animateItemPlacement().animateContentSize()
- )
- }
- } else {
- item(contentType = ContentType.EMPTY_TEXT) {
- SwitchComposeSubtitleCell(
- text = stringResource(R.string.to_create_a_custom_list),
- modifier =
- Modifier.background(backgroundColor).animateItemPlacement().animateContentSize()
- )
- }
- }
-}
-
-@OptIn(ExperimentalFoundationApi::class)
-private fun LazyListScope.relayList(
- countries: List<RelayItem.Location.Country>,
- selectedItem: RelayItemId?,
- onSelectRelay: (item: RelayItem) -> Unit,
- onShowLocationBottomSheet: (item: RelayItem.Location) -> Unit,
-) {
- item(
- contentType = ContentType.HEADER,
- ) {
- HeaderCell(
- text = stringResource(R.string.all_locations),
- modifier = Modifier.animateItemPlacement().animateContentSize()
- )
- }
- items(
- items = countries,
- key = { item -> item.id },
- contentType = { ContentType.ITEM },
- ) { country ->
- StatusRelayLocationCell(
- relay = country,
- selectedItem = selectedItem,
- onSelectRelay = onSelectRelay,
- onLongClick = { onShowLocationBottomSheet(it as RelayItem.Location) },
- modifier = Modifier.animateContentSize().animateItemPlacement(),
- )
- }
+@Composable
+private fun LazyItemScope.CustomListHeader(onShowCustomListBottomSheet: () -> Unit) {
+ ThreeDotCell(
+ text = stringResource(R.string.custom_lists),
+ onClickDots = onShowCustomListBottomSheet,
+ modifier = Modifier.testTag(SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG)
+ )
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -535,7 +548,7 @@ private fun BottomSheets(
onCreateCustomList: (RelayItem.Location?) -> Unit,
onEditCustomLists: () -> Unit,
onAddLocationToList: (RelayItem.Location, RelayItem.CustomList) -> Unit,
- onRemoveLocationFromList: (RelayItem.Location, RelayItem.CustomList) -> Unit,
+ onRemoveLocationFromList: (location: RelayItem.Location, parent: CustomListId) -> Unit,
onEditCustomListName: (RelayItem.CustomList) -> Unit,
onEditLocationsCustomList: (RelayItem.CustomList) -> Unit,
onDeleteCustomList: (RelayItem.CustomList) -> Unit,
@@ -559,7 +572,7 @@ private fun BottomSheets(
val onBackgroundColor: Color = MaterialTheme.colorScheme.onSurface
when (bottomSheetState) {
- is BottomSheetState.ShowCustomListsBottomSheet -> {
+ is ShowCustomListsBottomSheet -> {
CustomListsBottomSheet(
sheetState = sheetState,
onBackgroundColor = onBackgroundColor,
@@ -569,7 +582,7 @@ private fun BottomSheets(
closeBottomSheet = onCloseBottomSheet
)
}
- is BottomSheetState.ShowLocationBottomSheet -> {
+ is ShowLocationBottomSheet -> {
LocationBottomSheet(
sheetState = sheetState,
onBackgroundColor = onBackgroundColor,
@@ -580,7 +593,7 @@ private fun BottomSheets(
closeBottomSheet = onCloseBottomSheet
)
}
- is BottomSheetState.ShowEditCustomListBottomSheet -> {
+ is ShowEditCustomListBottomSheet -> {
EditCustomListBottomSheet(
sheetState = sheetState,
onBackgroundColor = onBackgroundColor,
@@ -591,11 +604,11 @@ private fun BottomSheets(
closeBottomSheet = onCloseBottomSheet
)
}
- is BottomSheetState.ShowCustomListsEntryBottomSheet -> {
+ is ShowCustomListsEntryBottomSheet -> {
CustomListEntryBottomSheet(
sheetState = sheetState,
onBackgroundColor = onBackgroundColor,
- customList = bottomSheetState.customList,
+ customListId = bottomSheetState.parentId,
item = bottomSheetState.item,
onRemoveLocationFromList = onRemoveLocationFromList,
closeBottomSheet = onCloseBottomSheet
@@ -609,14 +622,16 @@ private fun BottomSheets(
private fun SelectLocationUiState.indexOfSelectedRelayItem(): Int =
if (this is SelectLocationUiState.Content) {
- when (selectedItem) {
- is CustomListId ->
- filteredCustomLists.indexOfFirst { it.id == selectedItem } + EXTRA_ITEM_CUSTOM_LIST
- is GeoLocationId ->
- countries.indexOfFirst { it.id == selectedItem.country } +
- customLists.size +
- EXTRA_ITEMS_LOCATION
- else -> -1
+ relayListItems.indexOfFirst {
+ when (it) {
+ is RelayListItem.CustomListItem -> it.isSelected
+ is RelayListItem.GeoLocationItem -> it.isSelected
+ is RelayListItem.CustomListEntryItem -> false
+ is RelayListItem.CustomListFooter -> false
+ RelayListItem.CustomListHeader -> false
+ RelayListItem.LocationHeader -> false
+ is RelayListItem.LocationsEmptyText -> false
+ }
}
} else {
-1
@@ -627,7 +642,7 @@ private fun SelectLocationUiState.indexOfSelectedRelayItem(): Int =
private fun CustomListsBottomSheet(
onBackgroundColor: Color,
sheetState: SheetState,
- bottomSheetState: BottomSheetState.ShowCustomListsBottomSheet,
+ bottomSheetState: ShowCustomListsBottomSheet,
onCreateCustomList: () -> Unit,
onEditCustomLists: () -> Unit,
closeBottomSheet: (animate: Boolean) -> Unit
@@ -787,10 +802,9 @@ private fun EditCustomListBottomSheet(
private fun CustomListEntryBottomSheet(
onBackgroundColor: Color,
sheetState: SheetState,
- customList: RelayItem.CustomList,
+ customListId: CustomListId,
item: RelayItem.Location,
- onRemoveLocationFromList:
- (location: RelayItem.Location, customList: RelayItem.CustomList) -> Unit,
+ onRemoveLocationFromList: (location: RelayItem.Location, customListId: CustomListId) -> Unit,
closeBottomSheet: (animate: Boolean) -> Unit
) {
MullvadModalBottomSheet(
@@ -809,7 +823,7 @@ private fun CustomListEntryBottomSheet(
title = stringResource(id = R.string.remove_button),
titleColor = onBackgroundColor,
onClick = {
- onRemoveLocationFromList(item, customList)
+ onRemoveLocationFromList(item, customListId)
closeBottomSheet(true)
},
background = Color.Unspecified
@@ -879,16 +893,12 @@ private fun <D : DestinationSpec, R : CustomListSuccess> ResultRecipient<D, R>
}
}
-private const val EXTRA_ITEMS_LOCATION =
- 4 // Custom lists header, custom lists description, spacer, all locations header
-private const val EXTRA_ITEM_CUSTOM_LIST = 1 // Custom lists header
-
sealed interface BottomSheetState {
data class ShowCustomListsBottomSheet(val editListEnabled: Boolean) : BottomSheetState
data class ShowCustomListsEntryBottomSheet(
- val customList: RelayItem.CustomList,
+ val parentId: CustomListId,
val item: RelayItem.Location
) : BottomSheetState
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
index 52ef7445b0..01fe84f76c 100644
--- 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
@@ -5,12 +5,6 @@ import net.mullvad.mullvadvpn.lib.model.Ownership
import net.mullvad.mullvadvpn.lib.model.Provider
import net.mullvad.mullvadvpn.lib.model.Providers
-fun Constraint<Ownership>.toNullableOwnership(): Ownership? =
- when (this) {
- Constraint.Any -> null
- is Constraint.Only -> this.value
- }
-
fun Ownership?.toOwnershipConstraint(): Constraint<Ownership> =
when (this) {
null -> Constraint.Any
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 79f434aad1..5d6b683116 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,9 +1,9 @@
package net.mullvad.mullvadvpn.compose.state
-import net.mullvad.mullvadvpn.lib.model.Ownership
+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
+
+typealias ModelOwnership = net.mullvad.mullvadvpn.lib.model.Ownership
sealed interface SelectLocationUiState {
@@ -11,18 +11,88 @@ sealed interface SelectLocationUiState {
data class Content(
val searchTerm: String,
- val selectedOwnership: Ownership?,
- val selectedProvidersCount: Int?,
- val filteredCustomLists: List<RelayItem.CustomList>,
+ val filterChips: List<FilterChip>,
+ val relayListItems: List<RelayListItem>,
val customLists: List<RelayItem.CustomList>,
- val countries: List<RelayItem.Location.Country>,
- val selectedItem: RelayItemId?
- ) : SelectLocationUiState {
- val hasFilter: Boolean = (selectedProvidersCount != null || selectedOwnership != null)
- val inSearch = searchTerm.length >= MIN_SEARCH_LENGTH
- val showCustomLists = inSearch.not() || filteredCustomLists.isNotEmpty()
- // Show empty state if we don't have any relays or if we are searching and no custom list or
- // relay is found
- val showEmpty = countries.isEmpty() && (inSearch.not() || filteredCustomLists.isEmpty())
+ ) : SelectLocationUiState
+}
+
+sealed interface FilterChip {
+ data class Ownership(val ownership: ModelOwnership) : FilterChip
+
+ data class Provider(val count: Int) : FilterChip
+}
+
+enum class RelayListItemContentType {
+ CUSTOM_LIST_HEADER,
+ CUSTOM_LIST_ITEM,
+ CUSTOM_LIST_ENTRY_ITEM,
+ CUSTOM_LIST_FOOTER,
+ LOCATION_HEADER,
+ LOCATION_ITEM,
+ LOCATIONS_EMPTY_TEXT
+}
+
+sealed interface RelayListItem {
+ val key: Any
+ val contentType: RelayListItemContentType
+
+ data object CustomListHeader : RelayListItem {
+ override val key = "custom_list_header"
+ override val contentType = RelayListItemContentType.CUSTOM_LIST_HEADER
+ }
+
+ sealed interface SelectableItem : RelayListItem {
+ val depth: Int
+ val isSelected: Boolean
+ val expanded: Boolean
+ }
+
+ data class CustomListItem(
+ val item: RelayItem.CustomList,
+ override val isSelected: Boolean = false,
+ override val expanded: Boolean = false,
+ ) : SelectableItem {
+ override val key = item.id
+ override val depth: Int = 0
+ override val contentType = RelayListItemContentType.CUSTOM_LIST_ITEM
+ }
+
+ data class CustomListEntryItem(
+ val parentId: CustomListId,
+ val item: RelayItem.Location,
+ override val expanded: Boolean,
+ override val depth: Int = 0
+ ) : SelectableItem {
+ override val key = parentId to item.id
+
+ // Can't be displayed as selected
+ override val isSelected: Boolean = false
+ override val contentType = RelayListItemContentType.CUSTOM_LIST_ENTRY_ITEM
+ }
+
+ data class CustomListFooter(val hasCustomList: Boolean) : RelayListItem {
+ override val key = "custom_list_footer"
+ override val contentType = RelayListItemContentType.CUSTOM_LIST_FOOTER
+ }
+
+ data object LocationHeader : RelayListItem {
+ override val key: Any = "location_header"
+ override val contentType = RelayListItemContentType.LOCATION_HEADER
+ }
+
+ data class GeoLocationItem(
+ val item: RelayItem.Location,
+ override val isSelected: Boolean = false,
+ override val depth: Int = 0,
+ override val expanded: Boolean = false,
+ ) : SelectableItem {
+ override val key = item.id
+ override val contentType = RelayListItemContentType.LOCATION_ITEM
+ }
+
+ data class LocationsEmptyText(val searchTerm: String) : RelayListItem {
+ override val key: Any = "locations_empty_text"
+ override val contentType = RelayListItemContentType.LOCATIONS_EMPTY_TEXT
}
}
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 371a30bdf1..6494cbb167 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
@@ -44,6 +44,7 @@ import net.mullvad.mullvadvpn.usecase.VersionNotificationUseCase
import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase
import net.mullvad.mullvadvpn.usecase.customlists.CustomListRelayItemsUseCase
import net.mullvad.mullvadvpn.usecase.customlists.CustomListsRelayItemUseCase
+import net.mullvad.mullvadvpn.usecase.customlists.FilterCustomListsRelayItemUseCase
import net.mullvad.mullvadvpn.util.ChangelogDataProvider
import net.mullvad.mullvadvpn.util.IChangelogDataProvider
import net.mullvad.mullvadvpn.viewmodel.AccountViewModel
@@ -134,6 +135,7 @@ val uiModule = module {
single { CustomListActionUseCase(get(), get()) }
single { SelectedLocationTitleUseCase(get(), get()) }
single { AvailableProvidersUseCase(get()) }
+ single { FilterCustomListsRelayItemUseCase(get(), get()) }
single { CustomListsRelayItemUseCase(get(), get()) }
single { CustomListRelayItemsUseCase(get(), get()) }
single { FilteredRelayListUseCase(get(), get()) }
@@ -183,7 +185,7 @@ val uiModule = module {
viewModel { DnsDialogViewModel(get(), get(), get()) }
viewModel { LoginViewModel(get(), get(), get()) }
viewModel { PrivacyDisclaimerViewModel(get(), IS_PLAY_BUILD) }
- viewModel { SelectLocationViewModel(get(), get(), get(), get(), get(), get()) }
+ viewModel { SelectLocationViewModel(get(), get(), get(), get(), get(), get(), get(), get()) }
viewModel { SettingsViewModel(get(), get(), IS_PLAY_BUILD) }
viewModel { SplashViewModel(get(), get(), get(), get()) }
viewModel { VoucherDialogViewModel(get()) }
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 2a7eeddb69..ac03080e21 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
@@ -8,9 +8,7 @@ fun CustomList.toRelayItemCustomList(
relayCountries: List<RelayItem.Location.Country>
): RelayItem.CustomList =
RelayItem.CustomList(
- id = id,
- customListName = name,
- expanded = false,
+ customList = this,
locations = locations.mapNotNull { relayCountries.findByGeoLocationId(it) },
)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListExtensions.kt
index 069f0e1a08..ea017339f6 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListExtensions.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListExtensions.kt
@@ -1,9 +1,7 @@
package net.mullvad.mullvadvpn.relaylist
-import net.mullvad.mullvadvpn.lib.model.CustomListId
import net.mullvad.mullvadvpn.lib.model.GeoLocationId
import net.mullvad.mullvadvpn.lib.model.RelayItem
-import net.mullvad.mullvadvpn.lib.model.RelayItemId
fun List<RelayItem.Location.Country>.findByGeoLocationId(geoLocationId: GeoLocationId) =
withDescendants().firstOrNull { it.id == geoLocationId }
@@ -11,119 +9,46 @@ fun List<RelayItem.Location.Country>.findByGeoLocationId(geoLocationId: GeoLocat
fun List<RelayItem.Location.Country>.findByGeoLocationId(geoLocationId: GeoLocationId.City) =
flatMap { it.cities }.firstOrNull { it.id == geoLocationId }
-/**
- * Filter and expand the list based on search terms If a country is matched, that country and all
- * its children are added to the list, but the country is not expanded If a city is matched, its
- * parent country is added and expanded if needed and its children are added, but the city is not
- * expanded If a relay is matched, its parents are added and expanded and itself is also added.
- */
-@Suppress("NestedBlockDepth")
-fun List<RelayItem.Location.Country>.filterOnSearchTerm(
- searchTerm: String,
- selectedItem: RelayItemId?
-): List<RelayItem.Location.Country> {
- return if (searchTerm.length >= MIN_SEARCH_LENGTH) {
- val filteredCountries = mutableMapOf<GeoLocationId.Country, RelayItem.Location.Country>()
- this.forEach { relayCountry ->
- val cities = mutableListOf<RelayItem.Location.City>()
+fun List<RelayItem.Location.Country>.search(searchTerm: String): List<GeoLocationId> =
+ withDescendants().filter { it.name.contains(searchTerm, ignoreCase = true) }.map { it.id }
- // Try to match the search term with a country
- // If we match a country, add that country and all cities and relays in that country
- // Do not currently expand the country or any city
- if (relayCountry.name.contains(other = searchTerm, ignoreCase = true)) {
- cities.addAll(relayCountry.cities.map { city -> city.copy(expanded = false) })
- filteredCountries[relayCountry.id] =
- relayCountry.copy(expanded = false, cities = cities)
- }
+fun List<GeoLocationId>.expansionSet() = flatMap { it.ancestors() }.toSet()
- // Go through and try to match the search term with every city
- relayCountry.cities.forEach { relayCity ->
- val relays = mutableListOf<RelayItem.Location.Relay>()
- // If we match and we already added the country to the filtered list just expand the
- // country.
- // If the country is not currently in the filtered list, add it and expand it.
- // Finally if the city has not already been added to the filtered list, add it, but
- // do not expand it yet.
- if (relayCity.name.contains(other = searchTerm, ignoreCase = true)) {
- val value = filteredCountries[relayCountry.id]
- if (value != null) {
- filteredCountries[relayCountry.id] = value.copy(expanded = true)
- } else {
- filteredCountries[relayCountry.id] =
- relayCountry.copy(expanded = true, cities = cities)
- }
- if (cities.none { city -> city.id == relayCity.id }) {
- cities.add(relayCity.copy(expanded = false))
- }
- }
-
- // Go through and try to match the search term with every relay
- relayCity.relays.forEach { relay ->
- // If we match a relay, check if the county the relay is in already is added, if
- // so expand, if not add and expand the country.
- // Check if the city that the relay is in is already added to the filtered list,
- // if so expand it, if not add it to the filtered list and expand it.
- // Finally add the relay to the list.
- if (relay.name.contains(other = searchTerm, ignoreCase = true)) {
- val value = filteredCountries[relayCountry.id]
- if (value != null) {
- filteredCountries[relayCountry.id] = value.copy(expanded = true)
- } else {
- filteredCountries[relayCountry.id] =
- relayCountry.copy(expanded = true, cities = cities)
- }
- val cityIndex = cities.indexOfFirst { it.id == relayCity.id }
+fun List<RelayItem.Location.Country>.newFilterOnSearch(
+ searchTerm: String
+): Pair<Set<GeoLocationId>, List<RelayItem.Location.Country>> {
+ val matchesIds = search(searchTerm)
+ val expansionSet = matchesIds.expansionSet()
- // No city found
- if (cityIndex < 0) {
- cities.add(relayCity.copy(expanded = true, relays = relays))
- } else {
- // Update found city as expanded
- cities[cityIndex] = cities[cityIndex].copy(expanded = true)
- }
-
- relays.add(relay.copy())
+ val filteredCountryList = mapNotNull { country ->
+ if (country.id in matchesIds) {
+ country
+ } else if (country.id in expansionSet) {
+ country.copy(
+ cities =
+ country.cities.mapNotNull { city ->
+ if (city.id in matchesIds) {
+ city
+ } else if (city.id in expansionSet) {
+ city.copy(
+ relays = city.relays.filter { relay -> relay.id in matchesIds }
+ )
+ } else null
}
- }
- }
+ )
+ } else {
+ null
}
- filteredCountries.values.sortedBy { it.name }
- } else {
- this.expandItemForSelection(selectedItem)
}
+ return expansionSet to filteredCountryList
}
-/** Expand the parent(s), if any, for the current selected item */
-private fun List<RelayItem.Location.Country>.expandItemForSelection(
- selectedItem: RelayItemId?
-): List<RelayItem.Location.Country> {
- selectedItem ?: return this
- return when (selectedItem) {
- is CustomListId,
- is GeoLocationId.Country -> this
- is GeoLocationId.City ->
- map { if (it.id == selectedItem.country) it.copy(expanded = true) else it }
- is GeoLocationId.Hostname -> {
- map { country ->
- if (country.id == selectedItem.country) {
- country.copy(
- expanded = true,
- cities =
- country.cities.map { city ->
- if (city.id == selectedItem.city) {
- city.copy(expanded = true)
- } else {
- city
- }
- },
- )
- } else {
- country
- }
- }
- }
+fun GeoLocationId.ancestors(): List<GeoLocationId> =
+ when (this) {
+ is GeoLocationId.City -> listOf(country)
+ is GeoLocationId.Country -> emptyList()
+ is GeoLocationId.Hostname -> listOf(country, city)
}
-}
fun List<RelayItem.Location.Country>.getRelayItemsByCodes(
codes: List<GeoLocationId>
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/FilterCustomListsRelayItemUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/FilterCustomListsRelayItemUseCase.kt
new file mode 100644
index 0000000000..f82d9eed5e
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/FilterCustomListsRelayItemUseCase.kt
@@ -0,0 +1,30 @@
+package net.mullvad.mullvadvpn.usecase.customlists
+
+import kotlin.collections.mapNotNull
+import kotlinx.coroutines.flow.combine
+import net.mullvad.mullvadvpn.lib.model.Constraint
+import net.mullvad.mullvadvpn.lib.model.Ownership
+import net.mullvad.mullvadvpn.lib.model.Providers
+import net.mullvad.mullvadvpn.lib.model.RelayItem
+import net.mullvad.mullvadvpn.relaylist.filterOnOwnershipAndProvider
+import net.mullvad.mullvadvpn.repository.RelayListFilterRepository
+
+class FilterCustomListsRelayItemUseCase(
+ private val customListsRelayItemUseCase: CustomListsRelayItemUseCase,
+ private val relayListFilterRepository: RelayListFilterRepository
+) {
+
+ operator fun invoke() =
+ combine(
+ customListsRelayItemUseCase(),
+ relayListFilterRepository.selectedOwnership,
+ relayListFilterRepository.selectedProviders,
+ ) { customLists, selectedOwnership, selectedProviders ->
+ customLists.filterOnOwnershipAndProvider(selectedOwnership, selectedProviders)
+ }
+
+ private fun List<RelayItem.CustomList>.filterOnOwnershipAndProvider(
+ ownership: Constraint<Ownership>,
+ providers: Constraint<Providers>
+ ) = mapNotNull { it.filterOnOwnershipAndProvider(ownership, providers) }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt
index 13561aa7f8..368c614aae 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt
@@ -28,58 +28,6 @@ inline fun <T1, T2, T3, T4, T5, T6, R> combine(
}
}
-inline fun <T1, T2, T3, T4, T5, T6, T7, R> combine(
- flow: Flow<T1>,
- flow2: Flow<T2>,
- flow3: Flow<T3>,
- flow4: Flow<T4>,
- flow5: Flow<T5>,
- flow6: Flow<T6>,
- flow7: Flow<T7>,
- crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R
-): Flow<R> {
- return kotlinx.coroutines.flow.combine(flow, flow2, flow3, flow4, flow5, flow6, flow7) {
- args: Array<*> ->
- @Suppress("UNCHECKED_CAST")
- transform(
- args[0] as T1,
- args[1] as T2,
- args[2] as T3,
- args[3] as T4,
- args[4] as T5,
- args[5] as T6,
- args[6] as T7
- )
- }
-}
-
-inline fun <T1, T2, T3, T4, T5, T6, T7, T8, R> combine(
- flow: Flow<T1>,
- flow2: Flow<T2>,
- flow3: Flow<T3>,
- flow4: Flow<T4>,
- flow5: Flow<T5>,
- flow6: Flow<T6>,
- flow7: Flow<T7>,
- flow8: Flow<T8>,
- crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8) -> R
-): Flow<R> {
- return kotlinx.coroutines.flow.combine(flow, flow2, flow3, flow4, flow5, flow6, flow7, flow8) {
- args: Array<*> ->
- @Suppress("UNCHECKED_CAST")
- transform(
- args[0] as T1,
- args[1] as T2,
- args[2] as T3,
- args[3] as T4,
- args[4] as T5,
- args[5] as T6,
- args[6] as T7,
- args[7] as T8
- )
- }
-}
-
fun <T> Deferred<T>.getOrDefault(default: T) =
try {
getCompleted()
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
index 728142b3ff..1529bb4221 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/FilterViewModel.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/FilterViewModel.kt
@@ -13,7 +13,6 @@ 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.lib.model.Ownership
@@ -43,7 +42,7 @@ class FilterViewModel(
.first()
val ownershipConstraint = relayListFilterRepository.selectedOwnership.first()
- selectedOwnership.value = ownershipConstraint.toNullableOwnership()
+ selectedOwnership.value = ownershipConstraint.getOrNull()
}
}
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 2509fdc876..f31fcb3078 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
@@ -2,93 +2,71 @@ package net.mullvad.mullvadvpn.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import arrow.core.raise.either
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
+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.flow.update
import kotlinx.coroutines.launch
import net.mullvad.mullvadvpn.compose.communication.CustomListAction
import net.mullvad.mullvadvpn.compose.communication.LocationsChanged
+import net.mullvad.mullvadvpn.compose.state.FilterChip
+import net.mullvad.mullvadvpn.compose.state.RelayListItem
+import net.mullvad.mullvadvpn.compose.state.RelayListItem.CustomListHeader
import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState
-import net.mullvad.mullvadvpn.compose.state.toNullableOwnership
+import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState.Content
import net.mullvad.mullvadvpn.compose.state.toSelectedProviders
import net.mullvad.mullvadvpn.lib.model.Constraint
+import net.mullvad.mullvadvpn.lib.model.CustomListId
+import net.mullvad.mullvadvpn.lib.model.GeoLocationId
import net.mullvad.mullvadvpn.lib.model.Ownership
import net.mullvad.mullvadvpn.lib.model.Provider
-import net.mullvad.mullvadvpn.lib.model.Providers
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.descendants
-import net.mullvad.mullvadvpn.relaylist.filterOnOwnershipAndProvider
import net.mullvad.mullvadvpn.relaylist.filterOnSearchTerm
+import net.mullvad.mullvadvpn.relaylist.newFilterOnSearch
+import net.mullvad.mullvadvpn.repository.CustomListsRepository
import net.mullvad.mullvadvpn.repository.RelayListFilterRepository
import net.mullvad.mullvadvpn.repository.RelayListRepository
import net.mullvad.mullvadvpn.usecase.AvailableProvidersUseCase
import net.mullvad.mullvadvpn.usecase.FilteredRelayListUseCase
import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase
import net.mullvad.mullvadvpn.usecase.customlists.CustomListsRelayItemUseCase
-import net.mullvad.mullvadvpn.util.combine
+import net.mullvad.mullvadvpn.usecase.customlists.FilterCustomListsRelayItemUseCase
class SelectLocationViewModel(
private val relayListFilterRepository: RelayListFilterRepository,
- availableProvidersUseCase: AvailableProvidersUseCase,
- customListsRelayItemUseCase: CustomListsRelayItemUseCase,
+ private val availableProvidersUseCase: AvailableProvidersUseCase,
+ private val customListsRelayItemUseCase: CustomListsRelayItemUseCase,
+ private val filteredCustomListRelayItemsUseCase: FilterCustomListsRelayItemUseCase,
+ private val customListsRepository: CustomListsRepository,
private val customListActionUseCase: CustomListActionUseCase,
- filteredRelayListUseCase: FilteredRelayListUseCase,
+ private val filteredRelayListUseCase: FilteredRelayListUseCase,
private val relayListRepository: RelayListRepository
) : ViewModel() {
private val _searchTerm = MutableStateFlow(EMPTY_SEARCH_TERM)
+ private val _expandedItems = MutableStateFlow(initialExpand())
+
@Suppress("DestructuringDeclarationWithTooManyEntries")
val uiState =
- combine(
- filteredRelayListUseCase(),
- customListsRelayItemUseCase(),
- relayListRepository.selectedLocation,
- _searchTerm,
- relayListFilterRepository.selectedOwnership,
- availableProvidersUseCase(),
- relayListFilterRepository.selectedProviders,
- ) {
- relayCountries,
- customLists,
- selectedItem,
+ combine(_searchTerm, relayListItems(), filterChips(), customListsRelayItemUseCase()) {
searchTerm,
- selectedOwnership,
- allProviders,
- selectedConstraintProviders ->
- val selectRelayItemId = selectedItem.getOrNull()
- val selectedOwnershipItem = selectedOwnership.toNullableOwnership()
- val selectedProvidersCount =
- when (selectedConstraintProviders) {
- is Constraint.Any -> null
- is Constraint.Only ->
- filterSelectedProvidersByOwnership(
- selectedConstraintProviders.toSelectedProviders(allProviders),
- selectedOwnershipItem,
- )
- .size
- }
-
- val filteredRelayCountries =
- relayCountries.filterOnSearchTerm(searchTerm, selectRelayItemId)
-
- val filteredCustomLists =
- customLists
- .filterOnSearchTerm(searchTerm)
- .filterOnOwnershipAndProvider(
- ownership = selectedOwnership,
- providers = selectedConstraintProviders,
- )
-
- SelectLocationUiState.Content(
+ relayListItems,
+ filterChips,
+ customLists ->
+ Content(
searchTerm = searchTerm,
- selectedOwnership = selectedOwnershipItem,
- selectedProvidersCount = selectedProvidersCount,
- filteredCustomLists = filteredCustomLists,
- customLists = customLists,
- countries = filteredRelayCountries,
- selectedItem = selectRelayItemId,
+ filterChips = filterChips,
+ relayListItems = relayListItems,
+ customLists = customLists
)
}
.stateIn(
@@ -100,18 +78,268 @@ class SelectLocationViewModel(
private val _uiSideEffect = Channel<SelectLocationSideEffect>()
val uiSideEffect = _uiSideEffect.receiveAsFlow()
+ private fun initialExpand(): Set<String> = buildSet {
+ val item = relayListRepository.selectedLocation.value.getOrNull()
+ when (item) {
+ is GeoLocationId.City -> {
+ add(item.country.code)
+ }
+ is GeoLocationId.Hostname -> {
+ add(item.country.code)
+ add(item.city.code)
+ }
+ is CustomListId,
+ is GeoLocationId.Country,
+ null -> {
+ /* No expands */
+ }
+ }
+ }
+
+ private fun searchRelayListLocations() =
+ combine(
+ _searchTerm,
+ filteredRelayListUseCase(),
+ ) { searchTerm, relayCountries ->
+ val isSearching = searchTerm.length >= MIN_SEARCH_LENGTH
+ if (isSearching) {
+ val (exp, filteredRelayCountries) = relayCountries.newFilterOnSearch(searchTerm)
+ exp.map { it.expandKey() }.toSet() to filteredRelayCountries
+ } else {
+ initialExpand() to relayCountries
+ }
+ }
+ .onEach { _expandedItems.value = it.first }
+ .map { it.second }
+
+ private fun filterChips() =
+ combine(
+ relayListFilterRepository.selectedOwnership,
+ relayListFilterRepository.selectedProviders,
+ availableProvidersUseCase(),
+ ) { selectedOwnership, selectedConstraintProviders, allProviders,
+ ->
+ val ownershipFilter = selectedOwnership.getOrNull()
+ val providerCountFilter =
+ when (selectedConstraintProviders) {
+ is Constraint.Any -> null
+ is Constraint.Only ->
+ filterSelectedProvidersByOwnership(
+ selectedConstraintProviders.toSelectedProviders(allProviders),
+ ownershipFilter,
+ )
+ .size
+ }
+
+ buildList<FilterChip> {
+ if (ownershipFilter != null) {
+ add(FilterChip.Ownership(ownershipFilter))
+ }
+ if (providerCountFilter != null) {
+ add(FilterChip.Provider(providerCountFilter))
+ }
+ }
+ }
+
+ private fun relayListItems() =
+ combine(
+ _searchTerm,
+ searchRelayListLocations(),
+ filteredCustomListRelayItemsUseCase(),
+ relayListRepository.selectedLocation,
+ _expandedItems,
+ ) { searchTerm, relayCountries, customLists, selectedItem, expandedItems ->
+ val filteredCustomLists = customLists.filterOnSearchTerm(searchTerm)
+
+ buildList {
+ val relayItems =
+ createRelayListItems(
+ searchTerm.length >= MIN_SEARCH_LENGTH,
+ selectedItem.getOrNull(),
+ filteredCustomLists,
+ relayCountries,
+ { it in expandedItems }
+ )
+ if (relayItems.isEmpty()) {
+ add(RelayListItem.LocationsEmptyText(searchTerm))
+ } else {
+ addAll(relayItems)
+ }
+ }
+ }
+
+ private fun createRelayListItems(
+ isSearching: Boolean,
+ selectedItem: RelayItemId?,
+ customLists: List<RelayItem.CustomList>,
+ countries: List<RelayItem.Location.Country>,
+ isExpanded: (String) -> Boolean
+ ): List<RelayListItem> =
+ createCustomListSection(isSearching, selectedItem, customLists, isExpanded) +
+ createLocationSection(isSearching, selectedItem, countries, isExpanded)
+
+ private fun createCustomListSection(
+ isSearching: Boolean,
+ selectedItem: 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(CustomListHeader)
+ val customListItems = createCustomListRelayItems(customLists, selectedItem, isExpanded)
+ addAll(customListItems)
+ add(RelayListItem.CustomListFooter(customListItems.isNotEmpty()))
+ }
+ }
+
+ private fun createCustomListRelayItems(
+ customLists: List<RelayItem.CustomList>,
+ selectedItem: RelayItemId?,
+ isExpanded: (String) -> Boolean
+ ): List<RelayListItem> =
+ customLists.flatMap { customList ->
+ val expanded = isExpanded(customList.id.expandKey())
+ buildList<RelayListItem> {
+ add(
+ RelayListItem.CustomListItem(
+ customList,
+ isSelected = selectedItem == customList.id,
+ expanded
+ )
+ )
+
+ if (expanded) {
+ addAll(
+ customList.locations.flatMap {
+ createCustomListEntry(parent = customList.id, item = it, 1, isExpanded)
+ }
+ )
+ }
+ }
+ }
+
+ private fun createLocationSection(
+ isSearching: Boolean,
+ selectedItem: 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(country, selectedItem, isExpanded = isExpanded)
+ }
+ )
+ }
+ }
+
+ private fun createCustomListEntry(
+ parent: CustomListId,
+ item: RelayItem.Location,
+ depth: Int = 1,
+ isExpanded: (String) -> Boolean,
+ ): List<RelayListItem.CustomListEntryItem> =
+ buildList<RelayListItem.CustomListEntryItem> {
+ val expanded = isExpanded(item.id.expandKey(parent))
+ add(
+ RelayListItem.CustomListEntryItem(
+ parentId = parent,
+ item = item,
+ expanded = expanded,
+ depth
+ )
+ )
+
+ if (expanded) {
+ when (item) {
+ is RelayItem.Location.City ->
+ addAll(
+ item.relays.flatMap {
+ createCustomListEntry(parent, it, depth + 1, isExpanded)
+ }
+ )
+ is RelayItem.Location.Country ->
+ addAll(
+ item.cities.flatMap {
+ createCustomListEntry(parent, it, depth + 1, isExpanded)
+ }
+ )
+ is RelayItem.Location.Relay -> {} // No children to add
+ }
+ }
+ }
+
+ private fun createGeoLocationEntry(
+ item: RelayItem.Location,
+ selectedItem: RelayItemId?,
+ depth: Int = 0,
+ isExpanded: (String) -> Boolean
+ ): List<RelayListItem.GeoLocationItem> = buildList {
+ val expanded = isExpanded(item.id.expandKey())
+
+ add(
+ RelayListItem.GeoLocationItem(
+ item = item,
+ isSelected = selectedItem == item.id,
+ depth = depth,
+ expanded = expanded,
+ )
+ )
+
+ if (expanded) {
+ when (item) {
+ is RelayItem.Location.City ->
+ addAll(
+ item.relays.flatMap {
+ createGeoLocationEntry(it, selectedItem, depth + 1, isExpanded)
+ }
+ )
+ is RelayItem.Location.Country ->
+ addAll(
+ item.cities.flatMap {
+ createGeoLocationEntry(it, selectedItem, depth + 1, isExpanded)
+ }
+ )
+ is RelayItem.Location.Relay -> {} // Do nothing
+ }
+ }
+ }
+
+ private fun RelayItemId.expandKey(parent: CustomListId? = null) =
+ (parent?.value ?: "") +
+ when (this) {
+ is CustomListId -> value
+ is GeoLocationId -> code
+ }
+
fun selectRelay(relayItem: RelayItem) {
viewModelScope.launch {
val locationConstraint = relayItem.id
relayListRepository
.updateSelectedRelayLocation(locationConstraint)
.fold(
- { _uiSideEffect.trySend(SelectLocationSideEffect.GenericError) },
- { _uiSideEffect.trySend(SelectLocationSideEffect.CloseScreen) },
+ { _uiSideEffect.send(SelectLocationSideEffect.GenericError) },
+ { _uiSideEffect.send(SelectLocationSideEffect.CloseScreen) },
)
}
}
+ fun onToggleExpand(item: RelayItemId, parent: CustomListId? = null, expand: Boolean) {
+ _expandedItems.update {
+ val key = item.expandKey(parent)
+ if (expand) {
+ it + key
+ } else {
+ it - key
+ }
+ }
+ }
+
fun onSearchTermInput(searchTerm: String) {
viewModelScope.launch { _searchTerm.emit(searchTerm) }
}
@@ -147,28 +375,27 @@ class SelectLocationViewModel(
viewModelScope.launch { customListActionUseCase(action) }
}
- fun removeLocationFromList(item: RelayItem.Location, customList: RelayItem.CustomList) {
+ fun removeLocationFromList(item: RelayItem.Location, customListId: CustomListId) {
viewModelScope.launch {
- val newLocations = (customList.locations - item).map { it.id }
- customListActionUseCase(CustomListAction.UpdateLocations(customList.id, newLocations))
- .fold(
- { _uiSideEffect.send(SelectLocationSideEffect.GenericError) },
- {
- _uiSideEffect.send(
- SelectLocationSideEffect.LocationRemovedFromCustomList(it)
- )
+ val result =
+ either {
+ val customList =
+ customListsRepository.getCustomListById(customListId).bind()
+ val newLocations = (customList.locations - item.id)
+
+ customListActionUseCase(
+ CustomListAction.UpdateLocations(customList.id, newLocations)
+ )
+ .bind()
}
- )
+ .fold(
+ { SelectLocationSideEffect.GenericError },
+ { SelectLocationSideEffect.LocationRemovedFromCustomList(it) }
+ )
+ _uiSideEffect.send(result)
}
}
- private fun List<RelayItem.CustomList>.filterOnOwnershipAndProvider(
- ownership: Constraint<Ownership>,
- providers: Constraint<Providers>
- ): List<RelayItem.CustomList> = map { item ->
- item.filterOnOwnershipAndProvider(ownership, providers)
- }
-
companion object {
private const val EMPTY_SEARCH_TERM = ""
}
diff --git a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/FromDomain.kt b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/FromDomain.kt
index 014bafb85b..4359ddff93 100644
--- a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/FromDomain.kt
+++ b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/FromDomain.kt
@@ -106,12 +106,10 @@ internal fun GeoLocationId.fromDomain(): ManagementInterface.GeographicLocationC
ManagementInterface.GeographicLocationConstraint.newBuilder()
.apply {
when (val id = this@fromDomain) {
- is GeoLocationId.Country -> setCountry(id.countryCode)
- is GeoLocationId.City -> setCountry(id.countryCode.countryCode).setCity(id.cityCode)
+ is GeoLocationId.Country -> setCountry(id.code)
+ is GeoLocationId.City -> setCountry(id.country.code).setCity(id.code)
is GeoLocationId.Hostname ->
- setCountry(id.country.countryCode)
- .setCity(id.city.cityCode)
- .setHostname(id.hostname)
+ setCountry(id.country.code).setCity(id.city.code).setHostname(id.code)
}
}
.build()
diff --git a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt
index 13ebe74350..50599ad7f4 100644
--- a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt
+++ b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt
@@ -455,7 +455,6 @@ internal fun ManagementInterface.RelayListCountry.toDomain(): RelayItem.Location
return RelayItem.Location.Country(
countryCode,
name,
- false,
citiesList
.map { city -> city.toDomain(countryCode) }
.filter { it.relays.isNotEmpty() }
@@ -470,7 +469,6 @@ internal fun ManagementInterface.RelayListCity.toDomain(
return RelayItem.Location.City(
name = name,
id = cityCode,
- expanded = false,
relays =
relaysList
.filter { it.endpointType == ManagementInterface.Relay.RelayType.WIREGUARD }
diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItem.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItem.kt
index a31a6f67df..17bc563a8d 100644
--- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItem.kt
+++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItem.kt
@@ -2,28 +2,26 @@ package net.mullvad.mullvadvpn.lib.model
import arrow.optics.optics
+typealias DomainCustomList = CustomList
+
@optics
sealed interface RelayItem {
val id: RelayItemId
val name: String
+
val active: Boolean
val hasChildren: Boolean
- val expanded: Boolean
@optics
data class CustomList(
- override val id: CustomListId,
- val customListName: CustomListName,
+ val customList: DomainCustomList,
val locations: List<Location>,
- override val expanded: Boolean,
) : RelayItem {
- override val name: String = customListName.value
+ override val name: String = customList.name.value
+ override val id = customList.id
- override val active
- get() = locations.any { location -> location.active }
-
- override val hasChildren
- get() = locations.isNotEmpty()
+ override val active = locations.any { it.active }
+ override val hasChildren: Boolean = locations.isNotEmpty()
companion object
}
@@ -36,16 +34,11 @@ sealed interface RelayItem {
data class Country(
override val id: GeoLocationId.Country,
override val name: String,
- override val expanded: Boolean,
val cities: List<City>
) : Location {
val relays = cities.flatMap { city -> city.relays }
-
- override val active
- get() = cities.any { city -> city.active }
-
- override val hasChildren
- get() = cities.isNotEmpty()
+ override val active = cities.any { it.active }
+ override val hasChildren: Boolean = cities.isNotEmpty()
companion object
}
@@ -54,15 +47,10 @@ sealed interface RelayItem {
data class City(
override val id: GeoLocationId.City,
override val name: String,
- override val expanded: Boolean,
val relays: List<Relay>
) : Location {
-
- override val active
- get() = relays.any { relay -> relay.active }
-
- override val hasChildren
- get() = relays.isNotEmpty()
+ override val active = relays.any { it.active }
+ override val hasChildren: Boolean = relays.isNotEmpty()
companion object
}
@@ -73,10 +61,8 @@ sealed interface RelayItem {
val provider: Provider,
override val active: Boolean,
) : Location {
- override val name: String = id.hostname
-
- override val hasChildren = false
- override val expanded = false
+ override val name: String = id.code
+ override val hasChildren: Boolean = false
companion object
}
diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItemId.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItemId.kt
index c560fab49c..85326d8d2a 100644
--- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItemId.kt
+++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItemId.kt
@@ -21,28 +21,29 @@ value class CustomListId(val value: String) : RelayItemId, Parcelable {
sealed interface GeoLocationId : RelayItemId, Parcelable {
@optics
@Parcelize
- data class Country(val countryCode: String) : GeoLocationId {
+ data class Country(override val code: String) : GeoLocationId {
companion object
}
@optics
@Parcelize
- data class City(val countryCode: Country, val cityCode: String) : GeoLocationId {
+ data class City(override val country: Country, override val code: String) : GeoLocationId {
companion object
}
@optics
@Parcelize
- data class Hostname(val city: City, val hostname: String) : GeoLocationId {
+ data class Hostname(val city: City, override val code: String) : GeoLocationId {
companion object
}
+ val code: String
val country: Country
get() =
when (this) {
is Country -> this
- is City -> this.countryCode
- is Hostname -> this.city.countryCode
+ is City -> this.country
+ is Hostname -> this.city.country
}
companion object
diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt
index d4e5e4803d..922e97073b 100644
--- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt
+++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt
@@ -18,7 +18,7 @@ data class Dimensions(
val cellHeight: Dp = 56.dp,
val cellHeightTwoRows: Dp = 72.dp,
val cellLabelVerticalPadding: Dp = 14.dp,
- val cellStartPadding: Dp = 22.dp,
+ val cellStartPadding: Dp = 14.dp,
val cellStartPaddingInteractive: Dp = 14.dp,
val cellTopPadding: Dp = 6.dp,
val cellVerticalSpacing: Dp = 14.dp,
@@ -41,7 +41,7 @@ data class Dimensions(
val dropdownMenuVerticalPadding: Dp = 8.dp, // Used to remove padding from dropdown menu
val dropdownMenuBorder: Dp = 1.dp,
val expandableCellChevronSize: Dp = 30.dp,
- val filterTittlePadding: Dp = 4.dp,
+ val filterTitlePadding: Dp = 4.dp,
val formTextFieldMinHeight: Dp = 72.dp,
val iconFailSuccessTopMargin: Dp = 30.dp,
val iconHeight: Dp = 44.dp,