summaryrefslogtreecommitdiffhomepage
path: root/android/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'android/app/src')
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt19
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/constant/CommonContentKey.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DiscardChangesDialog.kt38
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreen.kt219
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListLocationsUiState.kt32
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt21
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItem.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemExtensions.kt14
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModel.kt216
9 files changed, 562 insertions, 3 deletions
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt
index 52e6e03efb..a9db7e0e62 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt
@@ -189,6 +189,25 @@ fun MullvadTopBar(
)
}
+@Composable
+fun MullvadSmallTopBar(
+ title: String,
+ navigationIcon: @Composable () -> Unit = {},
+ actions: @Composable RowScope.() -> Unit = {}
+) {
+ TopAppBar(
+ title = { Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
+ navigationIcon = navigationIcon,
+ colors =
+ TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.background,
+ scrolledContainerColor = MaterialTheme.colorScheme.background,
+ actionIconContentColor = MaterialTheme.colorScheme.onPrimary.copy(AlphaTopBar),
+ ),
+ actions = actions
+ )
+}
+
@Preview
@Composable
private fun PreviewMediumTopBar() {
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/constant/CommonContentKey.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/constant/CommonContentKey.kt
index 41dbcadaa1..899a5b8fee 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/constant/CommonContentKey.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/constant/CommonContentKey.kt
@@ -2,6 +2,6 @@ package net.mullvad.mullvadvpn.compose.constant
object CommonContentKey {
const val DESCRIPTION = "description"
- const val SPACER = "spacer"
const val PROGRESS = "progress"
+ const val EMPTY = "empty"
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DiscardChangesDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DiscardChangesDialog.kt
new file mode 100644
index 0000000000..06f2df0003
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DiscardChangesDialog.kt
@@ -0,0 +1,38 @@
+package net.mullvad.mullvadvpn.compose.dialog
+
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.res.stringResource
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.result.ResultBackNavigator
+import com.ramcosta.composedestinations.spec.DestinationStyle
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.button.PrimaryButton
+
+@Destination(style = DestinationStyle.Dialog::class)
+@Composable
+fun DiscardChangesDialog(resultBackNavigator: ResultBackNavigator<Boolean>) {
+ AlertDialog(
+ onDismissRequest = resultBackNavigator::navigateBack,
+ title = { Text(text = stringResource(id = R.string.discard_changes)) },
+ dismissButton = {
+ PrimaryButton(
+ modifier = Modifier.focusRequester(FocusRequester()),
+ onClick = resultBackNavigator::navigateBack,
+ text = stringResource(id = R.string.cancel)
+ )
+ },
+ confirmButton = {
+ PrimaryButton(
+ onClick = { resultBackNavigator.navigateBack(result = true) },
+ text = stringResource(id = R.string.discard)
+ )
+ },
+ containerColor = MaterialTheme.colorScheme.background
+ )
+}
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
new file mode 100644
index 0000000000..3bd924a189
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreen.kt
@@ -0,0 +1,219 @@
+package net.mullvad.mullvadvpn.compose.screen
+
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListScope
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.ramcosta.composedestinations.annotation.Destination
+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 net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.cell.CheckableRelayLocationCell
+import net.mullvad.mullvadvpn.compose.communication.CustomListResult
+import net.mullvad.mullvadvpn.compose.component.LocationsEmptyText
+import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge
+import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton
+import net.mullvad.mullvadvpn.compose.component.ScaffoldWithSmallTopBar
+import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar
+import net.mullvad.mullvadvpn.compose.constant.CommonContentKey
+import net.mullvad.mullvadvpn.compose.constant.ContentType
+import net.mullvad.mullvadvpn.compose.destinations.DiscardChangesDialogDestination
+import net.mullvad.mullvadvpn.compose.state.CustomListLocationsUiState
+import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR
+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.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar
+import net.mullvad.mullvadvpn.relaylist.RelayItem
+import net.mullvad.mullvadvpn.viewmodel.CustomListLocationsSideEffect
+import net.mullvad.mullvadvpn.viewmodel.CustomListLocationsViewModel
+import org.koin.androidx.compose.koinViewModel
+import org.koin.core.parameter.parametersOf
+
+@Composable
+@Preview
+private fun PreviewCustomListLocationScreen() {
+ AppTheme { CustomListLocationsScreen(state = CustomListLocationsUiState.Content.Data()) }
+}
+
+@Composable
+@Destination(style = SlideInFromRightTransition::class)
+fun CustomListLocations(
+ navigator: DestinationsNavigator,
+ backNavigator: ResultBackNavigator<CustomListResult.LocationsChanged>,
+ discardChangesResultRecipient: ResultRecipient<DiscardChangesDialogDestination, Boolean>,
+ customListId: String,
+ newList: Boolean,
+) {
+ val customListsViewModel =
+ koinViewModel<CustomListLocationsViewModel>(
+ parameters = { parametersOf(customListId, newList) }
+ )
+
+ discardChangesResultRecipient.onNavResult {
+ when (it) {
+ NavResult.Canceled -> {}
+ is NavResult.Value -> {
+ if (it.value) {
+ backNavigator.navigateBack()
+ }
+ }
+ }
+ }
+
+ LaunchedEffectCollect(customListsViewModel.uiSideEffect) { sideEffect ->
+ when (sideEffect) {
+ is CustomListLocationsSideEffect.ReturnWithResult ->
+ backNavigator.navigateBack(result = sideEffect.result)
+ CustomListLocationsSideEffect.CloseScreen -> backNavigator.navigateBack()
+ }
+ }
+
+ val state by customListsViewModel.uiState.collectAsStateWithLifecycle()
+ CustomListLocationsScreen(
+ state = state,
+ onSearchTermInput = customListsViewModel::onSearchTermInput,
+ onSaveClick = customListsViewModel::save,
+ onRelaySelectionClick = customListsViewModel::onRelaySelectionClick,
+ onBackClick = {
+ if (state.hasUnsavedChanges) {
+ navigator.navigate(DiscardChangesDialogDestination) { launchSingleTop = true }
+ } else {
+ backNavigator.navigateBack()
+ }
+ }
+ )
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun CustomListLocationsScreen(
+ state: CustomListLocationsUiState,
+ onSearchTermInput: (String) -> Unit = {},
+ onSaveClick: () -> Unit = {},
+ onRelaySelectionClick: (RelayItem, selected: Boolean) -> Unit = { _, _ -> },
+ onBackClick: () -> Unit = {}
+) {
+ ScaffoldWithSmallTopBar(
+ appBarTitle =
+ stringResource(
+ if (state.newList) {
+ R.string.add_locations
+ } else {
+ R.string.edit_locations
+ }
+ ),
+ navigationIcon = { NavigateBackIconButton(onNavigateBack = onBackClick) },
+ actions = { Actions(isSaveEnabled = state.saveEnabled, onSaveClick = onSaveClick) }
+ ) { modifier ->
+ Column(modifier = modifier) {
+ SearchTextField(
+ modifier =
+ Modifier.fillMaxWidth()
+ .height(Dimens.searchFieldHeight)
+ .padding(horizontal = Dimens.searchFieldHorizontalPadding),
+ backgroundColor = MaterialTheme.colorScheme.tertiaryContainer,
+ textColor = MaterialTheme.colorScheme.onTertiaryContainer,
+ ) { searchString ->
+ onSearchTermInput.invoke(searchString)
+ }
+ Spacer(modifier = Modifier.height(Dimens.verticalSpace))
+ val lazyListState = rememberLazyListState()
+ LazyColumn(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier =
+ Modifier.drawVerticalScrollbar(
+ state = lazyListState,
+ color =
+ MaterialTheme.colorScheme.onBackground.copy(alpha = AlphaScrollbar)
+ )
+ .fillMaxWidth(),
+ state = lazyListState,
+ ) {
+ when (state) {
+ is CustomListLocationsUiState.Loading -> {
+ loading()
+ }
+ is CustomListLocationsUiState.Content.Empty -> {
+ empty(searchTerm = state.searchTerm)
+ }
+ is CustomListLocationsUiState.Content.Data -> {
+ content(uiState = state, onRelaySelectedChanged = onRelaySelectionClick)
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun Actions(isSaveEnabled: Boolean, onSaveClick: () -> Unit) {
+ TextButton(
+ onClick = onSaveClick,
+ enabled = isSaveEnabled,
+ colors =
+ ButtonDefaults.textButtonColors()
+ .copy(contentColor = MaterialTheme.colorScheme.onPrimary),
+ modifier = Modifier.testTag(SAVE_BUTTON_TEST_TAG)
+ ) {
+ Text(
+ text = stringResource(R.string.save),
+ )
+ }
+}
+
+private fun LazyListScope.loading() {
+ item(key = CommonContentKey.PROGRESS, contentType = ContentType.PROGRESS) {
+ MullvadCircularProgressIndicatorLarge(
+ modifier = Modifier.testTag(CIRCULAR_PROGRESS_INDICATOR)
+ )
+ }
+}
+
+private fun LazyListScope.empty(searchTerm: String) {
+ item(key = CommonContentKey.EMPTY, contentType = ContentType.EMPTY_TEXT) {
+ LocationsEmptyText(searchTerm = searchTerm)
+ }
+}
+
+private fun LazyListScope.content(
+ uiState: CustomListLocationsUiState.Content.Data,
+ onRelaySelectedChanged: (RelayItem, selected: Boolean) -> Unit,
+) {
+ items(
+ count = uiState.availableLocations.size,
+ key = { index -> uiState.availableLocations[index].hashCode() },
+ contentType = { ContentType.ITEM },
+ ) { index ->
+ val country = uiState.availableLocations[index]
+ CheckableRelayLocationCell(
+ relay = country,
+ modifier = Modifier.animateContentSize(),
+ onRelayCheckedChange = onRelaySelectedChanged,
+ selectedRelays = uiState.selectedLocations,
+ )
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListLocationsUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListLocationsUiState.kt
new file mode 100644
index 0000000000..7c9c5aedec
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListLocationsUiState.kt
@@ -0,0 +1,32 @@
+package net.mullvad.mullvadvpn.compose.state
+
+import net.mullvad.mullvadvpn.relaylist.RelayItem
+
+sealed interface CustomListLocationsUiState {
+ val newList: Boolean
+ val saveEnabled: Boolean
+ val hasUnsavedChanges: Boolean
+
+ data class Loading(override val newList: Boolean = false) : CustomListLocationsUiState {
+ override val saveEnabled: Boolean = false
+ override val hasUnsavedChanges: Boolean = false
+ }
+
+ sealed interface Content : CustomListLocationsUiState {
+ val searchTerm: String
+
+ data class Empty(override val newList: Boolean, override val searchTerm: String) : Content {
+ override val saveEnabled: Boolean = false
+ override val hasUnsavedChanges: Boolean = false
+ }
+
+ data class Data(
+ override val newList: Boolean = false,
+ val availableLocations: List<RelayItem.Country> = emptyList(),
+ val selectedLocations: Set<RelayItem> = emptySet(),
+ override val searchTerm: String = "",
+ override val saveEnabled: Boolean = false,
+ override val hasUnsavedChanges: Boolean = false
+ ) : Content
+ }
+}
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 afd3f72211..62fd84854d 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
@@ -37,14 +37,21 @@ import net.mullvad.mullvadvpn.usecase.RelayListUseCase
import net.mullvad.mullvadvpn.usecase.SystemVpnSettingsUseCase
import net.mullvad.mullvadvpn.usecase.TunnelStateNotificationUseCase
import net.mullvad.mullvadvpn.usecase.VersionNotificationUseCase
+import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase
import net.mullvad.mullvadvpn.util.ChangelogDataProvider
import net.mullvad.mullvadvpn.util.IChangelogDataProvider
import net.mullvad.mullvadvpn.viewmodel.AccountViewModel
import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel
import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel
+import net.mullvad.mullvadvpn.viewmodel.CreateCustomListDialogViewModel
+import net.mullvad.mullvadvpn.viewmodel.CustomListLocationsViewModel
+import net.mullvad.mullvadvpn.viewmodel.CustomListsViewModel
+import net.mullvad.mullvadvpn.viewmodel.DeleteCustomListConfirmationViewModel
import net.mullvad.mullvadvpn.viewmodel.DeviceListViewModel
import net.mullvad.mullvadvpn.viewmodel.DeviceRevokedViewModel
import net.mullvad.mullvadvpn.viewmodel.DnsDialogViewModel
+import net.mullvad.mullvadvpn.viewmodel.EditCustomListNameDialogViewModel
+import net.mullvad.mullvadvpn.viewmodel.EditCustomListViewModel
import net.mullvad.mullvadvpn.viewmodel.FilterViewModel
import net.mullvad.mullvadvpn.viewmodel.LoginViewModel
import net.mullvad.mullvadvpn.viewmodel.MtuDialogViewModel
@@ -100,7 +107,7 @@ val uiModule = module {
}
single { SettingsRepository(get()) }
single { MullvadProblemReport(get()) }
- single { CustomListsRepository(get()) }
+ single { CustomListsRepository(get(), get(), get()) }
single { AccountExpiryNotificationUseCase(get()) }
single { TunnelStateNotificationUseCase(get()) }
@@ -111,6 +118,7 @@ val uiModule = module {
single { OutOfTimeUseCase(get(), get(), MainScope()) }
single { ConnectivityUseCase(get()) }
single { SystemVpnSettingsUseCase(androidContext()) }
+ single { CustomListActionUseCase(get(), get()) }
single { InAppNotificationController(get(), get(), get(), get(), MainScope()) }
@@ -150,6 +158,7 @@ val uiModule = module {
viewModel { LoginViewModel(get(), get(), get(), get()) }
viewModel { PrivacyDisclaimerViewModel(get(), IS_PLAY_BUILD) }
viewModel { SelectLocationViewModel(get(), get(), get()) }
+ viewModel { SelectLocationViewModel(get(), get(), get(), get()) }
viewModel { SettingsViewModel(get(), get(), IS_PLAY_BUILD) }
viewModel { SplashViewModel(get(), get(), get()) }
viewModel { VoucherDialogViewModel(get(), get()) }
@@ -160,6 +169,16 @@ val uiModule = module {
viewModel { OutOfTimeViewModel(get(), get(), get(), get(), get(), isPlayBuild = IS_PLAY_BUILD) }
viewModel { PaymentViewModel(get()) }
viewModel { FilterViewModel(get()) }
+ viewModel { parameters -> CreateCustomListDialogViewModel(parameters.get(), get()) }
+ viewModel { parameters ->
+ CustomListLocationsViewModel(parameters.get(), parameters.get(), get(), get())
+ }
+ viewModel { parameters -> EditCustomListViewModel(parameters.get(), get()) }
+ viewModel { parameters ->
+ EditCustomListNameDialogViewModel(parameters.get(), parameters.get(), get())
+ }
+ viewModel { CustomListsViewModel(get(), get()) }
+ viewModel { parameters -> DeleteCustomListConfirmationViewModel(parameters.get(), 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/relaylist/RelayItem.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItem.kt
index 03ba7a02f3..54c4a9bef4 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItem.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItem.kt
@@ -16,7 +16,6 @@ sealed interface RelayItem {
data class CustomList(
override val name: String,
- override val code: String,
override val expanded: Boolean,
val id: String,
val locations: List<RelayItem>,
@@ -26,6 +25,8 @@ sealed interface RelayItem {
override val hasChildren
get() = locations.isNotEmpty()
+
+ override val code = id
}
data class Country(
@@ -35,6 +36,7 @@ sealed interface RelayItem {
val cities: List<City>
) : RelayItem {
val location = GeographicLocationConstraint.Country(code)
+ val relays = cities.flatMap { city -> city.relays }
override val active
get() = cities.any { city -> city.active }
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemExtensions.kt
index 6808d707b2..a71005a78a 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemExtensions.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemExtensions.kt
@@ -10,3 +10,17 @@ fun RelayItem.toLocationConstraint(): LocationConstraint {
is RelayItem.CustomList -> LocationConstraint.CustomList(id)
}
}
+
+fun RelayItem.children(): List<RelayItem> {
+ return when (this) {
+ is RelayItem.Country -> cities
+ is RelayItem.City -> relays
+ is RelayItem.CustomList -> locations
+ else -> emptyList()
+ }
+}
+
+fun RelayItem.descendants(): List<RelayItem> {
+ val children = children()
+ return children + children.flatMap { it.descendants() }
+}
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
new file mode 100644
index 0000000000..5fa99306a3
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModel.kt
@@ -0,0 +1,216 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.mapNotNull
+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.CustomListResult
+import net.mullvad.mullvadvpn.compose.state.CustomListLocationsUiState
+import net.mullvad.mullvadvpn.relaylist.RelayItem
+import net.mullvad.mullvadvpn.relaylist.descendants
+import net.mullvad.mullvadvpn.relaylist.filterOnSearchTerm
+import net.mullvad.mullvadvpn.relaylist.getById
+import net.mullvad.mullvadvpn.usecase.RelayListUseCase
+import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase
+import net.mullvad.mullvadvpn.util.firstOrNullWithTimeout
+
+class CustomListLocationsViewModel(
+ private val customListId: String,
+ private val newList: Boolean,
+ private val relayListUseCase: RelayListUseCase,
+ private val customListActionUseCase: CustomListActionUseCase
+) : ViewModel() {
+ private var customListName: String = ""
+
+ private val _uiSideEffect =
+ MutableSharedFlow<CustomListLocationsSideEffect>(replay = 1, extraBufferCapacity = 1)
+ val uiSideEffect: SharedFlow<CustomListLocationsSideEffect> = _uiSideEffect
+
+ private val _initialLocations = MutableStateFlow<Set<RelayItem>>(emptySet())
+ private val _selectedLocations = MutableStateFlow<Set<RelayItem>?>(null)
+ private val _searchTerm = MutableStateFlow(EMPTY_SEARCH_TERM)
+
+ val uiState =
+ combine(relayListUseCase.relayList(), _searchTerm, _selectedLocations) {
+ relayCountries,
+ searchTerm,
+ selectedLocations ->
+ val filteredRelayCountries = relayCountries.filterOnSearchTerm(searchTerm, null)
+
+ when {
+ selectedLocations == null ->
+ CustomListLocationsUiState.Loading(newList = newList)
+ filteredRelayCountries.isEmpty() ->
+ CustomListLocationsUiState.Content.Empty(
+ newList = newList,
+ searchTerm = searchTerm
+ )
+ else ->
+ CustomListLocationsUiState.Content.Data(
+ newList = newList,
+ searchTerm = searchTerm,
+ availableLocations = filteredRelayCountries,
+ selectedLocations = selectedLocations,
+ saveEnabled =
+ selectedLocations.isNotEmpty() &&
+ selectedLocations != _initialLocations.value,
+ hasUnsavedChanges = selectedLocations != _initialLocations.value
+ )
+ }
+ }
+ .stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(),
+ CustomListLocationsUiState.Loading(newList = newList)
+ )
+
+ init {
+ viewModelScope.launch { fetchInitialSelectedLocations() }
+ }
+
+ fun save() {
+ viewModelScope.launch {
+ _selectedLocations.value?.let { selectedLocations ->
+ val result =
+ customListActionUseCase.performAction(
+ CustomListAction.UpdateLocations(
+ customListId,
+ selectedLocations.calculateLocationsToSave().map { it.code }
+ )
+ )
+ _uiSideEffect.tryEmit(
+ // This is so that we don't show a snackbar after returning to the select
+ // location screen
+ if (newList) {
+ CustomListLocationsSideEffect.CloseScreen
+ } else {
+ CustomListLocationsSideEffect.ReturnWithResult(result.getOrThrow())
+ }
+ )
+ }
+ }
+ }
+
+ fun onRelaySelectionClick(relayItem: RelayItem, selected: Boolean) {
+ if (selected) {
+ selectLocation(relayItem)
+ } else {
+ deselectLocation(relayItem)
+ }
+ }
+
+ fun onSearchTermInput(searchTerm: String) {
+ viewModelScope.launch { _searchTerm.emit(searchTerm) }
+ }
+
+ private suspend fun awaitCustomListById(id: String): RelayItem.CustomList? =
+ relayListUseCase
+ .customLists()
+ .mapNotNull { customList -> customList.getById(id) }
+ .firstOrNullWithTimeout(GET_CUSTOM_LIST_TIMEOUT_MS)
+
+ private fun selectLocation(relayItem: RelayItem) {
+ viewModelScope.launch {
+ _selectedLocations.update {
+ it?.plus(relayItem)?.plus(relayItem.descendants()) ?: setOf(relayItem)
+ }
+ }
+ }
+
+ private fun deselectLocation(relayItem: RelayItem) {
+ viewModelScope.launch {
+ _selectedLocations.update {
+ val newSelectedLocations = it?.toMutableSet() ?: mutableSetOf()
+ newSelectedLocations.remove(relayItem)
+ newSelectedLocations.removeAll(relayItem.descendants().toSet())
+ // If a parent is selected, deselect it, since we only want to select a parent if
+ // all children are selected
+ newSelectedLocations.deselectParents(relayItem)
+ }
+ }
+ }
+
+ private fun availableLocations(): List<RelayItem.Country> =
+ (uiState.value as? CustomListLocationsUiState.Content.Data)?.availableLocations
+ ?: emptyList()
+
+ private fun Set<RelayItem>.deselectParents(relayItem: RelayItem): Set<RelayItem> {
+ val availableLocations = availableLocations()
+ val updateSelectionList = this.toMutableSet()
+ when (relayItem) {
+ is RelayItem.City -> {
+ availableLocations
+ .find { it.code == relayItem.location.countryCode }
+ ?.let { updateSelectionList.remove(it) }
+ }
+ is RelayItem.Relay -> {
+ availableLocations
+ .flatMap { country -> country.cities }
+ .find { it.code == relayItem.location.cityCode }
+ ?.let { updateSelectionList.remove(it) }
+ availableLocations
+ .find { it.code == relayItem.location.countryCode }
+ ?.let { updateSelectionList.remove(it) }
+ }
+ is RelayItem.Country,
+ is RelayItem.CustomList -> {
+ /* Do nothing */
+ }
+ }
+
+ return updateSelectionList
+ }
+
+ private fun Set<RelayItem>.calculateLocationsToSave(): List<RelayItem> {
+ // We don't want to save children for a selected parent
+ val saveSelectionList = this.toMutableList()
+ this.forEach { relayItem ->
+ when (relayItem) {
+ is RelayItem.Country -> {
+ saveSelectionList.removeAll(relayItem.cities)
+ saveSelectionList.removeAll(relayItem.relays)
+ }
+ is RelayItem.City -> {
+ saveSelectionList.removeAll(relayItem.relays)
+ }
+ is RelayItem.Relay,
+ is RelayItem.CustomList -> {
+ /* Do nothing */
+ }
+ }
+ }
+ return saveSelectionList
+ }
+
+ private fun List<RelayItem>.selectChildren(): Set<RelayItem> =
+ (this + flatMap { it.descendants() }).toSet()
+
+ private suspend fun fetchInitialSelectedLocations() {
+ _selectedLocations.value =
+ awaitCustomListById(customListId)
+ ?.apply { customListName = name }
+ ?.locations
+ ?.selectChildren()
+ .apply { _initialLocations.value = this ?: emptySet() }
+ }
+
+ companion object {
+ private const val EMPTY_SEARCH_TERM = ""
+ private const val GET_CUSTOM_LIST_TIMEOUT_MS = 5000L
+ }
+}
+
+sealed interface CustomListLocationsSideEffect {
+ data object CloseScreen : CustomListLocationsSideEffect
+
+ data class ReturnWithResult(val result: CustomListResult.LocationsChanged) :
+ CustomListLocationsSideEffect
+}