diff options
Diffstat (limited to 'android/lib/ui')
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, + ) + } + }, + ) + } +} |
