diff options
| author | David Göransson <david.goransson@mullvad.net> | 2025-07-04 16:12:32 +0200 |
|---|---|---|
| committer | David Göransson <david.goransson@mullvad.net> | 2025-07-04 16:12:32 +0200 |
| commit | 5300f1663559ebd7a87c699db8e858d13e6fa556 (patch) | |
| tree | 0081e14129def76d6a57b32232e42411c2fbe10d | |
| parent | 3e5795cbab6866fcd3eafee58d1790fc9e7f1829 (diff) | |
| parent | 0d5660226494abaf04dc619997bf4d6a27c637d8 (diff) | |
| download | mullvadvpn-5300f1663559ebd7a87c699db8e858d13e6fa556.tar.xz mullvadvpn-5300f1663559ebd7a87c699db8e858d13e6fa556.zip | |
Merge branch 'implement-new-select-location-design-droid-1954'
68 files changed, 1343 insertions, 800 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") diff --git a/desktop/packages/mullvad-vpn/locales/messages.pot b/desktop/packages/mullvad-vpn/locales/messages.pot index b21fd8317c..7b0ac3c8f2 100644 --- a/desktop/packages/mullvad-vpn/locales/messages.pot +++ b/desktop/packages/mullvad-vpn/locales/messages.pot @@ -2787,6 +2787,9 @@ msgstr "" msgid "%s custom port" msgstr "" +msgid "%s is unavailable" +msgstr "" + msgid "%s was added to your account." msgstr "" @@ -3261,10 +3264,10 @@ msgstr "" msgid "Time added" msgstr "" -msgid "To add locations to a list, press the \"︙\" or long press on a country, city, or server." +msgid "To add locations to a list, press the pen or long press on a country, city, or server." msgstr "" -msgid "To create a custom list press the \"︙\"" +msgid "To create a custom list press the \"+\"" msgstr "" msgid "To make sure that you have the most secure version and to inform you of any issues with the current version that is running, the app performs version checks automatically. This sends the app version and Android system version to Mullvad servers. Mullvad keeps counters on number of used app versions and Android versions. The data is never stored or used in any way that can identify you." |
