summaryrefslogtreecommitdiffhomepage
path: root/android
diff options
context:
space:
mode:
authorJonatan Rhodin <jonatan.rhodin@mullvad.net>2024-03-13 13:16:33 +0100
committerJonatan Rhodin <jonatan.rhodin@mullvad.net>2024-03-14 14:53:44 +0100
commitd16b190c62611a72910fc7db59842bda019c7364 (patch)
treeff14dcdd6202f85e5035c2fdd9383ab1eff7f5cd /android
parent8d580381d9984203fa734cb7a11a5e47723909ea (diff)
downloadmullvadvpn-d16b190c62611a72910fc7db59842bda019c7364.tar.xz
mullvadvpn-d16b190c62611a72910fc7db59842bda019c7364.zip
Add create custom list dialog
Diffstat (limited to 'android')
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CustomListNameTextField.kt72
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CreateCustomListDialog.kt132
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CreateCustomListUiState.kt5
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt6
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CreateCustomListDialogViewModel.kt85
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
+}