summaryrefslogtreecommitdiffhomepage
path: root/android/lib/ui/component
diff options
context:
space:
mode:
authorDavid Göransson <david.goransson@mullvad.net>2025-07-04 16:12:32 +0200
committerDavid Göransson <david.goransson@mullvad.net>2025-07-04 16:12:32 +0200
commit5300f1663559ebd7a87c699db8e858d13e6fa556 (patch)
tree0081e14129def76d6a57b32232e42411c2fbe10d /android/lib/ui/component
parent3e5795cbab6866fcd3eafee58d1790fc9e7f1829 (diff)
parent0d5660226494abaf04dc619997bf4d6a27c637d8 (diff)
downloadmullvadvpn-5300f1663559ebd7a87c699db8e858d13e6fa556.tar.xz
mullvadvpn-5300f1663559ebd7a87c699db8e858d13e6fa556.zip
Merge branch 'implement-new-select-location-design-droid-1954'
Diffstat (limited to 'android/lib/ui/component')
-rw-r--r--android/lib/ui/component/build.gradle.kts11
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/ExpandChevron.kt70
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/CheckableRelayListItem.kt89
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayItemCheckableCellPreviewParameterProvider.kt28
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayItemPreviewData.kt65
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayListItem.kt132
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayListItemPreviewData.kt125
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/SelectableRelayListItem.kt206
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/SelectableRelayListItemPreviewParameterProvider.kt66
9 files changed, 788 insertions, 4 deletions
diff --git a/android/lib/ui/component/build.gradle.kts b/android/lib/ui/component/build.gradle.kts
index 8ca10e29c5..56ec9a63ef 100644
--- a/android/lib/ui/component/build.gradle.kts
+++ b/android/lib/ui/component/build.gradle.kts
@@ -35,15 +35,18 @@ android {
}
dependencies {
+ implementation(projects.lib.model)
+ implementation(projects.lib.resource)
+ implementation(projects.lib.theme)
implementation(projects.lib.ui.tag)
+ implementation(projects.lib.ui.designsystem)
+
implementation(libs.compose.material3)
implementation(libs.compose.ui)
+ implementation(libs.compose.ui.tooling)
+ implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.constrainlayout)
implementation(libs.kotlin.stdlib)
implementation(libs.compose.icons.extended)
implementation(libs.androidx.ktx)
- implementation(projects.lib.resource)
- implementation(projects.lib.shared)
- implementation(projects.lib.theme)
- implementation(projects.lib.model)
}
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/ExpandChevron.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/ExpandChevron.kt
new file mode 100644
index 0000000000..5c5eed486e
--- /dev/null
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/ExpandChevron.kt
@@ -0,0 +1,70 @@
+package net.mullvad.mullvadvpn.lib.ui.component
+
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.TweenSpec
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+
+@Composable
+@Preview
+private fun PreviewChevron() {
+ AppTheme {
+ Surface {
+ Column {
+ ExpandChevron(isExpanded = false)
+ ExpandChevron(isExpanded = true)
+ }
+ }
+ }
+}
+
+@Composable
+fun ExpandChevron(modifier: Modifier = Modifier, isExpanded: Boolean) {
+ val degree = remember(isExpanded) { if (isExpanded) UP_ROTATION else DOWN_ROTATION }
+ val stateLabel =
+ if (isExpanded) {
+ stringResource(id = R.string.collapse)
+ } else {
+ stringResource(id = R.string.expand)
+ }
+ val animatedRotation =
+ animateFloatAsState(
+ targetValue = degree,
+ label = "",
+ animationSpec = TweenSpec(ROTATION_ANIMATION_DURATION, easing = LinearEasing),
+ )
+
+ Icon(
+ imageVector = Icons.Default.KeyboardArrowDown,
+ contentDescription = stateLabel,
+ // tint = color,
+ modifier = modifier.rotate(animatedRotation.value),
+ )
+}
+
+@Composable
+fun ExpandChevronIconButton(
+ modifier: Modifier = Modifier,
+ onExpand: (Boolean) -> Unit,
+ isExpanded: Boolean,
+) {
+ IconButton(modifier = modifier, onClick = { onExpand(!isExpanded) }) {
+ ExpandChevron(isExpanded = isExpanded)
+ }
+}
+
+private const val DOWN_ROTATION = 0f
+private const val UP_ROTATION = 180f
+private const val ROTATION_ANIMATION_DURATION = 100
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/CheckableRelayListItem.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/CheckableRelayListItem.kt
new file mode 100644
index 0000000000..d92e978d5c
--- /dev/null
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/CheckableRelayListItem.kt
@@ -0,0 +1,89 @@
+package net.mullvad.mullvadvpn.lib.ui.component.relaylist
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.MaterialTheme
+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.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.times
+import net.mullvad.mullvadvpn.lib.model.RelayItem
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.lib.ui.component.ExpandChevron
+import net.mullvad.mullvadvpn.lib.ui.designsystem.Checkbox
+import net.mullvad.mullvadvpn.lib.ui.designsystem.RelayListItem
+import net.mullvad.mullvadvpn.lib.ui.designsystem.RelayListItemDefaults
+import net.mullvad.mullvadvpn.lib.ui.tag.EXPAND_BUTTON_TEST_TAG
+import net.mullvad.mullvadvpn.lib.ui.tag.LOCATION_CELL_TEST_TAG
+
+@Composable
+@Preview
+private fun PreviewCheckableRelayLocationCell(
+ @PreviewParameter(RelayItemCheckableCellPreviewParameterProvider::class)
+ relayItems: List<RelayItem.Location.Country>
+) {
+ AppTheme {
+ Column(Modifier.background(color = MaterialTheme.colorScheme.surface)) {
+ relayItems.map {
+ Spacer(Modifier.size(1.dp))
+ CheckableRelayLocationCell(
+ item = CheckableRelayListItem(item = it, itemPosition = ItemPosition.Single),
+ onExpand = {},
+ modifier = Modifier.testTag(LOCATION_CELL_TEST_TAG),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun CheckableRelayLocationCell(
+ item: CheckableRelayListItem,
+ modifier: Modifier = Modifier,
+ onRelayCheckedChange: (isChecked: Boolean) -> Unit = { _ -> },
+ onExpand: (Boolean) -> Unit,
+) {
+ RelayListItem(
+ modifier = modifier.clip(itemPosition = item.itemPosition),
+ selected = false,
+ content = {
+ Row(
+ modifier =
+ Modifier.padding(start = item.depth * Dimens.mediumPadding)
+ .padding(Dimens.mediumPadding),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Name(name = item.item.name, state = null, active = true)
+ }
+ },
+ leadingContent = {
+ Checkbox(checked = item.checked, onCheckedChange = onRelayCheckedChange)
+ },
+ onClick = { onRelayCheckedChange(!item.checked) },
+ onLongClick = null,
+ trailingContent = {
+ if (item.item.hasChildren) {
+ ExpandChevron(
+ isExpanded = item.expanded,
+ modifier =
+ Modifier.clickable { onExpand(!item.expanded) }
+ .fillMaxSize()
+ .padding(Dimens.mediumPadding)
+ .testTag(EXPAND_BUTTON_TEST_TAG),
+ )
+ }
+ },
+ colors = RelayListItemDefaults.colors(containerColor = item.depth.toBackgroundColor()),
+ )
+}
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayItemCheckableCellPreviewParameterProvider.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayItemCheckableCellPreviewParameterProvider.kt
new file mode 100644
index 0000000000..918203db53
--- /dev/null
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayItemCheckableCellPreviewParameterProvider.kt
@@ -0,0 +1,28 @@
+package net.mullvad.mullvadvpn.lib.ui.component.relaylist
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import net.mullvad.mullvadvpn.lib.model.RelayItem
+
+class RelayItemCheckableCellPreviewParameterProvider :
+ PreviewParameterProvider<List<RelayItem.Location.Country>> {
+ override val values =
+ sequenceOf(
+ listOf(
+ generateRelayItemCountry(
+ name = "Relay country Active",
+ cityNames = listOf("Relay city 1", "Relay city 2"),
+ relaysPerCity = 2,
+ ),
+ generateRelayItemCountry(
+ name = "Relay country Expanded",
+ cityNames = listOf("Normal city"),
+ relaysPerCity = 2,
+ ),
+ generateRelayItemCountry(
+ name = "Country and city Expanded",
+ cityNames = listOf("Expanded city A", "Expanded city B"),
+ relaysPerCity = 2,
+ ),
+ )
+ )
+}
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayItemPreviewData.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayItemPreviewData.kt
new file mode 100644
index 0000000000..35397a6a27
--- /dev/null
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayItemPreviewData.kt
@@ -0,0 +1,65 @@
+package net.mullvad.mullvadvpn.lib.ui.component.relaylist
+
+import net.mullvad.mullvadvpn.lib.model.GeoLocationId
+import net.mullvad.mullvadvpn.lib.model.Ownership
+import net.mullvad.mullvadvpn.lib.model.ProviderId
+import net.mullvad.mullvadvpn.lib.model.RelayItem
+
+fun generateRelayItemCountry(
+ name: String,
+ cityNames: List<String>,
+ relaysPerCity: Int,
+ active: Boolean = true,
+) =
+ RelayItem.Location.Country(
+ name = name,
+ id = name.generateCountryCode(),
+ cities =
+ cityNames.map { cityName ->
+ generateRelayItemCity(cityName, name.generateCountryCode(), relaysPerCity, active)
+ },
+ )
+
+private fun generateRelayItemCity(
+ name: String,
+ countryCode: GeoLocationId.Country,
+ numberOfRelays: Int,
+ active: Boolean = true,
+) =
+ RelayItem.Location.City(
+ name = name,
+ id = name.generateCityCode(countryCode),
+ relays =
+ List(numberOfRelays) { index ->
+ generateRelayItemRelay(
+ name.generateCityCode(countryCode),
+ generateHostname(name.generateCityCode(countryCode), index),
+ active,
+ )
+ },
+ )
+
+private fun generateRelayItemRelay(
+ cityCode: GeoLocationId.City,
+ hostName: String,
+ active: Boolean = true,
+ daita: Boolean = true,
+) =
+ RelayItem.Location.Relay(
+ id = GeoLocationId.Hostname(city = cityCode, code = hostName),
+ active = active,
+ provider = ProviderId("Provider"),
+ ownership = Ownership.MullvadOwned,
+ daita = daita,
+ )
+
+private fun String.generateCountryCode() =
+ GeoLocationId.Country((take(1) + takeLast(1)).lowercase())
+
+private fun String.generateCityCode(countryCode: GeoLocationId.Country) =
+ GeoLocationId.City(countryCode, take(CITY_CODE_LENGTH).lowercase())
+
+private fun generateHostname(city: GeoLocationId.City, index: Int) =
+ "${city.country.code}-${city.code}-wg-${index+1}"
+
+private const val CITY_CODE_LENGTH = 3
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayListItem.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayListItem.kt
new file mode 100644
index 0000000000..8132a9ece7
--- /dev/null
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayListItem.kt
@@ -0,0 +1,132 @@
+package net.mullvad.mullvadvpn.lib.ui.component.relaylist
+
+import net.mullvad.mullvadvpn.lib.model.CustomListId
+import net.mullvad.mullvadvpn.lib.model.CustomListName
+import net.mullvad.mullvadvpn.lib.model.RelayItem
+
+enum class RelayListItemContentType {
+ CUSTOM_LIST_HEADER,
+ CUSTOM_LIST_ITEM,
+ CUSTOM_LIST_ENTRY_ITEM,
+ CUSTOM_LIST_FOOTER,
+ LOCATION_HEADER,
+ LOCATION_ITEM,
+ LOCATIONS_EMPTY_TEXT,
+ EMPTY_RELAY_LIST,
+}
+
+enum class RelayListItemState {
+ USED_AS_ENTRY,
+ USED_AS_EXIT,
+}
+
+sealed interface RelayListItem {
+ val key: Any
+ val contentType: RelayListItemContentType
+
+ data object CustomListHeader : RelayListItem {
+ override val key = "custom_list_header"
+ override val contentType = RelayListItemContentType.CUSTOM_LIST_HEADER
+ }
+
+ sealed interface SelectableItem : RelayListItem {
+ val item: RelayItem
+ val depth: Int
+ val isSelected: Boolean
+ val expanded: Boolean
+ val state: RelayListItemState?
+ val itemPosition: ItemPosition
+ }
+
+ data class CustomListItem(
+ override val item: RelayItem.CustomList,
+ override val isSelected: Boolean = false,
+ override val expanded: Boolean = false,
+ override val state: RelayListItemState? = null,
+ override val itemPosition: ItemPosition = ItemPosition.Single,
+ ) : SelectableItem {
+ override val key = item.id
+ override val depth: Int = 0
+ override val contentType = RelayListItemContentType.CUSTOM_LIST_ITEM
+ }
+
+ data class CustomListEntryItem(
+ val parentId: CustomListId,
+ val parentName: CustomListName,
+ override val item: RelayItem.Location,
+ override val expanded: Boolean,
+ override val depth: Int = 0,
+ override val state: RelayListItemState? = null,
+ override val itemPosition: ItemPosition,
+ ) : SelectableItem {
+ override val key = parentId to item.id
+
+ // Can't be displayed as selected
+ override val isSelected: Boolean = false
+ override val contentType = RelayListItemContentType.CUSTOM_LIST_ENTRY_ITEM
+ }
+
+ data class CustomListFooter(val hasCustomList: Boolean) : RelayListItem {
+ override val key = "custom_list_footer"
+ override val contentType = RelayListItemContentType.CUSTOM_LIST_FOOTER
+ }
+
+ data object LocationHeader : RelayListItem {
+ override val key = "location_header"
+ override val contentType = RelayListItemContentType.LOCATION_HEADER
+ }
+
+ data class GeoLocationItem(
+ override val item: RelayItem.Location,
+ override val isSelected: Boolean = false,
+ override val depth: Int = 0,
+ override val expanded: Boolean = false,
+ override val state: RelayListItemState? = null,
+ override val itemPosition: ItemPosition,
+ ) : SelectableItem {
+ override val key = item.id
+ override val contentType = RelayListItemContentType.LOCATION_ITEM
+ }
+
+ data class LocationsEmptyText(val searchTerm: String) : RelayListItem {
+ override val key = "locations_empty_text"
+ override val contentType = RelayListItemContentType.LOCATIONS_EMPTY_TEXT
+ }
+
+ data object EmptyRelayList : RelayListItem {
+ override val key = "empty_relay_list"
+ override val contentType = RelayListItemContentType.EMPTY_RELAY_LIST
+ }
+}
+
+data class CheckableRelayListItem(
+ val item: RelayItem.Location,
+ val depth: Int = 0,
+ val checked: Boolean = false,
+ val expanded: Boolean = false,
+ val itemPosition: ItemPosition = ItemPosition.Single,
+)
+
+sealed interface ItemPosition {
+ data object Top : ItemPosition
+
+ data object Middle : ItemPosition
+
+ data object Bottom : ItemPosition
+
+ data object Single : ItemPosition
+
+ fun roundTop(): Boolean =
+ when (this) {
+ is Single,
+ Top -> true
+ else -> false
+ }
+
+ fun roundBottom(): Boolean =
+ when (this) {
+ is Single,
+ Bottom -> true
+ else -> false
+ }
+}
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayListItemPreviewData.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayListItemPreviewData.kt
new file mode 100644
index 0000000000..5776601168
--- /dev/null
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayListItemPreviewData.kt
@@ -0,0 +1,125 @@
+package net.mullvad.mullvadvpn.lib.ui.component.relaylist
+
+import net.mullvad.mullvadvpn.lib.model.CustomList
+import net.mullvad.mullvadvpn.lib.model.CustomListId
+import net.mullvad.mullvadvpn.lib.model.CustomListName
+import net.mullvad.mullvadvpn.lib.model.RelayItem
+
+object RelayListItemPreviewData {
+ @Suppress("LongMethod")
+ fun generateRelayListItems(
+ includeCustomLists: Boolean,
+ isSearching: Boolean,
+ ): List<RelayListItem> = buildList {
+ if (!isSearching || includeCustomLists) {
+ add(RelayListItem.CustomListHeader)
+ // Add custom list items
+ if (includeCustomLists) {
+ RelayListItem.CustomListItem(
+ item =
+ RelayItem.CustomList(
+ customList =
+ CustomList(
+ id = CustomListId("custom_list_id"),
+ name = CustomListName.fromString("Custom List"),
+ locations = emptyList(),
+ ),
+ locations =
+ listOf(
+ generateRelayItemCountry(
+ name = "Country",
+ cityNames = listOf("City"),
+ relaysPerCity = 2,
+ active = true,
+ )
+ ),
+ ),
+ isSelected = false,
+ state = null,
+ expanded = false,
+ itemPosition = ItemPosition.Single,
+ )
+ }
+ if (!isSearching) {
+ add(RelayListItem.CustomListFooter(hasCustomList = includeCustomLists))
+ }
+ }
+ add(RelayListItem.LocationHeader)
+ val locations =
+ listOf(
+ generateRelayItemCountry(
+ name = "First Country",
+ cityNames = listOf("Capital City", "Minor City"),
+ relaysPerCity = 2,
+ active = true,
+ ),
+ generateRelayItemCountry(
+ name = "Second Country",
+ cityNames = listOf("Medium City", "Small City", "Vivec City"),
+ relaysPerCity = 1,
+ active = false,
+ ),
+ )
+ addAll(
+ listOf(
+ RelayListItem.GeoLocationItem(
+ item = locations[0],
+ isSelected = false,
+ depth = 0,
+ expanded = true,
+ state = null,
+ itemPosition = ItemPosition.Middle,
+ ),
+ RelayListItem.GeoLocationItem(
+ item = locations[0].cities[0],
+ isSelected = true,
+ depth = 1,
+ expanded = false,
+ state = null,
+ itemPosition = ItemPosition.Middle,
+ ),
+ RelayListItem.GeoLocationItem(
+ item = locations[0].cities[1],
+ isSelected = false,
+ depth = 1,
+ expanded = true,
+ state = null,
+ itemPosition = ItemPosition.Middle,
+ ),
+ RelayListItem.GeoLocationItem(
+ item = locations[0].cities[1].relays[0],
+ isSelected = false,
+ depth = 2,
+ expanded = false,
+ state = RelayListItemState.USED_AS_EXIT,
+ itemPosition = ItemPosition.Middle,
+ ),
+ RelayListItem.GeoLocationItem(
+ item = locations[0].cities[1].relays[1],
+ isSelected = false,
+ depth = 2,
+ expanded = false,
+ state = null,
+ itemPosition = ItemPosition.Middle,
+ ),
+ RelayListItem.GeoLocationItem(
+ item = locations[1],
+ isSelected = false,
+ depth = 0,
+ expanded = false,
+ state = null,
+ itemPosition = ItemPosition.Bottom,
+ ),
+ )
+ )
+ }
+
+ fun generateEmptyList(searchTerm: String, isSearching: Boolean) =
+ listOf(
+ if (isSearching) {
+ RelayListItem.LocationsEmptyText(searchTerm)
+ } else {
+ RelayListItem.EmptyRelayList
+ }
+ )
+}
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/SelectableRelayListItem.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/SelectableRelayListItem.kt
new file mode 100644
index 0000000000..e66bfbd359
--- /dev/null
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/SelectableRelayListItem.kt
@@ -0,0 +1,206 @@
+package net.mullvad.mullvadvpn.lib.ui.component.relaylist
+
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.CornerSize
+import androidx.compose.foundation.shape.RoundedCornerShape
+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.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.times
+import net.mullvad.mullvadvpn.lib.resource.R
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible
+import net.mullvad.mullvadvpn.lib.theme.color.selected
+import net.mullvad.mullvadvpn.lib.ui.component.ExpandChevron
+import net.mullvad.mullvadvpn.lib.ui.designsystem.RelayListItem
+import net.mullvad.mullvadvpn.lib.ui.designsystem.RelayListItemDefaults
+import net.mullvad.mullvadvpn.lib.ui.designsystem.RelayListTokens
+import net.mullvad.mullvadvpn.lib.ui.tag.EXPAND_BUTTON_TEST_TAG
+
+@Composable
+@Preview
+private fun PreviewSelectableRelayLocationItem(
+ @PreviewParameter(SelectableRelayListItemPreviewParameterProvider::class)
+ relayItems: List<RelayListItem.SelectableItem>
+) {
+ AppTheme {
+ Column(Modifier.background(color = MaterialTheme.colorScheme.surface)) {
+ relayItems.map {
+ Spacer(Modifier.size(1.dp))
+ SelectableRelayListItem(relayListItem = it, onClick = {}, onToggleExpand = {})
+ }
+ }
+ }
+}
+
+@Composable
+fun SelectableRelayListItem(
+ relayListItem: RelayListItem.SelectableItem,
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit,
+ onLongClick: (() -> Unit)? = null,
+ onToggleExpand: ((Boolean) -> Unit),
+) {
+ RelayListItem(
+ modifier = modifier,
+ shape = relayListItem.itemPosition.toShape(),
+ selected = relayListItem.isSelected,
+ enabled = relayListItem.item.active,
+ content = {
+ Row(
+ modifier =
+ Modifier.fillMaxSize()
+ .padding(start = relayListItem.depth * Dimens.mediumPadding)
+ .padding(Dimens.mediumPadding),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(Dimens.smallPadding),
+ ) {
+ val iconTint =
+ when {
+ !relayListItem.item.active -> MaterialTheme.colorScheme.error
+ relayListItem.isSelected -> MaterialTheme.colorScheme.tertiary
+ else -> Color.Transparent
+ }
+ if (relayListItem.isSelected) {
+ Icon(
+ imageVector = Icons.Default.Check,
+ contentDescription = null,
+ tint = iconTint,
+ )
+ } else if (!relayListItem.item.active) {
+ InactiveRelayIndicator(iconTint)
+ }
+
+ Name(
+ name = relayListItem.item.name,
+ state = relayListItem.state,
+ active = relayListItem.item.active,
+ )
+ }
+ },
+ onClick = onClick,
+ onLongClick = onLongClick,
+ trailingContent =
+ if (relayListItem.item.hasChildren) {
+ {
+ ExpandChevron(
+ isExpanded = relayListItem.expanded,
+ modifier =
+ Modifier.clickable { onToggleExpand(!relayListItem.expanded) }
+ .fillMaxSize()
+ .padding(Dimens.mediumPadding)
+ .testTag(EXPAND_BUTTON_TEST_TAG),
+ )
+ }
+ } else {
+ null
+ },
+ colors =
+ RelayListItemDefaults.colors(containerColor = relayListItem.depth.toBackgroundColor()),
+ )
+}
+
+@Composable
+internal fun Name(
+ modifier: Modifier = Modifier,
+ name: String,
+ state: RelayListItemState?,
+ active: Boolean,
+) {
+ Text(
+ text = state?.let { name.withSuffix(state) } ?: name,
+ style = MaterialTheme.typography.bodyLarge,
+ modifier =
+ modifier.alpha(
+ if (state == null && active) {
+ AlphaVisible
+ } else {
+ RelayListTokens.RelayListItemDisabledLabelTextOpacity
+ }
+ ),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+}
+
+@Suppress("MagicNumber")
+@Composable
+internal fun Int.toBackgroundColor(): Color =
+ when (this) {
+ 0 -> MaterialTheme.colorScheme.surfaceContainerHighest
+ 1 -> MaterialTheme.colorScheme.surfaceContainerHigh
+ 2 -> MaterialTheme.colorScheme.surfaceContainerLow
+ else -> MaterialTheme.colorScheme.surfaceContainerLowest
+ }
+
+@Composable
+private fun String.withSuffix(state: RelayListItemState) =
+ when (state) {
+ RelayListItemState.USED_AS_EXIT -> stringResource(R.string.x_exit, this)
+ RelayListItemState.USED_AS_ENTRY -> stringResource(R.string.x_entry, this)
+ }
+
+@Composable
+fun InactiveRelayIndicator(tint: Color) {
+ Box(
+ modifier =
+ Modifier.size(Dimens.listIconSize)
+ .padding(Dimens.relayCirclePadding)
+ .background(color = tint, shape = CircleShape)
+ )
+}
+
+@Composable
+internal fun Modifier.clip(itemPosition: ItemPosition): Modifier {
+ val topCornerSize =
+ animateDpAsState(if (itemPosition.roundTop()) Dimens.relayItemCornerRadius else 0.dp)
+ val bottomCornerSize =
+ animateDpAsState(if (itemPosition.roundBottom()) Dimens.relayItemCornerRadius else 0.dp)
+ return clip(
+ RoundedCornerShape(
+ topStart = CornerSize(topCornerSize.value),
+ topEnd = CornerSize(topCornerSize.value),
+ bottomStart = CornerSize(bottomCornerSize.value),
+ bottomEnd = CornerSize(bottomCornerSize.value),
+ )
+ )
+}
+
+@Composable
+private fun ItemPosition.toShape(): Shape {
+ val topCornerSize = if (roundTop()) Dimens.relayItemCornerRadius else 0.dp
+ val bottomCornerSize = if (roundBottom()) Dimens.relayItemCornerRadius else 0.dp
+ return RoundedCornerShape(
+ topStart = CornerSize(topCornerSize),
+ topEnd = CornerSize(topCornerSize),
+ bottomStart = CornerSize(bottomCornerSize),
+ bottomEnd = CornerSize(bottomCornerSize),
+ )
+}
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/SelectableRelayListItemPreviewParameterProvider.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/SelectableRelayListItemPreviewParameterProvider.kt
new file mode 100644
index 0000000000..732c03bbc4
--- /dev/null
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/SelectableRelayListItemPreviewParameterProvider.kt
@@ -0,0 +1,66 @@
+package net.mullvad.mullvadvpn.lib.ui.component.relaylist
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+
+class SelectableRelayListItemPreviewParameterProvider :
+ PreviewParameterProvider<List<RelayListItem.SelectableItem>> {
+ override val values =
+ sequenceOf(
+ listOf(
+ RelayListItem.GeoLocationItem(
+ item =
+ generateRelayItemCountry(
+ name = "Relay country Active",
+ cityNames = listOf("Relay city 1", "Relay city 2"),
+ relaysPerCity = 2,
+ ),
+ isSelected = true,
+ expanded = false,
+ itemPosition = ItemPosition.Single,
+ ),
+ RelayListItem.GeoLocationItem(
+ item =
+ generateRelayItemCountry(
+ name = "Not Enabled Relay country",
+ cityNames = listOf("Not Enabled city"),
+ relaysPerCity = 1,
+ active = false,
+ ),
+ isSelected = false,
+ itemPosition = ItemPosition.Single,
+ ),
+ RelayListItem.GeoLocationItem(
+ item =
+ generateRelayItemCountry(
+ name = "Relay country Expanded",
+ cityNames = listOf("Normal city"),
+ relaysPerCity = 2,
+ ),
+ isSelected = true,
+ expanded = true,
+ itemPosition = ItemPosition.Single,
+ ),
+ RelayListItem.GeoLocationItem(
+ item =
+ generateRelayItemCountry(
+ name = "Country and city Expanded",
+ cityNames = listOf("Expanded city A", "Expanded city B"),
+ relaysPerCity = 2,
+ ),
+ isSelected = false,
+ itemPosition = ItemPosition.Single,
+ ),
+ RelayListItem.GeoLocationItem(
+ item =
+ generateRelayItemCountry(
+ name = "Country selected but inactive",
+ cityNames = listOf("Expanded city A", "Expanded city B"),
+ relaysPerCity = 2,
+ active = false,
+ ),
+ isSelected = true,
+ itemPosition = ItemPosition.Single,
+ ),
+ )
+ )
+}