summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKalle Lindström <karl.lindstrom@mullvad.net>2025-11-20 09:38:45 +0100
committerKalle Lindström <karl.lindstrom@mullvad.net>2025-11-21 10:12:10 +0100
commit688f9509357a96d1be398705b0cee2e8b43e7131 (patch)
treeca0c80b8dbd6d4c2e0515af6801ea37020a518f6
parente1439b23d7bc0c374a343d797842da55ecbd4234 (diff)
downloadmullvadvpn-688f9509357a96d1be398705b0cee2e8b43e7131.tar.xz
mullvadvpn-688f9509357a96d1be398705b0cee2e8b43e7131.zip
Create new ListItem component/VPN settings design
This is the first part of the work to switch to the new list item design. It adds the ListItem design system component and various components that use the ListItem. As of this PR only the main VPN Settings view is converted to the new design.
-rw-r--r--android/app/build.gradle.kts1
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreenTest.kt10
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt329
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingsUiState.kt1
-rw-r--r--android/lib/resource/src/main/res/values/strings.xml1
-rw-r--r--android/lib/ui/component/build.gradle.kts1
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/DividerButton.kt57
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/Switch.kt81
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/CustomPortListItem.kt103
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/DnsListItem.kt69
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/ExpandableListItem.kt90
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/InfoListItem.kt75
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/ListItemComponentTokens.kt7
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/MtuListItem.kt47
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/NavigationListItem.kt75
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/ObfuscationModeListItem.kt92
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/SelectableListItem.kt131
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/SwitchListItem.kt92
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/preview/PreviewSpacedColumn.kt26
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/preview/SelectObfuscationListItemPreviewParameterProvider.kt23
-rw-r--r--android/lib/ui/designsystem/build.gradle.kts1
-rw-r--r--android/lib/ui/designsystem/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/designsystem/ListItem.kt411
-rw-r--r--android/lib/ui/util/build.gradle.kts17
-rw-r--r--android/lib/ui/util/src/main/AndroidManifest.xml1
-rw-r--r--android/lib/ui/util/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/util/Modifier.kt21
-rw-r--r--android/settings.gradle.kts3
-rw-r--r--desktop/packages/mullvad-vpn/locales/messages.pot3
27 files changed, 1621 insertions, 147 deletions
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index ed781dff97..f6cbdedd23 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -379,6 +379,7 @@ dependencies {
implementation(projects.lib.ui.designsystem)
implementation(projects.lib.ui.component)
implementation(projects.lib.ui.tag)
+ implementation(projects.lib.ui.util)
implementation(projects.tile)
implementation(projects.lib.theme)
implementation(projects.service)
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreenTest.kt
index 879bad9ac4..fefb4d916a 100644
--- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreenTest.kt
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreenTest.kt
@@ -439,7 +439,7 @@ class VpnSettingsScreenTest {
.performScrollToNode(hasTestTag(LAZY_LIST_WIREGUARD_CUSTOM_PORT_TEXT_TEST_TAG))
// Assert
- onNodeWithText("4000").assertExists()
+ onNodeWithText("Port: 4000").assertExists()
}
@Test
@@ -579,7 +579,7 @@ class VpnSettingsScreenTest {
navigateToWireguardPortDialog = mockedClickHandler,
)
- onNodeWithTag(LAZY_LIST_VPN_SETTINGS_TEST_TAG)
+ onNodeWithTag(LAZY_LIST_VPN_SETTINGS_TEST_TAG, useUnmergedTree = true)
.performScrollToNode(hasTestTag(LAZY_LIST_WIREGUARD_CUSTOM_PORT_NUMBER_TEST_TAG))
onNodeWithText("Custom").performClick()
@@ -629,7 +629,11 @@ class VpnSettingsScreenTest {
// Act
onNodeWithTag(LAZY_LIST_VPN_SETTINGS_TEST_TAG)
.performScrollToNode(hasTestTag(LAZY_LIST_WIREGUARD_CUSTOM_PORT_TEXT_TEST_TAG))
- onNodeWithTag(testTag = LAZY_LIST_WIREGUARD_CUSTOM_PORT_NUMBER_TEST_TAG).performClick()
+ onNodeWithTag(
+ testTag = LAZY_LIST_WIREGUARD_CUSTOM_PORT_NUMBER_TEST_TAG,
+ useUnmergedTree = true,
+ )
+ .performClick()
// Assert
verify { mockOnShowCustomPortDialog.invoke(customPort, availablePortRanges) }
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 e6956977d9..29b0cd2b12 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
@@ -16,6 +16,8 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
@@ -69,20 +71,9 @@ import com.ramcosta.composedestinations.result.ResultRecipient
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import net.mullvad.mullvadvpn.R
-import net.mullvad.mullvadvpn.compose.cell.BaseCell
import net.mullvad.mullvadvpn.compose.cell.BaseSubtitleCell
import net.mullvad.mullvadvpn.compose.cell.ContentBlockersDisableModeCellSubtitle
-import net.mullvad.mullvadvpn.compose.cell.CustomPortCell
-import net.mullvad.mullvadvpn.compose.cell.DnsCell
-import net.mullvad.mullvadvpn.compose.cell.ExpandableComposeCell
-import net.mullvad.mullvadvpn.compose.cell.HeaderSwitchComposeCell
-import net.mullvad.mullvadvpn.compose.cell.InformationComposeCell
-import net.mullvad.mullvadvpn.compose.cell.MtuComposeCell
import net.mullvad.mullvadvpn.compose.cell.MtuSubtitle
-import net.mullvad.mullvadvpn.compose.cell.NavigationComposeCell
-import net.mullvad.mullvadvpn.compose.cell.NormalSwitchComposeCell
-import net.mullvad.mullvadvpn.compose.cell.ObfuscationModeCell
-import net.mullvad.mullvadvpn.compose.cell.SelectableCell
import net.mullvad.mullvadvpn.compose.cell.SwitchComposeSubtitleCell
import net.mullvad.mullvadvpn.compose.communication.DnsDialogResult
import net.mullvad.mullvadvpn.compose.component.MullvadMediumTopBar
@@ -115,6 +106,19 @@ import net.mullvad.mullvadvpn.lib.theme.Dimens
import net.mullvad.mullvadvpn.lib.theme.color.AlphaInvisible
import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar
import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible
+import net.mullvad.mullvadvpn.lib.ui.component.DividerButton
+import net.mullvad.mullvadvpn.lib.ui.component.listitem.CustomPortListItem
+import net.mullvad.mullvadvpn.lib.ui.component.listitem.DnsListItem
+import net.mullvad.mullvadvpn.lib.ui.component.listitem.ExpandableListItem
+import net.mullvad.mullvadvpn.lib.ui.component.listitem.InfoListItem
+import net.mullvad.mullvadvpn.lib.ui.component.listitem.MtuListItem
+import net.mullvad.mullvadvpn.lib.ui.component.listitem.NavigationListItem
+import net.mullvad.mullvadvpn.lib.ui.component.listitem.ObfuscationModeListItem
+import net.mullvad.mullvadvpn.lib.ui.component.listitem.SelectableListItem
+import net.mullvad.mullvadvpn.lib.ui.component.listitem.SwitchListItem
+import net.mullvad.mullvadvpn.lib.ui.designsystem.Hierarchy
+import net.mullvad.mullvadvpn.lib.ui.designsystem.MullvadListItem
+import net.mullvad.mullvadvpn.lib.ui.designsystem.Position
import net.mullvad.mullvadvpn.lib.ui.tag.LAZY_LIST_LAST_ITEM_TEST_TAG
import net.mullvad.mullvadvpn.lib.ui.tag.LAZY_LIST_QUANTUM_ITEM_OFF_TEST_TAG
import net.mullvad.mullvadvpn.lib.ui.tag.LAZY_LIST_QUANTUM_ITEM_ON_TEST_TAG
@@ -128,6 +132,7 @@ import net.mullvad.mullvadvpn.lib.ui.tag.WIREGUARD_OBFUSCATION_OFF_CELL_TEST_TAG
import net.mullvad.mullvadvpn.lib.ui.tag.WIREGUARD_OBFUSCATION_QUIC_CELL_TEST_TAG
import net.mullvad.mullvadvpn.lib.ui.tag.WIREGUARD_OBFUSCATION_SHADOWSOCKS_CELL_TEST_TAG
import net.mullvad.mullvadvpn.lib.ui.tag.WIREGUARD_OBFUSCATION_UDP_OVER_TCP_CELL_TEST_TAG
+import net.mullvad.mullvadvpn.lib.ui.util.applyIf
import net.mullvad.mullvadvpn.util.Lc
import net.mullvad.mullvadvpn.util.indexOfFirstOrNull
import net.mullvad.mullvadvpn.viewmodel.VpnSettingsSideEffect
@@ -502,13 +507,12 @@ fun VpnSettingsContent(
}
}
- val highlightBackground: @Composable (featureIndicators: FeatureIndicator) -> Color =
- { featureIndicator: FeatureIndicator ->
- if (initialScrollToFeature == featureIndicator) {
- MaterialTheme.colorScheme.primary.copy(alpha = highlightAnimation.value)
- } else {
- MaterialTheme.colorScheme.primary
- }
+ @Composable
+ fun highlightBackgroundAlpha(featureIndicator: FeatureIndicator): Float =
+ if (initialScrollToFeature == featureIndicator) {
+ highlightAnimation.value
+ } else {
+ 1.0f
}
val lazyListState = rememberLazyListState(initialIndexFocus)
@@ -527,6 +531,7 @@ fun VpnSettingsContent(
state = lazyListState,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = AlphaScrollbar),
)
+ .padding(horizontal = Dimens.mediumPadding)
.animateContentSize(),
state = lazyListState,
) {
@@ -534,9 +539,9 @@ fun VpnSettingsContent(
when (it) {
VpnSettingItem.AutoConnectAndLockdownMode ->
item(key = it::class.simpleName) {
- NavigationComposeCell(
- title = stringResource(id = R.string.auto_connect_and_lockdown_mode),
+ NavigationListItem(
modifier = Modifier.animateItem(),
+ title = stringResource(id = R.string.auto_connect_and_lockdown_mode),
onClick = { navigateToAutoConnectScreen() },
)
}
@@ -552,7 +557,7 @@ fun VpnSettingsContent(
is VpnSettingItem.ConnectDeviceOnStartUpSetting ->
item(key = it::class.simpleName) {
- HeaderSwitchComposeCell(
+ SwitchListItem(
modifier = Modifier.animateItem(),
title = stringResource(R.string.connect_on_start),
isToggled = it.enabled,
@@ -565,85 +570,98 @@ fun VpnSettingsContent(
VpnSettingItem.CustomDnsAdd ->
item(key = it::class.simpleName) {
- BaseCell(
+ MullvadListItem(
modifier = Modifier.animateItem(),
- onCellClicked = { navigateToDns(null, null) },
- headlineContent = {
- Text(
- text = stringResource(id = R.string.add_a_server),
- color = MaterialTheme.colorScheme.onSurface,
- style = MaterialTheme.typography.bodyLarge,
+ hierarchy = Hierarchy.Child1,
+ position = Position.Bottom,
+ onClick = { navigateToDns(null, null) },
+ content = { Text(text = stringResource(id = R.string.add_a_server)) },
+ trailingContent = {
+ DividerButton(
+ onClick = { navigateToDns(null, null) },
+ icon = Icons.Default.Add,
)
},
- bodyView = {},
- background = MaterialTheme.colorScheme.surfaceContainerHighest,
- startPadding = Dimens.indentedCellStartPadding,
)
}
is VpnSettingItem.CustomDnsEntry ->
item(key = it::class.simpleName + it.index) {
- DnsCell(
+ DnsListItem(
+ modifier = Modifier.animateItem(),
+ hierarchy = Hierarchy.Child1,
+ position = Position.Middle,
address = it.customDnsItem.address,
isUnreachableLocalDnsWarningVisible = it.showUnreachableLocalDnsWarning,
isUnreachableIpv6DnsWarningVisible = it.showUnreachableIpv6DnsWarning,
onClick = { navigateToDns(it.index, it.customDnsItem.address) },
- modifier = Modifier.animateItem(),
)
}
VpnSettingItem.CustomDnsInfo ->
item(key = it::class.simpleName) {
BaseSubtitleCell(
+ modifier = Modifier.animateItem(),
text = textResource(id = R.string.custom_dns_footer),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.animateItem(),
)
}
is VpnSettingItem.CustomDnsServerSetting ->
item(key = it::class.simpleName) {
- HeaderSwitchComposeCell(
- title = stringResource(R.string.enable_custom_dns),
- isToggled = it.enabled,
- isEnabled = it.isOptionEnabled,
- onCellClicked = { newValue -> onToggleDnsClick(newValue) },
- onInfoClicked = { navigateToCustomDnsInfo() },
- background = highlightBackground(FeatureIndicator.CUSTOM_DNS),
+ SwitchListItem(
modifier =
Modifier.animateItem()
.focusRequester(
focusRequesters.getValue(FeatureIndicator.CUSTOM_DNS)
),
+ position = if (it.enabled) Position.Top else Position.Single,
+ title = stringResource(R.string.enable_custom_dns),
+ isToggled = it.enabled,
+ isEnabled = it.isOptionEnabled,
+ onCellClicked = { newValue -> onToggleDnsClick(newValue) },
+ onInfoClicked = { navigateToCustomDnsInfo() },
+ backgroundAlpha = highlightBackgroundAlpha(FeatureIndicator.CUSTOM_DNS),
)
}
VpnSettingItem.CustomDnsUnavailable ->
item(key = it::class.simpleName) {
BaseSubtitleCell(
- textResource(
- id = R.string.custom_dns_disable_mode_subtitle,
- textResource(id = R.string.dns_content_blockers),
- ),
+ modifier = Modifier.animateItem(),
+ text =
+ textResource(
+ id = R.string.custom_dns_disable_mode_subtitle,
+ textResource(id = R.string.dns_content_blockers),
+ ),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.animateItem(),
)
}
VpnSettingItem.DeviceIpVersionHeader ->
item(key = it::class.simpleName) {
- InformationComposeCell(
+ InfoListItem(
+ modifier = Modifier.animateItem(),
+ position = Position.Top,
title = stringResource(R.string.device_ip_version_title),
onInfoClicked = navigateToDeviceIpInfo,
onCellClicked = navigateToDeviceIpInfo,
- modifier = Modifier.animateItem(),
)
}
is VpnSettingItem.DeviceIpVersionItem ->
item(key = it::class.simpleName + it.constraint.getOrNull().toString()) {
- SelectableCell(
+ SelectableListItem(
+ modifier = Modifier.animateItem(),
+ hierarchy = Hierarchy.Child1,
+ position =
+ if (
+ it.constraint is Constraint.Only &&
+ it.constraint.value == IpVersion.IPV6
+ ) {
+ Position.Bottom
+ } else Position.Middle,
title =
when (it.constraint) {
Constraint.Any -> stringResource(id = R.string.automatic)
@@ -656,8 +674,7 @@ fun VpnSettingsContent(
}
},
isSelected = it.selected,
- modifier = Modifier.animateItem(),
- onCellClicked = { onSelectDeviceIpVersion(it.constraint) },
+ onClick = { onSelectDeviceIpVersion(it.constraint) },
)
}
@@ -672,79 +689,85 @@ fun VpnSettingsContent(
is VpnSettingItem.DnsContentBlockerItem.Ads ->
item(key = it::class.simpleName) {
- NormalSwitchComposeCell(
+ SwitchListItem(
+ modifier = Modifier.animateItem(),
+ position = Position.Middle,
+ hierarchy = Hierarchy.Child1,
title = stringResource(R.string.block_ads_title),
isToggled = it.enabled,
isEnabled = it.featureEnabled,
onCellClicked = { onToggleBlockAds(it) },
- startPadding = Dimens.indentedCellStartPadding,
- modifier = Modifier.animateItem(),
)
}
is VpnSettingItem.DnsContentBlockerItem.AdultContent ->
item(key = it::class.simpleName) {
- NormalSwitchComposeCell(
+ SwitchListItem(
+ modifier = Modifier.animateItem(),
+ position = Position.Middle,
+ hierarchy = Hierarchy.Child1,
title = stringResource(R.string.block_adult_content_title),
isToggled = it.enabled,
isEnabled = it.featureEnabled,
onCellClicked = { onToggleBlockAdultContent(it) },
- startPadding = Dimens.indentedCellStartPadding,
- modifier = Modifier.animateItem(),
)
}
is VpnSettingItem.DnsContentBlockerItem.Gambling ->
item(key = it::class.simpleName) {
- NormalSwitchComposeCell(
+ SwitchListItem(
+ modifier = Modifier.animateItem(),
+ position = Position.Middle,
+ hierarchy = Hierarchy.Child1,
title = stringResource(R.string.block_gambling_title),
isToggled = it.enabled,
isEnabled = it.featureEnabled,
onCellClicked = { onToggleBlockGambling(it) },
- startPadding = Dimens.indentedCellStartPadding,
- modifier = Modifier.animateItem(),
)
}
is VpnSettingItem.DnsContentBlockerItem.Malware ->
item(key = it::class.simpleName) {
- NormalSwitchComposeCell(
+ SwitchListItem(
modifier = Modifier.animateItem(),
+ position = Position.Middle,
+ hierarchy = Hierarchy.Child1,
title = stringResource(R.string.block_malware_title),
isToggled = it.enabled,
isEnabled = it.featureEnabled,
onCellClicked = { onToggleBlockMalware(it) },
onInfoClicked = { navigateToMalwareInfo() },
- startPadding = Dimens.indentedCellStartPadding,
)
}
is VpnSettingItem.DnsContentBlockerItem.SocialMedia ->
item(key = it::class.simpleName) {
- NormalSwitchComposeCell(
+ SwitchListItem(
modifier = Modifier.animateItem(),
+ position = Position.Bottom,
+ hierarchy = Hierarchy.Child1,
title = stringResource(R.string.block_social_media_title),
isToggled = it.enabled,
isEnabled = it.featureEnabled,
onCellClicked = { onToggleBlockSocialMedia(it) },
- startPadding = Dimens.indentedCellStartPadding,
)
}
is VpnSettingItem.DnsContentBlockerItem.Trackers ->
item(key = it::class.simpleName) {
- NormalSwitchComposeCell(
+ SwitchListItem(
modifier = Modifier.animateItem(),
title = stringResource(R.string.block_trackers_title),
isToggled = it.enabled,
isEnabled = it.featureEnabled,
onCellClicked = { onToggleBlockTrackers(it) },
- startPadding = Dimens.indentedCellStartPadding,
+ hierarchy = Hierarchy.Child1,
+ position = Position.Middle,
)
}
is VpnSettingItem.DnsContentBlockersHeader ->
item(key = it::class.simpleName) {
- ExpandableComposeCell(
+ ExpandableListItem(
modifier =
Modifier.animateItem()
.focusRequester(
@@ -752,8 +775,8 @@ fun VpnSettingsContent(
FeatureIndicator.DNS_CONTENT_BLOCKERS
)
),
+ position = if (it.expanded) Position.Top else Position.Single,
title = stringResource(R.string.dns_content_blockers),
- background = highlightBackground(FeatureIndicator.DNS_CONTENT_BLOCKERS),
isExpanded = it.expanded,
isEnabled = it.featureEnabled,
onInfoClicked = { navigateToContentBlockersInfo() },
@@ -768,28 +791,28 @@ fun VpnSettingsContent(
is VpnSettingItem.EnableIpv6Setting ->
item(key = it::class.simpleName) {
- HeaderSwitchComposeCell(
+ SwitchListItem(
+ modifier = Modifier.animateItem(),
title = stringResource(R.string.enable_ipv6),
isToggled = it.enabled,
isEnabled = true,
onCellClicked = onToggleIpv6,
onInfoClicked = navigateToIpv6Info,
- modifier = Modifier.animateItem(),
)
}
is VpnSettingItem.LocalNetworkSharingSetting ->
item(key = it::class.simpleName) {
- HeaderSwitchComposeCell(
- background = highlightBackground(FeatureIndicator.LAN_SHARING),
- title = stringResource(R.string.local_network_sharing),
- isToggled = it.enabled,
- isEnabled = true,
+ SwitchListItem(
modifier =
Modifier.animateItem()
.focusRequester(
focusRequesters.getValue(FeatureIndicator.LAN_SHARING)
),
+ backgroundAlpha = highlightBackgroundAlpha(FeatureIndicator.CUSTOM_DNS),
+ title = stringResource(R.string.local_network_sharing),
+ isToggled = it.enabled,
+ isEnabled = true,
onCellClicked = { newValue -> onToggleLocalNetworkSharing(newValue) },
onInfoClicked = navigateToLocalNetworkSharingInfo,
)
@@ -797,15 +820,15 @@ fun VpnSettingsContent(
is VpnSettingItem.Mtu ->
item(key = it::class.simpleName) {
- MtuComposeCell(
- mtuValue = it.mtu,
- onEditMtu = { navigateToMtuDialog(it.mtu) },
+ MtuListItem(
modifier =
Modifier.animateItem()
.focusRequester(
focusRequesters.getValue(FeatureIndicator.CUSTOM_MTU)
),
- background = highlightBackground(FeatureIndicator.CUSTOM_MTU),
+ mtuValue = it.mtu,
+ onEditMtu = { navigateToMtuDialog(it.mtu) },
+ backgroundAlpha = highlightBackgroundAlpha(FeatureIndicator.CUSTOM_DNS),
)
}
@@ -818,132 +841,145 @@ fun VpnSettingsContent(
VpnSettingItem.ObfuscationHeader ->
item(key = it::class.simpleName) {
- InformationComposeCell(
- title = stringResource(R.string.obfuscation_title),
- onInfoClicked = navigateToObfuscationInfo,
- onCellClicked = navigateToObfuscationInfo,
- background =
+ InfoListItem(
+ modifier = Modifier.animateItem(),
+ backgroundAlpha =
when (initialScrollToFeature) {
FeatureIndicator.UDP_2_TCP,
FeatureIndicator.SHADOWSOCKS,
FeatureIndicator.QUIC,
- FeatureIndicator.LWO ->
- MaterialTheme.colorScheme.primary.copy(
- alpha = highlightAnimation.value
- )
- else -> MaterialTheme.colorScheme.primary
+ FeatureIndicator.LWO -> highlightAnimation.value
+ else -> 1.0f
},
+ position = Position.Top,
+ title = stringResource(R.string.obfuscation_title),
+ onInfoClicked = navigateToObfuscationInfo,
+ onCellClicked = navigateToObfuscationInfo,
testTag = LAZY_LIST_WIREGUARD_OBFUSCATION_TITLE_TEST_TAG,
- modifier = Modifier.animateItem(),
)
}
is VpnSettingItem.ObfuscationItem.Automatic ->
item(key = it::class.simpleName) {
- SelectableCell(
+ SelectableListItem(
+ modifier = Modifier.animateItem(),
+ hierarchy = Hierarchy.Child1,
+ position = Position.Middle,
title = stringResource(id = R.string.automatic),
isSelected = it.selected,
- modifier = Modifier.animateItem(),
- onCellClicked = { onSelectObfuscationMode(ObfuscationMode.Auto) },
+ onClick = { onSelectObfuscationMode(ObfuscationMode.Auto) },
)
}
is VpnSettingItem.ObfuscationItem.Off ->
item(key = it::class.simpleName) {
- SelectableCell(
+ SelectableListItem(
+ modifier = Modifier.animateItem(),
+ hierarchy = Hierarchy.Child1,
+ position = Position.Bottom,
title = stringResource(id = R.string.off),
isSelected = it.selected,
- modifier = Modifier.animateItem(),
- onCellClicked = { onSelectObfuscationMode(ObfuscationMode.Off) },
+ onClick = { onSelectObfuscationMode(ObfuscationMode.Off) },
testTag = WIREGUARD_OBFUSCATION_OFF_CELL_TEST_TAG,
)
}
is VpnSettingItem.ObfuscationItem.Shadowsocks ->
item(key = it::class.simpleName) {
- ObfuscationModeCell(
+ ObfuscationModeListItem(
+ modifier =
+ Modifier.animateItem()
+ .focusRequester(
+ focusRequesters.getValue(FeatureIndicator.SHADOWSOCKS)
+ ),
+ hierarchy = Hierarchy.Child1,
+ position = Position.Middle,
obfuscationMode = ObfuscationMode.Shadowsocks,
isSelected = it.selected,
port = it.port,
onSelected = onSelectObfuscationMode,
onNavigate = navigateToShadowSocksSettings,
testTag = WIREGUARD_OBFUSCATION_SHADOWSOCKS_CELL_TEST_TAG,
- modifier =
- Modifier.animateItem()
- .focusRequester(
- focusRequesters.getValue(FeatureIndicator.SHADOWSOCKS)
- ),
)
}
is VpnSettingItem.ObfuscationItem.UdpOverTcp ->
item(key = it::class.simpleName) {
- ObfuscationModeCell(
+ ObfuscationModeListItem(
+ modifier =
+ Modifier.animateItem()
+ .focusRequester(
+ focusRequesters.getValue(FeatureIndicator.UDP_2_TCP)
+ ),
+ hierarchy = Hierarchy.Child1,
+ position = Position.Middle,
obfuscationMode = ObfuscationMode.Udp2Tcp,
isSelected = it.selected,
port = it.port,
onSelected = onSelectObfuscationMode,
onNavigate = navigateToUdp2TcpSettings,
testTag = WIREGUARD_OBFUSCATION_UDP_OVER_TCP_CELL_TEST_TAG,
- modifier =
- Modifier.animateItem()
- .focusRequester(
- focusRequesters.getValue(FeatureIndicator.UDP_2_TCP)
- ),
)
}
is VpnSettingItem.ObfuscationItem.Quic ->
item(key = it::class.simpleName) {
- SelectableCell(
- title = stringResource(id = R.string.quic),
- isSelected = it.selected,
+ SelectableListItem(
modifier =
Modifier.animateItem()
.focusRequester(
focusRequesters.getValue(FeatureIndicator.QUIC)
),
+ hierarchy = Hierarchy.Child1,
+ position = Position.Middle,
+ title = stringResource(id = R.string.quic),
+ isSelected = it.selected,
testTag = WIREGUARD_OBFUSCATION_QUIC_CELL_TEST_TAG,
- onCellClicked = { onSelectObfuscationMode(ObfuscationMode.Quic) },
+ onClick = { onSelectObfuscationMode(ObfuscationMode.Quic) },
)
}
is VpnSettingItem.ObfuscationItem.Lwo ->
item(key = it::class.simpleName) {
- SelectableCell(
- title = stringResource(id = R.string.lwo),
- isSelected = it.selected,
+ SelectableListItem(
modifier =
Modifier.animateItem()
.focusRequester(focusRequesters.getValue(FeatureIndicator.LWO)),
+ hierarchy = Hierarchy.Child1,
+ position = Position.Middle,
+ title = stringResource(id = R.string.lwo),
+ isSelected = it.selected,
testTag = WIREGUARD_OBFUSCATION_LWO_CELL_TEST_TAG,
- onCellClicked = { onSelectObfuscationMode(ObfuscationMode.Lwo) },
+ onClick = { onSelectObfuscationMode(ObfuscationMode.Lwo) },
)
}
is VpnSettingItem.QuantumItem ->
item(key = it::class.simpleName + it.quantumResistantState) {
- SelectableCell(
+ SelectableListItem(
+ modifier =
+ Modifier.animateItem().applyIf(
+ it.quantumResistantState == QuantumResistantState.On
+ ) {
+ focusRequester(
+ focusRequesters.getValue(
+ FeatureIndicator.QUANTUM_RESISTANCE
+ )
+ )
+ },
+ hierarchy = Hierarchy.Child1,
+ position =
+ when (it.quantumResistantState) {
+ QuantumResistantState.Off -> Position.Bottom
+ QuantumResistantState.On -> Position.Middle
+ },
title =
when (it.quantumResistantState) {
QuantumResistantState.Off -> stringResource(id = R.string.off)
QuantumResistantState.On -> stringResource(id = R.string.on)
},
isSelected = it.selected,
- modifier =
- Modifier.animateItem()
- .then(
- if (it.quantumResistantState == QuantumResistantState.On) {
- Modifier.focusRequester(
- focusRequesters.getValue(
- FeatureIndicator.QUANTUM_RESISTANCE
- )
- )
- } else {
- Modifier
- }
- ),
- onCellClicked = {
+ onClick = {
onSelectQuantumResistanceSetting(it.quantumResistantState)
},
testTag =
@@ -957,12 +993,14 @@ fun VpnSettingsContent(
VpnSettingItem.QuantumResistanceHeader ->
item(key = it::class.simpleName) {
- InformationComposeCell(
+ InfoListItem(
+ modifier = Modifier.animateItem(),
+ position = Position.Top,
title = stringResource(R.string.quantum_resistant_title),
- background = highlightBackground(FeatureIndicator.QUANTUM_RESISTANCE),
+ backgroundAlpha =
+ highlightBackgroundAlpha(FeatureIndicator.QUANTUM_RESISTANCE),
onInfoClicked = navigateToQuantumResistanceInfo,
onCellClicked = navigateToQuantumResistanceInfo,
- modifier = Modifier.animateItem(),
)
}
@@ -978,18 +1016,22 @@ fun VpnSettingsContent(
is VpnSettingItem.WireguardPortHeader ->
item(key = it::class.simpleName) {
- InformationComposeCell(
+ InfoListItem(
+ modifier = Modifier.animateItem(),
+ position = Position.Top,
title = stringResource(id = R.string.wireguard_port_title),
onInfoClicked = { navigateToWireguardPortInfo(it.availablePortRanges) },
onCellClicked = { navigateToWireguardPortInfo(it.availablePortRanges) },
isEnabled = it.enabled,
- modifier = Modifier.animateItem(),
)
}
is VpnSettingItem.WireguardPortItem.Constraint ->
item(key = it::class.simpleName + it.constraint) {
- SelectableCell(
+ SelectableListItem(
+ modifier = Modifier.animateItem(),
+ hierarchy = Hierarchy.Child1,
+ position = Position.Middle,
title =
when (it.constraint) {
is Constraint.Only -> it.constraint.value.toString()
@@ -997,9 +1039,8 @@ fun VpnSettingsContent(
is Constraint.Any -> stringResource(id = R.string.automatic)
},
isSelected = it.selected,
- modifier = Modifier.animateItem(),
isEnabled = it.enabled,
- onCellClicked = { onWireguardPortSelected(it.constraint) },
+ onClick = { onWireguardPortSelected(it.constraint) },
testTag =
when (it.constraint) {
is Constraint.Only ->
@@ -1016,7 +1057,10 @@ fun VpnSettingsContent(
is VpnSettingItem.WireguardPortItem.WireguardPortCustom ->
item(key = it::class.simpleName) {
- CustomPortCell(
+ CustomPortListItem(
+ modifier = Modifier.animateItem(),
+ hierarchy = Hierarchy.Child1,
+ position = Position.Bottom,
title = stringResource(id = R.string.wireguard_custon_port_title),
isSelected = it.selected,
port = it.customPort,
@@ -1031,7 +1075,6 @@ fun VpnSettingsContent(
navigateToWireguardPortDialog(it.customPort, it.availablePortRanges)
},
isEnabled = it.enabled,
- modifier = Modifier.animateItem(),
mainTestTag = LAZY_LIST_WIREGUARD_CUSTOM_PORT_TEXT_TEST_TAG,
numberTestTag = LAZY_LIST_WIREGUARD_CUSTOM_PORT_NUMBER_TEST_TAG,
)
@@ -1040,6 +1083,7 @@ fun VpnSettingsContent(
VpnSettingItem.WireguardPortUnavailable ->
item(key = it::class.simpleName) {
BaseSubtitleCell(
+ modifier = Modifier.animateItem(),
text =
stringResource(
id = R.string.wg_port_subtitle,
@@ -1047,7 +1091,6 @@ fun VpnSettingsContent(
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.animateItem(),
)
}
}
@@ -1057,7 +1100,7 @@ fun VpnSettingsContent(
@Composable
private fun ServerIpOverrides(onServerIpOverridesClick: () -> Unit, modifier: Modifier = Modifier) {
- NavigationComposeCell(
+ NavigationListItem(
title = stringResource(id = R.string.server_ip_override),
modifier = modifier,
onClick = onServerIpOverridesClick,
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingsUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingsUiState.kt
index 3da55e69b9..0fc95c7d4a 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingsUiState.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingsUiState.kt
@@ -105,6 +105,7 @@ data class VpnSettingsUiState(val settings: List<VpnSettingItem>, val isModal: B
add(VpnSettingItem.DnsContentBlockersUnavailable)
}
}
+ add(VpnSettingItem.Spacer)
// Custom DNS
add(
diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml
index 8620613e11..4cf60b6845 100644
--- a/android/lib/resource/src/main/res/values/strings.xml
+++ b/android/lib/resource/src/main/res/values/strings.xml
@@ -470,4 +470,5 @@
<string name="disable_multihop">Disable multihop</string>
<string name="enable_multihop">Enable multihop</string>
<string name="unavailable">Unavailable</string>
+ <string name="warning">Warning</string>
</resources>
diff --git a/android/lib/ui/component/build.gradle.kts b/android/lib/ui/component/build.gradle.kts
index 61a69c77ae..1f99d6a37c 100644
--- a/android/lib/ui/component/build.gradle.kts
+++ b/android/lib/ui/component/build.gradle.kts
@@ -17,6 +17,7 @@ dependencies {
implementation(projects.lib.theme)
implementation(projects.lib.ui.tag)
implementation(projects.lib.ui.designsystem)
+ implementation(projects.lib.ui.util)
implementation(libs.compose.material3)
implementation(libs.compose.ui)
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/DividerButton.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/DividerButton.kt
new file mode 100644
index 0000000000..6a55a99d74
--- /dev/null
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/DividerButton.kt
@@ -0,0 +1,57 @@
+package net.mullvad.mullvadvpn.lib.ui.component
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.VerticalDivider
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.color.AlphaInactive
+
+@Preview
+@Composable
+private fun PreviewDividerButton() {
+ AppTheme { Box(modifier = Modifier.height(56.dp)) { DividerButton(icon = Icons.Default.Add) } }
+}
+
+@Composable
+fun DividerButton(
+ modifier: Modifier = Modifier,
+ isEnabled: Boolean = true,
+ icon: ImageVector,
+ onClick: () -> Unit = {},
+) {
+ Row(modifier) {
+ VerticalDivider(thickness = VerticalDividerWidth)
+ Box(
+ modifier =
+ Modifier.width(DividerButtonWidth)
+ .fillMaxHeight()
+ .clickable(enabled = isEnabled, onClick = onClick),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint =
+ if (isEnabled) LocalContentColor.current
+ else LocalContentColor.current.copy(AlphaInactive),
+ )
+ }
+ }
+}
+
+private val DividerButtonWidth = 64.dp
+private val VerticalDividerWidth = 2.dp
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/Switch.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/Switch.kt
new file mode 100644
index 0000000000..a12a4f44c6
--- /dev/null
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/Switch.kt
@@ -0,0 +1,81 @@
+package net.mullvad.mullvadvpn.lib.ui.component
+
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Switch
+import androidx.compose.material3.SwitchColors
+import androidx.compose.material3.SwitchDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.tooling.preview.Preview
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.lib.theme.color.AlphaDisabled
+import net.mullvad.mullvadvpn.lib.theme.color.selected
+import net.mullvad.mullvadvpn.lib.ui.tag.SWITCH_TEST_TAG
+
+@Preview
+@Composable
+private fun PreviewMullvadSwitch() {
+ AppTheme {
+ Surface(color = MaterialTheme.colorScheme.surface) {
+ Column(modifier = Modifier.padding(Dimens.sideMargin)) {
+ MullvadSwitch(checked = true, onCheckedChange = null)
+ MullvadSwitch(checked = false, onCheckedChange = null)
+ MullvadSwitch(checked = true, onCheckedChange = null, enabled = false)
+ MullvadSwitch(checked = false, onCheckedChange = null, enabled = false)
+ }
+ }
+ }
+}
+
+@Composable
+fun MullvadSwitch(
+ checked: Boolean,
+ onCheckedChange: ((Boolean) -> Unit)?,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ colors: SwitchColors = mullvadSwitchColors(),
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+ content: @Composable (() -> Unit)? = {
+ // This is needed to ensure the thumb always is big in off mode
+ Spacer(modifier = Modifier.size(Dimens.switchIconSize))
+ },
+) {
+ Switch(
+ checked = checked,
+ onCheckedChange = onCheckedChange,
+ modifier = modifier.testTag(SWITCH_TEST_TAG),
+ thumbContent = content,
+ enabled = enabled,
+ colors = colors,
+ interactionSource = interactionSource,
+ )
+}
+
+@Composable
+fun mullvadSwitchColors(): SwitchColors =
+ SwitchDefaults.colors(
+ checkedThumbColor = MaterialTheme.colorScheme.selected,
+ checkedTrackColor = Color.Transparent,
+ checkedBorderColor = MaterialTheme.colorScheme.onPrimary,
+ uncheckedThumbColor = MaterialTheme.colorScheme.error,
+ uncheckedTrackColor = Color.Transparent,
+ uncheckedBorderColor = MaterialTheme.colorScheme.onPrimary,
+ disabledCheckedThumbColor = MaterialTheme.colorScheme.selected.copy(alpha = AlphaDisabled),
+ disabledCheckedTrackColor = Color.Transparent,
+ disabledCheckedBorderColor =
+ MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaDisabled),
+ disabledUncheckedThumbColor = MaterialTheme.colorScheme.error.copy(alpha = AlphaDisabled),
+ disabledUncheckedTrackColor = Color.Transparent,
+ disabledUncheckedBorderColor =
+ MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaDisabled),
+ )
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/CustomPortListItem.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/CustomPortListItem.kt
new file mode 100644
index 0000000000..5fd01de114
--- /dev/null
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/CustomPortListItem.kt
@@ -0,0 +1,103 @@
+package net.mullvad.mullvadvpn.lib.ui.component.listitem
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import net.mullvad.mullvadvpn.lib.model.Port
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.ui.component.DividerButton
+import net.mullvad.mullvadvpn.lib.ui.component.R
+import net.mullvad.mullvadvpn.lib.ui.component.preview.PreviewSpacedColumn
+import net.mullvad.mullvadvpn.lib.ui.designsystem.Hierarchy
+import net.mullvad.mullvadvpn.lib.ui.designsystem.ListItemDefaults
+import net.mullvad.mullvadvpn.lib.ui.designsystem.Position
+import net.mullvad.mullvadvpn.lib.ui.util.applyIfNotNull
+
+@Preview
+@Composable
+private fun PreviewCustomPortListItem() {
+ AppTheme {
+ PreviewSpacedColumn(Modifier.background(MaterialTheme.colorScheme.surface)) {
+ CustomPortListItem(
+ hierarchy = Hierarchy.Child1,
+ title = "Custom",
+ isSelected = true,
+ port = Port(4444),
+ onPortCellClicked = {},
+ onMainCellClicked = {},
+ )
+ CustomPortListItem(
+ hierarchy = Hierarchy.Child1,
+ title = "Custom",
+ isSelected = true,
+ isEnabled = false,
+ port = Port(44449),
+ onPortCellClicked = {},
+ onMainCellClicked = {},
+ )
+ CustomPortListItem(
+ hierarchy = Hierarchy.Child1,
+ title = "Custom",
+ isSelected = false,
+ port = null,
+ onPortCellClicked = {},
+ onMainCellClicked = {},
+ )
+ }
+ }
+}
+
+@Composable
+fun CustomPortListItem(
+ modifier: Modifier = Modifier,
+ hierarchy: Hierarchy = Hierarchy.Parent,
+ position: Position = Position.Single,
+ title: String,
+ isEnabled: Boolean = true,
+ isSelected: Boolean,
+ port: Port?,
+ mainTestTag: String? = null,
+ numberTestTag: String? = null,
+ onMainCellClicked: (() -> Unit)? = null,
+ onPortCellClicked: () -> Unit,
+) {
+ SelectableListItem(
+ modifier = modifier,
+ hierarchy = hierarchy,
+ position = position,
+ isEnabled = isEnabled,
+ isSelected = isSelected,
+ testTag = mainTestTag,
+ onClick = onMainCellClicked,
+ content = {
+ Column {
+ Text(title)
+ if (port != null) {
+ Text(
+ stringResource(id = R.string.port_x, port.value),
+ style = MaterialTheme.typography.labelLarge,
+ color =
+ if (isEnabled) MaterialTheme.colorScheme.onSurfaceVariant
+ else ListItemDefaults.colors().disabledHeadlineColor,
+ )
+ }
+ }
+ },
+ trailingContent = {
+ DividerButton(
+ modifier = Modifier.applyIfNotNull(numberTestTag) { testTag(it) },
+ onClick = onPortCellClicked,
+ isEnabled = isEnabled,
+ icon = Icons.Default.Edit,
+ )
+ },
+ )
+}
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/DnsListItem.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/DnsListItem.kt
new file mode 100644
index 0000000000..2efedac675
--- /dev/null
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/DnsListItem.kt
@@ -0,0 +1,69 @@
+package net.mullvad.mullvadvpn.lib.ui.component.listitem
+
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Error
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.lib.ui.component.R
+import net.mullvad.mullvadvpn.lib.ui.designsystem.Hierarchy
+import net.mullvad.mullvadvpn.lib.ui.designsystem.MullvadListItem
+import net.mullvad.mullvadvpn.lib.ui.designsystem.Position
+
+@Preview
+@Composable
+private fun PreviewDnsListItem() {
+ AppTheme {
+ DnsListItem(
+ address = "0.0.0.0",
+ isUnreachableLocalDnsWarningVisible = true,
+ isUnreachableIpv6DnsWarningVisible = false,
+ onClick = {},
+ )
+ }
+}
+
+@Composable
+fun DnsListItem(
+ modifier: Modifier = Modifier,
+ hierarchy: Hierarchy = Hierarchy.Parent,
+ position: Position = Position.Single,
+ address: String,
+ isUnreachableLocalDnsWarningVisible: Boolean,
+ isUnreachableIpv6DnsWarningVisible: Boolean,
+ onClick: () -> Unit,
+) {
+ MullvadListItem(
+ modifier = modifier,
+ hierarchy = hierarchy,
+ position = position,
+ onClick = onClick,
+ content = {
+ Row {
+ if (isUnreachableLocalDnsWarningVisible || isUnreachableIpv6DnsWarningVisible) {
+ Icon(
+ modifier = Modifier.padding(end = Dimens.smallPadding),
+ imageVector = Icons.Rounded.Error,
+ contentDescription =
+ if (isUnreachableLocalDnsWarningVisible) {
+ stringResource(id = R.string.confirm_local_dns)
+ } else {
+ stringResource(id = R.string.confirm_ipv6_dns)
+ },
+ tint = MaterialTheme.colorScheme.error,
+ )
+ }
+
+ Text(address)
+ }
+ },
+ )
+}
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/ExpandableListItem.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/ExpandableListItem.kt
new file mode 100644
index 0000000000..dc5b1be976
--- /dev/null
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/ExpandableListItem.kt
@@ -0,0 +1,90 @@
+package net.mullvad.mullvadvpn.lib.ui.component.listitem
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.ui.component.ExpandChevron
+import net.mullvad.mullvadvpn.lib.ui.designsystem.Hierarchy
+import net.mullvad.mullvadvpn.lib.ui.designsystem.MullvadListItem
+import net.mullvad.mullvadvpn.lib.ui.designsystem.Position
+import net.mullvad.mullvadvpn.lib.ui.tag.EXPAND_BUTTON_TEST_TAG
+
+@Preview
+@Composable
+private fun PreviewExpandedEnabledExpandableListItem() {
+ AppTheme {
+ ExpandableListItem(
+ title = "Expandable row title",
+ isExpanded = true,
+ isEnabled = true,
+ onCellClicked = {},
+ onInfoClicked = {},
+ )
+ }
+}
+
+@Composable
+fun ExpandableListItem(
+ modifier: Modifier = Modifier,
+ hierarchy: Hierarchy = Hierarchy.Parent,
+ position: Position = Position.Single,
+ title: String,
+ isExpanded: Boolean,
+ isEnabled: Boolean = true,
+ onCellClicked: (Boolean) -> Unit,
+ onInfoClicked: (() -> Unit)? = null,
+) {
+ MullvadListItem(
+ modifier = modifier,
+ hierarchy = hierarchy,
+ position = position,
+ isEnabled = isEnabled,
+ onClick = { onCellClicked(!isExpanded) },
+ content = { Text(title) },
+ trailingContent = {
+ Row(modifier = modifier.fillMaxSize()) {
+ if (onInfoClicked != null) {
+ Box(
+ modifier =
+ Modifier.width(ListItemComponentTokens.infoIconContainerWidth)
+ .fillMaxHeight(),
+ contentAlignment = Alignment.Center,
+ ) {
+ IconButton(onClick = onInfoClicked) {
+ Icon(imageVector = Icons.Default.Info, contentDescription = null)
+ }
+ }
+ }
+ Box(
+ modifier = Modifier.width(ChevronContainerWidth).fillMaxHeight(),
+ contentAlignment = Alignment.Center,
+ ) {
+ ExpandChevron(
+ isExpanded = isExpanded,
+ modifier =
+ Modifier.padding(end = ChevronIconPaddingEnd)
+ .testTag(EXPAND_BUTTON_TEST_TAG),
+ )
+ }
+ }
+ },
+ )
+}
+
+private val ChevronContainerWidth = 60.dp
+private val ChevronIconPaddingEnd = 8.dp
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/InfoListItem.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/InfoListItem.kt
new file mode 100644
index 0000000000..0e461a016f
--- /dev/null
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/InfoListItem.kt
@@ -0,0 +1,75 @@
+package net.mullvad.mullvadvpn.lib.ui.component.listitem
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.lib.ui.designsystem.Hierarchy
+import net.mullvad.mullvadvpn.lib.ui.designsystem.MullvadListItem
+import net.mullvad.mullvadvpn.lib.ui.designsystem.Position
+
+@Preview
+@Composable
+private fun PreviewInfoListItem() {
+ AppTheme {
+ InfoListItem(
+ title = "Information row title",
+ isEnabled = true,
+ onCellClicked = {},
+ onInfoClicked = {},
+ )
+ }
+}
+
+@Composable
+fun InfoListItem(
+ modifier: Modifier = Modifier,
+ hierarchy: Hierarchy = Hierarchy.Parent,
+ position: Position = Position.Single,
+ title: String,
+ isEnabled: Boolean = true,
+ backgroundAlpha: Float = 1f,
+ iconContentDescription: String? = null,
+ onCellClicked: (() -> Unit)? = null,
+ onInfoClicked: (() -> Unit),
+ testTag: String? = null,
+) {
+
+ MullvadListItem(
+ modifier = modifier,
+ hierarchy = hierarchy,
+ position = position,
+ isEnabled = isEnabled,
+ onClick = onCellClicked,
+ testTag = testTag,
+ backgroundAlpha = backgroundAlpha,
+ content = { Text(title) },
+ trailingContent = {
+ Box(
+ modifier =
+ Modifier.width(ListItemComponentTokens.infoIconContainerWidth)
+ .padding(end = Dimens.smallPadding)
+ .fillMaxHeight(),
+ contentAlignment = Alignment.Center,
+ ) {
+ IconButton(onClick = onInfoClicked) {
+ Icon(
+ imageVector = Icons.Default.Info,
+ contentDescription = iconContentDescription,
+ )
+ }
+ }
+ },
+ )
+}
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/ListItemComponentTokens.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/ListItemComponentTokens.kt
new file mode 100644
index 0000000000..03f16efe86
--- /dev/null
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/ListItemComponentTokens.kt
@@ -0,0 +1,7 @@
+package net.mullvad.mullvadvpn.lib.ui.component.listitem
+
+import androidx.compose.ui.unit.dp
+
+object ListItemComponentTokens {
+ val infoIconContainerWidth = 48.dp
+}
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/MtuListItem.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/MtuListItem.kt
new file mode 100644
index 0000000000..7b265418be
--- /dev/null
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/MtuListItem.kt
@@ -0,0 +1,47 @@
+package net.mullvad.mullvadvpn.lib.ui.component.listitem
+
+import androidx.compose.foundation.layout.padding
+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.tooling.preview.Preview
+import net.mullvad.mullvadvpn.lib.model.Mtu
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.lib.ui.component.R
+import net.mullvad.mullvadvpn.lib.ui.designsystem.Hierarchy
+import net.mullvad.mullvadvpn.lib.ui.designsystem.MullvadListItem
+import net.mullvad.mullvadvpn.lib.ui.designsystem.Position
+
+@Preview
+@Composable
+private fun PreviewMtuListView() {
+ AppTheme { MtuListItem(mtuValue = Mtu(55555), onEditMtu = {}) }
+}
+
+@Composable
+fun MtuListItem(
+ modifier: Modifier = Modifier,
+ hierarchy: Hierarchy = Hierarchy.Parent,
+ position: Position = Position.Single,
+ mtuValue: Mtu?,
+ onEditMtu: () -> Unit,
+ backgroundAlpha: Float = 1f,
+) {
+ MullvadListItem(
+ modifier = modifier,
+ hierarchy = hierarchy,
+ position = position,
+ backgroundAlpha = backgroundAlpha,
+ onClick = onEditMtu,
+ content = { Text(text = stringResource(R.string.wireguard_mtu)) },
+ trailingContent = {
+ Text(
+ modifier = Modifier.align(Alignment.CenterEnd).padding(Dimens.mediumPadding),
+ text = mtuValue?.value?.toString() ?: stringResource(id = R.string.hint_default),
+ )
+ },
+ )
+}
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/NavigationListItem.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/NavigationListItem.kt
new file mode 100644
index 0000000000..9cb6db122b
--- /dev/null
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/NavigationListItem.kt
@@ -0,0 +1,75 @@
+package net.mullvad.mullvadvpn.lib.ui.component.listitem
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
+import androidx.compose.material.icons.filled.Error
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.lib.ui.component.R
+import net.mullvad.mullvadvpn.lib.ui.component.preview.PreviewSpacedColumn
+import net.mullvad.mullvadvpn.lib.ui.designsystem.Hierarchy
+import net.mullvad.mullvadvpn.lib.ui.designsystem.MullvadListItem
+import net.mullvad.mullvadvpn.lib.ui.designsystem.Position
+
+@Preview
+@Composable
+private fun PreviewNavigationListItem() {
+ AppTheme {
+ PreviewSpacedColumn(Modifier.background(MaterialTheme.colorScheme.surface)) {
+ NavigationListItem(title = "Navigation sample", showWarning = false, onClick = {})
+ NavigationListItem(
+ hierarchy = Hierarchy.Child1,
+ title = "Navigation sample",
+ showWarning = true,
+ onClick = {},
+ )
+ }
+ }
+}
+
+@Suppress("ComposableLambdaParameterNaming")
+@Composable
+fun NavigationListItem(
+ modifier: Modifier = Modifier,
+ hierarchy: Hierarchy = Hierarchy.Parent,
+ position: Position = Position.Single,
+ title: String,
+ showWarning: Boolean = false,
+ isRowEnabled: Boolean = true,
+ onClick: () -> Unit,
+ testTag: String? = null,
+ icon: @Composable ((BoxScope) -> Unit) = {
+ Icon(Icons.AutoMirrored.Default.KeyboardArrowRight, contentDescription = title)
+ },
+) {
+ MullvadListItem(
+ modifier = modifier,
+ hierarchy = hierarchy,
+ position = position,
+ onClick = onClick,
+ isEnabled = isRowEnabled,
+ testTag = testTag,
+ leadingContent = {
+ if (showWarning) {
+ Icon(
+ imageVector = Icons.Default.Error,
+ modifier = Modifier.padding(end = Dimens.smallPadding),
+ contentDescription = stringResource(R.string.warning),
+ tint = MaterialTheme.colorScheme.error,
+ )
+ }
+ },
+ content = { Text(title) },
+ trailingContent = icon,
+ )
+}
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/ObfuscationModeListItem.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/ObfuscationModeListItem.kt
new file mode 100644
index 0000000000..95e265be81
--- /dev/null
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/ObfuscationModeListItem.kt
@@ -0,0 +1,92 @@
+package net.mullvad.mullvadvpn.lib.ui.component.listitem
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import net.mullvad.mullvadvpn.lib.model.Constraint
+import net.mullvad.mullvadvpn.lib.model.ObfuscationMode
+import net.mullvad.mullvadvpn.lib.model.Port
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.ui.component.DividerButton
+import net.mullvad.mullvadvpn.lib.ui.component.R
+import net.mullvad.mullvadvpn.lib.ui.component.preview.SelectObfuscationListItemPreviewParameterProvider
+import net.mullvad.mullvadvpn.lib.ui.designsystem.Hierarchy
+import net.mullvad.mullvadvpn.lib.ui.designsystem.Position
+
+@Preview
+@Composable
+private fun PreviewObfuscationListItem(
+ @PreviewParameter(SelectObfuscationListItemPreviewParameterProvider::class)
+ selectedObfuscationCellData: Triple<ObfuscationMode, Constraint<Port>, Boolean>
+) {
+ AppTheme {
+ ObfuscationModeListItem(
+ hierarchy = Hierarchy.Child1,
+ obfuscationMode = selectedObfuscationCellData.first,
+ port = selectedObfuscationCellData.second,
+ isSelected = selectedObfuscationCellData.third,
+ onSelected = {},
+ onNavigate = {},
+ )
+ }
+}
+
+@Composable
+fun ObfuscationModeListItem(
+ modifier: Modifier = Modifier,
+ hierarchy: Hierarchy = Hierarchy.Parent,
+ position: Position = Position.Single,
+ obfuscationMode: ObfuscationMode,
+ port: Constraint<Port>,
+ isSelected: Boolean,
+ onSelected: (ObfuscationMode) -> Unit,
+ onNavigate: () -> Unit,
+ testTag: String? = null,
+) {
+ SelectableListItem(
+ modifier = modifier,
+ hierarchy = hierarchy,
+ position = position,
+ isSelected = isSelected,
+ testTag = testTag,
+ onClick = { onSelected(obfuscationMode) },
+ content = {
+ Column {
+ Text(obfuscationMode.toTitle())
+ Text(
+ stringResource(id = R.string.port_x, port.toSubTitle()),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ },
+ trailingContent = {
+ DividerButton(onClick = onNavigate, icon = Icons.AutoMirrored.Filled.KeyboardArrowRight)
+ },
+ )
+}
+
+@Composable
+private fun ObfuscationMode.toTitle() =
+ when (this) {
+ ObfuscationMode.Auto -> stringResource(id = R.string.automatic)
+ ObfuscationMode.Off -> stringResource(id = R.string.off)
+ ObfuscationMode.Udp2Tcp -> stringResource(id = R.string.udp_over_tcp)
+ ObfuscationMode.Shadowsocks -> stringResource(id = R.string.shadowsocks)
+ ObfuscationMode.Quic -> stringResource(id = R.string.quic)
+ ObfuscationMode.Lwo -> stringResource(id = R.string.lwo)
+ }
+
+@Composable
+private fun Constraint<Port>.toSubTitle() =
+ when (this) {
+ Constraint.Any -> stringResource(id = R.string.automatic)
+ is Constraint.Only -> this.value.toString()
+ }
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/SelectableListItem.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/SelectableListItem.kt
new file mode 100644
index 0000000000..86bc7ab3a4
--- /dev/null
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/SelectableListItem.kt
@@ -0,0 +1,131 @@
+package net.mullvad.mullvadvpn.lib.ui.component.listitem
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.expandHorizontally
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkHorizontally
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material3.Icon
+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.tooling.preview.Preview
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.lib.ui.designsystem.Hierarchy
+import net.mullvad.mullvadvpn.lib.ui.designsystem.ListItemDefaults
+import net.mullvad.mullvadvpn.lib.ui.designsystem.MullvadListItem
+import net.mullvad.mullvadvpn.lib.ui.designsystem.Position
+
+@Preview
+@Composable
+private fun PreviewSelectableListItem() {
+ AppTheme {
+ Column(
+ Modifier.background(MaterialTheme.colorScheme.surface),
+ verticalArrangement = Arrangement.spacedBy(Dimens.listItemDivider, Alignment.Bottom),
+ ) {
+ SelectableListItem(hierarchy = Hierarchy.Child1, title = "Selected", isSelected = true)
+ SelectableListItem(
+ hierarchy = Hierarchy.Child1,
+ title = "Not Selected",
+ isSelected = false,
+ )
+ SelectableListItem(
+ hierarchy = Hierarchy.Child1,
+ title = "Selected and disabled",
+ isSelected = true,
+ isEnabled = false,
+ )
+ }
+ }
+}
+
+@Composable
+fun SelectableListItem(
+ modifier: Modifier = Modifier,
+ hierarchy: Hierarchy = Hierarchy.Parent,
+ position: Position = Position.Single,
+ isSelected: Boolean,
+ isEnabled: Boolean = true,
+ iconContentDescription: String? = null,
+ onClick: (() -> Unit)? = null,
+ testTag: String? = null,
+ content: @Composable ((BoxScope.() -> Unit)),
+ trailingContent: @Composable ((BoxScope.() -> Unit))? = null,
+) {
+ MullvadListItem(
+ modifier = modifier,
+ hierarchy = hierarchy,
+ position = position,
+ isEnabled = isEnabled,
+ isSelected = isSelected,
+ testTag = testTag,
+ onClick = onClick,
+ leadingContent = {
+ AnimatedVisibility(
+ modifier = Modifier.align(Alignment.Center),
+ visible = isSelected,
+ enter =
+ fadeIn(tween(ANIMATION_DURATION)) +
+ expandHorizontally(tween(ANIMATION_DURATION)),
+ exit =
+ fadeOut(tween(ANIMATION_DURATION)) +
+ shrinkHorizontally(tween(ANIMATION_DURATION)),
+ ) {
+ val defaultColors = ListItemDefaults.colors()
+ Icon(
+ modifier = Modifier.padding(end = Dimens.smallPadding),
+ imageVector = Icons.Default.Check,
+ contentDescription = iconContentDescription,
+ // Set the tint explicitly here because the animation looks better if the icon
+ // does not change color to white while sliding out.
+ tint =
+ if (isEnabled) defaultColors.selectedHeadlineColor
+ else defaultColors.disabledHeadlineColor,
+ )
+ }
+ },
+ content = content,
+ trailingContent = trailingContent,
+ )
+}
+
+@Composable
+fun SelectableListItem(
+ modifier: Modifier = Modifier,
+ hierarchy: Hierarchy = Hierarchy.Parent,
+ position: Position = Position.Single,
+ isSelected: Boolean,
+ isEnabled: Boolean = true,
+ title: String,
+ iconContentDescription: String? = null,
+ onClick: (() -> Unit)? = null,
+ testTag: String? = null,
+ trailingContent: @Composable ((BoxScope.() -> Unit))? = null,
+) {
+ SelectableListItem(
+ modifier = modifier,
+ hierarchy = hierarchy,
+ position = position,
+ isSelected = isSelected,
+ isEnabled = isEnabled,
+ iconContentDescription = iconContentDescription,
+ testTag = testTag,
+ onClick = onClick,
+ content = { Text(title) },
+ trailingContent = trailingContent,
+ )
+}
+
+private const val ANIMATION_DURATION = 200
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/SwitchListItem.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/SwitchListItem.kt
new file mode 100644
index 0000000000..28e92e8e5b
--- /dev/null
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/listitem/SwitchListItem.kt
@@ -0,0 +1,92 @@
+package net.mullvad.mullvadvpn.lib.ui.component.listitem
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+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.tooling.preview.Preview
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.lib.ui.component.MullvadSwitch
+import net.mullvad.mullvadvpn.lib.ui.component.R
+import net.mullvad.mullvadvpn.lib.ui.designsystem.Hierarchy
+import net.mullvad.mullvadvpn.lib.ui.designsystem.MullvadListItem
+import net.mullvad.mullvadvpn.lib.ui.designsystem.Position
+
+@Preview
+@Composable
+private fun PreviewSwitchListItem() {
+ AppTheme {
+ SwitchListItem(
+ title = "Checkbox Title",
+ isEnabled = true,
+ isToggled = true,
+ onCellClicked = {},
+ onInfoClicked = {},
+ )
+ }
+}
+
+@Composable
+fun SwitchListItem(
+ modifier: Modifier = Modifier,
+ hierarchy: Hierarchy = Hierarchy.Parent,
+ position: Position = Position.Single,
+ title: String,
+ isToggled: Boolean,
+ isEnabled: Boolean = true,
+ backgroundAlpha: Float = 1f,
+ onCellClicked: (Boolean) -> Unit,
+ onInfoClicked: (() -> Unit)? = null,
+) {
+ MullvadListItem(
+ modifier = modifier,
+ hierarchy = hierarchy,
+ position = position,
+ isEnabled = isEnabled,
+ backgroundAlpha = backgroundAlpha,
+ onClick = { onCellClicked(!isToggled) },
+ content = { Text(title) },
+ trailingContent = {
+ Row(
+ modifier = modifier.fillMaxHeight(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (onInfoClicked != null) {
+ Box(
+ modifier =
+ Modifier.width(ListItemComponentTokens.infoIconContainerWidth)
+ .fillMaxHeight(),
+ contentAlignment = Alignment.Center,
+ ) {
+ IconButton(onClick = onInfoClicked) {
+ Icon(
+ imageVector = Icons.Default.Info,
+ contentDescription = stringResource(id = R.string.more_information),
+ )
+ }
+ }
+ }
+
+ Box(modifier = Modifier.fillMaxHeight().padding(end = Dimens.smallPadding)) {
+ MullvadSwitch(
+ modifier = Modifier.align(Alignment.Center),
+ checked = isToggled,
+ onCheckedChange = onCellClicked,
+ enabled = isEnabled,
+ )
+ }
+ }
+ },
+ )
+}
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/preview/PreviewSpacedColumn.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/preview/PreviewSpacedColumn.kt
new file mode 100644
index 0000000000..4fbe865d1d
--- /dev/null
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/preview/PreviewSpacedColumn.kt
@@ -0,0 +1,26 @@
+package net.mullvad.mullvadvpn.lib.ui.component.preview
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.Dp
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+
+@Composable
+internal fun PreviewSpacedColumn(
+ modifier: Modifier = Modifier,
+ spacing: Dp = Dimens.listItemDivider,
+ verticalAlignment: Alignment.Vertical = Alignment.Bottom,
+ horizontalAlignment: Alignment.Horizontal = Alignment.Start,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(spacing, verticalAlignment),
+ horizontalAlignment = horizontalAlignment,
+ content = content,
+ )
+}
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/preview/SelectObfuscationListItemPreviewParameterProvider.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/preview/SelectObfuscationListItemPreviewParameterProvider.kt
new file mode 100644
index 0000000000..70fb983d86
--- /dev/null
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/preview/SelectObfuscationListItemPreviewParameterProvider.kt
@@ -0,0 +1,23 @@
+package net.mullvad.mullvadvpn.lib.ui.component.preview
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import net.mullvad.mullvadvpn.lib.model.Constraint
+import net.mullvad.mullvadvpn.lib.model.ObfuscationMode
+import net.mullvad.mullvadvpn.lib.model.Port
+
+class SelectObfuscationListItemPreviewParameterProvider :
+ PreviewParameterProvider<Triple<ObfuscationMode, Constraint<Port>, Boolean>> {
+ override val values: Sequence<Triple<ObfuscationMode, Constraint<Port>, Boolean>> =
+ sequenceOf(
+ Triple(ObfuscationMode.Shadowsocks, Constraint.Any, false),
+ Triple(ObfuscationMode.Shadowsocks, Constraint.Any, true),
+ Triple(ObfuscationMode.Shadowsocks, Constraint.Only(Port(PORT)), false),
+ Triple(ObfuscationMode.Shadowsocks, Constraint.Only(Port(PORT)), true),
+ Triple(ObfuscationMode.Udp2Tcp, Constraint.Any, false),
+ Triple(ObfuscationMode.Udp2Tcp, Constraint.Any, true),
+ Triple(ObfuscationMode.Udp2Tcp, Constraint.Only(Port(PORT)), false),
+ Triple(ObfuscationMode.Udp2Tcp, Constraint.Only(Port(PORT)), true),
+ )
+}
+
+private const val PORT = 44
diff --git a/android/lib/ui/designsystem/build.gradle.kts b/android/lib/ui/designsystem/build.gradle.kts
index 5dd7c87b70..fcb6eded95 100644
--- a/android/lib/ui/designsystem/build.gradle.kts
+++ b/android/lib/ui/designsystem/build.gradle.kts
@@ -13,6 +13,7 @@ dependencies {
implementation(projects.lib.theme)
implementation(projects.lib.model)
implementation(projects.lib.ui.tag)
+ implementation(projects.lib.ui.util)
implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling)
diff --git a/android/lib/ui/designsystem/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/designsystem/ListItem.kt b/android/lib/ui/designsystem/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/designsystem/ListItem.kt
new file mode 100644
index 0000000000..d3beb7ddb8
--- /dev/null
+++ b/android/lib/ui/designsystem/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/designsystem/ListItem.kt
@@ -0,0 +1,411 @@
+package net.mullvad.mullvadvpn.lib.ui.designsystem
+
+import androidx.compose.animation.core.animateDpAsState
+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.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.sizeIn
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material.icons.filled.Star
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.Stable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewFontScale
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.color.AlphaInactive
+import net.mullvad.mullvadvpn.lib.ui.util.applyIfNotNull
+
+enum class Hierarchy {
+ Parent,
+ Child1,
+ Child2,
+ Child3,
+}
+
+enum class Position {
+ Single,
+ Top,
+ Middle,
+ Bottom,
+}
+
+data class CornerSize(val topStart: Dp, val topEnd: Dp, val bottomStart: Dp, val bottomEnd: Dp)
+
+val Position.cornerSize: CornerSize
+ get() =
+ ListTokens.listItemRoundedCornerSize.let { size ->
+ when (this) {
+ Position.Single -> CornerSize(size, size, size, size)
+ Position.Top -> CornerSize(size, size, 0.dp, 0.dp)
+ Position.Bottom -> CornerSize(0.dp, 0.dp, size, size)
+ Position.Middle -> CornerSize(0.dp, 0.dp, 0.dp, 0.dp)
+ }
+ }
+
+val Hierarchy.paddingStart: Dp
+ get() =
+ when (this) {
+ Hierarchy.Parent -> 0.dp
+ Hierarchy.Child1 -> ListTokens.listItemPaddingStart
+ Hierarchy.Child2 -> ListTokens.listItemPaddingStart * 2
+ Hierarchy.Child3 -> ListTokens.listItemPaddingStart * 3
+ }
+
+val Hierarchy.containerColor: Color
+ @Composable
+ get() =
+ when (this) {
+ // Using primary is a workaround to ensure enough contrast between lowest depth (3) and
+ // the background.
+ Hierarchy.Parent -> MaterialTheme.colorScheme.primary
+ Hierarchy.Child1 -> MaterialTheme.colorScheme.surfaceContainerHighest
+ Hierarchy.Child2 -> MaterialTheme.colorScheme.surfaceContainerHigh
+ Hierarchy.Child3 -> MaterialTheme.colorScheme.surfaceContainerLow
+ }
+
+@Composable
+fun MullvadListItem(
+ modifier: Modifier = Modifier,
+ hierarchy: Hierarchy = Hierarchy.Parent,
+ position: Position = Position.Single,
+ colors: ListItemColors = ListItemDefaults.colors(),
+ backgroundAlpha: Float = 1f,
+ isEnabled: Boolean = true,
+ isSelected: Boolean = false,
+ testTag: String? = null,
+ onClick: (() -> Unit)? = null,
+ onLongClick: (() -> Unit)? = null,
+ leadingContent: @Composable (BoxScope.() -> Unit)? = null,
+ trailingContent: @Composable (BoxScope.() -> Unit)? = null,
+ content: @Composable (BoxScope.() -> Unit),
+) {
+ val size = position.cornerSize
+ val cornerTopStart = animateDpAsState(targetValue = size.topStart)
+ val cornerTopEnd = animateDpAsState(targetValue = size.topEnd)
+ val cornerBottomStart = animateDpAsState(targetValue = size.bottomStart)
+ val cornerBottomEnd = animateDpAsState(targetValue = size.bottomEnd)
+
+ Surface(
+ modifier =
+ modifier
+ .defaultMinSize(minHeight = ListTokens.listItemMinHeight)
+ .height(IntrinsicSize.Min),
+ shape =
+ RoundedCornerShape(
+ topStart = cornerTopStart.value,
+ topEnd = cornerTopEnd.value,
+ bottomStart = cornerBottomStart.value,
+ bottomEnd = cornerBottomEnd.value,
+ ),
+ ) {
+ Row(
+ modifier =
+ Modifier.background(hierarchy.containerColor.copy(alpha = backgroundAlpha))
+ .applyIfNotNull(testTag) { testTag(it) }
+ .applyIfNotNull(onClick, and = isEnabled) {
+ combinedClickable(enabled = true, onClick = it, onLongClick = onLongClick)
+ }
+ .padding(start = ListTokens.listItemPaddingStart + hierarchy.paddingStart),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (leadingContent != null) {
+ Box(modifier = Modifier.fillMaxHeight(), contentAlignment = Alignment.CenterStart) {
+ ProvideContentColorTextStyle(
+ colors.headlineColor(isEnabled, isSelected),
+ MaterialTheme.typography.titleMedium,
+ ) {
+ leadingContent(this)
+ }
+ }
+ }
+
+ Box(
+ modifier = Modifier.weight(1f, fill = true).fillMaxHeight(),
+ contentAlignment = Alignment.CenterStart,
+ ) {
+ ProvideContentColorTextStyle(
+ colors.headlineColor(isEnabled, isSelected),
+ MaterialTheme.typography.titleMedium,
+ ) {
+ content(this)
+ }
+ }
+
+ if (trailingContent != null) {
+ Box(
+ modifier =
+ Modifier.sizeIn(minWidth = ListTokens.listItemButtonWidth)
+ .width(IntrinsicSize.Max)
+ .fillMaxHeight(),
+ contentAlignment = Alignment.Center,
+ ) {
+ ProvideContentColorTextStyle(
+ colors.trailingIconColor,
+ MaterialTheme.typography.titleMedium,
+ ) {
+ trailingContent(this)
+ }
+ }
+ }
+ }
+ }
+}
+
+// Based of ListItem
+@Immutable
+class ListItemColors(
+ val containerColor: Color,
+ val headlineColor: Color,
+ val trailingIconColor: Color,
+ val selectedHeadlineColor: Color,
+ val disabledHeadlineColor: Color,
+) {
+ internal fun containerColor(): Color = containerColor
+
+ @Stable
+ internal fun headlineColor(enabled: Boolean, selected: Boolean): Color =
+ when {
+ !enabled -> disabledHeadlineColor
+ selected -> selectedHeadlineColor
+ else -> headlineColor
+ }
+}
+
+object ListItemDefaults {
+ @Composable
+ fun colors(
+ containerColor: Color = MaterialTheme.colorScheme.surface,
+ headlineColor: Color = MaterialTheme.colorScheme.onSurface,
+ trailingIconColor: Color = MaterialTheme.colorScheme.onSurface,
+ selectedHeadlineColor: Color = MaterialTheme.colorScheme.tertiary,
+ disabledHeadlineColor: Color =
+ headlineColor.copy(alpha = ListTokens.ListItemDisabledLabelTextOpacity),
+ ): ListItemColors =
+ ListItemColors(
+ containerColor = containerColor,
+ headlineColor = headlineColor,
+ trailingIconColor = trailingIconColor,
+ selectedHeadlineColor = selectedHeadlineColor,
+ disabledHeadlineColor = disabledHeadlineColor,
+ )
+}
+
+object ListTokens {
+ const val ListItemDisabledLabelTextOpacity = AlphaInactive
+
+ val listItemMinHeight = 56.dp
+ val listItemButtonWidth = 56.dp
+ val listItemPaddingStart = 16.dp
+ val listItemRoundedCornerSize = 20.dp
+}
+
+@Preview
+@PreviewFontScale
+@Composable
+private fun PreviewLeadingContentListItem() {
+ AppTheme {
+ MullvadListItem(
+ modifier = Modifier.fillMaxWidth(),
+ hierarchy = Hierarchy.Child3,
+ isEnabled = false,
+ isSelected = true,
+ leadingContent = {
+ Icon(
+ modifier = Modifier.size(24.dp).align(Alignment.Center),
+ imageVector = Icons.Default.Star,
+ contentDescription = null,
+ )
+ },
+ content = {
+ Text(
+ "Hello world fsadhkuhfiuskahf iuhsadhuf sa",
+ modifier =
+ Modifier.padding(start = 4.dp, top = 16.dp, bottom = 16.dp)
+ .fillMaxSize()
+ .wrapContentHeight(align = Alignment.CenterVertically),
+ style = MaterialTheme.typography.titleMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ },
+ trailingContent = {
+ Box(
+ modifier = Modifier.fillMaxSize().clickable(onClick = { /* Handle click */ }),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ modifier = Modifier.padding(16.dp),
+ imageVector = Icons.Default.KeyboardArrowDown,
+ contentDescription = null,
+ )
+ }
+ },
+ )
+ }
+}
+
+@Preview
+@PreviewFontScale
+@Composable
+private fun PreviewLeadingListItem() {
+ AppTheme {
+ MullvadListItem(
+ modifier = Modifier.fillMaxWidth(),
+ content = {
+ Text(
+ "Hello world fsadhkuhfiuskahf iuhsadhuf sa",
+ modifier =
+ Modifier.padding(16.dp)
+ .fillMaxSize()
+ .wrapContentHeight(align = Alignment.CenterVertically),
+ style = MaterialTheme.typography.titleMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ },
+ trailingContent = {
+ Box(
+ modifier = Modifier.fillMaxSize().clickable(onClick = { /* Handle click */ }),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ modifier = Modifier.padding(16.dp),
+ imageVector = Icons.Default.KeyboardArrowDown,
+ contentDescription = null,
+ )
+ }
+ },
+ )
+ }
+}
+
+@Preview
+@PreviewFontScale
+@Composable
+private fun PreviewTrailingListItem() {
+ AppTheme {
+ MullvadListItem(
+ modifier = Modifier.fillMaxWidth(),
+ isSelected = true,
+ content = {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(imageVector = Icons.Default.Check, contentDescription = null)
+ Spacer(Modifier.width(8.dp))
+ Text("Sample Item", maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+ },
+ trailingContent = {
+ Box(
+ modifier = Modifier.fillMaxSize().clickable(onClick = {}),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ modifier = Modifier.padding(16.dp),
+ imageVector = Icons.Default.Add,
+ contentDescription = null,
+ )
+ }
+ },
+ )
+ }
+}
+
+@Preview
+@PreviewFontScale
+@Composable
+private fun PreviewHierarchyListItem() {
+ AppTheme {
+ MullvadListItem(
+ modifier = Modifier.fillMaxWidth(),
+ isSelected = true,
+ hierarchy = Hierarchy.Child3,
+ content = {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(imageVector = Icons.Default.Check, contentDescription = null)
+ Spacer(Modifier.width(8.dp))
+ Text("Sample Item", maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+ },
+ trailingContent = {
+ Box(
+ modifier = Modifier.fillMaxSize().clickable(onClick = {}),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ modifier = Modifier.padding(16.dp),
+ imageVector = Icons.Default.Add,
+ contentDescription = null,
+ )
+ }
+ },
+ )
+ }
+}
+
+@Preview
+@PreviewFontScale
+@Composable
+private fun PreviewListItemPositions() {
+ AppTheme {
+ MullvadListItem(
+ modifier = Modifier.fillMaxWidth(),
+ position = Position.Top,
+ content = {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text("Sample Item", maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+ },
+ trailingContent = {
+ Box(
+ modifier = Modifier.fillMaxSize().clickable(onClick = {}),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ modifier = Modifier.padding(16.dp),
+ imageVector = Icons.Default.Add,
+ contentDescription = null,
+ )
+ }
+ },
+ )
+ }
+}
diff --git a/android/lib/ui/util/build.gradle.kts b/android/lib/ui/util/build.gradle.kts
new file mode 100644
index 0000000000..ede0822b03
--- /dev/null
+++ b/android/lib/ui/util/build.gradle.kts
@@ -0,0 +1,17 @@
+plugins {
+ alias(libs.plugins.mullvad.android.library)
+ alias(libs.plugins.compose)
+}
+
+android {
+ namespace = "net.mullvad.mullvadvpn.lib.ui.util"
+ buildFeatures { compose = true }
+}
+
+dependencies {
+ implementation(libs.compose.ui)
+ implementation(libs.compose.ui.tooling)
+ implementation(libs.compose.ui.tooling.preview)
+ implementation(libs.compose.material3)
+ implementation(libs.compose.icons.extended)
+}
diff --git a/android/lib/ui/util/src/main/AndroidManifest.xml b/android/lib/ui/util/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..cc947c5679
--- /dev/null
+++ b/android/lib/ui/util/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+<manifest />
diff --git a/android/lib/ui/util/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/util/Modifier.kt b/android/lib/ui/util/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/util/Modifier.kt
new file mode 100644
index 0000000000..49d0a47c13
--- /dev/null
+++ b/android/lib/ui/util/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/util/Modifier.kt
@@ -0,0 +1,21 @@
+package net.mullvad.mullvadvpn.lib.ui.util
+
+import androidx.compose.ui.Modifier
+
+fun <T> Modifier.applyIfNotNull(
+ value: T?,
+ and: Boolean = true,
+ block: Modifier.(T) -> Modifier,
+): Modifier =
+ if (value != null && and) {
+ this.then(Modifier.block(value))
+ } else {
+ this
+ }
+
+fun Modifier.applyIf(condition: Boolean, block: Modifier.() -> Modifier): Modifier =
+ if (condition) {
+ this.then(Modifier.block())
+ } else {
+ this
+ }
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
index 0824825edf..38dcedfe19 100644
--- a/android/settings.gradle.kts
+++ b/android/settings.gradle.kts
@@ -47,7 +47,8 @@ include(
":lib:tv",
":lib:ui:designsystem",
":lib:ui:component",
- ":lib:ui:tag"
+ ":lib:ui:tag",
+ ":lib:ui:util"
)
include(
":test",
diff --git a/desktop/packages/mullvad-vpn/locales/messages.pot b/desktop/packages/mullvad-vpn/locales/messages.pot
index bbf3381261..a43037a88f 100644
--- a/desktop/packages/mullvad-vpn/locales/messages.pot
+++ b/desktop/packages/mullvad-vpn/locales/messages.pot
@@ -3397,6 +3397,9 @@ msgstr ""
msgid "Verifying voucher…"
msgstr ""
+msgid "Warning"
+msgstr ""
+
msgid "We are still verifying your purchase, this might take some time. Your time will be added if the verification is successful."
msgstr ""