diff options
| author | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2024-03-13 13:16:33 +0100 |
|---|---|---|
| committer | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2024-03-14 14:53:44 +0100 |
| commit | d16b190c62611a72910fc7db59842bda019c7364 (patch) | |
| tree | ff14dcdd6202f85e5035c2fdd9383ab1eff7f5cd /android/app/src | |
| parent | 8d580381d9984203fa734cb7a11a5e47723909ea (diff) | |
| download | mullvadvpn-d16b190c62611a72910fc7db59842bda019c7364.tar.xz mullvadvpn-d16b190c62611a72910fc7db59842bda019c7364.zip | |
Add create custom list dialog
Diffstat (limited to 'android/app/src')
5 files changed, 298 insertions, 2 deletions
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CustomListNameTextField.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CustomListNameTextField.kt new file mode 100644 index 0000000000..675f6f8f14 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CustomListNameTextField.kt @@ -0,0 +1,72 @@ +package net.mullvad.mullvadvpn.compose.component + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.textfield.CustomTextField +import net.mullvad.mullvadvpn.model.CustomListsError + +@Composable +fun CustomListNameTextField( + modifier: Modifier = Modifier, + name: String, + isValidName: Boolean, + error: CustomListsError?, + onValueChanged: (String) -> Unit, + onSubmit: (String) -> Unit +) { + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + CustomTextField( + value = name, + onValueChanged = onValueChanged, + onSubmit = { + if (isValidName) { + onSubmit(it) + } + }, + // This can not be set to KeyboardType.Text because it will show the + // suggestions, this will cause an infinite loop on Android TV with Gboard + keyboardType = KeyboardType.Password, + placeholderText = null, + isValidValue = error == null, + isDigitsOnlyAllowed = false, + supportingText = + error?.let { + { + Text( + text = it.errorString(), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + }, + modifier = + modifier.focusRequester(focusRequester).onFocusChanged { focusState -> + if (focusState.hasFocus) { + keyboardController?.show() + } + } + ) + + LaunchedEffect(Unit) { focusRequester.requestFocus() } +} + +@Composable +private fun CustomListsError.errorString() = + stringResource( + when (this) { + CustomListsError.CustomListExists -> R.string.custom_list_error_list_exists + CustomListsError.OtherError -> R.string.error_occurred + } + ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CreateCustomListDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CreateCustomListDialog.kt new file mode 100644 index 0000000000..98f2007bc0 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CreateCustomListDialog.kt @@ -0,0 +1,132 @@ +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.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.ResultBackNavigator +import com.ramcosta.composedestinations.spec.DestinationStyle +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.button.PrimaryButton +import net.mullvad.mullvadvpn.compose.communication.CustomListResult +import net.mullvad.mullvadvpn.compose.component.CustomListNameTextField +import net.mullvad.mullvadvpn.compose.destinations.CustomListLocationsDestination +import net.mullvad.mullvadvpn.compose.state.CreateCustomListUiState +import net.mullvad.mullvadvpn.compose.test.CREATE_CUSTOM_LIST_DIALOG_INPUT_TEST_TAG +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.model.CustomListsError +import net.mullvad.mullvadvpn.viewmodel.CreateCustomListDialogSideEffect +import net.mullvad.mullvadvpn.viewmodel.CreateCustomListDialogViewModel +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf + +@Preview +@Composable +private fun PreviewCreateCustomListDialog() { + AppTheme { CreateCustomListDialog(state = CreateCustomListUiState()) } +} + +@Preview +@Composable +private fun PreviewCreateCustomListDialogError() { + AppTheme { + CreateCustomListDialog( + state = CreateCustomListUiState(error = CustomListsError.CustomListExists) + ) + } +} + +@Composable +@Destination(style = DestinationStyle.Dialog::class) +fun CreateCustomList( + navigator: DestinationsNavigator, + backNavigator: ResultBackNavigator<CustomListResult.Created>, + locationCode: String = "" +) { + val vm: CreateCustomListDialogViewModel = + koinViewModel(parameters = { parametersOf(locationCode) }) + LaunchedEffect(key1 = Unit) { + vm.uiSideEffect.collect { sideEffect -> + when (sideEffect) { + is CreateCustomListDialogSideEffect.NavigateToCustomListLocationsScreen -> { + navigator.navigate( + CustomListLocationsDestination( + customListId = sideEffect.customListId, + newList = true + ) + ) { + launchSingleTop = true + } + } + is CreateCustomListDialogSideEffect.ReturnWithResult -> { + backNavigator.navigateBack(result = sideEffect.result) + } + } + } + } + val state by vm.uiState.collectAsStateWithLifecycle() + CreateCustomListDialog( + state = state, + createCustomList = vm::createCustomList, + onInputChanged = vm::clearError, + onDismiss = backNavigator::navigateBack + ) +} + +@Composable +fun CreateCustomListDialog( + state: CreateCustomListUiState, + createCustomList: (String) -> Unit = {}, + onInputChanged: () -> Unit = {}, + onDismiss: () -> Unit = {} +) { + + val name = remember { mutableStateOf("") } + val isValidName by remember { derivedStateOf { name.value.isNotBlank() } } + + AlertDialog( + title = { + Text( + text = stringResource(id = R.string.create_new_list), + ) + }, + text = { + CustomListNameTextField( + name = name.value, + isValidName = isValidName, + error = state.error, + onSubmit = createCustomList, + onValueChanged = { + name.value = it + onInputChanged() + }, + modifier = Modifier.testTag(CREATE_CUSTOM_LIST_DIALOG_INPUT_TEST_TAG) + ) + }, + containerColor = MaterialTheme.colorScheme.background, + titleContentColor = MaterialTheme.colorScheme.onBackground, + onDismissRequest = onDismiss, + confirmButton = { + PrimaryButton( + text = stringResource(id = R.string.create), + onClick = { createCustomList(name.value) }, + isEnabled = isValidName + ) + }, + dismissButton = { + PrimaryButton(text = stringResource(id = R.string.cancel), onClick = onDismiss) + } + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CreateCustomListUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CreateCustomListUiState.kt new file mode 100644 index 0000000000..43052702bd --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CreateCustomListUiState.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.compose.state + +import net.mullvad.mullvadvpn.model.CustomListsError + +data class CreateCustomListUiState(val error: CustomListsError? = null) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt index 4b3817b441..be5750ef5c 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt @@ -38,7 +38,8 @@ fun CustomTextField( maxCharLength: Int = Int.MAX_VALUE, isValidValue: Boolean, isDigitsOnlyAllowed: Boolean, - visualTransformation: VisualTransformation = VisualTransformation.None + visualTransformation: VisualTransformation = VisualTransformation.None, + supportingText: @Composable (() -> Unit)? = null, ) { val scope = rememberCoroutineScope() @@ -102,6 +103,7 @@ fun CustomTextField( visualTransformation = visualTransformation, colors = mullvadDarkTextFieldColors(), isError = !isValidValue, - modifier = modifier.clip(MaterialTheme.shapes.small).fillMaxWidth() + modifier = modifier.clip(MaterialTheme.shapes.small).fillMaxWidth(), + supportingText = supportingText ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CreateCustomListDialogViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CreateCustomListDialogViewModel.kt new file mode 100644 index 0000000000..9ae5bb7a64 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CreateCustomListDialogViewModel.kt @@ -0,0 +1,85 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.compose.communication.CustomListAction +import net.mullvad.mullvadvpn.compose.communication.CustomListResult +import net.mullvad.mullvadvpn.compose.state.CreateCustomListUiState +import net.mullvad.mullvadvpn.model.CustomListsError +import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase +import net.mullvad.mullvadvpn.usecase.customlists.CustomListsException + +class CreateCustomListDialogViewModel( + private val locationCode: String, + private val customListActionUseCase: CustomListActionUseCase, +) : ViewModel() { + + private val _uiSideEffect = + Channel<CreateCustomListDialogSideEffect>(1, BufferOverflow.DROP_OLDEST) + val uiSideEffect = _uiSideEffect.receiveAsFlow() + + private val _error = MutableStateFlow<CustomListsError?>(null) + + val uiState = + _error + .map { CreateCustomListUiState(it) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), CreateCustomListUiState()) + + fun createCustomList(name: String) { + viewModelScope.launch { + customListActionUseCase + .performAction( + CustomListAction.Create( + name, + if (locationCode.isNotEmpty()) { + listOf(locationCode) + } else { + emptyList() + } + ) + ) + .fold( + onSuccess = { result -> + if (result.locationName != null) { + _uiSideEffect.send( + CreateCustomListDialogSideEffect.ReturnWithResult(result) + ) + } else { + _uiSideEffect.send( + CreateCustomListDialogSideEffect + .NavigateToCustomListLocationsScreen(result.id) + ) + } + }, + onFailure = { error -> + if (error is CustomListsException) { + _error.emit(error.error) + } else { + _error.emit(CustomListsError.OtherError) + } + } + ) + } + } + + fun clearError() { + viewModelScope.launch { _error.emit(null) } + } +} + +sealed interface CreateCustomListDialogSideEffect { + + data class NavigateToCustomListLocationsScreen(val customListId: String) : + CreateCustomListDialogSideEffect + + data class ReturnWithResult(val result: CustomListResult.Created) : + CreateCustomListDialogSideEffect +} |
