summaryrefslogtreecommitdiffhomepage
path: root/android
diff options
context:
space:
mode:
authorJonatan Rhodin <jonatan.rhodin@mullvad.net>2024-03-13 13:04:14 +0100
committerJonatan Rhodin <jonatan.rhodin@mullvad.net>2024-03-14 14:53:44 +0100
commit866d475e6688ca0fa35ec182b0715a258be467b8 (patch)
tree470683af79c9e6893ee5059661c705fbe67e6a44 /android
parenta4308de73a14665330bdff45cd9add1339bbccf8 (diff)
downloadmullvadvpn-866d475e6688ca0fa35ec182b0715a258be467b8.tar.xz
mullvadvpn-866d475e6688ca0fa35ec182b0715a258be467b8.zip
Add custom lists to select location screen
Diffstat (limited to 'android')
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/BaseCell.kt8
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CheckboxCell.kt26
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomListCell.kt31
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DnsCell.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DropdownMenuCell.kt61
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/HeaderCell.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/IconCell.kt54
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/InformationComposeCell.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/MtuComposeCell.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt436
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SelectableCell.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SplitTunnelingCell.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SwitchComposeCell.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/TwoRowCell.kt50
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LocationsEmptyText.kt60
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadCheckbox.kt21
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadModalBottomSheet.kt69
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt28
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/SnackbarHostExtensions.kt18
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt681
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt20
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Effect.kt12
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/PreviewData.kt78
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/CustomListExtensions.kt20
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListConstant.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt54
-rw-r--r--android/lib/resource/src/main/res/drawable/icon_add.xml9
-rw-r--r--android/lib/resource/src/main/res/drawable/icon_delete.xml9
-rw-r--r--android/lib/resource/src/main/res/drawable/icon_edit.xml9
-rw-r--r--android/lib/resource/src/main/res/drawable/icon_more_vert.xml9
-rw-r--r--android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt12
-rw-r--r--android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/Color.kt1
-rw-r--r--android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/ColorTokens.kt7
-rw-r--r--android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt6
-rw-r--r--android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/shape/Shape.kt6
39 files changed, 1443 insertions, 377 deletions
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/BaseCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/BaseCell.kt
index 15f6e4d111..19049613bd 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/BaseCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/BaseCell.kt
@@ -35,7 +35,7 @@ private fun PreviewBaseCell() {
AppTheme {
SpacedColumn {
BaseCell(
- title = {
+ headlineContent = {
BaseCellTitle(
title = "Header title",
style = MaterialTheme.typography.titleMedium
@@ -43,7 +43,7 @@ private fun PreviewBaseCell() {
}
)
BaseCell(
- title = {
+ headlineContent = {
BaseCellTitle(
title = "Normal title",
style = MaterialTheme.typography.labelLarge
@@ -58,7 +58,7 @@ private fun PreviewBaseCell() {
internal fun BaseCell(
modifier: Modifier = Modifier,
iconView: @Composable RowScope.() -> Unit = {},
- title: @Composable RowScope.() -> Unit,
+ headlineContent: @Composable RowScope.() -> Unit,
bodyView: @Composable ColumnScope.() -> Unit = {},
isRowEnabled: Boolean = true,
onCellClicked: () -> Unit = {},
@@ -83,7 +83,7 @@ internal fun BaseCell(
) {
iconView()
- title()
+ headlineContent()
Column(modifier = Modifier.wrapContentWidth().wrapContentHeight()) { bodyView() }
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CheckboxCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CheckboxCell.kt
index 5190a3a959..fe270ba445 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CheckboxCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CheckboxCell.kt
@@ -2,16 +2,12 @@ package net.mullvad.mullvadvpn.compose.cell
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.Spacer
import androidx.compose.foundation.layout.defaultMinSize
-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.material3.Checkbox
-import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -20,9 +16,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
+import net.mullvad.mullvadvpn.compose.component.MullvadCheckbox
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
-import net.mullvad.mullvadvpn.lib.theme.color.selected
@Preview
@Composable
@@ -37,7 +33,7 @@ internal fun CheckboxCell(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
background: Color = MaterialTheme.colorScheme.secondaryContainer,
- startPadding: Dp = Dimens.cellStartPadding,
+ startPadding: Dp = Dimens.mediumPadding,
endPadding: Dp = Dimens.cellEndPadding,
minHeight: Dp = Dimens.cellHeight
) {
@@ -51,23 +47,7 @@ internal fun CheckboxCell(
.background(background)
.padding(start = startPadding, end = endPadding)
) {
- Box(
- modifier =
- Modifier.size(Dimens.checkBoxSize)
- .background(Color.White, MaterialTheme.shapes.small)
- ) {
- Checkbox(
- modifier = Modifier.fillMaxSize(),
- checked = checked,
- onCheckedChange = onCheckedChange,
- colors =
- CheckboxDefaults.colors(
- checkedColor = Color.Transparent,
- uncheckedColor = Color.Transparent,
- checkmarkColor = MaterialTheme.colorScheme.selected
- ),
- )
- }
+ MullvadCheckbox(checked = checked, onCheckedChange = onCheckedChange)
Spacer(modifier = Modifier.size(Dimens.mediumPadding))
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomListCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomListCell.kt
new file mode 100644
index 0000000000..1029cfada0
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomListCell.kt
@@ -0,0 +1,31 @@
+package net.mullvad.mullvadvpn.compose.cell
+
+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.text.TextStyle
+import net.mullvad.mullvadvpn.relaylist.RelayItem
+
+@Composable
+fun CustomListCell(
+ customList: RelayItem.CustomList,
+ modifier: Modifier = Modifier,
+ onCellClicked: (RelayItem.CustomList) -> Unit = {},
+ textStyle: TextStyle = MaterialTheme.typography.titleMedium,
+ textColor: Color = MaterialTheme.colorScheme.onPrimary,
+ background: Color = MaterialTheme.colorScheme.primary,
+) {
+ BaseCell(
+ headlineContent = {
+ BaseCellTitle(
+ title = customList.name,
+ style = textStyle,
+ color = textColor,
+ )
+ },
+ modifier = modifier,
+ background = background,
+ onCellClicked = { onCellClicked(customList) }
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DnsCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DnsCell.kt
index 2a0043842a..21d5558f9e 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DnsCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DnsCell.kt
@@ -34,7 +34,7 @@ fun DnsCell(
val startPadding = 54.dp
BaseCell(
- title = { DnsTitle(address = address, modifier = titleModifier) },
+ headlineContent = { DnsTitle(address = address, modifier = titleModifier) },
bodyView = {
if (isUnreachableLocalDnsWarningVisible) {
Icon(
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DropdownMenuCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DropdownMenuCell.kt
new file mode 100644
index 0000000000..3d52aca80c
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DropdownMenuCell.kt
@@ -0,0 +1,61 @@
+package net.mullvad.mullvadvpn.compose.cell
+
+import androidx.compose.foundation.background
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.tooling.preview.Preview
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+
+@Preview
+@Composable
+private fun PreviewThreeDotCell() {
+ AppTheme {
+ ThreeDotCell(
+ text = "Three dots",
+ )
+ }
+}
+
+@Composable
+fun ThreeDotCell(
+ text: String,
+ modifier: Modifier = Modifier,
+ onClickDots: () -> Unit = {},
+ textStyle: TextStyle = MaterialTheme.typography.titleMedium,
+ textColor: Color = MaterialTheme.colorScheme.onPrimary,
+ background: Color = MaterialTheme.colorScheme.primary
+) {
+ BaseCell(
+ headlineContent = {
+ BaseCellTitle(
+ title = text,
+ style = textStyle,
+ color = textColor,
+ modifier = Modifier.weight(1f, true)
+ )
+ },
+ modifier = modifier,
+ background = background,
+ bodyView = {
+ IconButton(onClick = onClickDots) {
+ Icon(
+ painter = painterResource(id = R.drawable.icon_more_vert),
+ contentDescription = null,
+ tint = textColor
+ )
+ }
+ },
+ isRowEnabled = false,
+ endPadding = Dimens.smallPadding
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt
index 9ea26d6e01..73a6a5283d 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt
@@ -54,7 +54,7 @@ fun ExpandableComposeCell(
BaseCell(
modifier = Modifier.focusProperties { canFocus = false },
- title = {
+ headlineContent = {
BaseCellTitle(
title = title,
style = MaterialTheme.typography.titleMedium,
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/HeaderCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/HeaderCell.kt
index 8807430989..3260e9099a 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/HeaderCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/HeaderCell.kt
@@ -15,7 +15,7 @@ fun HeaderCell(
background: Color = MaterialTheme.colorScheme.primary,
) {
BaseCell(
- title = {
+ headlineContent = {
BaseCellTitle(
title = text,
style = textStyle,
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/IconCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/IconCell.kt
new file mode 100644
index 0000000000..faf537fb7f
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/IconCell.kt
@@ -0,0 +1,54 @@
+package net.mullvad.mullvadvpn.compose.cell
+
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.tooling.preview.Preview
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+
+@Preview
+@Composable
+private fun PreviewIconCell() {
+ AppTheme { IconCell(iconId = R.drawable.icon_add, title = "Add") }
+}
+
+@Composable
+fun IconCell(
+ iconId: Int?,
+ contentDescription: String? = null,
+ title: String,
+ titleStyle: TextStyle = MaterialTheme.typography.labelLarge,
+ titleColor: Color = MaterialTheme.colorScheme.onPrimary,
+ onClick: () -> Unit = {},
+ background: Color = MaterialTheme.colorScheme.primary,
+ enabled: Boolean = true,
+) {
+ BaseCell(
+ headlineContent = {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ iconId?.let {
+ Icon(
+ painter = painterResource(id = iconId),
+ contentDescription = contentDescription,
+ tint = titleColor
+ )
+ Spacer(modifier = Modifier.width(Dimens.mediumPadding))
+ }
+ BaseCellTitle(title = title, style = titleStyle, color = titleColor)
+ }
+ },
+ onCellClicked = onClick,
+ background = background,
+ isRowEnabled = enabled
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/InformationComposeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/InformationComposeCell.kt
index ef898aac6d..f490294491 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/InformationComposeCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/InformationComposeCell.kt
@@ -47,7 +47,7 @@ fun InformationComposeCell(
BaseCell(
modifier = Modifier.focusProperties { canFocus = false },
- title = {
+ headlineContent = {
BaseCellTitle(
title = title,
style = MaterialTheme.typography.titleMedium,
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/MtuComposeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/MtuComposeCell.kt
index 7cd45ddb2d..d949f2a708 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/MtuComposeCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/MtuComposeCell.kt
@@ -28,7 +28,7 @@ fun MtuComposeCell(
val titleModifier = Modifier
BaseCell(
- title = { MtuTitle(modifier = titleModifier.weight(1f, true)) },
+ headlineContent = { MtuTitle(modifier = titleModifier.weight(1f, true)) },
bodyView = { MtuBodyView(mtuValue = mtuValue, modifier = titleModifier) },
onCellClicked = { onEditMtu.invoke() }
)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt
index 272e599d8d..27b74227ca 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt
@@ -70,7 +70,7 @@ fun NavigationComposeCell(
) {
BaseCell(
onCellClicked = onClick,
- title = {
+ headlineContent = {
NavigationTitleView(
title = title,
modifier = modifier.weight(1f, true),
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 68899f7f77..032695be88 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,13 +1,17 @@
package net.mullvad.mullvadvpn.compose.cell
import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
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
+import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -25,163 +29,210 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
+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.util.generateRelayItemCountry
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
-import net.mullvad.mullvadvpn.lib.theme.color.Alpha40
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.model.GeographicLocationConstraint
import net.mullvad.mullvadvpn.relaylist.RelayItem
+import net.mullvad.mullvadvpn.relaylist.children
@Composable
@Preview
-private fun PreviewRelayLocationCell() {
+private fun PreviewStatusRelayLocationCell() {
AppTheme {
Column(Modifier.background(color = MaterialTheme.colorScheme.background)) {
val countryActive =
- RelayItem.Country(
+ generateRelayItemCountry(
name = "Relay country Active",
- code = "RC1",
- expanded = false,
- cities =
- listOf(
- RelayItem.City(
- name = "Relay city 1",
- code = "RI1",
- expanded = false,
- location = GeographicLocationConstraint.City("RC1", "RI1"),
- relays =
- listOf(
- RelayItem.Relay(
- name = "Relay 1",
- active = true,
- locationName = "",
- location =
- GeographicLocationConstraint.Hostname(
- "RC1",
- "RI1",
- "NER"
- )
- )
- )
- ),
- RelayItem.City(
- name = "Relay city 2",
- code = "RI2",
- expanded = true,
- location = GeographicLocationConstraint.City("RC1", "RI2"),
- relays =
- listOf(
- RelayItem.Relay(
- name = "Relay 2",
- active = true,
- locationName = "",
- location =
- GeographicLocationConstraint.Hostname(
- "RC1",
- "RI2",
- "NER"
- )
- ),
- RelayItem.Relay(
- name = "Relay 3",
- active = true,
- locationName = "",
- location =
- GeographicLocationConstraint.Hostname(
- "RC1",
- "RI1",
- "NER"
- )
- )
- )
- )
- )
+ cityNames = listOf("Relay city 1", "Relay city 2"),
+ relaysPerCity = 2
)
val countryNotActive =
- RelayItem.Country(
+ generateRelayItemCountry(
name = "Not Enabled Relay country",
- code = "RC3",
+ cityNames = listOf("Not Enabled city"),
+ relaysPerCity = 1,
+ active = false
+ )
+ val countryExpanded =
+ generateRelayItemCountry(
+ name = "Relay country Expanded",
+ cityNames = listOf("Normal city"),
+ relaysPerCity = 2,
+ expanded = true
+ )
+ val countryAndCityExpanded =
+ generateRelayItemCountry(
+ name = "Country and city Expanded",
+ cityNames = listOf("Expanded city A", "Expanded city B"),
+ relaysPerCity = 2,
expanded = true,
- cities =
- listOf(
- RelayItem.City(
- name = "Not Enabled city",
- code = "RI3",
- expanded = true,
- location = GeographicLocationConstraint.City("RC3", "RI3"),
- relays =
- listOf(
- RelayItem.Relay(
- name = "Not Enabled Relay",
- active = false,
- locationName = "",
- location =
- GeographicLocationConstraint.Hostname(
- "RC3",
- "RI3",
- "NER"
- )
- )
- )
- )
- )
+ expandChildren = true
)
// Active relay list not expanded
- RelayLocationCell(countryActive)
+ StatusRelayLocationCell(countryActive)
// Not Active Relay
- RelayLocationCell(countryNotActive)
- // Relay expanded country and city
- RelayLocationCell(countryActive.copy(expanded = true))
+ StatusRelayLocationCell(countryNotActive)
+ // Relay expanded country
+ StatusRelayLocationCell(countryExpanded)
+ // Relay expanded country and cities
+ StatusRelayLocationCell(countryAndCityExpanded)
+ }
+ }
+}
+
+@Composable
+@Preview
+private fun PreviewCheckableRelayLocationCell() {
+ AppTheme {
+ Column(Modifier.background(color = MaterialTheme.colorScheme.background)) {
+ val countryActive =
+ generateRelayItemCountry(
+ name = "Relay country Active",
+ cityNames = listOf("Relay city 1", "Relay city 2"),
+ relaysPerCity = 2
+ )
+ val countryExpanded =
+ generateRelayItemCountry(
+ name = "Relay country Expanded",
+ cityNames = listOf("Normal city"),
+ relaysPerCity = 2,
+ expanded = true
+ )
+ val countryAndCityExpanded =
+ generateRelayItemCountry(
+ name = "Country and city Expanded",
+ cityNames = listOf("Expanded city A", "Expanded city B"),
+ relaysPerCity = 2,
+ expanded = true,
+ expandChildren = true
+ )
+ // Active relay list not expanded
+ CheckableRelayLocationCell(countryActive)
+ // Relay expanded country
+ CheckableRelayLocationCell(countryExpanded)
+ // Relay expanded country and cities
+ CheckableRelayLocationCell(countryAndCityExpanded)
}
}
}
@Composable
-fun RelayLocationCell(
+fun StatusRelayLocationCell(
relay: RelayItem,
modifier: Modifier = Modifier,
activeColor: Color = MaterialTheme.colorScheme.selected,
inactiveColor: Color = MaterialTheme.colorScheme.error,
+ disabledColor: Color = MaterialTheme.colorScheme.onSecondary,
selectedItem: RelayItem? = null,
- onSelectRelay: (item: RelayItem) -> Unit = {}
+ onSelectRelay: (item: RelayItem) -> Unit = {},
+ onLongClick: (item: RelayItem) -> Unit = {}
) {
- val startPadding =
- when (relay) {
- is RelayItem.Country,
- is RelayItem.CustomList -> Dimens.countryRowPadding
- is RelayItem.City -> Dimens.cityRowPadding
- is RelayItem.Relay -> Dimens.relayRowPadding
- }
- val selected = selectedItem?.code == relay.code
+ RelayLocationCell(
+ relay = relay,
+ leadingContent = { relayItem ->
+ val selected = selectedItem?.code == relayItem.code
+ 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
+ )
+ )
+ Image(
+ painter = painterResource(id = R.drawable.icon_tick),
+ modifier =
+ Modifier.align(Alignment.CenterStart)
+ .alpha(
+ if (selected) {
+ AlphaVisible
+ } else {
+ AlphaInvisible
+ }
+ ),
+ contentDescription = null
+ )
+ },
+ modifier = modifier,
+ specialBackgroundColor = { relayItem ->
+ when {
+ selectedItem?.code == relayItem.code -> MaterialTheme.colorScheme.selected
+ relayItem is RelayItem.CustomList && !relayItem.active ->
+ MaterialTheme.colorScheme.surfaceTint
+ else -> null
+ }
+ },
+ onClick = onSelectRelay,
+ 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
+ )
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun RelayLocationCell(
+ relay: RelayItem,
+ leadingContent: @Composable BoxScope.(relay: RelayItem) -> Unit,
+ 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
+) {
+ val startPadding = leadingContentStartPadding + leadingContentStarPaddingModifier * depth
val expanded =
rememberSaveable(key = relay.expanded.toString()) { mutableStateOf(relay.expanded) }
- val backgroundColor =
- when {
- selected -> MaterialTheme.colorScheme.inversePrimary
- relay is RelayItem.Country -> MaterialTheme.colorScheme.primary
- relay is RelayItem.City ->
- MaterialTheme.colorScheme.primary
- .copy(alpha = Alpha40)
- .compositeOver(MaterialTheme.colorScheme.background)
- relay is RelayItem.Relay -> MaterialTheme.colorScheme.secondaryContainer
- else -> MaterialTheme.colorScheme.primary
- }
Column(
modifier =
- modifier.then(
- Modifier.fillMaxWidth()
- .padding(top = Dimens.listItemDivider)
- .wrapContentHeight()
- .fillMaxWidth()
- )
+ modifier
+ .fillMaxWidth()
+ .padding(top = Dimens.listItemDivider)
+ .wrapContentHeight()
+ .fillMaxWidth()
) {
Row(
modifier =
@@ -189,110 +240,89 @@ fun RelayLocationCell(
.wrapContentHeight()
.height(IntrinsicSize.Min)
.fillMaxWidth()
- .background(backgroundColor)
+ .background(specialBackgroundColor.invoke(relay) ?: depth.toBackgroundColor())
) {
Row(
modifier =
Modifier.weight(1f)
- .then(
- if (relay.active) {
- Modifier.clickable { onSelectRelay(relay) }
- } else {
- Modifier
- }
+ .combinedClickable(
+ enabled = relay.active,
+ onClick = { onClick(relay) },
+ onLongClick = { onLongClick?.invoke(relay) },
)
) {
Box(
modifier =
Modifier.align(Alignment.CenterVertically).padding(start = startPadding)
) {
- Box(
- modifier =
- Modifier.align(Alignment.CenterStart)
- .size(Dimens.relayCircleSize)
- .background(
- color =
- when {
- selected -> Color.Transparent
- relay.active -> activeColor
- else -> inactiveColor
- },
- shape = CircleShape
- )
- )
- Image(
- painter = painterResource(id = R.drawable.icon_tick),
- modifier =
- Modifier.align(Alignment.CenterStart)
- .alpha(
- if (selected) {
- AlphaVisible
- } else {
- AlphaInvisible
- }
- ),
- contentDescription = null
- )
+ leadingContent(relay)
}
- Text(
- text = relay.name,
- color = MaterialTheme.colorScheme.onPrimary,
- modifier =
- Modifier.weight(1f)
- .align(Alignment.CenterVertically)
- .alpha(
- if (relay.active) {
- AlphaVisible
- } else {
- AlphaInactive
- }
- )
- .padding(
- horizontal = Dimens.smallPadding,
- vertical = Dimens.mediumPadding
- )
+ Name(
+ modifier = Modifier.weight(1f).align(Alignment.CenterVertically),
+ relay = relay
)
}
if (relay.hasChildren) {
- VerticalDivider(
- color = MaterialTheme.colorScheme.background,
- modifier = Modifier.padding(vertical = Dimens.verticalDividerPadding)
- )
- Chevron(
- isExpanded = expanded.value,
- modifier =
- Modifier.fillMaxHeight()
- .clickable { expanded.value = !expanded.value }
- .padding(horizontal = Dimens.largePadding)
- .align(Alignment.CenterVertically)
- )
+ ExpandButton(isExpanded = expanded.value) { expand -> expanded.value = expand }
}
}
if (expanded.value) {
- when (relay) {
- is RelayItem.Country -> {
- relay.cities.forEach { relayCity ->
- RelayLocationCell(
- relay = relayCity,
- selectedItem = selectedItem,
- onSelectRelay = onSelectRelay,
- modifier = Modifier.animateContentSize()
- )
- }
- }
- is RelayItem.City -> {
- relay.relays.forEach { relay ->
- RelayLocationCell(
- relay = relay,
- selectedItem = selectedItem,
- onSelectRelay = onSelectRelay,
- modifier = Modifier.animateContentSize()
- )
- }
- }
- is RelayItem.Relay,
- is RelayItem.CustomList -> {}
+ relay.children().forEach {
+ RelayLocationCell(
+ relay = it,
+ onClick = onClick,
+ modifier = Modifier.animateContentSize(),
+ leadingContent = leadingContent,
+ specialBackgroundColor = specialBackgroundColor,
+ onLongClick = onLongClick,
+ depth = depth + 1,
+ )
}
}
}
}
+
+@Composable
+private fun Name(modifier: Modifier = Modifier, relay: RelayItem) {
+ Text(
+ text = relay.name,
+ color = MaterialTheme.colorScheme.onPrimary,
+ modifier =
+ modifier
+ .alpha(
+ if (relay.active) {
+ AlphaVisible
+ } else {
+ AlphaInactive
+ }
+ )
+ .padding(horizontal = Dimens.smallPadding, vertical = Dimens.mediumPadding)
+ )
+}
+
+@Composable
+private fun RowScope.ExpandButton(isExpanded: Boolean, onClick: (expand: Boolean) -> Unit) {
+ VerticalDivider(
+ color = MaterialTheme.colorScheme.background,
+ modifier = Modifier.padding(vertical = Dimens.verticalDividerPadding)
+ )
+ Chevron(
+ isExpanded = isExpanded,
+ modifier =
+ Modifier.fillMaxHeight()
+ .clickable { onClick(!isExpanded) }
+ .padding(horizontal = Dimens.largePadding)
+ .align(Alignment.CenterVertically)
+ )
+}
+
+@Suppress("MagicNumber")
+@Composable
+private fun Int.toBackgroundColor(): Color =
+ when (this) {
+ 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/cell/SelectableCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SelectableCell.kt
index 686ba8846d..d69194490e 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SelectableCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SelectableCell.kt
@@ -56,7 +56,7 @@ fun SelectableCell(
) {
BaseCell(
onCellClicked = onCellClicked,
- title = { BaseCellTitle(title = title, style = titleStyle) },
+ headlineContent = { BaseCellTitle(title = title, style = titleStyle) },
background =
if (isSelected) {
selectedColor
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SplitTunnelingCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SplitTunnelingCell.kt
index 25b6f71445..98c3767393 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SplitTunnelingCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SplitTunnelingCell.kt
@@ -89,7 +89,7 @@ fun SplitTunnelingCell(
Modifier.align(Alignment.CenterVertically).size(size = Dimens.listIconSize)
)
},
- title = {
+ headlineContent = {
Text(
text = title,
style = MaterialTheme.typography.listItemText,
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SwitchComposeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SwitchComposeCell.kt
index 6aeea8897d..f74349113c 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SwitchComposeCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SwitchComposeCell.kt
@@ -121,7 +121,7 @@ private fun SwitchComposeCell(
) {
BaseCell(
modifier = modifier.focusProperties { canFocus = false },
- title = titleView,
+ headlineContent = titleView,
isRowEnabled = isEnabled,
bodyView = {
SwitchCellView(
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/TwoRowCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/TwoRowCell.kt
new file mode 100644
index 0000000000..0b1f36d21d
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/TwoRowCell.kt
@@ -0,0 +1,50 @@
+package net.mullvad.mullvadvpn.compose.cell
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.tooling.preview.Preview
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+
+@Preview
+@Composable
+private fun PreviewTwoRowCell() {
+ AppTheme { TwoRowCell(titleText = "Title", subtitleText = "Subtitle") }
+}
+
+@Composable
+fun TwoRowCell(
+ titleText: String,
+ subtitleText: String,
+ onCellClicked: () -> Unit = {},
+ titleColor: Color = MaterialTheme.colorScheme.onPrimary,
+ subtitleColor: Color = MaterialTheme.colorScheme.onPrimary,
+ background: Color = MaterialTheme.colorScheme.primary
+) {
+ BaseCell(
+ headlineContent = {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = titleText,
+ style = MaterialTheme.typography.labelLarge,
+ color = titleColor
+ )
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = subtitleText,
+ style = MaterialTheme.typography.labelLarge,
+ color = subtitleColor
+ )
+ }
+ },
+ onCellClicked = onCellClicked,
+ background = background,
+ minHeight = Dimens.cellHeightTwoRows
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LocationsEmptyText.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LocationsEmptyText.kt
new file mode 100644
index 0000000000..7c12443647
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LocationsEmptyText.kt
@@ -0,0 +1,60 @@
+package net.mullvad.mullvadvpn.compose.component
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.core.text.HtmlCompat
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.extensions.toAnnotatedString
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.relaylist.MIN_SEARCH_LENGTH
+
+@Composable
+fun LocationsEmptyText(searchTerm: String) {
+ if (searchTerm.length >= MIN_SEARCH_LENGTH) {
+ val firstRow =
+ HtmlCompat.fromHtml(
+ textResource(
+ id = R.string.select_location_empty_text_first_row,
+ searchTerm,
+ ),
+ HtmlCompat.FROM_HTML_MODE_COMPACT,
+ )
+ .toAnnotatedString(boldFontWeight = FontWeight.ExtraBold)
+ val secondRow = textResource(id = R.string.select_location_empty_text_second_row)
+ Column(
+ modifier = Modifier.padding(horizontal = Dimens.selectLocationTitlePadding),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ text = firstRow,
+ style = MaterialTheme.typography.labelMedium,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSecondary,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ text = secondRow,
+ style = MaterialTheme.typography.labelMedium,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSecondary,
+ )
+ }
+ } else {
+ Text(
+ text = stringResource(R.string.no_locations_found),
+ modifier = Modifier.padding(Dimens.screenVerticalMargin),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSecondary
+ )
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadCheckbox.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadCheckbox.kt
new file mode 100644
index 0000000000..741fcf960f
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadCheckbox.kt
@@ -0,0 +1,21 @@
+package net.mullvad.mullvadvpn.compose.component
+
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.CheckboxDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import net.mullvad.mullvadvpn.lib.theme.color.selected
+
+@Composable
+fun MullvadCheckbox(checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
+ Checkbox(
+ checked = checked,
+ onCheckedChange = onCheckedChange,
+ colors =
+ CheckboxDefaults.colors(
+ checkedColor = MaterialTheme.colorScheme.onPrimary,
+ uncheckedColor = MaterialTheme.colorScheme.onPrimary,
+ checkmarkColor = MaterialTheme.colorScheme.selected
+ )
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadModalBottomSheet.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadModalBottomSheet.kt
new file mode 100644
index 0000000000..1f8fb46cd7
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadModalBottomSheet.kt
@@ -0,0 +1,69 @@
+package net.mullvad.mullvadvpn.compose.component
+
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.BottomSheetDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.SheetState
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.tooling.preview.Preview
+import net.mullvad.mullvadvpn.compose.cell.HeaderCell
+import net.mullvad.mullvadvpn.compose.cell.IconCell
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Preview
+@Composable
+private fun PreviewMullvadModalBottomSheet() {
+ AppTheme {
+ MullvadModalBottomSheet(
+ sheetContent = {
+ HeaderCell(
+ text = "Title",
+ )
+ HorizontalDivider()
+ IconCell(
+ iconId = null,
+ title = "Select",
+ )
+ },
+ closeBottomSheet = {}
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun MullvadModalBottomSheet(
+ modifier: Modifier = Modifier,
+ sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
+ backgroundColor: Color = MaterialTheme.colorScheme.surfaceContainer,
+ onBackgroundColor: Color = MaterialTheme.colorScheme.onSurface,
+ closeBottomSheet: () -> Unit,
+ sheetContent: @Composable ColumnScope.() -> Unit
+) {
+ // This is to avoid weird colors in the status bar and the navigation bar
+ val paddingValues = BottomSheetDefaults.windowInsets.asPaddingValues()
+ ModalBottomSheet(
+ onDismissRequest = closeBottomSheet,
+ sheetState = sheetState,
+ containerColor = backgroundColor,
+ modifier = modifier,
+ windowInsets = WindowInsets(0, 0, 0, 0), // No insets
+ dragHandle = { BottomSheetDefaults.DragHandle(color = onBackgroundColor) }
+ ) {
+ sheetContent()
+ Spacer(modifier = Modifier.height(Dimens.smallPadding))
+ Spacer(modifier = Modifier.height(paddingValues.calculateBottomPadding()))
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt
index 7ca9af3b17..b9a6306413 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt
@@ -108,7 +108,12 @@ fun ScaffoldWithTopBarAndDeviceName(
@Composable
fun MullvadSnackbar(snackbarData: SnackbarData) {
- Snackbar(snackbarData = snackbarData, contentColor = MaterialTheme.colorScheme.secondary)
+ Snackbar(
+ snackbarData = snackbarData,
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ contentColor = MaterialTheme.colorScheme.onSurface,
+ actionColor = MaterialTheme.colorScheme.onSurface
+ )
}
@Composable
@@ -257,3 +262,24 @@ fun ScaffoldWithLargeTopBarAndButton(
}
)
}
+
+@Composable
+fun ScaffoldWithSmallTopBar(
+ appBarTitle: String,
+ modifier: Modifier = Modifier,
+ navigationIcon: @Composable () -> Unit = {},
+ actions: @Composable RowScope.() -> Unit = {},
+ content: @Composable (modifier: Modifier) -> Unit
+) {
+ Scaffold(
+ modifier = modifier.fillMaxSize(),
+ topBar = {
+ MullvadSmallTopBar(
+ title = appBarTitle,
+ navigationIcon = navigationIcon,
+ actions = actions
+ )
+ },
+ content = { content(Modifier.fillMaxSize().padding(it)) }
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/SnackbarHostExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/SnackbarHostExtensions.kt
new file mode 100644
index 0000000000..8a418c17aa
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/SnackbarHostExtensions.kt
@@ -0,0 +1,18 @@
+package net.mullvad.mullvadvpn.compose.extensions
+
+import androidx.compose.material3.SnackbarDuration
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.SnackbarResult
+
+suspend fun SnackbarHostState.showSnackbar(
+ message: String,
+ actionLabel: String,
+ duration: SnackbarDuration = SnackbarDuration.Indefinite,
+ onAction: (() -> Unit),
+ onDismiss: (() -> Unit) = {}
+) {
+ when (showSnackbar(message = message, actionLabel = actionLabel, duration = duration)) {
+ SnackbarResult.ActionPerformed -> onAction()
+ SnackbarResult.Dismissed -> onDismiss()
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt
index 6e762bbf43..e6402fc8bd 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt
@@ -350,7 +350,7 @@ private fun DeviceListItem(
) {
BaseCell(
isRowEnabled = false,
- title = {
+ headlineContent = {
Column(modifier = Modifier.weight(1f)) {
Text(
modifier = Modifier.fillMaxWidth(),
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 1f17da8bc5..594c657cdb 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,6 +1,8 @@
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
import androidx.compose.foundation.layout.Column
@@ -13,49 +15,81 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SheetState
+import androidx.compose.material3.SnackbarDuration
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
-import androidx.core.text.HtmlCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import com.ramcosta.composedestinations.result.NavResult
+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.RelayLocationCell
+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.SwitchComposeSubtitleCell
+import net.mullvad.mullvadvpn.compose.cell.ThreeDotCell
+import net.mullvad.mullvadvpn.compose.communication.CustomListAction
+import net.mullvad.mullvadvpn.compose.communication.CustomListResult
+import net.mullvad.mullvadvpn.compose.component.LocationsEmptyText
import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge
+import net.mullvad.mullvadvpn.compose.component.MullvadModalBottomSheet
+import net.mullvad.mullvadvpn.compose.component.MullvadSnackbar
import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar
-import net.mullvad.mullvadvpn.compose.component.textResource
import net.mullvad.mullvadvpn.compose.constant.ContentType
+import net.mullvad.mullvadvpn.compose.destinations.CreateCustomListDestination
+import net.mullvad.mullvadvpn.compose.destinations.CustomListLocationsDestination
+import net.mullvad.mullvadvpn.compose.destinations.CustomListsDestination
+import net.mullvad.mullvadvpn.compose.destinations.DeleteCustomListDestination
+import net.mullvad.mullvadvpn.compose.destinations.EditCustomListNameDestination
import net.mullvad.mullvadvpn.compose.destinations.FilterScreenDestination
-import net.mullvad.mullvadvpn.compose.extensions.toAnnotatedString
-import net.mullvad.mullvadvpn.compose.state.RelayListState
+import net.mullvad.mullvadvpn.compose.extensions.showSnackbar
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
+import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG
+import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_LOCATION_BOTTOM_SHEET_TEST_TAG
import net.mullvad.mullvadvpn.compose.textfield.SearchTextField
import net.mullvad.mullvadvpn.compose.transitions.SelectLocationTransition
import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect
+import net.mullvad.mullvadvpn.compose.util.RunOnKeyChange
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.AlphaScrollbar
+import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible
import net.mullvad.mullvadvpn.relaylist.RelayItem
+import net.mullvad.mullvadvpn.relaylist.canAddLocation
import net.mullvad.mullvadvpn.viewmodel.SelectLocationSideEffect
import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel
import org.koin.androidx.compose.koinViewModel
@@ -64,16 +98,14 @@ import org.koin.androidx.compose.koinViewModel
@Composable
private fun PreviewSelectLocationScreen() {
val state =
- SelectLocationUiState.Data(
+ SelectLocationUiState.Content(
searchTerm = "",
selectedOwnership = null,
selectedProvidersCount = 0,
- relayListState =
- RelayListState.RelayList(
- countries =
- listOf(RelayItem.Country("Country 1", "Code 1", false, emptyList())),
- selectedItem = null,
- )
+ countries = listOf(RelayItem.Country("Country 1", "Code 1", false, emptyList())),
+ selectedItem = null,
+ customLists = emptyList(),
+ filteredCustomLists = emptyList()
)
AppTheme {
SelectLocationScreen(
@@ -84,23 +116,84 @@ private fun PreviewSelectLocationScreen() {
@Destination(style = SelectLocationTransition::class)
@Composable
-fun SelectLocation(navigator: DestinationsNavigator) {
+fun SelectLocation(
+ navigator: DestinationsNavigator,
+ createCustomListDialogResultRecipient:
+ ResultRecipient<CreateCustomListDestination, CustomListResult.Created>,
+ editCustomListNameDialogResultRecipient:
+ ResultRecipient<EditCustomListNameDestination, CustomListResult.Renamed>,
+ deleteCustomListDialogResultRecipient:
+ ResultRecipient<DeleteCustomListDestination, CustomListResult.Deleted>,
+ updateCustomListResultRecipient:
+ ResultRecipient<CustomListLocationsDestination, CustomListResult.LocationsChanged>
+) {
val vm = koinViewModel<SelectLocationViewModel>()
- val state by vm.uiState.collectAsStateWithLifecycle()
+ val state = vm.uiState.collectAsStateWithLifecycle().value
+
+ val snackbarHostState = remember { SnackbarHostState() }
+ val context = LocalContext.current
+
LaunchedEffectCollect(vm.uiSideEffect) {
when (it) {
SelectLocationSideEffect.CloseScreen -> navigator.navigateUp()
+ is SelectLocationSideEffect.LocationAddedToCustomList -> {
+ launch {
+ snackbarHostState.showResultSnackbar(
+ context = context,
+ result = it.result,
+ onUndo = vm::performAction
+ )
+ }
+ }
}
}
+ createCustomListDialogResultRecipient.OnCustomListNavResult(
+ snackbarHostState,
+ vm::performAction
+ )
+
+ editCustomListNameDialogResultRecipient.OnCustomListNavResult(
+ snackbarHostState,
+ vm::performAction
+ )
+
+ deleteCustomListDialogResultRecipient.OnCustomListNavResult(
+ snackbarHostState,
+ vm::performAction
+ )
+
+ updateCustomListResultRecipient.OnCustomListNavResult(snackbarHostState, vm::performAction)
+
SelectLocationScreen(
state = state,
+ snackbarHostState = snackbarHostState,
onSelectRelay = vm::selectRelay,
onSearchTermInput = vm::onSearchTermInput,
onBackClick = navigator::navigateUp,
onFilterClick = { navigator.navigate(FilterScreenDestination, true) },
+ onCreateCustomList = { relayItem ->
+ navigator.navigate(CreateCustomListDestination(locationCode = relayItem?.code ?: "")) {
+ launchSingleTop = true
+ }
+ },
+ onEditCustomLists = { navigator.navigate(CustomListsDestination()) },
removeOwnershipFilter = vm::removeOwnerFilter,
- removeProviderFilter = vm::removeProviderFilter
+ removeProviderFilter = vm::removeProviderFilter,
+ onAddLocationToList = vm::addLocationToList,
+ onEditCustomListName = {
+ navigator.navigate(
+ EditCustomListNameDestination(customListId = it.id, initialName = it.name)
+ )
+ },
+ onEditLocationsCustomList = {
+ navigator.navigate(
+ CustomListLocationsDestination(customListId = it.id, newList = false)
+ )
+ },
+ onDeleteCustomList = {
+ navigator.navigate(DeleteCustomListDestination(customListId = it.id, name = it.name))
+ }
)
}
@@ -108,16 +201,43 @@ fun SelectLocation(navigator: DestinationsNavigator) {
@Composable
fun SelectLocationScreen(
state: SelectLocationUiState,
+ snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
onSelectRelay: (item: RelayItem) -> Unit = {},
onSearchTermInput: (searchTerm: String) -> Unit = {},
onBackClick: () -> Unit = {},
onFilterClick: () -> Unit = {},
+ onCreateCustomList: (location: RelayItem?) -> Unit = {},
+ onEditCustomLists: () -> Unit = {},
removeOwnershipFilter: () -> Unit = {},
- removeProviderFilter: () -> Unit = {}
+ removeProviderFilter: () -> Unit = {},
+ onAddLocationToList: (location: RelayItem, customList: RelayItem.CustomList) -> Unit = { _, _ ->
+ },
+ onEditCustomListName: (RelayItem.CustomList) -> Unit = {},
+ onEditLocationsCustomList: (RelayItem.CustomList) -> Unit = {},
+ onDeleteCustomList: (RelayItem.CustomList) -> Unit = {}
) {
val backgroundColor = MaterialTheme.colorScheme.background
- Scaffold {
+ Scaffold(
+ snackbarHost = {
+ SnackbarHost(
+ snackbarHostState,
+ snackbar = { snackbarData -> MullvadSnackbar(snackbarData = snackbarData) }
+ )
+ }
+ ) {
+ var bottomSheetState by remember { mutableStateOf<BottomSheetState?>(null) }
+ BottomSheets(
+ bottomSheetState = bottomSheetState,
+ onCreateCustomList = onCreateCustomList,
+ onEditCustomLists = onEditCustomLists,
+ onAddLocationToList = onAddLocationToList,
+ onEditCustomListName = onEditCustomListName,
+ onEditLocationsCustomList = onEditLocationsCustomList,
+ onDeleteCustomList = onDeleteCustomList,
+ onHideBottomSheet = { bottomSheetState = null }
+ )
+
Column(modifier = Modifier.padding(it).background(backgroundColor).fillMaxSize()) {
Row(modifier = Modifier.fillMaxWidth()) {
IconButton(onClick = onBackClick) {
@@ -133,7 +253,7 @@ fun SelectLocationScreen(
modifier = Modifier.align(Alignment.CenterVertically).weight(weight = 1f),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLarge,
- color = MaterialTheme.colorScheme.onPrimary
+ color = MaterialTheme.colorScheme.onPrimary,
)
IconButton(onClick = onFilterClick) {
Icon(
@@ -146,13 +266,13 @@ fun SelectLocationScreen(
when (state) {
SelectLocationUiState.Loading -> {}
- is SelectLocationUiState.Data -> {
+ is SelectLocationUiState.Content -> {
if (state.hasFilter) {
FilterCell(
ownershipFilter = state.selectedOwnership,
selectedProviderFilter = state.selectedProvidersCount,
removeOwnershipFilter = removeOwnershipFilter,
- removeProviderFilter = removeProviderFilter
+ removeProviderFilter = removeProviderFilter,
)
}
}
@@ -164,24 +284,20 @@ fun SelectLocationScreen(
.height(Dimens.searchFieldHeight)
.padding(horizontal = Dimens.searchFieldHorizontalPadding),
backgroundColor = MaterialTheme.colorScheme.tertiaryContainer,
- textColor = MaterialTheme.colorScheme.onTertiaryContainer
+ textColor = MaterialTheme.colorScheme.onTertiaryContainer,
) { searchString ->
onSearchTermInput.invoke(searchString)
}
Spacer(modifier = Modifier.height(height = Dimens.verticalSpace))
val lazyListState = rememberLazyListState()
- if (
- state is SelectLocationUiState.Data &&
- state.relayListState is RelayListState.RelayList &&
- state.relayListState.selectedItem != null
- ) {
- LaunchedEffect(state.relayListState.selectedItem) {
- val index = state.relayListState.indexOfSelectedRelayItem()
+ val selectedItemCode =
+ (state as? SelectLocationUiState.Content)?.selectedItem?.code ?: ""
+ RunOnKeyChange(key = selectedItemCode) {
+ val index = state.indexOfSelectedRelayItem()
- if (index >= 0) {
- lazyListState.scrollToItem(index)
- lazyListState.animateScrollAndCentralizeItem(index)
- }
+ if (index >= 0) {
+ lazyListState.scrollToItem(index)
+ lazyListState.animateScrollAndCentralizeItem(index)
}
}
LazyColumn(
@@ -189,7 +305,7 @@ fun SelectLocationScreen(
Modifier.fillMaxSize()
.drawVerticalScrollbar(
lazyListState,
- MaterialTheme.colorScheme.onBackground.copy(alpha = AlphaScrollbar)
+ MaterialTheme.colorScheme.onBackground.copy(alpha = AlphaScrollbar),
),
state = lazyListState,
horizontalAlignment = Alignment.CenterHorizontally,
@@ -198,12 +314,42 @@ fun SelectLocationScreen(
SelectLocationUiState.Loading -> {
loading()
}
- is SelectLocationUiState.Data -> {
- relayList(
- relayListState = state.relayListState,
- searchTerm = state.searchTerm,
- onSelectRelay = onSelectRelay
- )
+ is SelectLocationUiState.Content -> {
+ if (state.showCustomLists) {
+ customLists(
+ customLists = state.filteredCustomLists,
+ selectedItem = state.selectedItem,
+ onSelectRelay = onSelectRelay,
+ onShowCustomListBottomSheet = {
+ bottomSheetState =
+ BottomSheetState.ShowCustomListsBottomSheet(
+ state.customLists.isNotEmpty()
+ )
+ },
+ onShowEditBottomSheet = { customList ->
+ bottomSheetState =
+ BottomSheetState.ShowEditCustomListBottomSheet(customList)
+ }
+ )
+ item { Spacer(modifier = Modifier.height(Dimens.mediumPadding)) }
+ }
+ 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) }
+ }
}
}
}
@@ -217,78 +363,337 @@ private fun LazyListScope.loading() {
}
}
-private fun LazyListScope.relayList(
- relayListState: RelayListState,
- searchTerm: String,
- onSelectRelay: (item: RelayItem) -> Unit
+@OptIn(ExperimentalFoundationApi::class)
+private fun LazyListScope.customLists(
+ customLists: List<RelayItem.CustomList>,
+ selectedItem: RelayItem?,
+ onSelectRelay: (item: RelayItem) -> Unit,
+ onShowCustomListBottomSheet: () -> Unit,
+ onShowEditBottomSheet: (RelayItem.CustomList) -> Unit
) {
- when (relayListState) {
- is RelayListState.RelayList -> {
- items(
- count = relayListState.countries.size,
- key = { index -> relayListState.countries[index].hashCode() },
- contentType = { ContentType.ITEM }
- ) { index ->
- val country = relayListState.countries[index]
- RelayLocationCell(
- relay = country,
- selectedItem = relayListState.selectedItem,
- onSelectRelay = onSelectRelay,
- modifier = Modifier.animateContentSize()
- )
- }
+ item(
+ contentType = { ContentType.HEADER },
+ ) {
+ ThreeDotCell(
+ text = stringResource(R.string.custom_lists),
+ onClickDots = onShowCustomListBottomSheet,
+ modifier = Modifier.testTag(SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG)
+ )
+ }
+ if (customLists.isNotEmpty()) {
+ items(
+ items = customLists,
+ key = { item -> item.code },
+ contentType = { ContentType.ITEM },
+ ) { customList ->
+ StatusRelayLocationCell(
+ relay = customList,
+ // Do not show selection for locations in custom lists
+ selectedItem = selectedItem as? RelayItem.CustomList,
+ onSelectRelay = onSelectRelay,
+ onLongClick = {
+ if (it is RelayItem.CustomList) {
+ onShowEditBottomSheet(it)
+ }
+ },
+ modifier = Modifier.animateContentSize().animateItemPlacement(),
+ )
}
- RelayListState.Empty -> {
- if (searchTerm.isNotEmpty())
- item(contentType = ContentType.EMPTY_TEXT) {
- val firstRow =
- HtmlCompat.fromHtml(
- textResource(
- id = R.string.select_location_empty_text_first_row,
- searchTerm
- ),
- HtmlCompat.FROM_HTML_MODE_COMPACT
- )
- .toAnnotatedString(boldFontWeight = FontWeight.ExtraBold)
- val secondRow =
- textResource(id = R.string.select_location_empty_text_second_row)
- Column(
- modifier = Modifier.padding(horizontal = Dimens.selectLocationTitlePadding),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- Text(
- text = firstRow,
- style = MaterialTheme.typography.labelMedium,
- textAlign = TextAlign.Center,
- color = MaterialTheme.colorScheme.onSecondary,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis
- )
- Text(
- text = secondRow,
- style = MaterialTheme.typography.labelMedium,
- textAlign = TextAlign.Center,
- color = MaterialTheme.colorScheme.onSecondary
- )
+ item {
+ SwitchComposeSubtitleCell(text = stringResource(R.string.to_add_locations_to_a_list))
+ }
+ } else {
+ item(contentType = ContentType.EMPTY_TEXT) {
+ SwitchComposeSubtitleCell(text = stringResource(R.string.to_create_a_custom_list))
+ }
+ }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+private fun LazyListScope.relayList(
+ countries: List<RelayItem.Country>,
+ selectedItem: RelayItem?,
+ onSelectRelay: (item: RelayItem) -> Unit,
+ onShowLocationBottomSheet: (item: RelayItem) -> Unit,
+) {
+ item(
+ contentType = ContentType.HEADER,
+ ) {
+ HeaderCell(
+ text = stringResource(R.string.all_locations),
+ )
+ }
+ items(
+ items = countries,
+ key = { item -> item.code },
+ contentType = { ContentType.ITEM },
+ ) { country ->
+ StatusRelayLocationCell(
+ relay = country,
+ selectedItem = selectedItem,
+ onSelectRelay = onSelectRelay,
+ onLongClick = onShowLocationBottomSheet,
+ modifier = Modifier.animateContentSize().animateItemPlacement(),
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun BottomSheets(
+ bottomSheetState: BottomSheetState?,
+ onCreateCustomList: (RelayItem?) -> Unit,
+ onEditCustomLists: () -> Unit,
+ onAddLocationToList: (RelayItem, RelayItem.CustomList) -> Unit,
+ onEditCustomListName: (RelayItem.CustomList) -> Unit,
+ onEditLocationsCustomList: (RelayItem.CustomList) -> Unit,
+ onDeleteCustomList: (RelayItem.CustomList) -> Unit,
+ onHideBottomSheet: () -> Unit
+) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ val scope = rememberCoroutineScope()
+ val onCloseBottomSheet: (animate: Boolean) -> Unit = { animate ->
+ if (animate) {
+ scope
+ .launch { sheetState.hide() }
+ .invokeOnCompletion {
+ if (!sheetState.isVisible) {
+ onHideBottomSheet()
}
}
+ } else {
+ onHideBottomSheet()
+ }
+ }
+ val onBackgroundColor: Color = MaterialTheme.colorScheme.onSurface
+
+ when (bottomSheetState) {
+ is BottomSheetState.ShowCustomListsBottomSheet -> {
+ CustomListsBottomSheet(
+ sheetState = sheetState,
+ onBackgroundColor = onBackgroundColor,
+ bottomSheetState = bottomSheetState,
+ onCreateCustomList = { onCreateCustomList(null) },
+ onEditCustomLists = onEditCustomLists,
+ closeBottomSheet = onCloseBottomSheet
+ )
+ }
+ is BottomSheetState.ShowLocationBottomSheet -> {
+ LocationBottomSheet(
+ sheetState = sheetState,
+ onBackgroundColor = onBackgroundColor,
+ customLists = bottomSheetState.customLists,
+ item = bottomSheetState.item,
+ onCreateCustomList = onCreateCustomList,
+ onAddLocationToList = onAddLocationToList,
+ closeBottomSheet = onCloseBottomSheet
+ )
+ }
+ is BottomSheetState.ShowEditCustomListBottomSheet -> {
+ EditCustomListBottomSheet(
+ sheetState = sheetState,
+ onBackgroundColor = onBackgroundColor,
+ customList = bottomSheetState.customList,
+ onEditName = onEditCustomListName,
+ onEditLocations = onEditLocationsCustomList,
+ onDeleteCustomList = onDeleteCustomList,
+ closeBottomSheet = onCloseBottomSheet
+ )
+ }
+ null -> {
+ /* Do nothing */
}
}
}
-private fun RelayListState.RelayList.indexOfSelectedRelayItem(): Int =
- countries.indexOfFirst { relayCountry ->
- relayCountry.location.location.country ==
- when (selectedItem) {
- is RelayItem.Country -> selectedItem.code
- is RelayItem.City -> selectedItem.location.countryCode
- is RelayItem.Relay -> selectedItem.location.countryCode
- is RelayItem.CustomList,
- null -> null
- }
+private fun SelectLocationUiState.indexOfSelectedRelayItem(): Int =
+ if (this is SelectLocationUiState.Content) {
+ when (selectedItem) {
+ is RelayItem.Country,
+ is RelayItem.City,
+ is RelayItem.Relay ->
+ countries.indexOfFirst { it.code == selectedItem.countryCode() } +
+ customLists.size +
+ EXTRA_ITEMS_LOCATION
+ is RelayItem.CustomList ->
+ filteredCustomLists.indexOfFirst { it.id == selectedItem.id } +
+ EXTRA_ITEM_CUSTOM_LIST
+ else -> -1
+ }
+ } else {
+ -1
+ }
+
+private fun RelayItem.countryCode(): String =
+ when (this) {
+ is RelayItem.Country -> this.code
+ is RelayItem.City -> this.location.countryCode
+ is RelayItem.Relay -> this.location.countryCode
+ is RelayItem.CustomList ->
+ throw IllegalArgumentException("Custom list does not have a country code")
}
-suspend fun LazyListState.animateScrollAndCentralizeItem(index: Int) {
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun CustomListsBottomSheet(
+ onBackgroundColor: Color,
+ sheetState: SheetState,
+ bottomSheetState: BottomSheetState.ShowCustomListsBottomSheet,
+ onCreateCustomList: () -> Unit,
+ onEditCustomLists: () -> Unit,
+ closeBottomSheet: (animate: Boolean) -> Unit
+) {
+ MullvadModalBottomSheet(
+ sheetState = sheetState,
+ closeBottomSheet = { closeBottomSheet(false) },
+ modifier = Modifier.testTag(SELECT_LOCATION_CUSTOM_LIST_BOTTOM_SHEET_TEST_TAG)
+ ) { ->
+ HeaderCell(
+ text = stringResource(id = R.string.edit_custom_lists),
+ background = Color.Unspecified
+ )
+ HorizontalDivider(color = onBackgroundColor)
+ IconCell(
+ iconId = R.drawable.icon_add,
+ title = stringResource(id = R.string.new_list),
+ onClick = {
+ onCreateCustomList()
+ closeBottomSheet(true)
+ },
+ background = Color.Unspecified,
+ titleColor = onBackgroundColor
+ )
+ IconCell(
+ iconId = R.drawable.icon_edit,
+ title = stringResource(id = R.string.edit_lists),
+ onClick = {
+ onEditCustomLists()
+ closeBottomSheet(true)
+ },
+ background = Color.Unspecified,
+ titleColor =
+ onBackgroundColor.copy(
+ alpha =
+ if (bottomSheetState.editListEnabled) {
+ AlphaVisible
+ } else {
+ AlphaInactive
+ }
+ ),
+ enabled = bottomSheetState.editListEnabled
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun LocationBottomSheet(
+ onBackgroundColor: Color,
+ sheetState: SheetState,
+ customLists: List<RelayItem.CustomList>,
+ item: RelayItem,
+ onCreateCustomList: (relayItem: RelayItem) -> Unit,
+ onAddLocationToList: (location: RelayItem, customList: RelayItem.CustomList) -> Unit,
+ closeBottomSheet: (animate: Boolean) -> Unit
+) {
+ MullvadModalBottomSheet(
+ sheetState = sheetState,
+ closeBottomSheet = { closeBottomSheet(false) },
+ modifier = Modifier.testTag(SELECT_LOCATION_LOCATION_BOTTOM_SHEET_TEST_TAG)
+ ) { ->
+ HeaderCell(
+ text = stringResource(id = R.string.add_location_to_list, item.name),
+ background = Color.Unspecified
+ )
+ HorizontalDivider(color = onBackgroundColor)
+ customLists.forEach {
+ val enabled = it.canAddLocation(item)
+ IconCell(
+ background = Color.Unspecified,
+ titleColor =
+ if (enabled) {
+ onBackgroundColor
+ } else {
+ MaterialTheme.colorScheme.onSecondary
+ },
+ iconId = null,
+ title =
+ if (enabled) {
+ it.name
+ } else {
+ stringResource(id = R.string.location_added, it.name)
+ },
+ onClick = {
+ onAddLocationToList(item, it)
+ closeBottomSheet(true)
+ },
+ enabled = enabled
+ )
+ }
+ IconCell(
+ iconId = R.drawable.icon_add,
+ title = stringResource(id = R.string.new_list),
+ onClick = {
+ onCreateCustomList(item)
+ closeBottomSheet(true)
+ },
+ background = Color.Unspecified,
+ titleColor = onBackgroundColor
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun EditCustomListBottomSheet(
+ onBackgroundColor: Color,
+ sheetState: SheetState,
+ customList: RelayItem.CustomList,
+ onEditName: (item: RelayItem.CustomList) -> Unit,
+ onEditLocations: (item: RelayItem.CustomList) -> Unit,
+ onDeleteCustomList: (item: RelayItem.CustomList) -> Unit,
+ closeBottomSheet: (animate: Boolean) -> Unit
+) {
+ MullvadModalBottomSheet(
+ sheetState = sheetState,
+ closeBottomSheet = { closeBottomSheet(false) }
+ ) {
+ HeaderCell(text = customList.name, background = Color.Unspecified)
+ IconCell(
+ iconId = R.drawable.icon_edit,
+ title = stringResource(id = R.string.edit_name),
+ onClick = {
+ onEditName(customList)
+ closeBottomSheet(true)
+ },
+ background = Color.Unspecified,
+ titleColor = onBackgroundColor
+ )
+ IconCell(
+ iconId = R.drawable.icon_add,
+ title = stringResource(id = R.string.edit_locations),
+ onClick = {
+ onEditLocations(customList)
+ closeBottomSheet(true)
+ },
+ background = Color.Unspecified,
+ titleColor = onBackgroundColor
+ )
+ HorizontalDivider(color = onBackgroundColor)
+ IconCell(
+ iconId = R.drawable.icon_delete,
+ title = stringResource(id = R.string.delete),
+ onClick = {
+ onDeleteCustomList(customList)
+ closeBottomSheet(true)
+ },
+ background = Color.Unspecified,
+ titleColor = onBackgroundColor
+ )
+ }
+}
+
+private suspend fun LazyListState.animateScrollAndCentralizeItem(index: Int) {
val itemInfo = this.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index }
if (itemInfo != null) {
val center = layoutInfo.viewportEndOffset / 2
@@ -298,3 +703,71 @@ suspend fun LazyListState.animateScrollAndCentralizeItem(index: Int) {
animateScrollToItem(index)
}
}
+
+private suspend fun SnackbarHostState.showResultSnackbar(
+ context: Context,
+ result: CustomListResult,
+ onUndo: (CustomListAction) -> Unit
+) {
+ currentSnackbarData?.dismiss()
+ showSnackbar(
+ message = result.message(context),
+ actionLabel = context.getString(R.string.undo),
+ duration = SnackbarDuration.Long,
+ onAction = { onUndo(result.undo) }
+ )
+}
+
+private fun CustomListResult.message(context: Context): String =
+ when (this) {
+ is CustomListResult.Created ->
+ context.getString(R.string.location_was_added_to_list, locationName, name)
+ is CustomListResult.Deleted -> context.getString(R.string.delete_custom_list_message, name)
+ is CustomListResult.Renamed -> context.getString(R.string.name_was_changed_to, name)
+ is CustomListResult.LocationsChanged ->
+ context.getString(R.string.locations_were_changed_for, name)
+ }
+
+@Composable
+private fun <D : DestinationSpec<*>, R : CustomListResult> ResultRecipient<D, R>
+ .OnCustomListNavResult(
+ snackbarHostState: SnackbarHostState,
+ performAction: (action: CustomListAction) -> Unit
+) {
+ val scope = rememberCoroutineScope()
+ val context = LocalContext.current
+ this.onNavResult { result ->
+ when (result) {
+ NavResult.Canceled -> {
+ /* Do nothing */
+ }
+ is NavResult.Value -> {
+ // Handle result
+ scope.launch {
+ snackbarHostState.showResultSnackbar(
+ context = context,
+ result = result.value,
+ onUndo = performAction
+ )
+ }
+ }
+ }
+ }
+}
+
+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 ShowLocationBottomSheet(
+ val customLists: List<RelayItem.CustomList>,
+ val item: RelayItem
+ ) : BottomSheetState
+
+ data class ShowEditCustomListBottomSheet(val customList: RelayItem.CustomList) :
+ BottomSheetState
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt
index 403b1bb57e..bd8809b00f 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt
@@ -468,7 +468,7 @@ fun VpnSettingsScreen(
itemWithDivider {
BaseCell(
onCellClicked = { navigateToDns(null, null) },
- title = {
+ headlineContent = {
Text(
text = stringResource(id = R.string.add_a_server),
color = Color.White,
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 fd775fa1bb..747e21d91c 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,25 +1,27 @@
package net.mullvad.mullvadvpn.compose.state
import net.mullvad.mullvadvpn.model.Ownership
+import net.mullvad.mullvadvpn.relaylist.MIN_SEARCH_LENGTH
import net.mullvad.mullvadvpn.relaylist.RelayItem
sealed interface SelectLocationUiState {
data object Loading : SelectLocationUiState
- data class Data(
+ data class Content(
val searchTerm: String,
val selectedOwnership: Ownership?,
val selectedProvidersCount: Int?,
- val relayListState: RelayListState
+ val filteredCustomLists: List<RelayItem.CustomList>,
+ val customLists: List<RelayItem.CustomList>,
+ val countries: List<RelayItem.Country>,
+ val selectedItem: RelayItem?
) : 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())
}
}
-
-sealed interface RelayListState {
- data object Empty : RelayListState
-
- data class RelayList(val countries: List<RelayItem.Country>, val selectedItem: RelayItem?) :
- RelayListState
-}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Effect.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Effect.kt
index 42d5f6caa0..c8a0847e89 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Effect.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Effect.kt
@@ -2,11 +2,14 @@ package net.mullvad.mullvadvpn.compose.util
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.flowWithLifecycle
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.launch
@Composable
inline fun <T> LaunchedEffectCollect(
@@ -34,3 +37,12 @@ inline fun <T> CollectSideEffectWithLifecycle(
}
}
}
+
+@Composable
+fun RunOnKeyChange(key: Any, block: suspend CoroutineScope.() -> Unit) {
+ val scope = rememberCoroutineScope()
+ rememberSaveable(key) {
+ scope.launch { block() }
+ key
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/PreviewData.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/PreviewData.kt
new file mode 100644
index 0000000000..61b563564c
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/PreviewData.kt
@@ -0,0 +1,78 @@
+package net.mullvad.mullvadvpn.compose.util
+
+import net.mullvad.mullvadvpn.model.GeographicLocationConstraint
+import net.mullvad.mullvadvpn.relaylist.RelayItem
+
+fun generateRelayItemCountry(
+ name: String,
+ cityNames: List<String>,
+ relaysPerCity: Int,
+ active: Boolean = true,
+ expanded: Boolean = false,
+ expandChildren: Boolean = false,
+) =
+ RelayItem.Country(
+ name = name,
+ code = name.generateCountryCode(),
+ cities =
+ cityNames.map { cityName ->
+ generateRelayItemCity(
+ cityName,
+ name.generateCountryCode(),
+ relaysPerCity,
+ active,
+ expandChildren
+ )
+ },
+ expanded = expanded,
+ )
+
+fun generateRelayItemCity(
+ name: String,
+ countryCode: String,
+ numberOfRelays: Int,
+ active: Boolean = true,
+ expanded: Boolean = false,
+) =
+ RelayItem.City(
+ name = name,
+ code = name.generateCityCode(),
+ relays =
+ List(numberOfRelays) { index ->
+ generateRelayItemRelay(
+ countryCode,
+ name.generateCityCode(),
+ generateHostname(countryCode, name.generateCityCode(), index),
+ active
+ )
+ },
+ expanded = expanded,
+ location = GeographicLocationConstraint.City(countryCode, name.generateCityCode()),
+ )
+
+fun generateRelayItemRelay(
+ countryCode: String,
+ cityCode: String,
+ hostName: String,
+ active: Boolean = true,
+) =
+ RelayItem.Relay(
+ name = hostName,
+ location =
+ GeographicLocationConstraint.Hostname(
+ countryCode = countryCode,
+ cityCode = cityCode,
+ hostname = hostName,
+ ),
+ locationName = "$cityCode $hostName",
+ active = active
+ )
+
+private fun String.generateCountryCode() = (take(1) + takeLast(1)).lowercase()
+
+private fun String.generateCityCode() = take(CITY_CODE_LENGTH).lowercase()
+
+private fun generateHostname(countryCode: String, cityCode: String, index: Int) =
+ "$countryCode-$cityCode-wg-${index+1}"
+
+private const val CITY_CODE_LENGTH = 3
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 9ad0c220e9..6fb87a6af5 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
@@ -2,18 +2,32 @@ package net.mullvad.mullvadvpn.relaylist
import net.mullvad.mullvadvpn.model.CustomList
-fun CustomList.toRelayItemCustomList(
+private fun CustomList.toRelayItemCustomList(
relayCountries: List<RelayItem.Country>
): RelayItem.CustomList =
RelayItem.CustomList(
- code = this.id,
id = this.id,
name = this.name,
expanded = false,
locations =
- this.locations.mapNotNull { relayCountries.findItemForGeographicLocationConstraint(it) }
+ this.locations.mapNotNull {
+ relayCountries.findItemForGeographicLocationConstraint(it)
+ },
)
fun List<CustomList>.toRelayItemLists(
relayCountries: List<RelayItem.Country>
): List<RelayItem.CustomList> = this.map { it.toRelayItemCustomList(relayCountries) }
+
+fun List<RelayItem.CustomList>.filterOnSearchTerm(searchTerm: String) =
+ if (searchTerm.length >= MIN_SEARCH_LENGTH) {
+ this.filter { it.name.contains(searchTerm, ignoreCase = true) }
+ } else {
+ this
+ }
+
+fun RelayItem.CustomList.canAddLocation(location: RelayItem) =
+ this.locations.none { it.code == location.code } &&
+ this.locations.flatMap { it.descendants() }.none { it.code == location.code }
+
+fun List<RelayItem.CustomList>.getById(id: String) = this.find { it.id == id }
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListConstant.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListConstant.kt
new file mode 100644
index 0000000000..4fcc5c7902
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListConstant.kt
@@ -0,0 +1,3 @@
+package net.mullvad.mullvadvpn.relaylist
+
+const val MIN_SEARCH_LENGTH = 2
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 4ca27a105a..15df90be9e 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
@@ -10,7 +10,8 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
-import net.mullvad.mullvadvpn.compose.state.RelayListState
+import net.mullvad.mullvadvpn.compose.communication.CustomListAction
+import net.mullvad.mullvadvpn.compose.communication.CustomListResult
import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState
import net.mullvad.mullvadvpn.compose.state.toNullableOwnership
import net.mullvad.mullvadvpn.compose.state.toSelectedProviders
@@ -24,11 +25,13 @@ import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy
import net.mullvad.mullvadvpn.usecase.RelayListFilterUseCase
import net.mullvad.mullvadvpn.usecase.RelayListUseCase
+import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase
class SelectLocationViewModel(
private val serviceConnectionManager: ServiceConnectionManager,
private val relayListUseCase: RelayListUseCase,
- private val relayListFilterUseCase: RelayListFilterUseCase
+ private val relayListFilterUseCase: RelayListFilterUseCase,
+ private val customListActionUseCase: CustomListActionUseCase
) : ViewModel() {
private val _searchTerm = MutableStateFlow(EMPTY_SEARCH_TERM)
@@ -38,9 +41,9 @@ class SelectLocationViewModel(
_searchTerm,
relayListFilterUseCase.selectedOwnership(),
relayListFilterUseCase.availableProviders(),
- relayListFilterUseCase.selectedProviders()
+ relayListFilterUseCase.selectedProviders(),
) {
- (customList, relayCountries, selectedItem),
+ (customLists, relayCountries, selectedItem),
searchTerm,
selectedOwnership,
allProviders,
@@ -60,25 +63,22 @@ class SelectLocationViewModel(
val filteredRelayCountries =
relayCountries.filterOnSearchTerm(searchTerm, selectedItem)
- SelectLocationUiState.Data(
+ val filteredCustomLists = customLists.filterOnSearchTerm(searchTerm)
+
+ SelectLocationUiState.Content(
searchTerm = searchTerm,
selectedOwnership = selectedOwnershipItem,
selectedProvidersCount = selectedProvidersCount,
- relayListState =
- if (filteredRelayCountries.isNotEmpty()) {
- RelayListState.RelayList(
- countries = filteredRelayCountries,
- selectedItem = selectedItem
- )
- } else {
- RelayListState.Empty
- },
+ filteredCustomLists = filteredCustomLists,
+ customLists = customLists,
+ countries = filteredRelayCountries,
+ selectedItem = selectedItem,
)
}
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(),
- SelectLocationUiState.Loading
+ SelectLocationUiState.Loading,
)
private val _uiSideEffect = Channel<SelectLocationSideEffect>()
@@ -113,7 +113,7 @@ class SelectLocationViewModel(
viewModelScope.launch {
relayListFilterUseCase.updateOwnershipAndProviderFilter(
Constraint.Any(),
- relayListFilterUseCase.selectedProviders().first()
+ relayListFilterUseCase.selectedProviders().first(),
)
}
}
@@ -122,11 +122,28 @@ class SelectLocationViewModel(
viewModelScope.launch {
relayListFilterUseCase.updateOwnershipAndProviderFilter(
relayListFilterUseCase.selectedOwnership().first(),
- Constraint.Any()
+ Constraint.Any(),
)
}
}
+ fun addLocationToList(item: RelayItem, customList: RelayItem.CustomList) {
+ viewModelScope.launch {
+ val newLocations = (customList.locations + item).map { it.code }
+ val result =
+ customListActionUseCase.performAction(
+ CustomListAction.UpdateLocations(customList.id, newLocations)
+ )
+ _uiSideEffect.send(
+ SelectLocationSideEffect.LocationAddedToCustomList(result.getOrThrow())
+ )
+ }
+ }
+
+ fun performAction(action: CustomListAction) {
+ viewModelScope.launch { customListActionUseCase.performAction(action) }
+ }
+
companion object {
private const val EMPTY_SEARCH_TERM = ""
}
@@ -134,4 +151,7 @@ class SelectLocationViewModel(
sealed interface SelectLocationSideEffect {
data object CloseScreen : SelectLocationSideEffect
+
+ data class LocationAddedToCustomList(val result: CustomListResult.LocationsChanged) :
+ SelectLocationSideEffect
}
diff --git a/android/lib/resource/src/main/res/drawable/icon_add.xml b/android/lib/resource/src/main/res/drawable/icon_add.xml
new file mode 100644
index 0000000000..1b016dcfb2
--- /dev/null
+++ b/android/lib/resource/src/main/res/drawable/icon_add.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="960"
+ android:viewportHeight="960">
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M440,520L200,520L200,440L440,440L440,200L520,200L520,440L760,440L760,520L520,520L520,760L440,760L440,520Z" />
+</vector>
diff --git a/android/lib/resource/src/main/res/drawable/icon_delete.xml b/android/lib/resource/src/main/res/drawable/icon_delete.xml
new file mode 100644
index 0000000000..0e8b2004cb
--- /dev/null
+++ b/android/lib/resource/src/main/res/drawable/icon_delete.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="960"
+ android:viewportHeight="960">
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M280,840Q247,840 223.5,816.5Q200,793 200,760L200,240L160,240L160,160L360,160L360,120L600,120L600,160L800,160L800,240L760,240L760,760Q760,793 736.5,816.5Q713,840 680,840L280,840ZM680,240L280,240L280,760Q280,760 280,760Q280,760 280,760L680,760Q680,760 680,760Q680,760 680,760L680,240ZM360,680L440,680L440,320L360,320L360,680ZM520,680L600,680L600,320L520,320L520,680ZM280,240L280,240L280,760Q280,760 280,760Q280,760 280,760L280,760Q280,760 280,760Q280,760 280,760L280,240Z" />
+</vector>
diff --git a/android/lib/resource/src/main/res/drawable/icon_edit.xml b/android/lib/resource/src/main/res/drawable/icon_edit.xml
new file mode 100644
index 0000000000..3df2eb93a6
--- /dev/null
+++ b/android/lib/resource/src/main/res/drawable/icon_edit.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="960"
+ android:viewportHeight="960">
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M200,760L257,760L648,369L591,312L200,703L200,760ZM120,840L120,670L648,143Q660,132 674.5,126Q689,120 705,120Q721,120 736,126Q751,132 762,144L817,200Q829,211 834.5,226Q840,241 840,256Q840,272 834.5,286.5Q829,301 817,313L290,840L120,840ZM760,256L760,256L704,200L704,200L760,256ZM619,341L591,312L591,312L648,369L648,369L619,341Z" />
+</vector>
diff --git a/android/lib/resource/src/main/res/drawable/icon_more_vert.xml b/android/lib/resource/src/main/res/drawable/icon_more_vert.xml
new file mode 100644
index 0000000000..59400ec977
--- /dev/null
+++ b/android/lib/resource/src/main/res/drawable/icon_more_vert.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="960"
+ android:viewportHeight="960">
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M480,800Q447,800 423.5,776.5Q400,753 400,720Q400,687 423.5,663.5Q447,640 480,640Q513,640 536.5,663.5Q560,687 560,720Q560,753 536.5,776.5Q513,800 480,800ZM480,560Q447,560 423.5,536.5Q400,513 400,480Q400,447 423.5,423.5Q447,400 480,400Q513,400 536.5,423.5Q560,447 560,480Q560,513 536.5,536.5Q513,560 480,560ZM480,320Q447,320 423.5,296.5Q400,273 400,240Q400,207 423.5,183.5Q447,160 480,160Q513,160 536.5,183.5Q560,207 560,240Q560,273 536.5,296.5Q513,320 480,320Z"/>
+</vector>
diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt
index 85d45c4d2b..69096ceccb 100644
--- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt
+++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt
@@ -33,6 +33,12 @@ import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_onSurfaceVariant
import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_onTertiaryContainer
import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_primary
import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_secondaryContainer
+import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_surfaceContainer
+import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_surfaceContainerHigh
+import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_surfaceContainerHighest
+import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_surfaceContainerLow
+import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_surfaceContainerLowest
+import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_surfaceTint
import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_surfaceVariant
import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_tertiaryContainer
import net.mullvad.mullvadvpn.lib.theme.dimensions.Dimensions
@@ -89,6 +95,12 @@ private val darkColorScheme =
// surfaceTint = md_theme_dark_surfaceTint,
outlineVariant = Color.Transparent, // Used by divider,
// scrim = md_theme_dark_scrim,
+ surfaceContainerHighest = md_theme_dark_surfaceContainerHighest,
+ surfaceContainerHigh = md_theme_dark_surfaceContainerHigh,
+ surfaceContainerLow = md_theme_dark_surfaceContainerLow,
+ surfaceContainerLowest = md_theme_dark_surfaceContainerLowest,
+ surfaceContainer = md_theme_dark_surfaceContainer,
+ surfaceTint = md_theme_dark_surfaceTint
)
val Shapes =
diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/Color.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/Color.kt
index 82f924ebe0..01959b7934 100644
--- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/Color.kt
+++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/Color.kt
@@ -21,6 +21,7 @@ const val AlphaDescription = 0.6f
const val AlphaDisconnectButton = 0.6f
const val AlphaChevron = 0.6f
const val AlphaScrollbar = 0.6f
+const val Alpha60 = 0.6f
const val AlphaTopBar = 0.8f
const val AlphaInvisible = 0f
diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/ColorTokens.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/ColorTokens.kt
index 413a37f93e..1915cc911a 100644
--- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/ColorTokens.kt
+++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/ColorTokens.kt
@@ -61,6 +61,11 @@ internal val md_theme_dark_outline = Color(0xFF8D9199) // Generated
internal val md_theme_dark_inverseOnSurface = Color(0xFFFFFFFF) // MullvadWhite
internal val md_theme_dark_inverseSurface = Color(0xFFFFFFFF) // MullvadWhite
internal val md_theme_dark_inversePrimary = Color(0xFF0561A3) // Generated
-internal val md_theme_dark_surfaceTint = Color(0xFF9FCAFF) // Generated
+internal val md_theme_dark_surfaceTint = Color(0xFF233953) // Custom list disabled
internal val md_theme_dark_outlineVariant = Color(0xFF43474E) // Generated
internal val md_theme_dark_scrim = Color(0xFF000000) // Generated
+internal val md_theme_dark_surfaceContainerHighest = Color(0xFF234161) // Relay list depth 0
+internal val md_theme_dark_surfaceContainerHigh = Color(0xFF1F3A57) // Relay list depth 1
+internal val md_theme_dark_surfaceContainerLow = Color(0xFF1C344E) // Relay list depth 2
+internal val md_theme_dark_surfaceContainerLowest = Color(0xFF1B314A) // Relay list depth 3
+internal val md_theme_dark_surfaceContainer = Color(0xFF192638) // Alert Blue
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 ef6b04146e..2763033a30 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
@@ -16,8 +16,10 @@ data class Dimensions(
val cellEndPadding: Dp = 16.dp,
val cellFooterTopPadding: Dp = 6.dp,
val cellHeight: Dp = 56.dp,
+ val cellHeightTwoRows: Dp = 72.dp,
val cellLabelVerticalPadding: Dp = 14.dp,
val cellStartPadding: Dp = 22.dp,
+ val cellStartPaddingInteractive: Dp = 14.dp,
val cellTopPadding: Dp = 6.dp,
val cellVerticalSpacing: Dp = 14.dp,
val checkBoxSize: Dp = 24.dp,
@@ -36,7 +38,8 @@ data class Dimensions(
val customPortBoxMinWidth: Dp = 80.dp,
val deleteIconSize: Dp = 24.dp,
val dialogIconHeight: Dp = 44.dp,
- val dialogIconSize: Dp = 48.dp,
+ 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 iconFailSuccessTopMargin: Dp = 30.dp,
@@ -61,6 +64,7 @@ data class Dimensions(
val progressIndicatorSize: Dp = 48.dp,
val relayCircleSize: Dp = 16.dp,
val relayRowPadding: Dp = 50.dp,
+ val relayRowPaddingExtra: Dp = 66.dp,
val screenVerticalMargin: Dp = 22.dp,
val searchFieldHeight: Dp = 42.dp,
val searchFieldHorizontalPadding: Dp = 22.dp,
diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/shape/Shape.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/shape/Shape.kt
index 501cb72946..aa2f40782c 100644
--- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/shape/Shape.kt
+++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/shape/Shape.kt
@@ -11,3 +11,9 @@ val Shapes.chipShape: Shape
get() {
return RoundedCornerShape(8.dp)
}
+
+val Shapes.fabShape: Shape
+ @Composable
+ get() {
+ return RoundedCornerShape(16.dp)
+ }