diff options
| author | Kalle Lindström <karl.lindstrom@mullvad.net> | 2025-11-20 09:38:45 +0100 |
|---|---|---|
| committer | Kalle Lindström <karl.lindstrom@mullvad.net> | 2025-11-21 10:12:10 +0100 |
| commit | 688f9509357a96d1be398705b0cee2e8b43e7131 (patch) | |
| tree | ca0c80b8dbd6d4c2e0515af6801ea37020a518f6 | |
| parent | e1439b23d7bc0c374a343d797842da55ecbd4234 (diff) | |
| download | mullvadvpn-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.
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 "" |
