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 index 9f1fef4e08ae..f00382cb419c 100644 --- 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 @@ -34,6 +34,7 @@ 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.dp import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.component.Chevron import net.mullvad.mullvadvpn.compose.component.MullvadCheckbox @@ -75,6 +76,76 @@ private fun PreviewCheckableRelayLocationCell( } } +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun RelayItemCell( + item: RelayItem, + isSelected: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit, + onToggleExpand: ((Boolean) -> Unit), + isExpanded: Boolean, + depth: Int, + modifier: Modifier = Modifier, + activeColor: Color = MaterialTheme.colorScheme.selected, + inactiveColor: Color = MaterialTheme.colorScheme.error, + disabledColor: Color = MaterialTheme.colorScheme.onSecondary, +) { + + 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 + // item is RelayItem.CustomList && !relayItem.active + // -> + // MaterialTheme.colorScheme.surfaceTint + else -> depth.toBackgroundColor() + } + /*specialBackgroundColor.invoke(item) ?: */ + ) + .combinedClickable( + enabled = item.active, + onClick = onClick, + onLongClick = onLongClick, + ) + .padding(start = startPadding), + verticalAlignment = Alignment.CenterVertically + ) { + if (isSelected) { + Icon(painter = painterResource(id = R.drawable.icon_tick), contentDescription = null) + } else { + Box( + modifier = + Modifier.padding(4.dp) + .size(Dimens.relayCircleSize) + .background( + color = + when { + isSelected -> Color.Unspecified + item is RelayItem.CustomList && item.locations.isEmpty() -> + disabledColor + item.active -> activeColor + else -> inactiveColor + }, + shape = CircleShape + ) + ) + } + Name(modifier = Modifier.weight(1f), relay = item) + + if (item.hasChildren) { + ExpandButton(isExpanded = isExpanded, onClick = { onToggleExpand(!isExpanded) }) + } + } +} + @Composable fun StatusRelayLocationCell( relay: RelayItem, @@ -87,7 +158,7 @@ fun StatusRelayLocationCell( onLongClick: (item: RelayItem) -> Unit = {}, ) { RelayLocationCell( - relay = relay, + item = relay, leadingContent = { relayItem -> val selected = selectedItem == relayItem.id Box( @@ -98,8 +169,8 @@ fun StatusRelayLocationCell( color = when { selected -> Color.Unspecified - relayItem is RelayItem.CustomList && !relayItem.hasChildren -> - disabledColor + relayItem is RelayItem.CustomList && + relayItem.locations.isEmpty() -> disabledColor relayItem.active -> activeColor else -> inactiveColor }, @@ -144,7 +215,7 @@ fun CheckableRelayLocationCell( selectedRelays: Set = emptySet(), ) { RelayLocationCell( - relay = relay, + item = relay, leadingContent = { relayItem -> val checked = selectedRelays.contains(relayItem) MullvadCheckbox( @@ -163,7 +234,7 @@ fun CheckableRelayLocationCell( @OptIn(ExperimentalFoundationApi::class) @Composable private fun RelayLocationCell( - relay: RelayItem, + item: RelayItem, leadingContent: @Composable BoxScope.(relay: RelayItem) -> Unit, modifier: Modifier = Modifier, leadingContentStartPadding: Dp = Dimens.cellStartPadding, @@ -174,8 +245,7 @@ private fun RelayLocationCell( depth: Int ) { val startPadding = leadingContentStartPadding + leadingContentStarPaddingModifier * depth - val expanded = - rememberSaveable(key = relay.expanded.toString()) { mutableStateOf(relay.expanded) } + val expanded = rememberSaveable(key = item.id.toString()) { mutableStateOf(false) } Column( modifier = modifier @@ -190,36 +260,33 @@ private fun RelayLocationCell( .wrapContentHeight() .height(IntrinsicSize.Min) .fillMaxWidth() - .background(specialBackgroundColor.invoke(relay) ?: depth.toBackgroundColor()) + .background(specialBackgroundColor.invoke(item) ?: depth.toBackgroundColor()) ) { Row( modifier = Modifier.weight(1f) .combinedClickable( - enabled = relay.active, - onClick = { onClick(relay) }, - onLongClick = { onLongClick?.invoke(relay) }, + enabled = item.active, + onClick = { onClick(item) }, + onLongClick = { onLongClick?.invoke(item) }, ) ) { Box( modifier = Modifier.align(Alignment.CenterVertically).padding(start = startPadding) ) { - leadingContent(relay) + leadingContent(item) } - Name( - modifier = Modifier.weight(1f).align(Alignment.CenterVertically), - relay = relay - ) + Name(modifier = Modifier.weight(1f).align(Alignment.CenterVertically), relay = item) } - if (relay.hasChildren) { + if (item.children().isNotEmpty()) { ExpandButton(isExpanded = expanded.value) { expand -> expanded.value = expand } } } if (expanded.value) { - relay.children().forEach { + item.children().forEach { RelayLocationCell( - relay = it, + item = it, onClick = onClick, modifier = Modifier.animateContentSize(), leadingContent = leadingContent, @@ -275,6 +342,5 @@ private fun Int.toBackgroundColor(): Color = 0 -> MaterialTheme.colorScheme.surfaceContainerHighest 1 -> MaterialTheme.colorScheme.surfaceContainerHigh 2 -> MaterialTheme.colorScheme.surfaceContainerLow - 3 -> MaterialTheme.colorScheme.surfaceContainerLowest else -> MaterialTheme.colorScheme.surfaceContainerLowest } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemCheckableCellPreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemCheckableCellPreviewParameterProvider.kt index c0cae0128f5b..bdf1ace17357 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemCheckableCellPreviewParameterProvider.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemCheckableCellPreviewParameterProvider.kt @@ -18,14 +18,11 @@ class RelayItemCheckableCellPreviewParameterProvider : name = "Relay country Expanded", cityNames = listOf("Normal city"), relaysPerCity = 2, - expanded = true ), generateRelayItemCountry( name = "Country and city Expanded", cityNames = listOf("Expanded city A", "Expanded city B"), relaysPerCity = 2, - expanded = true, - expandChildren = true ) ) ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemPreviewData.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemPreviewData.kt index afaf81ac5567..570c39441268 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemPreviewData.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemPreviewData.kt @@ -12,8 +12,6 @@ internal object RelayItemPreviewData { cityNames: List, relaysPerCity: Int, active: Boolean = true, - expanded: Boolean = false, - expandChildren: Boolean = false, ) = RelayItem.Location.Country( name = name, @@ -25,10 +23,8 @@ internal object RelayItemPreviewData { name.generateCountryCode(), relaysPerCity, active, - expandChildren ) }, - expanded = expanded, ) } @@ -37,7 +33,6 @@ private fun generateRelayItemCity( countryCode: GeoLocationId.Country, numberOfRelays: Int, active: Boolean = true, - expanded: Boolean = false, ) = RelayItem.Location.City( name = name, @@ -50,7 +45,6 @@ private fun generateRelayItemCity( active ) }, - expanded = expanded, ) private fun generateRelayItemRelay( 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 index 26ea64418595..a825975b0fad 100644 --- 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 @@ -24,14 +24,11 @@ class RelayItemStatusCellPreviewParameterProvider : name = "Relay country Expanded", cityNames = listOf("Normal city"), relaysPerCity = 2, - expanded = true ), generateRelayItemCountry( name = "Country and city Expanded", cityNames = listOf("Expanded city A", "Expanded city B"), relaysPerCity = 2, - expanded = true, - expandChildren = true ) ) ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt index 555983d51d9a..447ee60831ca 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt @@ -1,7 +1,6 @@ package net.mullvad.mullvadvpn.compose.screen import android.content.Context -import androidx.compose.animation.animateContentSize import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.gestures.animateScrollBy @@ -13,6 +12,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items @@ -61,11 +61,12 @@ import com.ramcosta.composedestinations.result.ResultBackNavigator import com.ramcosta.composedestinations.result.ResultRecipient import com.ramcosta.composedestinations.spec.DestinationSpec import kotlinx.coroutines.launch +import mullvad_daemon.management_interface.customList import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.cell.FilterCell import net.mullvad.mullvadvpn.compose.cell.HeaderCell import net.mullvad.mullvadvpn.compose.cell.IconCell -import net.mullvad.mullvadvpn.compose.cell.StatusRelayLocationCell +import net.mullvad.mullvadvpn.compose.cell.RelayItemCell import net.mullvad.mullvadvpn.compose.cell.SwitchComposeSubtitleCell import net.mullvad.mullvadvpn.compose.cell.ThreeDotCell import net.mullvad.mullvadvpn.compose.communication.Created @@ -81,6 +82,7 @@ import net.mullvad.mullvadvpn.compose.component.MullvadSnackbar import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar import net.mullvad.mullvadvpn.compose.constant.ContentType import net.mullvad.mullvadvpn.compose.extensions.dropUnlessResumed +import net.mullvad.mullvadvpn.compose.state.RelayListItem import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_CUSTOM_LIST_BOTTOM_SHEET_TEST_TAG @@ -91,8 +93,8 @@ import net.mullvad.mullvadvpn.compose.transitions.SelectLocationTransition import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle import net.mullvad.mullvadvpn.compose.util.RunOnKeyChange import net.mullvad.mullvadvpn.compose.util.showSnackbarImmediately +import net.mullvad.mullvadvpn.lib.model.CustomList 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.theme.AppTheme @@ -113,18 +115,9 @@ private fun PreviewSelectLocationScreen() { searchTerm = "", selectedOwnership = null, selectedProvidersCount = 0, - countries = - listOf( - RelayItem.Location.Country( - GeoLocationId.Country("Country 1"), - "Code 1", - false, - emptyList() - ) - ), - selectedItem = null, + relayListItems = emptyList(), customLists = emptyList(), - filteredCustomLists = emptyList() + selectedItem = null ) AppTheme { SelectLocationScreen( @@ -211,6 +204,7 @@ fun SelectLocation( CreateCustomListDestination(locationCode = relayItem?.id), ) }, + onToggleExpand = vm::onToggleExpand, onEditCustomLists = dropUnlessResumed { navigator.navigate(CustomListsDestination()) }, removeOwnershipFilter = vm::removeOwnerFilter, removeProviderFilter = vm::removeProviderFilter, @@ -221,7 +215,7 @@ fun SelectLocation( navigator.navigate( EditCustomListNameDestination( customListId = customList.id, - initialName = customList.customListName + initialName = customList.customList.name ), ) }, @@ -236,7 +230,7 @@ fun SelectLocation( navigator.navigate( DeleteCustomListDestination( customListId = customList.id, - name = customList.customListName + name = customList.customList.name ), ) } @@ -260,13 +254,13 @@ fun SelectLocationScreen( onAddLocationToList: (location: RelayItem.Location, customList: RelayItem.CustomList) -> Unit = { _, _ -> }, - onRemoveLocationFromList: - (location: RelayItem.Location, customList: RelayItem.CustomList) -> Unit = + onRemoveLocationFromList: (location: RelayItem.Location, customList: CustomListId) -> Unit = { _, _ -> }, onEditCustomListName: (RelayItem.CustomList) -> Unit = {}, onEditLocationsCustomList: (RelayItem.CustomList) -> Unit = {}, - onDeleteCustomList: (RelayItem.CustomList) -> Unit = {} + onDeleteCustomList: (RelayItem.CustomList) -> Unit = {}, + onToggleExpand: (RelayItemId, CustomListId?, Boolean) -> Unit = { _, _, _ -> }, ) { val backgroundColor = MaterialTheme.colorScheme.background @@ -329,6 +323,7 @@ fun SelectLocationScreen( lazyListState.animateScrollAndCentralizeItem(index) } } + LazyColumn( modifier = Modifier.fillMaxSize() @@ -344,58 +339,63 @@ fun SelectLocationScreen( loading() } is SelectLocationUiState.Content -> { - if (state.showCustomLists) { - customLists( - customLists = state.filteredCustomLists, - selectedItem = state.selectedItem, - backgroundColor = backgroundColor, - onSelectRelay = onSelectRelay, - onShowCustomListBottomSheet = { - bottomSheetState = - BottomSheetState.ShowCustomListsBottomSheet( - state.customLists.isNotEmpty() - ) - }, - onShowEditBottomSheet = { customList -> - bottomSheetState = - BottomSheetState.ShowEditCustomListBottomSheet(customList) - }, - onShowEditCustomListEntryBottomSheet = { - item: RelayItem.Location, - customList: RelayItem.CustomList -> - bottomSheetState = - BottomSheetState.ShowCustomListsEntryBottomSheet( - customList, - item, - ) - } - ) - item { - Spacer( - modifier = - Modifier.height(Dimens.mediumPadding) - .animateItemPlacement() - .animateContentSize() - ) + items(state.relayListItems, key = { a -> a.key }, contentType = { null }) { + when (it) { + RelayListItem.CustomListHeader -> + CustomListHeader( + onShowCustomListBottomSheet = { + bottomSheetState = + BottomSheetState.ShowCustomListsBottomSheet( + editListEnabled = false + ) + } + ) + is RelayListItem.CustomListItem -> + CustomListItem( + it, + onSelectRelay, + { + bottomSheetState = + BottomSheetState.ShowEditCustomListBottomSheet(it) + }, + { customListId, expand -> + onToggleExpand(customListId, null, expand) + } + ) + is RelayListItem.CustomListEntryItem -> + CustomListEntryItem( + it, + { onSelectRelay(it.item) }, + { + bottomSheetState = + BottomSheetState.ShowCustomListsEntryBottomSheet( + it.parentId, + it.item + ) + }, + { expand: Boolean -> + onToggleExpand(it.item.id, it.parentId, expand) + } + ) + is RelayListItem.CustomListFooter -> CustomListFooter(it) + RelayListItem.LocationHeader -> RelayLocationHeader() + is RelayListItem.GeoLocationItem -> + RelayLocationItem( + it, + { onSelectRelay(it.item) }, + { + bottomSheetState = + BottomSheetState.ShowLocationBottomSheet( + state.customLists, + it.item + ) + }, + { expand -> onToggleExpand(it.item.id, null, expand) } + ) + is RelayListItem.LocationsEmptyText -> + LocationsEmptyText(it.searchTerm) } } - if (state.countries.isNotEmpty()) { - relayList( - countries = state.countries, - selectedItem = state.selectedItem, - onSelectRelay = onSelectRelay, - onShowLocationBottomSheet = { location -> - bottomSheetState = - BottomSheetState.ShowLocationBottomSheet( - customLists = state.customLists, - item = location - ) - } - ) - } - if (state.showEmpty) { - item { LocationsEmptyText(searchTerm = state.searchTerm) } - } } } } @@ -403,6 +403,84 @@ fun SelectLocationScreen( } } +@Composable +fun LazyItemScope.RelayLocationHeader() { + HeaderCell(text = stringResource(R.string.all_locations), modifier = Modifier.animateItem()) +} + +@Composable +fun LazyItemScope.RelayLocationItem( + relayItem: RelayListItem.GeoLocationItem, + onSelectRelay: () -> Unit, + onLongClick: () -> Unit, + onExpand: (Boolean) -> Unit, +) { + val location = relayItem.item + RelayItemCell( + location, + relayItem.isSelected, + { onSelectRelay() }, + { onLongClick() }, + { onExpand(it) }, + relayItem.expanded, + relayItem.depth, + modifier = Modifier.animateItem() + ) +} + +@Composable +fun LazyItemScope.CustomListItem( + itemState: RelayListItem.CustomListItem, + onSelectRelay: (item: RelayItem) -> Unit, + onShowEditBottomSheet: (RelayItem.CustomList) -> Unit, + onExpand: ((CustomListId, Boolean) -> Unit), +) { + val customListItem = itemState.item + RelayItemCell( + customListItem, + itemState.isSelected, + { onSelectRelay(customListItem) }, + { onShowEditBottomSheet(customListItem) }, + { onExpand(customListItem.id, it) }, + itemState.expanded, + 0, + modifier = Modifier.animateItem() + ) +} + +@Composable +fun LazyItemScope.CustomListEntryItem( + itemState: RelayListItem.CustomListEntryItem, + onSelectRelay: () -> Unit, + onShowEditCustomListEntryBottomSheet: () -> Unit, + onToggleExpand: (Boolean) -> Unit, +) { + val customListEntryItem = itemState.item + RelayItemCell( + customListEntryItem, + false, + onSelectRelay, + onShowEditCustomListEntryBottomSheet, + onToggleExpand, + itemState.expanded, + itemState.depth, + modifier = Modifier.animateItem() + ) +} + +@Composable +fun LazyItemScope.CustomListFooter(item: RelayListItem.CustomListFooter) { + SwitchComposeSubtitleCell( + text = + if (item.hasCustomList) { + stringResource(R.string.to_add_locations_to_a_list) + } else { + stringResource(R.string.to_create_a_custom_list) + }, + modifier = Modifier.background(MaterialTheme.colorScheme.background).animateItem() + ) +} + @Composable private fun SelectLocationTopBar(onBackClick: () -> Unit, onFilterClick: () -> Unit) { Row(modifier = Modifier.fillMaxWidth()) { @@ -437,95 +515,13 @@ private fun LazyListScope.loading() { } } -@OptIn(ExperimentalFoundationApi::class) -private fun LazyListScope.customLists( - customLists: List, - selectedItem: RelayItemId?, - backgroundColor: Color, - onSelectRelay: (item: RelayItem) -> Unit, - onShowCustomListBottomSheet: () -> Unit, - onShowEditBottomSheet: (RelayItem.CustomList) -> Unit, - onShowEditCustomListEntryBottomSheet: (item: RelayItem.Location, RelayItem.CustomList) -> Unit -) { - item( - contentType = { ContentType.HEADER }, - ) { - ThreeDotCell( - text = stringResource(R.string.custom_lists), - onClickDots = onShowCustomListBottomSheet, - modifier = - Modifier.testTag(SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG) - .animateItemPlacement() - .animateContentSize() - ) - } - if (customLists.isNotEmpty()) { - items( - items = customLists, - key = { item -> item.id }, - contentType = { ContentType.ITEM }, - ) { customList -> - StatusRelayLocationCell( - relay = customList, - // Do not show selection for locations in custom lists - selectedItem = selectedItem as? CustomListId, - onSelectRelay = onSelectRelay, - onLongClick = { - if (it is RelayItem.CustomList) { - onShowEditBottomSheet(it) - } else if (it is RelayItem.Location && it in customList.locations) { - onShowEditCustomListEntryBottomSheet(it, customList) - } - }, - modifier = Modifier.animateContentSize().animateItemPlacement(), - ) - } - item { - SwitchComposeSubtitleCell( - text = stringResource(R.string.to_add_locations_to_a_list), - modifier = - Modifier.background(backgroundColor).animateItemPlacement().animateContentSize() - ) - } - } else { - item(contentType = ContentType.EMPTY_TEXT) { - SwitchComposeSubtitleCell( - text = stringResource(R.string.to_create_a_custom_list), - modifier = - Modifier.background(backgroundColor).animateItemPlacement().animateContentSize() - ) - } - } -} - -@OptIn(ExperimentalFoundationApi::class) -private fun LazyListScope.relayList( - countries: List, - selectedItem: RelayItemId?, - onSelectRelay: (item: RelayItem) -> Unit, - onShowLocationBottomSheet: (item: RelayItem.Location) -> Unit, -) { - item( - contentType = ContentType.HEADER, - ) { - HeaderCell( - text = stringResource(R.string.all_locations), - modifier = Modifier.animateItemPlacement().animateContentSize() - ) - } - items( - items = countries, - key = { item -> item.id }, - contentType = { ContentType.ITEM }, - ) { country -> - StatusRelayLocationCell( - relay = country, - selectedItem = selectedItem, - onSelectRelay = onSelectRelay, - onLongClick = { onShowLocationBottomSheet(it as RelayItem.Location) }, - modifier = Modifier.animateContentSize().animateItemPlacement(), - ) - } +@Composable +private fun LazyItemScope.CustomListHeader(onShowCustomListBottomSheet: () -> Unit) { + ThreeDotCell( + text = stringResource(R.string.custom_lists), + onClickDots = onShowCustomListBottomSheet, + modifier = Modifier.testTag(SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG).animateItem() + ) } @OptIn(ExperimentalMaterial3Api::class) @@ -535,7 +531,7 @@ private fun BottomSheets( onCreateCustomList: (RelayItem.Location?) -> Unit, onEditCustomLists: () -> Unit, onAddLocationToList: (RelayItem.Location, RelayItem.CustomList) -> Unit, - onRemoveLocationFromList: (RelayItem.Location, RelayItem.CustomList) -> Unit, + onRemoveLocationFromList: (location: RelayItem.Location, parent: CustomListId) -> Unit, onEditCustomListName: (RelayItem.CustomList) -> Unit, onEditLocationsCustomList: (RelayItem.CustomList) -> Unit, onDeleteCustomList: (RelayItem.CustomList) -> Unit, @@ -595,7 +591,7 @@ private fun BottomSheets( CustomListEntryBottomSheet( sheetState = sheetState, onBackgroundColor = onBackgroundColor, - customList = bottomSheetState.customList, + customListId = bottomSheetState.parentId, item = bottomSheetState.item, onRemoveLocationFromList = onRemoveLocationFromList, closeBottomSheet = onCloseBottomSheet @@ -609,14 +605,16 @@ private fun BottomSheets( private fun SelectLocationUiState.indexOfSelectedRelayItem(): Int = if (this is SelectLocationUiState.Content) { - when (selectedItem) { - is CustomListId -> - filteredCustomLists.indexOfFirst { it.id == selectedItem } + EXTRA_ITEM_CUSTOM_LIST - is GeoLocationId -> - countries.indexOfFirst { it.id == selectedItem.country } + - customLists.size + - EXTRA_ITEMS_LOCATION - else -> -1 + relayListItems.indexOfFirst { + when (it) { + is RelayListItem.CustomListItem -> it.isSelected + is RelayListItem.GeoLocationItem -> it.isSelected + is RelayListItem.CustomListEntryItem -> false + is RelayListItem.CustomListFooter -> false + RelayListItem.CustomListHeader -> false + RelayListItem.LocationHeader -> false + is RelayListItem.LocationsEmptyText -> false + } } } else { -1 @@ -787,10 +785,9 @@ private fun EditCustomListBottomSheet( private fun CustomListEntryBottomSheet( onBackgroundColor: Color, sheetState: SheetState, - customList: RelayItem.CustomList, + customListId: CustomListId, item: RelayItem.Location, - onRemoveLocationFromList: - (location: RelayItem.Location, customList: RelayItem.CustomList) -> Unit, + onRemoveLocationFromList: (location: RelayItem.Location, customList: CustomListId) -> Unit, closeBottomSheet: (animate: Boolean) -> Unit ) { MullvadModalBottomSheet( @@ -809,7 +806,7 @@ private fun CustomListEntryBottomSheet( title = stringResource(id = R.string.remove_button), titleColor = onBackgroundColor, onClick = { - onRemoveLocationFromList(item, customList) + onRemoveLocationFromList(item, customListId) closeBottomSheet(true) }, background = Color.Unspecified @@ -879,16 +876,12 @@ private fun ResultRecipient } } -private const val EXTRA_ITEMS_LOCATION = - 4 // Custom lists header, custom lists description, spacer, all locations header -private const val EXTRA_ITEM_CUSTOM_LIST = 1 // Custom lists header - sealed interface BottomSheetState { data class ShowCustomListsBottomSheet(val editListEnabled: Boolean) : BottomSheetState data class ShowCustomListsEntryBottomSheet( - val customList: RelayItem.CustomList, + val parentId: CustomListId, val item: RelayItem.Location ) : BottomSheetState diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt index 79f434aad1a0..9a74c63918b9 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt @@ -1,5 +1,6 @@ package net.mullvad.mullvadvpn.compose.state +import net.mullvad.mullvadvpn.lib.model.CustomListId import net.mullvad.mullvadvpn.lib.model.Ownership import net.mullvad.mullvadvpn.lib.model.RelayItem import net.mullvad.mullvadvpn.lib.model.RelayItemId @@ -13,16 +14,67 @@ sealed interface SelectLocationUiState { val searchTerm: String, val selectedOwnership: Ownership?, val selectedProvidersCount: Int?, - val filteredCustomLists: List, + val relayListItems: List, val customLists: List, - val countries: List, val selectedItem: RelayItemId? ) : SelectLocationUiState { val hasFilter: Boolean = (selectedProvidersCount != null || selectedOwnership != null) val inSearch = searchTerm.length >= MIN_SEARCH_LENGTH - val showCustomLists = inSearch.not() || filteredCustomLists.isNotEmpty() - // Show empty state if we don't have any relays or if we are searching and no custom list or - // relay is found - val showEmpty = countries.isEmpty() && (inSearch.not() || filteredCustomLists.isEmpty()) + } +} + +sealed interface RelayListItem { + val key: Any + + data object CustomListHeader : RelayListItem { + override val key = "custom_list_header" + } + + sealed interface SelectableItem : RelayListItem { + val depth: Int + val isSelected: Boolean + val expanded: Boolean + } + + data class CustomListItem( + val item: RelayItem.CustomList, + override val isSelected: Boolean, + override val expanded: Boolean, + ) : SelectableItem { + override val key = item.id + override val depth: Int = 0 + } + + data class CustomListEntryItem( + val parentId: CustomListId, + val item: RelayItem.Location, + override val expanded: Boolean, + override val depth: Int = 0 + ) : SelectableItem { + override val key = parentId to item.id + + // Can't be displayed as selected + override val isSelected: Boolean = false + } + + data class GeoLocationItem( + val item: RelayItem.Location, + override val isSelected: Boolean, + override val depth: Int, + override val expanded: Boolean, + ) : SelectableItem { + override val key = item.id + } + + data class CustomListFooter(val hasCustomList: Boolean) : RelayListItem { + override val key = "custom_list_footer" + } + + data object LocationHeader : RelayListItem { + override val key: Any = "location_header" + } + + data class LocationsEmptyText(val searchTerm: String) : RelayListItem { + override val key: Any = "locations_empty_text" } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt index 371a30bdf1c5..f9ab7d53bf0b 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt @@ -183,7 +183,7 @@ val uiModule = module { viewModel { DnsDialogViewModel(get(), get(), get()) } viewModel { LoginViewModel(get(), get(), get()) } viewModel { PrivacyDisclaimerViewModel(get(), IS_PLAY_BUILD) } - viewModel { SelectLocationViewModel(get(), get(), get(), get(), get(), get()) } + viewModel { SelectLocationViewModel(get(), get(), get(), get(), get(), get(), get()) } viewModel { SettingsViewModel(get(), get(), IS_PLAY_BUILD) } viewModel { SplashViewModel(get(), get(), get(), get()) } viewModel { VoucherDialogViewModel(get()) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/CustomListExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/CustomListExtensions.kt index 2a7eeddb696b..ac03080e218d 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/CustomListExtensions.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/CustomListExtensions.kt @@ -8,9 +8,7 @@ fun CustomList.toRelayItemCustomList( relayCountries: List ): RelayItem.CustomList = RelayItem.CustomList( - id = id, - customListName = name, - expanded = false, + customList = this, locations = locations.mapNotNull { relayCountries.findByGeoLocationId(it) }, ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListExtensions.kt index 069f0e1a088a..d0ae284d865f 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListExtensions.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListExtensions.kt @@ -1,9 +1,7 @@ package net.mullvad.mullvadvpn.relaylist -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 fun List.findByGeoLocationId(geoLocationId: GeoLocationId) = withDescendants().firstOrNull { it.id == geoLocationId } @@ -18,112 +16,116 @@ fun List.findByGeoLocationId(geoLocationId: GeoLocat * expanded If a relay is matched, its parents are added and expanded and itself is also added. */ @Suppress("NestedBlockDepth") -fun List.filterOnSearchTerm( - searchTerm: String, - selectedItem: RelayItemId? -): List { - return if (searchTerm.length >= MIN_SEARCH_LENGTH) { - val filteredCountries = mutableMapOf() - this.forEach { relayCountry -> - val cities = mutableListOf() +// fun List.filterOnSearchTerm( +// searchTerm: String, +// selectedItem: RelayItemId? +// ): List { +// return if (searchTerm.length >= MIN_SEARCH_LENGTH) { +// val filteredCountries = mutableMapOf() +// this.forEach { relayCountry -> +// val cities = mutableListOf() +// +// // Try to match the search term with a country +// // If we match a country, add that country and all cities and relays in that country +// // Do not currently expand the country or any city +// if (relayCountry.name.contains(other = searchTerm, ignoreCase = true)) { +// cities.addAll(relayCountry.cities.map { city -> city.copy(expanded = false) }) +// filteredCountries[relayCountry.id] = +// relayCountry.copy(expanded = false, cities = cities) +// } +// +// // Go through and try to match the search term with every city +// relayCountry.cities.forEach { relayCity -> +// val relays = mutableListOf() +// // If we match and we already added the country to the filtered list just expand +// the +// // country. +// // If the country is not currently in the filtered list, add it and expand it. +// // Finally if the city has not already been added to the filtered list, add it, +// but +// // do not expand it yet. +// if (relayCity.name.contains(other = searchTerm, ignoreCase = true)) { +// val value = filteredCountries[relayCountry.id] +// if (value != null) { +// filteredCountries[relayCountry.id] = value.copy(expanded = true) +// } else { +// filteredCountries[relayCountry.id] = +// relayCountry.copy(expanded = true, cities = cities) +// } +// if (cities.none { city -> city.id == relayCity.id }) { +// cities.add(relayCity.copy(expanded = false)) +// } +// } +// +// // Go through and try to match the search term with every relay +// relayCity.relays.forEach { relay -> +// // If we match a relay, check if the county the relay is in already is added, +// if +// // so expand, if not add and expand the country. +// // Check if the city that the relay is in is already added to the filtered +// list, +// // if so expand it, if not add it to the filtered list and expand it. +// // Finally add the relay to the list. +// if (relay.name.contains(other = searchTerm, ignoreCase = true)) { +// val value = filteredCountries[relayCountry.id] +// if (value != null) { +// filteredCountries[relayCountry.id] = value.copy(expanded = true) +// } else { +// filteredCountries[relayCountry.id] = +// relayCountry.copy(expanded = true, cities = cities) +// } +// val cityIndex = cities.indexOfFirst { it.id == relayCity.id } +// +// // No city found +// if (cityIndex < 0) { +// cities.add(relayCity.copy(expanded = true, relays = relays)) +// } else { +// // Update found city as expanded +// cities[cityIndex] = cities[cityIndex].copy(expanded = true) +// } +// +// relays.add(relay.copy()) +// } +// } +// } +// } +// filteredCountries.values.sortedBy { it.name } +// } else { +// this.expandItemForSelection(selectedItem) +// } +// } - // Try to match the search term with a country - // If we match a country, add that country and all cities and relays in that country - // Do not currently expand the country or any city - if (relayCountry.name.contains(other = searchTerm, ignoreCase = true)) { - cities.addAll(relayCountry.cities.map { city -> city.copy(expanded = false) }) - filteredCountries[relayCountry.id] = - relayCountry.copy(expanded = false, cities = cities) - } - - // Go through and try to match the search term with every city - relayCountry.cities.forEach { relayCity -> - val relays = mutableListOf() - // If we match and we already added the country to the filtered list just expand the - // country. - // If the country is not currently in the filtered list, add it and expand it. - // Finally if the city has not already been added to the filtered list, add it, but - // do not expand it yet. - if (relayCity.name.contains(other = searchTerm, ignoreCase = true)) { - val value = filteredCountries[relayCountry.id] - if (value != null) { - filteredCountries[relayCountry.id] = value.copy(expanded = true) - } else { - filteredCountries[relayCountry.id] = - relayCountry.copy(expanded = true, cities = cities) - } - if (cities.none { city -> city.id == relayCity.id }) { - cities.add(relayCity.copy(expanded = false)) - } - } - - // Go through and try to match the search term with every relay - relayCity.relays.forEach { relay -> - // If we match a relay, check if the county the relay is in already is added, if - // so expand, if not add and expand the country. - // Check if the city that the relay is in is already added to the filtered list, - // if so expand it, if not add it to the filtered list and expand it. - // Finally add the relay to the list. - if (relay.name.contains(other = searchTerm, ignoreCase = true)) { - val value = filteredCountries[relayCountry.id] - if (value != null) { - filteredCountries[relayCountry.id] = value.copy(expanded = true) - } else { - filteredCountries[relayCountry.id] = - relayCountry.copy(expanded = true, cities = cities) - } - val cityIndex = cities.indexOfFirst { it.id == relayCity.id } - - // No city found - if (cityIndex < 0) { - cities.add(relayCity.copy(expanded = true, relays = relays)) - } else { - // Update found city as expanded - cities[cityIndex] = cities[cityIndex].copy(expanded = true) - } - - relays.add(relay.copy()) - } - } - } - } - filteredCountries.values.sortedBy { it.name } - } else { - this.expandItemForSelection(selectedItem) - } -} - -/** Expand the parent(s), if any, for the current selected item */ -private fun List.expandItemForSelection( - selectedItem: RelayItemId? -): List { - selectedItem ?: return this - return when (selectedItem) { - is CustomListId, - is GeoLocationId.Country -> this - is GeoLocationId.City -> - map { if (it.id == selectedItem.country) it.copy(expanded = true) else it } - is GeoLocationId.Hostname -> { - map { country -> - if (country.id == selectedItem.country) { - country.copy( - expanded = true, - cities = - country.cities.map { city -> - if (city.id == selectedItem.city) { - city.copy(expanded = true) - } else { - city - } - }, - ) - } else { - country - } - } - } - } -} +/// ** Expand the parent(s), if any, for the current selected item */ +// private fun List.expandItemForSelection( +// selectedItem: RelayItemId? +// ): List { +// selectedItem ?: return this +// return when (selectedItem) { +// is CustomListId, +// is GeoLocationId.Country -> this +// is GeoLocationId.City -> +// map { if (it.id == selectedItem.country) it.copy(expanded = true) else it } +// is GeoLocationId.Hostname -> { +// map { country -> +// if (country.id == selectedItem.country) { +// country.copy( +// expanded = true, +// cities = +// country.cities.map { city -> +// if (city.id == selectedItem.city) { +// city.copy(expanded = true) +// } else { +// city +// } +// }, +// ) +// } else { +// country +// } +// } +// } +// } +// } fun List.getRelayItemsByCodes( codes: List 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 6d738a641707..aa884fae32b7 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 @@ -18,7 +18,6 @@ import net.mullvad.mullvadvpn.compose.communication.LocationsChanged import net.mullvad.mullvadvpn.compose.state.CustomListLocationsUiState import net.mullvad.mullvadvpn.lib.model.RelayItem import net.mullvad.mullvadvpn.relaylist.descendants -import net.mullvad.mullvadvpn.relaylist.filterOnSearchTerm import net.mullvad.mullvadvpn.relaylist.withDescendants import net.mullvad.mullvadvpn.repository.RelayListRepository import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase @@ -45,7 +44,9 @@ class CustomListLocationsViewModel( relayCountries, searchTerm, selectedLocations -> - val filteredRelayCountries = relayCountries.filterOnSearchTerm(searchTerm, null) + // val filteredRelayCountries = + // relayCountries.filterOnSearchTerm(searchTerm, null) + val filteredRelayCountries = relayCountries when { selectedLocations == null -> diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt index 2509fdc8765d..a7af563ab5bb 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt @@ -2,25 +2,33 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import arrow.core.raise.either import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.compose.communication.CustomListAction import net.mullvad.mullvadvpn.compose.communication.LocationsChanged +import net.mullvad.mullvadvpn.compose.state.RelayListItem +import net.mullvad.mullvadvpn.compose.state.RelayListItem.CustomListHeader import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState import net.mullvad.mullvadvpn.compose.state.toNullableOwnership import net.mullvad.mullvadvpn.compose.state.toSelectedProviders import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.GeoLocationId import net.mullvad.mullvadvpn.lib.model.Ownership import net.mullvad.mullvadvpn.lib.model.Provider import net.mullvad.mullvadvpn.lib.model.Providers import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.lib.model.RelayItemId import net.mullvad.mullvadvpn.relaylist.descendants import net.mullvad.mullvadvpn.relaylist.filterOnOwnershipAndProvider import net.mullvad.mullvadvpn.relaylist.filterOnSearchTerm +import net.mullvad.mullvadvpn.repository.CustomListsRepository import net.mullvad.mullvadvpn.repository.RelayListFilterRepository import net.mullvad.mullvadvpn.repository.RelayListRepository import net.mullvad.mullvadvpn.usecase.AvailableProvidersUseCase @@ -33,18 +41,33 @@ class SelectLocationViewModel( private val relayListFilterRepository: RelayListFilterRepository, availableProvidersUseCase: AvailableProvidersUseCase, customListsRelayItemUseCase: CustomListsRelayItemUseCase, + private val customListsRepository: CustomListsRepository, private val customListActionUseCase: CustomListActionUseCase, filteredRelayListUseCase: FilteredRelayListUseCase, private val relayListRepository: RelayListRepository ) : ViewModel() { private val _searchTerm = MutableStateFlow(EMPTY_SEARCH_TERM) + private fun initialExpand(): Set { + val item = relayListRepository.selectedLocation.value.getOrNull() + return when (item) { + is CustomListId -> setOf() + is GeoLocationId.City -> setOf(item.countryCode.countryCode) + is GeoLocationId.Country -> setOf() + is GeoLocationId.Hostname -> setOf(item.country.countryCode, item.city.cityCode) + null -> setOf() + } + } + + private val _expandedItems = MutableStateFlow(initialExpand()) + @Suppress("DestructuringDeclarationWithTooManyEntries") val uiState = combine( filteredRelayListUseCase(), customListsRelayItemUseCase(), relayListRepository.selectedLocation, + _expandedItems, _searchTerm, relayListFilterRepository.selectedOwnership, availableProvidersUseCase(), @@ -53,6 +76,7 @@ class SelectLocationViewModel( relayCountries, customLists, selectedItem, + expandedItems, searchTerm, selectedOwnership, allProviders, @@ -70,8 +94,10 @@ class SelectLocationViewModel( .size } - val filteredRelayCountries = - relayCountries.filterOnSearchTerm(searchTerm, selectRelayItemId) + // val filteredRelayCountries = + // relayCountries.filterOnSearchTerm(searchTerm, + // selectRelayItemId) + // val filteredRelayCountries = relayCountries val filteredCustomLists = customLists @@ -85,9 +111,14 @@ class SelectLocationViewModel( searchTerm = searchTerm, selectedOwnership = selectedOwnershipItem, selectedProvidersCount = selectedProvidersCount, - filteredCustomLists = filteredCustomLists, + relayListItems = + createRelayListItems( + selectedItem.getOrNull(), + filteredCustomLists, + relayCountries, + expandedItems + ), customLists = customLists, - countries = filteredRelayCountries, selectedItem = selectRelayItemId, ) } @@ -100,6 +131,133 @@ class SelectLocationViewModel( private val _uiSideEffect = Channel() val uiSideEffect = _uiSideEffect.receiveAsFlow() + fun createRelayListItems( + selectedItem: RelayItemId?, + customLists: List, + countries: List, + expandedkeys: Set + ): List { + + val customListItems: List = + customLists.flatMap { customList -> + val expanded = customList.id.expandKey() in expandedkeys + val item = + listOf( + RelayListItem.CustomListItem( + customList, + isSelected = selectedItem == customList.id, + expanded + ) + ) + + if (expanded) { + item + + customList.locations.flatMap { + createCustomListEntry( + parent = customList.id, + item = it, + 1, + expandedkeys + ) + } + } else { + item + } + } + + val relayLocations: List = + countries.flatMap { country -> + createGeoLocationEntry(country, selectedItem, expandedkeys = expandedkeys) + } + + return listOf(CustomListHeader) + + customListItems + + listOf( + RelayListItem.CustomListFooter(customListItems.isNotEmpty()), + RelayListItem.LocationHeader + ) + + relayLocations + } + + fun createCustomListEntry( + parent: CustomListId, + item: RelayItem.Location, + depth: Int = 1, + expandedkeys: Set + ): List { + val expanded = item.id.expandKey(parent) in expandedkeys + val entry = + listOf( + RelayListItem.CustomListEntryItem( + parentId = parent, + item = item, + expanded = expanded, + depth + ) + ) + + return if (expanded) { + entry + + when (item) { + is RelayItem.Location.City -> + item.relays.flatMap { + createCustomListEntry(parent, it, depth + 1, expandedkeys) + } + is RelayItem.Location.Country -> + item.cities.flatMap { + createCustomListEntry(parent, it, depth + 1, expandedkeys) + } + is RelayItem.Location.Relay -> emptyList() + } + } else { + entry + } + } + + fun createGeoLocationEntry( + item: RelayItem.Location, + selectedItem: RelayItemId?, + depth: Int = 0, + expandedkeys: Set + ): List { + val expanded = item.id.expandKey() in expandedkeys + val entry = + listOf( + RelayListItem.GeoLocationItem( + item = item, + isSelected = selectedItem == item.id, + depth = depth, + expanded = expanded, + ) + ) + + return if (expanded) { + entry + + when (item) { + is RelayItem.Location.City -> + item.relays.flatMap { + createGeoLocationEntry(it, selectedItem, depth + 1, expandedkeys) + } + is RelayItem.Location.Country -> + item.cities.flatMap { + createGeoLocationEntry(it, selectedItem, depth + 1, expandedkeys) + } + is RelayItem.Location.Relay -> emptyList() + } + } else { + entry + } + } + + private fun RelayItemId.expandKey(parent: CustomListId? = null) = + (parent?.value ?: "") + + when (this) { + is CustomListId -> value + is GeoLocationId.City -> cityCode + is GeoLocationId.Country -> countryCode + is GeoLocationId.Hostname -> hostname + } + fun selectRelay(relayItem: RelayItem) { viewModelScope.launch { val locationConstraint = relayItem.id @@ -112,6 +270,17 @@ class SelectLocationViewModel( } } + fun onToggleExpand(item: RelayItemId, parent: CustomListId? = null, expand: Boolean) { + _expandedItems.update { + val key = item.expandKey(parent) + if (expand) { + it + key + } else { + it - key + } + } + } + fun onSearchTermInput(searchTerm: String) { viewModelScope.launch { _searchTerm.emit(searchTerm) } } @@ -147,18 +316,24 @@ class SelectLocationViewModel( viewModelScope.launch { customListActionUseCase(action) } } - fun removeLocationFromList(item: RelayItem.Location, customList: RelayItem.CustomList) { + fun removeLocationFromList(item: RelayItem.Location, customListId: CustomListId) { viewModelScope.launch { - val newLocations = (customList.locations - item).map { it.id } - customListActionUseCase(CustomListAction.UpdateLocations(customList.id, newLocations)) - .fold( - { _uiSideEffect.send(SelectLocationSideEffect.GenericError) }, - { - _uiSideEffect.send( - SelectLocationSideEffect.LocationRemovedFromCustomList(it) - ) + val result = + either { + val customList = + customListsRepository.getCustomListById(customListId).bind() + val newLocations = (customList.locations - item.id) + + customListActionUseCase( + CustomListAction.UpdateLocations(customList.id, newLocations) + ) + .bind() } - ) + .fold( + { SelectLocationSideEffect.GenericError }, + { SelectLocationSideEffect.LocationRemovedFromCustomList(it) } + ) + _uiSideEffect.send(result) } } diff --git a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt index 13ebe74350ce..50599ad7f448 100644 --- a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt +++ b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt @@ -455,7 +455,6 @@ internal fun ManagementInterface.RelayListCountry.toDomain(): RelayItem.Location return RelayItem.Location.Country( countryCode, name, - false, citiesList .map { city -> city.toDomain(countryCode) } .filter { it.relays.isNotEmpty() } @@ -470,7 +469,6 @@ internal fun ManagementInterface.RelayListCity.toDomain( return RelayItem.Location.City( name = name, id = cityCode, - expanded = false, relays = relaysList .filter { it.endpointType == ManagementInterface.Relay.RelayType.WIREGUARD } diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItem.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItem.kt index a31a6f67df02..ff809a31dde8 100644 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItem.kt +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItem.kt @@ -2,28 +2,26 @@ package net.mullvad.mullvadvpn.lib.model import arrow.optics.optics +typealias DomainCustomList = CustomList + @optics sealed interface RelayItem { val id: RelayItemId val name: String + val active: Boolean val hasChildren: Boolean - val expanded: Boolean @optics data class CustomList( - override val id: CustomListId, - val customListName: CustomListName, + val customList: DomainCustomList, val locations: List, - override val expanded: Boolean, ) : RelayItem { - override val name: String = customListName.value + override val name: String = customList.name.value + override val id = customList.id - override val active - get() = locations.any { location -> location.active } - - override val hasChildren - get() = locations.isNotEmpty() + override val active = locations.any { it.active } + override val hasChildren: Boolean = locations.isNotEmpty() companion object } @@ -36,16 +34,11 @@ sealed interface RelayItem { data class Country( override val id: GeoLocationId.Country, override val name: String, - override val expanded: Boolean, val cities: List ) : Location { val relays = cities.flatMap { city -> city.relays } - - override val active - get() = cities.any { city -> city.active } - - override val hasChildren - get() = cities.isNotEmpty() + override val active = cities.any { it.active } + override val hasChildren: Boolean = cities.isNotEmpty() companion object } @@ -54,15 +47,10 @@ sealed interface RelayItem { data class City( override val id: GeoLocationId.City, override val name: String, - override val expanded: Boolean, val relays: List ) : Location { - - override val active - get() = relays.any { relay -> relay.active } - - override val hasChildren - get() = relays.isNotEmpty() + override val active = relays.any { it.active } + override val hasChildren: Boolean = relays.isNotEmpty() companion object } @@ -74,9 +62,7 @@ sealed interface RelayItem { override val active: Boolean, ) : Location { override val name: String = id.hostname - - override val hasChildren = false - override val expanded = false + override val hasChildren: Boolean = false companion object } 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 d4e5e4803db4..d38813f59674 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 @@ -18,7 +18,7 @@ data class Dimensions( val cellHeight: Dp = 56.dp, val cellHeightTwoRows: Dp = 72.dp, val cellLabelVerticalPadding: Dp = 14.dp, - val cellStartPadding: Dp = 22.dp, + val cellStartPadding: Dp = 14.dp, val cellStartPaddingInteractive: Dp = 14.dp, val cellTopPadding: Dp = 6.dp, val cellVerticalSpacing: Dp = 14.dp,