diff options
Diffstat (limited to 'android')
67 files changed, 1338 insertions, 798 deletions
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 32ed53060b..24172279fc 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -348,6 +348,7 @@ dependencies { implementation(projects.lib.shared) implementation(projects.lib.talpid) implementation(projects.lib.tv) + implementation(projects.lib.ui.designsystem) implementation(projects.lib.ui.component) implementation(projects.lib.ui.tag) implementation(projects.tile) diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreenTest.kt index d556fe5d10..a7bed534ba 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreenTest.kt @@ -14,8 +14,8 @@ import net.mullvad.mullvadvpn.compose.data.DUMMY_RELAY_COUNTRIES import net.mullvad.mullvadvpn.compose.setContentWithTheme import net.mullvad.mullvadvpn.compose.state.CustomListLocationsData import net.mullvad.mullvadvpn.compose.state.CustomListLocationsUiState -import net.mullvad.mullvadvpn.compose.state.RelayLocationListItem import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.lib.ui.component.relaylist.CheckableRelayListItem import net.mullvad.mullvadvpn.lib.ui.tag.CIRCULAR_PROGRESS_INDICATOR_TEST_TAG import net.mullvad.mullvadvpn.lib.ui.tag.SAVE_BUTTON_TEST_TAG import net.mullvad.mullvadvpn.util.Lce @@ -104,11 +104,11 @@ class CustomListLocationsScreenTest { CustomListLocationsData( locations = listOf( - RelayLocationListItem( + CheckableRelayListItem( DUMMY_RELAY_COUNTRIES[0], checked = true, ), - RelayLocationListItem( + CheckableRelayListItem( DUMMY_RELAY_COUNTRIES[1], checked = false, ), @@ -141,7 +141,7 @@ class CustomListLocationsScreenTest { CustomListLocationsData( locations = listOf( - RelayLocationListItem(selectedCountry, checked = true) + CheckableRelayListItem(selectedCountry, checked = true) ), searchTerm = "", saveEnabled = false, diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SearchLocationScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SearchLocationScreenTest.kt index 32a91fa61b..7e03afb18c 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SearchLocationScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SearchLocationScreenTest.kt @@ -12,11 +12,11 @@ import io.mockk.verify import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension import net.mullvad.mullvadvpn.compose.data.DUMMY_RELAY_ITEM_CUSTOM_LISTS import net.mullvad.mullvadvpn.compose.setContentWithTheme -import net.mullvad.mullvadvpn.compose.state.RelayListItem import net.mullvad.mullvadvpn.compose.state.SearchLocationUiState import net.mullvad.mullvadvpn.lib.model.CustomListId import net.mullvad.mullvadvpn.lib.model.RelayItem import net.mullvad.mullvadvpn.lib.model.RelayItemId +import net.mullvad.mullvadvpn.lib.ui.component.relaylist.RelayListItem import net.mullvad.mullvadvpn.lib.ui.tag.SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG import net.mullvad.mullvadvpn.util.Lce import org.junit.jupiter.api.AfterEach @@ -44,7 +44,6 @@ class SearchLocationScreenTest { onToggleExpand: (RelayItemId, CustomListId?, Boolean) -> Unit = { _, _, _ -> }, onSearchInputChanged: (String) -> Unit = {}, onCreateCustomList: (location: RelayItem.Location?) -> Unit = {}, - onEditCustomLists: () -> Unit = {}, onAddLocationToList: (location: RelayItem.Location, customList: RelayItem.CustomList) -> Unit = { _, _ -> @@ -67,7 +66,6 @@ class SearchLocationScreenTest { onToggleExpand = onToggleExpand, onSearchInputChanged = onSearchInputChanged, onCreateCustomList = onCreateCustomList, - onEditCustomLists = onEditCustomLists, onAddLocationToList = onAddLocationToList, onRemoveLocationFromList = onRemoveLocationFromList, onEditCustomListName = onEditCustomListName, diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreenTest.kt index e03786aef3..0767fc35ad 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreenTest.kt @@ -15,12 +15,13 @@ import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension import net.mullvad.mullvadvpn.compose.data.DUMMY_RELAY_COUNTRIES import net.mullvad.mullvadvpn.compose.data.DUMMY_RELAY_ITEM_CUSTOM_LISTS import net.mullvad.mullvadvpn.compose.setContentWithTheme -import net.mullvad.mullvadvpn.compose.state.RelayListItem import net.mullvad.mullvadvpn.compose.state.RelayListType import net.mullvad.mullvadvpn.compose.state.SelectLocationListUiState import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState import net.mullvad.mullvadvpn.lib.model.CustomListId import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.lib.ui.component.relaylist.ItemPosition +import net.mullvad.mullvadvpn.lib.ui.component.relaylist.RelayListItem import net.mullvad.mullvadvpn.lib.ui.tag.SELECT_LOCATION_CUSTOM_LIST_BOTTOM_SHEET_TEST_TAG import net.mullvad.mullvadvpn.lib.ui.tag.SELECT_LOCATION_LOCATION_BOTTOM_SHEET_TEST_TAG import net.mullvad.mullvadvpn.performLongClick @@ -110,7 +111,10 @@ class SelectLocationScreenTest { SelectLocationListUiState( relayListItems = DUMMY_RELAY_COUNTRIES.map { - RelayListItem.GeoLocationItem(item = it) + RelayListItem.GeoLocationItem( + item = it, + itemPosition = ItemPosition.Single, + ) }, customLists = emptyList(), ) @@ -250,7 +254,13 @@ class SelectLocationScreenTest { MutableStateFlow( Lce.Content( SelectLocationListUiState( - relayListItems = listOf(RelayListItem.GeoLocationItem(relayItem)), + relayListItems = + listOf( + RelayListItem.GeoLocationItem( + relayItem, + itemPosition = ItemPosition.Single, + ) + ), customLists = emptyList(), ) ) @@ -278,6 +288,6 @@ class SelectLocationScreenTest { } companion object { - private const val CUSTOM_LISTS_EMPTY_TEXT = "To create a custom list press the \"︙\"" + private const val CUSTOM_LISTS_EMPTY_TEXT = "To create a custom list press the \"+\"" } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CheckboxCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CheckboxCell.kt index a3fbd4d94d..633b32e420 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CheckboxCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CheckboxCell.kt @@ -14,9 +14,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp -import net.mullvad.mullvadvpn.compose.component.MullvadCheckbox import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.lib.ui.designsystem.Checkbox @Preview @Composable @@ -46,7 +46,7 @@ internal fun CheckboxCell( .background(background) .padding(start = startPadding, end = endPadding), ) { - MullvadCheckbox(checked = checked, onCheckedChange = onCheckedChange) + Checkbox(checked = checked, onCheckedChange = onCheckedChange) Text( text = title, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt index 536a7ef8b8..a811682767 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt @@ -19,11 +19,11 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.component.ExpandChevronIconButton import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaInactive import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible +import net.mullvad.mullvadvpn.lib.ui.component.ExpandChevronIconButton @Preview @Composable @@ -103,11 +103,7 @@ private fun ExpandableComposeCellBody( } } - ExpandChevronIconButton( - isExpanded = isExpanded, - onExpand = onExpand, - color = MaterialTheme.colorScheme.onPrimary, - ) + ExpandChevronIconButton(isExpanded = isExpanded, onExpand = onExpand) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt deleted file mode 100644 index 45fd0d3bd0..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt +++ /dev/null @@ -1,273 +0,0 @@ -package net.mullvad.mullvadvpn.compose.cell - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -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.material3.VerticalDivider -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.graphics.Color -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 net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.component.ExpandChevron -import net.mullvad.mullvadvpn.compose.component.MullvadCheckbox -import net.mullvad.mullvadvpn.compose.preview.RelayItemCheckableCellPreviewParameterProvider -import net.mullvad.mullvadvpn.compose.state.RelayListItemState -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.theme.color.AlphaInactive -import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible -import net.mullvad.mullvadvpn.lib.theme.color.selected -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 { - CheckableRelayLocationCell( - item = it, - checked = false, - expanded = false, - depth = 0, - onExpand = {}, - modifier = Modifier.testTag(LOCATION_CELL_TEST_TAG), - ) - } - } - } -} - -@Composable -fun StatusRelayItemCell( - item: RelayItem, - isSelected: Boolean, - state: RelayListItemState?, - modifier: Modifier = Modifier, - onClick: () -> Unit, - onLongClick: (() -> Unit)? = null, - onToggleExpand: ((Boolean) -> Unit), - isExpanded: Boolean = false, - depth: Int = 0, - activeColor: Color = MaterialTheme.colorScheme.selected, - inactiveColor: Color = MaterialTheme.colorScheme.error, - disabledColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, -) { - RelayItemCell( - modifier = modifier, - item = item, - isSelected = isSelected, - state = state, - onClick = onClick, - onLongClick = onLongClick, - onToggleExpand = onToggleExpand, - isExpanded = isExpanded, - depth = depth, - content = { - if (isSelected) { - Icon(imageVector = Icons.Default.Check, contentDescription = null) - } else { - Box( - modifier = - Modifier.padding(4.dp) - .size(Dimens.relayCircleSize) - .background( - color = - when { - item is RelayItem.CustomList && item.locations.isEmpty() -> - disabledColor - state != null -> disabledColor - item.active -> activeColor - else -> inactiveColor - }, - shape = CircleShape, - ) - ) - } - }, - ) -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun RelayItemCell( - modifier: Modifier = Modifier, - item: RelayItem, - isSelected: Boolean, - state: RelayListItemState?, - onClick: () -> Unit, - onLongClick: (() -> Unit)? = null, - onToggleExpand: (Boolean) -> Unit, - isExpanded: Boolean, - depth: Int, - content: @Composable (RowScope.() -> Unit)? = null, -) { - - val leadingContentStartPadding = Dimens.cellStartPadding - val leadingContentStarPaddingModifier = Dimens.mediumPadding - val startPadding = leadingContentStartPadding + leadingContentStarPaddingModifier * depth - Row( - modifier = - modifier - .fillMaxWidth() - .height(IntrinsicSize.Min) - .background( - when { - isSelected -> MaterialTheme.colorScheme.selected - else -> depth.toBackgroundColor() - } - ), - verticalAlignment = Alignment.CenterVertically, - ) { - // Duplicate row is needed for selection of the item on TV. - Row( - modifier = - Modifier.combinedClickable( - enabled = state == null && item.active, - onClick = onClick, - onLongClick = onLongClick, - ) - .padding(start = startPadding) - .weight(1f), - verticalAlignment = Alignment.CenterVertically, - ) { - if (content != null) { - content() - } - Name(name = item.name, state = state, active = item.active) - } - - if (item.hasChildren) { - ExpandButton( - color = MaterialTheme.colorScheme.onSurface, - isExpanded = isExpanded, - onClick = { onToggleExpand(!isExpanded) }, - modifier = Modifier.testTag(EXPAND_BUTTON_TEST_TAG), - ) - } - } -} - -@Composable -fun CheckableRelayLocationCell( - item: RelayItem, - modifier: Modifier = Modifier, - checked: Boolean, - onRelayCheckedChange: (isChecked: Boolean) -> Unit = { _ -> }, - expanded: Boolean, - onExpand: (Boolean) -> Unit, - depth: Int, -) { - RelayItemCell( - modifier = modifier, - item = item, - isSelected = false, - state = null, - onClick = { onRelayCheckedChange(!checked) }, - onToggleExpand = onExpand, - isExpanded = expanded, - depth = depth, - content = { - MullvadCheckbox( - checked = checked, - onCheckedChange = { isChecked -> onRelayCheckedChange(isChecked) }, - ) - }, - ) -} - -@Composable -private fun Name( - modifier: Modifier = Modifier, - name: String, - state: RelayListItemState?, - active: Boolean, -) { - Text( - text = state?.let { name.withSuffix(state) } ?: name, - color = MaterialTheme.colorScheme.onSurface, - modifier = - modifier - .alpha( - if (state == null && active) { - AlphaVisible - } else { - AlphaInactive - } - ) - .padding(horizontal = Dimens.smallPadding, vertical = Dimens.mediumPadding), - maxLines = 1, - style = MaterialTheme.typography.bodyLarge, - overflow = TextOverflow.Ellipsis, - ) -} - -@Composable -private fun RowScope.ExpandButton( - modifier: Modifier, - color: Color, - isExpanded: Boolean, - onClick: (expand: Boolean) -> Unit, -) { - VerticalDivider( - color = MaterialTheme.colorScheme.surface, - modifier = Modifier.padding(vertical = Dimens.verticalDividerPadding), - ) - ExpandChevron( - color = color, - isExpanded = isExpanded, - modifier = - modifier - .fillMaxHeight() - .clickable { onClick(!isExpanded) } - .padding(horizontal = Dimens.largePadding) - .align(Alignment.CenterVertically), - ) -} - -@Suppress("MagicNumber") -@Composable -private 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) - } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadCheckbox.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadCheckbox.kt deleted file mode 100644 index 8ff5ae1df1..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadCheckbox.kt +++ /dev/null @@ -1,43 +0,0 @@ -package net.mullvad.mullvadvpn.compose.component - -import androidx.compose.foundation.background -import androidx.compose.material3.Checkbox -import androidx.compose.material3.CheckboxDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.tooling.preview.Preview -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.lib.theme.color.selected - -@Preview -@Composable -private fun PreviewMullvadCheckbox() { - AppTheme { - SpacedColumn(Modifier.background(color = MaterialTheme.colorScheme.primary)) { - MullvadCheckbox(checked = false) {} - MullvadCheckbox(checked = true) {} - } - } -} - -@Composable -fun MullvadCheckbox( - checkedColor: Color = MaterialTheme.colorScheme.onPrimary, - uncheckedColor: Color = MaterialTheme.colorScheme.onPrimary, - checkmarkColor: Color = MaterialTheme.colorScheme.selected, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, -) { - Checkbox( - checked = checked, - onCheckedChange = onCheckedChange, - colors = - CheckboxDefaults.colors( - checkedColor = checkedColor, - uncheckedColor = uncheckedColor, - checkmarkColor = checkmarkColor, - ), - ) -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/CustomListLocationUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/CustomListLocationUiStatePreviewParameterProvider.kt index 514005095b..d17a42d76f 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/CustomListLocationUiStatePreviewParameterProvider.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/CustomListLocationUiStatePreviewParameterProvider.kt @@ -3,8 +3,9 @@ package net.mullvad.mullvadvpn.compose.preview import androidx.compose.ui.tooling.preview.PreviewParameterProvider import net.mullvad.mullvadvpn.compose.state.CustomListLocationsData import net.mullvad.mullvadvpn.compose.state.CustomListLocationsUiState -import net.mullvad.mullvadvpn.compose.state.RelayLocationListItem import net.mullvad.mullvadvpn.lib.model.GeoLocationId +import net.mullvad.mullvadvpn.lib.ui.component.relaylist.CheckableRelayListItem +import net.mullvad.mullvadvpn.lib.ui.component.relaylist.generateRelayItemCountry import net.mullvad.mullvadvpn.util.Lce class CustomListLocationUiStatePreviewParameterProvider : @@ -18,7 +19,7 @@ class CustomListLocationUiStatePreviewParameterProvider : CustomListLocationsData( locations = listOf( - RelayLocationListItem( + CheckableRelayListItem( item = generateRelayItemCountry( name = "A relay", @@ -27,7 +28,7 @@ class CustomListLocationUiStatePreviewParameterProvider : active = true, ) ), - RelayLocationListItem( + CheckableRelayListItem( item = generateRelayItemCountry( name = "Another relay", diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemStatusCellPreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemStatusCellPreviewParameterProvider.kt deleted file mode 100644 index fcb1fdd194..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemStatusCellPreviewParameterProvider.kt +++ /dev/null @@ -1,34 +0,0 @@ -package net.mullvad.mullvadvpn.compose.preview - -import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import net.mullvad.mullvadvpn.lib.model.RelayItem - -class RelayItemStatusCellPreviewParameterProvider : - 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 = "Not Enabled Relay country", - cityNames = listOf("Not Enabled city"), - relaysPerCity = 1, - active = false, - ), - 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/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SearchLocationsUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SearchLocationsUiStatePreviewParameterProvider.kt index 50cf464bb5..c868360c55 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SearchLocationsUiStatePreviewParameterProvider.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SearchLocationsUiStatePreviewParameterProvider.kt @@ -2,6 +2,7 @@ package net.mullvad.mullvadvpn.compose.preview import androidx.compose.ui.tooling.preview.PreviewParameterProvider import net.mullvad.mullvadvpn.compose.state.SearchLocationUiState +import net.mullvad.mullvadvpn.lib.ui.component.relaylist.RelayListItemPreviewData import net.mullvad.mullvadvpn.usecase.FilterChip import net.mullvad.mullvadvpn.util.Lce diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt index 878ab70d9d..82d6736c99 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt @@ -83,7 +83,6 @@ import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.ConnectionButton import net.mullvad.mullvadvpn.compose.button.SwitchLocationButton import net.mullvad.mullvadvpn.compose.component.ConnectionStatusText -import net.mullvad.mullvadvpn.compose.component.ExpandChevron import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge import net.mullvad.mullvadvpn.compose.component.MullvadSnackbar import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBarAndDeviceName @@ -128,6 +127,7 @@ import net.mullvad.mullvadvpn.lib.theme.color.AlphaInvisible import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible import net.mullvad.mullvadvpn.lib.tv.NavigationDrawerTv +import net.mullvad.mullvadvpn.lib.ui.component.ExpandChevron import net.mullvad.mullvadvpn.lib.ui.tag.CONNECT_BUTTON_TEST_TAG import net.mullvad.mullvadvpn.lib.ui.tag.CONNECT_CARD_HEADER_TEST_TAG import net.mullvad.mullvadvpn.lib.ui.tag.RECONNECT_BUTTON_TEST_TAG @@ -581,7 +581,7 @@ private fun ConnectionCardHeader( Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { ConnectionStatusText(state = state.tunnelState) if (state.tunnelState is TunnelState.Connected) { - ExpandChevron(isExpanded = !expanded, color = MaterialTheme.colorScheme.onSurface) + ExpandChevron(isExpanded = !expanded) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreen.kt index 1e81231e7d..6aea279110 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreen.kt @@ -8,10 +8,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -33,7 +32,6 @@ import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.result.ResultBackNavigator import com.ramcosta.composedestinations.result.ResultRecipient import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.cell.CheckableRelayLocationCell import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData import net.mullvad.mullvadvpn.compose.component.EmptyRelayListText import net.mullvad.mullvadvpn.compose.component.LocationsEmptyText @@ -46,6 +44,7 @@ import net.mullvad.mullvadvpn.compose.constant.ContentType import net.mullvad.mullvadvpn.compose.dialog.info.Confirmed import net.mullvad.mullvadvpn.compose.extensions.animateScrollAndCentralizeItem import net.mullvad.mullvadvpn.compose.preview.CustomListLocationUiStatePreviewParameterProvider +import net.mullvad.mullvadvpn.compose.screen.location.positionalPadding import net.mullvad.mullvadvpn.compose.state.CustomListLocationsData import net.mullvad.mullvadvpn.compose.state.CustomListLocationsUiState import net.mullvad.mullvadvpn.compose.textfield.SearchTextField @@ -57,6 +56,7 @@ 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.theme.color.AlphaScrollbar +import net.mullvad.mullvadvpn.lib.ui.component.relaylist.CheckableRelayLocationCell import net.mullvad.mullvadvpn.lib.ui.tag.SAVE_BUTTON_TEST_TAG import net.mullvad.mullvadvpn.util.Lce import net.mullvad.mullvadvpn.viewmodel.CustomListLocationsSideEffect @@ -162,6 +162,7 @@ fun CustomListLocationsScreen( state = lazyListState, color = MaterialTheme.colorScheme.onSurface.copy(alpha = AlphaScrollbar), ) + .padding(horizontal = Dimens.mediumPadding) .fillMaxWidth(), state = lazyListState, ) { @@ -169,9 +170,11 @@ fun CustomListLocationsScreen( is Lce.Loading -> { loading() } + is Lce.Error -> { empty() } + is Lce.Content -> { content( uiState = state.content.value, @@ -231,32 +234,15 @@ private fun LazyListScope.content( LocationsEmptyText(searchTerm = uiState.searchTerm) } } else { - itemsIndexed(uiState.locations, key = { index, listItem -> listItem.item.id }) { - index, - listItem -> - Column(modifier = Modifier.animateItem()) { - if (index != 0) { - HorizontalDivider() - } - CheckableRelayLocationCell( - item = listItem.item, - onRelayCheckedChange = { isChecked -> - onRelaySelectedChanged(listItem.item, isChecked) - }, - checked = listItem.checked, - depth = listItem.depth, - onExpand = { expand -> onExpand(listItem.item, expand) }, - expanded = listItem.expanded, - ) - } + items(uiState.locations, key = { listItem -> listItem.item.id }) { listItem -> + CheckableRelayLocationCell( + modifier = Modifier.animateItem().positionalPadding(listItem.itemPosition), + item = listItem, + onRelayCheckedChange = { isChecked -> + onRelaySelectedChanged(listItem.item, isChecked) + }, + onExpand = { expand -> onExpand(listItem.item, expand) }, + ) } } } - -private fun Lce<Boolean, CustomListLocationsUiState, Boolean>.newList(): Boolean { - return when (this) { - is Lce.Content -> this.value.newList - is Lce.Loading -> this.value - is Lce.Error -> this.error - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/LocationBottomSheet.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/LocationBottomSheet.kt index 46013a4f1a..29870b710b 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/LocationBottomSheet.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/LocationBottomSheet.kt @@ -30,7 +30,6 @@ import net.mullvad.mullvadvpn.compose.cell.IconCell import net.mullvad.mullvadvpn.compose.communication.CustomListAction import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData import net.mullvad.mullvadvpn.compose.component.MullvadModalBottomSheet -import net.mullvad.mullvadvpn.compose.screen.location.LocationBottomSheetState.ShowCustomListsBottomSheet import net.mullvad.mullvadvpn.compose.screen.location.LocationBottomSheetState.ShowCustomListsEntryBottomSheet import net.mullvad.mullvadvpn.compose.screen.location.LocationBottomSheetState.ShowEditCustomListBottomSheet import net.mullvad.mullvadvpn.compose.screen.location.LocationBottomSheetState.ShowLocationBottomSheet @@ -38,9 +37,6 @@ import net.mullvad.mullvadvpn.compose.util.showSnackbarImmediately import net.mullvad.mullvadvpn.lib.model.CustomListId import net.mullvad.mullvadvpn.lib.model.CustomListName import net.mullvad.mullvadvpn.lib.model.RelayItem -import net.mullvad.mullvadvpn.lib.theme.color.AlphaInactive -import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible -import net.mullvad.mullvadvpn.lib.ui.tag.SELECT_LOCATION_CUSTOM_LIST_BOTTOM_SHEET_TEST_TAG import net.mullvad.mullvadvpn.lib.ui.tag.SELECT_LOCATION_LOCATION_BOTTOM_SHEET_TEST_TAG import net.mullvad.mullvadvpn.relaylist.canAddLocation @@ -49,7 +45,6 @@ import net.mullvad.mullvadvpn.relaylist.canAddLocation internal fun LocationBottomSheets( locationBottomSheetState: LocationBottomSheetState?, onCreateCustomList: (RelayItem.Location?) -> Unit, - onEditCustomLists: () -> Unit, onAddLocationToList: (RelayItem.Location, RelayItem.CustomList) -> Unit, onRemoveLocationFromList: (location: RelayItem.Location, parent: CustomListId) -> Unit, onEditCustomListName: (RelayItem.CustomList) -> Unit, @@ -70,17 +65,6 @@ internal fun LocationBottomSheets( val onBackgroundColor: Color = MaterialTheme.colorScheme.onSurface when (locationBottomSheetState) { - is ShowCustomListsBottomSheet -> { - CustomListsBottomSheet( - backgroundColor = backgroundColor, - onBackgroundColor = onBackgroundColor, - sheetState = sheetState, - bottomSheetState = locationBottomSheetState, - onCreateCustomList = { onCreateCustomList(null) }, - onEditCustomLists = onEditCustomLists, - closeBottomSheet = onCloseBottomSheet, - ) - } is ShowLocationBottomSheet -> { LocationBottomSheet( backgroundColor = backgroundColor, @@ -125,62 +109,6 @@ internal fun LocationBottomSheets( @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun CustomListsBottomSheet( - backgroundColor: Color, - onBackgroundColor: Color, - sheetState: SheetState, - bottomSheetState: ShowCustomListsBottomSheet, - onCreateCustomList: () -> Unit, - onEditCustomLists: () -> Unit, - closeBottomSheet: (animate: Boolean) -> Unit, -) { - - MullvadModalBottomSheet( - sheetState = sheetState, - backgroundColor = backgroundColor, - onBackgroundColor = onBackgroundColor, - onDismissRequest = { closeBottomSheet(false) }, - modifier = Modifier.testTag(SELECT_LOCATION_CUSTOM_LIST_BOTTOM_SHEET_TEST_TAG), - ) { - HeaderCell( - text = stringResource(id = R.string.edit_custom_lists), - background = backgroundColor, - ) - HorizontalDivider(color = onBackgroundColor) - IconCell( - imageVector = Icons.Default.Add, - title = stringResource(id = R.string.new_list), - titleColor = onBackgroundColor, - onClick = { - onCreateCustomList() - closeBottomSheet(true) - }, - background = backgroundColor, - ) - IconCell( - imageVector = Icons.Default.Edit, - title = stringResource(id = R.string.edit_lists), - titleColor = - onBackgroundColor.copy( - alpha = - if (bottomSheetState.editListEnabled) { - AlphaVisible - } else { - AlphaInactive - } - ), - onClick = { - onEditCustomLists() - closeBottomSheet(true) - }, - background = backgroundColor, - enabled = bottomSheetState.editListEnabled, - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable private fun LocationBottomSheet( backgroundColor: Color, onBackgroundColor: Color, @@ -407,9 +335,6 @@ internal fun <D : DestinationSpec, R : CustomListActionResultData> ResultRecipie } sealed interface LocationBottomSheetState { - - data class ShowCustomListsBottomSheet(val editListEnabled: Boolean) : LocationBottomSheetState - data class ShowCustomListsEntryBottomSheet( val customListId: CustomListId, val customListName: CustomListName, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/RelayListContent.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/RelayListContent.kt index 0b5617fc7e..a39270921e 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/RelayListContent.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/RelayListContent.kt @@ -2,105 +2,81 @@ package net.mullvad.mullvadvpn.compose.screen.location import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.HorizontalDivider +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Edit +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.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.cell.HeaderCell -import net.mullvad.mullvadvpn.compose.cell.StatusRelayItemCell import net.mullvad.mullvadvpn.compose.cell.SwitchComposeSubtitleCell -import net.mullvad.mullvadvpn.compose.cell.ThreeDotCell import net.mullvad.mullvadvpn.compose.component.EmptyRelayListText import net.mullvad.mullvadvpn.compose.component.LocationsEmptyText -import net.mullvad.mullvadvpn.compose.screen.location.LocationBottomSheetState.ShowCustomListsBottomSheet import net.mullvad.mullvadvpn.compose.screen.location.LocationBottomSheetState.ShowCustomListsEntryBottomSheet import net.mullvad.mullvadvpn.compose.screen.location.LocationBottomSheetState.ShowEditCustomListBottomSheet import net.mullvad.mullvadvpn.compose.screen.location.LocationBottomSheetState.ShowLocationBottomSheet -import net.mullvad.mullvadvpn.compose.state.RelayListItem import net.mullvad.mullvadvpn.lib.model.CustomListId import net.mullvad.mullvadvpn.lib.model.RelayItem import net.mullvad.mullvadvpn.lib.model.RelayItemId +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.lib.ui.component.relaylist.ItemPosition +import net.mullvad.mullvadvpn.lib.ui.component.relaylist.RelayListItem +import net.mullvad.mullvadvpn.lib.ui.component.relaylist.SelectableRelayListItem +import net.mullvad.mullvadvpn.lib.ui.designsystem.RelayListHeader import net.mullvad.mullvadvpn.lib.ui.tag.LOCATION_CELL_TEST_TAG import net.mullvad.mullvadvpn.lib.ui.tag.SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG /** Used by both the select location screen and search select location screen */ fun LazyListScope.relayListContent( - backgroundColor: Color, relayListItems: List<RelayListItem>, customLists: List<RelayItem.CustomList>, onSelectRelay: (RelayItem) -> Unit, onToggleExpand: (RelayItemId, CustomListId?, Boolean) -> Unit, onUpdateBottomSheetState: (LocationBottomSheetState) -> Unit, - customListHeader: @Composable LazyItemScope.() -> Unit = { - CustomListHeader( - onShowCustomListBottomSheet = { - onUpdateBottomSheetState( - ShowCustomListsBottomSheet(editListEnabled = customLists.isNotEmpty()) - ) - } - ) - }, - locationHeader: @Composable LazyItemScope.() -> Unit = { RelayLocationHeader() }, + customListHeader: @Composable (LazyItemScope.() -> Unit) = {}, + locationHeader: @Composable (LazyItemScope.() -> Unit) = { RelayLocationHeader() }, ) { - itemsIndexed( + items( items = relayListItems, - key = { _: Int, item: RelayListItem -> item.key }, - contentType = { _, item -> item.contentType }, - itemContent = { index: Int, listItem: RelayListItem -> + key = { item: RelayListItem -> item.key }, + contentType = { item: RelayListItem -> item.contentType }, + itemContent = { listItem: RelayListItem -> Column(modifier = Modifier.animateItem()) { - if (index != 0) { - HorizontalDivider(color = backgroundColor) - } when (listItem) { RelayListItem.CustomListHeader -> customListHeader() is RelayListItem.CustomListItem -> CustomListItem( listItem, - onSelectRelay, - { onUpdateBottomSheetState(ShowEditCustomListBottomSheet(it)) }, - { customListId, expand -> onToggleExpand(customListId, null, expand) }, + onSelectRelay = onSelectRelay, + onToggleExpand = onToggleExpand, + onUpdateBottomSheetState = onUpdateBottomSheetState, ) is RelayListItem.CustomListEntryItem -> CustomListEntryItem( listItem, - { onSelectRelay(listItem.item) }, - // Only direct children can be removed - if (listItem.depth == 1) { - { - onUpdateBottomSheetState( - ShowCustomListsEntryBottomSheet( - listItem.parentId, - listItem.parentName, - listItem.item, - ) - ) - } - } else { - null - }, - { expand: Boolean -> - onToggleExpand(listItem.item.id, listItem.parentId, expand) - }, + onSelectRelay = onSelectRelay, + onToggleExpand = onToggleExpand, + onUpdateBottomSheetState = onUpdateBottomSheetState, ) is RelayListItem.CustomListFooter -> CustomListFooter(listItem) RelayListItem.LocationHeader -> locationHeader() is RelayListItem.GeoLocationItem -> - RelayLocationItem( + GeoLocationItem( listItem, - { onSelectRelay(listItem.item) }, - { - onUpdateBottomSheetState( - ShowLocationBottomSheet(customLists, listItem.item) - ) - }, - { expand -> onToggleExpand(listItem.item.id, null, expand) }, + onSelectRelay = onSelectRelay, + onToggleExpand = onToggleExpand, + onUpdateBottomSheetState = onUpdateBottomSheetState, + customLists = customLists, ) is RelayListItem.LocationsEmptyText -> LocationsEmptyText(listItem.searchTerm) is RelayListItem.EmptyRelayList -> EmptyRelayListText() @@ -111,76 +87,107 @@ fun LazyListScope.relayListContent( } @Composable -private fun LazyItemScope.RelayLocationItem( - relayItem: RelayListItem.GeoLocationItem, - onSelectRelay: () -> Unit, - onLongClick: () -> Unit, - onExpand: (Boolean) -> Unit, +fun Modifier.positionalPadding(itemPosition: ItemPosition): Modifier = + when (itemPosition) { + ItemPosition.Top, + ItemPosition.Single -> padding(top = Dimens.miniPadding) + ItemPosition.Middle -> padding(top = Dimens.listItemDivider) + ItemPosition.Bottom -> padding(top = Dimens.listItemDivider, bottom = Dimens.miniPadding) + } + +@Composable +private fun GeoLocationItem( + listItem: RelayListItem.GeoLocationItem, + onSelectRelay: (RelayItem) -> Unit, + onToggleExpand: (RelayItemId, CustomListId?, Boolean) -> Unit, + onUpdateBottomSheetState: (LocationBottomSheetState) -> Unit, + customLists: List<RelayItem.CustomList>, ) { - val location = relayItem.item - StatusRelayItemCell( - item = location, - state = relayItem.state, - isSelected = relayItem.isSelected, - onClick = { onSelectRelay() }, - onLongClick = { onLongClick() }, - onToggleExpand = { onExpand(it) }, - isExpanded = relayItem.expanded, - depth = relayItem.depth, - modifier = Modifier.testTag(LOCATION_CELL_TEST_TAG), + SelectableRelayListItem( + relayListItem = listItem, + onClick = { onSelectRelay(listItem.item) }, + onLongClick = { + onUpdateBottomSheetState(ShowLocationBottomSheet(customLists, listItem.item)) + }, + onToggleExpand = { onToggleExpand(listItem.item.id, null, it) }, + modifier = Modifier.positionalPadding(listItem.itemPosition).testTag(LOCATION_CELL_TEST_TAG), ) } @Composable -private fun LazyItemScope.CustomListEntryItem( - itemState: RelayListItem.CustomListEntryItem, - onSelectRelay: () -> Unit, - onShowEditCustomListEntryBottomSheet: (() -> Unit)?, - onToggleExpand: (Boolean) -> Unit, +private fun CustomListItem( + listItem: RelayListItem.CustomListItem, + onSelectRelay: (RelayItem) -> Unit, + onToggleExpand: (RelayItemId, CustomListId?, Boolean) -> Unit, + onUpdateBottomSheetState: (LocationBottomSheetState) -> Unit, ) { - val customListEntryItem = itemState.item - StatusRelayItemCell( - item = customListEntryItem, - state = itemState.state, - isSelected = false, - onClick = onSelectRelay, - onLongClick = onShowEditCustomListEntryBottomSheet, - onToggleExpand = onToggleExpand, - isExpanded = itemState.expanded, - depth = itemState.depth, + SelectableRelayListItem( + relayListItem = listItem, + onClick = { onSelectRelay(listItem.item) }, + onLongClick = { onUpdateBottomSheetState(ShowEditCustomListBottomSheet(listItem.item)) }, + onToggleExpand = { onToggleExpand(listItem.item.id, null, it) }, + modifier = Modifier.positionalPadding(listItem.itemPosition), ) } @Composable -private fun LazyItemScope.CustomListItem( - itemState: RelayListItem.CustomListItem, - onSelectRelay: (item: RelayItem) -> Unit, - onShowEditBottomSheet: (RelayItem.CustomList) -> Unit, - onExpand: ((CustomListId, Boolean) -> Unit), +private fun CustomListEntryItem( + listItem: RelayListItem.CustomListEntryItem, + onSelectRelay: (RelayItem) -> Unit, + onToggleExpand: (RelayItemId, CustomListId?, Boolean) -> Unit, + onUpdateBottomSheetState: (LocationBottomSheetState) -> Unit, ) { - val customListItem = itemState.item - StatusRelayItemCell( - item = customListItem, - state = itemState.state, - isSelected = itemState.isSelected, - onClick = { onSelectRelay(customListItem) }, - onLongClick = { onShowEditBottomSheet(customListItem) }, - onToggleExpand = { onExpand(customListItem.id, it) }, - isExpanded = itemState.expanded, + SelectableRelayListItem( + relayListItem = listItem, + onClick = { onSelectRelay(listItem.item) }, + // Only direct children can be removed + onLongClick = + if (listItem.depth == 1) { + { + onUpdateBottomSheetState( + ShowCustomListsEntryBottomSheet( + listItem.parentId, + listItem.parentName, + listItem.item, + ) + ) + } + } else { + null + }, + onToggleExpand = { expand: Boolean -> + onToggleExpand(listItem.item.id, listItem.parentId, expand) + }, + modifier = Modifier.positionalPadding(listItem.itemPosition), ) } @Composable -private fun LazyItemScope.CustomListHeader(onShowCustomListBottomSheet: () -> Unit) { - ThreeDotCell( - text = stringResource(R.string.custom_lists), - onClickDots = onShowCustomListBottomSheet, +fun CustomListHeader(addCustomList: () -> Unit, editCustomLists: (() -> Unit)?) { + RelayListHeader( + { Text(stringResource(R.string.custom_lists), overflow = TextOverflow.Ellipsis) }, + actions = { + IconButton(onClick = addCustomList) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(id = R.string.new_list), + ) + } + editCustomLists?.run { + IconButton(onClick = editCustomLists) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = stringResource(id = R.string.edit_lists), + ) + } + } + }, modifier = Modifier.testTag(SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG), ) } @Composable -private fun LazyItemScope.CustomListFooter(item: RelayListItem.CustomListFooter) { +private fun CustomListFooter(item: RelayListItem.CustomListFooter) { SwitchComposeSubtitleCell( text = if (item.hasCustomList) { @@ -193,6 +200,10 @@ private fun LazyItemScope.CustomListFooter(item: RelayListItem.CustomListFooter) } @Composable -private fun LazyItemScope.RelayLocationHeader() { - HeaderCell(text = stringResource(R.string.all_locations)) +private fun RelayLocationHeader() { + RelayListHeader( + content = { + Text(text = stringResource(R.string.all_locations), overflow = TextOverflow.Ellipsis) + } + ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SearchLocationScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SearchLocationScreen.kt index f095160107..ea88ada52b 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SearchLocationScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SearchLocationScreen.kt @@ -45,7 +45,6 @@ import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.generated.destinations.CreateCustomListDestination import com.ramcosta.composedestinations.generated.destinations.CustomListLocationsDestination -import com.ramcosta.composedestinations.generated.destinations.CustomListsDestination import com.ramcosta.composedestinations.generated.destinations.DeleteCustomListDestination import com.ramcosta.composedestinations.generated.destinations.EditCustomListNameDestination import com.ramcosta.composedestinations.navigation.DestinationsNavigator @@ -93,7 +92,6 @@ private fun PreviewSearchLocationScreen( { _, _, _ -> }, {}, {}, - {}, { _, _ -> }, { _, _ -> }, {}, @@ -150,6 +148,15 @@ fun SearchLocation( message = context.getString(R.string.error_occurred) ) } + + is SearchLocationSideEffect.RelayItemInactive -> { + launch { + snackbarHostState.showSnackbarImmediately( + message = + context.getString(R.string.relayitem_is_inactive, it.relayItem.name) + ) + } + } } } @@ -183,7 +190,6 @@ fun SearchLocation( dropUnlessResumed { relayItem -> navigator.navigate(CreateCustomListDestination(locationCode = relayItem?.id)) }, - onEditCustomLists = dropUnlessResumed { navigator.navigate(CustomListsDestination()) }, onAddLocationToList = viewModel::addLocationToList, onRemoveLocationFromList = viewModel::removeLocationFromList, onEditCustomListName = @@ -226,7 +232,6 @@ fun SearchLocationScreen( onToggleExpand: (RelayItemId, CustomListId?, Boolean) -> Unit, onSearchInputChanged: (String) -> Unit, onCreateCustomList: (location: RelayItem.Location?) -> Unit, - onEditCustomLists: () -> Unit, onAddLocationToList: (location: RelayItem.Location, customList: RelayItem.CustomList) -> Unit, onRemoveLocationFromList: (location: RelayItem.Location, customListId: CustomListId) -> Unit, onEditCustomListName: (RelayItem.CustomList) -> Unit, @@ -251,7 +256,6 @@ fun SearchLocationScreen( LocationBottomSheets( locationBottomSheetState = locationBottomSheetState, onCreateCustomList = onCreateCustomList, - onEditCustomLists = onEditCustomLists, onAddLocationToList = onAddLocationToList, onRemoveLocationFromList = onRemoveLocationFromList, onEditCustomListName = onEditCustomListName, @@ -277,6 +281,7 @@ fun SearchLocationScreen( LazyColumn( modifier = Modifier.fillMaxSize() + .padding(horizontal = Dimens.mediumPadding) .background(color = backgroundColor) .drawVerticalScrollbar( lazyListState, @@ -301,9 +306,8 @@ fun SearchLocationScreen( } is Lce.Content -> { relayListContent( - backgroundColor = backgroundColor, - customLists = state.value.customLists, relayListItems = state.value.relayListItems, + customLists = state.value.customLists, onSelectRelay = onSelectRelay, onToggleExpand = onToggleExpand, onUpdateBottomSheetState = { newSheetState -> diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationList.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationList.kt index bf3db9a534..4ef79723c0 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationList.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationList.kt @@ -14,7 +14,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.style.TextAlign @@ -26,13 +25,13 @@ import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicator import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar import net.mullvad.mullvadvpn.compose.constant.ContentType import net.mullvad.mullvadvpn.compose.extensions.animateScrollAndCentralizeItem -import net.mullvad.mullvadvpn.compose.state.RelayListItem import net.mullvad.mullvadvpn.compose.state.RelayListType import net.mullvad.mullvadvpn.compose.state.SelectLocationListUiState import net.mullvad.mullvadvpn.compose.util.RunOnKeyChange import net.mullvad.mullvadvpn.lib.model.RelayItem import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar +import net.mullvad.mullvadvpn.lib.ui.component.relaylist.RelayListItem import net.mullvad.mullvadvpn.util.Lce import net.mullvad.mullvadvpn.viewmodel.location.SelectLocationListViewModel import org.koin.androidx.compose.koinViewModel @@ -44,10 +43,11 @@ private typealias Content = Lce.Content<SelectLocationListUiState> @Composable fun SelectLocationList( - backgroundColor: Color, relayListType: RelayListType, onSelectRelay: (RelayItem) -> Unit, openDaitaSettings: () -> Unit, + onAddCustomList: () -> Unit, + onEditCustomLists: (() -> Unit)?, onUpdateBottomSheetState: (LocationBottomSheetState) -> Unit, ) { val viewModel = @@ -67,6 +67,7 @@ fun SelectLocationList( LazyColumn( modifier = Modifier.fillMaxSize() + .padding(horizontal = Dimens.mediumPadding) .drawVerticalScrollbar( lazyListState, MaterialTheme.colorScheme.onSurface.copy(alpha = AlphaScrollbar), @@ -89,12 +90,18 @@ fun SelectLocationList( } is Content -> { relayListContent( - backgroundColor = backgroundColor, relayListItems = stateActual.value.relayListItems, customLists = stateActual.value.customLists, onSelectRelay = onSelectRelay, onToggleExpand = viewModel::onToggleExpand, onUpdateBottomSheetState = onUpdateBottomSheetState, + customListHeader = { + CustomListHeader( + onAddCustomList, + if (stateActual.value.customLists.isNotEmpty()) onEditCustomLists + else null, + ) + }, ) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreen.kt index ec8c25207b..7429b39324 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreen.kt @@ -31,7 +31,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag @@ -152,6 +151,14 @@ fun SelectLocation( message = context.getString(R.string.error_occurred) ) } + + is SelectLocationSideEffect.RelayItemInactive -> + launch { + snackbarHostState.showSnackbarImmediately( + message = + context.getString(R.string.relayitem_is_inactive, it.relayItem.name) + ) + } } } @@ -295,7 +302,6 @@ fun SelectLocationScreen( LocationBottomSheets( locationBottomSheetState = locationBottomSheetState, onCreateCustomList = onCreateCustomList, - onEditCustomLists = onEditCustomLists, onAddLocationToList = onAddLocationToList, onRemoveLocationFromList = onRemoveLocationFromList, onEditCustomListName = onEditCustomListName, @@ -341,9 +347,10 @@ fun SelectLocationScreen( RelayLists( state = state.value, - backgroundColor = backgroundColor, onSelectRelay = onSelectRelay, openDaitaSettings = openDaitaSettings, + onAddCustomList = { onCreateCustomList(null) }, + onEditCustomLists = onEditCustomLists, onUpdateBottomSheetState = { newState -> locationBottomSheetState = newState }, @@ -381,9 +388,10 @@ private fun MultihopBar(relayListType: RelayListType, onSelectRelayList: (RelayL @Composable private fun RelayLists( state: SelectLocationUiState, - backgroundColor: Color, onSelectRelay: (RelayItem) -> Unit, openDaitaSettings: () -> Unit, + onAddCustomList: () -> Unit, + onEditCustomLists: (() -> Unit)?, onUpdateBottomSheetState: (LocationBottomSheetState) -> Unit, ) { // This is a workaround for the HorizontalPager being broken on Android TV when it contains @@ -392,10 +400,11 @@ private fun RelayLists( if (configuration.navigation == Configuration.NAVIGATION_DPAD) { SelectLocationList( - backgroundColor = backgroundColor, relayListType = state.relayListType, onSelectRelay = onSelectRelay, openDaitaSettings = openDaitaSettings, + onAddCustomList = onAddCustomList, + onEditCustomLists = onEditCustomLists, onUpdateBottomSheetState = onUpdateBottomSheetState, ) } else { @@ -420,10 +429,11 @@ private fun RelayLists( }, ) { pageIndex -> SelectLocationList( - backgroundColor = backgroundColor, relayListType = RelayListType.entries[pageIndex], onSelectRelay = onSelectRelay, openDaitaSettings = openDaitaSettings, + onAddCustomList = onAddCustomList, + onEditCustomLists = onEditCustomLists, onUpdateBottomSheetState = onUpdateBottomSheetState, ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListLocationsUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListLocationsUiState.kt index 9f8eae8c53..527bad85fa 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListLocationsUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListLocationsUiState.kt @@ -1,6 +1,6 @@ package net.mullvad.mullvadvpn.compose.state -import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.lib.ui.component.relaylist.CheckableRelayListItem import net.mullvad.mullvadvpn.util.Lce data class CustomListLocationsUiState( @@ -12,12 +12,5 @@ data class CustomListLocationsData( val saveEnabled: Boolean, val hasUnsavedChanges: Boolean, val searchTerm: String, - val locations: List<RelayLocationListItem>, -) - -data class RelayLocationListItem( - val item: RelayItem.Location, - val depth: Int = 0, - val checked: Boolean = false, - val expanded: Boolean = false, + val locations: List<CheckableRelayListItem>, ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SearchLocationUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SearchLocationUiState.kt index c377be8814..909e4ea8eb 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SearchLocationUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SearchLocationUiState.kt @@ -1,6 +1,7 @@ package net.mullvad.mullvadvpn.compose.state import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.lib.ui.component.relaylist.RelayListItem import net.mullvad.mullvadvpn.usecase.FilterChip data class SearchLocationUiState( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationListUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationListUiState.kt index 393286a35e..39199b9d04 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationListUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationListUiState.kt @@ -1,6 +1,7 @@ package net.mullvad.mullvadvpn.compose.state import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.lib.ui.component.relaylist.RelayListItem data class SelectLocationListUiState( val relayListItems: List<RelayListItem>, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModel.kt index baadf379cb..8cee6c7423 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModel.kt @@ -21,9 +21,10 @@ import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData import net.mullvad.mullvadvpn.compose.communication.LocationsChanged import net.mullvad.mullvadvpn.compose.state.CustomListLocationsData import net.mullvad.mullvadvpn.compose.state.CustomListLocationsUiState -import net.mullvad.mullvadvpn.compose.state.RelayLocationListItem import net.mullvad.mullvadvpn.lib.model.RelayItem import net.mullvad.mullvadvpn.lib.model.RelayItemId +import net.mullvad.mullvadvpn.lib.ui.component.relaylist.CheckableRelayListItem +import net.mullvad.mullvadvpn.lib.ui.component.relaylist.ItemPosition import net.mullvad.mullvadvpn.relaylist.ancestors import net.mullvad.mullvadvpn.relaylist.descendants import net.mullvad.mullvadvpn.relaylist.newFilterOnSearch @@ -62,11 +63,13 @@ class CustomListLocationsViewModel( newList = navArgs.newList, content = Lce.Loading(Unit), ) + relayCountries.isEmpty() -> CustomListLocationsUiState( newList = navArgs.newList, content = Lce.Error(Unit), ) + else -> { val (expandSet, filteredRelayCountries) = searchRelayListLocations(searchTerm, relayCountries) @@ -78,10 +81,13 @@ class CustomListLocationsViewModel( CustomListLocationsData( searchTerm = searchTerm, locations = - filteredRelayCountries.toRelayItems( - isSelected = { it in selectedLocations }, - isExpanded = { it in expandedLocations }, - ), + filteredRelayCountries.flatMap { + it.toRelayItems( + isSelected = { it in selectedLocations }, + isExpanded = { it in expandedLocations }, + isLastChild = true, + ) + }, saveEnabled = selectedLocations.isNotEmpty() && selectedLocations != _initialLocations.value, @@ -190,6 +196,7 @@ class CustomListLocationsViewModel( .find { it.id == relayItem.id.country } ?.let { updateSelectionList.remove(it) } } + is RelayItem.Location.Relay -> { availableLocations .flatMap { country -> country.cities } @@ -199,6 +206,7 @@ class CustomListLocationsViewModel( .find { it.id == relayItem.id.country } ?.let { updateSelectionList.remove(it) } } + is RelayItem.Location.Country -> { /* Do nothing */ } @@ -216,9 +224,11 @@ class CustomListLocationsViewModel( saveSelectionList.removeAll(relayItem.cities) saveSelectionList.removeAll(relayItem.relays) } + is RelayItem.Location.City -> { saveSelectionList.removeAll(relayItem.relays) } + is RelayItem.Location.Relay -> { /* Do nothing */ } @@ -240,42 +250,56 @@ class CustomListLocationsViewModel( private fun initialExpands(locations: List<RelayItem.Location>): Set<RelayItemId> = locations.flatMap { it.id.ancestors() }.toSet() - private fun List<RelayItem.Location>.toRelayItems( + private fun RelayItem.Location.toRelayItems( isSelected: (RelayItem) -> Boolean, isExpanded: (RelayItemId) -> Boolean, depth: Int = 0, - ): List<RelayLocationListItem> = flatMap { relayItem -> - buildList { - val expanded = isExpanded(relayItem.id) - add( - RelayLocationListItem( - item = relayItem, - depth = depth, - checked = isSelected(relayItem), - expanded = expanded, - ) + isLastChild: Boolean, + ): List<CheckableRelayListItem> = buildList { + val expanded = isExpanded(id) + add( + CheckableRelayListItem( + item = this@toRelayItems, + depth = depth, + checked = isSelected(this@toRelayItems), + expanded = expanded, + itemPosition = + when { + this@toRelayItems is RelayItem.Location.Country -> + if (!expanded) ItemPosition.Single else ItemPosition.Top + isLastChild && !expanded -> ItemPosition.Bottom + else -> ItemPosition.Middle + }, ) - if (expanded) { - when (relayItem) { - is RelayItem.Location.City -> - addAll( - relayItem.relays.toRelayItems( + ) + if (expanded) { + when (this@toRelayItems) { + is RelayItem.Location.City -> + addAll( + relays.flatMapIndexed { index, relay -> + relay.toRelayItems( isSelected = isSelected, isExpanded = isExpanded, depth = depth + 1, + isLastChild = isLastChild && index == relays.lastIndex, ) - ) - is RelayItem.Location.Country -> - addAll( - relayItem.cities.toRelayItems( + } + ) + + is RelayItem.Location.Country -> + addAll( + cities.flatMapIndexed { index, item -> + item.toRelayItems( isSelected = isSelected, isExpanded = isExpanded, depth = depth + 1, + isLastChild = isLastChild && index == cities.lastIndex, ) - ) - is RelayItem.Location.Relay -> { - /* Do nothing */ - } + } + ) + + is RelayItem.Location.Relay -> { + /* Do nothing */ } } } @@ -299,6 +323,7 @@ class CustomListLocationsViewModel( relayListRepository.find(success.addedLocations.first())!!.name, undo = success.undo, ) + success.removedLocations.size == 1 && success.addedLocations.isEmpty() -> CustomListActionResultData.Success.LocationRemoved( customListName = success.name, @@ -306,6 +331,7 @@ class CustomListLocationsViewModel( relayListRepository.find(success.removedLocations.first())!!.name, undo = success.undo, ) + else -> CustomListActionResultData.Success.LocationChanged( customListName = success.name, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/RelayItemListCreator.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/RelayItemListCreator.kt index d80b8fc548..85bd7b282f 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/RelayItemListCreator.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/RelayItemListCreator.kt @@ -1,13 +1,14 @@ package net.mullvad.mullvadvpn.viewmodel.location -import net.mullvad.mullvadvpn.compose.state.RelayListItem -import net.mullvad.mullvadvpn.compose.state.RelayListItemState import net.mullvad.mullvadvpn.compose.state.RelayListType import net.mullvad.mullvadvpn.lib.model.CustomListId import net.mullvad.mullvadvpn.lib.model.GeoLocationId import net.mullvad.mullvadvpn.lib.model.RelayItem import net.mullvad.mullvadvpn.lib.model.RelayItemId import net.mullvad.mullvadvpn.lib.model.RelayItemSelection +import net.mullvad.mullvadvpn.lib.ui.component.relaylist.ItemPosition +import net.mullvad.mullvadvpn.lib.ui.component.relaylist.RelayListItem +import net.mullvad.mullvadvpn.lib.ui.component.relaylist.RelayListItemState import net.mullvad.mullvadvpn.relaylist.filterOnSearchTerm // Creates a relay list to be displayed by RelayListContent @@ -176,19 +177,26 @@ private fun createCustomListRelayItems( selectedByOtherId = selectedByOtherEntryExitList, ), expanded = expanded, + itemPosition = + if (expanded) { + ItemPosition.Top + } else { + ItemPosition.Single + }, ) ) if (expanded) { addAll( - customList.locations.flatMap { + customList.locations.flatMapIndexed { index, item -> createCustomListEntry( parent = customList, - item = it, + item = item, relayListType = relayListType, selectedByOtherEntryExitList = selectedByOtherEntryExitList, depth = 1, isExpanded = isExpanded, + isLast = index == customList.locations.lastIndex, ) } ) @@ -205,13 +213,14 @@ private fun createLocationSection( ): List<RelayListItem> = buildList { add(RelayListItem.LocationHeader) addAll( - countries.flatMap { country -> + countries.flatMapIndexed { index, country -> createGeoLocationEntry( item = country, selectedByThisEntryExitList = selectedByThisEntryExitList, relayListType = relayListType, selectedByOtherEntryExitList = selectedByOtherEntryExitList, isExpanded = isExpanded, + isLast = true, ) } ) @@ -234,6 +243,7 @@ private fun createLocationSectionSearching( relayListType = relayListType, selectedByOtherEntryExitList = selectedByOtherEntryExitList, isExpanded = isExpanded, + isLast = true, ) } ) @@ -247,6 +257,7 @@ private fun createCustomListEntry( selectedByOtherEntryExitList: RelayItemId?, depth: Int = 1, isExpanded: (String) -> Boolean, + isLast: Boolean, ): List<RelayListItem.CustomListEntryItem> = buildList { val expanded = isExpanded(item.id.expandKey(parent.id)) add( @@ -261,6 +272,12 @@ private fun createCustomListEntry( ), expanded = expanded, depth = depth, + itemPosition = + if (!expanded && isLast) { + ItemPosition.Bottom + } else { + ItemPosition.Middle + }, ) ) @@ -268,27 +285,29 @@ private fun createCustomListEntry( when (item) { is RelayItem.Location.City -> addAll( - item.relays.flatMap { + item.relays.flatMapIndexed { index, relay -> createCustomListEntry( parent = parent, - item = it, + item = relay, relayListType = relayListType, selectedByOtherEntryExitList = selectedByOtherEntryExitList, depth = depth + 1, isExpanded = isExpanded, + isLast = isLast && index == item.relays.lastIndex, ) } ) is RelayItem.Location.Country -> addAll( - item.cities.flatMap { + item.cities.flatMapIndexed { index, city -> createCustomListEntry( parent = parent, - item = it, + item = city, relayListType = relayListType, selectedByOtherEntryExitList = selectedByOtherEntryExitList, depth = depth + 1, isExpanded = isExpanded, + isLast = isLast && index == item.cities.lastIndex, ) } ) @@ -304,6 +323,7 @@ private fun createGeoLocationEntry( selectedByOtherEntryExitList: RelayItemId?, depth: Int = 0, isExpanded: (String) -> Boolean, + isLast: Boolean, ): List<RelayListItem.GeoLocationItem> = buildList { val expanded = isExpanded(item.id.expandKey()) @@ -318,6 +338,24 @@ private fun createGeoLocationEntry( ), depth = depth, expanded = expanded, + itemPosition = + when (item) { + is RelayItem.Location.Country -> { + if (expanded) { + ItemPosition.Top + } else { + ItemPosition.Single + } + } + + else -> { + if (isLast && !expanded) { + ItemPosition.Bottom + } else { + ItemPosition.Middle + } + } + }, ) ) @@ -325,27 +363,29 @@ private fun createGeoLocationEntry( when (item) { is RelayItem.Location.City -> addAll( - item.relays.flatMap { + item.relays.flatMapIndexed { index, relay -> createGeoLocationEntry( - item = it, + item = relay, relayListType = relayListType, selectedByThisEntryExitList = selectedByThisEntryExitList, selectedByOtherEntryExitList = selectedByOtherEntryExitList, depth = depth + 1, isExpanded = isExpanded, + isLast = isLast && index == item.relays.lastIndex, ) } ) is RelayItem.Location.Country -> addAll( - item.cities.flatMap { + item.cities.flatMapIndexed { index, city -> createGeoLocationEntry( - item = it, + item = city, relayListType = relayListType, selectedByThisEntryExitList = selectedByThisEntryExitList, selectedByOtherEntryExitList = selectedByOtherEntryExitList, depth = depth + 1, isExpanded = isExpanded, + isLast = isLast && index == item.cities.lastIndex, ) } ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModel.kt index 5310fe5ca8..9ac657423f 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModel.kt @@ -119,16 +119,24 @@ class SearchLocationViewModel( fun selectRelay(relayItem: RelayItem) { viewModelScope.launch { - selectRelayItem( - relayItem = relayItem, - relayListType = relayListType, - selectEntryLocation = wireguardConstraintsRepository::setEntryLocation, - selectExitLocation = relayListRepository::updateSelectedRelayLocation, - ) - .fold( - { _uiSideEffect.send(SearchLocationSideEffect.GenericError) }, - { _uiSideEffect.send(SearchLocationSideEffect.LocationSelected(relayListType)) }, - ) + if (relayItem.active) { + selectRelayItem( + relayItem = relayItem, + relayListType = relayListType, + selectEntryLocation = wireguardConstraintsRepository::setEntryLocation, + selectExitLocation = relayListRepository::updateSelectedRelayLocation, + ) + .fold( + { _uiSideEffect.send(SearchLocationSideEffect.GenericError) }, + { + _uiSideEffect.send( + SearchLocationSideEffect.LocationSelected(relayListType) + ) + }, + ) + } else { + _uiSideEffect.send(SearchLocationSideEffect.RelayItemInactive(relayItem)) + } } } @@ -217,5 +225,7 @@ sealed interface SearchLocationSideEffect { data class CustomListActionToast(val resultData: CustomListActionResultData) : SearchLocationSideEffect + data class RelayItemInactive(val relayItem: RelayItem) : SearchLocationSideEffect + data object GenericError : SearchLocationSideEffect } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModel.kt index c9cca74602..0420559245 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModel.kt @@ -75,22 +75,27 @@ class SelectLocationViewModel( fun selectRelay(relayItem: RelayItem) { viewModelScope.launch { - selectRelayItem( - relayItem = relayItem, - relayListType = _relayListType.value, - selectEntryLocation = wireguardConstraintsRepository::setEntryLocation, - selectExitLocation = relayListRepository::updateSelectedRelayLocation, - ) - .fold( - { _uiSideEffect.send(SelectLocationSideEffect.GenericError) }, - { - when (_relayListType.value) { - RelayListType.ENTRY -> _relayListType.emit(RelayListType.EXIT) - RelayListType.EXIT -> - _uiSideEffect.send(SelectLocationSideEffect.CloseScreen) - } - }, - ) + if (relayItem.active) { + + selectRelayItem( + relayItem = relayItem, + relayListType = _relayListType.value, + selectEntryLocation = wireguardConstraintsRepository::setEntryLocation, + selectExitLocation = relayListRepository::updateSelectedRelayLocation, + ) + .fold( + { _uiSideEffect.send(SelectLocationSideEffect.GenericError) }, + { + when (_relayListType.value) { + RelayListType.ENTRY -> _relayListType.emit(RelayListType.EXIT) + RelayListType.EXIT -> + _uiSideEffect.send(SelectLocationSideEffect.CloseScreen) + } + }, + ) + } else { + _uiSideEffect.send(SelectLocationSideEffect.RelayItemInactive(relayItem)) + } } } @@ -139,4 +144,6 @@ sealed interface SelectLocationSideEffect { SelectLocationSideEffect data object GenericError : SelectLocationSideEffect + + data class RelayItemInactive(val relayItem: RelayItem) : SelectLocationSideEffect } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModelTest.kt index edb4a9adfe..9b31f8bf24 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModelTest.kt @@ -15,7 +15,6 @@ import net.mullvad.mullvadvpn.compose.communication.LocationsChanged import net.mullvad.mullvadvpn.compose.screen.CustomListLocationsNavArgs import net.mullvad.mullvadvpn.compose.state.CustomListLocationsData import net.mullvad.mullvadvpn.compose.state.CustomListLocationsUiState -import net.mullvad.mullvadvpn.compose.state.RelayLocationListItem import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule import net.mullvad.mullvadvpn.lib.common.test.assertLists import net.mullvad.mullvadvpn.lib.model.CustomList @@ -25,6 +24,7 @@ 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 +import net.mullvad.mullvadvpn.lib.ui.component.relaylist.CheckableRelayListItem import net.mullvad.mullvadvpn.relaylist.descendants import net.mullvad.mullvadvpn.relaylist.withDescendants import net.mullvad.mullvadvpn.repository.RelayListRepository @@ -77,7 +77,7 @@ class CustomListLocationsViewModelTest { // Arrange val expectedList = DUMMY_COUNTRIES.map { - RelayLocationListItem( + CheckableRelayListItem( item = it, depth = it.toDepth(), checked = false, @@ -118,8 +118,9 @@ class CustomListLocationsViewModelTest { viewModel.uiState.test { // Check no selected val firstState = awaitItem() - assertIs<Lce.Content<CustomListLocationsData>>(firstState.content) - assertEquals(emptyList<RelayItem>(), firstState.content.selectedLocations()) + val firstStateContent = firstState.content + assertIs<Lce.Content<CustomListLocationsData>>(firstStateContent) + assertEquals(emptyList<RelayItem>(), firstStateContent.selectedLocations()) // Expand country viewModel.onExpand(DUMMY_COUNTRIES[0], true) awaitItem() @@ -130,8 +131,9 @@ class CustomListLocationsViewModelTest { viewModel.onRelaySelectionClick(DUMMY_COUNTRIES[0], true) // Check all items selected val secondState = awaitItem() - assertIs<Lce.Content<CustomListLocationsData>>(secondState.content) - assertLists(expectedSelection, secondState.content.selectedLocations()) + val content = secondState.content + assertIs<Lce.Content<CustomListLocationsData>>(content) + assertLists(expectedSelection, content.selectedLocations()) } } @@ -150,14 +152,14 @@ class CustomListLocationsViewModelTest { // Act, Assert viewModel.uiState.test { // Check initial selected - val firstState = awaitItem() - assertIs<Lce.Content<CustomListLocationsData>>(firstState.content) - assertEquals(initialSelectionIds, firstState.content.selectedLocations()) + val firstStateContent = awaitItem().content + assertIs<Lce.Content<CustomListLocationsData>>(firstStateContent) + assertEquals(initialSelectionIds, firstStateContent.selectedLocations()) viewModel.onRelaySelectionClick(DUMMY_COUNTRIES[0].cities[0].relays[0], false) // Check all items selected - val secondState = awaitItem() - assertIs<Lce.Content<CustomListLocationsData>>(secondState.content) - assertEquals(expectedSelection, secondState.content.selectedLocations()) + val secondStateContent = awaitItem().content + assertIs<Lce.Content<CustomListLocationsData>>(secondStateContent) + assertEquals(expectedSelection, secondStateContent.selectedLocations()) } } @@ -176,14 +178,14 @@ class CustomListLocationsViewModelTest { // Act, Assert viewModel.uiState.test { - val firstState = awaitItem() - assertIs<Lce.Content<CustomListLocationsData>>(firstState.content) - assertEquals(initialSelectionIds, firstState.content.selectedLocations()) + val firstStateContent = awaitItem().content + assertIs<Lce.Content<CustomListLocationsData>>(firstStateContent) + assertEquals(initialSelectionIds, firstStateContent.selectedLocations()) viewModel.onRelaySelectionClick(DUMMY_COUNTRIES[0], false) // Check all items selected - val secondState = awaitItem() - assertIs<Lce.Content<CustomListLocationsData>>(secondState.content) - assertEquals(expectedSelection, secondState.content.selectedLocations()) + val secondStateContent = awaitItem().content + assertIs<Lce.Content<CustomListLocationsData>>(secondStateContent) + assertEquals(expectedSelection, secondStateContent.selectedLocations()) } } @@ -205,14 +207,14 @@ class CustomListLocationsViewModelTest { // Expand city viewModel.onExpand(DUMMY_COUNTRIES[0].cities[0], true) // Check no selected - val firstState = awaitItem() - assertIs<Lce.Content<CustomListLocationsData>>(firstState.content) - assertEquals(emptyList<RelayItem>(), firstState.content.selectedLocations()) + val firstStateContent = awaitItem().content + assertIs<Lce.Content<CustomListLocationsData>>(firstStateContent) + assertEquals(emptyList<RelayItem>(), firstStateContent.selectedLocations()) viewModel.onRelaySelectionClick(DUMMY_COUNTRIES[0].cities[0].relays[0], true) // Check all items selected - val secondState = awaitItem() - assertIs<Lce.Content<CustomListLocationsData>>(secondState.content) - assertEquals(expectedSelection, secondState.content.selectedLocations()) + val secondStateContent = awaitItem().content + assertIs<Lce.Content<CustomListLocationsData>>(secondStateContent) + assertEquals(expectedSelection, secondStateContent.selectedLocations()) } } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModelTest.kt index 0166bafa98..ad0f87638f 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SearchLocationViewModelTest.kt @@ -8,7 +8,6 @@ import kotlin.test.assertIs import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.compose.screen.location.SearchLocationNavArgs -import net.mullvad.mullvadvpn.compose.state.RelayListItem import net.mullvad.mullvadvpn.compose.state.RelayListType import net.mullvad.mullvadvpn.compose.state.SearchLocationUiState import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule @@ -18,6 +17,7 @@ import net.mullvad.mullvadvpn.lib.model.GeoLocationId import net.mullvad.mullvadvpn.lib.model.RelayItem import net.mullvad.mullvadvpn.lib.model.RelayItemSelection import net.mullvad.mullvadvpn.lib.model.WireguardConstraints +import net.mullvad.mullvadvpn.lib.ui.component.relaylist.RelayListItem import net.mullvad.mullvadvpn.repository.CustomListsRepository import net.mullvad.mullvadvpn.repository.RelayListFilterRepository import net.mullvad.mullvadvpn.repository.RelayListRepository diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationListViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationListViewModelTest.kt index 46994ead49..fb974e52fb 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationListViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationListViewModelTest.kt @@ -6,7 +6,6 @@ import io.mockk.mockk import kotlin.test.assertIs import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest -import net.mullvad.mullvadvpn.compose.state.RelayListItem import net.mullvad.mullvadvpn.compose.state.RelayListType import net.mullvad.mullvadvpn.compose.state.SelectLocationListUiState import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule @@ -16,6 +15,7 @@ import net.mullvad.mullvadvpn.lib.model.GeoLocationId import net.mullvad.mullvadvpn.lib.model.RelayItem import net.mullvad.mullvadvpn.lib.model.RelayItemSelection import net.mullvad.mullvadvpn.lib.model.Settings +import net.mullvad.mullvadvpn.lib.ui.component.relaylist.RelayListItem import net.mullvad.mullvadvpn.repository.RelayListRepository import net.mullvad.mullvadvpn.repository.SettingsRepository import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModelTest.kt index 304b664dc6..7115cd58c0 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModelTest.kt @@ -108,6 +108,7 @@ class SelectLocationViewModelTest { val mockRelayItem: RelayItem.Location.Country = mockk() val relayItemId: GeoLocationId.Country = mockk(relaxed = true) every { mockRelayItem.id } returns relayItemId + every { mockRelayItem.active } returns true coEvery { mockRelayListRepository.updateSelectedRelayLocation(relayItemId) } returns Unit.right() @@ -126,6 +127,7 @@ class SelectLocationViewModelTest { // Arrange val mockRelayItem: RelayItem.Location.Country = mockk() val relayItemId: GeoLocationId.Country = mockk(relaxed = true) + every { mockRelayItem.active } returns true every { mockRelayItem.id } returns relayItemId coEvery { mockWireguardConstraintsRepository.setEntryLocation(relayItemId) } returns Unit.right() diff --git a/android/gradle/verification-metadata.keys.xml b/android/gradle/verification-metadata.keys.xml index 5e8e8ea39d..4782bca7a1 100644 --- a/android/gradle/verification-metadata.keys.xml +++ b/android/gradle/verification-metadata.keys.xml @@ -260,6 +260,11 @@ <sha256 value="d3a676709dea04f2a8506e2ae85052fff763db526ac7f16b04de50fdd05b0720" origin="Generated by Gradle"/> </artifact> </component> + <component group="androidx.activity" name="activity-compose" version="1.7.0"> + <artifact name="activity-compose-1.7.0.module"> + <sha256 value="f7a29bcba338575dcf89a553cff9cfad3f140340eaf2b56fd0193244da602c0a" origin="Generated by Gradle" reason="Artifact is not signed"/> + </artifact> + </component> <component group="androidx.activity" name="activity-ktx" version="1.7.0"> <artifact name="activity-ktx-1.7.0.aar"> <sha256 value="fce317d61a22f12967b475bfcb80c89dda66e418975e890ea703cb74e12b5b11" origin="Generated by Gradle" reason="Artifact is not signed"/> @@ -625,6 +630,11 @@ <sha256 value="8083710b758ac096891e91f51d91ee56a445b265d7becf230355377327c0418b" origin="Generated by Gradle" reason="Artifact is not signed"/> </artifact> </component> + <component group="androidx.lifecycle" name="lifecycle-viewmodel-savedstate" version="2.5.1"> + <artifact name="lifecycle-viewmodel-savedstate-2.5.1.module"> + <sha256 value="29acd5fe614b3f89123eb838f688d625eaa8b422c8d1905b48ad8e760cd7ad8b" origin="Generated by Gradle" reason="Artifact is not signed"/> + </artifact> + </component> <component group="androidx.lifecycle" name="lifecycle-viewmodel-savedstate" version="2.6.1"> <artifact name="lifecycle-viewmodel-savedstate-2.6.1.module"> <sha256 value="dafb8649763d29c29cda27bc22fcdab9a9efc53c0fff9ae3de90882eabaa8944" origin="Generated by Gradle" reason="Artifact is not signed"/> diff --git a/android/gradle/verification-metadata.xml b/android/gradle/verification-metadata.xml index 8512f7c0db..7f3a3cc60d 100644 --- a/android/gradle/verification-metadata.xml +++ b/android/gradle/verification-metadata.xml @@ -91,6 +91,11 @@ <sha256 value="1ed13a50edbb885962751e1bcb5b8a4207a20cb780ea248ffa653aab3fb10fe9" origin="Generated by Gradle"/> </artifact> </component> + <component group="androidx.activity" name="activity-compose" version="1.7.0"> + <artifact name="activity-compose-1.7.0.module"> + <sha256 value="f7a29bcba338575dcf89a553cff9cfad3f140340eaf2b56fd0193244da602c0a" origin="Generated by Gradle"/> + </artifact> + </component> <component group="androidx.activity" name="activity-compose" version="1.8.0"> <artifact name="activity-compose-1.8.0.aar"> <sha256 value="3b462bc760eba180956543232ca467c7b6200a091c5bfa146f906cfabc856314" origin="Generated by Gradle"/> @@ -364,11 +369,6 @@ <sha256 value="4228894e25eec7e17329b206ba72e8aad255bc535018dc62acc5eff3b5c7aaa5" origin="Generated by Gradle"/> </artifact> </component> - <component group="androidx.compose.animation" name="animation" version="1.6.0"> - <artifact name="animation-1.6.0.module"> - <sha256 value="e98defdf92ca1fcbeaf16e78a60c18052b01340da3849b93e25e944f06a4e527" origin="Generated by Gradle"/> - </artifact> - </component> <component group="androidx.compose.animation" name="animation" version="1.6.8"> <artifact name="animation-1.6.8.module"> <sha256 value="31e6783f9a1de6e021942c5be1f1d777e330bfe017f5429032a24f4c3a940726" origin="Generated by Gradle"/> @@ -415,9 +415,6 @@ <artifact name="animation-android-1.7.2.module"> <sha256 value="7f4773c5800c09adb41f37a266effaac1618737eeadae80310a771a37cd8b547" origin="Generated by Gradle"/> </artifact> - <artifact name="animation-release.aar"> - <sha256 value="cbd5746f38201f19511c4afc99311c646647b2bed1f902ec677d3e17d83576da" origin="Generated by Gradle"/> - </artifact> </component> <component group="androidx.compose.animation" name="animation-android" version="1.8.3"> <artifact name="animation-android-1.8.3.module"> @@ -471,11 +468,6 @@ <sha256 value="c571f43b5780fb80f42e85644e0ba5696af088d7228f3251c3e1d64c3bd64376" origin="Generated by Gradle"/> </artifact> </component> - <component group="androidx.compose.animation" name="animation-core-android" version="1.6.8"> - <artifact name="animation-core-android-1.6.8.module"> - <sha256 value="2814fcf1645cb1d5782b216236b99a4e2dde5bdcbb8e815f4514c044c28b2bef" origin="Generated by Gradle"/> - </artifact> - </component> <component group="androidx.compose.animation" name="animation-core-android" version="1.7.0"> <artifact name="animation-core-android-1.7.0.module"> <sha256 value="4c6bed497979faa58f3252b7922da7cab80da247ef40aca8eb513243e9d40cdc" origin="Generated by Gradle"/> @@ -484,14 +476,6 @@ <sha256 value="2dc22359de83ed0259a8b7834f16d987d82a90c7197eabb44d72cbdac67bf82c" origin="Generated by Gradle"/> </artifact> </component> - <component group="androidx.compose.animation" name="animation-core-android" version="1.7.2"> - <artifact name="animation-core-android-1.7.2.module"> - <sha256 value="81c5eb129bdc1d09567726b7a88297c6d43def70a1065734bc64fba648403c5b" origin="Generated by Gradle"/> - </artifact> - <artifact name="animation-core-release.aar"> - <sha256 value="a44134a34b934b75e93a06b4605a581073902f7d232a167fd1446c95200b5c4d" origin="Generated by Gradle"/> - </artifact> - </component> <component group="androidx.compose.animation" name="animation-core-android" version="1.8.3"> <artifact name="animation-core-android-1.8.3.module"> <sha256 value="ccb58ff68ab6188b143f9b45d5b7e67838e3b7127a0ed76cd05ac80dd773c136" origin="Generated by Gradle"/> @@ -569,9 +553,6 @@ <artifact name="foundation-android-1.7.2.module"> <sha256 value="d1d33ea24a8dde06b182bf34e2509bd3f6a1217a9cb411c82645d705fe377b24" origin="Generated by Gradle"/> </artifact> - <artifact name="foundation-release.aar"> - <sha256 value="93c53326154ea17b4d8c3e04b54361da1e1715bc9b40461a44ffc23b37227a87" origin="Generated by Gradle"/> - </artifact> </component> <component group="androidx.compose.foundation" name="foundation-android" version="1.8.3"> <artifact name="foundation-android-1.8.3.module"> @@ -635,9 +616,6 @@ <artifact name="foundation-layout-android-1.7.2.module"> <sha256 value="34978d5115d736c20a6ae0aa8805a5799c8f4bd902059171a8e644b9484702f5" origin="Generated by Gradle"/> </artifact> - <artifact name="foundation-layout-release.aar"> - <sha256 value="1cbdce4b394da76b0f98ff8d31a3613c4ec355c9198cfe4d8c06bb02f7b76b38" origin="Generated by Gradle"/> - </artifact> </component> <component group="androidx.compose.foundation" name="foundation-layout-android" version="1.8.3"> <artifact name="foundation-layout-android-1.8.3.module"> @@ -2155,6 +2133,11 @@ <sha256 value="8083710b758ac096891e91f51d91ee56a445b265d7becf230355377327c0418b" origin="Generated by Gradle"/> </artifact> </component> + <component group="androidx.lifecycle" name="lifecycle-viewmodel-savedstate" version="2.5.1"> + <artifact name="lifecycle-viewmodel-savedstate-2.5.1.module"> + <sha256 value="29acd5fe614b3f89123eb838f688d625eaa8b422c8d1905b48ad8e760cd7ad8b" origin="Generated by Gradle"/> + </artifact> + </component> <component group="androidx.lifecycle" name="lifecycle-viewmodel-savedstate" version="2.6.1"> <artifact name="lifecycle-viewmodel-savedstate-2.6.1.module"> <sha256 value="dafb8649763d29c29cda27bc22fcdab9a9efc53c0fff9ae3de90882eabaa8944" origin="Generated by Gradle"/> diff --git a/android/lib/resource/src/main/res/values-da/strings.xml b/android/lib/resource/src/main/res/values-da/strings.xml index abb07da226..48ed96072a 100644 --- a/android/lib/resource/src/main/res/values-da/strings.xml +++ b/android/lib/resource/src/main/res/values-da/strings.xml @@ -370,8 +370,6 @@ <string name="this_field_is_required">Dette felt er påkrævet</string> <string name="this_is_already_set_as_current">Den er allerede indstillet som aktuel</string> <string name="time_added">Tid tilføjet</string> - <string name="to_add_locations_to_a_list">Tryk på \" ︙ \" eller tryk langvarigt på et land, en by eller en server for at tilføje placeringer til en liste.</string> - <string name="to_create_a_custom_list">Tryk på \" ︙ \" for at oprette en brugerdefineret liste</string> <string name="toggle_vpn">Slå VPN til/fra</string> <string name="top_bar_device_name">Enhedsnavn: %1$s</string> <string name="top_bar_time_left">Resterende tid: %1$s</string> diff --git a/android/lib/resource/src/main/res/values-de/strings.xml b/android/lib/resource/src/main/res/values-de/strings.xml index 37277ab1cf..3dcfcae8d8 100644 --- a/android/lib/resource/src/main/res/values-de/strings.xml +++ b/android/lib/resource/src/main/res/values-de/strings.xml @@ -370,8 +370,6 @@ <string name="this_field_is_required">Dieses Feld ist erforderlich</string> <string name="this_is_already_set_as_current">Diese ist bereits die aktuelle</string> <string name="time_added">Zeit hinzugefügt</string> - <string name="to_add_locations_to_a_list">Um Standorte zu einer Liste hinzuzufügen, drücken Sie auf „︙“ oder drücken Sie lange auf ein Land, eine Stadt oder einen Server.</string> - <string name="to_create_a_custom_list">Um eine eigene Liste zu erstellen, drücken Sie auf „︙“</string> <string name="toggle_vpn">VPN umschalten</string> <string name="top_bar_device_name">Gerätename: %1$s</string> <string name="top_bar_time_left">Verbleibende Zeit: %1$s</string> diff --git a/android/lib/resource/src/main/res/values-es/strings.xml b/android/lib/resource/src/main/res/values-es/strings.xml index 6644043d86..b5b55248a8 100644 --- a/android/lib/resource/src/main/res/values-es/strings.xml +++ b/android/lib/resource/src/main/res/values-es/strings.xml @@ -370,8 +370,6 @@ <string name="this_field_is_required">Este campo es obligatorio</string> <string name="this_is_already_set_as_current">Este ya está configurado como actual</string> <string name="time_added">Tiempo añadido</string> - <string name="to_add_locations_to_a_list">Para añadir ubicaciones a una lista, pulse «︙» o mantenga pulsado unos segundos un país, ciudad o servidor.</string> - <string name="to_create_a_custom_list">Para crear una lista personalizada, pulse «︙»</string> <string name="toggle_vpn">Alternar VPN</string> <string name="top_bar_device_name">Nombre del dispositivo: %1$s</string> <string name="top_bar_time_left">Tiempo restante: %1$s</string> diff --git a/android/lib/resource/src/main/res/values-fi/strings.xml b/android/lib/resource/src/main/res/values-fi/strings.xml index b81c2b8d6b..71b5abaa50 100644 --- a/android/lib/resource/src/main/res/values-fi/strings.xml +++ b/android/lib/resource/src/main/res/values-fi/strings.xml @@ -370,8 +370,6 @@ <string name="this_field_is_required">Tämä kenttä on pakollinen</string> <string name="this_is_already_set_as_current">Tämä on jo asetettu nykyiseksi</string> <string name="time_added">Käyttöaikaa lisätty</string> - <string name="to_add_locations_to_a_list">Jos haluat lisätä luetteloon sijainteja, paina \"︙\" tai paina pitkään maata, kaupunkia tai palvelinta.</string> - <string name="to_create_a_custom_list">Voit luoda mukautetun luettelon painamalla \"︙\"</string> <string name="toggle_vpn">Vaihda VPN:ää</string> <string name="top_bar_device_name">Laitteen nimi: %1$s</string> <string name="top_bar_time_left">Aikaa jäljellä: %1$s</string> diff --git a/android/lib/resource/src/main/res/values-fr/strings.xml b/android/lib/resource/src/main/res/values-fr/strings.xml index a9299b359c..5eb05d1775 100644 --- a/android/lib/resource/src/main/res/values-fr/strings.xml +++ b/android/lib/resource/src/main/res/values-fr/strings.xml @@ -370,8 +370,6 @@ <string name="this_field_is_required">Ce champ est requis</string> <string name="this_is_already_set_as_current">Déjà définie comme méthode actuelle</string> <string name="time_added">Temps ajouté</string> - <string name="to_add_locations_to_a_list">Pour ajouter des localisations à une liste, appuyez sur la touche « ︙ » ou appuyez longuement sur un pays, une ville ou un serveur.</string> - <string name="to_create_a_custom_list">Appuyez sur « ︙ » pour créer une liste personnalisée</string> <string name="toggle_vpn">Activer/désactiver le VPN</string> <string name="top_bar_device_name">Nom de l\'appareil : %1$s</string> <string name="top_bar_time_left">Temps restant : %1$s</string> diff --git a/android/lib/resource/src/main/res/values-it/strings.xml b/android/lib/resource/src/main/res/values-it/strings.xml index b17d8d64f8..84a4bd347a 100644 --- a/android/lib/resource/src/main/res/values-it/strings.xml +++ b/android/lib/resource/src/main/res/values-it/strings.xml @@ -370,8 +370,6 @@ <string name="this_field_is_required">Questo campo è obbligatorio</string> <string name="this_is_already_set_as_current">È già impostato come attuale</string> <string name="time_added">Tempo aggiunto</string> - <string name="to_add_locations_to_a_list">Per aggiungere posizioni a un elenco, premi \"︙\" o tieni premuto su un Paese, una città o un server.</string> - <string name="to_create_a_custom_list">Per creare un elenco personalizzato, premi \"︙\"</string> <string name="toggle_vpn">Attiva/disattiva VPN</string> <string name="top_bar_device_name">Nome del dispositivo: %1$s</string> <string name="top_bar_time_left">Tempo rimasto: %1$s</string> diff --git a/android/lib/resource/src/main/res/values-ja/strings.xml b/android/lib/resource/src/main/res/values-ja/strings.xml index d90c551c36..9c623768af 100644 --- a/android/lib/resource/src/main/res/values-ja/strings.xml +++ b/android/lib/resource/src/main/res/values-ja/strings.xml @@ -370,8 +370,6 @@ <string name="this_field_is_required">このフィールドは必須です</string> <string name="this_is_already_set_as_current">これはすでに現在の設定として設定されています</string> <string name="time_added">時間が追加されました</string> - <string name="to_add_locations_to_a_list">リストに場所を追加するには、\"︙\" を押すか、または、国、都市、サーバーを長押ししてください。</string> - <string name="to_create_a_custom_list">カスタムリストを作成するには、\"︙\" を押してください</string> <string name="toggle_vpn">VPNの切り替え</string> <string name="top_bar_device_name">デバイス名: %1$s</string> <string name="top_bar_time_left">残り時間: %1$s</string> diff --git a/android/lib/resource/src/main/res/values-ko/strings.xml b/android/lib/resource/src/main/res/values-ko/strings.xml index e47b9d5ed7..1e4e0950e5 100644 --- a/android/lib/resource/src/main/res/values-ko/strings.xml +++ b/android/lib/resource/src/main/res/values-ko/strings.xml @@ -370,8 +370,6 @@ <string name="this_field_is_required">이 필드는 필수입니다</string> <string name="this_is_already_set_as_current">이미 현재로 설정되어 있습니다</string> <string name="time_added">시간 추가됨</string> - <string name="to_add_locations_to_a_list">목록에 위치를 추가하려면 \"︙\"를 누르거나 국가, 도시 또는 서버를 길게 누릅니다.</string> - <string name="to_create_a_custom_list">사용자 지정 목록을 생성하려면 \"︙\"를 누릅니다</string> <string name="toggle_vpn">VPN 전환</string> <string name="top_bar_device_name">장치 이름: %1$s</string> <string name="top_bar_time_left">남은 시간: %1$s</string> diff --git a/android/lib/resource/src/main/res/values-my/strings.xml b/android/lib/resource/src/main/res/values-my/strings.xml index 252426d735..deeb595a88 100644 --- a/android/lib/resource/src/main/res/values-my/strings.xml +++ b/android/lib/resource/src/main/res/values-my/strings.xml @@ -370,8 +370,6 @@ <string name="this_field_is_required">ဤအကွက်ကို မဖြစ်မနေဖြည့်ရမည်</string> <string name="this_is_already_set_as_current">၎င်းကို လက်ရှိအဖြစ် သတ်မှတ်ထားပြီးပါပြီ</string> <string name="time_added">အချိန်တိုးထားသည်</string> - <string name="to_add_locations_to_a_list">စာရင်းထဲသို့ တည်နေရာများကို ပေါင်းထည့်ရန် \"︙\" ကို နှိပ်ပါ သို့မဟုတ် နိုင်ငံ၊ မြို့၊ ဆာဗာကို နှိပ်ပါ။</string> - <string name="to_create_a_custom_list">စိတ်ကြိုက် စာရင်းများကို ဖန်တီးရန် \"︙\" ကို နှိပ်ပါ</string> <string name="toggle_vpn">VPN ရွေးသုံးရန်</string> <string name="top_bar_device_name">စက်အမည်- %1$s</string> <string name="top_bar_time_left">ကျန်သည့် အချိန်- %1$s</string> diff --git a/android/lib/resource/src/main/res/values-nb/strings.xml b/android/lib/resource/src/main/res/values-nb/strings.xml index 310f0bfabf..5d9fe91901 100644 --- a/android/lib/resource/src/main/res/values-nb/strings.xml +++ b/android/lib/resource/src/main/res/values-nb/strings.xml @@ -370,8 +370,6 @@ <string name="this_field_is_required">Feltet er påkrevd</string> <string name="this_is_already_set_as_current">Denne er allerede angitt som gjeldende</string> <string name="time_added">Tid lagt til</string> - <string name="to_add_locations_to_a_list">Hvis du vil legge til plasseringer i en liste, trykker du på ︙ eller trykker på og holder inne et land, en by eller en server.</string> - <string name="to_create_a_custom_list">For å opprette en egendefinert liste trykker du på ︙</string> <string name="toggle_vpn">Velg VPN</string> <string name="top_bar_device_name">Enhetsnavn: %1$s</string> <string name="top_bar_time_left">Tid igjen: %1$s</string> diff --git a/android/lib/resource/src/main/res/values-nl/strings.xml b/android/lib/resource/src/main/res/values-nl/strings.xml index 472ea9f1ab..4687d40208 100644 --- a/android/lib/resource/src/main/res/values-nl/strings.xml +++ b/android/lib/resource/src/main/res/values-nl/strings.xml @@ -370,8 +370,6 @@ <string name="this_field_is_required">Dit veld is verplicht</string> <string name="this_is_already_set_as_current">Deze is al ingesteld als huidige</string> <string name="time_added">Tijd van toevoegen</string> - <string name="to_add_locations_to_a_list">Druk op de \"︙\" of druk lang op een land, plaats of server om locaties toe te voegen.</string> - <string name="to_create_a_custom_list">Druk op de \"︙\" om een aangepaste lijst te maken</string> <string name="toggle_vpn">VPN in-/uitschakelen</string> <string name="top_bar_device_name">Apparaatnaam: %1$s</string> <string name="top_bar_time_left">Resterende tijd: %1$s</string> diff --git a/android/lib/resource/src/main/res/values-pl/strings.xml b/android/lib/resource/src/main/res/values-pl/strings.xml index 76a26b0ec9..fb344a0998 100644 --- a/android/lib/resource/src/main/res/values-pl/strings.xml +++ b/android/lib/resource/src/main/res/values-pl/strings.xml @@ -370,8 +370,6 @@ <string name="this_field_is_required">To pole jest wymagane</string> <string name="this_is_already_set_as_current">Już ustawiona jako bieżąca</string> <string name="time_added">Dodano czas</string> - <string name="to_add_locations_to_a_list">Aby dodać lokalizacje do listy, naciśnij przycisk „︙” lub naciśnij i przytrzymaj kraj, miasto albo serwer.</string> - <string name="to_create_a_custom_list">Aby utworzyć listę niestandardową, naciśnij przycisk „︙”</string> <string name="toggle_vpn">Przełącz VPN</string> <string name="top_bar_device_name">Nazwa urządzenia: %1$s</string> <string name="top_bar_time_left">Pozostało: %1$s</string> diff --git a/android/lib/resource/src/main/res/values-pt/strings.xml b/android/lib/resource/src/main/res/values-pt/strings.xml index 39727d780c..dd3b3be340 100644 --- a/android/lib/resource/src/main/res/values-pt/strings.xml +++ b/android/lib/resource/src/main/res/values-pt/strings.xml @@ -370,8 +370,6 @@ <string name="this_field_is_required">Este campo é obrigatório</string> <string name="this_is_already_set_as_current">Este já está definido como o atual</string> <string name="time_added">Tempo adicionado</string> - <string name="to_add_locations_to_a_list">Para adicionar localizações a uma lista, prima o botão \"︙\" ou mantenha premido um país, uma cidade ou um servidor.</string> - <string name="to_create_a_custom_list">Para criar uma lista personalizada prima o botão \"︙\"</string> <string name="toggle_vpn">Alternar VPN</string> <string name="top_bar_device_name">Nome do dispositivo: %1$s</string> <string name="top_bar_time_left">Tempo restante: %1$s</string> diff --git a/android/lib/resource/src/main/res/values-ru/strings.xml b/android/lib/resource/src/main/res/values-ru/strings.xml index aa7785dc7b..4c57b0817a 100644 --- a/android/lib/resource/src/main/res/values-ru/strings.xml +++ b/android/lib/resource/src/main/res/values-ru/strings.xml @@ -370,8 +370,6 @@ <string name="this_field_is_required">Это обязательное поле</string> <string name="this_is_already_set_as_current">Этот метод уже установлен как текущий</string> <string name="time_added">Время добавлено</string> - <string name="to_add_locations_to_a_list">Чтобы добавить местоположения в список, нажмите «︙» или нажмите и удерживайте страну, город или сервер.</string> - <string name="to_create_a_custom_list">Чтобы создать свой список, нажмите «︙»</string> <string name="toggle_vpn">Включение VPN</string> <string name="top_bar_device_name">Имя устройства: %1$s</string> <string name="top_bar_time_left">Осталось времени: %1$s</string> diff --git a/android/lib/resource/src/main/res/values-sv/strings.xml b/android/lib/resource/src/main/res/values-sv/strings.xml index 732e8a11ed..93a98f6874 100644 --- a/android/lib/resource/src/main/res/values-sv/strings.xml +++ b/android/lib/resource/src/main/res/values-sv/strings.xml @@ -370,8 +370,6 @@ <string name="this_field_is_required">Fältet är obligatoriskt</string> <string name="this_is_already_set_as_current">Den har redan ställts in som aktuell</string> <string name="time_added">Tid har lagts till</string> - <string name="to_add_locations_to_a_list">Om du vill lägga till platser i en lista kan du trycka på \"︙\" eller trycka länge på ett land, en stad eller en server.</string> - <string name="to_create_a_custom_list">Om du vill skapa en anpassad lista trycker du på \"︙\"</string> <string name="toggle_vpn">Växla VPN</string> <string name="top_bar_device_name">Enhetsnamn: %1$s</string> <string name="top_bar_time_left">Tid kvar: %1$s</string> diff --git a/android/lib/resource/src/main/res/values-th/strings.xml b/android/lib/resource/src/main/res/values-th/strings.xml index 598334218a..2bbca2a79b 100644 --- a/android/lib/resource/src/main/res/values-th/strings.xml +++ b/android/lib/resource/src/main/res/values-th/strings.xml @@ -370,8 +370,6 @@ <string name="this_field_is_required">จำเป็นต้องกรอกช่องนี้</string> <string name="this_is_already_set_as_current">นี่ได้รับการตั้งค่าเป็นปัจจุบันแล้ว</string> <string name="time_added">เพิ่มเวลาแล้ว</string> - <string name="to_add_locations_to_a_list">กด \"︙\" หรือกดประเทศ เมือง หรือเซิร์ฟเวอร์ค้างไว้ เพื่อเพิ่มตำแหน่งที่ตั้งลงในรายการ</string> - <string name="to_create_a_custom_list">กด \"︙\" เพื่อสร้างรายการแบบกำหนดเอง</string> <string name="toggle_vpn">เปิด/ปิด VPN</string> <string name="top_bar_device_name">ชื่ออุปกรณ์: %1$s</string> <string name="top_bar_time_left">เหลือเวลา: %1$s</string> diff --git a/android/lib/resource/src/main/res/values-tr/strings.xml b/android/lib/resource/src/main/res/values-tr/strings.xml index 0c7cbace17..038f87f4d8 100644 --- a/android/lib/resource/src/main/res/values-tr/strings.xml +++ b/android/lib/resource/src/main/res/values-tr/strings.xml @@ -370,8 +370,6 @@ <string name="this_field_is_required">Bu alan gereklidir</string> <string name="this_is_already_set_as_current">Bu, zaten geçerli olarak ayarlı yöntemdir</string> <string name="time_added">Süre eklendi</string> - <string name="to_add_locations_to_a_list">Listeye konum eklemek için \"︙\" düğmesine basın veya bir ülke, şehir veya sunucunun üzerine uzun basın.</string> - <string name="to_create_a_custom_list">Özel bir liste oluşturmak için \"︙\" düğmesine basın</string> <string name="toggle_vpn">VPN\'i aç/kapat</string> <string name="top_bar_device_name">Cihaz adı: %1$s</string> <string name="top_bar_time_left">Kalan süre: %1$s</string> diff --git a/android/lib/resource/src/main/res/values-zh-rCN/strings.xml b/android/lib/resource/src/main/res/values-zh-rCN/strings.xml index 55e749d49c..fbf050b7b8 100644 --- a/android/lib/resource/src/main/res/values-zh-rCN/strings.xml +++ b/android/lib/resource/src/main/res/values-zh-rCN/strings.xml @@ -370,8 +370,6 @@ <string name="this_field_is_required">此字段为必填项</string> <string name="this_is_already_set_as_current">它已被设置为当前方法</string> <string name="time_added">时间已添加</string> - <string name="to_add_locations_to_a_list">要将位置添加到列表中,请按“︙”或长按国家/地区、城市或服务器。</string> - <string name="to_create_a_custom_list">要创建自定义列表,请按“︙”</string> <string name="toggle_vpn">切换 VPN</string> <string name="top_bar_device_name">设备名称:%1$s</string> <string name="top_bar_time_left">剩余时间:%1$s</string> diff --git a/android/lib/resource/src/main/res/values-zh-rTW/strings.xml b/android/lib/resource/src/main/res/values-zh-rTW/strings.xml index edfd7b2d02..be4e67c35c 100644 --- a/android/lib/resource/src/main/res/values-zh-rTW/strings.xml +++ b/android/lib/resource/src/main/res/values-zh-rTW/strings.xml @@ -370,8 +370,6 @@ <string name="this_field_is_required">此欄位為必填項</string> <string name="this_is_already_set_as_current">已將其設定為目前方式</string> <string name="time_added">已增加時間</string> - <string name="to_add_locations_to_a_list">若要在清單中新增位置,請按下「︙」,或是長按下國家/地區、城市或伺服器。</string> - <string name="to_create_a_custom_list">若要建立自訂清單,請按下「︙」</string> <string name="toggle_vpn">切換 VPN</string> <string name="top_bar_device_name">裝置名稱:%1$s</string> <string name="top_bar_time_left">剩餘時間:%1$s</string> diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml index 09cd1ebd99..fd01cfbcd6 100644 --- a/android/lib/resource/src/main/res/values/strings.xml +++ b/android/lib/resource/src/main/res/values/strings.xml @@ -259,9 +259,9 @@ <string name="custom_list_error_list_exists">Name is already taken.</string> <string name="update_list_name">Update list name</string> <string name="no_custom_lists_available">No custom lists available</string> - <string name="to_create_a_custom_list">To create a custom list press the \"︙\"</string> + <string name="to_create_a_custom_list">To create a custom list press the \"+\"</string> <string name="new_list">New list</string> - <string name="to_add_locations_to_a_list">To add locations to a list, press the \"︙\" or long press on a country, city, or server.</string> + <string name="to_add_locations_to_a_list">To add locations to a list, press the pen or long press on a country, city, or server.</string> <string name="edit_list">Edit list</string> <string name="delete">Delete</string> <string name="delete_custom_list_message">\"%s\" was deleted</string> @@ -426,4 +426,5 @@ <string name="time_added">Time added</string> <string name="app_is_blocking_internet">The app is blocking internet, please disconnect first</string> <string name="in_app_products_unavailable">In-app products unavailable, please make sure you have the latest version of Google Play.</string> + <string name="relayitem_is_inactive">%s is unavailable</string> </resources> diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt index 0d40e7c805..5179b5c306 100644 --- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt +++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt @@ -59,6 +59,8 @@ data class Dimensions( val problemReportIconToTitlePadding: Dp = 60.dp, val reconnectButtonMinInteractiveComponentSize: Dp = 40.dp, val relayCircleSize: Dp = 16.dp, + val relayCirclePadding: Dp = 6.dp, + val relayItemCornerRadius: Dp = 16.dp, val screenBottomMargin: Dp = 16.dp, val screenTopMargin: Dp = 24.dp, val searchFieldHeight: Dp = 42.dp, 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/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/ExpandChevron.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/ExpandChevron.kt index d1ec86fde7..5c5eed486e 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/ExpandChevron.kt +++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/ExpandChevron.kt @@ -1,4 +1,4 @@ -package net.mullvad.mullvadvpn.compose.component +package net.mullvad.mullvadvpn.lib.ui.component import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.TweenSpec @@ -8,28 +8,30 @@ 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.MaterialTheme +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.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.lib.theme.AppTheme @Composable @Preview private fun PreviewChevron() { - Column { - ExpandChevron(color = MaterialTheme.colorScheme.onPrimary, isExpanded = false) - ExpandChevron(color = MaterialTheme.colorScheme.onPrimary, isExpanded = true) + AppTheme { + Surface { + Column { + ExpandChevron(isExpanded = false) + ExpandChevron(isExpanded = true) + } + } } } @Composable -fun ExpandChevron(modifier: Modifier = Modifier, color: Color, isExpanded: Boolean) { - +fun ExpandChevron(modifier: Modifier = Modifier, isExpanded: Boolean) { val degree = remember(isExpanded) { if (isExpanded) UP_ROTATION else DOWN_ROTATION } val stateLabel = if (isExpanded) { @@ -47,7 +49,7 @@ fun ExpandChevron(modifier: Modifier = Modifier, color: Color, isExpanded: Boole Icon( imageVector = Icons.Default.KeyboardArrowDown, contentDescription = stateLabel, - tint = color, + // tint = color, modifier = modifier.rotate(animatedRotation.value), ) } @@ -55,12 +57,11 @@ fun ExpandChevron(modifier: Modifier = Modifier, color: Color, isExpanded: Boole @Composable fun ExpandChevronIconButton( modifier: Modifier = Modifier, - color: Color, onExpand: (Boolean) -> Unit, isExpanded: Boolean, ) { IconButton(modifier = modifier, onClick = { onExpand(!isExpanded) }) { - ExpandChevron(isExpanded = isExpanded, color = color) + ExpandChevron(isExpanded = isExpanded) } } 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/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemCheckableCellPreviewParameterProvider.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayItemCheckableCellPreviewParameterProvider.kt index c0d4ddc063..918203db53 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemCheckableCellPreviewParameterProvider.kt +++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayItemCheckableCellPreviewParameterProvider.kt @@ -1,4 +1,4 @@ -package net.mullvad.mullvadvpn.compose.preview +package net.mullvad.mullvadvpn.lib.ui.component.relaylist import androidx.compose.ui.tooling.preview.PreviewParameterProvider import net.mullvad.mullvadvpn.lib.model.RelayItem diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemPreviewData.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayItemPreviewData.kt index 3d706443d2..35397a6a27 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemPreviewData.kt +++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayItemPreviewData.kt @@ -1,4 +1,4 @@ -package net.mullvad.mullvadvpn.compose.preview +package net.mullvad.mullvadvpn.lib.ui.component.relaylist import net.mullvad.mullvadvpn.lib.model.GeoLocationId import net.mullvad.mullvadvpn.lib.model.Ownership diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayListItem.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayListItem.kt index 028912f15d..8132a9ece7 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayListItem.kt +++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayListItem.kt @@ -1,4 +1,4 @@ -package net.mullvad.mullvadvpn.compose.state +package net.mullvad.mullvadvpn.lib.ui.component.relaylist import net.mullvad.mullvadvpn.lib.model.CustomListId import net.mullvad.mullvadvpn.lib.model.CustomListName @@ -30,17 +30,20 @@ sealed interface RelayListItem { } 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( - val item: RelayItem.CustomList, + 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 @@ -50,10 +53,11 @@ sealed interface RelayListItem { data class CustomListEntryItem( val parentId: CustomListId, val parentName: CustomListName, - val item: RelayItem.Location, + 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 @@ -73,11 +77,12 @@ sealed interface RelayListItem { } data class GeoLocationItem( - val item: RelayItem.Location, + 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 @@ -93,3 +98,35 @@ sealed interface RelayListItem { 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/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayListItemPreviewData.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayListItemPreviewData.kt index aaf5fd5a6e..5776601168 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayListItemPreviewData.kt +++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/relaylist/RelayListItemPreviewData.kt @@ -1,7 +1,5 @@ -package net.mullvad.mullvadvpn.compose.preview +package net.mullvad.mullvadvpn.lib.ui.component.relaylist -import net.mullvad.mullvadvpn.compose.state.RelayListItem -import net.mullvad.mullvadvpn.compose.state.RelayListItemState import net.mullvad.mullvadvpn.lib.model.CustomList import net.mullvad.mullvadvpn.lib.model.CustomListId import net.mullvad.mullvadvpn.lib.model.CustomListName @@ -39,6 +37,7 @@ object RelayListItemPreviewData { isSelected = false, state = null, expanded = false, + itemPosition = ItemPosition.Single, ) } if (!isSearching) { @@ -69,6 +68,7 @@ object RelayListItemPreviewData { depth = 0, expanded = true, state = null, + itemPosition = ItemPosition.Middle, ), RelayListItem.GeoLocationItem( item = locations[0].cities[0], @@ -76,6 +76,7 @@ object RelayListItemPreviewData { depth = 1, expanded = false, state = null, + itemPosition = ItemPosition.Middle, ), RelayListItem.GeoLocationItem( item = locations[0].cities[1], @@ -83,6 +84,7 @@ object RelayListItemPreviewData { depth = 1, expanded = true, state = null, + itemPosition = ItemPosition.Middle, ), RelayListItem.GeoLocationItem( item = locations[0].cities[1].relays[0], @@ -90,6 +92,7 @@ object RelayListItemPreviewData { depth = 2, expanded = false, state = RelayListItemState.USED_AS_EXIT, + itemPosition = ItemPosition.Middle, ), RelayListItem.GeoLocationItem( item = locations[0].cities[1].relays[1], @@ -97,6 +100,7 @@ object RelayListItemPreviewData { depth = 2, expanded = false, state = null, + itemPosition = ItemPosition.Middle, ), RelayListItem.GeoLocationItem( item = locations[1], @@ -104,6 +108,7 @@ object RelayListItemPreviewData { depth = 0, expanded = false, state = null, + itemPosition = ItemPosition.Bottom, ), ) ) 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, + ) + } + }, + ) + } +} diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 686df96851..0d3df97602 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -29,7 +29,9 @@ include( ":lib:talpid", ":lib:theme", ":lib:tv", + ":lib:ui:designsystem", ":lib:ui:component", + ":lib:ui:tag" ) include( ":test", @@ -38,4 +40,3 @@ include( ":test:e2e", ":test:mockapi" ) -include(":lib:ui:tag") |
