From 0f6a02c49a08f1414676a685451d6f69bd1d7b8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20G=C3=B6ransson?= Date: Mon, 15 Jul 2024 17:03:19 +0200 Subject: [PATCH] Rework sheets into destinations --- android/app/build.gradle.kts | 1 + .../bottomsheet/CustomListEntryBottomSheet.kt | 63 +++ .../bottomsheet/CustomListsBottomSheet.kt | 61 +++ .../bottomsheet/EditCustomListBottomSheet.kt | 110 ++++ .../bottomsheet/ImportOverridesBottomSheet.kt | 92 ++++ .../bottomsheet/LocationBottomSheet.kt | 115 +++++ .../communication/CustomListSuccess.kt | 6 +- .../component/MullvadModalBottomContainer.kt | 54 ++ .../component/MullvadModalBottomSheet.kt | 69 --- .../compose/extensions/LifecycleExtensions.kt | 22 + .../mullvadvpn/compose/screen/MullvadApp.kt | 25 +- .../compose/screen/SelectLocationScreen.kt | 482 +++--------------- .../compose/screen/ServerIpOverridesScreen.kt | 130 +---- .../state/CustomListEntrySheetUiState.kt | 5 + .../compose/state/CustomListSheetUiState.kt | 9 + .../state/ImportOverridesSheetUiState.kt | 3 + .../compose/state/LocationUiState.kt | 16 + .../net/mullvad/mullvadvpn/di/UiModule.kt | 9 + .../CustomListEntrySheetViewModel.kt | 53 ++ .../viewmodel/CustomListSheetViewModel.kt | 31 ++ .../ImportOverridesSheetViewModel.kt | 24 + .../viewmodel/LocationSheetViewModel.kt | 76 +++ .../viewmodel/SelectLocationViewModel.kt | 28 - .../buildSrc/src/main/kotlin/Dependencies.kt | 6 +- 24 files changed, 857 insertions(+), 633 deletions(-) create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/bottomsheet/CustomListEntryBottomSheet.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/bottomsheet/CustomListsBottomSheet.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/bottomsheet/EditCustomListBottomSheet.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/bottomsheet/ImportOverridesBottomSheet.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/bottomsheet/LocationBottomSheet.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadModalBottomContainer.kt delete mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadModalBottomSheet.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListEntrySheetUiState.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListSheetUiState.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ImportOverridesSheetUiState.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/LocationUiState.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListEntrySheetViewModel.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListSheetViewModel.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ImportOverridesSheetViewModel.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LocationSheetViewModel.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b05e604b64ff..5ac67c3bb743 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -339,6 +339,7 @@ dependencies { implementation(Dependencies.Compose.ui) implementation(Dependencies.Compose.uiUtil) implementation(Dependencies.Compose.destinations) + implementation(Dependencies.Compose.destinationsBottomSheet) ksp(Dependencies.Compose.destinationsKsp) implementation(Dependencies.jodaTime) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/bottomsheet/CustomListEntryBottomSheet.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/bottomsheet/CustomListEntryBottomSheet.kt new file mode 100644 index 000000000000..06441a4a20ea --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/bottomsheet/CustomListEntryBottomSheet.kt @@ -0,0 +1,63 @@ +package net.mullvad.mullvadvpn.compose.bottomsheet + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.bottomsheet.spec.DestinationStyleBottomSheet +import com.ramcosta.composedestinations.result.ResultBackNavigator +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.cell.HeaderCell +import net.mullvad.mullvadvpn.compose.cell.IconCell +import net.mullvad.mullvadvpn.compose.communication.CustomListActionResult +import net.mullvad.mullvadvpn.compose.component.MullvadModalBottomContainer +import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.GeoLocationId +import net.mullvad.mullvadvpn.viewmodel.CustomListEntrySheetSideEffect +import net.mullvad.mullvadvpn.viewmodel.CustomListEntrySheetViewModel +import org.koin.androidx.compose.koinViewModel + +data class CustomListEntrySheetNavArgs( + val name: String, + val customListId: CustomListId, + val location: GeoLocationId +) + +@OptIn(ExperimentalMaterial3Api::class) +@Destination( + navArgs = CustomListEntrySheetNavArgs::class, + style = DestinationStyleBottomSheet::class +) +@Composable +fun CustomListEntrySheet(backNavigator: ResultBackNavigator) { + val vm = koinViewModel() + val state = vm.uiState.collectAsStateWithLifecycle() + CollectSideEffectWithLifecycle(vm.uiSideEffect) { + when (it) { + is CustomListEntrySheetSideEffect.LocationRemovedResult -> + backNavigator.navigateBack(it.result) + } + } + MullvadModalBottomContainer { + HeaderCell( + text = + stringResource(id = R.string.remove_location_from_list, state.value.locationName), + background = Color.Unspecified + ) + HorizontalDivider(color = MaterialTheme.colorScheme.onBackground) + + IconCell( + iconId = R.drawable.ic_remove, + title = stringResource(id = R.string.remove_button), + titleColor = MaterialTheme.colorScheme.onBackground, + onClick = vm::removeLocationFromList, + background = Color.Unspecified + ) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/bottomsheet/CustomListsBottomSheet.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/bottomsheet/CustomListsBottomSheet.kt new file mode 100644 index 000000000000..ef3f05ab0e73 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/bottomsheet/CustomListsBottomSheet.kt @@ -0,0 +1,61 @@ +package net.mullvad.mullvadvpn.compose.bottomsheet + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.bottomsheet.spec.DestinationStyleBottomSheet +import com.ramcosta.composedestinations.generated.destinations.CreateCustomListDestination +import com.ramcosta.composedestinations.generated.destinations.CustomListsDestination +import com.ramcosta.composedestinations.generated.destinations.CustomListsSheetDestination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.cell.HeaderCell +import net.mullvad.mullvadvpn.compose.cell.IconCell +import net.mullvad.mullvadvpn.compose.component.MullvadModalBottomContainer +import net.mullvad.mullvadvpn.lib.theme.color.AlphaInactive +import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible + +@OptIn(ExperimentalMaterial3Api::class) +@Destination(style = DestinationStyleBottomSheet::class) +@Composable +fun CustomListsSheet(navigator: DestinationsNavigator, editListEnabled: Boolean) { + MullvadModalBottomContainer { + HeaderCell( + text = stringResource(id = R.string.edit_custom_lists), + background = Color.Unspecified + ) + HorizontalDivider(color = MaterialTheme.colorScheme.onBackground) + IconCell( + iconId = R.drawable.icon_add, + title = stringResource(id = R.string.new_list), + titleColor = MaterialTheme.colorScheme.onBackground, + onClick = { + navigator.navigate(CreateCustomListDestination(null)) { + popUpTo(CustomListsSheetDestination) { inclusive = true } + } + }, + background = Color.Unspecified + ) + IconCell( + iconId = R.drawable.icon_edit, + title = stringResource(id = R.string.edit_lists), + titleColor = + MaterialTheme.colorScheme.onBackground.copy( + alpha = + if (editListEnabled) { + AlphaVisible + } else { + AlphaInactive + } + ), + onClick = { navigator.navigate(CustomListsDestination) }, + background = Color.Unspecified, + enabled = editListEnabled + ) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/bottomsheet/EditCustomListBottomSheet.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/bottomsheet/EditCustomListBottomSheet.kt new file mode 100644 index 000000000000..b4f0d9df8315 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/bottomsheet/EditCustomListBottomSheet.kt @@ -0,0 +1,110 @@ +package net.mullvad.mullvadvpn.compose.bottomsheet + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.compose.dropUnlessResumed +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.bottomsheet.spec.DestinationStyleBottomSheet +import com.ramcosta.composedestinations.generated.destinations.CustomListLocationsDestination +import com.ramcosta.composedestinations.generated.destinations.CustomListSheetDestination +import com.ramcosta.composedestinations.generated.destinations.DeleteCustomListDestination +import com.ramcosta.composedestinations.generated.destinations.EditCustomListNameDestination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.cell.HeaderCell +import net.mullvad.mullvadvpn.compose.cell.IconCell +import net.mullvad.mullvadvpn.compose.component.MullvadModalBottomContainer +import net.mullvad.mullvadvpn.compose.state.CustomListSheetUiState +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.CustomListName +import net.mullvad.mullvadvpn.viewmodel.CustomListSheetViewModel +import org.koin.androidx.compose.koinViewModel + +data class CustomListSheetNavArgs( + val customListId: CustomListId, + val customListName: CustomListName +) + +@OptIn(ExperimentalMaterial3Api::class) +@Destination( + navArgs = CustomListSheetNavArgs::class, + style = DestinationStyleBottomSheet::class +) +@Composable +fun CustomListSheet( + navigator: DestinationsNavigator, +) { + val vm = koinViewModel() + val state = vm.uiState.collectAsStateWithLifecycle() + MullvadModalBottomContainer { + CustomListContent( + state.value, + dropUnlessResumed { + navigator.navigate( + EditCustomListNameDestination( + state.value.customListId, + state.value.customListName + ) + ) { + popUpTo(CustomListSheetDestination) { inclusive = true } + } + }, + dropUnlessResumed { + navigator.navigate( + CustomListLocationsDestination(state.value.customListId, false) + ) { + popUpTo(CustomListSheetDestination) { inclusive = true } + } + }, + dropUnlessResumed { + navigator.navigate( + DeleteCustomListDestination( + state.value.customListId, + state.value.customListName, + ) + ) { + popUpTo(CustomListSheetDestination) { inclusive = true } + } + }, + ) + } +} + +@Composable +private fun ColumnScope.CustomListContent( + state: CustomListSheetUiState, + editCustomListName: () -> Unit, + editLocations: () -> Unit, + deleteCustomList: () -> Unit, +) { + HeaderCell(text = state.customListName.value, background = Color.Unspecified) + IconCell( + iconId = R.drawable.icon_edit, + title = stringResource(id = R.string.edit_name), + titleColor = MaterialTheme.colorScheme.onBackground, + onClick = editCustomListName, + background = Color.Unspecified + ) + IconCell( + iconId = R.drawable.icon_add, + title = stringResource(id = R.string.edit_locations), + titleColor = MaterialTheme.colorScheme.onBackground, + onClick = editLocations, + background = Color.Unspecified + ) + HorizontalDivider(color = MaterialTheme.colorScheme.onBackground) + IconCell( + iconId = R.drawable.icon_delete, + title = stringResource(id = R.string.delete), + titleColor = MaterialTheme.colorScheme.onBackground, + onClick = deleteCustomList, + background = Color.Unspecified + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/bottomsheet/ImportOverridesBottomSheet.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/bottomsheet/ImportOverridesBottomSheet.kt new file mode 100644 index 000000000000..1c5a9a21bcb4 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/bottomsheet/ImportOverridesBottomSheet.kt @@ -0,0 +1,92 @@ +package net.mullvad.mullvadvpn.compose.bottomsheet + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.compose.dropUnlessResumed +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.bottomsheet.spec.DestinationStyleBottomSheet +import com.ramcosta.composedestinations.generated.destinations.ImportOverridesByTextDestination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.result.ResultBackNavigator +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.cell.HeaderCell +import net.mullvad.mullvadvpn.compose.cell.IconCell +import net.mullvad.mullvadvpn.compose.component.MullvadModalBottomContainer +import net.mullvad.mullvadvpn.compose.test.SERVER_IP_OVERRIDES_IMPORT_BY_FILE_TEST_TAG +import net.mullvad.mullvadvpn.compose.test.SERVER_IP_OVERRIDES_IMPORT_BY_TEXT_TEST_TAG +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.viewmodel.ImportOverridesSheetViewModel +import org.koin.androidx.compose.koinViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Destination(style = DestinationStyleBottomSheet::class) +@Composable +fun ImportOverridesSheet( + navigator: DestinationsNavigator, + resultRecipient: ResultBackNavigator +) { + val vm = koinViewModel() + val state = vm.uiState.collectAsStateWithLifecycle() + + MullvadModalBottomContainer { + HeaderCell( + text = stringResource(id = R.string.server_ip_overrides_import_by), + background = Color.Unspecified + ) + HorizontalDivider(color = MaterialTheme.colorScheme.onBackground) + IconCell( + iconId = R.drawable.icon_upload_file, + title = stringResource(id = R.string.server_ip_overrides_import_by_file), + onClick = dropUnlessResumed { resultRecipient.navigateBack(true) }, + background = Color.Unspecified, + modifier = Modifier.testTag(SERVER_IP_OVERRIDES_IMPORT_BY_FILE_TEST_TAG) + ) + IconCell( + iconId = R.drawable.icon_text_fields, + title = stringResource(id = R.string.server_ip_overrides_import_by_text), + onClick = dropUnlessResumed { navigator.navigate(ImportOverridesByTextDestination) }, + background = Color.Unspecified, + modifier = Modifier.testTag(SERVER_IP_OVERRIDES_IMPORT_BY_TEXT_TEST_TAG) + ) + if (state.value.overridesActive) { + HorizontalDivider(color = MaterialTheme.colorScheme.onBackground) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.padding(Dimens.mediumPadding), + painter = painterResource(id = R.drawable.icon_info), + tint = MaterialTheme.colorScheme.errorContainer, + contentDescription = null + ) + Text( + modifier = + Modifier.padding( + top = Dimens.smallPadding, + end = Dimens.mediumPadding, + bottom = Dimens.smallPadding + ), + text = stringResource(R.string.import_overrides_bottom_sheet_override_warning), + maxLines = 2, + style = MaterialTheme.typography.bodySmall, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/bottomsheet/LocationBottomSheet.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/bottomsheet/LocationBottomSheet.kt new file mode 100644 index 000000000000..8f52e58c1bab --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/bottomsheet/LocationBottomSheet.kt @@ -0,0 +1,115 @@ +package net.mullvad.mullvadvpn.compose.bottomsheet + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.bottomsheet.spec.DestinationStyleBottomSheet +import com.ramcosta.composedestinations.generated.destinations.CreateCustomListDestination +import com.ramcosta.composedestinations.generated.destinations.LocationSheetDestination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.result.ResultBackNavigator +import kotlin.collections.forEach +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.cell.HeaderCell +import net.mullvad.mullvadvpn.compose.cell.IconCell +import net.mullvad.mullvadvpn.compose.communication.CustomListActionResult +import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorMedium +import net.mullvad.mullvadvpn.compose.component.MullvadModalBottomContainer +import net.mullvad.mullvadvpn.compose.state.LocationUiState +import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle +import net.mullvad.mullvadvpn.lib.model.GeoLocationId +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.viewmodel.LocationSheetViewModel +import net.mullvad.mullvadvpn.viewmodel.LocationSideEffect +import org.koin.androidx.compose.koinViewModel + +data class LocationNavArgs(val locationName: String, val id: GeoLocationId) + +@OptIn(ExperimentalMaterial3Api::class) +@Destination( + navArgs = LocationNavArgs::class, + style = DestinationStyleBottomSheet::class +) +@Composable +fun LocationSheet( + navigator: DestinationsNavigator, + backNavigator: ResultBackNavigator, +) { + val viewModel = koinViewModel() + val state = viewModel.uiState.collectAsStateWithLifecycle() + + CollectSideEffectWithLifecycle(viewModel.uiSideEffect) { + when (it) { + is LocationSideEffect.AddLocationResult -> backNavigator.navigateBack(it.result) + } + } + + MullvadModalBottomContainer { + HeaderCell( + text = stringResource(id = R.string.add_location_to_list, state.value.name), + background = Color.Unspecified + ) + HorizontalDivider(color = MaterialTheme.colorScheme.onBackground) + + when (val s = state.value) { + is LocationUiState.Content -> + LocationContent( + s, + { + navigator.navigate(CreateCustomListDestination(s.location.id)) { + popUpTo(LocationSheetDestination) { inclusive = true } + } + }, + viewModel::addLocationToList + ) + is LocationUiState.Loading -> + MullvadCircularProgressIndicatorMedium( + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + } + } +} + +@Composable +private fun ColumnScope.LocationContent( + state: LocationUiState.Content, + createCustomListWithLocation: (location: GeoLocationId) -> Unit, + addLocationToList: (location: RelayItem.Location, customList: RelayItem.CustomList) -> Unit +) { + state.customLists.forEach { + IconCell( + iconId = null, + title = + if (it.canAdd) { + it.customList.name + } else { + stringResource(id = R.string.location_added, it.customList.name) + }, + titleColor = + if (it.canAdd) { + MaterialTheme.colorScheme.onBackground + } else { + MaterialTheme.colorScheme.onSecondary + }, + onClick = { addLocationToList(state.location, it.customList) }, + background = Color.Unspecified, + enabled = it.canAdd + ) + } + IconCell( + iconId = R.drawable.icon_add, + title = stringResource(id = R.string.new_list), + titleColor = MaterialTheme.colorScheme.onBackground, + onClick = { createCustomListWithLocation(state.location.id) }, + background = Color.Unspecified + ) +} 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..f6fda430ce34 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 @@ -5,7 +5,11 @@ import kotlinx.parcelize.Parcelize import net.mullvad.mullvadvpn.lib.model.CustomListId import net.mullvad.mullvadvpn.lib.model.CustomListName -sealed interface CustomListSuccess : Parcelable { +@Parcelize sealed interface CustomListActionResult : Parcelable + +@Parcelize data object GenericError : CustomListActionResult, Parcelable + +sealed interface CustomListSuccess : CustomListActionResult, Parcelable { val undo: CustomListAction } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadModalBottomContainer.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadModalBottomContainer.kt new file mode 100644 index 000000000000..c29f7a366111 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadModalBottomContainer.kt @@ -0,0 +1,54 @@ +package net.mullvad.mullvadvpn.compose.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.BottomSheetDefaults.DragHandle +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import net.mullvad.mullvadvpn.compose.cell.HeaderCell +import net.mullvad.mullvadvpn.compose.cell.IconCell +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun PreviewMullvadModalBottomSheet() { + AppTheme { + MullvadModalBottomContainer { + HeaderCell( + text = "Title", + ) + HorizontalDivider() + IconCell( + iconId = null, + title = "Select", + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MullvadModalBottomContainer(sheetContent: @Composable ColumnScope.() -> Unit) { + val paddingValues = BottomSheetDefaults.windowInsets.asPaddingValues() + Column(Modifier.fillMaxWidth()) { + DragHandle( + modifier = Modifier.align(CenterHorizontally), + color = MaterialTheme.colorScheme.onSurface + ) + sheetContent() + Spacer(modifier = Modifier.height(Dimens.smallPadding)) + Spacer(modifier = Modifier.height(paddingValues.calculateBottomPadding())) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadModalBottomSheet.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadModalBottomSheet.kt deleted file mode 100644 index 9cbd5df5bee0..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadModalBottomSheet.kt +++ /dev/null @@ -1,69 +0,0 @@ -package net.mullvad.mullvadvpn.compose.component - -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.height -import androidx.compose.material3.BottomSheetDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.SheetState -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.tooling.preview.Preview -import net.mullvad.mullvadvpn.compose.cell.HeaderCell -import net.mullvad.mullvadvpn.compose.cell.IconCell -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.lib.theme.Dimens - -@OptIn(ExperimentalMaterial3Api::class) -@Preview -@Composable -private fun PreviewMullvadModalBottomSheet() { - AppTheme { - MullvadModalBottomSheet( - sheetContent = { - HeaderCell( - text = "Title", - ) - HorizontalDivider() - IconCell( - iconId = null, - title = "Select", - ) - }, - onDismissRequest = {} - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun MullvadModalBottomSheet( - modifier: Modifier = Modifier, - sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - backgroundColor: Color = MaterialTheme.colorScheme.surfaceContainer, - onBackgroundColor: Color = MaterialTheme.colorScheme.onSurface, - onDismissRequest: () -> Unit, - sheetContent: @Composable ColumnScope.() -> Unit -) { - // This is to avoid weird colors in the status bar and the navigation bar - val paddingValues = BottomSheetDefaults.windowInsets.asPaddingValues() - ModalBottomSheet( - onDismissRequest = onDismissRequest, - sheetState = sheetState, - containerColor = backgroundColor, - modifier = modifier, - contentWindowInsets = { WindowInsets(0, 0, 0, 0) }, // No insets - dragHandle = { BottomSheetDefaults.DragHandle(color = onBackgroundColor) } - ) { - sheetContent() - Spacer(modifier = Modifier.height(Dimens.smallPadding)) - Spacer(modifier = Modifier.height(paddingValues.calculateBottomPadding())) - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/LifecycleExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/LifecycleExtensions.kt index 3e04f83b5403..f8285d71ee15 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/LifecycleExtensions.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/LifecycleExtensions.kt @@ -55,3 +55,25 @@ fun LifecycleOwner.runOnAtLeast( } } } + +@Composable +fun dropUnlessResumed(block: (T, T2, T3) -> Unit): (T, T2, T3) -> Unit { + val lifecycleOwner = LocalLifecycleOwner.current + return dropUnlessResumed(lifecycleOwner, block) +} + +fun dropUnlessResumed( + lifecycleOwner: LifecycleOwner, + block: (T, T2, T3) -> Unit +): (T, T2, T3) -> Unit = lifecycleOwner.runOnAtLeast(Lifecycle.State.RESUMED, block) + +fun LifecycleOwner.runOnAtLeast( + expectedState: Lifecycle.State, + block: (T, T2, T3) -> Unit +): (T, T2, T3) -> Unit { + return { t, t2, t3 -> + if (lifecycle.currentState.isAtLeast(expectedState)) { + block(t, t2, t3) + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt index c73e6601e560..d23e3724dea9 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt @@ -2,6 +2,9 @@ package net.mullvad.mullvadvpn.compose.screen import androidx.activity.compose.rememberLauncherForActivityResult import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.navigation.ModalBottomSheetLayout +import androidx.compose.material.navigation.rememberBottomSheetNavigator +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.ExperimentalComposeUiApi @@ -36,20 +39,28 @@ fun MullvadApp() { val engine = rememberNavHostEngine() val navController: NavHostController = engine.rememberNavController() + val bottomSheetNavigator = rememberBottomSheetNavigator() + navController.navigatorProvider.addNavigator(bottomSheetNavigator) + val serviceVm = koinViewModel() val permissionVm = koinViewModel() - DisposableEffect(Unit) { navController.addOnDestinationChangedListener(serviceVm) onDispose { navController.removeOnDestinationChangedListener(serviceVm) } } - DestinationsNavHost( - modifier = Modifier.semantics { testTagsAsResourceId = true }.fillMaxSize(), - engine = engine, - navController = navController, - navGraph = NavGraphs.root, - ) + ModalBottomSheetLayout( + bottomSheetNavigator = bottomSheetNavigator, + sheetBackgroundColor = MaterialTheme.colorScheme.surfaceContainer, + sheetContentColor = MaterialTheme.colorScheme.onSurface, + ) { + DestinationsNavHost( + modifier = Modifier.semantics { testTagsAsResourceId = true }.fillMaxSize(), + engine = engine, + navController = navController, + navGraph = NavGraphs.root, + ) + } // Globally handle daemon dropped connection with NoDaemonScreen LaunchedEffectCollect(serviceVm.uiSideEffect) { 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..986633c4220d 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 @@ -17,24 +16,17 @@ import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.SheetState import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate @@ -50,11 +42,14 @@ import androidx.lifecycle.compose.dropUnlessResumed import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.generated.destinations.CreateCustomListDestination +import com.ramcosta.composedestinations.generated.destinations.CustomListEntrySheetDestination import com.ramcosta.composedestinations.generated.destinations.CustomListLocationsDestination -import com.ramcosta.composedestinations.generated.destinations.CustomListsDestination +import com.ramcosta.composedestinations.generated.destinations.CustomListSheetDestination +import com.ramcosta.composedestinations.generated.destinations.CustomListsSheetDestination import com.ramcosta.composedestinations.generated.destinations.DeleteCustomListDestination import com.ramcosta.composedestinations.generated.destinations.EditCustomListNameDestination import com.ramcosta.composedestinations.generated.destinations.FilterDestination +import com.ramcosta.composedestinations.generated.destinations.LocationSheetDestination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.result.NavResult import com.ramcosta.composedestinations.result.ResultBackNavigator @@ -64,43 +59,39 @@ import kotlinx.coroutines.launch 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.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.CustomListActionResult import net.mullvad.mullvadvpn.compose.communication.CustomListSuccess import net.mullvad.mullvadvpn.compose.communication.Deleted +import net.mullvad.mullvadvpn.compose.communication.GenericError import net.mullvad.mullvadvpn.compose.communication.LocationsChanged import net.mullvad.mullvadvpn.compose.communication.Renamed import net.mullvad.mullvadvpn.compose.component.LocationsEmptyText import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge -import net.mullvad.mullvadvpn.compose.component.MullvadModalBottomSheet 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.SelectLocationUiState import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR -import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_CUSTOM_LIST_BOTTOM_SHEET_TEST_TAG import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG -import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_LOCATION_BOTTOM_SHEET_TEST_TAG import net.mullvad.mullvadvpn.compose.textfield.SearchTextField 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.CustomListId +import net.mullvad.mullvadvpn.lib.model.CustomListName 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 import net.mullvad.mullvadvpn.lib.theme.Dimens -import net.mullvad.mullvadvpn.lib.theme.color.AlphaInactive import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar -import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible -import net.mullvad.mullvadvpn.relaylist.canAddLocation import net.mullvad.mullvadvpn.viewmodel.SelectLocationSideEffect import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel import org.koin.androidx.compose.koinViewModel @@ -144,7 +135,10 @@ fun SelectLocation( ResultRecipient, deleteCustomListDialogResultRecipient: ResultRecipient, updateCustomListResultRecipient: - ResultRecipient + ResultRecipient, + locationSheetResultRecipient: ResultRecipient, + customListEntryResultRecipient: + ResultRecipient ) { val vm = koinViewModel() val state = vm.uiState.collectAsStateWithLifecycle().value @@ -196,6 +190,10 @@ fun SelectLocation( vm::performAction ) + locationSheetResultRecipient.OnCustomListNavResult(snackbarHostState, vm::performAction) + + customListEntryResultRecipient.OnCustomListNavResult(snackbarHostState, vm::performAction) + updateCustomListResultRecipient.OnCustomListNavResult(snackbarHostState, vm::performAction) SelectLocationScreen( @@ -205,41 +203,27 @@ fun SelectLocation( onSearchTermInput = vm::onSearchTermInput, onBackClick = dropUnlessResumed { backNavigator.navigateBack() }, onFilterClick = dropUnlessResumed { navigator.navigate(FilterDestination) }, - onCreateCustomList = - dropUnlessResumed { relayItem -> - navigator.navigate( - CreateCustomListDestination(locationCode = relayItem?.id), - ) - }, - onEditCustomLists = dropUnlessResumed { navigator.navigate(CustomListsDestination()) }, removeOwnershipFilter = vm::removeOwnerFilter, removeProviderFilter = vm::removeProviderFilter, - onAddLocationToList = vm::addLocationToList, - onRemoveLocationFromList = vm::removeLocationFromList, - onEditCustomListName = - dropUnlessResumed { customList: RelayItem.CustomList -> - navigator.navigate( - EditCustomListNameDestination( - customListId = customList.id, - initialName = customList.customListName - ), - ) + showCustomListBottomSheet = + dropUnlessResumed { navigator.navigate(CustomListsSheetDestination(true)) }, + showLocationBottomSheet = + dropUnlessResumed { name, location -> + navigator.navigate(LocationSheetDestination(name, location)) }, - onEditLocationsCustomList = - dropUnlessResumed { customList: RelayItem.CustomList -> - navigator.navigate( - CustomListLocationsDestination(customListId = customList.id, newList = false), - ) + showEditCustomListBottomSheet = + dropUnlessResumed { customListId: CustomListId, customListName: CustomListName -> + navigator.navigate(CustomListSheetDestination(customListId, customListName)) }, - onDeleteCustomList = - dropUnlessResumed { customList: RelayItem.CustomList -> + showEditCustomListEntryBottomSheet = + dropUnlessResumed { + locationName: String, + customList: CustomListId, + location: GeoLocationId -> navigator.navigate( - DeleteCustomListDestination( - customListId = customList.id, - name = customList.customListName - ), + CustomListEntrySheetDestination(locationName, customList, location) ) - } + }, ) } @@ -253,20 +237,13 @@ fun SelectLocationScreen( onSearchTermInput: (searchTerm: String) -> Unit = {}, onBackClick: () -> Unit = {}, onFilterClick: () -> Unit = {}, - onCreateCustomList: (location: RelayItem.Location?) -> Unit = {}, - onEditCustomLists: () -> Unit = {}, removeOwnershipFilter: () -> Unit = {}, removeProviderFilter: () -> Unit = {}, - onAddLocationToList: (location: RelayItem.Location, customList: RelayItem.CustomList) -> Unit = - { _, _ -> - }, - onRemoveLocationFromList: - (location: RelayItem.Location, customList: RelayItem.CustomList) -> Unit = - { _, _ -> - }, - onEditCustomListName: (RelayItem.CustomList) -> Unit = {}, - onEditLocationsCustomList: (RelayItem.CustomList) -> Unit = {}, - onDeleteCustomList: (RelayItem.CustomList) -> Unit = {} + showCustomListBottomSheet: () -> Unit = {}, + showEditCustomListBottomSheet: (CustomListId, CustomListName) -> Unit = { _, _ -> }, + showEditCustomListEntryBottomSheet: (String, CustomListId, GeoLocationId) -> Unit = { _, _, _ -> + }, + showLocationBottomSheet: (String, GeoLocationId) -> Unit = { _, _ -> }, ) { val backgroundColor = MaterialTheme.colorScheme.background @@ -278,19 +255,6 @@ fun SelectLocationScreen( ) } ) { - var bottomSheetState by remember { mutableStateOf(null) } - BottomSheets( - bottomSheetState = bottomSheetState, - onCreateCustomList = onCreateCustomList, - onEditCustomLists = onEditCustomLists, - onAddLocationToList = onAddLocationToList, - onRemoveLocationFromList = onRemoveLocationFromList, - onEditCustomListName = onEditCustomListName, - onEditLocationsCustomList = onEditLocationsCustomList, - onDeleteCustomList = onDeleteCustomList, - onHideBottomSheet = { bottomSheetState = null } - ) - Column(modifier = Modifier.padding(it).background(backgroundColor).fillMaxSize()) { SelectLocationTopBar(onBackClick = onBackClick, onFilterClick = onFilterClick) @@ -350,32 +314,21 @@ fun SelectLocationScreen( selectedItem = state.selectedItem, backgroundColor = backgroundColor, onSelectRelay = onSelectRelay, - onShowCustomListBottomSheet = { - bottomSheetState = - BottomSheetState.ShowCustomListsBottomSheet( - state.customLists.isNotEmpty() - ) - }, - onShowEditBottomSheet = { customList -> - bottomSheetState = - BottomSheetState.ShowEditCustomListBottomSheet(customList) - }, + onShowCustomListBottomSheet = showCustomListBottomSheet, + onShowEditBottomSheet = showEditCustomListBottomSheet, onShowEditCustomListEntryBottomSheet = { item: RelayItem.Location, customList: RelayItem.CustomList -> - bottomSheetState = - BottomSheetState.ShowCustomListsEntryBottomSheet( - customList, - item, - ) + showEditCustomListEntryBottomSheet( + item.name, + customList.id, + item.id + ) } ) item { Spacer( - modifier = - Modifier.height(Dimens.mediumPadding) - .animateItemPlacement() - .animateContentSize() + modifier = Modifier.height(Dimens.mediumPadding).animateItem() ) } } @@ -385,11 +338,7 @@ fun SelectLocationScreen( selectedItem = state.selectedItem, onSelectRelay = onSelectRelay, onShowLocationBottomSheet = { location -> - bottomSheetState = - BottomSheetState.ShowLocationBottomSheet( - customLists = state.customLists, - item = location - ) + showLocationBottomSheet(location.name, location.id) } ) } @@ -444,7 +393,7 @@ private fun LazyListScope.customLists( backgroundColor: Color, onSelectRelay: (item: RelayItem) -> Unit, onShowCustomListBottomSheet: () -> Unit, - onShowEditBottomSheet: (RelayItem.CustomList) -> Unit, + onShowEditBottomSheet: (CustomListId, CustomListName) -> Unit, onShowEditCustomListEntryBottomSheet: (item: RelayItem.Location, RelayItem.CustomList) -> Unit ) { item( @@ -453,10 +402,7 @@ private fun LazyListScope.customLists( ThreeDotCell( text = stringResource(R.string.custom_lists), onClickDots = onShowCustomListBottomSheet, - modifier = - Modifier.testTag(SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG) - .animateItemPlacement() - .animateContentSize() + modifier = Modifier.testTag(SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG).animateItem() ) } if (customLists.isNotEmpty()) { @@ -472,27 +418,25 @@ private fun LazyListScope.customLists( onSelectRelay = onSelectRelay, onLongClick = { if (it is RelayItem.CustomList) { - onShowEditBottomSheet(it) + onShowEditBottomSheet(it.id, it.customListName) } else if (it is RelayItem.Location && it in customList.locations) { onShowEditCustomListEntryBottomSheet(it, customList) } }, - modifier = Modifier.animateContentSize().animateItemPlacement(), + modifier = Modifier.animateItem(), ) } item { SwitchComposeSubtitleCell( text = stringResource(R.string.to_add_locations_to_a_list), - modifier = - Modifier.background(backgroundColor).animateItemPlacement().animateContentSize() + modifier = Modifier.background(backgroundColor).animateItem() ) } } else { item(contentType = ContentType.EMPTY_TEXT) { SwitchComposeSubtitleCell( text = stringResource(R.string.to_create_a_custom_list), - modifier = - Modifier.background(backgroundColor).animateItemPlacement().animateContentSize() + modifier = Modifier.background(backgroundColor).animateItem() ) } } @@ -506,12 +450,10 @@ private fun LazyListScope.relayList( onShowLocationBottomSheet: (item: RelayItem.Location) -> Unit, ) { item( + key = "all_locations_header", contentType = ContentType.HEADER, ) { - HeaderCell( - text = stringResource(R.string.all_locations), - modifier = Modifier.animateItemPlacement().animateContentSize() - ) + HeaderCell(text = stringResource(R.string.all_locations), modifier = Modifier.animateItem()) } items( items = countries, @@ -523,90 +465,11 @@ private fun LazyListScope.relayList( selectedItem = selectedItem, onSelectRelay = onSelectRelay, onLongClick = { onShowLocationBottomSheet(it as RelayItem.Location) }, - modifier = Modifier.animateContentSize().animateItemPlacement(), + modifier = Modifier.animateItem() ) } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun BottomSheets( - bottomSheetState: BottomSheetState?, - onCreateCustomList: (RelayItem.Location?) -> Unit, - onEditCustomLists: () -> Unit, - onAddLocationToList: (RelayItem.Location, RelayItem.CustomList) -> Unit, - onRemoveLocationFromList: (RelayItem.Location, RelayItem.CustomList) -> Unit, - onEditCustomListName: (RelayItem.CustomList) -> Unit, - onEditLocationsCustomList: (RelayItem.CustomList) -> Unit, - onDeleteCustomList: (RelayItem.CustomList) -> Unit, - onHideBottomSheet: () -> Unit -) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val scope = rememberCoroutineScope() - val onCloseBottomSheet: (animate: Boolean) -> Unit = { animate -> - if (animate) { - scope - .launch { sheetState.hide() } - .invokeOnCompletion { - if (!sheetState.isVisible) { - onHideBottomSheet() - } - } - } else { - onHideBottomSheet() - } - } - val onBackgroundColor: Color = MaterialTheme.colorScheme.onSurface - - when (bottomSheetState) { - is BottomSheetState.ShowCustomListsBottomSheet -> { - CustomListsBottomSheet( - sheetState = sheetState, - onBackgroundColor = onBackgroundColor, - bottomSheetState = bottomSheetState, - onCreateCustomList = { onCreateCustomList(null) }, - onEditCustomLists = onEditCustomLists, - closeBottomSheet = onCloseBottomSheet - ) - } - is BottomSheetState.ShowLocationBottomSheet -> { - LocationBottomSheet( - sheetState = sheetState, - onBackgroundColor = onBackgroundColor, - customLists = bottomSheetState.customLists, - item = bottomSheetState.item, - onCreateCustomList = onCreateCustomList, - onAddLocationToList = onAddLocationToList, - closeBottomSheet = onCloseBottomSheet - ) - } - is BottomSheetState.ShowEditCustomListBottomSheet -> { - EditCustomListBottomSheet( - sheetState = sheetState, - onBackgroundColor = onBackgroundColor, - customList = bottomSheetState.customList, - onEditName = onEditCustomListName, - onEditLocations = onEditLocationsCustomList, - onDeleteCustomList = onDeleteCustomList, - closeBottomSheet = onCloseBottomSheet - ) - } - is BottomSheetState.ShowCustomListsEntryBottomSheet -> { - CustomListEntryBottomSheet( - sheetState = sheetState, - onBackgroundColor = onBackgroundColor, - customList = bottomSheetState.customList, - item = bottomSheetState.item, - onRemoveLocationFromList = onRemoveLocationFromList, - closeBottomSheet = onCloseBottomSheet - ) - } - null -> { - /* Do nothing */ - } - } -} - private fun SelectLocationUiState.indexOfSelectedRelayItem(): Int = if (this is SelectLocationUiState.Content) { when (selectedItem) { @@ -622,201 +485,6 @@ private fun SelectLocationUiState.indexOfSelectedRelayItem(): Int = -1 } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun CustomListsBottomSheet( - onBackgroundColor: Color, - sheetState: SheetState, - bottomSheetState: BottomSheetState.ShowCustomListsBottomSheet, - onCreateCustomList: () -> Unit, - onEditCustomLists: () -> Unit, - closeBottomSheet: (animate: Boolean) -> Unit -) { - MullvadModalBottomSheet( - sheetState = sheetState, - onDismissRequest = { closeBottomSheet(false) }, - modifier = Modifier.testTag(SELECT_LOCATION_CUSTOM_LIST_BOTTOM_SHEET_TEST_TAG) - ) { - HeaderCell( - text = stringResource(id = R.string.edit_custom_lists), - background = Color.Unspecified - ) - HorizontalDivider(color = onBackgroundColor) - IconCell( - iconId = R.drawable.icon_add, - title = stringResource(id = R.string.new_list), - titleColor = onBackgroundColor, - onClick = { - onCreateCustomList() - closeBottomSheet(true) - }, - background = Color.Unspecified - ) - IconCell( - iconId = R.drawable.icon_edit, - title = stringResource(id = R.string.edit_lists), - titleColor = - onBackgroundColor.copy( - alpha = - if (bottomSheetState.editListEnabled) { - AlphaVisible - } else { - AlphaInactive - } - ), - onClick = { - onEditCustomLists() - closeBottomSheet(true) - }, - background = Color.Unspecified, - enabled = bottomSheetState.editListEnabled - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun LocationBottomSheet( - onBackgroundColor: Color, - sheetState: SheetState, - customLists: List, - item: RelayItem.Location, - onCreateCustomList: (relayItem: RelayItem.Location) -> Unit, - onAddLocationToList: (location: RelayItem.Location, customList: RelayItem.CustomList) -> Unit, - closeBottomSheet: (animate: Boolean) -> Unit -) { - MullvadModalBottomSheet( - sheetState = sheetState, - onDismissRequest = { closeBottomSheet(false) }, - modifier = Modifier.testTag(SELECT_LOCATION_LOCATION_BOTTOM_SHEET_TEST_TAG) - ) { -> - HeaderCell( - text = stringResource(id = R.string.add_location_to_list, item.name), - background = Color.Unspecified - ) - HorizontalDivider(color = onBackgroundColor) - customLists.forEach { - val enabled = it.canAddLocation(item) - IconCell( - iconId = null, - title = - if (enabled) { - it.name - } else { - stringResource(id = R.string.location_added, it.name) - }, - titleColor = - if (enabled) { - onBackgroundColor - } else { - MaterialTheme.colorScheme.onSecondary - }, - onClick = { - onAddLocationToList(item, it) - closeBottomSheet(true) - }, - background = Color.Unspecified, - enabled = enabled - ) - } - IconCell( - iconId = R.drawable.icon_add, - title = stringResource(id = R.string.new_list), - titleColor = onBackgroundColor, - onClick = { - onCreateCustomList(item) - closeBottomSheet(true) - }, - background = Color.Unspecified - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun EditCustomListBottomSheet( - onBackgroundColor: Color, - sheetState: SheetState, - customList: RelayItem.CustomList, - onEditName: (item: RelayItem.CustomList) -> Unit, - onEditLocations: (item: RelayItem.CustomList) -> Unit, - onDeleteCustomList: (item: RelayItem.CustomList) -> Unit, - closeBottomSheet: (animate: Boolean) -> Unit -) { - MullvadModalBottomSheet( - sheetState = sheetState, - onDismissRequest = { closeBottomSheet(false) } - ) { - HeaderCell(text = customList.name, background = Color.Unspecified) - IconCell( - iconId = R.drawable.icon_edit, - title = stringResource(id = R.string.edit_name), - titleColor = onBackgroundColor, - onClick = { - onEditName(customList) - closeBottomSheet(true) - }, - background = Color.Unspecified - ) - IconCell( - iconId = R.drawable.icon_add, - title = stringResource(id = R.string.edit_locations), - titleColor = onBackgroundColor, - onClick = { - onEditLocations(customList) - closeBottomSheet(true) - }, - background = Color.Unspecified - ) - HorizontalDivider(color = onBackgroundColor) - IconCell( - iconId = R.drawable.icon_delete, - title = stringResource(id = R.string.delete), - titleColor = onBackgroundColor, - onClick = { - onDeleteCustomList(customList) - closeBottomSheet(true) - }, - background = Color.Unspecified - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun CustomListEntryBottomSheet( - onBackgroundColor: Color, - sheetState: SheetState, - customList: RelayItem.CustomList, - item: RelayItem.Location, - onRemoveLocationFromList: - (location: RelayItem.Location, customList: RelayItem.CustomList) -> Unit, - closeBottomSheet: (animate: Boolean) -> Unit -) { - MullvadModalBottomSheet( - sheetState = sheetState, - onDismissRequest = { closeBottomSheet(false) }, - modifier = Modifier.testTag(SELECT_LOCATION_LOCATION_BOTTOM_SHEET_TEST_TAG) - ) { - HeaderCell( - text = stringResource(id = R.string.remove_location_from_list, item.name), - background = Color.Unspecified - ) - HorizontalDivider(color = onBackgroundColor) - - IconCell( - iconId = R.drawable.ic_remove, - title = stringResource(id = R.string.remove_button), - titleColor = onBackgroundColor, - onClick = { - onRemoveLocationFromList(item, customList) - closeBottomSheet(true) - }, - background = Color.Unspecified - ) - } -} - private suspend fun LazyListState.animateScrollAndCentralizeItem(index: Int) { val itemInfo = this.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } if (itemInfo != null) { @@ -853,7 +521,7 @@ private fun CustomListSuccess.message(context: Context): String = } @Composable -private fun ResultRecipient +private fun ResultRecipient .OnCustomListNavResult( snackbarHostState: SnackbarHostState, performAction: (action: CustomListAction) -> Unit @@ -867,12 +535,24 @@ private fun ResultRecipient } is NavResult.Value -> { // Handle result - scope.launch { - snackbarHostState.showResultSnackbar( - context = context, - result = result.value, - onUndo = performAction - ) + val customListActionResult = result.value + when (customListActionResult) { + is GenericError -> { + scope.launch { + snackbarHostState.showSnackbarImmediately( + message = context.getString(R.string.error_occurred), + duration = SnackbarDuration.Short + ) + } + } + is CustomListSuccess -> + scope.launch { + snackbarHostState.showResultSnackbar( + context = context, + result = customListActionResult, + onUndo = performAction + ) + } } } } @@ -882,21 +562,3 @@ 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 item: RelayItem.Location - ) : BottomSheetState - - data class ShowLocationBottomSheet( - val customLists: List, - val item: RelayItem.Location - ) : BottomSheetState - - data class ShowEditCustomListBottomSheet(val customList: RelayItem.CustomList) : - BottomSheetState -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ServerIpOverridesScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ServerIpOverridesScreen.kt index c323fcce3f6c..ee2deb25130c 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ServerIpOverridesScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ServerIpOverridesScreen.kt @@ -6,7 +6,6 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons @@ -14,36 +13,31 @@ import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MenuDefaults -import androidx.compose.material3.SheetState import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.dropUnlessResumed import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.generated.destinations.ImportOverridesByTextDestination +import com.ramcosta.composedestinations.generated.destinations.ImportOverridesSheetDestination import com.ramcosta.composedestinations.generated.destinations.ResetServerIpOverridesConfirmationDestination import com.ramcosta.composedestinations.generated.destinations.ServerIpOverridesInfoDestination import com.ramcosta.composedestinations.navigation.DestinationsNavigator @@ -52,15 +46,10 @@ import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.InfoIconButton import net.mullvad.mullvadvpn.compose.button.PrimaryButton -import net.mullvad.mullvadvpn.compose.cell.HeaderCell -import net.mullvad.mullvadvpn.compose.cell.IconCell import net.mullvad.mullvadvpn.compose.cell.ServerIpOverridesCell -import net.mullvad.mullvadvpn.compose.component.MullvadModalBottomSheet import net.mullvad.mullvadvpn.compose.component.MullvadSnackbar import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar -import net.mullvad.mullvadvpn.compose.test.SERVER_IP_OVERRIDES_IMPORT_BY_FILE_TEST_TAG -import net.mullvad.mullvadvpn.compose.test.SERVER_IP_OVERRIDES_IMPORT_BY_TEXT_TEST_TAG import net.mullvad.mullvadvpn.compose.test.SERVER_IP_OVERRIDE_IMPORT_TEST_TAG import net.mullvad.mullvadvpn.compose.test.SERVER_IP_OVERRIDE_INFO_TEST_TAG import net.mullvad.mullvadvpn.compose.test.SERVER_IP_OVERRIDE_MORE_VERT_TEST_TAG @@ -87,8 +76,7 @@ private fun PreviewServerIpOverridesScreen() { onBackClick = {}, onInfoClick = {}, onResetOverridesClick = {}, - onImportByFile = {}, - onImportByText = {}, + showBottomSheet = {}, SnackbarHostState() ) } @@ -99,12 +87,19 @@ private fun PreviewServerIpOverridesScreen() { fun ServerIpOverrides( navigator: DestinationsNavigator, importByTextResult: ResultRecipient, + importByFileResult: ResultRecipient, clearOverridesResult: ResultRecipient, ) { val vm = koinViewModel() val state by vm.uiState.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } + val openFileLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { + if (it != null) { + vm.importFile(it) + } + } val context = LocalContext.current LaunchedEffectCollect(vm.uiSideEffect) { sideEffect -> when (sideEffect) { @@ -118,6 +113,7 @@ fun ServerIpOverrides( } } + importByFileResult.OnNavResultValue { openFileLauncher.launch("application/json") } importByTextResult.OnNavResultValue(vm::importText) // On successful clear of overrides, show snackbar @@ -136,21 +132,13 @@ fun ServerIpOverrides( } } - val openFileLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { - if (it != null) { - vm.importFile(it) - } - } - ServerIpOverridesScreen( state, onBackClick = dropUnlessResumed { navigator.navigateUp() }, onInfoClick = dropUnlessResumed { navigator.navigate(ServerIpOverridesInfoDestination) }, onResetOverridesClick = dropUnlessResumed { navigator.navigate(ResetServerIpOverridesConfirmationDestination) }, - onImportByFile = dropUnlessResumed { openFileLauncher.launch("application/json") }, - onImportByText = dropUnlessResumed { navigator.navigate(ImportOverridesByTextDestination) }, + showBottomSheet = dropUnlessResumed { navigator.navigate(ImportOverridesSheetDestination) }, snackbarHostState ) } @@ -162,14 +150,9 @@ fun ServerIpOverridesScreen( onBackClick: () -> Unit, onInfoClick: () -> Unit, onResetOverridesClick: () -> Unit, - onImportByFile: () -> Unit, - onImportByText: () -> Unit, + showBottomSheet: () -> Unit, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() } ) { - - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - var showBottomSheet by remember { mutableStateOf(false) } - ScaffoldWithMediumTopBar( appBarTitle = stringResource(id = R.string.server_ip_overrides), navigationIcon = { NavigateBackIconButton(onBackClick) }, @@ -181,16 +164,6 @@ fun ServerIpOverridesScreen( ) } ) { modifier -> - if (showBottomSheet && state.overridesActive != null) { - ImportOverridesByBottomSheet( - sheetState, - { showBottomSheet = it }, - state.overridesActive!!, - onImportByFile, - onImportByText - ) - } - Column( modifier = modifier.animateContentSize(), ) { @@ -198,7 +171,7 @@ fun ServerIpOverridesScreen( Spacer(modifier = Modifier.weight(1f)) PrimaryButton( - onClick = { showBottomSheet = true }, + onClick = showBottomSheet, text = stringResource(R.string.server_ip_overrides_import_button), modifier = Modifier.padding(horizontal = Dimens.sideMargin) @@ -212,83 +185,6 @@ fun ServerIpOverridesScreen( } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun ImportOverridesByBottomSheet( - sheetState: SheetState, - showBottomSheet: (Boolean) -> Unit, - overridesActive: Boolean, - onImportByFile: () -> Unit, - onImportByText: () -> Unit -) { - val scope = rememberCoroutineScope() - val onCloseSheet = { - scope - .launch { sheetState.hide() } - .invokeOnCompletion { - if (!sheetState.isVisible) { - showBottomSheet(false) - } - } - } - - MullvadModalBottomSheet( - sheetState = sheetState, - onDismissRequest = { showBottomSheet(false) }, - ) { - HeaderCell( - text = stringResource(id = R.string.server_ip_overrides_import_by), - background = Color.Unspecified - ) - HorizontalDivider(color = MaterialTheme.colorScheme.onBackground) - IconCell( - iconId = R.drawable.icon_upload_file, - title = stringResource(id = R.string.server_ip_overrides_import_by_file), - onClick = { - onImportByFile() - onCloseSheet() - }, - background = Color.Unspecified, - modifier = Modifier.testTag(SERVER_IP_OVERRIDES_IMPORT_BY_FILE_TEST_TAG) - ) - IconCell( - iconId = R.drawable.icon_text_fields, - title = stringResource(id = R.string.server_ip_overrides_import_by_text), - onClick = { - onImportByText() - onCloseSheet() - }, - background = Color.Unspecified, - modifier = Modifier.testTag(SERVER_IP_OVERRIDES_IMPORT_BY_TEXT_TEST_TAG) - ) - if (overridesActive) { - HorizontalDivider(color = MaterialTheme.colorScheme.onBackground) - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - modifier = Modifier.padding(Dimens.mediumPadding), - painter = painterResource(id = R.drawable.icon_info), - tint = MaterialTheme.colorScheme.errorContainer, - contentDescription = null - ) - Text( - modifier = - Modifier.padding( - top = Dimens.smallPadding, - end = Dimens.mediumPadding, - bottom = Dimens.smallPadding - ), - text = stringResource(R.string.import_overrides_bottom_sheet_override_warning), - maxLines = 2, - style = MaterialTheme.typography.bodySmall, - overflow = TextOverflow.Ellipsis, - ) - } - } - } -} - @Composable private fun TopBarActions( overridesActive: Boolean?, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListEntrySheetUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListEntrySheetUiState.kt new file mode 100644 index 000000000000..b27eb46e1adc --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListEntrySheetUiState.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.compose.state + +data class CustomListEntrySheetUiState( + val locationName: String, +) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListSheetUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListSheetUiState.kt new file mode 100644 index 000000000000..8b52e8abcaac --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListSheetUiState.kt @@ -0,0 +1,9 @@ +package net.mullvad.mullvadvpn.compose.state + +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.CustomListName + +data class CustomListSheetUiState( + val customListId: CustomListId, + val customListName: CustomListName +) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ImportOverridesSheetUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ImportOverridesSheetUiState.kt new file mode 100644 index 000000000000..5e5dd3533510 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ImportOverridesSheetUiState.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.compose.state + +data class ImportOverridesSheetUiState(val overridesActive: Boolean) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/LocationUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/LocationUiState.kt new file mode 100644 index 000000000000..5b19427ded3c --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/LocationUiState.kt @@ -0,0 +1,16 @@ +package net.mullvad.mullvadvpn.compose.state + +import net.mullvad.mullvadvpn.lib.model.RelayItem + +sealed interface LocationUiState { + val name: String + + data class Loading(override val name: String) : LocationUiState + + data class Content(val location: RelayItem.Location, val customLists: List) : + LocationUiState { + override val name: String = location.name + } +} + +data class CustomListEntry(val customList: RelayItem.CustomList, val canAdd: Boolean) 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..f51f22c76f33 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 @@ -52,7 +52,9 @@ import net.mullvad.mullvadvpn.viewmodel.ApiAccessMethodDetailsViewModel import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel import net.mullvad.mullvadvpn.viewmodel.CreateCustomListDialogViewModel +import net.mullvad.mullvadvpn.viewmodel.CustomListEntrySheetViewModel import net.mullvad.mullvadvpn.viewmodel.CustomListLocationsViewModel +import net.mullvad.mullvadvpn.viewmodel.CustomListSheetViewModel import net.mullvad.mullvadvpn.viewmodel.CustomListsViewModel import net.mullvad.mullvadvpn.viewmodel.DeleteApiAccessMethodConfirmationViewModel import net.mullvad.mullvadvpn.viewmodel.DeleteCustomListConfirmationViewModel @@ -63,6 +65,8 @@ import net.mullvad.mullvadvpn.viewmodel.EditApiAccessMethodViewModel import net.mullvad.mullvadvpn.viewmodel.EditCustomListNameDialogViewModel import net.mullvad.mullvadvpn.viewmodel.EditCustomListViewModel import net.mullvad.mullvadvpn.viewmodel.FilterViewModel +import net.mullvad.mullvadvpn.viewmodel.ImportOverridesSheetViewModel +import net.mullvad.mullvadvpn.viewmodel.LocationSheetViewModel import net.mullvad.mullvadvpn.viewmodel.LoginViewModel import net.mullvad.mullvadvpn.viewmodel.MtuDialogViewModel import net.mullvad.mullvadvpn.viewmodel.NoDaemonViewModel @@ -209,6 +213,11 @@ val uiModule = module { viewModel { ApiAccessMethodDetailsViewModel(get(), get()) } viewModel { DeleteApiAccessMethodConfirmationViewModel(get(), get()) } + viewModel { LocationSheetViewModel(get(), get(), get(), get()) } + viewModel { CustomListSheetViewModel(get()) } + viewModel { CustomListEntrySheetViewModel(get(), get(), get()) } + viewModel { ImportOverridesSheetViewModel(get()) } + // This view model must be single so we correctly attach lifecycle and share it with activity single { NoDaemonViewModel(get()) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListEntrySheetViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListEntrySheetViewModel.kt new file mode 100644 index 000000000000..d1a763802500 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListEntrySheetViewModel.kt @@ -0,0 +1,53 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import arrow.core.raise.either +import com.ramcosta.composedestinations.generated.destinations.CustomListEntrySheetDestination +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.compose.communication.CustomListAction +import net.mullvad.mullvadvpn.compose.communication.CustomListActionResult +import net.mullvad.mullvadvpn.compose.communication.GenericError +import net.mullvad.mullvadvpn.compose.state.CustomListEntrySheetUiState +import net.mullvad.mullvadvpn.repository.CustomListsRepository +import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase + +class CustomListEntrySheetViewModel( + val customListsRepository: CustomListsRepository, + val customListActionUseCase: CustomListActionUseCase, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + private val navArgs = CustomListEntrySheetDestination.argsFrom(savedStateHandle) + + private val _uiSideEffect = Channel() + val uiSideEffect = _uiSideEffect.receiveAsFlow() + + val uiState: StateFlow = + MutableStateFlow(CustomListEntrySheetUiState(locationName = navArgs.name)) + + fun removeLocationFromList() = + viewModelScope.launch { + val result = + either { + val customList = + customListsRepository.getCustomListById(navArgs.customListId).bind() + val newLocations = (customList.locations - navArgs.location) + customListActionUseCase( + CustomListAction.UpdateLocations(customList.id, newLocations) + ) + .bind() + } + .fold({ GenericError }, { it }) + _uiSideEffect.send(CustomListEntrySheetSideEffect.LocationRemovedResult(result)) + } +} + +sealed interface CustomListEntrySheetSideEffect { + data class LocationRemovedResult(val result: CustomListActionResult) : + CustomListEntrySheetSideEffect +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListSheetViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListSheetViewModel.kt new file mode 100644 index 000000000000..1e76b2fb689f --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListSheetViewModel.kt @@ -0,0 +1,31 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import com.ramcosta.composedestinations.generated.destinations.CustomListSheetDestination +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import net.mullvad.mullvadvpn.compose.state.CustomListSheetUiState + +class CustomListSheetViewModel( + savedStateHandle: SavedStateHandle, +) : ViewModel() { + private val navArgs = CustomListSheetDestination.argsFrom(savedStateHandle) + + private val _uiSideEffect = Channel() + val uiSideEffect = _uiSideEffect.receiveAsFlow() + + val uiState: StateFlow = + MutableStateFlow( + CustomListSheetUiState( + customListId = navArgs.customListId, + customListName = navArgs.customListName + ) + ) +} + +sealed interface CustomListSheetSideEffect { + data object GenericError : CustomListSheetSideEffect +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ImportOverridesSheetViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ImportOverridesSheetViewModel.kt new file mode 100644 index 000000000000..34cb9c31040c --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ImportOverridesSheetViewModel.kt @@ -0,0 +1,24 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import net.mullvad.mullvadvpn.compose.state.ImportOverridesSheetUiState +import net.mullvad.mullvadvpn.repository.RelayOverridesRepository + +class ImportOverridesSheetViewModel( + serverIpOverridesRepository: RelayOverridesRepository, +) : ViewModel() { + + val uiState = + serverIpOverridesRepository.relayOverrides + .map { it?.isNotEmpty() == true } + .map { ImportOverridesSheetUiState(overridesActive = it) } + .stateIn( + viewModelScope, + started = SharingStarted.WhileSubscribed(), + ImportOverridesSheetUiState(overridesActive = false) + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LocationSheetViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LocationSheetViewModel.kt new file mode 100644 index 000000000000..b78638bc9350 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LocationSheetViewModel.kt @@ -0,0 +1,76 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ramcosta.composedestinations.generated.destinations.LocationSheetDestination +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +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.CustomListActionResult +import net.mullvad.mullvadvpn.compose.communication.GenericError +import net.mullvad.mullvadvpn.compose.state.CustomListEntry +import net.mullvad.mullvadvpn.compose.state.LocationUiState +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.relaylist.descendants +import net.mullvad.mullvadvpn.relaylist.findByGeoLocationId +import net.mullvad.mullvadvpn.relaylist.withDescendants +import net.mullvad.mullvadvpn.usecase.FilteredRelayListUseCase +import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase +import net.mullvad.mullvadvpn.usecase.customlists.CustomListsRelayItemUseCase + +class LocationSheetViewModel( + filteredRelayListUseCase: FilteredRelayListUseCase, + val customListActionUseCase: CustomListActionUseCase, + customListsRelayItemUseCase: CustomListsRelayItemUseCase, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + private val navArgs = LocationSheetDestination.argsFrom(savedStateHandle) + private val geoLocationId = navArgs.id + private val locationName = navArgs.locationName + + private val _uiSideEffect = Channel() + val uiSideEffect = _uiSideEffect.receiveAsFlow() + + val uiState: StateFlow = + combine( + customListsRelayItemUseCase(), + filteredRelayListUseCase(), + ) { customListsRelayItem, relayList -> + LocationUiState.Content( + relayList.findByGeoLocationId(geoLocationId)!!, + customListsRelayItem.map { + CustomListEntry( + it, + it.locations.withDescendants().none { it.id == geoLocationId } + ) + } + ) + } + .stateIn(viewModelScope, WhileSubscribed(), LocationUiState.Loading(locationName)) + + fun addLocationToList(item: RelayItem.Location, customList: RelayItem.CustomList) { + viewModelScope.launch { + val newLocations = + (customList.locations + item).filter { it !in item.descendants() }.map { it.id } + val result = + customListActionUseCase( + CustomListAction.UpdateLocations(customList.id, newLocations) + ) + .fold( + { GenericError }, + { it }, + ) + _uiSideEffect.send(LocationSideEffect.AddLocationResult(result)) + } + } +} + +sealed interface LocationSideEffect { + data class AddLocationResult(val result: CustomListActionResult) : LocationSideEffect +} 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..379aad60c1f1 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 @@ -18,7 +18,6 @@ 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.relaylist.descendants import net.mullvad.mullvadvpn.relaylist.filterOnOwnershipAndProvider import net.mullvad.mullvadvpn.relaylist.filterOnSearchTerm import net.mullvad.mullvadvpn.repository.RelayListFilterRepository @@ -131,37 +130,10 @@ class SelectLocationViewModel( viewModelScope.launch { relayListFilterRepository.updateSelectedProviders(Constraint.Any) } } - fun addLocationToList(item: RelayItem.Location, customList: RelayItem.CustomList) { - 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)) }, - ) - } - } - fun performAction(action: CustomListAction) { viewModelScope.launch { customListActionUseCase(action) } } - fun removeLocationFromList(item: RelayItem.Location, customList: RelayItem.CustomList) { - 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) - ) - } - ) - } - } - private fun List.filterOnOwnershipAndProvider( ownership: Constraint, providers: Constraint diff --git a/android/buildSrc/src/main/kotlin/Dependencies.kt b/android/buildSrc/src/main/kotlin/Dependencies.kt index b15b8ffbb099..eae6a3ced900 100644 --- a/android/buildSrc/src/main/kotlin/Dependencies.kt +++ b/android/buildSrc/src/main/kotlin/Dependencies.kt @@ -23,7 +23,8 @@ object Dependencies { "androidx.activity:activity-compose:${Versions.AndroidX.activityCompose}" const val appcompat = "androidx.appcompat:appcompat:${Versions.AndroidX.appcompat}" const val coreKtx = "androidx.core:core-ktx:${Versions.AndroidX.coreKtx}" - const val coreSplashscreen = "androidx.core:core-splashscreen:${Versions.AndroidX.coreSplashscreen}" + const val coreSplashscreen = + "androidx.core:core-splashscreen:${Versions.AndroidX.coreSplashscreen}" const val fragmentTestning = "androidx.fragment:fragment-testing:${Versions.AndroidX.fragmentTesting}" const val lifecycleRuntimeKtx = @@ -58,8 +59,11 @@ object Dependencies { "androidx.constraintlayout:constraintlayout-compose:${Versions.Compose.constrainLayout}" const val destinations = "io.github.raamcosta.compose-destinations:core:${Versions.Compose.destinations}" + const val destinationsBottomSheet = + "io.github.raamcosta.compose-destinations:bottom-sheet:${Versions.Compose.destinations}" const val destinationsKsp = "io.github.raamcosta.compose-destinations:ksp:${Versions.Compose.destinations}" + const val foundation = "androidx.compose.foundation:foundation:${Versions.Compose.foundation}" const val junit5 = "de.mannodermaus.junit5:android-test-compose:${Versions.Android.junit}"