diff options
Diffstat (limited to 'android/app/src')
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 +} |
