summaryrefslogtreecommitdiffhomepage
path: root/android/lib/ui
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
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')
-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
-rw-r--r--android/lib/ui/designsystem/build.gradle.kts43
-rw-r--r--android/lib/ui/designsystem/src/main/AndroidManifest.xml2
-rw-r--r--android/lib/ui/designsystem/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/designsystem/Checkbox.kt56
-rw-r--r--android/lib/ui/designsystem/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/designsystem/RelayListHeader.kt78
-rw-r--r--android/lib/ui/designsystem/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/designsystem/RelayListItem.kt313
14 files changed, 1280 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,
+ ),
+ )
+ )
+}
diff --git a/android/lib/ui/designsystem/build.gradle.kts b/android/lib/ui/designsystem/build.gradle.kts
new file mode 100644
index 0000000000..efc9d0108b
--- /dev/null
+++ b/android/lib/ui/designsystem/build.gradle.kts
@@ -0,0 +1,43 @@
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.compose)
+}
+
+android {
+ namespace = "net.mullvad.mullvadvpn.lib.ui.designsystem"
+ compileSdk = libs.versions.compile.sdk.get().toInt()
+ buildToolsVersion = libs.versions.build.tools.get()
+
+ defaultConfig { minSdk = libs.versions.min.sdk.get().toInt() }
+
+ buildFeatures { compose = true }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = libs.versions.jvm.target.get()
+ allWarningsAsErrors = true
+ }
+
+ lint {
+ lintConfig = file("${rootProject.projectDir}/config/lint.xml")
+ abortOnError = true
+ warningsAsErrors = true
+ }
+}
+
+dependencies {
+ implementation(projects.lib.theme)
+ implementation(projects.lib.model)
+ implementation(projects.lib.ui.tag)
+
+ 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/designsystem/src/main/AndroidManifest.xml b/android/lib/ui/designsystem/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..b2d3ea1235
--- /dev/null
+++ b/android/lib/ui/designsystem/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
diff --git a/android/lib/ui/designsystem/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/designsystem/Checkbox.kt b/android/lib/ui/designsystem/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/designsystem/Checkbox.kt
new file mode 100644
index 0000000000..15e9556e47
--- /dev/null
+++ b/android/lib/ui/designsystem/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/designsystem/Checkbox.kt
@@ -0,0 +1,56 @@
+package net.mullvad.mullvadvpn.lib.ui.designsystem
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material3.Checkbox as Material3Checkbox
+import androidx.compose.material3.CheckboxColors
+import androidx.compose.material3.CheckboxDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+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.theme.color.selected
+
+@Composable
+fun Checkbox(
+ checked: Boolean,
+ onCheckedChange: ((Boolean) -> Unit)?,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ colors: CheckboxColors =
+ CheckboxDefaults.colors(
+ checkedColor = MaterialTheme.colorScheme.onPrimary,
+ uncheckedColor = MaterialTheme.colorScheme.onPrimary,
+ checkmarkColor = MaterialTheme.colorScheme.selected,
+ ),
+ interactionSource: MutableInteractionSource? = null,
+) {
+ Material3Checkbox(
+ checked = checked,
+ onCheckedChange = onCheckedChange,
+ modifier = modifier,
+ enabled = enabled,
+ colors = colors,
+ interactionSource = interactionSource,
+ )
+}
+
+@Preview
+@Composable
+private fun PreviewCheckbox() {
+ AppTheme {
+ Column(
+ Modifier.background(color = MaterialTheme.colorScheme.background),
+ verticalArrangement = Arrangement.spacedBy(Dimens.smallSpacer),
+ ) {
+ Checkbox(checked = false, null)
+ Checkbox(checked = true, null)
+ Checkbox(checked = false, null, enabled = false)
+ Checkbox(checked = true, null, enabled = false)
+ }
+ }
+}
diff --git a/android/lib/ui/designsystem/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/designsystem/RelayListHeader.kt b/android/lib/ui/designsystem/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/designsystem/RelayListHeader.kt
new file mode 100644
index 0000000000..3dc20ff0e7
--- /dev/null
+++ b/android/lib/ui/designsystem/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/designsystem/RelayListHeader.kt
@@ -0,0 +1,78 @@
+package net.mullvad.mullvadvpn.lib.ui.designsystem
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+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 androidx.compose.ui.unit.dp
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+
+private val LIST_HEADER_MIN_HEIGHT = 48.dp
+
+@Composable
+fun RelayListHeader(
+ content: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+ actions: @Composable (RowScope.() -> Unit)? = null,
+) {
+ ProvideContentColorTextStyle(
+ MaterialTheme.colorScheme.onBackground,
+ MaterialTheme.typography.bodyLarge,
+ ) {
+ Row(
+ modifier =
+ Modifier.padding(horizontal = Dimens.tinyPadding)
+ .defaultMinSize(minHeight = LIST_HEADER_MIN_HEIGHT)
+ .height(IntrinsicSize.Min)
+ .then(modifier),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ content()
+ HorizontalDivider(
+ Modifier.weight(1f, true).padding(start = Dimens.smallPadding),
+ color =
+ MaterialTheme.colorScheme.onBackground.copy(
+ alpha = RelayListHeaderTokens.RelayListHeaderDividerAlpha
+ ),
+ )
+ actions?.invoke(this)
+ }
+ }
+}
+
+object RelayListHeaderTokens {
+ const val RelayListHeaderDividerAlpha = 0.2f
+}
+
+@Preview(backgroundColor = 0xFF192E45, showBackground = true)
+@Composable
+fun PreviewRelayListHeader() {
+ AppTheme {
+ Column {
+ RelayListHeader(content = { Text("Header") })
+ RelayListHeader(
+ content = { Text("Header") },
+ actions = {
+ IconButton(onClick = {}) {
+ Icon(imageVector = Icons.Default.Edit, contentDescription = null)
+ }
+ },
+ )
+ }
+ }
+}
diff --git a/android/lib/ui/designsystem/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/designsystem/RelayListItem.kt b/android/lib/ui/designsystem/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/designsystem/RelayListItem.kt
new file mode 100644
index 0000000000..c2e9664a18
--- /dev/null
+++ b/android/lib/ui/designsystem/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/designsystem/RelayListItem.kt
@@ -0,0 +1,313 @@
+package net.mullvad.mullvadvpn.lib.ui.designsystem
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+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.width
+import androidx.compose.foundation.layout.wrapContentHeight
+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.material3.Icon
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+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.graphics.RectangleShape
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.text.TextStyle
+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 net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.color.AlphaInactive
+
+@Composable
+fun RelayListItem(
+ modifier: Modifier = Modifier,
+ selected: Boolean = false,
+ enabled: Boolean = true,
+ onClick: (() -> Unit) = {},
+ onLongClick: (() -> Unit)? = {},
+ leadingContent: @Composable (() -> Unit)? = null,
+ content: @Composable () -> Unit,
+ trailingContent: @Composable (() -> Unit)? = null,
+ colors: RelayListItemColors = RelayListItemDefaults.colors(),
+ shape: Shape = RectangleShape,
+) {
+ Surface(
+ modifier =
+ modifier
+ .defaultMinSize(minHeight = RelayListTokens.listItemMinHeight)
+ .height(IntrinsicSize.Min),
+ shape = shape,
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(RelayListTokens.listItemSpacer),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (leadingContent != null) {
+ Box(
+ Modifier.background(colors.containerColor)
+ .width(RelayListTokens.listItemButtonWidth)
+ .fillMaxHeight(),
+ contentAlignment = Alignment.Center,
+ ) {
+ ProvideContentColorTextStyle(
+ colors.leadingIconColor,
+ MaterialTheme.typography.titleMedium,
+ ) {
+ leadingContent()
+ }
+ }
+ }
+
+ Row(
+ Modifier.weight(1f, fill = true)
+ .background(colors.containerColor)
+ .fillMaxHeight()
+ .combinedClickable(
+ enabled = true,
+ onClick = onClick,
+ onLongClick = onLongClick,
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ ProvideContentColorTextStyle(
+ colors.headlineColor(enabled, selected),
+ MaterialTheme.typography.titleMedium,
+ ) {
+ content()
+ }
+ }
+
+ if (trailingContent != null) {
+ Box(
+ Modifier.background(color = colors.containerColor)
+ .width(RelayListTokens.listItemButtonWidth)
+ .fillMaxHeight()
+ ) {
+ ProvideContentColorTextStyle(
+ colors.trailingIconColor,
+ MaterialTheme.typography.titleMedium,
+ ) {
+ trailingContent()
+ }
+ }
+ }
+ }
+ }
+}
+
+// Based of ListItem
+@Immutable
+class RelayListItemColors(
+ val containerColor: Color,
+ val headlineColor: Color,
+ val leadingIconColor: 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
+ }
+}
+
+@Composable
+internal fun ProvideContentColorTextStyle(
+ contentColor: Color,
+ textStyle: TextStyle,
+ content: @Composable () -> Unit,
+) {
+ val mergedStyle = LocalTextStyle.current.merge(textStyle)
+ CompositionLocalProvider(
+ LocalContentColor provides contentColor,
+ LocalTextStyle provides mergedStyle,
+ content = content,
+ )
+}
+
+object RelayListItemDefaults {
+ @Composable
+ fun colors(
+ containerColor: Color = MaterialTheme.colorScheme.surface,
+ headlineColor: Color = MaterialTheme.colorScheme.onSurface,
+ leadingIconColor: Color = MaterialTheme.colorScheme.onSurface,
+ trailingIconColor: Color = MaterialTheme.colorScheme.onSurface,
+ selectedHeadlineColor: Color = MaterialTheme.colorScheme.tertiary,
+ disabledHeadlineColor: Color =
+ headlineColor.copy(alpha = RelayListTokens.RelayListItemDisabledLabelTextOpacity),
+ ): RelayListItemColors =
+ RelayListItemColors(
+ containerColor = containerColor,
+ headlineColor = headlineColor,
+ leadingIconColor = leadingIconColor,
+ trailingIconColor = trailingIconColor,
+ selectedHeadlineColor = selectedHeadlineColor,
+ disabledHeadlineColor = disabledHeadlineColor,
+ )
+}
+
+object RelayListTokens {
+ const val RelayListItemDisabledLabelTextOpacity = AlphaInactive
+
+ val listItemMinHeight = 56.dp
+ val listItemSpacer = 2.dp
+ val listItemButtonWidth = 56.dp
+}
+
+@Preview
+@PreviewFontScale
+@Composable
+private fun PreviewSimpleRelayListItem() {
+ AppTheme {
+ RelayListItem(
+ modifier = Modifier.fillMaxWidth(),
+ content = { Text("Hello world", modifier = Modifier.padding(16.dp).fillMaxSize()) },
+ )
+ }
+}
+
+@Preview
+@PreviewFontScale
+@Composable
+private fun PreviewLeadingRelayListItem() {
+ AppTheme {
+ RelayListItem(
+ 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,
+ )
+ },
+ leadingContent = {
+ 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 PreviewTrailingRelayListItem() {
+ AppTheme {
+ RelayListItem(
+ modifier = Modifier.fillMaxWidth(),
+ selected = true,
+ content = {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(imageVector = Icons.Default.Check, contentDescription = null)
+ Spacer(Modifier.width(8.dp))
+ Text(
+ "Hello world fsadhkuhfiuskahf iuhsadhuf sa",
+ 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 PreviewLeadingAndTrailingRelayListItem() {
+ AppTheme {
+ RelayListItem(
+ modifier = Modifier.fillMaxWidth(),
+ content = {
+ Text(
+ "Hello world iuhsadhuf sa",
+ modifier = Modifier.clickable {}.padding(16.dp).fillMaxSize(),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ },
+ leadingContent = {
+ Box(
+ modifier = Modifier.fillMaxSize().clickable(onClick = {}),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ modifier = Modifier.padding(16.dp),
+ imageVector = Icons.Default.KeyboardArrowDown,
+ contentDescription = null,
+ )
+ }
+ },
+ trailingContent = {
+ Box(
+ modifier = Modifier.fillMaxSize().clickable(onClick = {}),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ modifier = Modifier.padding(16.dp),
+ imageVector = Icons.Default.Add,
+ contentDescription = null,
+ )
+ }
+ },
+ )
+ }
+}