diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/communication/CustomListActionResultData.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/communication/CustomListActionResultData.kt new file mode 100644 index 000000000000..441ddf1fc2c4 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/communication/CustomListActionResultData.kt @@ -0,0 +1,53 @@ +package net.mullvad.mullvadvpn.compose.communication + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import net.mullvad.mullvadvpn.lib.model.CustomListName + +sealed interface CustomListActionResultData : Parcelable { + + sealed interface Success : CustomListActionResultData, Parcelable { + val undo: CustomListAction + + @Parcelize + data class CreatedWithLocations( + val customListName: CustomListName, + val locationNames: List, + override val undo: CustomListAction + ) : Success + + @Parcelize + data class Deleted( + val customListName: CustomListName, + override val undo: CustomListAction.Create + ) : Success + + @Parcelize + data class Renamed( + val newName: CustomListName, + override val undo: CustomListAction.Rename + ) : Success + + @Parcelize + data class LocationAdded( + val customListName: CustomListName, + val locationName: String, + override val undo: CustomListAction + ) : Success + + @Parcelize + data class LocationRemoved( + val customListName: CustomListName, + val locationName: String, + override val undo: CustomListAction + ) : Success + + @Parcelize + data class LocationChanged( + val customListName: CustomListName, + override val undo: CustomListAction + ) : Success + } + + @Parcelize data object GenericError : CustomListActionResultData +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/communication/CustomListSuccess.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/communication/CustomListSuccess.kt index d83cd4c76de8..3b6a30bf38ca 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/communication/CustomListSuccess.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/communication/CustomListSuccess.kt @@ -1,9 +1,11 @@ package net.mullvad.mullvadvpn.compose.communication import android.os.Parcelable +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import net.mullvad.mullvadvpn.lib.model.CustomListId import net.mullvad.mullvadvpn.lib.model.CustomListName +import net.mullvad.mullvadvpn.lib.model.GeoLocationId sealed interface CustomListSuccess : Parcelable { val undo: CustomListAction @@ -31,6 +33,14 @@ data class Renamed(override val undo: CustomListAction.Rename) : CustomListSucce @Parcelize data class LocationsChanged( + val id: CustomListId, val name: CustomListName, + val locations: List, + val oldLocations: List, +) : CustomListSuccess { override val undo: CustomListAction.UpdateLocations -) : CustomListSuccess + get() = CustomListAction.UpdateLocations(id, oldLocations) + + @IgnoredOnParcel val addedLocations = locations - oldLocations + @IgnoredOnParcel val removedLocations = oldLocations - locations +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CreateCustomListDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CreateCustomListDialog.kt index db7f664e37b4..a94673ef454e 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CreateCustomListDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CreateCustomListDialog.kt @@ -23,7 +23,7 @@ import com.ramcosta.composedestinations.result.ResultBackNavigator import com.ramcosta.composedestinations.spec.DestinationStyle import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.PrimaryButton -import net.mullvad.mullvadvpn.compose.communication.Created +import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData import net.mullvad.mullvadvpn.compose.component.CustomListNameTextField import net.mullvad.mullvadvpn.compose.state.CreateCustomListUiState import net.mullvad.mullvadvpn.compose.test.CREATE_CUSTOM_LIST_DIALOG_INPUT_TEST_TAG @@ -63,7 +63,7 @@ data class CreateCustomListNavArgs(val locationCode: GeoLocationId?) ) fun CreateCustomList( navigator: DestinationsNavigator, - backNavigator: ResultBackNavigator, + backNavigator: ResultBackNavigator, ) { val vm: CreateCustomListDialogViewModel = koinViewModel() LaunchedEffect(key1 = Unit) { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteCustomListConfirmationDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteCustomListConfirmationDialog.kt index 0f26bcbe4865..bf0cd4927785 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteCustomListConfirmationDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteCustomListConfirmationDialog.kt @@ -11,7 +11,7 @@ import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.result.ResultBackNavigator import com.ramcosta.composedestinations.spec.DestinationStyle import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.communication.Deleted +import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData import net.mullvad.mullvadvpn.compose.state.DeleteCustomListUiState import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect import net.mullvad.mullvadvpn.lib.model.CustomListId @@ -39,7 +39,7 @@ data class DeleteCustomListNavArgs(val customListId: CustomListId, val name: Cus navArgs = DeleteCustomListNavArgs::class ) fun DeleteCustomList( - navigator: ResultBackNavigator, + navigator: ResultBackNavigator, ) { val viewModel: DeleteCustomListConfirmationViewModel = koinViewModel() val state by viewModel.uiState.collectAsStateWithLifecycle() diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/EditCustomListNameDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/EditCustomListNameDialog.kt index 3a6d234fc6b1..2b5c4372d05a 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/EditCustomListNameDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/EditCustomListNameDialog.kt @@ -17,7 +17,7 @@ import com.ramcosta.composedestinations.result.ResultBackNavigator import com.ramcosta.composedestinations.spec.DestinationStyle import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.PrimaryButton -import net.mullvad.mullvadvpn.compose.communication.Renamed +import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData import net.mullvad.mullvadvpn.compose.component.CustomListNameTextField import net.mullvad.mullvadvpn.compose.state.EditCustomListNameUiState import net.mullvad.mullvadvpn.compose.test.EDIT_CUSTOM_LIST_DIALOG_INPUT_TEST_TAG @@ -50,7 +50,7 @@ data class EditCustomListNameNavArgs( navArgs = EditCustomListNameNavArgs::class ) fun EditCustomListName( - backNavigator: ResultBackNavigator, + backNavigator: ResultBackNavigator, ) { val vm: EditCustomListNameDialogViewModel = koinViewModel() LaunchedEffectCollect(vm.uiSideEffect) { sideEffect -> 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 cb3767784edb..54bdaacb4142 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 @@ -1,6 +1,5 @@ package net.mullvad.mullvadvpn.compose.screen -import android.content.Context import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -13,16 +12,12 @@ 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.SnackbarDuration -import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -35,10 +30,9 @@ import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.result.NavResult import com.ramcosta.composedestinations.result.ResultBackNavigator import com.ramcosta.composedestinations.result.ResultRecipient -import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.cell.CheckableRelayLocationCell -import net.mullvad.mullvadvpn.compose.communication.LocationsChanged +import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData import net.mullvad.mullvadvpn.compose.component.LocationsEmptyText import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton @@ -52,7 +46,6 @@ import net.mullvad.mullvadvpn.compose.test.SAVE_BUTTON_TEST_TAG import net.mullvad.mullvadvpn.compose.textfield.SearchTextField import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect -import net.mullvad.mullvadvpn.compose.util.showSnackbarImmediately import net.mullvad.mullvadvpn.lib.model.CustomListId import net.mullvad.mullvadvpn.lib.model.RelayItem import net.mullvad.mullvadvpn.lib.theme.Dimens @@ -79,7 +72,7 @@ data class CustomListLocationsNavArgs( ) fun CustomListLocations( navigator: DestinationsNavigator, - backNavigator: ResultBackNavigator, + backNavigator: ResultBackNavigator, discardChangesResultRecipient: ResultRecipient, ) { val customListsViewModel = koinViewModel() @@ -95,27 +88,16 @@ fun CustomListLocations( } } - val snackbarHostState = remember { SnackbarHostState() } - val context: Context = LocalContext.current LaunchedEffectCollect(customListsViewModel.uiSideEffect) { sideEffect -> when (sideEffect) { - is CustomListLocationsSideEffect.ReturnWithResult -> + is CustomListLocationsSideEffect.ReturnWithResultData -> backNavigator.navigateBack(result = sideEffect.result) - CustomListLocationsSideEffect.CloseScreen -> backNavigator.navigateBack() - CustomListLocationsSideEffect.Error -> - launch { - snackbarHostState.showSnackbarImmediately( - message = context.getString(R.string.error_occurred), - duration = SnackbarDuration.Short - ) - } } } val state by customListsViewModel.uiState.collectAsStateWithLifecycle() CustomListLocationsScreen( state = state, - snackbarHostState = snackbarHostState, onSearchTermInput = customListsViewModel::onSearchTermInput, onSaveClick = customListsViewModel::save, onRelaySelectionClick = customListsViewModel::onRelaySelectionClick, @@ -134,7 +116,6 @@ fun CustomListLocations( @Composable fun CustomListLocationsScreen( state: CustomListLocationsUiState, - snackbarHostState: SnackbarHostState = SnackbarHostState(), onSearchTermInput: (String) -> Unit = {}, onSaveClick: () -> Unit = {}, onRelaySelectionClick: (RelayItem.Location, selected: Boolean) -> Unit = { _, _ -> }, @@ -142,7 +123,6 @@ fun CustomListLocationsScreen( onBackClick: () -> Unit = {} ) { ScaffoldWithSmallTopBar( - snackbarHostState = snackbarHostState, appBarTitle = stringResource( if (state.newList) { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListsScreen.kt index 5b9f0d4cc1c1..8ca728ca4bae 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListsScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListsScreen.kt @@ -34,6 +34,7 @@ import com.ramcosta.composedestinations.result.ResultRecipient import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.cell.NavigationComposeCell +import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData import net.mullvad.mullvadvpn.compose.communication.Deleted import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton @@ -63,7 +64,8 @@ private fun PreviewCustomListsScreen() { @Destination(style = SlideInFromRightTransition::class) fun CustomLists( navigator: DestinationsNavigator, - editCustomListResultRecipient: ResultRecipient + editCustomListResultRecipient: + ResultRecipient ) { val viewModel = koinViewModel() val state by viewModel.uiState.collectAsStateWithLifecycle() @@ -82,7 +84,7 @@ fun CustomLists( message = context.getString( R.string.delete_custom_list_message, - result.value.name + result.value.customListName ), actionLabel = context.getString(R.string.undo), duration = SnackbarDuration.Long, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreen.kt index 365328ea939a..3bd6dc0ce105 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreen.kt @@ -33,6 +33,7 @@ import com.ramcosta.composedestinations.result.ResultBackNavigator import com.ramcosta.composedestinations.result.ResultRecipient import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.cell.TwoRowCell +import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData import net.mullvad.mullvadvpn.compose.communication.Deleted import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton @@ -83,8 +84,9 @@ data class EditCustomListNavArgs(val customListId: CustomListId) ) fun EditCustomList( navigator: DestinationsNavigator, - backNavigator: ResultBackNavigator, - confirmDeleteListResultRecipient: ResultRecipient + backNavigator: ResultBackNavigator, + confirmDeleteListResultRecipient: + ResultRecipient ) { val viewModel = koinViewModel() 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 8cca835f744d..422b7b58ee2f 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 @@ -68,12 +68,8 @@ import net.mullvad.mullvadvpn.compose.cell.IconCell 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.communication.Created import net.mullvad.mullvadvpn.compose.communication.CustomListAction -import net.mullvad.mullvadvpn.compose.communication.CustomListSuccess -import net.mullvad.mullvadvpn.compose.communication.Deleted -import net.mullvad.mullvadvpn.compose.communication.LocationsChanged -import net.mullvad.mullvadvpn.compose.communication.Renamed +import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData import net.mullvad.mullvadvpn.compose.component.LocationsEmptyText import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge import net.mullvad.mullvadvpn.compose.component.MullvadModalBottomSheet @@ -132,12 +128,17 @@ private fun PreviewSelectLocationScreen() { fun SelectLocation( navigator: DestinationsNavigator, backNavigator: ResultBackNavigator, - createCustomListDialogResultRecipient: ResultRecipient, + createCustomListDialogResultRecipient: + ResultRecipient< + CreateCustomListDestination, + CustomListActionResultData.Success.CreatedWithLocations + >, editCustomListNameDialogResultRecipient: - ResultRecipient, - deleteCustomListDialogResultRecipient: ResultRecipient, + ResultRecipient, + deleteCustomListDialogResultRecipient: + ResultRecipient, updateCustomListResultRecipient: - ResultRecipient + ResultRecipient ) { val vm = koinViewModel() val state = vm.uiState.collectAsStateWithLifecycle() @@ -147,22 +148,12 @@ fun SelectLocation( val lazyListState = rememberLazyListState() CollectSideEffectWithLifecycle(vm.uiSideEffect) { when (it) { - SelectLocationSideEffect.CloseScreen -> { - backNavigator.navigateBack(result = true) - } - is SelectLocationSideEffect.LocationAddedToCustomList -> - launch { - snackbarHostState.showResultSnackbar( - context = context, - result = it.result, - onUndo = vm::performAction - ) - } - is SelectLocationSideEffect.LocationRemovedFromCustomList -> + SelectLocationSideEffect.CloseScreen -> backNavigator.navigateBack(result = true) + is SelectLocationSideEffect.CustomListActionToast -> launch { snackbarHostState.showResultSnackbar( context = context, - result = it.result, + result = it.resultData, onUndo = vm::performAction ) } @@ -335,7 +326,7 @@ fun SelectLocationScreen( itemsIndexed( items = state.relayListItems, - key = { index: Int, item: RelayListItem -> item.key }, + key = { _: Int, item: RelayListItem -> item.key }, contentType = { _, item -> item.contentType }, itemContent = { index: Int, listItem: RelayListItem -> Column(modifier = Modifier.animateItem()) { @@ -834,30 +825,53 @@ private suspend fun LazyListState.animateScrollAndCentralizeItem(index: Int) { private suspend fun SnackbarHostState.showResultSnackbar( context: Context, - result: CustomListSuccess, + result: CustomListActionResultData, onUndo: (CustomListAction) -> Unit ) { + showSnackbarImmediately( message = result.message(context), - actionLabel = context.getString(R.string.undo), + actionLabel = + if (result is CustomListActionResultData.Success) context.getString(R.string.undo) + else { + null + }, duration = SnackbarDuration.Long, - onAction = { onUndo(result.undo) } + onAction = { + if (result is CustomListActionResultData.Success) { + onUndo(result.undo) + } + } ) } -private fun CustomListSuccess.message(context: Context): String = +private fun CustomListActionResultData.message(context: Context): String = when (this) { - is Created -> - locationNames.firstOrNull()?.let { locationName -> - context.getString(R.string.location_was_added_to_list, locationName, name) - } ?: context.getString(R.string.locations_were_changed_for, name) - is Deleted -> context.getString(R.string.delete_custom_list_message, name) - is Renamed -> context.getString(R.string.name_was_changed_to, name) - is LocationsChanged -> context.getString(R.string.locations_were_changed_for, name) + is CustomListActionResultData.Success.CreatedWithLocations -> + if (locationNames.size == 1) { + context.getString( + R.string.location_was_added_to_list, + locationNames.first(), + customListName + ) + } else { + context.getString(R.string.create_custom_list_message, customListName) + } + is CustomListActionResultData.Success.Deleted -> + context.getString(R.string.delete_custom_list_message, customListName) + is CustomListActionResultData.Success.LocationAdded -> + context.getString(R.string.location_was_added_to_list, locationName, customListName) + is CustomListActionResultData.Success.LocationRemoved -> + context.getString(R.string.location_was_removed_from_list, locationName, customListName) + is CustomListActionResultData.Success.LocationChanged -> + context.getString(R.string.locations_were_changed_for, customListName) + is CustomListActionResultData.Success.Renamed -> + context.getString(R.string.name_was_changed_to, newName) + CustomListActionResultData.GenericError -> context.getString(R.string.error_occurred) } @Composable -private fun ResultRecipient +private fun ResultRecipient .OnCustomListNavResult( snackbarHostState: SnackbarHostState, performAction: (action: CustomListAction) -> Unit diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/RelayListRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/RelayListRepository.kt index 7d9846c31bb9..ce41b57c4ca4 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/RelayListRepository.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/RelayListRepository.kt @@ -11,11 +11,13 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.GeoLocationId import net.mullvad.mullvadvpn.lib.model.PortRange import net.mullvad.mullvadvpn.lib.model.RelayItem import net.mullvad.mullvadvpn.lib.model.RelayItemId import net.mullvad.mullvadvpn.lib.model.WireguardConstraints import net.mullvad.mullvadvpn.lib.model.WireguardEndpointData +import net.mullvad.mullvadvpn.relaylist.findByGeoLocationId class RelayListRepository( private val managementService: ManagementService, @@ -49,5 +51,7 @@ class RelayListRepository( suspend fun updateSelectedWireguardConstraints(value: WireguardConstraints) = managementService.setWireguardConstraints(value) + fun find(geoLocationId: GeoLocationId) = relayList.value.findByGeoLocationId(geoLocationId) + private fun defaultWireguardEndpointData() = WireguardEndpointData(emptyList()) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/CustomListActionUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/CustomListActionUseCase.kt index 354316793c45..a23a6c6de5c0 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/CustomListActionUseCase.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/CustomListActionUseCase.kt @@ -107,8 +107,10 @@ class CustomListActionUseCase( .mapLeft(UpdateLocationsError::UpdateLocations) .bind() LocationsChanged( + id = action.id, name = customList.name, - undo = action.not(locations = customList.locations) + locations = action.locations, + oldLocations = customList.locations, ) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CreateCustomListDialogViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CreateCustomListDialogViewModel.kt index e367386bf265..5603ab41846c 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CreateCustomListDialogViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CreateCustomListDialogViewModel.kt @@ -12,8 +12,8 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import net.mullvad.mullvadvpn.compose.communication.Created import net.mullvad.mullvadvpn.compose.communication.CustomListAction +import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData import net.mullvad.mullvadvpn.compose.state.CreateCustomListUiState import net.mullvad.mullvadvpn.lib.model.CustomListId import net.mullvad.mullvadvpn.lib.model.CustomListName @@ -58,7 +58,13 @@ class CreateCustomListDialogViewModel( ) } else { _uiSideEffect.send( - CreateCustomListDialogSideEffect.ReturnWithResult(it) + CreateCustomListDialogSideEffect.ReturnWithResult( + CustomListActionResultData.Success.CreatedWithLocations( + customListName = it.name, + locationNames = it.locationNames, + undo = it.undo + ) + ) ) } } @@ -76,5 +82,7 @@ sealed interface CreateCustomListDialogSideEffect { data class NavigateToCustomListLocationsScreen(val customListId: CustomListId) : CreateCustomListDialogSideEffect - data class ReturnWithResult(val result: Created) : CreateCustomListDialogSideEffect + data class ReturnWithResult( + val result: CustomListActionResultData.Success.CreatedWithLocations + ) : CreateCustomListDialogSideEffect } 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 7fa101a8e370..ffed0757b0ab 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 @@ -3,6 +3,8 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import arrow.core.getOrElse +import arrow.core.raise.either import com.ramcosta.composedestinations.generated.destinations.CustomListLocationsDestination import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -16,6 +18,7 @@ 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.CustomListActionResultData import net.mullvad.mullvadvpn.compose.communication.LocationsChanged import net.mullvad.mullvadvpn.compose.state.CustomListLocationsUiState import net.mullvad.mullvadvpn.compose.state.RelayLocationListItem @@ -87,7 +90,7 @@ class CustomListLocationsViewModel( viewModelScope.launch { fetchInitialSelectedLocations() } } - fun searchRelayListLocations() = + private fun searchRelayListLocations() = combine( _searchTerm, relayListRepository.relayList, @@ -108,26 +111,23 @@ class CustomListLocationsViewModel( fun save() { viewModelScope.launch { _selectedLocations.value?.let { selectedLocations -> - customListActionUseCase( - CustomListAction.UpdateLocations( - navArgs.customListId, - selectedLocations.calculateLocationsToSave().map { it.id } - ) - ) - .fold( - { _uiSideEffect.tryEmit(CustomListLocationsSideEffect.Error) }, - { - _uiSideEffect.tryEmit( - // This is so that we don't show a snackbar after returning to the - // select location screen - if (navArgs.newList) { - CustomListLocationsSideEffect.CloseScreen - } else { - CustomListLocationsSideEffect.ReturnWithResult(it) - } - ) + val locationsToSave = selectedLocations.calculateLocationsToSave() + val result = + either { + val success = + customListActionUseCase( + CustomListAction.UpdateLocations( + navArgs.customListId, + locationsToSave.map { it.id } + ) + ) + .bind() + calculateResultData(success, locationsToSave) } - ) + .getOrElse { CustomListActionResultData.GenericError } + _uiSideEffect.tryEmit( + CustomListLocationsSideEffect.ReturnWithResultData(result = result) + ) } } } @@ -241,7 +241,7 @@ class CustomListLocationsViewModel( isExpanded: (RelayItemId) -> Boolean, depth: Int = 0, ): List = flatMap { relayItem -> - buildList { + buildList { val expanded = isExpanded(relayItem.id) add( RelayLocationListItem( @@ -277,15 +277,45 @@ class CustomListLocationsViewModel( } } + private fun calculateResultData( + success: LocationsChanged, + locationsToSave: List + ) = + if (navArgs.newList) { + CustomListActionResultData.Success.CreatedWithLocations( + customListName = success.name, + locationNames = locationsToSave.map { it.name }, + undo = CustomListAction.Delete(id = success.id) + ) + } else { + when { + success.addedLocations.size == 1 && success.removedLocations.isEmpty() -> + CustomListActionResultData.Success.LocationAdded( + customListName = success.name, + relayListRepository.find(success.removedLocations.first())!!.name, + undo = success.undo + ) + success.removedLocations.size == 1 && success.addedLocations.isEmpty() -> + CustomListActionResultData.Success.LocationRemoved( + customListName = success.name, + locationName = + relayListRepository.find(success.removedLocations.first())!!.name, + undo = success.undo + ) + else -> + CustomListActionResultData.Success.LocationChanged( + customListName = success.name, + undo = success.undo + ) + } + } + companion object { private const val EMPTY_SEARCH_TERM = "" } } sealed interface CustomListLocationsSideEffect { - data object CloseScreen : CustomListLocationsSideEffect - - data class ReturnWithResult(val result: LocationsChanged) : CustomListLocationsSideEffect - - data object Error : CustomListLocationsSideEffect + data class ReturnWithResultData(val result: CustomListActionResultData) : + CustomListLocationsSideEffect } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeleteCustomListConfirmationViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeleteCustomListConfirmationViewModel.kt index c492bd368a30..cdb0327d1643 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeleteCustomListConfirmationViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeleteCustomListConfirmationViewModel.kt @@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.compose.communication.CustomListAction -import net.mullvad.mullvadvpn.compose.communication.Deleted +import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData import net.mullvad.mullvadvpn.compose.state.DeleteCustomListUiState import net.mullvad.mullvadvpn.lib.model.CustomListId import net.mullvad.mullvadvpn.lib.model.CustomListName @@ -49,7 +49,12 @@ class DeleteCustomListConfirmationViewModel( { _error.tryEmit(it) }, { _uiSideEffect.send( - DeleteCustomListConfirmationSideEffect.ReturnWithResult(it) + DeleteCustomListConfirmationSideEffect.ReturnWithResult( + CustomListActionResultData.Success.Deleted( + customListName = it.name, + undo = it.undo + ) + ) ) } ) @@ -58,5 +63,6 @@ class DeleteCustomListConfirmationViewModel( } sealed interface DeleteCustomListConfirmationSideEffect { - data class ReturnWithResult(val result: Deleted) : DeleteCustomListConfirmationSideEffect + data class ReturnWithResult(val result: CustomListActionResultData.Success.Deleted) : + DeleteCustomListConfirmationSideEffect } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListNameDialogViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListNameDialogViewModel.kt index 0b71a5053ead..c4825be9b710 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListNameDialogViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListNameDialogViewModel.kt @@ -13,7 +13,7 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.compose.communication.CustomListAction -import net.mullvad.mullvadvpn.compose.communication.Renamed +import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData import net.mullvad.mullvadvpn.compose.state.EditCustomListNameUiState import net.mullvad.mullvadvpn.lib.model.CustomListName import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase @@ -52,7 +52,16 @@ class EditCustomListNameDialogViewModel( ) .fold( { _error.emit(it) }, - { _uiSideEffect.send(EditCustomListNameDialogSideEffect.ReturnWithResult(it)) } + { + _uiSideEffect.send( + EditCustomListNameDialogSideEffect.ReturnWithResult( + CustomListActionResultData.Success.Renamed( + newName = it.name, + undo = it.undo + ) + ) + ) + } ) } } @@ -64,5 +73,6 @@ class EditCustomListNameDialogViewModel( } sealed interface EditCustomListNameDialogSideEffect { - data class ReturnWithResult(val result: Renamed) : EditCustomListNameDialogSideEffect + data class ReturnWithResult(val result: CustomListActionResultData.Success.Renamed) : + EditCustomListNameDialogSideEffect } 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 f31fcb307840..a219bb976b77 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,6 +2,7 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import arrow.core.getOrElse import arrow.core.raise.either import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -14,7 +15,7 @@ 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.communication.CustomListActionResultData import net.mullvad.mullvadvpn.compose.state.FilterChip import net.mullvad.mullvadvpn.compose.state.RelayListItem import net.mullvad.mullvadvpn.compose.state.RelayListItem.CustomListHeader @@ -44,7 +45,7 @@ import net.mullvad.mullvadvpn.usecase.customlists.FilterCustomListsRelayItemUseC class SelectLocationViewModel( private val relayListFilterRepository: RelayListFilterRepository, private val availableProvidersUseCase: AvailableProvidersUseCase, - private val customListsRelayItemUseCase: CustomListsRelayItemUseCase, + customListsRelayItemUseCase: CustomListsRelayItemUseCase, private val filteredCustomListRelayItemsUseCase: FilterCustomListsRelayItemUseCase, private val customListsRepository: CustomListsRepository, private val customListActionUseCase: CustomListActionUseCase, @@ -79,8 +80,7 @@ class SelectLocationViewModel( val uiSideEffect = _uiSideEffect.receiveAsFlow() private fun initialExpand(): Set = buildSet { - val item = relayListRepository.selectedLocation.value.getOrNull() - when (item) { + when (val item = relayListRepository.selectedLocation.value.getOrNull()) { is GeoLocationId.City -> { add(item.country.code) } @@ -131,7 +131,7 @@ class SelectLocationViewModel( .size } - buildList { + buildList { if (ownershipFilter != null) { add(FilterChip.Ownership(ownershipFilter)) } @@ -157,9 +157,10 @@ class SelectLocationViewModel( searchTerm.length >= MIN_SEARCH_LENGTH, selectedItem.getOrNull(), filteredCustomLists, - relayCountries, - { it in expandedItems } - ) + relayCountries + ) { + it in expandedItems + } if (relayItems.isEmpty()) { add(RelayListItem.LocationsEmptyText(searchTerm)) } else { @@ -201,7 +202,7 @@ class SelectLocationViewModel( ): List = customLists.flatMap { customList -> val expanded = isExpanded(customList.id.expandKey()) - buildList { + buildList { add( RelayListItem.CustomListItem( customList, @@ -243,36 +244,35 @@ class SelectLocationViewModel( item: RelayItem.Location, depth: Int = 1, isExpanded: (String) -> Boolean, - ): List = - buildList { - val expanded = isExpanded(item.id.expandKey(parent)) - add( - RelayListItem.CustomListEntryItem( - parentId = parent, - item = item, - expanded = expanded, - depth - ) + ): List = buildList { + val expanded = isExpanded(item.id.expandKey(parent)) + add( + RelayListItem.CustomListEntryItem( + parentId = parent, + item = item, + expanded = expanded, + depth ) + ) - if (expanded) { - when (item) { - is RelayItem.Location.City -> - addAll( - item.relays.flatMap { - createCustomListEntry(parent, it, depth + 1, isExpanded) - } - ) - is RelayItem.Location.Country -> - addAll( - item.cities.flatMap { - createCustomListEntry(parent, it, depth + 1, isExpanded) - } - ) - is RelayItem.Location.Relay -> {} // No children to add - } + if (expanded) { + when (item) { + is RelayItem.Location.City -> + addAll( + item.relays.flatMap { + createCustomListEntry(parent, it, depth + 1, isExpanded) + } + ) + is RelayItem.Location.Country -> + addAll( + item.cities.flatMap { + createCustomListEntry(parent, it, depth + 1, isExpanded) + } + ) + is RelayItem.Location.Relay -> {} // No children to add } } + } private fun createGeoLocationEntry( item: RelayItem.Location, @@ -363,11 +363,28 @@ class SelectLocationViewModel( viewModelScope.launch { val newLocations = (customList.locations + item).filter { it !in item.descendants() }.map { it.id } - customListActionUseCase(CustomListAction.UpdateLocations(customList.id, newLocations)) - .fold( - { _uiSideEffect.send(SelectLocationSideEffect.GenericError) }, - { _uiSideEffect.send(SelectLocationSideEffect.LocationAddedToCustomList(it)) }, - ) + val result = + customListActionUseCase( + CustomListAction.UpdateLocations(customList.id, newLocations) + ) + .fold( + { CustomListActionResultData.GenericError }, + { + if (it.removedLocations.isEmpty()) { + CustomListActionResultData.Success.LocationAdded( + customListName = it.name, + locationName = item.name, + undo = it.undo + ) + } else { + CustomListActionResultData.Success.LocationChanged( + customListName = it.name, + undo = it.undo + ) + } + }, + ) + _uiSideEffect.send(SelectLocationSideEffect.CustomListActionToast(result)) } } @@ -382,17 +399,26 @@ class SelectLocationViewModel( val customList = customListsRepository.getCustomListById(customListId).bind() val newLocations = (customList.locations - item.id) - - customListActionUseCase( - CustomListAction.UpdateLocations(customList.id, newLocations) + val success = + customListActionUseCase( + CustomListAction.UpdateLocations(customList.id, newLocations) + ) + .bind() + if (success.addedLocations.isEmpty()) { + CustomListActionResultData.Success.LocationRemoved( + customListName = success.name, + locationName = item.name, + undo = success.undo + ) + } else { + CustomListActionResultData.Success.LocationChanged( + customListName = success.name, + undo = success.undo ) - .bind() + } } - .fold( - { SelectLocationSideEffect.GenericError }, - { SelectLocationSideEffect.LocationRemovedFromCustomList(it) } - ) - _uiSideEffect.send(result) + .getOrElse { CustomListActionResultData.GenericError } + _uiSideEffect.send(SelectLocationSideEffect.CustomListActionToast(result)) } } @@ -404,9 +430,8 @@ class SelectLocationViewModel( sealed interface SelectLocationSideEffect { data object CloseScreen : SelectLocationSideEffect - data class LocationAddedToCustomList(val result: LocationsChanged) : SelectLocationSideEffect - - class LocationRemovedFromCustomList(val result: LocationsChanged) : SelectLocationSideEffect + data class CustomListActionToast(val resultData: CustomListActionResultData) : + SelectLocationSideEffect data object GenericError : SelectLocationSideEffect } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/CustomListActionUseCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/CustomListActionUseCaseTest.kt index d72d183202a3..3528d1bfa2ee 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/CustomListActionUseCaseTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/CustomListActionUseCaseTest.kt @@ -174,7 +174,13 @@ class CustomListActionUseCaseTest { val customList = CustomList(id = customListId, name = name, locations = oldLocations) val action = CustomListAction.UpdateLocations(id = customListId, locations = newLocations) val expectedResult = - LocationsChanged(name = name, undo = action.not(locations = oldLocations)).right() + LocationsChanged( + id = customListId, + name = name, + locations = newLocations, + oldLocations = oldLocations, + ) + .right() coEvery { mockCustomListsRepository.getCustomListById(customListId) } returns customList.right() diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CreateCustomListDialogViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CreateCustomListDialogViewModelTest.kt index 2ab6e6267bf3..fee63ae8047a 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CreateCustomListDialogViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CreateCustomListDialogViewModelTest.kt @@ -11,6 +11,7 @@ import kotlin.test.assertIs import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.compose.communication.Created import net.mullvad.mullvadvpn.compose.communication.CustomListAction +import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData import net.mullvad.mullvadvpn.compose.dialog.CreateCustomListNavArgs import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule import net.mullvad.mullvadvpn.lib.model.CustomListAlreadyExists @@ -32,16 +33,28 @@ class CreateCustomListDialogViewModelTest { fun `when successfully creating a list with locations should emit return with result side effect`() = runTest { // Arrange - val expectedResult: Created = mockk() - val customListName = "list" + val mockCreated: Created = mockk() + val mockUndo: CustomListAction.Delete = mockk() + val customListName = CustomListName.fromString("list") + val customListId = CustomListId("1") + val locationNames = listOf("locationName") + val expectedResult = + CustomListActionResultData.Success.CreatedWithLocations( + customListName = customListName, + locationNames = locationNames, + undo = mockUndo + ) val viewModel = createViewModelWithLocationCode(GeoLocationId.Country("AB")) coEvery { mockCustomListActionUseCase(any()) } returns - expectedResult.right() - every { expectedResult.locationNames } returns listOf("locationName") + mockCreated.right() + every { mockCreated.locationNames } returns locationNames + every { mockCreated.name } returns customListName + every { mockCreated.id } returns customListId + every { mockCreated.undo } returns mockUndo // Act, Assert viewModel.uiSideEffect.test { - viewModel.createCustomList(customListName) + viewModel.createCustomList(customListName.value) val sideEffect = awaitItem() assertIs(sideEffect) assertEquals(expectedResult, sideEffect.result) 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 f73dbee7a3fd..f453223ec709 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 @@ -10,6 +10,7 @@ import kotlin.test.assertIs import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.compose.communication.CustomListAction +import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData import net.mullvad.mullvadvpn.compose.communication.LocationsChanged import net.mullvad.mullvadvpn.compose.screen.CustomListLocationsNavArgs import net.mullvad.mullvadvpn.compose.state.CustomListLocationsUiState @@ -214,40 +215,57 @@ class CustomListLocationsViewModelTest { } @Test - fun `given new list true when saving successfully should emit close screen side effect`() = + fun `given new list true when saving successfully should emit return with result data`() = runTest { // Arrange val customListId = CustomListId("1") + val customListName = CustomListName.fromString("name") val newList = true - val expectedResult: LocationsChanged = mockk() + val locationChangedMock: LocationsChanged = mockk() coEvery { mockCustomListUseCase(any()) } returns - expectedResult.right() + locationChangedMock.right() + every { locationChangedMock.name } returns customListName + every { locationChangedMock.id } returns customListId val viewModel = createViewModel(customListId, newList) // Act, Assert viewModel.uiSideEffect.test { viewModel.save() val sideEffect = awaitItem() - assertIs(sideEffect) + assertIs(sideEffect) } } @Test - fun `given new list false when saving successfully should emit return with result side effect`() = + fun `given new list false when saving successfully should emit return with result data`() = runTest { // Arrange val customListId = CustomListId("1") + val customListName = CustomListName.fromString("name") + val mockUndo: CustomListAction.UpdateLocations = mockk() + val addedLocations: List = listOf(mockk()) + val removedLocations: List = listOf(mockk()) val newList = false - val expectedResult: LocationsChanged = mockk() + val locationsChangedMock: LocationsChanged = mockk() + val expectedResult = + CustomListActionResultData.Success.LocationChanged( + customListName = customListName, + undo = mockUndo + ) coEvery { mockCustomListUseCase(any()) } returns - expectedResult.right() + locationsChangedMock.right() + every { locationsChangedMock.id } returns customListId + every { locationsChangedMock.name } returns customListName + every { locationsChangedMock.addedLocations } returns addedLocations + every { locationsChangedMock.removedLocations } returns removedLocations + every { locationsChangedMock.undo } returns mockUndo val viewModel = createViewModel(customListId, newList) // Act, Assert viewModel.uiSideEffect.test { viewModel.save() val sideEffect = awaitItem() - assertIs(sideEffect) + assertIs(sideEffect) assertEquals(expectedResult, sideEffect.result) } } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/DeleteCustomListConfirmationViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/DeleteCustomListConfirmationViewModelTest.kt index 2656d1a4ff38..d90deeb99e31 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/DeleteCustomListConfirmationViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/DeleteCustomListConfirmationViewModelTest.kt @@ -4,10 +4,12 @@ import app.cash.turbine.test import arrow.core.right import com.ramcosta.composedestinations.generated.navargs.toSavedStateHandle import io.mockk.coEvery +import io.mockk.every import io.mockk.mockk import kotlin.test.assertIs import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.compose.communication.CustomListAction +import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData import net.mullvad.mullvadvpn.compose.communication.Deleted import net.mullvad.mullvadvpn.compose.dialog.DeleteCustomListNavArgs import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule @@ -25,10 +27,16 @@ class DeleteCustomListConfirmationViewModelTest { @Test fun `when successfully deleting a list should emit return with result side effect`() = runTest { // Arrange - val expectedResult: Deleted = mockk() + val deleted: Deleted = mockk() + val customListName = CustomListName.fromString("name") + val undo: CustomListAction.Create = mockk() + val expectedResult = + CustomListActionResultData.Success.Deleted(customListName = customListName, undo = undo) + every { deleted.name } returns customListName + every { deleted.undo } returns undo val viewModel = createViewModel() coEvery { mockCustomListActionUseCase(any()) } returns - expectedResult.right() + deleted.right() // Act, Assert viewModel.uiSideEffect.test { diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListNameDialogViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListNameDialogViewModelTest.kt index 7b4dcc0b8321..faf83da7a8c9 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListNameDialogViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListNameDialogViewModelTest.kt @@ -5,10 +5,12 @@ import arrow.core.left import arrow.core.right import com.ramcosta.composedestinations.generated.navargs.toSavedStateHandle import io.mockk.coEvery +import io.mockk.every import io.mockk.mockk import kotlin.test.assertIs import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.compose.communication.CustomListAction +import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData import net.mullvad.mullvadvpn.compose.communication.Renamed import net.mullvad.mullvadvpn.compose.dialog.EditCustomListNameNavArgs import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule @@ -28,16 +30,21 @@ class EditCustomListNameDialogViewModelTest { @Test fun `when successfully renamed list should emit return with result side effect`() = runTest { // Arrange - val expectedResult: Renamed = mockk() + val renamed: Renamed = mockk() val customListId = CustomListId("id") - val customListName = "list" - val viewModel = createViewModel(customListId, customListName) + val customListName = CustomListName.fromString("list") + val undo: CustomListAction.Rename = mockk() + val expectedResult = + CustomListActionResultData.Success.Renamed(newName = customListName, undo = undo) + every { renamed.name } returns customListName + every { renamed.undo } returns undo + val viewModel = createViewModel(customListId, customListName.value) coEvery { mockCustomListActionUseCase(any()) } returns - expectedResult.right() + renamed.right() // Act, Assert viewModel.uiSideEffect.test { - viewModel.updateCustomListName(customListName) + viewModel.updateCustomListName(customListName.value) val sideEffect = awaitItem() assertIs(sideEffect) assertEquals(expectedResult, sideEffect.result) diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModelTest.kt index d15e460e5d78..993190ba2c74 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModelTest.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.compose.communication.CustomListAction +import net.mullvad.mullvadvpn.compose.communication.CustomListActionResultData import net.mullvad.mullvadvpn.compose.communication.LocationsChanged import net.mullvad.mullvadvpn.compose.state.RelayListItem import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState @@ -273,9 +274,12 @@ class SelectLocationViewModelTest { @Test fun `after adding a location to a list should emit location added side effect`() = runTest { // Arrange - val expectedResult: LocationsChanged = mockk() + val customListId = CustomListId("1") + val addedLocationsId = GeoLocationId.Country("se") + val customListName = CustomListName.fromString("custom") val location: RelayItem.Location.Country = mockk { every { id } returns GeoLocationId.Country("se") + every { name } returns "Sweden" every { descendants() } returns emptyList() } val customList = @@ -283,24 +287,85 @@ class SelectLocationViewModelTest { customList = CustomList( id = CustomListId("1"), - name = CustomListName.fromString("custom"), + name = customListName, locations = emptyList() ), locations = emptyList(), ) + val expectedResult = + CustomListActionResultData.Success.LocationAdded( + customListName = customListName, + locationName = location.name, + undo = CustomListAction.UpdateLocations(id = customListId, locations = emptyList()) + ) + coEvery { mockCustomListActionUseCase(any()) } returns - expectedResult.right() + LocationsChanged( + id = customListId, + name = customListName, + locations = listOf(addedLocationsId), + oldLocations = emptyList() + ) + .right() // Act, Assert viewModel.uiSideEffect.test { viewModel.addLocationToList(item = location, customList = customList) val sideEffect = awaitItem() - assertIs(sideEffect) - assertEquals(expectedResult, sideEffect.result) + assertIs(sideEffect) + assertEquals(expectedResult, sideEffect.resultData) } } - fun RelayListItem.relayItemId() = + @Test + fun `after removing a location from a list should emit location removed side effect`() = + runTest { + // Arrange + val locationName = "Sweden" + val customListId = CustomListId("1") + val removedLocationsId = GeoLocationId.Country("se") + val customListName = CustomListName.fromString("custom") + val location: RelayItem.Location.Country = mockk { + every { id } returns removedLocationsId + every { name } returns locationName + every { descendants() } returns emptyList() + } + val expectedResult = + CustomListActionResultData.Success.LocationRemoved( + customListName = customListName, + locationName = locationName, + undo = + CustomListAction.UpdateLocations( + id = customListId, + locations = listOf(location.id) + ) + ) + coEvery { mockCustomListActionUseCase(any()) } returns + LocationsChanged( + id = customListId, + name = customListName, + locations = emptyList(), + oldLocations = listOf(removedLocationsId), + ) + .right() + coEvery { mockCustomListsRepository.getCustomListById(customListId) } returns + CustomList( + id = customListId, + name = customListName, + locations = listOf(removedLocationsId) + ) + .right() + + // Act, Assert + viewModel.uiSideEffect.test { + viewModel.removeLocationFromList(item = location, customListId = customListId) + val sideEffect = awaitItem() + assertIs(sideEffect) + assertEquals(expectedResult, sideEffect.resultData) + } + } + + private fun RelayListItem.relayItemId() = when (this) { is RelayListItem.CustomListFooter -> null RelayListItem.CustomListHeader -> null @@ -320,7 +385,7 @@ class SelectLocationViewModelTest { "net.mullvad.mullvadvpn.relaylist.CustomListExtensionsKt" private val testCountries = - listOf( + listOf( RelayItem.Location.Country( id = GeoLocationId.Country("se"), "Sweden", diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml index 367b635879de..531a9b046b94 100644 --- a/android/lib/resource/src/main/res/values/strings.xml +++ b/android/lib/resource/src/main/res/values/strings.xml @@ -383,4 +383,6 @@ Delete method? Failed to set to current - API not reachable Failed to set to current - Unknown reason + %s was removed from \"%s\" + \"%s\" was created diff --git a/gui/locales/messages.pot b/gui/locales/messages.pot index 7aea156fb992..0b2154711ffb 100644 --- a/gui/locales/messages.pot +++ b/gui/locales/messages.pot @@ -2114,6 +2114,9 @@ msgstr "" msgid "%s was added to your account." msgstr "" +msgid "%s was removed from \"%s\"" +msgstr "" + msgid "1. After clicking on the Go to VPN settings button below, click on the cogwheel next to the Mullvad VPN name." msgstr "" @@ -2579,6 +2582,9 @@ msgstr "" msgid "You are running an unsupported app version." msgstr "" +msgid "\"%s\" was created" +msgstr "" + msgid "\"%s\" was deleted" msgstr ""