diff options
| author | David Göransson <david.goransson@mullvad.net> | 2025-07-04 16:12:32 +0200 |
|---|---|---|
| committer | David Göransson <david.goransson@mullvad.net> | 2025-07-04 16:12:32 +0200 |
| commit | 5300f1663559ebd7a87c699db8e858d13e6fa556 (patch) | |
| tree | 0081e14129def76d6a57b32232e42411c2fbe10d /android/lib/ui/component | |
| parent | 3e5795cbab6866fcd3eafee58d1790fc9e7f1829 (diff) | |
| parent | 0d5660226494abaf04dc619997bf4d6a27c637d8 (diff) | |
| download | mullvadvpn-5300f1663559ebd7a87c699db8e858d13e6fa556.tar.xz mullvadvpn-5300f1663559ebd7a87c699db8e858d13e6fa556.zip | |
Merge branch 'implement-new-select-location-design-droid-1954'
Diffstat (limited to 'android/lib/ui/component')
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, + ), + ) + ) +} |
