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