From 137d35ac02b9aa23c7fef6a772666c173af772d2 Mon Sep 17 00:00:00 2001 From: dankito Date: Mon, 24 Aug 2020 12:08:58 +0200 Subject: [PATCH] Implemented validating and auto correcting user input in TransferMoneyDialog --- .../dankito/utils/multiplatform/BigDecimal.kt | 2 + .../dankito/utils/multiplatform/BigDecimal.kt | 16 +- .../dankito/utils/multiplatform/BigDecimal.kt | 3 + .../implementierte/sepa/SepaMessageCreator.kt | 1 + .../ui/android/dialogs/TransferMoneyDialog.kt | 203 +++--- .../dankito/banking/util/InputValidator.kt | 181 ++++- .../dankito/banking/util/ValidationResult.kt | 15 + .../banking/util/InputValidatorTest.kt | 687 ++++++++++++++++++ .../banking/util/InputValidatorTest.kt | 38 - .../BankingiOSApp/ui/ValidationLabel.swift | 57 ++ .../ui/views/TransferMoneyDialog.swift | 142 +++- 11 files changed, 1156 insertions(+), 189 deletions(-) create mode 100644 ui/BankingUiCommon/src/commonMain/kotlin/net/dankito/banking/util/ValidationResult.kt create mode 100644 ui/BankingUiCommon/src/jvmTest/kotlin/net/dankito/banking/util/InputValidatorTest.kt delete mode 100644 ui/BankingUiCommon/src/test/kotlin/net/dankito/banking/util/InputValidatorTest.kt create mode 100644 ui/BankingiOSApp/BankingiOSApp/ui/ValidationLabel.swift diff --git a/common/src/commonMain/kotlin/net/dankito/utils/multiplatform/BigDecimal.kt b/common/src/commonMain/kotlin/net/dankito/utils/multiplatform/BigDecimal.kt index f68cedb5..be931132 100644 --- a/common/src/commonMain/kotlin/net/dankito/utils/multiplatform/BigDecimal.kt +++ b/common/src/commonMain/kotlin/net/dankito/utils/multiplatform/BigDecimal.kt @@ -16,6 +16,8 @@ expect class BigDecimal { constructor(double: Double) + val isPositive: Boolean + fun format(countDecimalPlaces: Int): String } \ No newline at end of file diff --git a/common/src/iosMain/kotlin/net/dankito/utils/multiplatform/BigDecimal.kt b/common/src/iosMain/kotlin/net/dankito/utils/multiplatform/BigDecimal.kt index 12fdc606..3f192155 100644 --- a/common/src/iosMain/kotlin/net/dankito/utils/multiplatform/BigDecimal.kt +++ b/common/src/iosMain/kotlin/net/dankito/utils/multiplatform/BigDecimal.kt @@ -12,7 +12,7 @@ actual fun Collection.sum(): BigDecimal { } -actual class BigDecimal(val decimal: NSDecimalNumber) { // it's almost impossible to derive from NSDecimalNumber so i keep it as property +actual class BigDecimal(val decimal: NSDecimalNumber) : Comparable { // it's almost impossible to derive from NSDecimalNumber so i keep it as property actual companion object { actual val Zero = BigDecimal(0.0) @@ -23,6 +23,9 @@ actual class BigDecimal(val decimal: NSDecimalNumber) { // it's almost impossibl actual constructor(decimal: String) : this(decimal.toDouble()) + actual val isPositive: Boolean + get() = this >= Zero + actual fun format(countDecimalPlaces: Int): String { val formatter = NSNumberFormatter() @@ -33,9 +36,18 @@ actual class BigDecimal(val decimal: NSDecimalNumber) { // it's almost impossibl } + override fun compareTo(other: BigDecimal): Int { + return when (decimal.compare(other.decimal)) { + NSOrderedSame -> 0 + NSOrderedAscending -> -1 + NSOrderedDescending -> 1 + else -> 0 + } + } + override fun equals(other: Any?): Boolean { if (other is BigDecimal) { - return this.decimal.compare(other.decimal) == NSOrderedSame + return this.compareTo(other) == 0 } return super.equals(other) diff --git a/common/src/jvmMain/kotlin/net/dankito/utils/multiplatform/BigDecimal.kt b/common/src/jvmMain/kotlin/net/dankito/utils/multiplatform/BigDecimal.kt index 00be278c..35d2e397 100644 --- a/common/src/jvmMain/kotlin/net/dankito/utils/multiplatform/BigDecimal.kt +++ b/common/src/jvmMain/kotlin/net/dankito/utils/multiplatform/BigDecimal.kt @@ -26,6 +26,9 @@ actual class BigDecimal actual constructor(decimal: String) : java.math.BigDecim actual constructor(double: Double) : this(java.math.BigDecimal.valueOf(double).toPlainString()) // for object deserializers + actual val isPositive: Boolean + get() = this >= ZERO + actual fun format(countDecimalPlaces: Int): String { return String.format("%.0${countDecimalPlaces}f", this) } diff --git a/fints4k/src/commonMain/kotlin/net/dankito/banking/fints/messages/segmente/implementierte/sepa/SepaMessageCreator.kt b/fints4k/src/commonMain/kotlin/net/dankito/banking/fints/messages/segmente/implementierte/sepa/SepaMessageCreator.kt index 57af60d8..6fafd3c8 100644 --- a/fints4k/src/commonMain/kotlin/net/dankito/banking/fints/messages/segmente/implementierte/sepa/SepaMessageCreator.kt +++ b/fints4k/src/commonMain/kotlin/net/dankito/banking/fints/messages/segmente/implementierte/sepa/SepaMessageCreator.kt @@ -34,6 +34,7 @@ open class SepaMessageCreator : ISepaMessageCreator { override fun containsOnlyAllowedCharacters(stringToTest: String): Boolean { return AllowedSepaCharactersPattern.matches(stringToTest) + && convertDiacriticsAndReservedXmlCharacters(stringToTest) == stringToTest } override fun convertDiacriticsAndReservedXmlCharacters(input: String): String { diff --git a/ui/BankingAndroidApp/src/main/java/net/dankito/banking/ui/android/dialogs/TransferMoneyDialog.kt b/ui/BankingAndroidApp/src/main/java/net/dankito/banking/ui/android/dialogs/TransferMoneyDialog.kt index 707a96df..0864b19e 100644 --- a/ui/BankingAndroidApp/src/main/java/net/dankito/banking/ui/android/dialogs/TransferMoneyDialog.kt +++ b/ui/BankingAndroidApp/src/main/java/net/dankito/banking/ui/android/dialogs/TransferMoneyDialog.kt @@ -12,6 +12,7 @@ import android.widget.EditText import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.DialogFragment +import com.google.android.material.textfield.TextInputLayout import com.otaliastudios.autocomplete.Autocomplete import kotlinx.android.synthetic.main.dialog_transfer_money.* import kotlinx.android.synthetic.main.dialog_transfer_money.view.* @@ -31,6 +32,7 @@ import net.dankito.banking.ui.model.responses.BankingClientResponse import net.dankito.banking.ui.presenter.BankingPresenter import net.dankito.banking.util.InputValidator import net.dankito.banking.bankfinder.BankInfo +import net.dankito.banking.util.ValidationResult import net.dankito.utils.multiplatform.toBigDecimal import net.dankito.utils.android.extensions.asActivity import java.math.BigDecimal @@ -57,7 +59,17 @@ open class TransferMoneyDialog : DialogFragment() { protected val inputValidator = InputValidator() // TODO: move to presenter - protected var foundBankForEnteredIban = false + protected var validRemitteeNameEntered = false + + protected var validRemitteeIbanEntered = false + + protected var validRemitteeBicEntered = false + + protected var validUsageEntered = true + + protected var validAmountEntered = false + + protected var didJustCorrectInput = mutableMapOf() @Inject @@ -113,10 +125,13 @@ open class TransferMoneyDialog : DialogFragment() { initRemitteeAutocompletion(rootView.edtxtRemitteeName) rootView.edtxtRemitteeName.addTextChangedListener(checkRequiredDataWatcher { - checkIfEnteredRemitteeNameIsValid() + checkIfEnteredRemitteeNameIsValidWhileUserIsTyping() }) - rootView.edtxtRemitteeIban.addTextChangedListener(StandardTextWatcher { tryToGetBicFromIban(it) }) + rootView.edtxtRemitteeIban.addTextChangedListener(StandardTextWatcher { + checkIfEnteredRemitteeIbanIsValidWhileUserIsTyping() + tryToGetBicFromIban(it) + }) rootView.edtxtRemitteeBic.addTextChangedListener(checkRequiredDataWatcher()) rootView.edtxtAmount.addTextChangedListener(checkRequiredDataWatcher()) @@ -124,8 +139,8 @@ open class TransferMoneyDialog : DialogFragment() { checkIfEnteredUsageTextIsValid() }) - rootView.edtxtRemitteeName.setOnFocusChangeListener { _, hasFocus -> if (hasFocus == false) checkIfEnteredRemitteeNameIsValid() } - rootView.edtxtRemitteeIban.setOnFocusChangeListener { _, hasFocus -> if (hasFocus == false) checkIfEnteredRemitteeIbanIsValid() } + rootView.edtxtRemitteeName.setOnFocusChangeListener { _, hasFocus -> if (hasFocus == false) checkIfEnteredRemitteeNameIsValidAfterFocusLost() } + rootView.edtxtRemitteeIban.setOnFocusChangeListener { _, hasFocus -> if (hasFocus == false) checkIfEnteredRemitteeIbanIsValidAfterFocusLost() } rootView.edtxtRemitteeBic.setOnFocusChangeListener { _, hasFocus -> if (hasFocus == false) checkIfEnteredRemitteeBicIsValid() } rootView.edtxtAmount.setOnFocusChangeListener { _, hasFocus -> if (hasFocus == false) checkIfEnteredAmountIsValid() } rootView.edtxtUsage.setOnFocusChangeListener { _, hasFocus -> if (hasFocus == false) checkIfEnteredUsageTextIsValid() } @@ -290,8 +305,8 @@ open class TransferMoneyDialog : DialogFragment() { } } - protected open fun tryToGetBicFromIban(enteredText: CharSequence) { - presenter.findUniqueBankForIbanAsync(enteredText.toString()) { foundBank -> + protected open fun tryToGetBicFromIban(enteredIban: CharSequence) { + presenter.findUniqueBankForIbanAsync(enteredIban.toString()) { foundBank -> context?.asActivity()?.runOnUiThread { showValuesForFoundBankOnUiThread(foundBank) } @@ -299,72 +314,51 @@ open class TransferMoneyDialog : DialogFragment() { } private fun showValuesForFoundBankOnUiThread(foundBank: BankInfo?) { - foundBankForEnteredIban = foundBank != null + validRemitteeBicEntered = foundBank != null edtxtRemitteeBank.setText(if (foundBank != null) (foundBank.name + " " + foundBank.city) else "") edtxtRemitteeBic.setText(foundBank?.bic ?: "") // TODO: check if user entered BIC to not overwrite self entered BIC - lytRemitteeBic.error = null - - if (foundBankForEnteredIban) { - lytRemitteeIban.error = null - } + lytRemitteeBic.error = null // TODO: show information here if BIC hasn't been found checkIfRequiredDataEnteredOnUiThread() } protected open fun checkIfRequiredDataEnteredOnUiThread() { - btnTransferMoney.isEnabled = isRemitteeNameValid() && isRemitteeIbanValid() - && isRemitteeBicValid() - && isAmountGreaterZero() && isUsageTextValid() + btnTransferMoney.isEnabled = validRemitteeNameEntered && validRemitteeIbanEntered + && validRemitteeBicEntered + && validAmountEntered && validUsageEntered } - protected open fun checkIfEnteredRemitteeNameIsValid() { - if (isRemitteeNameValid()) { - lytRemitteeName.error = null - } - else { - val enteredName = edtxtRemitteeName.text.toString() - - if (enteredName.isEmpty()) { - lytRemitteeName.error = context?.getString(R.string.error_no_name_entered) - } - else if (inputValidator.hasRemitteeNameValidLength(enteredName) == false) { - lytRemitteeName.error = context?.getString(R.string.error_entered_name_too_long) - } - else { - lytRemitteeName.error = context?.getString( - R.string.error_invalid_sepa_characters_entered, inputValidator.getInvalidSepaCharacters(enteredName)) - } - } - } - - protected open fun isRemitteeNameValid(): Boolean { + protected open fun checkIfEnteredRemitteeNameIsValidWhileUserIsTyping() { val enteredRemitteeName = edtxtRemitteeName.text.toString() + val validationResult = inputValidator.validateRemitteeNameWhileTyping(enteredRemitteeName) - return inputValidator.isRemitteeNameValid(enteredRemitteeName) + this.validRemitteeNameEntered = validationResult.validationSuccessfulOrCouldCorrectString + + showValidationResult(lytRemitteeName, validationResult) } - protected open fun checkIfEnteredRemitteeIbanIsValid() { + protected open fun checkIfEnteredRemitteeNameIsValidAfterFocusLost() { + val enteredRemitteeName = edtxtRemitteeName.text.toString() + val validationResult = inputValidator.validateRemitteeName(enteredRemitteeName) + + this.validRemitteeNameEntered = validationResult.validationSuccessfulOrCouldCorrectString + + if (validationResult.validationSuccessful == false) { // only update hint / error if validation fails, don't hide previous hint / error otherwise + showValidationResult(lytRemitteeName, validationResult) + } + } + + protected open fun checkIfEnteredRemitteeIbanIsValidWhileUserIsTyping() { val enteredIban = edtxtRemitteeIban.text.toString() + val validationResult = inputValidator.validateIbanWhileTyping(enteredIban) - if (isRemitteeIbanValid()) { - lytRemitteeIban.error = null - } - else if (enteredIban.isBlank()) { - lytRemitteeIban.error = context?.getString(R.string.error_no_iban_entered) - } - else { - val invalidIbanCharacters = inputValidator.getInvalidIbanCharacters(enteredIban) - if (invalidIbanCharacters.isNotEmpty()) { - lytRemitteeIban.error = context?.getString(R.string.error_invalid_iban_characters_entered, invalidIbanCharacters) - } - else { - lytRemitteeIban.error = context?.getString(R.string.error_invalid_iban_pattern_entered) - } - } + this.validRemitteeIbanEntered = validationResult.validationSuccessfulOrCouldCorrectString - if (foundBankForEnteredIban || enteredIban.isBlank()) { + showValidationResult(lytRemitteeIban, validationResult) + + if (validRemitteeBicEntered || enteredIban.isBlank()) { lytRemitteeBic.error = null } else { @@ -372,56 +366,31 @@ open class TransferMoneyDialog : DialogFragment() { } } - protected open fun isRemitteeIbanValid(): Boolean { - return inputValidator.isValidIban(edtxtRemitteeIban.text.toString()) + protected open fun checkIfEnteredRemitteeIbanIsValidAfterFocusLost() { + val validationResult = inputValidator.validateIban(edtxtRemitteeIban.text.toString()) + + this.validRemitteeIbanEntered = validationResult.validationSuccessfulOrCouldCorrectString + + if (validationResult.validationSuccessful == false) { // only update hint / error if validation fails, don't hide previous hint / error otherwise + showValidationResult(lytRemitteeIban, validationResult) + } } protected open fun checkIfEnteredRemitteeBicIsValid() { - if (isRemitteeBicValid()) { - lytRemitteeBic.error = null - } - else { - val enteredBic = edtxtRemitteeBic.text.toString() + val enteredBic = edtxtRemitteeBic.text.toString() + val validationResult = inputValidator.validateBic(enteredBic) - if (enteredBic.isBlank()) { - lytRemitteeBic.error = context?.getString(R.string.error_no_bic_entered) - } - else { - val invalidBicCharacters = inputValidator.getInvalidBicCharacters(enteredBic) - if (invalidBicCharacters.isNotEmpty()) { - lytRemitteeBic.error = context?.getString(R.string.error_invalid_bic_characters_entered, invalidBicCharacters) - } - else { - lytRemitteeBic.error = context?.getString(R.string.error_invalid_bic_pattern_entered) - } - } - } - } + this.validRemitteeBicEntered = validationResult.validationSuccessfulOrCouldCorrectString - protected open fun isRemitteeBicValid(): Boolean { - return inputValidator.isValidBic(edtxtRemitteeBic.text.toString()) + showValidationResult(lytRemitteeBic, validationResult) } protected open fun checkIfEnteredAmountIsValid() { - if (isAmountGreaterZero()) { - lytAmount.error = null - } - else if (edtxtAmount.text.toString().isBlank()) { - lytAmount.error = context?.getString(R.string.error_no_amount_entered) - } - else { - lytAmount.error = context?.getString(R.string.error_invalid_amount_entered) - } - } + val validationResult = inputValidator.validateAmount(edtxtAmount.text.toString()) - protected open fun isAmountGreaterZero(): Boolean { - try { - getEnteredAmount()?.let { amount -> - return amount > BigDecimal.ZERO - } - } catch (ignored: Exception) { } + this.validAmountEntered = validationResult.validationSuccessfulOrCouldCorrectString - return false + showValidationResult(lytAmount, validationResult) } protected open fun getEnteredAmount(): BigDecimal? { @@ -435,22 +404,40 @@ open class TransferMoneyDialog : DialogFragment() { } protected open fun checkIfEnteredUsageTextIsValid() { - val enteredUsage = edtxtUsage.text.toString() + val validationResult = inputValidator.validateUsage(edtxtUsage.text.toString()) - if (isUsageTextValid()) { - lytUsage.error = null - } - else if (inputValidator.hasUsageValidLength(enteredUsage) == false) { - lytUsage.error = context?.getString(R.string.error_entered_usage_too_long) - } - else { - lytUsage.error = context?.getString(R.string.error_invalid_sepa_characters_entered, - inputValidator.getInvalidSepaCharacters(enteredUsage)) - } + this.validUsageEntered = validationResult.validationSuccessfulOrCouldCorrectString + + showValidationResult(lytUsage, validationResult) } - protected open fun isUsageTextValid(): Boolean { - return inputValidator.isUsageValid(edtxtUsage.text.toString()) + protected open fun showValidationResult(textInputLayout: TextInputLayout, validationResult: ValidationResult) { + if (didJustCorrectInput.containsKey(textInputLayout)) { // we have just auto corrected TextInputLayout's EditText's text below, don't overwrite its displayed hints and error + return + } + + if (validationResult.didCorrectString) { + textInputLayout.editText?.let { editText -> + val selectionStart = editText.selectionStart + val selectionEnd = editText.selectionEnd + val lengthDiff = validationResult.correctedInputString.length - validationResult.inputString.length + + didJustCorrectInput.put(textInputLayout, true) + + editText.setText(validationResult.correctedInputString) + + if (validationResult.correctedInputString.isNotEmpty()) { + editText.setSelection(selectionStart + lengthDiff, selectionEnd + lengthDiff) + } + + didJustCorrectInput.remove(textInputLayout) + } + } + + textInputLayout.error = validationResult.validationError + if (validationResult.validationError == null) { // don't overwrite error text + textInputLayout.helperText = validationResult.validationHint + } } } \ No newline at end of file diff --git a/ui/BankingUiCommon/src/commonMain/kotlin/net/dankito/banking/util/InputValidator.kt b/ui/BankingUiCommon/src/commonMain/kotlin/net/dankito/banking/util/InputValidator.kt index 453fdd9f..8b0fdb36 100644 --- a/ui/BankingUiCommon/src/commonMain/kotlin/net/dankito/banking/util/InputValidator.kt +++ b/ui/BankingUiCommon/src/commonMain/kotlin/net/dankito/banking/util/InputValidator.kt @@ -2,6 +2,7 @@ package net.dankito.banking.util import net.dankito.banking.fints.messages.segmente.implementierte.sepa.ISepaMessageCreator import net.dankito.banking.fints.messages.segmente.implementierte.sepa.SepaMessageCreator +import net.dankito.utils.multiplatform.BigDecimal open class InputValidator { @@ -10,6 +11,10 @@ open class InputValidator { const val RemitteNameMaxLength = 70 + const val IbanMaxLength = 34 + + const val BicMaxLength = 11 + const val UsageMaxLength = 140 @@ -58,6 +63,94 @@ open class InputValidator { protected val sepaMessageCreator: ISepaMessageCreator = SepaMessageCreator() + open fun validateRemitteeNameWhileTyping(remitteeNameToTest: String): ValidationResult { + return validateRemitteeName(remitteeNameToTest, true) + } + + open fun validateRemitteeName(remitteeNameToTest: String): ValidationResult { + return validateRemitteeName(remitteeNameToTest, false) + } + + open fun validateRemitteeName(remitteeNameToTest: String, userIsStillTyping: Boolean = false): ValidationResult { + if (isRemitteeNameValid(remitteeNameToTest)) { + return ValidationResult(remitteeNameToTest, true) + } + + if (remitteeNameToTest.isEmpty()) { + if (userIsStillTyping) { // if user is still typing, don't check if something has been entered yet + return ValidationResult(remitteeNameToTest, true) + } + return ValidationResult(remitteeNameToTest, false, validationError = "Bitte geben Sie den Namen des Empfängers ein") // TODO: translate + } + + if (hasRemitteeNameValidLength(remitteeNameToTest) == false) { + val correctedString = remitteeNameToTest.substring(0, RemitteNameMaxLength) + return ValidationResult(remitteeNameToTest, isRemitteeNameValid(correctedString), true, correctedString, "Name darf maximal 70 Zeichen lang sein") // TODO: translate + } + + + val invalidRemitteeNameCharacters = getInvalidSepaCharacters(remitteeNameToTest) + + val correctedString = getCorrectedString(remitteeNameToTest, invalidRemitteeNameCharacters, true) + return ValidationResult(remitteeNameToTest, isRemitteeNameValid(correctedString), true, correctedString, null, "Unzulässige(s) Zeichen eingegeben: $invalidRemitteeNameCharacters") // TODO: translate + } + + open fun isRemitteeNameValid(stringToTest: String): Boolean { + return hasRemitteeNameValidLength(stringToTest) + && containsOnlyValidSepaCharacters(stringToTest) + } + + open fun hasRemitteeNameValidLength(stringToTest: String): Boolean { + return stringToTest.length in 1..RemitteNameMaxLength + } + + + /** + * Validate entered IBAN while user is still typing. Just checks (and corrects) if invalid + * characters have been entered or string is too long. + * + * Doesn't check yet if entered text has the correct pattern, min length etc. as user may is + * just about to enter this information. + */ + open fun validateIbanWhileTyping(ibanToTest: String): ValidationResult { + return validateIban(ibanToTest, true) + } + + open fun validateIban(ibanToTest: String): ValidationResult { + return validateIban(ibanToTest, false) + } + + protected open fun validateIban(ibanToTest: String, userIsStillTyping: Boolean = false): ValidationResult { + if (isValidIban(ibanToTest)) { + return ValidationResult(ibanToTest, true) + } + + if (ibanToTest.isBlank()) { + if (userIsStillTyping) { // if user is still typing, don't check if something has been entered yet + return ValidationResult(ibanToTest, true) + } + return ValidationResult(ibanToTest, false, validationError = "Bitte geben Sie die IBAN des Empfängers ein") // TODO: translate + } + + if (ibanToTest.length > IbanMaxLength) { + val correctedString = ibanToTest.substring(0, IbanMaxLength) + return ValidationResult(ibanToTest, isValidIban(correctedString), true, correctedString, null, "Eine IBAN darf maximal 34 Zeichen lang sein") // TODO: translate // TODO: may test country specific IBAN length, e.g. German IBANs have 22 charactersaa + } + + val invalidIbanCharacters = getInvalidIbanCharacters(ibanToTest) + + if (invalidIbanCharacters.isNotEmpty()) { + val correctedString = getCorrectedString(ibanToTest, invalidIbanCharacters) + return ValidationResult(ibanToTest, isValidIban(correctedString), true, correctedString, null, "Unzulässige(s) Zeichen eingegeben: $invalidIbanCharacters") // TODO: translate + } + else if (userIsStillTyping) { // entered IBAN hasn't required pattern yet but that's ok as user is may just about to provide that information + return ValidationResult(ibanToTest, true) + } + else { + return ValidationResult(ibanToTest, false, validationError = "IBANs haben folgendes Muster: DE12 1234 5678 9012 3456 78") // TODO: translate + } + } + open fun isValidIban(stringToTest: String): Boolean { return IbanPattern.matches(stringToTest.replace(" ", "")) } @@ -67,6 +160,31 @@ open class InputValidator { } + open fun validateBic(bicToTest: String): ValidationResult { + if (isValidBic(bicToTest)) { + return ValidationResult(bicToTest, true) + } + else { + if (bicToTest.isBlank()) { + return ValidationResult(bicToTest, false, validationError = "Bitte geben Sie die BIC des Empfängers ein") // TODO: translate + } + else if (bicToTest.length > BicMaxLength) { + val correctedString = bicToTest.substring(0, BicMaxLength) + return ValidationResult(bicToTest, isValidBic(correctedString), true, correctedString, null, "Eine IBAN darf maximal 11 Zeichen lang sein") // TODO: translate // TODO: may test country specific IBAN length, e.g. German IBANs have 22 charactersaa + } + else { + val invalidBicCharacters = getInvalidBicCharacters(bicToTest) + if (invalidBicCharacters.isNotEmpty()) { + val correctedString = getCorrectedString(bicToTest, invalidBicCharacters) + return ValidationResult(bicToTest, isValidBic(correctedString), true, correctedString, null, "Unzulässige(s) Zeichen eingegeben: $invalidBicCharacters") // TODO: translate + } + else { + return ValidationResult(bicToTest, false, validationError = "Eine BIC besteht aus 8 oder 11 Zeichen und folgt dem Muster: ABCDED12(XYZ)") // TODO: translate + } + } + } + } + open fun isValidBic(stringToTest: String): Boolean { return BicPattern.matches(stringToTest) } @@ -76,23 +194,50 @@ open class InputValidator { } - open fun isRemitteeNameValid(stringToTest: String): Boolean { - val convertedString = convertToAllowedSepaCharacters(stringToTest) + open fun validateAmount(enteredAmountString: String): ValidationResult { + if (enteredAmountString.isBlank()) { + return ValidationResult(enteredAmountString, false, validationError = "Bitte geben Sie den zu überweisenden Betrag ein") // TODO: translate + } - return hasRemitteeNameValidLength(convertedString) - && containsOnlyValidSepaCharacters(convertedString) + convertAmountString(enteredAmountString)?.let { amount -> + if (amount.isPositive && amount != BigDecimal.Zero) { + return ValidationResult(enteredAmountString, true) + } + } + + return ValidationResult(enteredAmountString, false, validationError = "Bitte geben Sie einen Betrag größer 0 ein.") // TODO: translate } - open fun hasRemitteeNameValidLength(stringToTest: String): Boolean { - return stringToTest.length in 1..RemitteNameMaxLength + open fun convertAmountString(enteredAmountString: String): BigDecimal? { + try { + val amountString = enteredAmountString.replace(',', '.') + + return BigDecimal(amountString) + } catch (ignored: Exception) { } + + return null } + open fun validateUsage(usageToTest: String): ValidationResult { + if (isUsageValid(usageToTest)) { + return ValidationResult(usageToTest, true) + } + + if (hasUsageValidLength(usageToTest) == false) { + val correctedString = usageToTest.substring(0, UsageMaxLength) + return ValidationResult(usageToTest, isUsageValid(correctedString), true, correctedString, "Verwendungszweck darf nur 140 Zeichen lang sein") // TODO: translate + } + + + val invalidUsageCharacters = getInvalidSepaCharacters(usageToTest) + val correctedString = getCorrectedString(usageToTest, invalidUsageCharacters, true) + return ValidationResult(usageToTest, isUsageValid(correctedString), true, correctedString, null, "Unzulässige(s) Zeichen eingegeben: $invalidUsageCharacters") // TODO: translate return ValidationResult(remitteeNameToTest, false, validationError = "Unzulässige(s) Zeichen eingegeben: ") // TODO: translate + } + open fun isUsageValid(stringToTest: String): Boolean { - val convertedString = convertToAllowedSepaCharacters(stringToTest) - - return hasUsageValidLength(convertedString) - && containsOnlyValidSepaCharacters(convertedString) + return hasUsageValidLength(stringToTest) + && containsOnlyValidSepaCharacters(stringToTest) } open fun hasUsageValidLength(stringToTest: String): Boolean { @@ -105,7 +250,7 @@ open class InputValidator { } open fun getInvalidSepaCharacters(string: String): String { - return getInvalidCharacters(convertToAllowedSepaCharacters(string), InvalidSepaCharactersPattern) + return getInvalidCharacters(string, InvalidSepaCharactersPattern) } open fun convertToAllowedSepaCharacters(string: String): String { @@ -114,7 +259,19 @@ open class InputValidator { open fun getInvalidCharacters(string: String, pattern: Regex): String { - return pattern.findAll(string).joinToString("") + return pattern.findAll(string).map { it.value }.joinToString("") + } + + // TODO: do not convert XML entities in user's. User will a) not understand what happened and b) afterwards auto correction will not work anymore (i think the issue lies in used Regex: '(&\w{2,4};)'). + // But take converted XML entities length into account when checking if remittee's name and usage length isn't too long + protected open fun getCorrectedString(inputString: String, invalidCharacters: String, convertToAllowedSepaCharacters: Boolean = false): String { + var correctedString = if (convertToAllowedSepaCharacters) convertToAllowedSepaCharacters(inputString) else inputString + + invalidCharacters.forEach { invalidChar -> + correctedString = correctedString.replace(invalidChar.toString(), "") + } + + return correctedString } } \ No newline at end of file diff --git a/ui/BankingUiCommon/src/commonMain/kotlin/net/dankito/banking/util/ValidationResult.kt b/ui/BankingUiCommon/src/commonMain/kotlin/net/dankito/banking/util/ValidationResult.kt new file mode 100644 index 00000000..aa3ebfca --- /dev/null +++ b/ui/BankingUiCommon/src/commonMain/kotlin/net/dankito/banking/util/ValidationResult.kt @@ -0,0 +1,15 @@ +package net.dankito.banking.util + + +open class ValidationResult( + open val inputString: String, + open val validationSuccessful: Boolean, + open val didCorrectString: Boolean = false, + open val correctedInputString: String = inputString, + open val validationError: String? = null, + open val validationHint: String? = null +) { + + open val validationSuccessfulOrCouldCorrectString: Boolean = validationSuccessful || didCorrectString + +} \ No newline at end of file diff --git a/ui/BankingUiCommon/src/jvmTest/kotlin/net/dankito/banking/util/InputValidatorTest.kt b/ui/BankingUiCommon/src/jvmTest/kotlin/net/dankito/banking/util/InputValidatorTest.kt new file mode 100644 index 00000000..4c7afd3c --- /dev/null +++ b/ui/BankingUiCommon/src/jvmTest/kotlin/net/dankito/banking/util/InputValidatorTest.kt @@ -0,0 +1,687 @@ +package net.dankito.banking.util + +import ch.tutteli.atrium.api.fluent.en_GB.notToBeNull +import ch.tutteli.atrium.api.fluent.en_GB.toBe +import ch.tutteli.atrium.api.verbs.expect +import org.junit.Test + + +class InputValidatorTest { + + companion object { + + const val ValidRemitteeName = "Marieke Musterfrau" + + const val ValidIban = "DE11123456780987654321" + + const val ValidBic = "ABCDDEBBXXX" + + const val ValidUsage = "Usage" + + const val InvalidSepaCharacter = "!" + + const val InvalidUmlaut = "ö" + const val ConvertedInvalidUmlaut = "o" + + } + + private val underTest = InputValidator() + + + @Test + fun getInvalidIbanCharacters() { + + // given + val invalidIbanCharacters = "ajvz!@#$%^&*()-_=+[]{}'\"\\|/?.,;:<>" + + // when + val result = underTest.getInvalidIbanCharacters("EN${invalidIbanCharacters}1234") + + // then + expect(result).toBe(invalidIbanCharacters) + } + + @Test + fun getInvalidSepaCharacters() { + + // given + val invalidSepaCharacters = "!€@#$%^*=[]\\|<>" + + // when + val result = underTest.getInvalidSepaCharacters("abcd${invalidSepaCharacters}1234") + + // then + expect(result).toBe(invalidSepaCharacters) + } + + + @Test + fun validateRemitteeName_EmptyStringEntered() { + + // given + val enteredName = "" + + // when + val result = underTest.validateRemitteeName(enteredName) + + // then + expect(result.validationSuccessful).toBe(false) + expect(result.didCorrectString).toBe(false) + expect(result.inputString).toBe(enteredName) + expect(result.correctedInputString).toBe(enteredName) + expect(result.validationHint).toBe(null) + expect(result.validationError).notToBeNull() + } + + @Test + fun validateRemitteeName_ValidNameEntered() { + + // given + val enteredName = ValidRemitteeName + + // when + val result = underTest.validateRemitteeName(enteredName) + + // then + expect(result.validationSuccessful).toBe(true) + expect(result.didCorrectString).toBe(false) + expect(result.inputString).toBe(enteredName) + expect(result.correctedInputString).toBe(enteredName) + expect(result.validationHint).toBe(null) + expect(result.validationError).toBe(null) + } + + @Test + fun validateRemitteeName_UmlautGetsConverted() { + + // given + val enteredName = ValidRemitteeName + InvalidUmlaut + + // when + val result = underTest.validateRemitteeName(enteredName) + + // then + expect(result.validationSuccessful).toBe(true) + expect(result.didCorrectString).toBe(true) + expect(result.inputString).toBe(enteredName) + expect(result.correctedInputString).toBe(ValidRemitteeName + ConvertedInvalidUmlaut) + expect(result.validationHint?.contains(InvalidUmlaut)).toBe(true) + expect(result.validationError).toBe(null) + } + + @Test + fun validateRemitteeName_InvalidCharacterGetsRemoved() { + + // given + val enteredName = ValidRemitteeName + InvalidSepaCharacter + + // when + val result = underTest.validateRemitteeName(enteredName) + + // then + expect(result.validationSuccessful).toBe(true) + expect(result.didCorrectString).toBe(true) + expect(result.inputString).toBe(enteredName) + expect(result.correctedInputString).toBe(ValidRemitteeName) + expect(result.validationHint?.contains(InvalidSepaCharacter)).toBe(true) + expect(result.validationError).toBe(null) + } + + @Test + fun validateRemitteeName_TooLong() { + + // given + val nameWithMaxLength = IntRange(0, InputValidator.RemitteNameMaxLength - 1).map { "a" }.joinToString("") + val enteredName = nameWithMaxLength + "a" + + // when + val result = underTest.validateRemitteeName(enteredName) + + // then + expect(result.validationSuccessful).toBe(true) + expect(result.didCorrectString).toBe(true) + expect(result.inputString).toBe(enteredName) + expect(result.correctedInputString).toBe(nameWithMaxLength) + expect(result.validationHint).toBe(null) + expect(result.validationError).notToBeNull() + } + + + @Test + fun validateIban_EmptyStringEntered() { + + // given + val enteredIban = "" + + // when + val result = underTest.validateIban(enteredIban) + + // then + expect(result.validationSuccessful).toBe(false) + expect(result.didCorrectString).toBe(false) + expect(result.inputString).toBe(enteredIban) + expect(result.correctedInputString).toBe(enteredIban) + expect(result.validationHint).toBe(null) + expect(result.validationError).notToBeNull() + } + + @Test + fun validateIban_ValidIbanEntered() { + + // given + val enteredIban = ValidIban + + // when + val result = underTest.validateIban(enteredIban) + + // then + expect(result.validationSuccessful).toBe(true) + expect(result.didCorrectString).toBe(false) + expect(result.inputString).toBe(enteredIban) + expect(result.correctedInputString).toBe(enteredIban) + expect(result.validationHint).toBe(null) + expect(result.validationError).toBe(null) + } + + @Test + fun validateIban_IbanTooShort() { + + // given + val enteredIban = "DE11" + + // when + val result = underTest.validateIban(enteredIban) + + // then + expect(result.validationSuccessful).toBe(false) + expect(result.didCorrectString).toBe(false) + expect(result.inputString).toBe(enteredIban) + expect(result.correctedInputString).toBe(enteredIban) + expect(result.validationHint).toBe(null) + expect(result.validationError).notToBeNull() + } + + @Test + fun validateIban_UmlautGetsRemoved() { + + // given + val enteredIban = ValidIban + InvalidUmlaut + + // when + val result = underTest.validateIban(enteredIban) + + // then + expect(result.validationSuccessful).toBe(true) + expect(result.didCorrectString).toBe(true) + expect(result.inputString).toBe(enteredIban) + expect(result.correctedInputString).toBe(ValidIban) + expect(result.validationHint?.contains(InvalidUmlaut)).toBe(true) + expect(result.validationError).toBe(null) + } + + @Test + fun validateIban_InvalidCharacterGetsRemoved() { + + // given + val enteredIban = ValidIban + InvalidSepaCharacter + + // when + val result = underTest.validateIban(enteredIban) + + // then + expect(result.validationSuccessful).toBe(true) + expect(result.didCorrectString).toBe(true) + expect(result.inputString).toBe(enteredIban) + expect(result.correctedInputString).toBe(ValidIban) + expect(result.validationHint?.contains(InvalidSepaCharacter)).toBe(true) + expect(result.validationError).toBe(null) + } + + @Test + fun validateIban_TooLong() { + + // given + val ibanWithMaxLength = IntRange(0, InputValidator.IbanMaxLength - 1).map { "1" }.joinToString("") + val enteredIban = ibanWithMaxLength + "1" + + // when + val result = underTest.validateIban(enteredIban) + + // then + expect(result.validationSuccessful).toBe(false) + expect(result.didCorrectString).toBe(true) + expect(result.inputString).toBe(enteredIban) + expect(result.correctedInputString).toBe(ibanWithMaxLength) + expect(result.validationHint).notToBeNull() + expect(result.validationError).toBe(null) + } + + + @Test + fun validateIbanWhileTyping_EmptyStringEntered() { + + // given + val enteredIban = "" + + // when + val result = underTest.validateIbanWhileTyping(enteredIban) + + // then + expect(result.validationSuccessful).toBe(true) // while user is typing an empty string is ok + expect(result.didCorrectString).toBe(false) + expect(result.inputString).toBe(enteredIban) + expect(result.correctedInputString).toBe(enteredIban) + expect(result.validationHint).toBe(null) + expect(result.validationError).toBe(null) + } + + @Test + fun validateIbanWhileTyping_ValidIbanEntered() { + + // given + val enteredIban = ValidIban + + // when + val result = underTest.validateIbanWhileTyping(enteredIban) + + // then + expect(result.validationSuccessful).toBe(true) + expect(result.didCorrectString).toBe(false) + expect(result.inputString).toBe(enteredIban) + expect(result.correctedInputString).toBe(enteredIban) + expect(result.validationHint).toBe(null) + expect(result.validationError).toBe(null) + } + + @Test + fun validateIbanWhileTyping_IbanTooShort() { + + // given + val enteredIban = "DE11" + + // when + val result = underTest.validateIbanWhileTyping(enteredIban) + + // then + expect(result.validationSuccessful).toBe(true) // while user is typing an incomplete IBAN is ok + expect(result.didCorrectString).toBe(false) + expect(result.inputString).toBe(enteredIban) + expect(result.correctedInputString).toBe(enteredIban) + expect(result.validationHint).toBe(null) + expect(result.validationError).toBe(null) + } + + @Test + fun validateIbanWhileTyping_UmlautGetsRemoved() { + + // given + val enteredIban = ValidIban + InvalidUmlaut + + // when + val result = underTest.validateIbanWhileTyping(enteredIban) + + // then + expect(result.validationSuccessful).toBe(true) + expect(result.didCorrectString).toBe(true) + expect(result.inputString).toBe(enteredIban) + expect(result.correctedInputString).toBe(ValidIban) + expect(result.validationHint?.contains(InvalidUmlaut)).toBe(true) + expect(result.validationError).toBe(null) + } + + @Test + fun validateIbanWhileTyping_InvalidCharacterGetsRemoved() { + + // given + val enteredIban = ValidIban + InvalidSepaCharacter + + // when + val result = underTest.validateIbanWhileTyping(enteredIban) + + // then + expect(result.validationSuccessful).toBe(true) + expect(result.didCorrectString).toBe(true) + expect(result.inputString).toBe(enteredIban) + expect(result.correctedInputString).toBe(ValidIban) + expect(result.validationHint?.contains(InvalidSepaCharacter)).toBe(true) + expect(result.validationError).toBe(null) + } + + @Test + fun validateIbanWhileTyping_TooLong() { + + // given + val ibanWithMaxLength = IntRange(0, InputValidator.IbanMaxLength - 1).map { "1" }.joinToString("") + val enteredIban = ibanWithMaxLength + "1" + + // when + val result = underTest.validateIbanWhileTyping(enteredIban) + + // then + expect(result.validationSuccessful).toBe(false) + expect(result.didCorrectString).toBe(true) + expect(result.inputString).toBe(enteredIban) + expect(result.correctedInputString).toBe(ibanWithMaxLength) + expect(result.validationHint).notToBeNull() + expect(result.validationError).toBe(null) + } + + + @Test + fun validateBic_EmptyStringEntered() { + + // given + val enteredBic = "" + + // when + val result = underTest.validateBic(enteredBic) + + // then + expect(result.validationSuccessful).toBe(false) + expect(result.didCorrectString).toBe(false) + expect(result.inputString).toBe(enteredBic) + expect(result.correctedInputString).toBe(enteredBic) + expect(result.validationHint).toBe(null) + expect(result.validationError).notToBeNull() + } + + @Test + fun validateBic_ValidBicEntered() { + + // given + val enteredBic = ValidBic + + // when + val result = underTest.validateBic(enteredBic) + + // then + expect(result.validationSuccessful).toBe(true) + expect(result.didCorrectString).toBe(false) + expect(result.inputString).toBe(enteredBic) + expect(result.correctedInputString).toBe(enteredBic) + expect(result.validationHint).toBe(null) + expect(result.validationError).toBe(null) + } + + @Test + fun validateBic_BicTooShort() { + + // given + val enteredBic = "ABCD" + + // when + val result = underTest.validateBic(enteredBic) + + // then + expect(result.validationSuccessful).toBe(false) + expect(result.didCorrectString).toBe(false) + expect(result.inputString).toBe(enteredBic) + expect(result.correctedInputString).toBe(enteredBic) + expect(result.validationHint).toBe(null) + expect(result.validationError).notToBeNull() + } + + @Test + fun validateBic_UmlautGetsRemoved() { + + // given + val bicWithoutLastPlace = ValidBic.substring(0, ValidBic.length - 1) + val enteredBic = bicWithoutLastPlace + InvalidUmlaut + + // when + val result = underTest.validateBic(enteredBic) + + // then + expect(result.validationSuccessful).toBe(false) + expect(result.didCorrectString).toBe(true) + expect(result.inputString).toBe(enteredBic) + expect(result.correctedInputString).toBe(bicWithoutLastPlace) + expect(result.validationHint?.contains(InvalidUmlaut)).toBe(true) + expect(result.validationError).toBe(null) + } + + @Test + fun validateBic_InvalidCharacterGetsRemoved() { + + // given + val bicWithoutLastPlace = ValidBic.substring(0, ValidBic.length - 1) + val enteredBic = bicWithoutLastPlace + InvalidSepaCharacter + + // when + val result = underTest.validateBic(enteredBic) + + // then + expect(result.validationSuccessful).toBe(false) + expect(result.didCorrectString).toBe(true) + expect(result.inputString).toBe(enteredBic) + expect(result.correctedInputString).toBe(bicWithoutLastPlace) + expect(result.validationHint?.contains(InvalidSepaCharacter)).toBe(true) + expect(result.validationError).toBe(null) + } + + @Test + fun validateBic_TooLong() { + + // given + val bicWithMaxLength = IntRange(0, InputValidator.BicMaxLength - 1).map { "A" }.joinToString("") + val enteredBic = bicWithMaxLength + "A" + + // when + val result = underTest.validateBic(enteredBic) + + // then + expect(result.validationSuccessful).toBe(true) + expect(result.didCorrectString).toBe(true) + expect(result.inputString).toBe(enteredBic) + expect(result.correctedInputString).toBe(bicWithMaxLength) + expect(result.validationHint).notToBeNull() + expect(result.validationError).toBe(null) + } + + + @Test + fun validateUsage_EmptyStringEntered() { + + // given + val enteredUsage = "" + + // when + val result = underTest.validateUsage(enteredUsage) + + // then + expect(result.validationSuccessful).toBe(true) + expect(result.didCorrectString).toBe(false) + expect(result.inputString).toBe(enteredUsage) + expect(result.correctedInputString).toBe(enteredUsage) + expect(result.validationHint).toBe(null) + expect(result.validationError).toBe(null) + } + + @Test + fun validateUsage_ValidUsageEntered() { + + // given + val enteredUsage = ValidUsage + + // when + val result = underTest.validateUsage(enteredUsage) + + // then + expect(result.validationSuccessful).toBe(true) + expect(result.didCorrectString).toBe(false) + expect(result.inputString).toBe(enteredUsage) + expect(result.correctedInputString).toBe(enteredUsage) + expect(result.validationHint).toBe(null) + expect(result.validationError).toBe(null) + } + + @Test + fun validateUsage_UmlautGetsConverted() { + + // given + val enteredUsage = ValidUsage + InvalidUmlaut + + // when + val result = underTest.validateUsage(enteredUsage) + + // then + expect(result.validationSuccessful).toBe(true) + expect(result.didCorrectString).toBe(true) + expect(result.inputString).toBe(enteredUsage) + expect(result.correctedInputString).toBe(ValidUsage + ConvertedInvalidUmlaut) + expect(result.validationHint?.contains(InvalidUmlaut)).toBe(true) + expect(result.validationError).toBe(null) + } + + @Test + fun validateUsage_InvalidCharacterGetsRemoved() { + + // given + val enteredUsage = ValidUsage + InvalidSepaCharacter + + // when + val result = underTest.validateUsage(enteredUsage) + + // then + expect(result.validationSuccessful).toBe(true) + expect(result.didCorrectString).toBe(true) + expect(result.inputString).toBe(enteredUsage) + expect(result.correctedInputString).toBe(ValidUsage) + expect(result.validationHint?.contains(InvalidSepaCharacter)).toBe(true) + expect(result.validationError).toBe(null) + } + + // TODO: does not work yet + @Test + fun validateUsage_AmpersandGetsRemoved() { + + // given + val invalidSepaCharacter = "&" + val enteredUsage = ValidUsage + invalidSepaCharacter + + // when + val result = underTest.validateUsage(enteredUsage) + + // then + expect(result.validationSuccessful).toBe(true) + expect(result.didCorrectString).toBe(true) + expect(result.inputString).toBe(enteredUsage) + expect(result.correctedInputString).toBe(ValidUsage) + expect(result.validationHint?.contains(invalidSepaCharacter)).toBe(true) + expect(result.validationError).toBe(null) + } + + // TODO: does not work yet + @Test + fun validateUsage_EnteringACharacterAfterConvertingAXmlEntityDoesNotFail() { + + // given + val convertedXmlEntity = "&" + val validSepaCharacter = "h" + val enteredUsage = ValidUsage + convertedXmlEntity + validSepaCharacter + + // when + val result = underTest.validateUsage(enteredUsage) + + // then + expect(result.validationSuccessful).toBe(true) + expect(result.didCorrectString).toBe(true) + expect(result.inputString).toBe(enteredUsage) + expect(result.correctedInputString).toBe(ValidUsage + convertedXmlEntity + validSepaCharacter) + expect(result.validationHint).toBe(null) + expect(result.validationError).toBe(null) + } + + @Test + fun validateUsage_TooLong() { + + // given + val usageWithMaxLength = IntRange(0, InputValidator.UsageMaxLength - 1).map { "a" }.joinToString("") + val enteredUsage = usageWithMaxLength + "a" + + // when + val result = underTest.validateUsage(enteredUsage) + + // then + expect(result.validationSuccessful).toBe(true) + expect(result.didCorrectString).toBe(true) + expect(result.inputString).toBe(enteredUsage) + expect(result.correctedInputString).toBe(usageWithMaxLength) + expect(result.validationHint).toBe(null) + expect(result.validationError).notToBeNull() + } + + + @Test + fun validateAmount_EmptyStringEntered() { + + // given + val enteredAmount = "" + + // when + val result = underTest.validateAmount(enteredAmount) + + // then + expect(result.validationSuccessful).toBe(false) + expect(result.didCorrectString).toBe(false) + expect(result.inputString).toBe(enteredAmount) + expect(result.correctedInputString).toBe(enteredAmount) + expect(result.validationHint).toBe(null) + expect(result.validationError).notToBeNull() + } + + @Test + fun validateAmount_ValidAmountEntered() { + + // given + val enteredAmount = "84,25" + + // when + val result = underTest.validateAmount(enteredAmount) + + // then + expect(result.validationSuccessful).toBe(true) + expect(result.didCorrectString).toBe(false) + expect(result.inputString).toBe(enteredAmount) + expect(result.correctedInputString).toBe(enteredAmount) + expect(result.validationHint).toBe(null) + expect(result.validationError).toBe(null) + } + + @Test + fun validateAmount_ZeroEntered() { + + // given + val enteredAmount = "0" + + // when + val result = underTest.validateAmount(enteredAmount) + + // then + expect(result.validationSuccessful).toBe(false) + expect(result.didCorrectString).toBe(false) + expect(result.inputString).toBe(enteredAmount) + expect(result.correctedInputString).toBe(enteredAmount) + expect(result.validationHint).toBe(null) + expect(result.validationError).notToBeNull() + } + + @Test + fun validateAmount_NegativeAmountEntered() { + + // given + val enteredAmount = "-84,25" + + // when + val result = underTest.validateAmount(enteredAmount) + + // then + expect(result.validationSuccessful).toBe(false) + expect(result.didCorrectString).toBe(false) + expect(result.inputString).toBe(enteredAmount) + expect(result.correctedInputString).toBe(enteredAmount) + expect(result.validationHint).toBe(null) + expect(result.validationError).notToBeNull() + } + +} \ No newline at end of file diff --git a/ui/BankingUiCommon/src/test/kotlin/net/dankito/banking/util/InputValidatorTest.kt b/ui/BankingUiCommon/src/test/kotlin/net/dankito/banking/util/InputValidatorTest.kt deleted file mode 100644 index 92db12d5..00000000 --- a/ui/BankingUiCommon/src/test/kotlin/net/dankito/banking/util/InputValidatorTest.kt +++ /dev/null @@ -1,38 +0,0 @@ -package net.dankito.banking.util - -import org.assertj.core.api.Assertions.assertThat -import org.junit.Test - - -class InputValidatorTest { - - private val underTest = InputValidator() - - - @Test - fun getInvalidIbanCharacters() { - - // given - val invalidIbanCharacters = "ajvz!@#$%^&*()-_=+[]{}'\"\\|/?.,;:<>" - - // when - val result = underTest.getInvalidIbanCharacters("EN${invalidIbanCharacters}1234") - - // then - assertThat(result).isEqualTo(invalidIbanCharacters as Any) - } - - @Test - fun getInvalidSepaCharacters() { - - // given - val invalidSepaCharacters = "!€@#$%^&*_=[]{}\\|;<>" - - // when - val result = underTest.getInvalidSepaCharacters("abcd${invalidSepaCharacters}1234") - - // then - assertThat(result).isEqualTo(invalidSepaCharacters as Any) - } - -} \ No newline at end of file diff --git a/ui/BankingiOSApp/BankingiOSApp/ui/ValidationLabel.swift b/ui/BankingiOSApp/BankingiOSApp/ui/ValidationLabel.swift new file mode 100644 index 00000000..db184332 --- /dev/null +++ b/ui/BankingiOSApp/BankingiOSApp/ui/ValidationLabel.swift @@ -0,0 +1,57 @@ +import SwiftUI +import BankingUiSwift + + +struct ValidationLabel: View { + + private let validationErrorOrHint: String + + private let isHint: Bool + + + init(_ validationError: String) { + self.init(validationError, false) + } + + init(_ validationResult: ValidationResult) { + self.init(validationResult.validationError ?? validationResult.validationHint ?? "", + validationResult.validationError == nil && validationResult.validationHint != nil) + } + + init(_ validationErrorOrHint: String, _ isHint: Bool) { + self.validationErrorOrHint = validationErrorOrHint + self.isHint = isHint + } + + + var body: some View { + VStack { + Spacer() + .frame(height: 6) + + HStack { + Text(validationErrorOrHint) + .padding(.leading, 16) + + Spacer() + } + + Spacer() + .frame(height: 18) + } + .font(.callout) + .foregroundColor(isHint ? Color.yellow : Color.red) + .systemGroupedBackground() + .listRowInsets(EdgeInsets()) + } + +} + + +struct ValidationLabel_Previews: PreviewProvider { + + static var previews: some View { + ValidationLabel("Invalid characters used") + } + +} diff --git a/ui/BankingiOSApp/BankingiOSApp/ui/views/TransferMoneyDialog.swift b/ui/BankingiOSApp/BankingiOSApp/ui/views/TransferMoneyDialog.swift index b9f43960..7c64356d 100644 --- a/ui/BankingiOSApp/BankingiOSApp/ui/views/TransferMoneyDialog.swift +++ b/ui/BankingiOSApp/BankingiOSApp/ui/views/TransferMoneyDialog.swift @@ -16,35 +16,44 @@ struct TransferMoneyDialog: View { @State private var remitteeName: String = "" @State private var isValidRemitteeNameEntered = false + @State private var remitteeNameValidationResult: ValidationResult? = nil @State private var showRemitteeAutocompleteList = false @State private var remitteeSearchResults = [Remittee]() @State private var remitteeIban: String = "" @State private var isValidRemitteeIbanEntered = false + @State private var remitteeIbanValidationResult: ValidationResult? = nil @State private var remitteeBic: String = "" @State private var isValidRemitteeBicEntered = false + @State private var remitteeBicValidationResult: ValidationResult? = nil @State private var amount = "" @State private var isValidAmountEntered = false + @State private var amountValidationResult: ValidationResult? = nil @State private var usage: String = "" @State private var isValidUsageEntered = true + @State private var usageValidationResult: ValidationResult? = nil @State private var instantPayment = false @State private var transferMoneyResponseMessage: Message? = nil + private let inputValidator = InputValidator() + + @State private var didJustCorrectEnteredValue = false + private var account: BankAccount? { if (self.selectedAccountIndex < self.accountsSupportingTransferringMoney.count) { return self.accountsSupportingTransferringMoney[selectedAccountIndex] } - + return self.accountsSupportingTransferringMoney.first } - + private var supportsInstantPayment: Bool { return self.account?.supportsInstantPaymentMoneyTransfer ?? false } @@ -95,9 +104,12 @@ struct TransferMoneyDialog: View { } Section { - UIKitTextField("Remittee Name", text: $remitteeName, focusOnStart: true, focusNextTextFieldOnReturnKeyPress: true, actionOnReturnKeyPress: handleReturnKeyPress) { newValue in - self.isValidRemitteeNameEntered = self.remitteeName.isNotBlank - } + LabelledUIKitTextField(label: "Remittee Name", text: $remitteeName, focusOnStart: true, focusNextTextFieldOnReturnKeyPress: true, + isFocussedChanged: validateRemitteeNameOnFocusLost, actionOnReturnKeyPress: handleReturnKeyPress, textChanged: validateRemitteeName) + + remitteeNameValidationResult.map { validationError in + ValidationLabel(validationError) + } if self.showRemitteeAutocompleteList { Section { @@ -109,26 +121,26 @@ struct TransferMoneyDialog: View { } } - UIKitTextField("Remittee IBAN", text: $remitteeIban, focusNextTextFieldOnReturnKeyPress: true, actionOnReturnKeyPress: handleReturnKeyPress) { newValue in - self.isValidRemitteeIbanEntered = newValue.count > 14 // TODO: implement real check if IBAN is valid - self.tryToGetBicFromIban(newValue) - } + LabelledUIKitTextField(label: "Remittee IBAN", text: $remitteeIban, focusNextTextFieldOnReturnKeyPress: true, isFocussedChanged: validateRemitteeIbanOnFocusLost, + actionOnReturnKeyPress: handleReturnKeyPress, textChanged: validateRemitteeIban) + + remitteeIbanValidationResult.map { validationError in + ValidationLabel(validationError) + } } Section { - UIKitTextField("Amount", text: $amount, keyboardType: .decimalPad, focusNextTextFieldOnReturnKeyPress: true, actionOnReturnKeyPress: handleReturnKeyPress) { newValue in - // TODO: implement DecimalTextField / NumericTextField - let filtered = newValue.filter { "0123456789,".contains($0) } - if filtered != newValue { - self.amount = filtered - } - - self.isValidAmountEntered = self.amount.isNotBlank - } + LabelledUIKitTextField(label: "Amount", text: $amount, keyboardType: .decimalPad, focusNextTextFieldOnReturnKeyPress: true, actionOnReturnKeyPress: handleReturnKeyPress, textChanged: validateAmount) + + amountValidationResult.map { validationError in + ValidationLabel(validationError) + } - UIKitTextField("Usage", text: $usage, actionOnReturnKeyPress: handleReturnKeyPress) { newValue in - self.isValidUsageEntered = true - } + LabelledUIKitTextField(label: "Usage", text: $usage, actionOnReturnKeyPress: handleReturnKeyPress, textChanged: validateUsage) + + usageValidationResult.map { validationError in + ValidationLabel(validationError) + } } if supportsInstantPayment { @@ -169,9 +181,36 @@ struct TransferMoneyDialog: View { return false } + + + private func validateRemitteeName(enteredRemitteeName: String) { + validateField($remitteeName, $remitteeNameValidationResult, $isValidRemitteeNameEntered) { + inputValidator.validateRemitteeNameWhileTyping(remitteeNameToTest: remitteeName) + } + } + + private func validateRemitteeNameOnFocusLost(_ isFocussed: Bool) { + if isFocussed == false { + validateField($remitteeName, $remitteeNameValidationResult, $isValidRemitteeNameEntered) { + inputValidator.validateRemitteeName(remitteeNameToTest: remitteeName) + } + } + } - func tryToGetBicFromIban(_ enteredIban: String) { + private func validateRemitteeIban(_ enteredIban: String) { + validateField($remitteeIban, $remitteeIbanValidationResult, $isValidRemitteeIbanEntered) { inputValidator.validateIbanWhileTyping(ibanToTest: enteredIban) } + + tryToGetBicFromIban(enteredIban) + } + + private func validateRemitteeIbanOnFocusLost(_ isFocussed: Bool) { + if isFocussed == false { + validateField($remitteeIban, $remitteeIbanValidationResult, $isValidRemitteeIbanEntered) { inputValidator.validateIban(ibanToTest: remitteeIban) } + } + } + + private func tryToGetBicFromIban(_ enteredIban: String) { let foundBank = presenter.findUniqueBankForIban(iban: enteredIban) if let foundBank = foundBank { @@ -186,7 +225,50 @@ struct TransferMoneyDialog: View { } - func isRequiredDataEntered() -> Bool { + private func validateAmount(_ enteredAmount: String) { + // TODO: implement DecimalTextField / NumericTextField + let filtered = enteredAmount.filter { "0123456789,".contains($0) } + if filtered != enteredAmount { + self.amount = filtered + + return // don't validate field after non decimal character has been entered + } + + if amount.isNotBlank { + validateField($amount, $amountValidationResult, $isValidAmountEntered) { inputValidator.validateAmount(enteredAmountString: enteredAmount) } + } + else { + isValidAmountEntered = false + amountValidationResult = nil + } + } + + + private func validateUsage(enteredUsage: String) { + validateField($usage, $usageValidationResult, $isValidUsageEntered) { inputValidator.validateUsage(usageToTest: enteredUsage) } + } + + private func validateField(_ newValue: Binding, _ validationResult: Binding, _ isValidValueEntered: Binding, _ validateValue: () -> ValidationResult) { + if (didJustCorrectEnteredValue == false) { + let fieldValidationResult = validateValue() + + isValidValueEntered.wrappedValue = fieldValidationResult.validationSuccessfulOrCouldCorrectString + + validationResult.wrappedValue = fieldValidationResult.didCorrectString || fieldValidationResult.validationSuccessful == false ? fieldValidationResult : nil + + if (fieldValidationResult.didCorrectString) { + didJustCorrectEnteredValue = true + + newValue.wrappedValue = fieldValidationResult.correctedInputString + } + } + else { + didJustCorrectEnteredValue = false + } + } + + + private func isRequiredDataEntered() -> Bool { return account != nil && isValidRemitteeNameEntered && isValidRemitteeIbanEntered @@ -195,15 +277,17 @@ struct TransferMoneyDialog: View { && isValidUsageEntered } - func transferMoney() { - let data = TransferMoneyData(creditorName: remitteeName, creditorIban: remitteeIban, creditorBic: remitteeBic, amount: CommonBigDecimal(decimal: amount.replacingOccurrences(of: ",", with: ".")), usage: usage, instantPayment: instantPayment) - - presenter.transferMoneyAsync(bankAccount: account!, data: data) { response in - self.handleTransferMoneyResponse(data, response) + private func transferMoney() { + if let amount = inputValidator.convertAmountString(enteredAmountString: self.amount) { + let data = TransferMoneyData(creditorName: remitteeName, creditorIban: remitteeIban, creditorBic: remitteeBic, amount: amount, usage: usage, instantPayment: instantPayment) + + presenter.transferMoneyAsync(bankAccount: account!, data: data) { response in + self.handleTransferMoneyResponse(data, response) + } } } - func handleTransferMoneyResponse(_ data: TransferMoneyData, _ response: BankingClientResponse) { + private func handleTransferMoneyResponse(_ data: TransferMoneyData, _ response: BankingClientResponse) { if (response.isSuccessful) { self.transferMoneyResponseMessage = Message(message: Text("Successfully transferred \(data.amount) \("€") to \(data.creditorName)"), primaryButton: .ok { self.presentation.wrappedValue.dismiss()