diff --git a/Core2/src/main/java/com/infomaniak/core2/EmailUtils.kt b/Core2/src/main/java/com/infomaniak/core2/EmailUtils.kt index b4576dabe..d99fffa77 100644 --- a/Core2/src/main/java/com/infomaniak/core2/EmailUtils.kt +++ b/Core2/src/main/java/com/infomaniak/core2/EmailUtils.kt @@ -19,4 +19,4 @@ package com.infomaniak.core2 import android.util.Patterns -fun String.isEmail(): Boolean = Patterns.EMAIL_ADDRESS.matcher(this).matches() +fun String.isValidEmail(): Boolean = Patterns.EMAIL_ADDRESS.matcher(trim()).matches() diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/components/EmailAddressChip.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/components/EmailAddressChip.kt deleted file mode 100644 index 18d757b9b..000000000 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/components/EmailAddressChip.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Infomaniak SwissTransfer - Android - * Copyright (C) 2024 Infomaniak Network SA - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package com.infomaniak.swisstransfer.ui.components - -import android.content.res.Configuration -import androidx.compose.material3.SuggestionChip -import androidx.compose.material3.SuggestionChipDefaults -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import com.infomaniak.swisstransfer.ui.theme.CustomShapes -import com.infomaniak.swisstransfer.ui.theme.SwissTransferTheme - -@Composable -fun EmailAddressChip( - text: String, - modifier: Modifier = Modifier, -) { - SuggestionChip( - onClick = {}, - label = { - Text( - text = text, - style = SwissTransferTheme.typography.bodyRegular, - maxLines = 1, - overflow = TextOverflow.MiddleEllipsis, - ) - }, - modifier = modifier, - enabled = false, - shape = CustomShapes.ROUNDED, - colors = SuggestionChipDefaults.suggestionChipColors( - disabledContainerColor = SwissTransferTheme.colors.emailAddressChipColor, - disabledLabelColor = SwissTransferTheme.colors.onEmailAddressChipColor - ), - border = null, - ) -} - -@Preview(name = "Light mode") -@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) -@Composable -private fun EmailAddressChipPreview() { - SwissTransferTheme { - Surface { - EmailAddressChip(text = "test.test@ik.me") - } - } -} diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/components/EmailsFlowRow.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/components/EmailsFlowRow.kt index f9da94764..749b62820 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/components/EmailsFlowRow.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/components/EmailsFlowRow.kt @@ -42,7 +42,7 @@ fun EmailsFlowRow( horizontalArrangement = horizontalArrangement, ) { emails.forEach { - EmailAddressChip( + SwissTransferSuggestionChip( text = it, modifier = Modifier.padding(horizontal = Margin.Micro), ) diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/components/SwissTransferChips.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/components/SwissTransferChips.kt new file mode 100644 index 000000000..969a916eb --- /dev/null +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/components/SwissTransferChips.kt @@ -0,0 +1,124 @@ +/* + * Infomaniak SwissTransfer - Android + * Copyright (C) 2024 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.infomaniak.swisstransfer.ui.components + +import android.content.res.Configuration +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import com.infomaniak.swisstransfer.R +import com.infomaniak.swisstransfer.ui.images.AppImages +import com.infomaniak.swisstransfer.ui.images.icons.CrossThick +import com.infomaniak.swisstransfer.ui.theme.CustomShapes +import com.infomaniak.swisstransfer.ui.theme.Dimens +import com.infomaniak.swisstransfer.ui.theme.Margin +import com.infomaniak.swisstransfer.ui.theme.SwissTransferTheme + +@Composable +fun SwissTransferSuggestionChip(text: String, modifier: Modifier = Modifier) { + SuggestionChip( + modifier = modifier, + onClick = {}, + label = { ChipLabel(text) }, + enabled = false, + shape = CustomShapes.ROUNDED, + colors = SuggestionChipDefaults.suggestionChipColors( + disabledContainerColor = SwissTransferTheme.colors.emailAddressChipColor, + disabledLabelColor = SwissTransferTheme.colors.onEmailAddressChipColor, + ), + border = null, + ) +} + +@Composable +fun SwissTransferInputChip( + modifier: Modifier = Modifier, + text: String, + isSelected: () -> Boolean, + onClick: () -> Unit, + onDismiss: () -> Unit, +) { + InputChip( + modifier = modifier.widthIn(min = Dimens.InputChipMinWidth), + selected = isSelected(), + onClick = onClick, + label = { ChipLabel(text) }, + shape = CustomShapes.ROUNDED, + colors = InputChipDefaults.inputChipColors( + containerColor = SwissTransferTheme.colors.emailAddressChipColor, + labelColor = SwissTransferTheme.colors.onEmailAddressChipColor, + selectedLabelColor = SwissTransferTheme.colors.emailAddressChipColor, + selectedContainerColor = SwissTransferTheme.colors.onEmailAddressChipColor, + selectedTrailingIconColor = SwissTransferTheme.colors.emailAddressChipColor, + trailingIconColor = SwissTransferTheme.colors.onEmailAddressChipColor, + ), + border = null, + trailingIcon = { + IconButton(modifier = Modifier.size(Dimens.IconSize), onClick = onDismiss) { + Icon( + modifier = Modifier.size(Dimens.MicroIconSize), + imageVector = AppImages.AppIcons.CrossThick, + contentDescription = stringResource(R.string.contentDescriptionButtonRemoveRecipient, text), + ) + } + }, + ) +} + +@Composable +private fun ChipLabel(text: String) { + Text( + text = text, + style = SwissTransferTheme.typography.bodyRegular, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + ) +} + +@Preview(name = "Light mode") +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) +@Composable +private fun EmailAddressChipPreview() { + + fun getLoremText(words: Int) = LoremIpsum(words).values.joinToString(separator = " ") + + SwissTransferTheme { + Surface { + Column(Modifier.padding(Margin.Small)) { + SwissTransferSuggestionChip(text = LoremIpsum(2).values.joinToString(separator = " ")) + + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(Margin.Mini), + ) { + SwissTransferInputChip(text = getLoremText(2), isSelected = { true }, onClick = {}, onDismiss = { }) + SwissTransferInputChip(text = getLoremText(1), isSelected = { false }, onClick = {}, onDismiss = { }) + SwissTransferInputChip(text = getLoremText(4), isSelected = { false }, onClick = {}, onDismiss = { }) + SwissTransferInputChip(text = getLoremText(1), isSelected = { false }, onClick = {}, onDismiss = { }) + } + } + } + } +} diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/components/SwissTransferTextField.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/components/SwissTransferTextField.kt index 694778247..d9b903932 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/components/SwissTransferTextField.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/components/SwissTransferTextField.kt @@ -76,13 +76,6 @@ fun SwissTransferTextField( var text by rememberSaveable { mutableStateOf(initialValue) } val displayLabel = if (isRequired) label else "$label ${stringResource(R.string.textFieldOptional)}" - val textFieldColors = OutlinedTextFieldDefaults.colors( - unfocusedLabelColor = SwissTransferTheme.colors.tertiaryTextColor, - unfocusedSupportingTextColor = SwissTransferTheme.colors.tertiaryTextColor, - disabledBorderColor = SwissTransferTheme.materialColors.outline, - unfocusedTrailingIconColor = SwissTransferTheme.colors.iconColor, - disabledTrailingIconColor = SwissTransferTheme.colors.iconColor, - ) OutlinedTextField( modifier = modifier, @@ -92,7 +85,7 @@ fun SwissTransferTextField( label = displayLabel?.let { { Text(it) } }, minLines = minLineNumber, maxLines = maxLineNumber, - colors = textFieldColors, + colors = SwissTransferTextFieldDefaults.colors(), textStyle = TextStyle(color = SwissTransferTheme.colors.primaryTextColor), onValueChange = { text = it @@ -129,6 +122,18 @@ private fun getShowPasswordButton(shouldShowPassword: Boolean, onClick: () -> Un } } +object SwissTransferTextFieldDefaults { + + @Composable + fun colors() = OutlinedTextFieldDefaults.colors( + unfocusedLabelColor = SwissTransferTheme.colors.tertiaryTextColor, + unfocusedSupportingTextColor = SwissTransferTheme.colors.tertiaryTextColor, + disabledBorderColor = SwissTransferTheme.materialColors.outline, + unfocusedTrailingIconColor = SwissTransferTheme.colors.iconColor, + disabledTrailingIconColor = SwissTransferTheme.colors.iconColor, + ) +} + @Composable @Preview private fun Preview() { diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/ImportFilesViewModel.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/ImportFilesViewModel.kt index 3f731ea80..14e208c38 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/ImportFilesViewModel.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/ImportFilesViewModel.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.infomaniak.core2.isValidEmail import com.infomaniak.multiplatform_swisstransfer.common.interfaces.upload.RemoteUploadFile import com.infomaniak.multiplatform_swisstransfer.common.interfaces.upload.UploadFileSession import com.infomaniak.multiplatform_swisstransfer.common.utils.mapToList @@ -40,6 +41,7 @@ import com.infomaniak.swisstransfer.ui.screen.main.settings.EmailLanguageOption. import com.infomaniak.swisstransfer.ui.screen.main.settings.ValidityPeriodOption import com.infomaniak.swisstransfer.ui.screen.main.settings.ValidityPeriodOption.Companion.toTransferOption import com.infomaniak.swisstransfer.ui.screen.main.settings.components.SettingOption +import com.infomaniak.swisstransfer.ui.screen.newtransfer.importfiles.EmailTextFieldCallbacks import com.infomaniak.swisstransfer.ui.screen.newtransfer.importfiles.PasswordTransferOption import com.infomaniak.swisstransfer.ui.screen.newtransfer.importfiles.TransferOptionState import com.infomaniak.swisstransfer.ui.screen.newtransfer.importfiles.TransferOptionsCallbacks @@ -81,13 +83,19 @@ class ImportFilesViewModel @Inject constructor( val currentSessionFilesCount = importationFilesManager.currentSessionFilesCount //region Transfer Author Email - private var _transferAuthorEmail by mutableStateOf("") - val transferAuthorEmail = GetSetCallbacks(get = { _transferAuthorEmail }, set = { _transferAuthorEmail = it }) + private var transferAuthorEmail by mutableStateOf("") + private val isAuthorEmailInvalid by derivedStateOf { !transferAuthorEmail.isValidEmail() } + //endregion + + //region Recipient Email + private var recipientEmail by mutableStateOf("") + private val isRecipientEmailInvalid by derivedStateOf { !recipientEmail.isValidEmail() } + private var validatedRecipientsEmails by mutableStateOf>(emptySet()) //endregion //region Transfer Message - private var _transferMessage by mutableStateOf("") - val transferMessage = GetSetCallbacks(get = { _transferMessage }, set = { _transferMessage = it }) + private var transferMessage by mutableStateOf("") + val transferMessageCallbacks = GetSetCallbacks(get = { transferMessage }, set = { transferMessage = it }) //endregion //region Password @@ -96,7 +104,7 @@ class ImportFilesViewModel @Inject constructor( //endregion private var isFirstViewModelCreation: Boolean - get() = savedStateHandle.get(IS_VIEW_MODEL_RESTORED_KEY) ?: true + get() = savedStateHandle[IS_VIEW_MODEL_RESTORED_KEY] ?: true set(value) { savedStateHandle[IS_VIEW_MODEL_RESTORED_KEY] = value } @@ -146,13 +154,13 @@ class ImportFilesViewModel @Inject constructor( private fun generateNewUploadSession(): NewUploadSession { return NewUploadSession( duration = selectedValidityPeriodOption.value.apiValue, - authorEmail = if (selectedTransferType.value == TransferTypeUi.MAIL) _transferAuthorEmail else "", + authorEmail = if (selectedTransferType.value == TransferTypeUi.MAIL) transferAuthorEmail.trim() else "", authorEmailToken = null, password = if (selectedPasswordOption.value == PasswordTransferOption.ACTIVATED) transferPassword else NO_PASSWORD, - message = _transferMessage, + message = transferMessage, numberOfDownload = selectedDownloadLimitOption.value.apiValue, language = selectedLanguageOption.value.apiValue, - recipientsEmails = emptySet(), + recipientsEmails = validatedRecipientsEmails, files = importationFilesManager.importedFiles.value.mapToList { fileUi -> object : UploadFileSession { override val path: String? = null @@ -212,6 +220,19 @@ class ImportFilesViewModel @Inject constructor( savedStateHandle[SELECTED_LANGUAGE_KEY] = language } + fun getEmailTextFieldCallbacks(): EmailTextFieldCallbacks { + return EmailTextFieldCallbacks( + transferAuthorEmail = GetSetCallbacks(get = { transferAuthorEmail }, set = { transferAuthorEmail = it }), + isAuthorEmailInvalid = { isAuthorEmailInvalid }, + recipientEmail = GetSetCallbacks(get = { recipientEmail }, set = { recipientEmail = it }), + isRecipientEmailInvalid = { isRecipientEmailInvalid }, + validatedRecipientsEmails = GetSetCallbacks( + get = { validatedRecipientsEmails }, + set = { validatedRecipientsEmails = it } + ), + ) + } + fun initTransferOptionsValues() { viewModelScope.launch(ioDispatcher) { appSettingsManager.getAppSettings()?.let { diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/importfiles/ImportFilesScreen.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/importfiles/ImportFilesScreen.kt index 86cd5e1ad..815e4e6aa 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/importfiles/ImportFilesScreen.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/importfiles/ImportFilesScreen.kt @@ -33,11 +33,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.infomaniak.core2.isEmail import com.infomaniak.multiplatform_swisstransfer.common.interfaces.ui.FileUi import com.infomaniak.swisstransfer.R import com.infomaniak.swisstransfer.ui.components.* @@ -126,8 +126,8 @@ fun ImportFilesScreen( files = { files }, filesToImportCount = { filesToImportCount }, currentSessionFilesCount = { currentSessionFilesCount }, - transferAuthorEmail = importFilesViewModel.transferAuthorEmail, - transferMessage = importFilesViewModel.transferMessage, + emailTextFieldCallbacks = importFilesViewModel.getEmailTextFieldCallbacks(), + transferMessageCallbacks = importFilesViewModel.transferMessageCallbacks, selectedTransferType = GetSetCallbacks( get = { selectedTransferType }, set = importFilesViewModel::selectTransferType, @@ -179,8 +179,8 @@ private fun ImportFilesScreen( files: () -> List, filesToImportCount: () -> Int, currentSessionFilesCount: () -> Int, - transferAuthorEmail: GetSetCallbacks, - transferMessage: GetSetCallbacks, + emailTextFieldCallbacks: EmailTextFieldCallbacks, + transferMessageCallbacks: GetSetCallbacks, selectedTransferType: GetSetCallbacks, transferOptionsCallbacks: TransferOptionsCallbacks, addFiles: (List) -> Unit, @@ -208,7 +208,7 @@ private fun ImportFilesScreen( currentSessionFilesCount = currentSessionFilesCount, importedFiles = files, shouldShowEmailAddressesFields = { shouldShowEmailAddressesFields }, - transferAuthorEmail = transferAuthorEmail, + isAuthorEmailInvalid = emailTextFieldCallbacks.isAuthorEmailInvalid, sendStatus = sendStatus, sendTransfer = sendTransfer, ) @@ -221,8 +221,8 @@ private fun ImportFilesScreen( Spacer(Modifier.height(Margin.Medium)) ImportTextFields( horizontalPaddingModifier = modifier, - transferAuthorEmail = transferAuthorEmail, - transferMessage = transferMessage, + emailTextFieldCallbacks = emailTextFieldCallbacks, + transferMessageCallbacks = transferMessageCallbacks, shouldShowEmailAddressesFields = { shouldShowEmailAddressesFields }, ) TransferOptions(modifier, transferOptionsCallbacks) @@ -261,57 +261,69 @@ private fun FilesToImport( @Composable private fun ColumnScope.ImportTextFields( horizontalPaddingModifier: Modifier, - transferAuthorEmail: GetSetCallbacks, - transferMessage: GetSetCallbacks, + emailTextFieldCallbacks: EmailTextFieldCallbacks, + transferMessageCallbacks: GetSetCallbacks, shouldShowEmailAddressesFields: () -> Boolean, ) { val modifier = horizontalPaddingModifier.fillMaxWidth() - EmailAddressesTextFields(modifier, transferAuthorEmail, shouldShowEmailAddressesFields) + EmailAddressesTextFields(modifier, emailTextFieldCallbacks, shouldShowEmailAddressesFields) SwissTransferTextField( modifier = modifier, label = stringResource(R.string.transferMessagePlaceholder), isRequired = false, minLineNumber = 3, - onValueChange = transferMessage.set, + onValueChange = transferMessageCallbacks.set, ) } @Composable private fun ColumnScope.EmailAddressesTextFields( modifier: Modifier, - transferAuthorEmail: GetSetCallbacks, + emailTextFieldCallbacks: EmailTextFieldCallbacks, shouldShowEmailAddressesFields: () -> Boolean, -) { +) = with(emailTextFieldCallbacks) { AnimatedVisibility(visible = shouldShowEmailAddressesFields()) { Column { - - val isError = transferAuthorEmail.get().isNotEmpty() && !transferAuthorEmail.get().isEmail() - val supportingText: @Composable (() -> Unit)? = if (isError) { - { Text(stringResource(R.string.invalidAddress)) } - } else { - null - } + val isAuthorError = checkEmailError(isAuthor = true) + val isRecipientError = checkEmailError(isAuthor = false) SwissTransferTextField( modifier = modifier, label = stringResource(R.string.transferSenderAddressPlaceholder), initialValue = transferAuthorEmail.get(), keyboardType = KeyboardType.Email, - isError = isError, - supportingText = supportingText, + maxLineNumber = 1, + imeAction = ImeAction.Next, + isError = isAuthorError, + supportingText = getEmailError(isAuthorError), onValueChange = transferAuthorEmail.set, ) Spacer(Modifier.height(Margin.Medium)) - SwissTransferTextField( + EmailAddressTextField( modifier = modifier, label = stringResource(R.string.transferRecipientAddressPlaceholder), - onValueChange = { /* TODO */ }, + initialValue = recipientEmail.get(), + validatedEmails = validatedRecipientsEmails, + onValueChange = { recipientEmail.set(it.text) }, + isError = isRecipientError, + supportingText = getEmailError(isRecipientError), ) Spacer(Modifier.height(Margin.Medium)) } } } +@Composable +private fun getEmailError(isError: Boolean): @Composable (() -> Unit)? { + val supportingText: @Composable (() -> Unit)? = if (isError) { + { Text(stringResource(R.string.invalidAddress)) } + } else { + null + } + + return supportingText +} + @Composable private fun SendByOptions(modifier: Modifier, selectedTransferType: GetSetCallbacks) { ImportFilesTitle(modifier, R.string.transferTypeTitle) @@ -387,7 +399,7 @@ private fun SendButton( currentSessionFilesCount: () -> Int, importedFiles: () -> List, shouldShowEmailAddressesFields: () -> Boolean, - transferAuthorEmail: GetSetCallbacks, + isAuthorEmailInvalid: () -> Boolean, sendStatus: () -> SendStatus, sendTransfer: () -> Unit, ) { @@ -403,7 +415,7 @@ private fun SendButton( } val isSenderEmailCorrect by remember { - derivedStateOf { if (shouldShowEmailAddressesFields()) transferAuthorEmail.get().isEmail() else true } + derivedStateOf { !shouldShowEmailAddressesFields() || !isAuthorEmailInvalid() } } LargeButton( @@ -423,6 +435,23 @@ private fun SendStatus.canEnableButton(): Boolean = when (this) { else -> false } +data class EmailTextFieldCallbacks( + val transferAuthorEmail: GetSetCallbacks, + val isAuthorEmailInvalid: () -> Boolean, + val recipientEmail: GetSetCallbacks, + val isRecipientEmailInvalid: () -> Boolean, + val validatedRecipientsEmails: GetSetCallbacks> +) { + + fun checkEmailError(isAuthor: Boolean): Boolean { + return if (isAuthor) { + isAuthorEmailInvalid() && transferAuthorEmail.get().isNotEmpty() + } else { + isRecipientEmailInvalid() && recipientEmail.get().isNotEmpty() + } + } +} + data class TransferOptionsCallbacks( val transferOptionsStates: () -> List, val onTransferOptionValueSelected: (SettingOption) -> Unit, @@ -473,13 +502,21 @@ private fun Preview(@PreviewParameter(FileUiListPreviewParameter::class) files: isPasswordValid = { true }, ) + val emailTextFieldCallbacks = EmailTextFieldCallbacks( + transferAuthorEmail = GetSetCallbacks(get = { "" }, set = {}), + isAuthorEmailInvalid = { false }, + recipientEmail = GetSetCallbacks(get = { "test.test@ik me" }, set = {}), + isRecipientEmailInvalid = { true }, + validatedRecipientsEmails = GetSetCallbacks(get = { setOf("test.test@ik.me") }, set = {}), + ) + SwissTransferTheme { ImportFilesScreen( files = { files }, filesToImportCount = { 0 }, currentSessionFilesCount = { 0 }, - transferAuthorEmail = GetSetCallbacks(get = { "" }, set = {}), - transferMessage = GetSetCallbacks(get = { "" }, set = {}), + emailTextFieldCallbacks = emailTextFieldCallbacks, + transferMessageCallbacks = GetSetCallbacks(get = { "" }, set = {}), selectedTransferType = GetSetCallbacks(get = { TransferTypeUi.MAIL }, set = {}), transferOptionsCallbacks = transferOptionsCallbacks, addFiles = {}, diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/importfiles/components/EmailAddressTextField.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/importfiles/components/EmailAddressTextField.kt new file mode 100644 index 000000000..48ec6e6da --- /dev/null +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/importfiles/components/EmailAddressTextField.kt @@ -0,0 +1,312 @@ +/* + * Infomaniak SwissTransfer - Android + * Copyright (C) 2024 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.infomaniak.swisstransfer.ui.screen.newtransfer.importfiles.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.key.* +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.* +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import com.infomaniak.core2.isValidEmail +import com.infomaniak.swisstransfer.R +import com.infomaniak.swisstransfer.ui.components.SwissTransferInputChip +import com.infomaniak.swisstransfer.ui.components.SwissTransferTextFieldDefaults +import com.infomaniak.swisstransfer.ui.previewparameter.EmailsPreviewParameter +import com.infomaniak.swisstransfer.ui.theme.Margin +import com.infomaniak.swisstransfer.ui.theme.SwissTransferTheme +import com.infomaniak.swisstransfer.ui.utils.GetSetCallbacks +import com.infomaniak.swisstransfer.ui.utils.PreviewLightAndDark + +@Composable +fun EmailAddressTextField( + modifier: Modifier = Modifier, + label: String, + initialValue: String, + validatedEmails: GetSetCallbacks>, + onValueChange: (TextFieldValue) -> Unit, + isError: Boolean = false, + supportingText: (@Composable () -> Unit)? = null, +) { + + val state = remember(validatedEmails) { EmailAddressTextFieldState(validatedEmails, initialText = initialValue) } + var textFieldValue by state::textFieldValue + val interactionSource = remember { MutableInteractionSource() } + + val cursorColor by animateColorAsState( + targetValue = if (isError) SwissTransferTheme.materialColors.error else SwissTransferTheme.materialColors.primary, + label = "CursorColor", + ) + + fun updateUiTextValue(newValue: TextFieldValue) { + state.unselectChip() + textFieldValue = newValue + onValueChange(newValue) + } + + val keyboardActions = KeyboardActions( + onDone = { + val trimmedText = textFieldValue.text.trim() + if (trimmedText.isValidEmail()) { + validatedEmails.set(validatedEmails.get() + trimmedText) + updateUiTextValue(TextFieldValue()) + } + }, + ) + + val keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Done, + showKeyboardOnFocus = true, + ) + + val emailAddressTextFieldModifier = modifier + .fillMaxWidth() + .onPreviewKeyEvent(state::onKeyEvent) + .onFocusChanged { event -> if (!event.isFocused) state.unselectChip() } + + BasicTextField( + modifier = emailAddressTextFieldModifier, + value = textFieldValue, + onValueChange = ::updateUiTextValue, + textStyle = TextStyle(color = SwissTransferTheme.colors.primaryTextColor), + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + singleLine = true, + interactionSource = interactionSource, + cursorBrush = SolidColor(cursorColor), + decorationBox = { innerTextField -> + EmailAddressDecorationBox( + text = textFieldValue.text, + validatedEmails = validatedEmails, + selectedChipIndexState = state.selectedChipIndexState, + innerTextField = innerTextField, + label = label, + interactionSource = interactionSource, + isError = isError, + supportingText = supportingText, + textFieldColors = SwissTransferTextFieldDefaults.colors(), + ) + } + ) +} + +private class EmailAddressTextFieldState( + private val validatedEmails: GetSetCallbacks>, + initialText: String +) { + var textFieldValue by mutableStateOf(TextFieldValue(initialText)) + + val selectedChipIndexState = mutableIntStateOf(UNSELECTED_CHIP_INDEX) + var selectedChipIndex by selectedChipIndexState + + fun unselectChip() { + selectedChipIndex = UNSELECTED_CHIP_INDEX + } + + fun onKeyEvent(event: KeyEvent): Boolean = when { + event.type != KeyEventType.KeyDown -> false + event.key == Key.Backspace -> handleBackspace() + event.isNavigatingLeft() -> handlePreviousNavigation() + event.isNavigatingRight() -> handleForwardNavigation() + else -> { + unselectChip() + false + } + } + + private fun handleForwardNavigation(): Boolean = when { + selectedChipIndex == UNSELECTED_CHIP_INDEX -> false + selectedChipIndex < getLastEmailIndex() -> { + selectedChipIndex++ + true + } + else -> { + // The currently selected chip is the last one, so going right should deselect it and come back to the text + unselectChip() + true + } + } + + private fun handleBackspace() = if (selectedChipIndex == UNSELECTED_CHIP_INDEX) { + // If no chip is currently selected, we select the last one + selectedChipIndex = getLastEmailIndex() + false + } else { + // If any chip is already selected, pressing on backspace deletes it and reset the selection + validatedEmails.get().elementAtOrNull(selectedChipIndex)?.let { email -> + validatedEmails.set(validatedEmails.get().minusElement(email)) + } + unselectChip() + true + } + + private fun getLastEmailIndex() = validatedEmails.get().toList().lastIndex + + private fun handlePreviousNavigation(): Boolean = when { + selectedChipIndex == UNSELECTED_CHIP_INDEX && textFieldValue.selection.start == 0 -> { + // If we go left when the cursor is already at the start of the textField, we select the last chip + selectedChipIndex = getLastEmailIndex() + true + } + selectedChipIndex != UNSELECTED_CHIP_INDEX -> { + selectedChipIndex-- + true + } + else -> false + } + + companion object { + private const val UNSELECTED_CHIP_INDEX = -1 + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun EmailAddressDecorationBox( + text: String, + validatedEmails: GetSetCallbacks>, + selectedChipIndexState: MutableIntState, + innerTextField: @Composable () -> Unit, + label: String, + interactionSource: MutableInteractionSource, + isError: Boolean, + supportingText: @Composable() (() -> Unit)?, + textFieldColors: TextFieldColors, +) { + OutlinedTextFieldDefaults.DecorationBox( + value = text, + innerTextField = { + EmailChipsAndInnerTextField( + validatedEmails = validatedEmails, + selectedChipIndexState = selectedChipIndexState, + innerTextField = innerTextField, + ) + }, + enabled = true, + singleLine = true, + visualTransformation = if (validatedEmails.get().isNotEmpty()) { + // TODO: Remove this hack to make the label always in "above" position when the labelPosition will be + // available in the DecorationBox's API + VisualTransformation { TransformedText(AnnotatedString(label), OffsetMapping.Identity) } + } else { + VisualTransformation.None + }, + interactionSource = interactionSource, + isError = isError, + supportingText = supportingText, + label = { Text(label) }, + colors = textFieldColors, + ) { + OutlinedTextFieldDefaults.Container( + enabled = true, + isError = isError, + interactionSource = interactionSource, + colors = textFieldColors, + ) + } +} + +@Composable +@OptIn(ExperimentalLayoutApi::class) +private fun EmailChipsAndInnerTextField( + validatedEmails: GetSetCallbacks>, + selectedChipIndexState: MutableIntState, + innerTextField: @Composable () -> Unit, +) { + var selectedChipIndex by selectedChipIndexState + FlowRow( + horizontalArrangement = Arrangement.spacedBy(Margin.Mini), + itemVerticalAlignment = Alignment.CenterVertically, + ) { + validatedEmails.get().forEachIndexed { index, email -> + SwissTransferInputChip( + text = email, + isSelected = { selectedChipIndex == index }, + onClick = { selectedChipIndex = index }, + onDismiss = { validatedEmails.set(validatedEmails.get().minus(email)) }, + ) + } + + Box( + modifier = Modifier + .widthIn(min = 80.dp) + .weight(1f) + ) { + innerTextField() + } + } +} + +private fun KeyEvent.isNavigatingLeft() = key == Key.DirectionLeft || key == Key.NavigatePrevious +private fun KeyEvent.isNavigatingRight() = key == Key.DirectionRight || key == Key.NavigateNext + +@PreviewLightAndDark +@Composable +private fun Preview(@PreviewParameter(EmailsPreviewParameter::class) emails: List) { + val label = stringResource(R.string.transferRecipientAddressPlaceholder) + + SwissTransferTheme { + Surface { + Column(Modifier.padding(Margin.Medium)) { + EmailAddressTextField( + validatedEmails = GetSetCallbacks(get = { emails.take(5).toSet() }, set = {}), + label = label, + initialValue = "test.test@ik.me", + onValueChange = {}, + ) + Spacer(Modifier.height(Margin.Large)) + EmailAddressTextField( + validatedEmails = GetSetCallbacks(get = { emptySet() }, set = {}), + label = label, + initialValue = "", + onValueChange = {}, + ) + Spacer(Modifier.height(Margin.Large)) + EmailAddressTextField( + validatedEmails = GetSetCallbacks(get = { emails.take(1).toSet() }, set = {}), + label = label, + initialValue = "test", + onValueChange = {}, + ) + Spacer(Modifier.height(Margin.Large)) + EmailAddressTextField( + validatedEmails = GetSetCallbacks(get = { emails.take(1).toSet() }, set = {}), + label = label, + initialValue = "test", + onValueChange = {}, + isError = true, + supportingText = { Text(stringResource(R.string.invalidAddress)) } + ) + } + } + } +} diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/theme/Dimens.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/theme/Dimens.kt index 547485b19..df8bc499b 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/theme/Dimens.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/theme/Dimens.kt @@ -27,8 +27,11 @@ object Dimens { val LargeButtonHeight = 56.dp val DoubleButtonMaxWidth = MaxSinglePaneScreenWidth val SingleButtonMaxWidth = DoubleButtonMaxWidth / 2 + val MicroIconSize = 8.dp + val MiniIconSize = 12.dp val SmallIconSize = 16.dp val IconSize = 24.dp val BorderWidth = 1.dp val ButtonComboVerticalPadding = Margin.Small + val InputChipMinWidth = 88.dp } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index dfe6a5a2d..c86889d03 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -29,6 +29,7 @@ Schliessen Passwort verbergen Datei entfernen + %s entfernen Passwort anzeigen Neuer Transfer Heruntergeladene Übertragung: %d/%d diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 6eab30e98..1f579232a 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -29,6 +29,7 @@ Cerrar Ocultar contraseña Eliminar archivo + Suprimir %s Mostrar contraseña Nueva transferencia Transferencia descargada: %d/%d diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 3f9a84ef7..dd187c5b7 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -29,6 +29,7 @@ Fermer Cacher le mot de passe Supprimer le fichier + Supprimer %s Afficher le mot de passe Nouveau transfert Transfert téléchargé : %d/%d\u0020 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 182e54071..d173c83ae 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -29,6 +29,7 @@ Chiudi Nascondere la password Rimuovi il file + Rimuove %s Mostra password Nuovo trasferimento Trasferimento scaricato: %d/%d diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 443a0e193..159564baf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -33,6 +33,7 @@ Close Hide password Remove file + Remove %s Show password New transfer Downloaded transfer: %d/%d\u0020